diff --git a/package.json b/package.json
index 29f6978a..a28116b4 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"type-check": "bun run --filter '*' type-check",
"type-check:web": "bun run --cwd packages/web type-check",
"type-check:ui": "bun run --cwd packages/ui type-check",
+ "settings-registry:generate": "bun run --cwd packages/ui src/lib/settings/registry-snapshot.ts",
"type-check:electron": "bun run --cwd packages/electron type-check",
"type-check:mobile": "bun run --cwd packages/mobile type-check",
"lint": "bun run --filter '*' lint",
diff --git a/packages/docs/content/docs/de/project-actions.mdx b/packages/docs/content/docs/de/project-actions.mdx
index a0ef9b78..e274f0a6 100644
--- a/packages/docs/content/docs/de/project-actions.mdx
+++ b/packages/docs/content/docs/de/project-actions.mdx
@@ -25,4 +25,6 @@ Aktiviere **auto-open URL** für eine Aktion, die einen Server startet. OpenCham
## Verwandt
+- [Repository-Konfiguration](/repository-config/) — Aktionen und Setup-Befehle im Repository für das ganze Team ablegen
+
- [Vorschau & Entwicklungsserver](/preview/) — einen laufenden Entwicklungsserver in OpenChamber öffnen
diff --git a/packages/docs/content/docs/de/repository-config.mdx b/packages/docs/content/docs/de/repository-config.mdx
new file mode 100644
index 00000000..58dea487
--- /dev/null
+++ b/packages/docs/content/docs/de/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: Repository-Konfiguration
+description: Projektaktionen, Worktree-Setup-Befehle, Starter und Pläne im Repository ablegen, damit alle sie bekommen, die es pullen.
+---
+
+# Repository-Konfiguration
+
+Projektaktionen, Worktree-Setup-Befehle und Entwurfs-Starter liegen standardmäßig in deinen eigenen OpenChamber-Einstellungen. Niemand sonst sieht sie. Wenn jemand im Team, der das Repository klont, dieselbe Dev-Server-Aktion und dasselbe `bun install` in jedem neuen Worktree bekommen soll, verschiebe diese Einträge ins Repository.
+
+OpenChamber legt sie in `.openchamber/project.json` im Wurzelverzeichnis des Repositorys ab. Die Datei entsteht erst, wenn du den ersten Eintrag dorthin verschiebst, und verschwindet wieder, wenn du den letzten herausnimmst. Committe sie wie jede andere Datei.
+
+## Was wohin gehört
+
+| Bleibt in deinen Einstellungen | Kann ins Repository |
+|---|---|
+| Notizen und Todos | Projektaktionen |
+| Geplante Aufgaben | Worktree-Setup-Befehle |
+| Welche Repository-Aktion du für dich ausgeblendet hast | Entwurfs-Starter (angeheftete Befehle und Skills) |
+| Deine Vertrauensantwort für Repository-Befehle | Pläne |
+
+Notizen, Todos und geplante Aufgaben gehören dir. Sie landen nie im Repository.
+
+## Einen Eintrag verschieben
+
+Öffne **Settings → Projects** und wähle das Projekt. Jede Aktion und jeder Setup-Befehl hat einen Button **Move to repository**, und jeder Eintrag aus dem Repository hat **Move to my settings**. Starter auf dem Bildschirm für neue Sitzungen zeigen dasselbe Paar beim Überfahren. Pläne haben es in jeder Zeile des Tabs „Pläne“.
+
+Verschieben heißt verschieben. Der Eintrag verlässt den einen Ort und landet am anderen, nichts wird verdoppelt.
+
+Einträge aus dem Repository tragen das Abzeichen **In repo**. Repository-Aktionen lassen sich mit **Hide for me** aus deinem Menü ausblenden. Das ändert nur dein Menü, nicht die Datei.
+
+## Die Datei
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+Nur `version` ist Pflicht. Alle anderen Schlüssel sind optional, und OpenChamber schreibt nur die, die etwas enthalten.
+
+`setupWorktree` ist die Liste der Shell-Befehle, die OpenChamber direkt nach dem Anlegen eines neuen Worktrees darin ausführt, der Reihe nach. Verwende `$ROOT_PROJECT_PATH` für den Pfad des Haupt-Checkouts. Mit `setupWorktreeWait: true` wartet OpenChamber auf diese Befehle, bevor es eine Sitzung im Worktree startet.
+
+`projectActions` ist die Liste der Aktionen im Kopfzeilenmenü. `id`, `name` und `command` sind Pflicht. `icon` ist optional und fällt auf ein Play-Symbol zurück; OpenChamber kennt die Namen `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` und `file`. `autoOpenUrl: true` öffnet die Adresse, die der Befehl ausgibt, siehe [Vorschau und Entwicklungsserver](/preview/). `platforms` beschränkt die Aktion auf `macos`, `linux` oder `windows`. `runIn: "parent"` führt die Aktion im Haupt-Checkout statt im aktuellen Worktree aus.
+
+`draftStarters` heftet Befehle und Skills an den Bildschirm für neue Sitzungen. Jeder Eintrag hat die Form `{ "type": "command" | "skill", "name": "..." }`, und der Befehl oder Skill selbst muss in der OpenCode-Konfiguration des Repositorys existieren.
+
+`plansDir` ist der Ort der Repository-Pläne. Lass ihn weg, um `.openchamber/plans` zu verwenden. Siehe unten.
+
+Du kannst diese Datei von Hand schreiben. Ein Schlüssel mit falscher Form macht die ganze Datei ungültig, und die Seite Projects sagt dir warum, statt ihn stillschweigend zu ignorieren.
+
+## Wie sich Repository- und eigene Einträge verbinden
+
+Zuerst laufen die Setup-Befehle aus dem Repository, dann deine eigenen. Hake **Use only my setup commands** im Abschnitt Worktree an, um die Befehle des Repositorys ganz zu überspringen.
+
+Aktionen werden nach `id` zusammengeführt. Eine Aktion in deinen Einstellungen mit derselben id wie eine Repository-Aktion ersetzt diese. Starter werden nach Name zusammengeführt.
+
+Das Warte-Flag kommt aus deinen Einstellungen, wenn du es gesetzt hast, sonst aus dem Repository.
+
+## Vertrauen
+
+Setup-Befehle und Aktionen aus dem Repository laufen auf deinem Rechner, und ein `git pull` kann sie ändern. Deshalb zeigt OpenChamber beim ersten Mal, wenn einer davon ausgeführt werden soll, die genauen Befehle und fragt. **Trust and run** merkt sich deine Antwort auf dieser Instanz. **Not this time** führt nur deine eigenen Befehle aus.
+
+Die Antwort ist an die Befehle selbst gebunden. Wenn ein Pull einen Repository-Befehl ändert, kommt die Frage für den neuen Text zurück. Mit **reset trust** im Abschnitt Worktree der Projekteinstellungen vergisst OpenChamber die Antwort.
+
+Einen eigenen Befehl ins Repository zu verschieben gilt als Vertrauen, denn du hast ihn gerade gesehen.
+
+## Pläne im Repository
+
+Pläne aus dem Tab „Pläne“ können ebenfalls im Repository liegen, als Markdown-Dateien. Der Standardordner ist `.openchamber/plans`. Setze **Plans folder** in den Projekteinstellungen, um einen anderen Ordner im Repository zu verwenden, etwa `docs/plans`, wenn das Team seine Pläne schon dort hat. Ein eigener Ordner ersetzt den Standard vollständig: OpenChamber liest und schreibt nur diesen Ordner, verschiebe vorhandene Dateien beim Wechsel also selbst.
+
+Jede `.md`-Datei in diesem Ordner erscheint im Tab „Pläne“, auch Dateien aus anderen Werkzeugen. Beim Bearbeiten in OpenChamber wird die Datei so gespeichert, wie du sie getippt hast. Ein Plan, den du ins Repository verschiebst, behält seine Identität, sodass Sitzungen, die ihn angehängt hatten, ihn weiterhin finden.
+
+## Verwandt
+
+- [Projektaktionen](/project-actions/)
+- [Worktrees](/worktrees/)
+- [Projektnotizen, Todos und Pläne](/notes-todos-plans/)
diff --git a/packages/docs/content/docs/environment.mdx b/packages/docs/content/docs/environment.mdx
index 1a6ca40e..15e761aa 100644
--- a/packages/docs/content/docs/environment.mdx
+++ b/packages/docs/content/docs/environment.mdx
@@ -25,6 +25,8 @@ Starts OpenChamber in headless mode when set to `true` or `1`. API routes stay a
Overrides the OpenChamber data directory. The default is `~/.config/openchamber`.
+Everything OpenChamber stores lives under this directory: settings, auth, project configs, themes, plans, and speech models. An instance that used a custom directory before version 1.23 gets its `projects`, `themes`, and `speech-models` folders copied from `~/.config/openchamber` on the first start; the originals stay in place and nothing is merged.
+
### `OPENCHAMBER_CHATS_DIR`
Moves the managed chat directories that OpenChamber creates for chats without a project. The default is `~/.config/openchamber/chats`. Set it to a directory the OpenCode server can read when OpenChamber and OpenCode run as different users. Existing chats are not moved.
diff --git a/packages/docs/content/docs/es/project-actions.mdx b/packages/docs/content/docs/es/project-actions.mdx
index a7036656..644c651e 100644
--- a/packages/docs/content/docs/es/project-actions.mdx
+++ b/packages/docs/content/docs/es/project-actions.mdx
@@ -25,4 +25,6 @@ Activa **auto-open URL** para una acción que inicia un servidor. OpenChamber ob
## Relacionado
+- [Configuración en el repositorio](/repository-config/) — guarda acciones y comandos de configuración en el repositorio para todo el equipo
+
- [Vista previa y servidores de desarrollo](/es/preview/) — abre un servidor de desarrollo en marcha dentro de OpenChamber
diff --git a/packages/docs/content/docs/es/repository-config.mdx b/packages/docs/content/docs/es/repository-config.mdx
new file mode 100644
index 00000000..163168b1
--- /dev/null
+++ b/packages/docs/content/docs/es/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: Configuración en el repositorio
+description: Guarda acciones del proyecto, comandos de configuración de worktree, arranques y planes en el repositorio para que los tenga todo el que lo clone.
+---
+
+# Configuración en el repositorio
+
+Las acciones del proyecto, los comandos de configuración de worktree y los arranques de borrador viven por defecto en tus propios ajustes de OpenChamber. Nadie más los ve. Si quieres que quien clone el repositorio tenga la misma acción de servidor de desarrollo y el mismo `bun install` en cada worktree nuevo, mueve esos elementos al repositorio.
+
+OpenChamber los guarda en `.openchamber/project.json` en la raíz del repositorio. El archivo aparece solo cuando mueves allí el primer elemento y desaparece cuando sacas el último. Haz commit como con cualquier otro archivo.
+
+## Qué va a cada sitio
+
+| Se queda en tus ajustes | Puede ir al repositorio |
+|---|---|
+| Notas y tareas | Acciones del proyecto |
+| Tareas programadas | Comandos de configuración de worktree |
+| Qué acción del repositorio has ocultado para ti | Arranques de borrador (comandos y skills fijados) |
+| Tu respuesta de confianza para los comandos del repositorio | Planes |
+
+Las notas, las tareas y las tareas programadas son tuyas. Nunca acaban en el repositorio.
+
+## Mover un elemento
+
+Abre **Settings → Projects** y elige el proyecto. Cada acción y cada comando de configuración tiene un botón **Move to repository**, y cada elemento que viene del repositorio tiene **Move to my settings**. Los arranques de la pantalla de nueva sesión muestran el mismo par al pasar el cursor. Los planes lo tienen en cada fila de la pestaña Planes.
+
+Mover es exactamente eso. El elemento sale de un sitio y llega al otro, no se duplica nada.
+
+Los elementos del repositorio muestran la insignia **In repo**. Las acciones del repositorio también se pueden ocultar de tu menú con **Hide for me**. Eso solo cambia tu menú, no el archivo.
+
+## El archivo
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+Solo `version` es obligatorio. El resto de claves son opcionales, y OpenChamber escribe solo las que contienen algo.
+
+`setupWorktree` es la lista de comandos de shell que OpenChamber ejecuta dentro de un worktree nuevo justo después de crearlo, en orden. Usa `$ROOT_PROJECT_PATH` para la ruta del checkout principal. `setupWorktreeWait: true` hace que OpenChamber espere a estos comandos antes de iniciar una sesión en el worktree.
+
+`projectActions` es la lista de acciones del menú de la cabecera. `id`, `name` y `command` son obligatorios. `icon` es opcional y por defecto es un icono de play; los nombres que OpenChamber conoce son `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` y `file`. `autoOpenUrl: true` abre la dirección que imprime el comando, consulta [Vista previa y servidores de desarrollo](/preview/). `platforms` limita la acción a `macos`, `linux` o `windows`. `runIn: "parent"` ejecuta la acción en el checkout principal en lugar del worktree actual.
+
+`draftStarters` fija comandos y skills en la pantalla de nueva sesión. Cada entrada es `{ "type": "command" | "skill", "name": "..." }`, y el comando o skill tiene que existir en la configuración de OpenCode del repositorio.
+
+`plansDir` es donde viven los planes del repositorio. Omítelo para usar `.openchamber/plans`. Más abajo se explica.
+
+Puedes escribir este archivo a mano. Una clave con forma incorrecta invalida el archivo entero, y la página Projects te dice por qué en lugar de ignorarla en silencio.
+
+## Cómo se combinan los elementos del repositorio y los tuyos
+
+Primero se ejecutan los comandos de configuración del repositorio, después los tuyos. Marca **Use only my setup commands** en la sección Worktree para omitir por completo los del repositorio.
+
+Las acciones se combinan por `id`. Una acción de tus ajustes con el mismo id que una del repositorio la reemplaza. Los arranques se combinan por nombre.
+
+La marca de espera sale de tus ajustes cuando la has fijado, y si no, del repositorio.
+
+## Confianza
+
+Los comandos de configuración y las acciones del repositorio se ejecutan en tu máquina, y un `git pull` puede cambiarlos. Por eso, la primera vez que uno de ellos está a punto de ejecutarse, OpenChamber muestra los comandos exactos y pregunta. **Trust and run** recuerda tu respuesta en esta instancia. **Not this time** ejecuta solo tus propios comandos.
+
+La respuesta va ligada a los comandos en sí. Cuando un pull cambia un comando del repositorio, la pregunta vuelve para el texto nuevo. Puedes olvidar la respuesta con **reset trust** en la sección Worktree de los ajustes del proyecto.
+
+Mover un comando tuyo al repositorio cuenta como confiar en él, porque acabas de verlo.
+
+## Planes en el repositorio
+
+Los planes de la pestaña Planes también pueden vivir en el repositorio como archivos Markdown. La carpeta por defecto es `.openchamber/plans`. Define **Plans folder** en los ajustes del proyecto para usar otra carpeta dentro del repositorio, por ejemplo `docs/plans` si tu equipo ya guarda ahí los planes. Una carpeta propia reemplaza por completo la predeterminada: OpenChamber lee y escribe solo en esa carpeta, así que mueve tú mismo los archivos existentes cuando la cambies.
+
+Cada archivo `.md` de esa carpeta aparece en la pestaña Planes, incluidos los escritos por otras herramientas. Editar uno en OpenChamber guarda el archivo tal como lo escribiste. Un plan que mueves al repositorio conserva su identidad, así que las sesiones que lo tenían adjunto lo siguen encontrando.
+
+## Relacionado
+
+- [Acciones del proyecto](/project-actions/)
+- [Worktrees](/worktrees/)
+- [Notas, tareas y planes del proyecto](/notes-todos-plans/)
diff --git a/packages/docs/content/docs/fr/project-actions.mdx b/packages/docs/content/docs/fr/project-actions.mdx
index f61a903f..ef1b0301 100644
--- a/packages/docs/content/docs/fr/project-actions.mdx
+++ b/packages/docs/content/docs/fr/project-actions.mdx
@@ -25,4 +25,6 @@ Activez **auto-open URL** pour une action qui démarre un serveur. OpenChamber s
## Pages liées
+- [Configuration du dépôt](/repository-config/) — garder les actions et commandes de configuration dans le dépôt pour toute l'équipe
+
- [Aperçu et serveurs de dev](/preview/) — ouvrir un serveur de dev en cours d’exécution dans OpenChamber
diff --git a/packages/docs/content/docs/fr/repository-config.mdx b/packages/docs/content/docs/fr/repository-config.mdx
new file mode 100644
index 00000000..d2701c7a
--- /dev/null
+++ b/packages/docs/content/docs/fr/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: Configuration du dépôt
+description: Gardez les actions de projet, les commandes de configuration de worktree, les amorces et les plans dans le dépôt pour que tous ceux qui le récupèrent les aient.
+---
+
+# Configuration du dépôt
+
+Les actions de projet, les commandes de configuration de worktree et les amorces de brouillon vivent par défaut dans vos propres réglages OpenChamber. Personne d'autre ne les voit. Si vous voulez qu'un collègue qui clone le dépôt ait la même action de serveur de dev et le même `bun install` dans chaque nouveau worktree, déplacez ces éléments dans le dépôt.
+
+OpenChamber les enregistre dans `.openchamber/project.json` à la racine du dépôt. Le fichier n'apparaît que lorsque vous y déplacez le premier élément, et il disparaît quand vous retirez le dernier. Committez-le comme n'importe quel autre fichier.
+
+## Ce qui va où
+
+| Reste dans vos réglages | Peut aller dans le dépôt |
+|---|---|
+| Notes et tâches | Actions de projet |
+| Tâches planifiées | Commandes de configuration de worktree |
+| Les actions du dépôt que vous avez masquées pour vous | Amorces de brouillon (commandes et skills épinglés) |
+| Votre réponse de confiance pour les commandes du dépôt | Plans |
+
+Les notes, les tâches et les tâches planifiées sont à vous. Elles n'arrivent jamais dans le dépôt.
+
+## Déplacer un élément
+
+Ouvrez **Settings → Projects** et choisissez le projet. Chaque action et chaque commande de configuration a un bouton **Move to repository**, et chaque élément venu du dépôt a **Move to my settings**. Les amorces de l'écran de nouvelle session montrent la même paire au survol. Les plans l'ont sur chaque ligne de l'onglet Plans.
+
+Déplacer, c'est déplacer. L'élément quitte un endroit et arrive à l'autre, rien n'est dupliqué.
+
+Les éléments du dépôt portent le badge **In repo**. Les actions du dépôt peuvent aussi être masquées de votre menu avec **Hide for me**. Cela ne change que votre menu, pas le fichier.
+
+## Le fichier
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+Seul `version` est obligatoire. Toutes les autres clés sont facultatives, et OpenChamber n'écrit que celles qui contiennent quelque chose.
+
+`setupWorktree` est la liste des commandes shell qu'OpenChamber exécute dans un nouveau worktree juste après sa création, dans l'ordre. Utilisez `$ROOT_PROJECT_PATH` pour le chemin du checkout principal. `setupWorktreeWait: true` fait attendre OpenChamber la fin de ces commandes avant de démarrer une session dans le worktree.
+
+`projectActions` est la liste des actions du menu d'en-tête. `id`, `name` et `command` sont obligatoires. `icon` est facultatif et retombe sur une icône play ; les noms connus d'OpenChamber sont `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` et `file`. `autoOpenUrl: true` ouvre l'adresse affichée par la commande, voir [Aperçu et serveurs de dev](/preview/). `platforms` limite l'action à `macos`, `linux` ou `windows`. `runIn: "parent"` exécute l'action dans le checkout principal plutôt que dans le worktree courant.
+
+`draftStarters` épingle des commandes et des skills sur l'écran de nouvelle session. Chaque entrée s'écrit `{ "type": "command" | "skill", "name": "..." }`, et la commande ou le skill doit exister dans la configuration OpenCode du dépôt.
+
+`plansDir` indique où vivent les plans du dépôt. Omettez-le pour utiliser `.openchamber/plans`. Voir plus bas.
+
+Vous pouvez écrire ce fichier à la main. Une clé de mauvaise forme rend tout le fichier invalide, et la page Projects vous dit pourquoi au lieu de l'ignorer en silence.
+
+## Comment les éléments du dépôt et les vôtres se combinent
+
+Les commandes de configuration du dépôt s'exécutent d'abord, puis les vôtres. Cochez **Use only my setup commands** dans la section Worktree pour ignorer complètement celles du dépôt.
+
+Les actions sont fusionnées par `id`. Une action de vos réglages avec le même id qu'une action du dépôt la remplace. Les amorces sont fusionnées par nom.
+
+L'indicateur d'attente vient de vos réglages quand vous l'avez défini, sinon du dépôt.
+
+## Confiance
+
+Les commandes de configuration et les actions du dépôt s'exécutent sur votre machine, et un `git pull` peut les changer. Donc la première fois que l'une d'elles est sur le point de s'exécuter, OpenChamber affiche les commandes exactes et demande. **Trust and run** mémorise votre réponse sur cette instance. **Not this time** n'exécute que vos propres commandes.
+
+La réponse est liée aux commandes elles-mêmes. Quand un pull modifie une commande du dépôt, la question revient pour le nouveau texte. Vous pouvez oublier la réponse avec **reset trust** dans la section Worktree des réglages du projet.
+
+Déplacer votre propre commande dans le dépôt vaut confiance, puisque vous venez de la voir.
+
+## Plans dans le dépôt
+
+Les plans de l'onglet Plans peuvent aussi vivre dans le dépôt, sous forme de fichiers Markdown. Le dossier par défaut est `.openchamber/plans`. Définissez **Plans folder** dans les réglages du projet pour utiliser un autre dossier du dépôt, par exemple `docs/plans` si votre équipe y garde déjà ses plans. Un dossier personnalisé remplace entièrement le dossier par défaut : OpenChamber ne lit et n'écrit que ce dossier, déplacez donc vous-même les fichiers existants quand vous le changez.
+
+Chaque fichier `.md` de ce dossier apparaît dans l'onglet Plans, y compris les fichiers écrits par d'autres outils. Modifier un plan dans OpenChamber enregistre le fichier tel que vous l'avez tapé. Un plan que vous déplacez dans le dépôt garde son identité, donc les sessions qui l'avaient attaché le retrouvent.
+
+## Pages liées
+
+- [Actions de projet](/project-actions/)
+- [Worktrees](/worktrees/)
+- [Notes, tâches et plans du projet](/notes-todos-plans/)
diff --git a/packages/docs/content/docs/ja/environment.mdx b/packages/docs/content/docs/ja/environment.mdx
index 260246ed..e38e81a4 100644
--- a/packages/docs/content/docs/ja/environment.mdx
+++ b/packages/docs/content/docs/ja/environment.mdx
@@ -25,6 +25,8 @@ OpenChamber Web サーバーのバインドアドレスです。他のマシン
OpenChamber のデータディレクトリを上書きします。デフォルトは `~/.config/openchamber` です。
+OpenChamber が保存するものはすべてこのディレクトリ配下にあります: 設定、認証、プロジェクト設定、テーマ、プラン、音声モデル。バージョン 1.23 より前にカスタムディレクトリを使っていたインスタンスでは、初回起動時に `projects`、`themes`、`speech-models` フォルダーが `~/.config/openchamber` からここへコピーされます。元のフォルダーはそのまま残り、マージはされません。
+
### `OPENCHAMBER_CHATS_DIR`
プロジェクトを持たないチャット用に OpenChamber が作成する管理チャットディレクトリの場所を変更します。デフォルトは `~/.config/openchamber/chats` です。OpenChamber と OpenCode を別のユーザーで実行している場合は、OpenCode サーバーが読み取れるディレクトリを指定してください。既存のチャットは移動されません。
diff --git a/packages/docs/content/docs/ja/project-actions.mdx b/packages/docs/content/docs/ja/project-actions.mdx
index 9d10579f..ed16ddc5 100644
--- a/packages/docs/content/docs/ja/project-actions.mdx
+++ b/packages/docs/content/docs/ja/project-actions.mdx
@@ -25,4 +25,6 @@ description: よく実行するコマンドを保存し、ワンクリックで
## 関連
+- [リポジトリ設定](/repository-config/) — アクションとセットアップコマンドをリポジトリに置いてチーム全体で使う
+
- [プレビューと開発サーバー](/preview/) — 実行中の開発サーバーを OpenChamber 内で開く
diff --git a/packages/docs/content/docs/ja/repository-config.mdx b/packages/docs/content/docs/ja/repository-config.mdx
new file mode 100644
index 00000000..25125573
--- /dev/null
+++ b/packages/docs/content/docs/ja/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: リポジトリ設定
+description: プロジェクトアクション、ワークツリーのセットアップコマンド、スターター、プランをリポジトリに置き、pull した全員が同じものを使えるようにします。
+---
+
+# リポジトリ設定
+
+プロジェクトアクション、ワークツリーのセットアップコマンド、下書きスターターは、デフォルトではあなた自身の OpenChamber 設定に保存されます。他の人には見えません。リポジトリをクローンしたチームメンバーにも同じ開発サーバーのアクションと、新しいワークツリーごとの同じ `bun install` を使ってほしいなら、それらの項目をリポジトリへ移動します。
+
+OpenChamber はそれらをリポジトリ直下の `.openchamber/project.json` に保存します。このファイルは最初の項目を移動したときに初めて作られ、最後の項目を戻すと消えます。ほかのファイルと同じようにコミットしてください。
+
+## 何がどこに入るか
+
+| あなたの設定に残るもの | リポジトリへ移動できるもの |
+|---|---|
+| ノートと Todo | プロジェクトアクション |
+| スケジュールタスク | ワークツリーのセットアップコマンド |
+| リポジトリのアクションのうち自分だけ非表示にしたもの | 下書きスターター(ピン留めしたコマンドとスキル) |
+| リポジトリのコマンドに対する信頼の回答 | プラン |
+
+ノート、Todo、スケジュールタスクはあなたのものです。リポジトリに入ることはありません。
+
+## 項目を移動する
+
+**Settings → Projects** を開き、プロジェクトを選びます。各アクションと各セットアップコマンドには **Move to repository** ボタンがあり、リポジトリ由来の各項目には **Move to my settings** があります。新規セッション画面のスターターはホバーで同じ 2 つを表示します。プランはプランタブの各行にあります。
+
+移動は文字どおり移動です。項目は一方から消えてもう一方に現れ、複製はされません。
+
+リポジトリ由来の項目には **In repo** バッジが付きます。リポジトリのアクションは **Hide for me** で自分のメニューから隠せます。これはあなたのメニューだけを変え、ファイルは変えません。
+
+## ファイル
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+必須なのは `version` だけです。ほかのキーはすべて省略可能で、OpenChamber は中身のあるキーだけを書き込みます。
+
+`setupWorktree` は、新しいワークツリーを作成した直後にその中で OpenChamber が順番に実行するシェルコマンドの一覧です。メインのチェックアウトのパスには `$ROOT_PROJECT_PATH` を使います。`setupWorktreeWait: true` にすると、OpenChamber はこれらのコマンドの完了を待ってからワークツリーでセッションを開始します。
+
+`projectActions` はヘッダーメニューのアクション一覧です。`id`、`name`、`command` は必須です。`icon` は省略可能で、省略時は play アイコンになります。OpenChamber が認識する名前は `play`、`build`、`lint`、`terminal`、`tools`、`bug`、`flask`、`rocket`、`code`、`server`、`branch`、`search`、`settings`、`brain`、`stack`、`robot`、`command`、`file` です。`autoOpenUrl: true` はコマンドが出力したアドレスを開きます([プレビューと開発サーバー](/preview/) を参照)。`platforms` はアクションを `macos`、`linux`、`windows` に限定します。`runIn: "parent"` は現在のワークツリーではなくメインのチェックアウトでアクションを実行します。
+
+`draftStarters` はコマンドとスキルを新規セッション画面にピン留めします。各項目は `{ "type": "command" | "skill", "name": "..." }` の形で、コマンドやスキル自体はリポジトリの OpenCode 設定に存在している必要があります。
+
+`plansDir` はリポジトリのプランを置く場所です。省略すると `.openchamber/plans` が使われます。後述します。
+
+このファイルは手で書いても構いません。形の違うキーがあるとファイル全体が無効になり、Projects ページは黙って無視する代わりに理由を表示します。
+
+## リポジトリの項目と自分の項目の組み合わせ
+
+セットアップコマンドはリポジトリのものが先に実行され、その後にあなたのものが実行されます。Worktree セクションの **Use only my setup commands** にチェックを入れると、リポジトリのコマンドを完全にスキップします。
+
+アクションは `id` でマージされます。リポジトリのアクションと同じ id があなたの設定にあれば、そちらが優先されます。スターターは名前でマージされます。
+
+待機フラグは、あなたが設定していればその値、なければリポジトリの値が使われます。
+
+## 信頼
+
+リポジトリのセットアップコマンドとアクションはあなたのマシンで実行され、`git pull` で内容が変わることがあります。そのため、いずれかが初めて実行されそうになったとき、OpenChamber は正確なコマンドを表示して確認します。**Trust and run** はこのインスタンスで回答を記憶します。**Not this time** はあなた自身のコマンドだけを実行します。
+
+回答はコマンドそのものに結び付いています。pull でリポジトリのコマンドが変わると、新しい内容について再度確認されます。プロジェクト設定の Worktree セクションにある **reset trust** で回答を忘れさせることができます。
+
+自分のコマンドをリポジトリへ移動することは、そのコマンドを信頼したものとみなされます。いま自分で見たばかりだからです。
+
+## リポジトリ内のプラン
+
+プランタブのプランも、Markdown ファイルとしてリポジトリに置けます。デフォルトのフォルダーは `.openchamber/plans` です。チームがすでに `docs/plans` などにプランを置いているなら、プロジェクト設定の **Plans folder** でリポジトリ内の別のフォルダーを指定します。カスタムフォルダーはデフォルトを完全に置き換えます。OpenChamber はそのフォルダーだけを読み書きするので、変更時は既存ファイルを自分で移動してください。
+
+そのフォルダー内のすべての `.md` ファイルがプランタブに表示されます。ほかのツールで書いたファイルも含みます。OpenChamber で編集すると、入力したとおりにファイルが保存されます。リポジトリへ移動したプランは同一性を保つため、そのプランを添付していたセッションからも引き続き見つかります。
+
+## 関連
+
+- [プロジェクトアクション](/project-actions/)
+- [ワークツリー](/worktrees/)
+- [プロジェクトのノート、Todo、プラン](/notes-todos-plans/)
diff --git a/packages/docs/content/docs/ko/project-actions.mdx b/packages/docs/content/docs/ko/project-actions.mdx
index 8eb7e9c5..3b582a1b 100644
--- a/packages/docs/content/docs/ko/project-actions.mdx
+++ b/packages/docs/content/docs/ko/project-actions.mdx
@@ -25,4 +25,6 @@ description: 자주 실행하는 명령을 저장하고 클릭 한 번으로 실
## 관련 항목
+- [저장소 설정](/repository-config/) — 작업과 설정 명령을 저장소에 두어 팀 전체가 사용
+
- [Preview & Dev Servers](/ko/preview/) — 실행 중인 개발 서버를 OpenChamber 안에서 여세요
diff --git a/packages/docs/content/docs/ko/repository-config.mdx b/packages/docs/content/docs/ko/repository-config.mdx
new file mode 100644
index 00000000..92981f55
--- /dev/null
+++ b/packages/docs/content/docs/ko/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: 저장소 설정
+description: 프로젝트 작업, 워크트리 설정 명령, 스타터, 플랜을 저장소에 두어 저장소를 받는 모든 사람이 같은 것을 사용하게 합니다.
+---
+
+# 저장소 설정
+
+프로젝트 작업, 워크트리 설정 명령, 초안 스타터는 기본적으로 자신의 OpenChamber 설정에 저장됩니다. 다른 사람에게는 보이지 않습니다. 저장소를 클론한 팀원도 같은 개발 서버 작업과 새 워크트리마다 같은 `bun install`을 쓰게 하고 싶다면, 해당 항목을 저장소로 옮기세요.
+
+OpenChamber는 이를 저장소 루트의 `.openchamber/project.json`에 저장합니다. 이 파일은 첫 항목을 옮길 때 처음 만들어지고, 마지막 항목을 빼면 사라집니다. 다른 파일과 똑같이 커밋하면 됩니다.
+
+## 무엇이 어디에 있는가
+
+| 내 설정에 남는 것 | 저장소로 옮길 수 있는 것 |
+|---|---|
+| 노트와 할 일 | 프로젝트 작업 |
+| 예약 작업 | 워크트리 설정 명령 |
+| 저장소 작업 중 나만 숨긴 것 | 초안 스타터(고정한 명령과 스킬) |
+| 저장소 명령에 대한 신뢰 응답 | 플랜 |
+
+노트, 할 일, 예약 작업은 내 것입니다. 저장소에 들어가지 않습니다.
+
+## 항목 옮기기
+
+**Settings → Projects**를 열고 프로젝트를 고릅니다. 각 작업과 각 설정 명령에는 **Move to repository** 버튼이 있고, 저장소에서 온 각 항목에는 **Move to my settings**가 있습니다. 새 세션 화면의 스타터는 마우스를 올리면 같은 두 버튼을 보여 줍니다. 플랜은 플랜 탭의 각 행에 있습니다.
+
+옮기기는 말 그대로 옮기기입니다. 항목이 한쪽에서 사라지고 다른 쪽에 나타나며, 복제되지 않습니다.
+
+저장소에서 온 항목에는 **In repo** 배지가 붙습니다. 저장소 작업은 **Hide for me**로 내 메뉴에서 숨길 수 있습니다. 이는 내 메뉴만 바꾸며 파일은 바꾸지 않습니다.
+
+## 파일
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+필수 키는 `version`뿐입니다. 나머지 키는 모두 선택이며, OpenChamber는 내용이 있는 키만 기록합니다.
+
+`setupWorktree`는 새 워크트리를 만든 직후 그 안에서 OpenChamber가 순서대로 실행하는 셸 명령 목록입니다. 메인 체크아웃 경로에는 `$ROOT_PROJECT_PATH`를 사용하세요. `setupWorktreeWait: true`로 두면 OpenChamber는 이 명령들이 끝난 뒤에 워크트리에서 세션을 시작합니다.
+
+`projectActions`는 헤더 메뉴의 작업 목록입니다. `id`, `name`, `command`는 필수입니다. `icon`은 선택이며 없으면 play 아이콘이 쓰입니다. OpenChamber가 아는 이름은 `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command`, `file`입니다. `autoOpenUrl: true`는 명령이 출력한 주소를 엽니다([미리보기 및 개발 서버](/preview/) 참고). `platforms`는 작업을 `macos`, `linux`, `windows`로 제한합니다. `runIn: "parent"`는 현재 워크트리 대신 메인 체크아웃에서 작업을 실행합니다.
+
+`draftStarters`는 명령과 스킬을 새 세션 화면에 고정합니다. 각 항목은 `{ "type": "command" | "skill", "name": "..." }` 형태이며, 해당 명령이나 스킬은 저장소의 OpenCode 설정에 있어야 합니다.
+
+`plansDir`는 저장소 플랜이 있는 곳입니다. 생략하면 `.openchamber/plans`를 사용합니다. 아래를 참고하세요.
+
+이 파일은 직접 써도 됩니다. 형태가 잘못된 키가 있으면 파일 전체가 무효가 되고, Projects 페이지는 조용히 무시하는 대신 이유를 알려 줍니다.
+
+## 저장소 항목과 내 항목이 합쳐지는 방식
+
+설정 명령은 저장소의 것이 먼저, 내 것이 그다음에 실행됩니다. Worktree 섹션의 **Use only my setup commands**에 체크하면 저장소 명령을 완전히 건너뜁니다.
+
+작업은 `id`로 병합됩니다. 저장소 작업과 같은 id가 내 설정에 있으면 내 것이 대신합니다. 스타터는 이름으로 병합됩니다.
+
+대기 플래그는 내가 설정했으면 내 값, 아니면 저장소 값이 쓰입니다.
+
+## 신뢰
+
+저장소의 설정 명령과 작업은 내 컴퓨터에서 실행되며, `git pull`로 내용이 바뀔 수 있습니다. 그래서 그중 하나가 처음 실행되려 할 때 OpenChamber는 정확한 명령을 보여 주고 묻습니다. **Trust and run**은 이 인스턴스에서 응답을 기억합니다. **Not this time**은 내 명령만 실행합니다.
+
+응답은 명령 자체에 묶여 있습니다. pull로 저장소 명령이 바뀌면 새 내용에 대해 다시 묻습니다. 프로젝트 설정의 Worktree 섹션에 있는 **reset trust**로 응답을 잊게 할 수 있습니다.
+
+내 명령을 저장소로 옮기는 것은 그 명령을 신뢰한 것으로 간주됩니다. 방금 직접 봤기 때문입니다.
+
+## 저장소의 플랜
+
+플랜 탭의 플랜도 Markdown 파일로 저장소에 둘 수 있습니다. 기본 폴더는 `.openchamber/plans`입니다. 팀이 이미 `docs/plans` 같은 곳에 플랜을 두고 있다면 프로젝트 설정의 **Plans folder**에서 저장소 안의 다른 폴더를 지정하세요. 사용자 지정 폴더는 기본값을 완전히 대체합니다. OpenChamber는 그 폴더만 읽고 쓰므로, 변경할 때 기존 파일은 직접 옮기세요.
+
+그 폴더의 모든 `.md` 파일이 플랜 탭에 표시되며, 다른 도구로 쓴 파일도 포함됩니다. OpenChamber에서 편집하면 입력한 그대로 파일이 저장됩니다. 저장소로 옮긴 플랜은 정체성을 유지하므로, 그 플랜을 첨부했던 세션에서도 계속 찾을 수 있습니다.
+
+## 관련 항목
+
+- [프로젝트 작업](/project-actions/)
+- [워크트리](/worktrees/)
+- [프로젝트 노트, 할 일, 플랜](/notes-todos-plans/)
diff --git a/packages/docs/content/docs/pl/environment.mdx b/packages/docs/content/docs/pl/environment.mdx
index 92623867..9db9592c 100644
--- a/packages/docs/content/docs/pl/environment.mdx
+++ b/packages/docs/content/docs/pl/environment.mdx
@@ -25,6 +25,8 @@ Uruchamia OpenChamber w trybie headless, gdy ustawione na `true` lub `1`. Trasy
Nadpisuje katalog danych OpenChamber. Domyślnie jest to `~/.config/openchamber`.
+Wszystko, co OpenChamber zapisuje, znajduje się w tym katalogu: ustawienia, dane logowania, konfiguracje projektów, motywy, plany i modele mowy. Instancja, która używała własnego katalogu przed wersją 1.23, przy pierwszym uruchomieniu otrzyma kopie folderów `projects`, `themes` i `speech-models` z `~/.config/openchamber`; oryginały pozostają na miejscu i nic nie jest scalane.
+
### `OPENCHAMBER_CHATS_DIR`
Przenosi katalogi zarządzanych czatów, które OpenChamber tworzy dla czatów bez projektu. Domyślnie jest to `~/.config/openchamber/chats`. Ustaw katalog, który serwer OpenCode może odczytać, gdy OpenChamber i OpenCode działają jako różni użytkownicy. Istniejące czaty nie są przenoszone.
diff --git a/packages/docs/content/docs/pl/project-actions.mdx b/packages/docs/content/docs/pl/project-actions.mdx
index 780efc5b..8e32f056 100644
--- a/packages/docs/content/docs/pl/project-actions.mdx
+++ b/packages/docs/content/docs/pl/project-actions.mdx
@@ -25,4 +25,6 @@ Włącz **auto-open URL** dla akcji, która uruchamia serwer. OpenChamber obserw
## Powiązane
+- [Konfiguracja w repozytorium](/repository-config/) — trzymaj akcje i polecenia konfiguracji w repozytorium dla całego zespołu
+
- [Podgląd i serwery deweloperskie](/pl/preview/) — otwórz działający serwer deweloperski wewnątrz OpenChamber
diff --git a/packages/docs/content/docs/pl/repository-config.mdx b/packages/docs/content/docs/pl/repository-config.mdx
new file mode 100644
index 00000000..dee357cd
--- /dev/null
+++ b/packages/docs/content/docs/pl/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: Konfiguracja w repozytorium
+description: Trzymaj akcje projektu, polecenia konfiguracji worktree, startery i plany w repozytorium, aby dostał je każdy, kto je pobierze.
+---
+
+# Konfiguracja w repozytorium
+
+Akcje projektu, polecenia konfiguracji worktree i startery szkicu domyślnie żyją w Twoich własnych ustawieniach OpenChamber. Nikt inny ich nie widzi. Jeśli chcesz, aby osoba z zespołu, która sklonuje repozytorium, dostała tę samą akcję serwera deweloperskiego i to samo `bun install` w każdym nowym worktree, przenieś te elementy do repozytorium.
+
+OpenChamber zapisuje je w pliku `.openchamber/project.json` w katalogu głównym repozytorium. Plik pojawia się dopiero wtedy, gdy przeniesiesz tam pierwszy element, i znika, gdy zabierzesz ostatni. Commituj go jak każdy inny plik.
+
+## Co gdzie trafia
+
+| Zostaje w Twoich ustawieniach | Można przenieść do repozytorium |
+|---|---|
+| Notatki i todo | Akcje projektu |
+| Zaplanowane zadania | Polecenia konfiguracji worktree |
+| Które akcje z repozytorium ukrywasz u siebie | Startery szkicu (przypięte polecenia i skille) |
+| Twoja odpowiedź o zaufaniu do poleceń z repozytorium | Plany |
+
+Notatki, todo i zaplanowane zadania są Twoje. Nigdy nie trafiają do repozytorium.
+
+## Przenoszenie elementu
+
+Otwórz **Settings → Projects** i wybierz projekt. Każda akcja i każde polecenie konfiguracji ma przycisk **Move to repository**, a każdy element pochodzący z repozytorium ma **Move to my settings**. Startery na ekranie nowej sesji pokazują tę samą parę po najechaniu. Plany mają ją w każdym wierszu karty Plany.
+
+Przeniesienie to po prostu przeniesienie. Element znika z jednego miejsca i pojawia się w drugim, nic nie jest duplikowane.
+
+Elementy z repozytorium mają odznakę **In repo**. Akcje z repozytorium można ukryć ze swojego menu przyciskiem **Hide for me**. To zmienia tylko Twoje menu, nie plik.
+
+## Plik
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+Wymagany jest tylko `version`. Wszystkie pozostałe klucze są opcjonalne, a OpenChamber zapisuje tylko te, które coś zawierają.
+
+`setupWorktree` to lista poleceń powłoki, które OpenChamber uruchamia w nowym worktree zaraz po jego utworzeniu, po kolei. Użyj `$ROOT_PROJECT_PATH` jako ścieżki do głównego checkoutu. `setupWorktreeWait: true` sprawia, że OpenChamber czeka na te polecenia, zanim uruchomi sesję w worktree.
+
+`projectActions` to lista akcji w menu nagłówka. `id`, `name` i `command` są wymagane. `icon` jest opcjonalna i domyślnie jest to ikona play; OpenChamber zna nazwy `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` i `file`. `autoOpenUrl: true` otwiera adres wypisany przez polecenie, zobacz [Podgląd i serwery deweloperskie](/preview/). `platforms` ogranicza akcję do `macos`, `linux` lub `windows`. `runIn: "parent"` uruchamia akcję w głównym checkoucie zamiast w bieżącym worktree.
+
+`draftStarters` przypina polecenia i skille do ekranu nowej sesji. Każdy wpis ma postać `{ "type": "command" | "skill", "name": "..." }`, a samo polecenie lub skill musi istnieć w konfiguracji OpenCode tego repozytorium.
+
+`plansDir` to miejsce planów repozytorium. Pomiń go, aby używać `.openchamber/plans`. Zobacz niżej.
+
+Ten plik można pisać ręcznie. Klucz o złym kształcie unieważnia cały plik, a strona Projects mówi dlaczego, zamiast go po cichu zignorować.
+
+## Jak łączą się elementy z repozytorium i Twoje
+
+Najpierw uruchamiane są polecenia konfiguracji z repozytorium, potem Twoje. Zaznacz **Use only my setup commands** w sekcji Worktree, aby całkowicie pominąć polecenia z repozytorium.
+
+Akcje są łączone po `id`. Akcja w Twoich ustawieniach o tym samym id co akcja z repozytorium zastępuje ją. Startery są łączone po nazwie.
+
+Flaga oczekiwania pochodzi z Twoich ustawień, jeśli ją ustawisz, w przeciwnym razie z repozytorium.
+
+## Zaufanie
+
+Polecenia konfiguracji i akcje z repozytorium uruchamiają się na Twoim komputerze, a `git pull` może je zmienić. Dlatego za pierwszym razem, gdy któreś z nich ma się uruchomić, OpenChamber pokazuje dokładną treść poleceń i pyta. **Trust and run** zapamiętuje odpowiedź w tej instancji. **Not this time** uruchamia tylko Twoje własne polecenia.
+
+Odpowiedź jest związana z samymi poleceniami. Gdy pull zmieni polecenie z repozytorium, pytanie wraca dla nowej treści. Odpowiedź możesz zapomnieć przyciskiem **reset trust** w sekcji Worktree ustawień projektu.
+
+Przeniesienie własnego polecenia do repozytorium liczy się jako zaufanie, bo właśnie je widziałeś.
+
+## Plany w repozytorium
+
+Plany z karty Plany też mogą żyć w repozytorium jako pliki Markdown. Domyślny folder to `.openchamber/plans`. Ustaw **Plans folder** w ustawieniach projektu, aby użyć innego folderu w repozytorium, na przykład `docs/plans`, jeśli zespół już trzyma tam plany. Własny folder całkowicie zastępuje domyślny: OpenChamber czyta i zapisuje tylko w nim, więc przy zmianie przenieś istniejące pliki samodzielnie.
+
+Każdy plik `.md` w tym folderze pojawia się w karcie Plany, także pliki zapisane przez inne narzędzia. Edycja w OpenChamber zapisuje plik tak, jak go wpisałeś. Plan przeniesiony do repozytorium zachowuje tożsamość, więc sesje, do których był dołączony, nadal go znajdują.
+
+## Powiązane
+
+- [Akcje projektu](/project-actions/)
+- [Worktrees](/worktrees/)
+- [Notatki, todo i plany projektu](/notes-todos-plans/)
diff --git a/packages/docs/content/docs/project-actions.mdx b/packages/docs/content/docs/project-actions.mdx
index da1ab42e..58eddc23 100644
--- a/packages/docs/content/docs/project-actions.mdx
+++ b/packages/docs/content/docs/project-actions.mdx
@@ -25,4 +25,6 @@ Turn on **auto-open URL** for an action that starts a server. OpenChamber watche
## Related
+- [Repository config](/repository-config/) — keep actions and setup commands in the repository for the whole team
+
- [Preview & Dev Servers](/preview/) — open a running dev server inside OpenChamber
diff --git a/packages/docs/content/docs/pt-br/project-actions.mdx b/packages/docs/content/docs/pt-br/project-actions.mdx
index ad7bebbe..aabcb8ae 100644
--- a/packages/docs/content/docs/pt-br/project-actions.mdx
+++ b/packages/docs/content/docs/pt-br/project-actions.mdx
@@ -25,4 +25,6 @@ Ative **auto-open URL** para uma ação que inicia um servidor. O OpenChamber ob
## Relacionado
+- [Configuração no repositório](/repository-config/) — guarde ações e comandos de configuração no repositório para toda a equipe
+
- [Preview e Servidores de Desenvolvimento](/pt-br/preview/) — abra um servidor de desenvolvimento em execução dentro do OpenChamber
diff --git a/packages/docs/content/docs/pt-br/repository-config.mdx b/packages/docs/content/docs/pt-br/repository-config.mdx
new file mode 100644
index 00000000..662aec44
--- /dev/null
+++ b/packages/docs/content/docs/pt-br/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: Configuração no repositório
+description: Guarde ações do projeto, comandos de configuração de worktree, iniciadores e planos no repositório para que todos que o baixarem os tenham.
+---
+
+# Configuração no repositório
+
+Ações do projeto, comandos de configuração de worktree e iniciadores de rascunho ficam por padrão nas suas próprias configurações do OpenChamber. Ninguém mais os vê. Se você quer que quem clonar o repositório tenha a mesma ação de servidor de desenvolvimento e o mesmo `bun install` em cada worktree novo, mova esses itens para o repositório.
+
+O OpenChamber os guarda em `.openchamber/project.json` na raiz do repositório. O arquivo só aparece quando você move o primeiro item para lá e some quando você tira o último. Faça commit dele como de qualquer outro arquivo.
+
+## O que vai para onde
+
+| Fica nas suas configurações | Pode ir para o repositório |
+|---|---|
+| Notas e tarefas | Ações do projeto |
+| Tarefas agendadas | Comandos de configuração de worktree |
+| Quais ações do repositório você ocultou para si | Iniciadores de rascunho (comandos e skills fixados) |
+| Sua resposta de confiança para os comandos do repositório | Planos |
+
+Notas, tarefas e tarefas agendadas são suas. Nunca vão parar no repositório.
+
+## Mover um item
+
+Abra **Settings → Projects** e escolha o projeto. Cada ação e cada comando de configuração tem um botão **Move to repository**, e cada item que veio do repositório tem **Move to my settings**. Os iniciadores da tela de nova sessão mostram o mesmo par ao passar o mouse. Os planos têm isso em cada linha da aba Planos.
+
+Mover é só isso. O item sai de um lugar e chega no outro, nada é duplicado.
+
+Itens do repositório mostram o selo **In repo**. Ações do repositório também podem ser ocultadas do seu menu com **Hide for me**. Isso muda só o seu menu, não o arquivo.
+
+## O arquivo
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+Só `version` é obrigatório. Todas as outras chaves são opcionais, e o OpenChamber grava apenas as que têm algo.
+
+`setupWorktree` é a lista de comandos de shell que o OpenChamber roda dentro de um worktree novo logo depois de criá-lo, em ordem. Use `$ROOT_PROJECT_PATH` para o caminho do checkout principal. `setupWorktreeWait: true` faz o OpenChamber esperar esses comandos antes de iniciar uma sessão no worktree.
+
+`projectActions` é a lista de ações do menu do cabeçalho. `id`, `name` e `command` são obrigatórios. `icon` é opcional e cai no ícone de play; os nomes que o OpenChamber conhece são `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` e `file`. `autoOpenUrl: true` abre o endereço que o comando imprime, veja [Pré-visualização e servidores de desenvolvimento](/preview/). `platforms` limita a ação a `macos`, `linux` ou `windows`. `runIn: "parent"` roda a ação no checkout principal em vez do worktree atual.
+
+`draftStarters` fixa comandos e skills na tela de nova sessão. Cada entrada é `{ "type": "command" | "skill", "name": "..." }`, e o comando ou skill precisa existir na configuração do OpenCode do repositório.
+
+`plansDir` é onde ficam os planos do repositório. Omita para usar `.openchamber/plans`. Veja abaixo.
+
+Você pode escrever esse arquivo à mão. Uma chave com formato errado invalida o arquivo inteiro, e a página Projects diz o motivo em vez de ignorar em silêncio.
+
+## Como itens do repositório e os seus se combinam
+
+Os comandos de configuração do repositório rodam primeiro, depois os seus. Marque **Use only my setup commands** na seção Worktree para pular por completo os comandos do repositório.
+
+As ações são combinadas por `id`. Uma ação nas suas configurações com o mesmo id de uma ação do repositório a substitui. Iniciadores são combinados por nome.
+
+A marca de espera vem das suas configurações quando você a definiu, senão do repositório.
+
+## Confiança
+
+Comandos de configuração e ações do repositório rodam na sua máquina, e um `git pull` pode mudá-los. Por isso, na primeira vez que um deles está prestes a rodar, o OpenChamber mostra os comandos exatos e pergunta. **Trust and run** guarda sua resposta nesta instância. **Not this time** roda só os seus próprios comandos.
+
+A resposta fica presa aos comandos em si. Quando um pull muda um comando do repositório, a pergunta volta para o texto novo. Você pode esquecer a resposta com **reset trust** na seção Worktree das configurações do projeto.
+
+Mover um comando seu para o repositório conta como confiar nele, já que você acabou de vê-lo.
+
+## Planos no repositório
+
+Os planos da aba Planos também podem ficar no repositório, como arquivos Markdown. A pasta padrão é `.openchamber/plans`. Defina **Plans folder** nas configurações do projeto para usar outra pasta dentro do repositório, por exemplo `docs/plans` se a equipe já guarda planos ali. Uma pasta própria substitui a padrão por completo: o OpenChamber lê e grava só nessa pasta, então mova você mesmo os arquivos existentes ao trocar.
+
+Todo arquivo `.md` dessa pasta aparece na aba Planos, inclusive os escritos por outras ferramentas. Editar um deles no OpenChamber salva o arquivo como você digitou. Um plano que você move para o repositório mantém a identidade, então as sessões que o tinham anexado continuam encontrando.
+
+## Relacionado
+
+- [Ações do projeto](/project-actions/)
+- [Worktrees](/worktrees/)
+- [Notas, tarefas e planos do projeto](/notes-todos-plans/)
diff --git a/packages/docs/content/docs/repository-config.mdx b/packages/docs/content/docs/repository-config.mdx
new file mode 100644
index 00000000..7c905567
--- /dev/null
+++ b/packages/docs/content/docs/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: Repository config
+description: Keep project actions, worktree setup commands, starters, and plans in the repository so everyone who pulls it gets them.
+---
+
+# Repository config
+
+Project actions, worktree setup commands, and draft starters live in your own OpenChamber settings by default. Nobody else sees them. If you want a teammate who clones the repository to get the same dev server action and the same `bun install` on every new worktree, move those items into the repository.
+
+OpenChamber stores them in `.openchamber/project.json` at the repository root. The file appears only when you move the first item there, and it goes away again when you move the last one out. Commit it like any other file.
+
+## What goes where
+
+| Stays in your settings | Can move to the repository |
+|---|---|
+| Notes and todos | Project actions |
+| Scheduled tasks | Worktree setup commands |
+| Which repository action is hidden for you | Draft starters (pinned commands and skills) |
+| Your trust answer for repository commands | Plans |
+
+Notes, todos, and scheduled tasks are yours. They never end up in the repository.
+
+## Moving an item
+
+Open **Settings → Projects** and pick the project. Every action and setup command has a **Move to repository** button, and every item that came from the repository has **Move to my settings**. Starters on the new session screen show the same pair on hover. Plans have it on each row of the Plans tab.
+
+A move is just that. The item leaves one place and lands in the other, so nothing is duplicated.
+
+Items from the repository show an **In repo** badge. Repository actions can also be hidden from your menu with **Hide for me**. That only changes your menu, not the file.
+
+## The file
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+Only `version` is required. Every other key is optional, and OpenChamber writes only the keys that carry something.
+
+`setupWorktree` is the list of shell commands OpenChamber runs inside a new worktree right after creating it, in order. Use `$ROOT_PROJECT_PATH` for the main checkout's path. `setupWorktreeWait: true` makes OpenChamber wait for these commands before it starts a session in the worktree.
+
+`projectActions` is the list of actions in the header menu. `id`, `name`, and `command` are required. `icon` is optional and falls back to a play icon; the names OpenChamber knows are `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command`, and `file`. `autoOpenUrl: true` opens the address the command prints, see [Preview & Dev Servers](/preview/). `platforms` limits the action to `macos`, `linux`, or `windows`. `runIn: "parent"` runs the action in the main checkout instead of the current worktree.
+
+`draftStarters` pins commands and skills to the new session screen. Each entry is `{ "type": "command" | "skill", "name": "..." }`, and the command or skill itself has to exist in the repository's OpenCode config.
+
+`plansDir` is where repository plans live. Leave it out to use `.openchamber/plans`. See below.
+
+You can write this file by hand. A key with the wrong shape makes the whole file invalid, and the Projects page tells you why instead of silently ignoring it.
+
+## How repository and personal items combine
+
+Repository setup commands run first, then your own. Tick **Use only my setup commands** in the Worktree section to skip the repository's commands altogether.
+
+Actions are merged by `id`. An action in your settings with the same id as a repository action replaces it. Starters are merged by name.
+
+The wait flag comes from your settings when you have set it, otherwise from the repository.
+
+## Trust
+
+Setup commands and actions from the repository run on your machine, and a `git pull` can change them. So the first time one of them is about to run, OpenChamber shows the exact commands and asks. **Trust and run** remembers your answer on this instance. **Not this time** runs only your own commands.
+
+The answer is tied to the commands themselves. When a pull changes a repository command, the question comes back for the new text. You can forget the answer with **reset trust** in the Worktree section of the project's settings.
+
+Moving your own command into the repository counts as trusting it, since you have just seen it.
+
+## Plans in the repository
+
+Plans on the Plans tab can also live in the repository, as Markdown files. The folder is `.openchamber/plans` by default. Set **Plans folder** in the project's settings to use another folder inside the repository, for example `docs/plans` if your team already keeps plans there. A custom folder replaces the default completely: OpenChamber reads and writes only that folder, so move existing files yourself when you change it.
+
+Every `.md` file in that folder shows on the Plans tab, including files written by other tools. Editing one in OpenChamber saves the file as you typed it. A plan you move into the repository keeps its identity, so sessions that had it attached still find it.
+
+## Related
+
+- [Project Actions](/project-actions/)
+- [Worktrees](/worktrees/)
+- [Project Notes, Todos & Plans](/notes-todos-plans/)
diff --git a/packages/docs/content/docs/tr/project-actions.mdx b/packages/docs/content/docs/tr/project-actions.mdx
index 1d5f5bc6..9796e7c0 100644
--- a/packages/docs/content/docs/tr/project-actions.mdx
+++ b/packages/docs/content/docs/tr/project-actions.mdx
@@ -25,4 +25,6 @@ Sunucu başlatan bir eylem için **auto-open URL** seçeneğini açın. OpenCham
## İlgili
+- [Depo yapılandırması](/repository-config/) — eylemleri ve kurulum komutlarını tüm ekip için depoda tutun
+
- [Preview & Dev Servers](/preview/) — çalışan bir geliştirme sunucusunu OpenChamber içinde açın
diff --git a/packages/docs/content/docs/tr/repository-config.mdx b/packages/docs/content/docs/tr/repository-config.mdx
new file mode 100644
index 00000000..f965f6f0
--- /dev/null
+++ b/packages/docs/content/docs/tr/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: Depo yapılandırması
+description: Proje eylemlerini, worktree kurulum komutlarını, başlatıcıları ve planları depoda tutun; depoyu çeken herkes aynısını alsın.
+---
+
+# Depo yapılandırması
+
+Proje eylemleri, worktree kurulum komutları ve taslak başlatıcıları varsayılan olarak kendi OpenChamber ayarlarında yaşar. Başka kimse görmez. Depoyu klonlayan bir ekip arkadaşının aynı geliştirme sunucusu eylemini ve her yeni worktree'de aynı `bun install` komutunu almasını istiyorsan, bu öğeleri depoya taşı.
+
+OpenChamber bunları deponun kökündeki `.openchamber/project.json` dosyasında saklar. Dosya, ilk öğeyi oraya taşıdığında ortaya çıkar ve son öğeyi geri aldığında kaybolur. Diğer dosyalar gibi commit'le.
+
+## Ne nereye gider
+
+| Ayarlarında kalır | Depoya taşınabilir |
+|---|---|
+| Notlar ve yapılacaklar | Proje eylemleri |
+| Zamanlanmış görevler | Worktree kurulum komutları |
+| Kendin için gizlediğin depo eylemleri | Taslak başlatıcıları (sabitlenmiş komutlar ve skill'ler) |
+| Depo komutları için güven yanıtın | Planlar |
+
+Notlar, yapılacaklar ve zamanlanmış görevler senindir. Asla depoya girmez.
+
+## Bir öğeyi taşıma
+
+**Settings → Projects** bölümünü aç ve projeyi seç. Her eylemin ve her kurulum komutunun bir **Move to repository** düğmesi, depodan gelen her öğenin de **Move to my settings** düğmesi vardır. Yeni oturum ekranındaki başlatıcılar üzerine gelince aynı ikiliyi gösterir. Planlarda bu, Planlar sekmesindeki her satırda bulunur.
+
+Taşımak tam olarak taşımaktır. Öğe bir yerden çıkar, diğerine gider; hiçbir şey çoğaltılmaz.
+
+Depodan gelen öğeler **In repo** rozeti taşır. Depo eylemleri **Hide for me** ile menünden gizlenebilir. Bu yalnızca senin menünü değiştirir, dosyayı değil.
+
+## Dosya
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+Yalnızca `version` zorunludur. Diğer tüm anahtarlar isteğe bağlıdır ve OpenChamber yalnızca içinde bir şey olanları yazar.
+
+`setupWorktree`, OpenChamber'ın yeni bir worktree oluşturduktan hemen sonra içinde sırayla çalıştırdığı kabuk komutlarının listesidir. Ana checkout yolu için `$ROOT_PROJECT_PATH` kullan. `setupWorktreeWait: true`, OpenChamber'ın worktree'de oturum başlatmadan önce bu komutları beklemesini sağlar.
+
+`projectActions`, başlık menüsündeki eylemlerin listesidir. `id`, `name` ve `command` zorunludur. `icon` isteğe bağlıdır ve verilmezse play simgesi kullanılır; OpenChamber'ın bildiği adlar `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` ve `file`. `autoOpenUrl: true`, komutun yazdırdığı adresi açar; bkz. [Önizleme ve geliştirme sunucuları](/preview/). `platforms`, eylemi `macos`, `linux` veya `windows` ile sınırlar. `runIn: "parent"`, eylemi geçerli worktree yerine ana checkout'ta çalıştırır.
+
+`draftStarters`, komutları ve skill'leri yeni oturum ekranına sabitler. Her giriş `{ "type": "command" | "skill", "name": "..." }` biçimindedir ve komutun ya da skill'in kendisi deponun OpenCode yapılandırmasında bulunmalıdır.
+
+`plansDir`, depo planlarının bulunduğu yerdir. `.openchamber/plans` kullanmak için atla. Aşağıya bak.
+
+Bu dosyayı elle yazabilirsin. Yanlış biçimli bir anahtar tüm dosyayı geçersiz kılar ve Projects sayfası sessizce yok saymak yerine nedenini söyler.
+
+## Depo öğeleriyle kendi öğelerin nasıl birleşir
+
+Önce depodaki kurulum komutları, sonra seninkiler çalışır. Depodakileri tamamen atlamak için Worktree bölümünde **Use only my setup commands** kutusunu işaretle.
+
+Eylemler `id` ile birleştirilir. Ayarlarında depo eylemiyle aynı id'ye sahip bir eylem varsa onun yerine geçer. Başlatıcılar ada göre birleştirilir.
+
+Bekleme bayrağı, ayarladıysan senin ayarlarından, yoksa depodan gelir.
+
+## Güven
+
+Depodaki kurulum komutları ve eylemler senin makinende çalışır ve bir `git pull` bunları değiştirebilir. Bu yüzden biri ilk kez çalışmak üzereyken OpenChamber komutları olduğu gibi gösterir ve sorar. **Trust and run**, yanıtını bu örnekte hatırlar. **Not this time** yalnızca senin komutlarını çalıştırır.
+
+Yanıt komutların kendisine bağlıdır. Bir pull depodaki bir komutu değiştirdiğinde soru yeni metin için geri gelir. Proje ayarlarının Worktree bölümündeki **reset trust** ile yanıtı unutturabilirsin.
+
+Kendi komutunu depoya taşımak ona güvenmek sayılır; onu az önce gördün.
+
+## Depodaki planlar
+
+Planlar sekmesindeki planlar da Markdown dosyaları olarak depoda yaşayabilir. Varsayılan klasör `.openchamber/plans`'tır. Ekibin planları zaten `docs/plans` gibi bir yerde tutuyorsa, proje ayarlarındaki **Plans folder** ile depo içinde başka bir klasör belirle. Özel bir klasör varsayılanı tamamen değiştirir: OpenChamber yalnızca o klasörü okur ve yazar, bu yüzden değiştirdiğinde mevcut dosyaları kendin taşı.
+
+O klasördeki her `.md` dosyası Planlar sekmesinde görünür; başka araçlarla yazılanlar da dahil. OpenChamber'da düzenlemek dosyayı yazdığın gibi kaydeder. Depoya taşıdığın bir plan kimliğini korur, böylece onu eklemiş oturumlar onu bulmaya devam eder.
+
+## İlgili
+
+- [Proje işlemleri](/project-actions/)
+- [Worktree'ler](/worktrees/)
+- [Proje notları, yapılacaklar ve planlar](/notes-todos-plans/)
diff --git a/packages/docs/content/docs/uk/project-actions.mdx b/packages/docs/content/docs/uk/project-actions.mdx
index ca5aea38..335f3c73 100644
--- a/packages/docs/content/docs/uk/project-actions.mdx
+++ b/packages/docs/content/docs/uk/project-actions.mdx
@@ -25,4 +25,6 @@ description: Зберігайте команди, які часто запуск
## Пов'язане
+- [Конфіг у репозиторії](/repository-config/) — тримайте дії й команди налаштування в репозиторії для всієї команди
+
- [Перегляд і dev-сервери](/uk/preview/) — відкрийте запущений dev-сервер усередині OpenChamber
diff --git a/packages/docs/content/docs/uk/repository-config.mdx b/packages/docs/content/docs/uk/repository-config.mdx
new file mode 100644
index 00000000..e46a2ede
--- /dev/null
+++ b/packages/docs/content/docs/uk/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: Конфіг у репозиторії
+description: Тримайте дії проєкту, команди налаштування worktree, стартери й плани в репозиторії, щоб їх отримував кожен, хто його клонує.
+---
+
+# Конфіг у репозиторії
+
+Дії проєкту, команди налаштування worktree і стартери чернетки за замовчуванням живуть у ваших власних налаштуваннях OpenChamber. Ніхто інший їх не бачить. Якщо ви хочете, щоб колега, який клонує репозиторій, отримав ту саму дію для dev-сервера і той самий `bun install` у кожному новому worktree, перенесіть ці елементи в репозиторій.
+
+OpenChamber зберігає їх у файлі `.openchamber/project.json` у корені репозиторію. Файл з'являється лише тоді, коли ви переносите туди перший елемент, і зникає, коли забираєте останній. Комітьте його як звичайний файл.
+
+## Що де лежить
+
+| Лишається у ваших налаштуваннях | Можна перенести в репозиторій |
+|---|---|
+| Нотатки й todo | Дії проєкту |
+| Заплановані задачі | Команди налаштування worktree |
+| Які дії з репозиторію ви сховали для себе | Стартери чернетки (закріплені команди й скіли) |
+| Ваша відповідь про довіру до команд із репозиторію | Плани |
+
+Нотатки, todo і заплановані задачі ваші. Вони ніколи не потрапляють у репозиторій.
+
+## Перенесення елемента
+
+Відкрийте **Settings → Projects** і виберіть проєкт. У кожної дії та команди налаштування є кнопка **Move to repository**, а в кожного елемента з репозиторію — **Move to my settings**. Стартери на екрані нової сесії показують ту саму пару при наведенні. У планів вона є в кожному рядку вкладки «Плани».
+
+Перенесення — це саме перенесення. Елемент зникає з одного місця і з'являється в іншому, нічого не дублюється.
+
+Елементи з репозиторію мають бейдж **In repo**. Дії з репозиторію можна сховати зі свого меню кнопкою **Hide for me**. Це змінює лише ваше меню, не файл.
+
+## Файл
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+Обов'язковий лише `version`. Усі інші ключі необов'язкові, і OpenChamber записує тільки ті, що щось містять.
+
+`setupWorktree` — список shell-команд, які OpenChamber виконує всередині нового worktree одразу після його створення, по порядку. Використовуйте `$ROOT_PROJECT_PATH` для шляху до основного checkout. `setupWorktreeWait: true` змушує OpenChamber дочекатися цих команд, перш ніж запускати сесію у worktree.
+
+`projectActions` — список дій у меню заголовка. `id`, `name` і `command` обов'язкові. `icon` необов'язкова і за замовчуванням це іконка play; OpenChamber знає такі назви: `play`, `build`, `lint`, `terminal`, `tools`, `bug`, `flask`, `rocket`, `code`, `server`, `branch`, `search`, `settings`, `brain`, `stack`, `robot`, `command` і `file`. `autoOpenUrl: true` відкриває адресу, яку виводить команда, див. [Перегляд і dev-сервери](/preview/). `platforms` обмежує дію до `macos`, `linux` або `windows`. `runIn: "parent"` виконує дію в основному checkout, а не в поточному worktree.
+
+`draftStarters` закріплює команди й скіли на екрані нової сесії. Кожен запис має вигляд `{ "type": "command" | "skill", "name": "..." }`, а сама команда чи скіл мають існувати в конфігу OpenCode цього репозиторію.
+
+`plansDir` — де лежать плани репозиторію. Пропустіть, щоб використовувати `.openchamber/plans`. Див. нижче.
+
+Цей файл можна писати руками. Ключ неправильної форми робить увесь файл недійсним, і сторінка Projects каже чому, замість того щоб мовчки його проігнорувати.
+
+## Як поєднуються елементи з репозиторію і ваші
+
+Спершу виконуються команди налаштування з репозиторію, потім ваші. Позначте **Use only my setup commands** у секції Worktree, щоб узагалі пропустити команди з репозиторію.
+
+Дії зливаються за `id`. Дія у ваших налаштуваннях із таким самим id, як у репозиторії, замінює її. Стартери зливаються за назвою.
+
+Прапорець очікування береться з ваших налаштувань, якщо ви його задали, інакше з репозиторію.
+
+## Довіра
+
+Команди налаштування й дії з репозиторію виконуються на вашому комп'ютері, а `git pull` може їх змінити. Тому першого разу, коли одна з них ось-ось виконається, OpenChamber показує точний текст команд і питає. **Trust and run** запам'ятовує вашу відповідь на цьому інстансі. **Not this time** виконує лише ваші власні команди.
+
+Відповідь прив'язана до самих команд. Коли pull змінює команду з репозиторію, питання повертається для нового тексту. Забути відповідь можна кнопкою **reset trust** у секції Worktree в налаштуваннях проєкту.
+
+Перенесення власної команди в репозиторій рахується як довіра до неї, адже ви її щойно бачили.
+
+## Плани в репозиторії
+
+Плани з вкладки «Плани» теж можуть жити в репозиторії як файли Markdown. За замовчуванням це тека `.openchamber/plans`. Задайте **Plans folder** у налаштуваннях проєкту, щоб використати іншу теку всередині репозиторію, наприклад `docs/plans`, якщо команда вже тримає плани там. Своя тека повністю замінює типову: OpenChamber читає й пише лише в неї, тож при зміні перенесіть наявні файли самі.
+
+Кожен файл `.md` у цій теці з'являється на вкладці «Плани», включно з файлами, які написали інші інструменти. Редагування в OpenChamber зберігає файл так, як ви його набрали. План, перенесений у репозиторій, зберігає свою ідентичність, тож сесії, до яких він був прикріплений, і далі його знаходять.
+
+## Пов'язане
+
+- [Дії проєкту](/project-actions/)
+- [Worktrees](/worktrees/)
+- [Нотатки, todo і плани проєкту](/notes-todos-plans/)
diff --git a/packages/docs/content/docs/zh-cn/environment.mdx b/packages/docs/content/docs/zh-cn/environment.mdx
index 7deb22f7..138ebce5 100644
--- a/packages/docs/content/docs/zh-cn/environment.mdx
+++ b/packages/docs/content/docs/zh-cn/environment.mdx
@@ -25,6 +25,8 @@ OpenChamber web 服务器监听的地址。使用 `0.0.0.0` 可允许其他机
覆盖 OpenChamber 数据目录。默认是 `~/.config/openchamber`。
+OpenChamber 存储的所有内容都位于此目录下:设置、认证、项目配置、主题、计划和语音模型。在 1.23 之前使用自定义目录的实例会在首次启动时将 `projects`、`themes` 和 `speech-models` 文件夹从 `~/.config/openchamber` 复制到此目录;原文件夹保持不变,不会合并任何内容。
+
### `OPENCHAMBER_CHATS_DIR`
更改 OpenChamber 为无项目聊天创建的托管聊天目录的位置。默认是 `~/.config/openchamber/chats`。当 OpenChamber 和 OpenCode 以不同用户运行时,请设置为 OpenCode 服务器可读取的目录。现有聊天不会被移动。
diff --git a/packages/docs/content/docs/zh-cn/project-actions.mdx b/packages/docs/content/docs/zh-cn/project-actions.mdx
index 8da9bf17..56fc8156 100644
--- a/packages/docs/content/docs/zh-cn/project-actions.mdx
+++ b/packages/docs/content/docs/zh-cn/project-actions.mdx
@@ -25,4 +25,6 @@ description: 保存你经常运行的命令,一键启动它们。
## 相关内容
+- [仓库配置](/repository-config/) — 把操作和设置命令放进仓库,供整个团队使用
+
- [预览与开发服务器](/zh-cn/preview/) — 在 OpenChamber 内部打开正在运行的开发服务器
diff --git a/packages/docs/content/docs/zh-cn/repository-config.mdx b/packages/docs/content/docs/zh-cn/repository-config.mdx
new file mode 100644
index 00000000..bca63815
--- /dev/null
+++ b/packages/docs/content/docs/zh-cn/repository-config.mdx
@@ -0,0 +1,100 @@
+---
+title: 仓库配置
+description: 把项目操作、工作树设置命令、启动项和计划放进仓库,让拉取仓库的每个人都能获得。
+---
+
+# 仓库配置
+
+项目操作、工作树设置命令和草稿启动项默认保存在你自己的 OpenChamber 设置里。别人看不到它们。如果你希望克隆仓库的队友也拥有同样的开发服务器操作,以及每个新工作树里同样的 `bun install`,就把这些项目移到仓库中。
+
+OpenChamber 把它们存放在仓库根目录的 `.openchamber/project.json` 里。只有当你把第一个项目移进去时这个文件才会出现,移出最后一个项目时它会消失。像提交其他文件一样提交它即可。
+
+## 什么放在哪里
+
+| 留在你的设置里 | 可以移到仓库 |
+|---|---|
+| 笔记和待办 | 项目操作 |
+| 定时任务 | 工作树设置命令 |
+| 你为自己隐藏了哪些仓库操作 | 草稿启动项(固定的命令和技能) |
+| 你对仓库命令的信任回答 | 计划 |
+
+笔记、待办和定时任务是你的。它们永远不会进入仓库。
+
+## 移动项目
+
+打开 **Settings → Projects** 并选择项目。每个操作和每条设置命令都有 **Move to repository** 按钮,每个来自仓库的项目都有 **Move to my settings**。新会话界面上的启动项在悬停时显示同样的一对按钮。计划则在“计划”标签的每一行里。
+
+移动就是移动。项目离开一处,落到另一处,不会产生副本。
+
+来自仓库的项目带有 **In repo** 徽章。仓库操作还可以用 **Hide for me** 从你的菜单中隐藏。这只改变你的菜单,不改变文件。
+
+## 文件
+
+```json
+{
+ "version": 1,
+ "setupWorktree": [
+ "bun install"
+ ],
+ "setupWorktreeWait": true,
+ "projectActions": [
+ {
+ "id": "dev",
+ "name": "Dev server",
+ "command": "bun run dev",
+ "icon": "rocket",
+ "autoOpenUrl": true,
+ "platforms": ["macos", "linux"]
+ },
+ {
+ "id": "test",
+ "name": "Tests",
+ "command": "bun test"
+ }
+ ],
+ "draftStarters": [
+ { "type": "skill", "name": "triage-prs" }
+ ],
+ "plansDir": "docs/plans"
+}
+```
+
+只有 `version` 是必填的。其余键都是可选的,OpenChamber 只写入有内容的键。
+
+`setupWorktree` 是 OpenChamber 在创建新工作树后立即在其中按顺序运行的 shell 命令列表。主检出路径用 `$ROOT_PROJECT_PATH` 表示。`setupWorktreeWait: true` 会让 OpenChamber 等这些命令完成后再在工作树中启动会话。
+
+`projectActions` 是头部菜单中的操作列表。`id`、`name` 和 `command` 是必填的。`icon` 可选,缺省时使用 play 图标;OpenChamber 认识的名称有 `play`、`build`、`lint`、`terminal`、`tools`、`bug`、`flask`、`rocket`、`code`、`server`、`branch`、`search`、`settings`、`brain`、`stack`、`robot`、`command` 和 `file`。`autoOpenUrl: true` 会打开命令输出的地址,见[预览与开发服务器](/preview/)。`platforms` 把操作限制在 `macos`、`linux` 或 `windows`。`runIn: "parent"` 在主检出而不是当前工作树中运行操作。
+
+`draftStarters` 把命令和技能固定到新会话界面。每一项形如 `{ "type": "command" | "skill", "name": "..." }`,命令或技能本身必须存在于仓库的 OpenCode 配置中。
+
+`plansDir` 是仓库计划所在的位置。省略则使用 `.openchamber/plans`。见下文。
+
+这个文件可以手写。某个键的形状不对会使整个文件无效,Projects 页面会告诉你原因,而不是悄悄忽略它。
+
+## 仓库项目与你自己的项目如何合并
+
+先运行仓库的设置命令,再运行你自己的。在 Worktree 区域勾选 **Use only my setup commands** 可以完全跳过仓库的命令。
+
+操作按 `id` 合并。你设置中与仓库操作 id 相同的操作会取代它。启动项按名称合并。
+
+等待标志在你设置了时取你的值,否则取仓库的值。
+
+## 信任
+
+仓库中的设置命令和操作会在你的机器上运行,而一次 `git pull` 就可能改变它们。因此,当其中某一条第一次即将运行时,OpenChamber 会显示完整的命令并询问你。**Trust and run** 会在此实例上记住你的回答。**Not this time** 只运行你自己的命令。
+
+回答与命令本身绑定。当 pull 改变了仓库中的命令,会针对新内容再次询问。你可以在项目设置的 Worktree 区域用 **reset trust** 忘记回答。
+
+把你自己的命令移到仓库视为信任它,因为你刚刚看过它。
+
+## 仓库中的计划
+
+“计划”标签中的计划也可以作为 Markdown 文件放在仓库里。默认文件夹是 `.openchamber/plans`。如果团队已经把计划放在例如 `docs/plans` 中,可在项目设置里的 **Plans folder** 指定仓库内的另一个文件夹。自定义文件夹会完全替代默认值:OpenChamber 只读写该文件夹,所以更改时请自行移动现有文件。
+
+该文件夹中的每个 `.md` 文件都会显示在“计划”标签中,包括其他工具写的文件。在 OpenChamber 中编辑会按你输入的内容原样保存文件。移到仓库的计划保持其身份,之前附加了它的会话仍能找到它。
+
+## 相关内容
+
+- [项目操作](/project-actions/)
+- [工作树](/worktrees/)
+- [项目笔记、待办与计划](/notes-todos-plans/)
diff --git a/packages/docs/sidebar.config.json b/packages/docs/sidebar.config.json
index 828726a0..f6b7b20f 100644
--- a/packages/docs/sidebar.config.json
+++ b/packages/docs/sidebar.config.json
@@ -224,6 +224,22 @@
"tr": "Proje işlemleri"
}
},
+ {
+ "label": "Repository config",
+ "link": "/repository-config/",
+ "translations": {
+ "uk": "Конфіг у репозиторії",
+ "zh-CN": "仓库配置",
+ "es": "Configuración en el repositorio",
+ "pt-BR": "Configuração no repositório",
+ "ko": "저장소 설정",
+ "pl": "Konfiguracja w repozytorium",
+ "fr": "Configuration du dépôt",
+ "ja": "リポジトリ設定",
+ "de": "Repository-Konfiguration",
+ "tr": "Depo yapılandırması"
+ }
+ },
{
"label": "Preview & Dev Servers",
"link": "/preview/",
diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs
index 0e7a922d..df9eb0a0 100644
--- a/packages/electron/main.mjs
+++ b/packages/electron/main.mjs
@@ -579,6 +579,30 @@ const readSettingsRoot = () => {
return root && typeof root === 'object' && !Array.isArray(root) ? root : {};
};
+// The user's profile (theme mode among it) lives in preferences.json beside
+// settings.json since the settings split; each entry is { value, updatedAt }.
+// Installs that predate the split still carry those keys in settings.json, so
+// readers merge both, preferences winning.
+const readPreferencesValues = () => {
+ const root = readJsonFile(path.join(path.dirname(settingsFilePath()), 'preferences.json'));
+ const fields = root && typeof root === 'object' && root.version === 1 && root.fields && typeof root.fields === 'object'
+ ? root.fields
+ : {};
+ // Per-surface keys (theme mode among them) are resolved for the desktop
+ // shell: its own value first, the base value otherwise.
+ const values = {};
+ for (const [key, entry] of Object.entries(fields)) {
+ if (!entry || typeof entry !== 'object') continue;
+ const own = entry.surfaces && typeof entry.surfaces === 'object' ? entry.surfaces.desktop : undefined;
+ if (own && typeof own === 'object' && 'value' in own) {
+ values[key] = own.value;
+ } else if ('value' in entry) {
+ values[key] = entry.value;
+ }
+ }
+ return values;
+};
+
// Serializes read-modify-write of the settings file within this process.
// Multiple call sites (spawnLocalServer, writeDesktopHostsConfig, theme
// preference saves, ssh manager imports, etc.) would otherwise have their
@@ -1784,12 +1808,24 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) =>
return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url, ...availability };
};
+const readSplashColor = (settings, key, fallback) => {
+ // The renderer hands the colours over IPC (desktop_set_window_theme) and
+ // main stores them under `desktopSplashColors`; the flat `splash*` keys are
+ // what builds before the settings split wrote and are read as a fallback.
+ const owned = settings.desktopSplashColors && typeof settings.desktopSplashColors === 'object'
+ ? settings.desktopSplashColors[key]
+ : undefined;
+ const legacy = settings[`splash${key.charAt(0).toUpperCase()}${key.slice(1)}`];
+ const value = typeof owned === 'string' ? owned : legacy;
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
+};
+
const buildStartupSplashHtml = () => {
const settings = readSettingsRoot();
- const splashBgLight = typeof settings.splashBgLight === 'string' ? settings.splashBgLight.trim() : '#f5f5f4';
- const splashFgLight = typeof settings.splashFgLight === 'string' ? settings.splashFgLight.trim() : '#1c1917';
- const splashBgDark = typeof settings.splashBgDark === 'string' ? settings.splashBgDark.trim() : '#0c0a09';
- const splashFgDark = typeof settings.splashFgDark === 'string' ? settings.splashFgDark.trim() : '#fafaf9';
+ const splashBgLight = readSplashColor(settings, 'bgLight', '#f5f5f4');
+ const splashFgLight = readSplashColor(settings, 'fgLight', '#1c1917');
+ const splashBgDark = readSplashColor(settings, 'bgDark', '#0c0a09');
+ const splashFgDark = readSplashColor(settings, 'fgDark', '#fafaf9');
return `
@@ -2408,7 +2444,7 @@ const nextWindowLabel = () => {
};
const readThemeSource = () => {
- const settings = readSettingsRoot();
+ const settings = { ...readSettingsRoot(), ...readPreferencesValues() };
// themeMode is the user's intent; themeVariant is only the resolved
// concrete appearance at persist time. When mode === 'system', we must
// follow the OS even if variant was saved as a specific value.
@@ -4447,6 +4483,21 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
case 'desktop_set_window_theme': {
const mode = typeof args.themeMode === 'string' ? args.themeMode : '';
const variant = typeof args.themeVariant === 'string' ? args.themeVariant : '';
+ const splash = args.splash && typeof args.splash === 'object' ? args.splash : null;
+ if (splash) {
+ const colors = {};
+ for (const key of ['bgLight', 'fgLight', 'bgDark', 'fgDark']) {
+ if (typeof splash[key] === 'string' && splash[key].trim()) colors[key] = splash[key].trim();
+ }
+ if (Object.keys(colors).length === 4) {
+ const current = readSettingsRoot().desktopSplashColors;
+ const unchanged = current && typeof current === 'object'
+ && ['bgLight', 'fgLight', 'bgDark', 'fgDark'].every((key) => current[key] === colors[key]);
+ if (!unchanged) {
+ void mutateSettingsRoot((root) => ({ ...root, desktopSplashColors: colors }));
+ }
+ }
+ }
// Priority order: themeMode expresses the user's intent (including
// "follow OS"). Variant is just the resolved variant at send time;
// when mode === 'system' with variant === 'dark' (because OS is
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index 21581e28..1e1cf14e 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { MainLayout } from '@/components/layout/MainLayout';
import { ChatView } from '@/components/views/ChatView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
+import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog';
import { FireworksProvider } from '@/contexts/FireworksContext';
import { Toaster } from '@/components/ui/sonner';
import { Button } from '@/components/ui/button';
@@ -913,6 +914,7 @@ function App({ apis }: AppProps) {
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
/>
+
@@ -957,6 +959,7 @@ function App({ apis }: AppProps) {
+
{!isBootShell && (
<>
diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx
index 4b59a36e..804d8cb7 100644
--- a/packages/ui/src/apps/ElectronMiniChatApp.tsx
+++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx
@@ -6,6 +6,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
+import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useRootScrollLock } from '@/hooks/useRootScrollLock';
@@ -329,6 +330,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx
index 0190da3f..6b6a5ec3 100644
--- a/packages/ui/src/apps/MobileApp.tsx
+++ b/packages/ui/src/apps/MobileApp.tsx
@@ -10,6 +10,7 @@ import { ChatView } from '@/components/views/ChatView';
import { PlanView } from '@/components/views/PlanView';
import { SettingsView } from '@/components/views/SettingsView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
+import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
@@ -1288,6 +1289,7 @@ export function MobileApp({ apis }: MobileAppProps) {
setConnectionEpoch((value) => value + 1);
}} />
+
{isInitialized ? : null}
diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx
index 43e3f6f4..91e27dfe 100644
--- a/packages/ui/src/apps/VSCodeApp.tsx
+++ b/packages/ui/src/apps/VSCodeApp.tsx
@@ -9,6 +9,7 @@ import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
+import { SharedTrustConfirmDialog } from '@/components/projects/SharedTrustConfirmDialog';
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
@@ -114,6 +115,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+
@@ -134,6 +136,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+
diff --git a/packages/ui/src/components/chat/DraftPresetChips.tsx b/packages/ui/src/components/chat/DraftPresetChips.tsx
index 956fc721..31230b64 100644
--- a/packages/ui/src/components/chat/DraftPresetChips.tsx
+++ b/packages/ui/src/components/chat/DraftPresetChips.tsx
@@ -68,9 +68,11 @@ const SortableChip: React.FC<{
item: ResolvedStarter;
onSubmit: (starter: ResolvedStarter) => void;
onRemove: () => void;
+ /** Project chips only: move the starter into the team's shared file, or back out of it. */
+ onToggleShared?: () => void;
/** Hide the per-chip hover "x" (mobile uses the trash drop-zone instead). */
hideRemove?: boolean;
-}> = ({ item, onSubmit, onRemove, hideRemove }) => {
+}> = ({ item, onSubmit, onRemove, onToggleShared, hideRemove }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id });
@@ -94,11 +96,25 @@ const SortableChip: React.FC<{
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}
+ title={item.shared ? t('chat.draftStarters.sharedTitle') : undefined}
>
{item.label}
- {hideRemove ? null : (
+ {onToggleShared && !hideRemove ? (
+ { e.stopPropagation(); onToggleShared(); }}
+ aria-label={t(item.shared ? 'chat.draftStarters.makePersonal' : 'chat.draftStarters.share')}
+ title={t(item.shared ? 'chat.draftStarters.makePersonal' : 'chat.draftStarters.share')}
+ className="absolute -left-1.5 -top-1.5 hidden h-4 w-4 items-center justify-center rounded-full border text-muted-foreground shadow-sm hover:text-foreground group-hover/chip:flex"
+ style={chipStyle}
+ >
+
+
+ ) : null}
+ {/* A shared starter is the team's: it leaves only through the repo file. */}
+ {hideRemove || item.shared ? null : (
{ e.stopPropagation(); onRemove(); }}
@@ -118,8 +134,9 @@ const StarterGroup: React.FC<{
items: ResolvedStarter[];
onSubmit: (starter: ResolvedStarter) => void;
onRemove: (item: ResolvedStarter) => void;
+ onToggleShared?: (item: ResolvedStarter) => void;
hideRemove?: boolean;
-}> = ({ items, onSubmit, onRemove, hideRemove }) => (
+}> = ({ items, onSubmit, onRemove, onToggleShared, hideRemove }) => (
i.id)} strategy={rectSortingStrategy}>
{items.map((item) => (
onRemove(item)}
+ onToggleShared={onToggleShared ? () => onToggleShared(item) : undefined}
hideRemove={hideRemove}
/>
))}
@@ -256,7 +274,7 @@ const AddStarterPicker: React.FC<{
* ignored.
*/
const DraftPresetChipsContent: React.FC = ({ onSubmit, className }) => {
- const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder } = useDraftStarters();
+ const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder, shareStarter, unshareStarter } = useDraftStarters();
const { isMobile } = useDeviceInfo();
const [isDragging, setIsDragging] = React.useState(false);
@@ -320,6 +338,7 @@ const DraftPresetChipsContent: React.FC = ({ onSubmit, cl
items={project}
onSubmit={onSubmit}
onRemove={(item) => removeStarter('project', item.ref)}
+ onToggleShared={(item) => (item.shared ? unshareStarter(item.ref) : shareStarter(item.ref))}
hideRemove={isMobile}
/>
) : null}
diff --git a/packages/ui/src/components/chat/useDraftStarters.ts b/packages/ui/src/components/chat/useDraftStarters.ts
index b0711bdf..db853fcb 100644
--- a/packages/ui/src/components/chat/useDraftStarters.ts
+++ b/packages/ui/src/components/chat/useDraftStarters.ts
@@ -6,7 +6,7 @@ import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { updateDesktopSettings } from '@/lib/persistence';
-import { getProjectDraftStarters, saveProjectDraftStarters } from '@/lib/openchamberConfig';
+import { getProjectDraftStarters, saveProjectDraftStarters, updateSharedProjectSetup, type ProjectDraftStarter } from '@/lib/openchamberConfig';
import { isVSCodeRuntime } from '@/lib/desktop';
import type { IconName } from '@/components/icon/icons';
import {
@@ -31,6 +31,8 @@ export type ResolvedStarter = {
label: string;
icon: IconName;
submitText: string;
+ /** Pinned by the team in the repo's shared config; not removable here. */
+ shared: boolean;
};
export type PinnableSection = 'built-in' | 'command' | 'skill';
@@ -55,6 +57,10 @@ export type UseDraftStartersResult = {
addStarter: (item: PinnableItem) => void;
removeStarter: (group: StarterGroup, ref: DraftStarterRef) => void;
reorder: (group: StarterGroup, fromId: string, toId: string) => void;
+ /** Move one of the user's project starters into the repo's shared file. */
+ shareStarter: (ref: DraftStarterRef) => void;
+ /** Move a shared project starter back into the user's own list. */
+ unshareStarter: (ref: DraftStarterRef) => void;
};
export function useDraftStarters(): UseDraftStartersResult {
@@ -73,7 +79,13 @@ export function useDraftStarters(): UseDraftStartersResult {
return { id: found.id, path: found.path };
}, [activeProjectId, projects]);
- const [projectStarters, setProjectStarters] = React.useState([]);
+ // Merged: the team's shared starters first, then the user's own. Only the
+ // personal ones are ever written back.
+ const [projectStarters, setProjectStarters] = React.useState([]);
+ const personalProjectStarters = React.useMemo(
+ () => projectStarters.filter((r) => r.source === 'personal').map(({ type, name }) => ({ type, name })),
+ [projectStarters],
+ );
React.useEffect(() => {
let cancelled = false;
@@ -106,18 +118,19 @@ export function useDraftStarters(): UseDraftStartersResult {
const commandNames = React.useMemo(() => new Set(commands.map((c) => c.name)), [commands]);
const skillNames = React.useMemo(() => new Set(skills.map((s) => s.name)), [skills]);
- const resolve = React.useCallback((ref: DraftStarterRef, group: StarterGroup): ResolvedStarter | null => {
+ const resolve = React.useCallback((ref: DraftStarterRef, group: StarterGroup, shared = false): ResolvedStarter | null => {
if (isVSCode && ref.type === 'command' && (ref.name === 'craft-goal' || ref.name === 'schedule-task')) return null;
+ const plain: DraftStarterRef = { type: ref.type, name: ref.name };
if (ref.type === 'command') {
const builtin = getBuiltInStarter(ref.name);
if (builtin) {
- return { id: chipId(group, ref), ref, group, label: t(builtin.labelKey), icon: builtin.icon, submitText: builtin.command };
+ return { id: chipId(group, plain), ref: plain, group, label: t(builtin.labelKey), icon: builtin.icon, submitText: builtin.command, shared };
}
if (!commandNames.has(ref.name)) return null;
- return { id: chipId(group, ref), ref, group, label: normalizeStarterLabel(ref.name), icon: COMMAND_FALLBACK_ICON, submitText: `/${ref.name}` };
+ return { id: chipId(group, plain), ref: plain, group, label: normalizeStarterLabel(ref.name), icon: COMMAND_FALLBACK_ICON, submitText: `/${ref.name}`, shared };
}
if (!skillNames.has(ref.name)) return null;
- return { id: chipId(group, ref), ref, group, label: normalizeStarterLabel(ref.name), icon: SKILL_FALLBACK_ICON, submitText: `/${ref.name}` };
+ return { id: chipId(group, plain), ref: plain, group, label: normalizeStarterLabel(ref.name), icon: SKILL_FALLBACK_ICON, submitText: `/${ref.name}`, shared };
}, [t, commandNames, skillNames, isVSCode]);
const globalRefs = React.useMemo(
@@ -130,7 +143,7 @@ export function useDraftStarters(): UseDraftStartersResult {
[globalRefs, resolve],
);
const project = React.useMemo(
- () => projectStarters.map((r) => resolve(r, 'project')).filter((x): x is ResolvedStarter => x !== null),
+ () => projectStarters.map((r) => resolve(r, 'project', r.source === 'shared')).filter((x): x is ResolvedStarter => x !== null),
[projectStarters, resolve],
);
@@ -160,11 +173,22 @@ export function useDraftStarters(): UseDraftStartersResult {
const persistGlobal = React.useCallback((next: DraftStarterRef[]) => {
useUIStore.getState().setGlobalDraftStarters(next);
- void updateDesktopSettings({ draftStarters: next });
+ // The markers make a deliberate removal of a built-in starter durable:
+ // without them the load path re-inserts Craft a Goal / Schedule a Task.
+ // They travel with the user's edit, never with a bootstrap.
+ void updateDesktopSettings({
+ draftStarters: next,
+ draftStartersCraftGoalAdded: true,
+ draftStartersScheduleTaskAdded: true,
+ });
}, []);
+ // `next` is the user's own list; the shared ones stay in front, untouched.
const persistProject = React.useCallback((next: DraftStarterRef[]) => {
- setProjectStarters(next);
+ setProjectStarters((current) => [
+ ...current.filter((r) => r.source === 'shared'),
+ ...next.map((r) => ({ ...r, source: 'personal' as const })),
+ ]);
if (projectRef) void saveProjectDraftStarters(projectRef, next);
}, [projectRef]);
@@ -172,31 +196,63 @@ export function useDraftStarters(): UseDraftStartersResult {
const ref: DraftStarterRef = { type: item.type, name: item.name };
if (item.scope === 'project') {
if (!projectRef || projectStarters.some((r) => sameStarter(r, ref))) return;
- persistProject([...projectStarters, ref]);
+ persistProject([...personalProjectStarters, ref]);
} else {
const base = globalRaw ?? DEFAULT_GLOBAL_STARTERS;
if (base.some((r) => sameStarter(r, ref))) return;
persistGlobal([...base, ref]);
}
- }, [projectRef, projectStarters, globalRaw, persistProject, persistGlobal]);
+ }, [projectRef, projectStarters, personalProjectStarters, globalRaw, persistProject, persistGlobal]);
const removeStarter = React.useCallback((group: StarterGroup, ref: DraftStarterRef) => {
if (group === 'project') {
- persistProject(projectStarters.filter((r) => !sameStarter(r, ref)));
+ // A shared starter is the team's; it leaves only through the repo file.
+ if (!personalProjectStarters.some((r) => sameStarter(r, ref))) return;
+ persistProject(personalProjectStarters.filter((r) => !sameStarter(r, ref)));
} else {
const base = globalRaw ?? DEFAULT_GLOBAL_STARTERS;
persistGlobal(base.filter((r) => !sameStarter(r, ref)));
}
- }, [projectStarters, globalRaw, persistProject, persistGlobal]);
+ }, [personalProjectStarters, globalRaw, persistProject, persistGlobal]);
+
+ // Sharing moves a starter between the two files: into the repo file first,
+ // then out of the personal list; the merged list is reloaded from the server.
+ const reloadProjectStarters = React.useCallback(() => {
+ if (!projectRef) return;
+ void getProjectDraftStarters(projectRef).then(setProjectStarters).catch(() => undefined);
+ }, [projectRef]);
+
+ const shareStarter = React.useCallback((ref: DraftStarterRef) => {
+ if (!projectRef) return;
+ const shared = projectStarters.filter((r) => r.source === 'shared').map(({ type, name }) => ({ type, name }));
+ if (shared.some((r) => sameStarter(r, ref))) return;
+ void (async () => {
+ if (!(await updateSharedProjectSetup(projectRef, { draftStarters: [...shared, ref] }))) return;
+ await saveProjectDraftStarters(projectRef, personalProjectStarters.filter((r) => !sameStarter(r, ref)));
+ reloadProjectStarters();
+ })();
+ }, [personalProjectStarters, projectRef, projectStarters, reloadProjectStarters]);
+
+ const unshareStarter = React.useCallback((ref: DraftStarterRef) => {
+ if (!projectRef) return;
+ const shared = projectStarters.filter((r) => r.source === 'shared').map(({ type, name }) => ({ type, name }));
+ if (!shared.some((r) => sameStarter(r, ref))) return;
+ void (async () => {
+ if (!(await updateSharedProjectSetup(projectRef, { draftStarters: shared.filter((r) => !sameStarter(r, ref)) }))) return;
+ await saveProjectDraftStarters(projectRef, [...personalProjectStarters.filter((r) => !sameStarter(r, ref)), ref]);
+ reloadProjectStarters();
+ })();
+ }, [personalProjectStarters, projectRef, projectStarters, reloadProjectStarters]);
const reorder = React.useCallback((group: StarterGroup, fromId: string, toId: string) => {
- const base = group === 'project' ? projectStarters : (globalRaw ?? DEFAULT_GLOBAL_STARTERS);
+ // Project chips reorder among the user's own; shared ones keep their place in front.
+ const base = group === 'project' ? personalProjectStarters : (globalRaw ?? DEFAULT_GLOBAL_STARTERS);
const from = base.findIndex((r) => chipId(group, r) === fromId);
const to = base.findIndex((r) => chipId(group, r) === toId);
if (from < 0 || to < 0 || from === to) return;
const next = arrayMove([...base], from, to);
if (group === 'project') persistProject(next); else persistGlobal(next);
- }, [projectStarters, globalRaw, persistProject, persistGlobal]);
+ }, [personalProjectStarters, globalRaw, persistProject, persistGlobal]);
- return { global, project, pinnable, hasProject: !!projectRef, ensureLoaded, addStarter, removeStarter, reorder };
+ return { global, project, pinnable, hasProject: !!projectRef, ensureLoaded, addStarter, removeStarter, reorder, shareStarter, unshareStarter };
}
diff --git a/packages/ui/src/components/layout/ProjectActionsButton.test.tsx b/packages/ui/src/components/layout/ProjectActionsButton.test.tsx
index 05be505f..1c259096 100644
--- a/packages/ui/src/components/layout/ProjectActionsButton.test.tsx
+++ b/packages/ui/src/components/layout/ProjectActionsButton.test.tsx
@@ -151,6 +151,18 @@ mock.module('@/stores/useDesktopSshStore', () => ({ useDesktopSshStore: useDeskt
mock.module('@/lib/url', () => ({ openExternalUrl: async (url: string) => { openExternalCalls.push(url); } }));
mock.module('@/lib/openchamberConfig', () => ({
getProjectActionsState: async () => mockedActionsState,
+ // The button loads the merged setup; the test's actions are personal, so nothing asks for trust.
+ getProjectSetup: async () => ({
+ trust: { hash: null, trusted: true },
+ setupWorktree: [],
+ setupWorktreeWait: false,
+ projectActions: mockedActionsState.actions.map((action) => ({ ...action, source: 'personal' })),
+ projectActionsPrimaryId: null,
+ draftStarters: [],
+ shared: { status: 'missing', path: '.openchamber/project.json', setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null },
+ personal: { setupWorktree: [], setupWorktreeWait: null, setupWorktreeMode: 'append', projectActions: mockedActionsState.actions, projectActionsPrimaryId: null, draftStarters: [], hiddenSharedActionIds: [], sharedTrust: null },
+ }),
+ updateProjectSetup: async () => true,
}));
mock.module('@/lib/browser/announcedServers', () => ({ setAnnouncedDevServers: () => undefined }));
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => effectiveDirectory }));
diff --git a/packages/ui/src/components/layout/ProjectActionsButton.tsx b/packages/ui/src/components/layout/ProjectActionsButton.tsx
index c9422b5e..9d528d98 100644
--- a/packages/ui/src/components/layout/ProjectActionsButton.tsx
+++ b/packages/ui/src/components/layout/ProjectActionsButton.tsx
@@ -26,9 +26,12 @@ import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
import {
getProjectActionsState,
+ getProjectSetup,
type OpenChamberProjectAction,
+ type ProjectSetup,
type ProjectRef,
} from '@/lib/openchamberConfig';
+import { ensureSharedSetupTrusted } from '@/lib/sharedTrustConfirmation';
import {
normalizeProjectActionDirectory,
PROJECT_ACTION_ICONS,
@@ -144,6 +147,8 @@ export const ProjectActionsButton = ({
const captureStartedActionMutationRevisions = useTerminalStore((state) => state.captureStartedActionMutationRevisions);
const [actions, setActions] = React.useState([]);
+ // The last merged setup, for the trust check before a shared action runs.
+ const setupRef = React.useRef(null);
const [selectedActionId, setSelectedActionId] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(false);
const urlWatchByRunKeyRef = React.useRef>({});
@@ -184,11 +189,12 @@ export const ProjectActionsButton = ({
setIsLoading(true);
try {
- const state = await getProjectActionsState(stableProjectRef);
+ const setup = await getProjectSetup(stableProjectRef);
if (loadRequestIdRef.current !== requestId) {
return;
}
- const filtered = state.actions;
+ setupRef.current = setup;
+ const filtered = setup.projectActions;
setActions(filtered);
setSelectedActionId((current) => {
if (current === AUTO_DISCOVER_ACTION_ID) {
@@ -1036,11 +1042,25 @@ export const ProjectActionsButton = ({
void runAction(action);
}, [displayActions, executionDirectoryFor, runAction, projectActionRuns, selectedAction, stopAction]);
+ // A shared action comes from the repo: the first time one would run, the
+ // trust prompt shows the team's commands; "not this time" runs nothing.
+ const runActionWithTrust = React.useCallback(async (action: OpenChamberProjectAction) => {
+ if (action.source === 'shared' && stableProjectRef) {
+ const setup = setupRef.current?.trust.trusted ? setupRef.current : await getProjectSetup(stableProjectRef);
+ setupRef.current = setup;
+ if (!(await ensureSharedSetupTrusted(stableProjectRef, setup))) {
+ return;
+ }
+ setupRef.current = { ...setup, trust: { ...setup.trust, trusted: true } };
+ }
+ await runAction(action);
+ }, [runAction, stableProjectRef]);
+
const handleSelectAction = React.useCallback((action: OpenChamberProjectAction, toggleStopIfRunning = false) => {
setSelectedActionId(action.id);
if (!toggleStopIfRunning) {
- void runAction(action);
+ void runActionWithTrust(action);
return;
}
@@ -1053,8 +1073,8 @@ export const ProjectActionsButton = ({
void stopAction(action);
return;
}
- void runAction(action);
- }, [executionDirectoryFor, runAction, projectActionRuns, stopAction]);
+ void runActionWithTrust(action);
+ }, [executionDirectoryFor, runActionWithTrust, projectActionRuns, stopAction]);
const openProjectActionsSettings = React.useCallback(() => {
if (!stableProjectRef?.id) {
@@ -1174,6 +1194,11 @@ export const ProjectActionsButton = ({
>
{entry.name}
+ {entry.source === 'shared' ? (
+
+ {t('projectActions.menu.sharedBadge')}
+
+ ) : null}
{isStopping || runState?.status === 'waiting-for-preview'
?
: isRunning
@@ -1284,6 +1309,11 @@ export const ProjectActionsButton = ({
>
{entry.name}
+ {entry.source === 'shared' ? (
+
+ {t('projectActions.menu.sharedBadge')}
+
+ ) : null}
{isStopping || runState?.status === 'waiting-for-preview'
?
: isRunning
diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx
index aff0ff7d..36323a3b 100644
--- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx
+++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx
@@ -13,7 +13,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useMultiRunStore } from '@/stores/useMultiRunStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useProjectsStore } from '@/stores/useProjectsStore';
-import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
+import { resolveWorktreeSetupCommands } from '@/lib/sharedTrustConfirmation';
import type { ProjectRef } from '@/lib/openchamberConfig';
import type { CreateMultiRunParams, MultiRunGroup } from '@/types/multirun';
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
@@ -280,7 +280,8 @@ export const MultiRunLauncher: React.FC = ({
setIsLoadingSetupCommands(true);
(async () => {
try {
- const commands = await getWorktreeSetupCommands(projectRef);
+ // The launcher prepares a run: the shared commands ask for trust here, before they are shown as the defaults.
+ const commands = await resolveWorktreeSetupCommands(projectRef);
if (!cancelled) setSetupCommands(commands);
} catch {
// Ignore
diff --git a/packages/ui/src/components/onboarding/ChooserScreen.tsx b/packages/ui/src/components/onboarding/ChooserScreen.tsx
index b769206c..f3372dbd 100644
--- a/packages/ui/src/components/onboarding/ChooserScreen.tsx
+++ b/packages/ui/src/components/onboarding/ChooserScreen.tsx
@@ -3,7 +3,7 @@ import { isDesktopShell, requestFileAccess, startDesktopWindowDrag } from '@/lib
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
-import { updateDesktopSettings } from '@/lib/persistence';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { copyTextToClipboard } from '@/lib/clipboard';
import { restartDesktopApp } from '@/lib/desktop';
import { cn } from '@/lib/utils';
@@ -79,11 +79,9 @@ export function ChooserScreen({ onCliAvailable, localAvailable = true }: Chooser
let cancelled = false;
void (async () => {
try {
- const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
- if (!response.ok) return;
- const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
+ const data = await loadDesktopSettings();
if (!data || cancelled) return;
- const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
+ const value = data.opencodeBinary ?? '';
if (value) setOpencodeBinary(value);
} catch {
// ignore
diff --git a/packages/ui/src/components/onboarding/LocalSetupScreen.tsx b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx
index 3f4edbe1..17821640 100644
--- a/packages/ui/src/components/onboarding/LocalSetupScreen.tsx
+++ b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx
@@ -3,7 +3,7 @@ import { isDesktopShell, requestFileAccess, startDesktopWindowDrag } from '@/lib
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
-import { updateDesktopSettings } from '@/lib/persistence';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { copyTextToClipboard } from '@/lib/clipboard';
import { restartDesktopApp } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -99,11 +99,9 @@ export function LocalSetupScreen({
let cancelled = false;
void (async () => {
try {
- const response = await runtimeFetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
- if (!response.ok) return;
- const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
+ const data = await loadDesktopSettings();
if (!data || cancelled) return;
- const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
+ const value = data.opencodeBinary ?? '';
if (value) {
setOpencodeBinary(value);
}
diff --git a/packages/ui/src/components/projects/SharedTrustConfirmDialog.tsx b/packages/ui/src/components/projects/SharedTrustConfirmDialog.tsx
new file mode 100644
index 00000000..a0fcd586
--- /dev/null
+++ b/packages/ui/src/components/projects/SharedTrustConfirmDialog.tsx
@@ -0,0 +1,91 @@
+import * as React from 'react';
+
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { useI18n } from '@/lib/i18n';
+import {
+ getSharedTrustConfirmationSnapshot,
+ settleSharedTrustConfirmation,
+ subscribeSharedTrustConfirmation,
+ type SharedTrustChoice,
+} from '@/lib/sharedTrustConfirmation';
+
+/**
+ * App-level dialog shown the first time a team's shared setup commands or
+ * shared actions (from `/.openchamber/project.json`) are about to run.
+ * It lists exactly what would run. Dismissing via the close button, Escape,
+ * or the backdrop counts as "run without the shared commands this time".
+ */
+export const SharedTrustConfirmDialog = () => {
+ const { t } = useI18n();
+ const request = React.useSyncExternalStore(
+ subscribeSharedTrustConfirmation,
+ getSharedTrustConfirmationSnapshot,
+ getSharedTrustConfirmationSnapshot,
+ );
+
+ const settle = React.useCallback((choice: SharedTrustChoice) => {
+ settleSharedTrustConfirmation(choice);
+ }, []);
+
+ return (
+ {
+ if (!open) {
+ settle('skip');
+ }
+ }}
+ >
+
+
+ {t('projects.sharedTrust.title')}
+
+ {t('projects.sharedTrust.description', { path: request?.sharedPath ?? '' })}
+
+
+
+ {request && request.setupCommands.length > 0 ? (
+
+
{t('projects.sharedTrust.setupCommands')}
+
+ {request.setupCommands.map((command, index) => (
+
{command}
+ ))}
+
+
+ ) : null}
+ {request && request.actions.length > 0 ? (
+
+
{t('projects.sharedTrust.actions')}
+
+ {request.actions.map((action) => (
+
+ {action.name}
+ {' — '}
+ {action.command}
+
+ ))}
+
+
+ ) : null}
+
+
+ settle('skip')}>
+ {t('projects.sharedTrust.skip')}
+
+ settle('trust')}>
+ {t('projects.sharedTrust.trust')}
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx
index 6738da19..23304e2b 100644
--- a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx
+++ b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useI18n, type I18nKey } from '@/lib/i18n';
-import { reportSettingsSaveState } from '@/lib/persistence';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import {
Select,
@@ -84,24 +84,9 @@ const RESPONSE_STYLE_OPTION_LABEL_KEYS: Record = {
};
const saveBehaviorSetting = async (settings: Partial, fallbackError: string) => {
- reportSettingsSaveState('saving');
- try {
- const response = await runtimeFetch('/api/config/settings', {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json',
- Accept: 'application/json',
- },
- body: JSON.stringify(settings),
- });
-
- if (!response.ok) {
- throw new Error(await readApiError(response, fallbackError));
- }
- reportSettingsSaveState('saved');
- } catch (error) {
- reportSettingsSaveState('error');
- throw error;
+ const result = await updateDesktopSettings(settings);
+ if (!result.ok) {
+ throw new Error(fallbackError);
}
};
@@ -130,12 +115,8 @@ export const BehaviorPage: React.FC = () => {
const load = async () => {
try {
- const [settingsRes, agentsMdRes] = await Promise.all([
- runtimeFetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- signal: abort.signal,
- }),
+ const [data, agentsMdRes] = await Promise.all([
+ loadDesktopSettings(),
runtimeFetch('/api/behavior/agents-md', {
method: 'GET',
headers: { Accept: 'application/json' },
@@ -144,18 +125,15 @@ export const BehaviorPage: React.FC = () => {
]);
let nextSettings: BehaviorSettingsState = DEFAULT_BEHAVIOR_SETTINGS;
- if (settingsRes.ok) {
- const data = await settingsRes.json();
+ if (data) {
nextSettings = {
...nextSettings,
optimizeSystemPrompt: data.optimizeSystemPrompt === true,
responseStyleEnabled: data.responseStyleEnabled === true,
responseStylePreset: sanitizeResponseStylePreset(data.responseStylePreset),
- responseStyleCustomInstructions: typeof data.responseStyleCustomInstructions === 'string'
- ? data.responseStyleCustomInstructions
- : '',
+ responseStyleCustomInstructions: data.responseStyleCustomInstructions ?? '',
};
- if (typeof data.globalBehaviorPrompt === 'string') {
+ if (data.globalBehaviorPrompt !== undefined) {
nextSettings = { ...nextSettings, prompt: data.globalBehaviorPrompt };
}
}
diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
index ca4be95c..ffe37018 100644
--- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx
@@ -14,12 +14,11 @@ import {
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
-import { updateDesktopSettings } from '@/lib/persistence';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
-import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useI18n } from '@/lib/i18n';
import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -79,75 +78,23 @@ export const DefaultsSettings: React.FC = () => {
React.useEffect(() => {
const loadSettings = async () => {
try {
- let data: {
- defaultModel?: string;
- defaultVariant?: string;
- defaultAgent?: string;
- smallModelUseDefault?: boolean;
- smallModelOverride?: string;
- walkthroughModelOverride?: string;
- } | null = null;
-
- if (!data) {
- const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
- if (runtimeSettings) {
- try {
- const result = await runtimeSettings.load();
- const settings = result?.settings;
- if (settings) {
- const raw = settings as Record;
- data = {
- defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined,
- defaultVariant:
- typeof raw.defaultVariant === 'string'
- ? (raw.defaultVariant as string)
- : undefined,
- defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined,
- smallModelUseDefault: typeof raw.smallModelUseDefault === 'boolean' ? raw.smallModelUseDefault : undefined,
- smallModelOverride: typeof raw.smallModelOverride === 'string' ? raw.smallModelOverride : undefined,
- walkthroughModelOverride:
- typeof raw.walkthroughModelOverride === 'string' ? raw.walkthroughModelOverride : undefined,
- };
- }
- } catch {
- // fall through
- }
- }
- }
-
- if (!data) {
- const response = await runtimeFetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
- if (response.ok) {
- data = await response.json();
- }
- }
-
+ const data = await loadDesktopSettings();
if (data) {
- const model =
- typeof data.defaultModel === 'string' && data.defaultModel.trim().length > 0
- ? data.defaultModel.trim()
- : undefined;
- const variant =
- typeof data.defaultVariant === 'string' && data.defaultVariant.trim().length > 0
- ? data.defaultVariant.trim()
- : undefined;
- const agent =
- typeof data.defaultAgent === 'string' && data.defaultAgent.trim().length > 0
- ? data.defaultAgent.trim()
- : undefined;
+ const model = data.defaultModel?.trim() || undefined;
+ const variant = data.defaultVariant?.trim() || undefined;
+ const agent = data.defaultAgent?.trim() || undefined;
if (model !== undefined) setDefaultModel(model);
if (variant !== undefined) setDefaultVariant(variant);
if (agent !== undefined) setDefaultAgent(agent);
- if (typeof data.smallModelUseDefault === 'boolean') setSmallModelUseDefault(data.smallModelUseDefault);
- if (typeof data.smallModelOverride === 'string' && data.smallModelOverride.trim()) {
- setSmallModelOverride(data.smallModelOverride.trim());
+ if (data.smallModelUseDefault !== undefined) setSmallModelUseDefault(data.smallModelUseDefault);
+ const smallOverride = data.smallModelOverride?.trim();
+ if (smallOverride) {
+ setSmallModelOverride(smallOverride);
}
- if (typeof data.walkthroughModelOverride === 'string' && data.walkthroughModelOverride.trim()) {
- setWalkthroughModelOverride(data.walkthroughModelOverride.trim());
+ const walkthroughOverride = data.walkthroughModelOverride?.trim();
+ if (walkthroughOverride) {
+ setWalkthroughModelOverride(walkthroughOverride);
}
}
} catch (error) {
@@ -181,14 +128,6 @@ export const DefaultsSettings: React.FC = () => {
try {
await updateDesktopSettings({ defaultModel: newValue ?? '', defaultVariant: '' });
- const response = await runtimeFetch('/api/config/settings', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ defaultModel: newValue }),
- });
- if (!response.ok) {
- console.warn('Failed to save default model to server:', response.status, response.statusText);
- }
} catch (error) {
console.warn('Failed to save default model:', error);
}
diff --git a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx
index 9eba0d71..d380029a 100644
--- a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx
@@ -1,7 +1,6 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
-import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import {
getDesktopLanAddress,
@@ -16,14 +15,13 @@ import {
setDesktopMinimizeToTray,
} from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
-import { runtimeFetch } from '@/lib/runtime-fetch';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import {
SettingsSection,
SettingsCheckboxRow,
SETTINGS_OPTION_STACK_CLASS,
SettingsStackedField,
- SETTINGS_ICON_BUTTON_CLASS,
} from '@/components/sections/shared/SettingsSection';
export const DesktopNetworkSettings: React.FC = () => {
@@ -34,9 +32,11 @@ export const DesktopNetworkSettings: React.FC = () => {
&& window.__OPENCHAMBER_PLATFORM__ === 'darwin';
const [savedValue, setSavedValue] = React.useState(false);
const [draftValue, setDraftValue] = React.useState(false);
- const [savedPassword, setSavedPassword] = React.useState('');
+ // The password is write-only: the server says whether one is set, and the
+ // page sends a value only when the user types a new one or removes it.
+ const [hasSavedPassword, setHasSavedPassword] = React.useState(false);
const [draftPassword, setDraftPassword] = React.useState('');
- const [showPassword, setShowPassword] = React.useState(false);
+ const [removePassword, setRemovePassword] = React.useState(false);
const [lanAccessActive, setLanAccessActive] = React.useState(false);
const [lanAccessBlockedReason, setLanAccessBlockedReason] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true);
@@ -64,36 +64,23 @@ export const DesktopNetworkSettings: React.FC = () => {
let cancelled = false;
void (async () => {
try {
- const response = await runtimeFetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
- if (!response.ok) {
+ const data = await loadDesktopSettings();
+ if (!data) {
throw new Error(t('settings.openchamber.desktopNetwork.error.loadFailed'));
}
-
- const data = (await response.json().catch(() => null)) as null | {
- desktopLanAccessEnabled?: unknown;
- desktopUiPassword?: unknown;
- desktopLanAccessActive?: unknown;
- desktopLanAccessBlockedReason?: unknown;
- desktopMacMenuBarEnabled?: unknown;
- };
if (cancelled) {
return;
}
- const enabled = data?.desktopLanAccessEnabled === true;
- const password = typeof data?.desktopUiPassword === 'string' ? data.desktopUiPassword : '';
+ const enabled = data.desktopLanAccessEnabled === true;
setSavedValue(enabled);
setDraftValue(enabled);
- setSavedPassword(password);
- setDraftPassword(password);
- setLanAccessActive(data?.desktopLanAccessActive === true);
- setLanAccessBlockedReason(
- typeof data?.desktopLanAccessBlockedReason === 'string' ? data.desktopLanAccessBlockedReason : null
- );
- const macMenuBarEnabled = data?.desktopMacMenuBarEnabled !== false;
+ setHasSavedPassword(data.hasDesktopUiPassword === true);
+ setDraftPassword('');
+ setRemovePassword(false);
+ setLanAccessActive(data.desktopLanAccessActive === true);
+ setLanAccessBlockedReason(data.desktopLanAccessBlockedReason ?? null);
+ const macMenuBarEnabled = data.desktopMacMenuBarEnabled !== false;
setSavedMacMenuBarEnabled(macMenuBarEnabled);
setDraftMacMenuBarEnabled(macMenuBarEnabled);
setError(null);
@@ -196,8 +183,10 @@ export const DesktopNetworkSettings: React.FC = () => {
};
}, [draftValue, isLocalDesktop]);
+ const nextPassword = draftPassword.trim();
+ const passwordDirty = nextPassword.length > 0 || removePassword;
const isDirty = draftValue !== savedValue
- || draftPassword !== savedPassword
+ || passwordDirty
|| draftMacMenuBarEnabled !== savedMacMenuBarEnabled;
const currentPort = React.useMemo(() => {
if (typeof window === 'undefined') {
@@ -215,17 +204,24 @@ export const DesktopNetworkSettings: React.FC = () => {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}, []);
const lanUrl = draftValue && lanAccessActive && lanAddress && currentPort ? `http://${lanAddress}:${currentPort}` : null;
- const lanRequiresPassword = draftValue && !draftPassword.trim();
+ const passwordWillBeSet = nextPassword.length > 0 || (hasSavedPassword && !removePassword);
+ const lanRequiresPassword = draftValue && !passwordWillBeSet;
const lanBlockedByMissingPassword = savedValue && !lanAccessActive && lanAccessBlockedReason === 'missing-password';
const saveDisabled = isLoading || isSaving || !isDirty || lanRequiresPassword;
const handlePasswordChange = React.useCallback((value: string) => {
setDraftPassword(value);
- if (!value.trim()) {
- setDraftValue(false);
+ if (value.trim()) {
+ setRemovePassword(false);
}
}, []);
+ const handleRemovePassword = React.useCallback(() => {
+ setDraftPassword('');
+ setRemovePassword(true);
+ setDraftValue(false);
+ }, []);
+
const handleLaunchAtLoginToggle = React.useCallback(async () => {
if (!launchAtLoginSupported || isSavingLaunchAtLogin) {
return;
@@ -310,25 +306,25 @@ export const DesktopNetworkSettings: React.FC = () => {
setError(null);
try {
- const response = await runtimeFetch('/api/config/settings', {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json',
- Accept: 'application/json',
- },
- body: JSON.stringify({
- desktopLanAccessEnabled: draftValue,
- desktopUiPassword: draftPassword,
- desktopMacMenuBarEnabled: draftMacMenuBarEnabled,
- }),
+ const result = await updateDesktopSettings({
+ desktopLanAccessEnabled: draftValue,
+ // Omitted when unchanged: the server keeps the password it has.
+ ...(nextPassword ? { desktopUiPassword: nextPassword } : removePassword ? { desktopUiPassword: '' } : {}),
+ desktopMacMenuBarEnabled: draftMacMenuBarEnabled,
});
- if (!response.ok) {
+ if (!result.ok) {
throw new Error(t('settings.openchamber.desktopNetwork.error.saveFailed'));
}
setSavedValue(draftValue);
- setSavedPassword(draftPassword);
+ if (nextPassword) {
+ setHasSavedPassword(true);
+ } else if (removePassword) {
+ setHasSavedPassword(false);
+ }
+ setDraftPassword('');
+ setRemovePassword(false);
setSavedMacMenuBarEnabled(draftMacMenuBarEnabled);
const restarted = await restartDesktopApp();
@@ -339,7 +335,7 @@ export const DesktopNetworkSettings: React.FC = () => {
setError(cause instanceof Error ? cause.message : t('settings.openchamber.desktopNetwork.error.saveFailed'));
setIsSaving(false);
}
- }, [draftMacMenuBarEnabled, draftPassword, draftValue, isDirty, t]);
+ }, [draftMacMenuBarEnabled, draftValue, isDirty, nextPassword, removePassword, t]);
if (!isLocalDesktop) {
return null;
@@ -420,26 +416,29 @@ export const DesktopNetworkSettings: React.FC = () => {
>
handlePasswordChange(event.target.value)}
- placeholder={t('settings.openchamber.desktopPassword.field.passwordPlaceholder')}
+ placeholder={t(hasSavedPassword && !removePassword
+ ? 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder'
+ : 'settings.openchamber.desktopPassword.field.passwordPlaceholder')}
disabled={isLoading || isSaving}
- required={draftValue}
+ required={draftValue && !passwordWillBeSet}
aria-invalid={lanRequiresPassword}
/>
- setShowPassword((current: boolean) => !current)}
- className={SETTINGS_ICON_BUTTON_CLASS}
- aria-label={t(showPassword ? 'settings.openchamber.desktopPassword.actions.hidePassword' : 'settings.openchamber.desktopPassword.actions.showPassword')}
- aria-pressed={showPassword}
- >
-
-
+ {hasSavedPassword && !removePassword ? (
+
+ {t('settings.openchamber.desktopPassword.actions.removePassword')}
+
+ ) : null}
diff --git a/packages/ui/src/components/sections/openchamber/GitSettings.tsx b/packages/ui/src/components/sections/openchamber/GitSettings.tsx
index d13740c2..02ca9c63 100644
--- a/packages/ui/src/components/sections/openchamber/GitSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/GitSettings.tsx
@@ -1,11 +1,9 @@
import React from 'react';
-import { updateDesktopSettings } from '@/lib/persistence';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
-import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
-import { runtimeFetch } from '@/lib/runtime-fetch';
import {
SettingsSection,
SettingsControlGroup,
@@ -32,58 +30,16 @@ export const GitSettings: React.FC = () => {
[t]
);
- type GitSettingsPayload = {
- gitmojiEnabled?: boolean;
- gitChangesViewMode?: 'flat' | 'tree';
- };
-
// Load current settings
React.useEffect(() => {
const loadSettings = async () => {
try {
- let data: GitSettingsPayload | null = null;
-
- // 1. Runtime settings API (VSCode)
- if (!data) {
- const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
- if (runtimeSettings) {
- try {
- const result = await runtimeSettings.load();
- const settings = result?.settings;
- if (settings) {
- data = {
- gitmojiEnabled: typeof (settings as Record
).gitmojiEnabled === 'boolean'
- ? ((settings as Record).gitmojiEnabled as boolean)
- : undefined,
- gitChangesViewMode:
- (settings as Record).gitChangesViewMode === 'flat'
- || (settings as Record).gitChangesViewMode === 'tree'
- ? ((settings as Record).gitChangesViewMode as 'flat' | 'tree')
- : undefined,
- };
- }
- } catch {
- // fall through
- }
- }
- }
-
- // 2. Fetch API (Web/server)
- if (!data) {
- const response = await runtimeFetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
- if (response.ok) {
- data = await response.json();
- }
- }
-
+ const data = await loadDesktopSettings();
if (data) {
- if (typeof data.gitmojiEnabled === 'boolean') {
+ if (data.gitmojiEnabled !== undefined) {
setSettingsGitmojiEnabled(data.gitmojiEnabled);
}
- if (data.gitChangesViewMode === 'flat' || data.gitChangesViewMode === 'tree') {
+ if (data.gitChangesViewMode !== undefined) {
setGitChangesViewMode(data.gitChangesViewMode);
}
}
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
index ee835c8b..1641a203 100644
--- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx
@@ -1,5 +1,4 @@
import React from 'react';
-import { runtimeFetch } from '@/lib/runtime-fetch';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme';
@@ -27,7 +26,7 @@ import {
} from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
import { usePwaDetection } from '@/hooks/usePwaDetection';
-import { updateDesktopSettings } from '@/lib/persistence';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
import { useI18n, type Locale } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -850,24 +849,19 @@ export const OpenChamberVisualSettings: React.FC
const loadPwaInstallName = async () => {
try {
- const response = await runtimeFetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- cache: 'no-store',
- });
+ const settings = await loadDesktopSettings();
- if (!response.ok) {
+ if (!settings) {
if (!cancelled) {
setPwaInstallName(DEFAULT_PWA_INSTALL_NAME);
}
return;
}
- const settings = await response.json().catch(() => ({}));
- const raw = typeof settings?.pwaAppName === 'string' ? settings.pwaAppName : '';
+ const raw = settings.pwaAppName ?? '';
const normalized = raw.trim().replace(/\s+/g, ' ').slice(0, 64);
- const orientation = normalizePwaOrientation(settings?.pwaOrientation);
- const nextMobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode);
+ const orientation = normalizePwaOrientation(settings.pwaOrientation);
+ const nextMobileKeyboardMode = normalizeMobileKeyboardMode(settings.mobileKeyboardMode);
if (!cancelled) {
if (showPwaInstallNameSetting) {
diff --git a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx
index cf2d9fb7..01561c7e 100644
--- a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx
@@ -11,11 +11,10 @@ import {
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { isDesktopShell, requestFileAccess } from '@/lib/desktop';
-import { updateDesktopSettings } from '@/lib/persistence';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
-import { runtimeFetch } from '@/lib/runtime-fetch';
import { isWindowsArm64 } from '@/lib/platform';
import { toast } from '@/components/ui';
@@ -31,19 +30,11 @@ export const OpenCodeCliSettings: React.FC = () => {
let cancelled = false;
void (async () => {
try {
- const response = await runtimeFetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
- if (!response.ok) {
- return;
- }
- const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
+ const data = await loadDesktopSettings();
if (cancelled || !data) {
return;
}
- const next = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
- setValue(next);
+ setValue(data.opencodeBinary ?? '');
} catch {
// ignore
} finally {
diff --git a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx
index 7294a1f4..7193d0dc 100644
--- a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx
@@ -8,8 +8,8 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
-import { requestFileAccess } from '@/lib/desktop';
-import { updateDesktopSettings } from '@/lib/persistence';
+import { requestFileAccess, type DesktopSettings } from '@/lib/desktop';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
@@ -524,37 +524,27 @@ export const TunnelSettings: React.FC = () => {
const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => {
try {
- const [checkRes, statusRes, settingsRes, providersRes] = await Promise.all([
+ const [checkRes, statusRes, loadedSettings, providersRes] = await Promise.all([
runtimeFetch('/api/openchamber/tunnel/check', { signal }),
runtimeFetch('/api/openchamber/tunnel/status', { signal }),
- runtimeFetch('/api/config/settings', { signal, headers: { Accept: 'application/json' } }),
+ loadDesktopSettings(),
runtimeFetch('/api/openchamber/tunnel/providers', { signal }),
]);
const checkData = (await checkRes.json()) as TunnelCheckResponse;
const statusData = (await statusRes.json()) as TunnelStatusResponse;
- const settingsData = settingsRes.ok ? await settingsRes.json() : {};
+ const settingsData: DesktopSettings = loadedSettings ?? {};
const providersData = providersRes.ok ? await providersRes.json() : {};
const loadedBootstrapTtl = statusData.ttlConfig?.bootstrapTtlMs
- ?? (settingsData?.tunnelBootstrapTtlMs === null
- ? null
- : typeof settingsData?.tunnelBootstrapTtlMs === 'number'
- ? settingsData.tunnelBootstrapTtlMs
- : 30 * 60 * 1000);
+ ?? (settingsData.tunnelBootstrapTtlMs === undefined ? 30 * 60 * 1000 : settingsData.tunnelBootstrapTtlMs);
const loadedSessionTtl = typeof statusData.ttlConfig?.sessionTtlMs === 'number'
? statusData.ttlConfig.sessionTtlMs
- : typeof settingsData?.tunnelSessionTtlMs === 'number'
- ? settingsData.tunnelSessionTtlMs
- : 8 * 60 * 60 * 1000;
+ : settingsData.tunnelSessionTtlMs ?? 8 * 60 * 60 * 1000;
- const loadedMode: TunnelMode = toUiTunnelMode(statusData.mode ?? settingsData?.tunnelMode);
- const loadedProvider = typeof settingsData?.tunnelProvider === 'string' && settingsData.tunnelProvider.trim().length > 0
- ? settingsData.tunnelProvider.trim().toLowerCase()
- : 'cloudflare';
- const loadedManagedLocalConfigPath = typeof settingsData?.managedLocalTunnelConfigPath === 'string'
- ? settingsData.managedLocalTunnelConfigPath.trim() || null
- : null;
+ const loadedMode: TunnelMode = toUiTunnelMode(statusData.mode ?? settingsData.tunnelMode);
+ const loadedProvider = settingsData.tunnelProvider ?? 'cloudflare';
+ const loadedManagedLocalConfigPath = settingsData.managedLocalTunnelConfigPath ?? null;
const dependencyAvailable = applyDependencyCheck(checkData, loadedProvider);
const loadedPresetsFromStatus = sanitizePresets(statusData?.managedRemoteTunnelPresets);
diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
index 7370afaa..5456e578 100644
--- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
+++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx
@@ -14,11 +14,13 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useDeviceInfo } from '@/lib/device';
import { checkIsGitRepository } from '@/lib/gitApi';
import {
- getWorktreeSetupCommands,
- getWorktreeSetupWaitEnabled,
+ getProjectSetup,
saveWorktreeSetupCommands,
saveWorktreeSetupWaitEnabled,
+ updateProjectSetup,
+ updateSharedProjectSetup,
} from '@/lib/openchamberConfig';
+import { resetSharedSetupTrust } from '@/lib/sharedTrustConfirmation';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { sessionEvents } from '@/lib/sessionEvents';
import type { WorktreeMetadata } from '@/types/worktree';
@@ -54,6 +56,15 @@ export const WorktreeSectionContent: React.FC = ({
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const [setupCommands, setSetupCommands] = React.useState([]);
+ const [sharedSetupCommands, setSharedSetupCommands] = React.useState([]);
+ const [sharedConfigPath, setSharedConfigPath] = React.useState('');
+ const [replaceSharedCommands, setReplaceSharedCommands] = React.useState(false);
+ // The trust answer covers the repository's setup commands and actions; it is
+ // shown here, next to the commands it is mostly about.
+ const [sharedTrusted, setSharedTrusted] = React.useState(false);
+ const [isResettingTrust, setIsResettingTrust] = React.useState(false);
+ const [isSharing, setIsSharing] = React.useState(false);
+ const [reloadCounter, setReloadCounter] = React.useState(0);
const [waitForSetupCommands, setWaitForSetupCommands] = React.useState(false);
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
const [commandsSnapshot, setCommandsSnapshot] = React.useState(null);
@@ -148,19 +159,24 @@ export const WorktreeSectionContent: React.FC = ({
(async () => {
try {
- const [commands, waitForSetup] = await Promise.all([
- getWorktreeSetupCommands(projectRef),
- getWorktreeSetupWaitEnabled(projectRef),
- ]);
+ // The page edits the user's own commands; the team's shared commands
+ // come from the repo, run first, and are never copied into the personal file.
+ const setup = await getProjectSetup(projectRef);
if (!cancelled) {
+ const commands = setup.personal.setupWorktree;
const nextCommands = commands.length > 0 ? commands : [''];
setSetupCommands(nextCommands);
+ setSharedSetupCommands(setup.shared.setupWorktree);
+ setSharedConfigPath(setup.shared.path);
+ setReplaceSharedCommands(setup.personal.setupWorktreeMode === 'replace');
+ setSharedTrusted(setup.trust.hash !== null && setup.trust.trusted);
setCommandsSnapshot(JSON.stringify(nextCommands));
- setWaitForSetupCommands(waitForSetup);
+ setWaitForSetupCommands(setup.setupWorktreeWait);
}
} catch {
if (!cancelled) {
setSetupCommands(['']);
+ setSharedSetupCommands([]);
setCommandsSnapshot(JSON.stringify(['']));
setWaitForSetupCommands(false);
}
@@ -174,8 +190,71 @@ export const WorktreeSectionContent: React.FC = ({
return () => {
cancelled = true;
};
+ }, [projectRef, reloadCounter]);
+
+ const reload = React.useCallback(() => setReloadCounter((count) => count + 1), []);
+
+ // Sharing moves a command between the two files: into the repo file first,
+ // then out of the personal list; the lists reload from disk afterwards.
+ const shareCommand = React.useCallback(async (index: number) => {
+ if (!projectRef || isSharing) return;
+ const command = setupCommands[index]?.trim();
+ if (!command) return;
+ setIsSharing(true);
+ try {
+ const shared = await updateSharedProjectSetup(projectRef, {
+ setupWorktree: [...sharedSetupCommands.filter((entry) => entry !== command), command],
+ });
+ if (!shared) {
+ toast.error(t('settings.projects.shared.toast.shareFailed'));
+ return;
+ }
+ await saveWorktreeSetupCommands(projectRef, setupCommands.filter((_entry, position) => position !== index));
+ reload();
+ } finally {
+ setIsSharing(false);
+ }
+ }, [isSharing, projectRef, reload, setupCommands, sharedSetupCommands, t]);
+
+ const makeCommandPersonal = React.useCallback(async (command: string) => {
+ if (!projectRef || isSharing) return;
+ setIsSharing(true);
+ try {
+ const shared = await updateSharedProjectSetup(projectRef, {
+ setupWorktree: sharedSetupCommands.filter((entry) => entry !== command),
+ });
+ if (!shared) {
+ toast.error(t('settings.projects.shared.toast.shareFailed'));
+ return;
+ }
+ await saveWorktreeSetupCommands(projectRef, [...setupCommands.filter((entry) => entry.trim().length > 0), command]);
+ reload();
+ } finally {
+ setIsSharing(false);
+ }
+ }, [isSharing, projectRef, reload, setupCommands, sharedSetupCommands, t]);
+
+ const handleResetTrust = React.useCallback(async () => {
+ if (!projectRef) return;
+ setIsResettingTrust(true);
+ try {
+ if (await resetSharedSetupTrust(projectRef)) {
+ setSharedTrusted(false);
+ }
+ } finally {
+ setIsResettingTrust(false);
+ }
}, [projectRef]);
+ const handleReplaceSharedCommandsChange = React.useCallback(async (next: boolean) => {
+ if (!projectRef) return;
+ setReplaceSharedCommands(next);
+ if (!(await updateProjectSetup(projectRef, { setupWorktreeMode: next ? 'replace' : 'append' }))) {
+ toast.error(t('settings.openchamber.worktrees.setup.toast.saveFailed'));
+ setReplaceSharedCommands(!next);
+ }
+ }, [projectRef, t]);
+
const persistSetupCommands = React.useCallback(async (commands: string[]): Promise => {
if (!projectRef) {
return false;
@@ -385,6 +464,45 @@ export const WorktreeSectionContent: React.FC = ({
{t('settings.openchamber.worktrees.setup.loading')}
) : (
+ {sharedSetupCommands.length > 0 ? (
+
+
+ {t('settings.projects.shared.commandsFromRepo', { path: sharedConfigPath })}
+
+ {sharedSetupCommands.map((command, index) => (
+
+ {command}
+
+ {t('settings.projects.shared.badge')}
+
+ void makeCommandPersonal(command)}>
+ {t('settings.projects.shared.actions.makePersonal')}
+
+
+ ))}
+ {sharedTrusted ? (
+
+ {t('settings.projects.shared.trusted')}
+ void handleResetTrust()}>
+ {t('settings.projects.shared.resetTrust')}
+
+
+ ) : null}
+
+ void handleReplaceSharedCommandsChange(next)}
+ ariaLabel={t('settings.projects.shared.replaceModeAria')}
+ />
+
+ {t('settings.projects.shared.replaceMode')}
+
+
+
+ ) : null}
{setupCommands.map((command, index) => (
= ({
placeholder={t('settings.openchamber.worktrees.setup.commandPlaceholder')}
className="h-7 min-w-0 flex-1 font-mono text-xs"
/>
+ {command.trim() ? (
+
void shareCommand(index)}
+ >
+ {t('settings.projects.shared.actions.share')}
+
+ ) : null}
({
useDesktopSshStore: (selector: (state: typeof desktopSshState) => T): T => selector(desktopSshState),
}));
mock.module('@/lib/openchamberConfig', () => ({
- getProjectActionsState: async () => ({
- actions: [{ id: 'build', name: 'Build', command: 'echo build', icon: 'build' }],
- primaryActionId: null,
+ getProjectSetup: async () => ({
+ trust: { hash: 'sha256:abc', trusted: true },
+ setupWorktree: [],
+ setupWorktreeWait: false,
+ projectActions: [{ id: 'build', name: 'Build', command: 'echo build', icon: 'build', source: 'personal' }],
+ projectActionsPrimaryId: null,
+ draftStarters: [],
+ shared: {
+ status: 'ok',
+ path: '.openchamber/project.json',
+ setupWorktree: [],
+ setupWorktreeWait: null,
+ projectActions: [{ id: 'dev', name: 'Team dev', command: 'bun run dev', icon: null }],
+ draftStarters: [],
+ plansDir: null,
+ },
+ personal: {
+ setupWorktree: [],
+ setupWorktreeWait: null,
+ setupWorktreeMode: 'append',
+ projectActions: [{ id: 'build', name: 'Build', command: 'echo build', icon: 'build' }],
+ projectActionsPrimaryId: null,
+ draftStarters: [],
+ hiddenSharedActionIds: [],
+ sharedTrust: { hash: 'sha256:abc', trustedAt: 1 },
+ },
}),
saveProjectActionsState: async () => true,
+ updateProjectSetup: async () => true,
+ updateSharedProjectSetup: async () => null,
}));
const { ProjectActionsSection } = await import('./ProjectActionsSection');
@@ -77,4 +102,24 @@ describe('ProjectActionsSection', () => {
expect(runInTrigger?.textContent).toContain('Current worktree');
expect(runInTrigger?.textContent).not.toContain('__project__');
});
+
+ test('lists the team\'s shared actions read-only, marked as shared, above the editable ones', async () => {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ await Promise.resolve();
+ });
+
+ const text = host.textContent ?? '';
+ expect(text).toContain('Team dev');
+ expect(text).toContain('Stored in the repository (.openchamber/project.json)');
+ // The shared row is not a collapsible editor: no button carries its name.
+ const sharedTrigger = Array.from(host.querySelectorAll('button'))
+ .find((button) => button.textContent?.includes('Team dev'));
+ expect(sharedTrigger).toBe(undefined);
+ expect(text.indexOf('Team dev')).toBeLessThan(text.indexOf('Build'));
+ });
});
diff --git a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx
index 62ef4270..1c16145c 100644
--- a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx
+++ b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx
@@ -25,8 +25,10 @@ import { Icon } from '@/components/icon/Icon';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { isDesktopShell } from '@/lib/desktop';
import {
- getProjectActionsState,
+ getProjectSetup,
saveProjectActionsState,
+ updateProjectSetup,
+ updateSharedProjectSetup,
type OpenChamberProjectAction,
type ProjectRef,
} from '@/lib/openchamberConfig';
@@ -78,6 +80,14 @@ export const ProjectActionsSection: React.FC = ({ pr
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
const [actions, setActions] = React.useState([]);
+ // Read-only here: the team's actions from the repo file, and whether that
+ // file could be read at all (a broken file is shown, never treated as empty).
+ const [sharedActions, setSharedActions] = React.useState([]);
+ const [sharedState, setSharedState] = React.useState<{ path: string; status: 'missing' | 'ok' | 'invalid'; reason?: string } | null>(null);
+ const [hiddenSharedIds, setHiddenSharedIds] = React.useState([]);
+ const [isSharing, setIsSharing] = React.useState(false);
+ const reloadCounterRef = React.useRef(0);
+ const [reloadCounter, setReloadCounter] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(false);
const [initialSnapshot, setInitialSnapshot] = React.useState(null);
const [expandedActions, setExpandedActions] = React.useState>({});
@@ -97,17 +107,25 @@ export const ProjectActionsSection: React.FC = ({ pr
(async () => {
try {
- const state = await getProjectActionsState(projectRef);
+ // The page edits the user's own actions; a teammate's shared actions
+ // are read from the repo and must never be copied into the personal file.
+ const setup = await getProjectSetup(projectRef);
if (cancelled) {
return;
}
- setActions(state.actions);
- setInitialSnapshot(JSON.stringify({ actions: state.actions }));
+ setActions(setup.personal.projectActions);
+ setSharedActions(setup.shared.projectActions);
+ setSharedState({ path: setup.shared.path, status: setup.shared.status, reason: setup.shared.reason });
+ setHiddenSharedIds(setup.personal.hiddenSharedActionIds);
+ setInitialSnapshot(JSON.stringify({ actions: setup.personal.projectActions }));
} catch {
if (cancelled) {
return;
}
setActions([]);
+ setSharedActions([]);
+ setSharedState(null);
+ setHiddenSharedIds([]);
setInitialSnapshot(JSON.stringify({ actions: [] }));
} finally {
if (!cancelled) {
@@ -119,7 +137,79 @@ export const ProjectActionsSection: React.FC = ({ pr
return () => {
cancelled = true;
};
- }, [projectRef]);
+ }, [projectRef, reloadCounter]);
+
+ const reload = React.useCallback(() => {
+ reloadCounterRef.current += 1;
+ setReloadCounter(reloadCounterRef.current);
+ }, []);
+
+ const notifyActionsUpdated = React.useCallback(() => {
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent(PROJECT_ACTIONS_UPDATED_EVENT, { detail: { projectId: projectRef.id } }));
+ }
+ }, [projectRef.id]);
+
+ // Sharing moves an action between the two files: first into the repo file,
+ // then out of the personal one (a failure after the first step leaves the
+ // action visible once, as personal, which the merge resolves). The lists
+ // reload from the server afterwards so both blocks show what is on disk.
+ const shareAction = React.useCallback(async (action: EditableProjectAction) => {
+ if (isSharing) return;
+ setIsSharing(true);
+ try {
+ const shared = await updateSharedProjectSetup(projectRef, {
+ projectActions: [...sharedActions.filter((entry) => entry.id !== action.id), action],
+ });
+ if (!shared) {
+ toast.error(t('settings.projects.shared.toast.shareFailed'));
+ return;
+ }
+ await saveProjectActionsState(projectRef, {
+ actions: actions.filter((entry) => entry.id !== action.id),
+ primaryActionId: null,
+ });
+ reload();
+ notifyActionsUpdated();
+ } finally {
+ setIsSharing(false);
+ }
+ }, [actions, isSharing, notifyActionsUpdated, projectRef, reload, sharedActions, t]);
+
+ const makeActionPersonal = React.useCallback(async (action: OpenChamberProjectAction) => {
+ if (isSharing) return;
+ setIsSharing(true);
+ try {
+ const shared = await updateSharedProjectSetup(projectRef, {
+ projectActions: sharedActions.filter((entry) => entry.id !== action.id),
+ });
+ if (!shared) {
+ toast.error(t('settings.projects.shared.toast.shareFailed'));
+ return;
+ }
+ await saveProjectActionsState(projectRef, {
+ actions: [...actions.filter((entry) => entry.id !== action.id), action],
+ primaryActionId: null,
+ });
+ reload();
+ notifyActionsUpdated();
+ } finally {
+ setIsSharing(false);
+ }
+ }, [actions, isSharing, notifyActionsUpdated, projectRef, reload, sharedActions, t]);
+
+ const setSharedActionHidden = React.useCallback(async (actionId: string, hidden: boolean) => {
+ const next = hidden
+ ? [...hiddenSharedIds.filter((id) => id !== actionId), actionId]
+ : hiddenSharedIds.filter((id) => id !== actionId);
+ setHiddenSharedIds(next);
+ if (!(await updateProjectSetup(projectRef, { hiddenSharedActionIds: next }))) {
+ toast.error(t('settings.projects.actions.toast.saveFailed'));
+ setHiddenSharedIds(hiddenSharedIds);
+ return;
+ }
+ notifyActionsUpdated();
+ }, [hiddenSharedIds, notifyActionsUpdated, projectRef, t]);
const desktopForwardOptions = React.useMemo(() => {
if (!isDesktopShellApp) {
@@ -243,11 +333,44 @@ export const ProjectActionsSection: React.FC = ({ pr
)}
contentClassName="space-y-0"
>
+ {!isLoading && sharedState?.status === 'invalid' ? (
+
+ {t('settings.projects.shared.invalid', { path: sharedState.path, reason: sharedState.reason ?? '' })}
+
+ ) : null}
+ {!isLoading && sharedActions.length > 0 && sharedState ? (
+
+
+ {t('settings.projects.shared.actionsFromRepo', { path: sharedState.path })}
+
+ {sharedActions.map((action) => {
+ const sharedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play';
+ const sharedIconName = PROJECT_ACTION_ICON_MAP[sharedIconKey] || 'play';
+ const hidden = hiddenSharedIds.includes(action.id);
+ return (
+
+
+ {action.name}
+
+ {hidden ? t('settings.projects.shared.hiddenBadge') : t('settings.projects.shared.badge')}
+
+ {action.command}
+ void setSharedActionHidden(action.id, !hidden)}>
+ {hidden ? t('settings.projects.shared.actions.show') : t('settings.projects.shared.actions.hide')}
+
+ void makeActionPersonal(action)}>
+ {t('settings.projects.shared.actions.makePersonal')}
+
+
+ );
+ })}
+
+ ) : null}
{isLoading ? (
{t('settings.projects.actions.state.loading')}
- ) : actions.length === 0 ? (
+ ) : actions.length === 0 && sharedActions.length === 0 ? (
{t('settings.projects.actions.state.empty')}
- ) : (
+ ) : actions.length === 0 ? null : (
{actions.map((action) => {
const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play';
@@ -280,6 +403,19 @@ export const ProjectActionsSection: React.FC
= ({ pr
+ {action.name.trim() && action.command.trim() ? (
+ void shareAction(action)}
+ >
+ {t('settings.projects.shared.actions.share')}
+
+ ) : null}
= ({
{showWorktrees ? : null}
+
);
};
diff --git a/packages/ui/src/components/sections/projects/SharedProjectConfigSection.tsx b/packages/ui/src/components/sections/projects/SharedProjectConfigSection.tsx
new file mode 100644
index 00000000..9cccc80d
--- /dev/null
+++ b/packages/ui/src/components/sections/projects/SharedProjectConfigSection.tsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import { toast } from 'sonner';
+
+import { Input } from '@/components/ui/input';
+import { ProjectSettingsSubsection } from '@/components/sections/projects/ProjectSettingsSubsection';
+import { SettingsFieldRow } from '@/components/sections/shared/SettingsSection';
+import { useI18n } from '@/lib/i18n';
+import {
+ getProjectSetup,
+ updateSharedProjectSetup,
+ type ProjectRef,
+ type ProjectSetup,
+} from '@/lib/openchamberConfig';
+
+type SharedProjectConfigSectionProps = {
+ projectRef: ProjectRef;
+};
+
+/**
+ * The team's shared file for this project: where it is, whether it could be
+ * read, and the shared plans folder. Sharing individual items happens next to the items themselves
+ * (actions, setup commands, starters); this block never creates the file on
+ * its own except when a plans folder is set.
+ */
+export const SharedProjectConfigSection: React.FC
= ({ projectRef }) => {
+ const { t } = useI18n();
+ const [setup, setSetup] = React.useState(null);
+ const [plansDirDraft, setPlansDirDraft] = React.useState('');
+ const [isSaving, setIsSaving] = React.useState(false);
+
+ React.useEffect(() => {
+ let cancelled = false;
+ void getProjectSetup(projectRef).then((next) => {
+ if (cancelled) return;
+ setSetup(next);
+ setPlansDirDraft(next.shared.plansDir ?? '');
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [projectRef]);
+
+ const savePlansDir = React.useCallback(async () => {
+ if (!setup) return;
+ const next = plansDirDraft.trim();
+ if (next === (setup.shared.plansDir ?? '')) return;
+ setIsSaving(true);
+ try {
+ const saved = await updateSharedProjectSetup(projectRef, { plansDir: next || null });
+ if (!saved) {
+ toast.error(t('settings.projects.shared.toast.shareFailed'));
+ setPlansDirDraft(setup.shared.plansDir ?? '');
+ return;
+ }
+ setSetup(saved);
+ setPlansDirDraft(saved.shared.plansDir ?? '');
+ } finally {
+ setIsSaving(false);
+ }
+ }, [plansDirDraft, projectRef, setup, t]);
+
+ if (!setup) {
+ return null;
+ }
+
+ const status = setup.shared.status === 'invalid'
+ ? t('settings.projects.shared.invalid', { path: setup.shared.path, reason: setup.shared.reason ?? '' })
+ : setup.shared.status === 'ok'
+ ? t('settings.projects.shared.status.ok')
+ : t('settings.projects.shared.status.missing');
+
+ return (
+
+
+
+ {setup.shared.path}
+
+ {status}
+
+
+
+
+
+ setPlansDirDraft(event.target.value)}
+ onBlur={() => void savePlansDir()}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.currentTarget.blur();
+ }
+ }}
+ placeholder={t('settings.projects.shared.plansDirPlaceholder')}
+ aria-label={t('settings.projects.shared.plansDirAria')}
+ disabled={isSaving}
+ className="h-8 rounded-md px-3 font-mono text-xs"
+ />
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
index ba792795..00bea76a 100644
--- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
+++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx
@@ -70,7 +70,7 @@ import {
} from '@/lib/desktopHosts';
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
import { getDesktopLanAddress, isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
-import { runtimeFetch } from '@/lib/runtime-fetch';
+import { loadDesktopSettings } from '@/lib/persistence';
import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from '@/lib/runtime-switch';
const randomPort = (): number => {
@@ -354,20 +354,7 @@ const resolvePairingServerUrl = async (): Promise => {
return fallback;
}
- let response: Response;
- try {
- response = await runtimeFetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
- } catch {
- return fallback;
- }
- if (!response.ok) return fallback;
-
- const settings = (await response.json().catch(() => null)) as null | {
- desktopLanAccessActive?: unknown;
- };
+ const settings = await loadDesktopSettings();
if (settings?.desktopLanAccessActive !== true) {
return fallback;
}
diff --git a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx
index e37a81c9..dc1f01f8 100644
--- a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx
+++ b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx
@@ -1,6 +1,5 @@
import React from 'react';
import { toast } from '@/components/ui';
-import { runtimeFetch } from '@/lib/runtime-fetch';
import {
Dialog,
@@ -21,10 +20,9 @@ import {
import { Icon } from "@/components/icon/Icon";
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
-import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { isVSCodeRuntime } from '@/lib/desktop';
-import { updateDesktopSettings } from '@/lib/persistence';
-import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
+import type { SkillCatalogConfig } from '@/lib/desktop';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useI18n } from '@/lib/i18n';
@@ -54,29 +52,6 @@ const guessLabelFromSource = (value: string) => {
type IdentityOption = { id: string; name: string };
-const loadSettings = async (): Promise => {
- try {
- const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
- if (runtimeSettings) {
- const result = await runtimeSettings.load();
- return (result?.settings || {}) as DesktopSettings;
- }
-
- const response = await runtimeFetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
-
- if (!response.ok) {
- return null;
- }
-
- return (await response.json().catch(() => null)) as DesktopSettings | null;
- } catch {
- return null;
- }
-};
-
interface AddCatalogDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -96,7 +71,9 @@ export const AddCatalogDialog: React.FC = ({ open, onOpen
const [source, setSource] = React.useState('');
const [subpath, setSubpath] = React.useState('');
- const [existingCatalogs, setExistingCatalogs] = React.useState([]);
+ // `null` until the current list is known: a failed load must never be
+ // treated as "no catalogs", or adding one would overwrite the others.
+ const [existingCatalogs, setExistingCatalogs] = React.useState(null);
const [scanCount, setScanCount] = React.useState(null);
const [scanOk, setScanOk] = React.useState(false);
@@ -128,10 +105,10 @@ export const AddCatalogDialog: React.FC = ({ open, onOpen
setGitIdentityId(null);
void loadDefaultGitIdentityId();
+ setExistingCatalogs(null);
void (async () => {
- const settings = await loadSettings();
- const catalogs = Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : [];
- setExistingCatalogs(catalogs || []);
+ const settings = await loadDesktopSettings();
+ setExistingCatalogs(settings ? settings.skillCatalogs ?? [] : null);
})();
}, [open, loadDefaultGitIdentityId]);
@@ -139,7 +116,7 @@ export const AddCatalogDialog: React.FC = ({ open, onOpen
const normalizedSource = source.trim();
const normalizedSubpath = subpath.trim();
- return existingCatalogs.some((c) => {
+ return (existingCatalogs ?? []).some((c) => {
const s = (c.source || '').trim();
const sp = (c.subpath || '').trim();
return s === normalizedSource && sp === normalizedSubpath;
@@ -244,10 +221,17 @@ export const AddCatalogDialog: React.FC = ({ open, onOpen
...(gitIdentityId ? { gitIdentityId } : {}),
};
+ if (existingCatalogs === null) {
+ toast.error(t('settings.skills.catalog.add.toast.saveFailed'));
+ return;
+ }
const updated = [...existingCatalogs, next];
try {
- await updateDesktopSettings({ skillCatalogs: updated });
+ const saved = await updateDesktopSettings({ skillCatalogs: updated });
+ if (!saved.ok) {
+ throw new Error(t('settings.skills.catalog.add.toast.saveFailed'));
+ }
setExistingCatalogs(updated);
toast.success(t('settings.skills.catalog.add.toast.catalogAdded'));
await loadCatalog({ refresh: true });
@@ -363,7 +347,7 @@ export const AddCatalogDialog: React.FC = ({ open, onOpen
void handleAdd()}
- disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
+ disabled={!scanOk || isDuplicate || existingCatalogs === null || !label.trim() || !source.trim()}
>
{t('settings.skills.catalog.add.actions.addCatalog')}
diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx
index 40db43a5..e7fdd843 100644
--- a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx
+++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx
@@ -1,6 +1,5 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
-import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -21,9 +20,7 @@ import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { SkillsCatalogItem, SkillsCatalogSource } from '@/lib/api/types';
-import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
-import { updateDesktopSettings } from '@/lib/persistence';
-import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
+import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { AddCatalogDialog } from './AddCatalogDialog';
@@ -102,29 +99,6 @@ const formatRelativeShort = (isoDate: string): { key: RelativeTimeKey; count: nu
return { key: 'common.relative.yearsAgoShort', count: Math.floor(days / 365) };
};
-const loadSettings = async (): Promise => {
- try {
- const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
- if (runtimeSettings) {
- const result = await runtimeSettings.load();
- return (result?.settings || {}) as DesktopSettings;
- }
-
- const response = await runtimeFetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
-
- if (!response.ok) {
- return null;
- }
-
- return (await response.json().catch(() => null)) as DesktopSettings | null;
- } catch {
- return null;
- }
-};
-
const SourceCard: React.FC<{
source: SkillsCatalogSource;
isActive: boolean;
@@ -290,8 +264,11 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo
setIsRemovingCatalog(true);
try {
- const settings = await loadSettings();
- const catalogs = (Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : []) as SkillCatalogConfig[];
+ const settings = await loadDesktopSettings();
+ // A failed load is not an empty list: writing [] here would drop every
+ // other catalog along with the selected one.
+ if (!settings) return;
+ const catalogs = settings.skillCatalogs ?? [];
const updated = catalogs.filter((c) => c.id !== selectedSourceId);
await updateDesktopSettings({ skillCatalogs: updated });
await loadCatalog({ refresh: true });
diff --git a/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx b/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx
index a638032b..2b74920f 100644
--- a/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx
+++ b/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx
@@ -113,6 +113,9 @@ mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ waitForWorktreeBootstr
mock.module('@/lib/openchamberConfig', () => ({
getWorktreeSetupCommands: async () => [],
getWorktreeSetupWaitEnabled: async () => false,
+}))
+mock.module('@/lib/sharedTrustConfirmation', () => ({
+ resolveWorktreeSetupCommands: async () => [],
}));
mock.module('@/lib/worktrees/worktreeStatus', () => ({ getRootBranch: async () => 'main' }));
mock.module('@/lib/git/branchNameGenerator', () => ({ generateBranchSlug: () => 'draft-name' }));
diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx
index 425e9281..1d22fe02 100644
--- a/packages/ui/src/components/session/NewWorktreeDialog.tsx
+++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx
@@ -37,7 +37,8 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager';
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
-import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig';
+import { getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig';
+import { resolveWorktreeSetupCommands } from '@/lib/sharedTrustConfirmation';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import { renderMagicPrompt } from '@/lib/magicPrompts';
@@ -962,7 +963,7 @@ export function NewWorktreeDialog({
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue);
- const setupCommands = await getWorktreeSetupCommands(projectRef);
+ const setupCommands = await resolveWorktreeSetupCommands(projectRef);
const sourceBranch = newBranchState.sourceBranch;
let sourceLabel = '';
diff --git a/packages/ui/src/components/session/project-context/PlansSection.tsx b/packages/ui/src/components/session/project-context/PlansSection.tsx
index d6c7f560..78436452 100644
--- a/packages/ui/src/components/session/project-context/PlansSection.tsx
+++ b/packages/ui/src/components/session/project-context/PlansSection.tsx
@@ -35,6 +35,28 @@ export const PlansSection: React.FC<{
const [deletingPlanId, setDeletingPlanId] = React.useState(null);
const createPlan = useProjectContextStore((state) => state.createPlan);
const removePlan = useProjectContextStore((state) => state.deletePlan);
+ const movePlan = useProjectContextStore((state) => state.movePlan);
+ const [movingPlanId, setMovingPlanId] = React.useState(null);
+
+ // A plan changes id when it moves between the two folders; the list reloads
+ // from the returned context, so nothing here tracks the new id.
+ const handleMovePlan = React.useCallback(
+ async (plan: ProjectPlanLink) => {
+ if (movingPlanId) return;
+ const direction = plan.source === 'shared' ? 'unshare' : 'share';
+ setMovingPlanId(plan.id);
+ try {
+ const ok = await movePlan(projectRef, plan.id, direction);
+ if (!ok) {
+ const detail = useProjectContextStore.getState().getEntry(projectRef).error;
+ toast.error(t('rightSidebar.contextNotesTodo.toast.movePlanFailed'), detail ? { description: detail } : undefined);
+ }
+ } finally {
+ setMovingPlanId(null);
+ }
+ },
+ [movePlan, movingPlanId, projectRef, t],
+ );
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
@@ -219,11 +241,32 @@ export const PlansSection: React.FC<{
onClick={() => handleOpenPlan(plan)}
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
- {plan.title}
+
+ {plan.title}
+ {plan.source === 'shared' ? (
+
+ {t('rightSidebar.contextNotesTodo.plans.sharedBadge')}
+
+ ) : null}
+
{new Date(plan.createdAt).toLocaleDateString(getCurrentIntlLocale())}
+ void handleMovePlan(plan)}
+ disabled={movingPlanId === plan.id}
+ className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
+ title={plan.source === 'shared'
+ ? t('rightSidebar.contextNotesTodo.plans.makePersonal')
+ : t('rightSidebar.contextNotesTodo.plans.share')}
+ aria-label={plan.source === 'shared'
+ ? t('rightSidebar.contextNotesTodo.plans.makePersonal')
+ : t('rightSidebar.contextNotesTodo.plans.share')}
+ >
+
+
void handleTogglePinned(plan.id, !pinnedPlanIds.has(plan.id))}
diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx
index 1f8df59a..a2bece4f 100644
--- a/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx
+++ b/packages/ui/src/components/views/agent-manager/AgentManagerEmptyState.tsx
@@ -13,7 +13,7 @@ import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo }
import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete';
import { Icon } from "@/components/icon/Icon";
import { isIMECompositionEvent } from '@/lib/ime';
-import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
+import { resolveWorktreeSetupCommands } from '@/lib/sharedTrustConfirmation';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { ProjectRef } from '@/lib/openchamberConfig';
@@ -113,7 +113,8 @@ export const AgentManagerEmptyState: React.FC = ({
(async () => {
try {
- const commands = await getWorktreeSetupCommands(projectRef);
+ // This screen prepares a run: the shared commands ask for trust here, before they are shown as the defaults.
+ const commands = await resolveWorktreeSetupCommands(projectRef);
if (!cancelled) {
setSetupCommands(commands);
}
diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx
index c417fc24..aacd8add 100644
--- a/packages/ui/src/contexts/ThemeSystemContext.tsx
+++ b/packages/ui/src/contexts/ThemeSystemContext.tsx
@@ -160,6 +160,12 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
const isDesktopShell = useMemo(() => detectDesktopShell(), []);
const customThemesRequestRef = useRef(0);
+ // Set only by the handlers a person reaches through the UI. The persist
+ // effect below writes to the server only while this is raised, so a mount,
+ // a runtime switch, an OS light/dark flip, or a settings sync adopting
+ // another window's theme never produce a write (see the 2026-08-30 theme
+ // flip-flop: a fresh client used to PUT its default theme on load).
+ const themeWriteIntentRef = useRef(false);
const receivesParentThemeSync = useMemo(() => {
if (typeof window === 'undefined') {
return false;
@@ -556,12 +562,10 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
}, [applyIncomingThemeSync]);
useEffect(() => {
- if (receivesParentThemeSync) {
+ if (receivesParentThemeSync || !themeWriteIntentRef.current) {
return;
}
-
- const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
- const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
+ themeWriteIntentRef.current = false;
void updateDesktopSettings({
themeId: currentTheme.metadata.id,
@@ -569,22 +573,27 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
useSystemTheme: preferences.themeMode === 'system',
lightThemeId: preferences.lightThemeId,
darkThemeId: preferences.darkThemeId,
- splashBgLight: lightTheme.colors.surface.background,
- splashFgLight: lightTheme.colors.surface.foreground,
- splashBgDark: darkTheme.colors.surface.background,
- splashFgDark: darkTheme.colors.surface.foreground,
});
- }, [currentTheme.metadata.id, currentTheme.metadata.variant, ensureThemeById, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId, receivesParentThemeSync]);
+ }, [currentTheme.metadata.id, currentTheme.metadata.variant, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId, receivesParentThemeSync]);
useEffect(() => {
if (receivesParentThemeSync || !isDesktopShell) {
return;
}
+ // The shell paints the next startup splash from these; they are this
+ // install's cosmetics, so they go to main directly, not to the server.
+ const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
+ const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
void (async () => {
- await setDesktopWindowTheme(preferences.themeMode, currentTheme.metadata.variant);
+ await setDesktopWindowTheme(preferences.themeMode, currentTheme.metadata.variant, {
+ bgLight: lightTheme.colors.surface.background,
+ fgLight: lightTheme.colors.surface.foreground,
+ bgDark: darkTheme.colors.surface.background,
+ fgDark: darkTheme.colors.surface.foreground,
+ });
})();
- }, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode, receivesParentThemeSync]);
+ }, [currentTheme.metadata.variant, ensureThemeById, isDesktopShell, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId, receivesParentThemeSync]);
useEffect(() => {
if (typeof window === 'undefined' || receivesParentThemeSync) {
@@ -617,6 +626,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
if (prev.darkThemeId === theme.metadata.id && prev.themeMode === 'dark') {
return prev;
}
+ themeWriteIntentRef.current = true;
return {
...prev,
darkThemeId: theme.metadata.id,
@@ -628,6 +638,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return prev;
}
+ themeWriteIntentRef.current = true;
return {
...prev,
lightThemeId: theme.metadata.id,
@@ -643,18 +654,12 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return;
}
+ themeWriteIntentRef.current = true;
setPreferences((prev) => ({
...prev,
themeMode: mode,
}));
-
- if (!receivesParentThemeSync) {
- void updateDesktopSettings({
- themeVariant: mode === 'system' ? currentTheme.metadata.variant : mode,
- useSystemTheme: mode === 'system',
- });
- }
- }, [currentTheme.metadata.variant, preferences.themeMode, receivesParentThemeSync]);
+ }, [preferences.themeMode]);
const setSystemPreferenceHandler = useCallback(
(use: boolean) => {
@@ -663,6 +668,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
if (prev.themeMode === 'system') {
return prev;
}
+ themeWriteIntentRef.current = true;
return {
...prev,
themeMode: 'system',
@@ -677,6 +683,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
if (prev.themeMode === fallbackMode) {
return prev;
}
+ themeWriteIntentRef.current = true;
return {
...prev,
themeMode: fallbackMode,
@@ -700,6 +707,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
if (prev.lightThemeId === theme.metadata.id) {
return prev;
}
+ themeWriteIntentRef.current = true;
return {
...prev,
lightThemeId: theme.metadata.id,
@@ -723,6 +731,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
if (prev.darkThemeId === theme.metadata.id) {
return prev;
}
+ themeWriteIntentRef.current = true;
return {
...prev,
darkThemeId: theme.metadata.id,
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts
index dc0a40a9..0fa31382 100644
--- a/packages/ui/src/lib/api/types.ts
+++ b/packages/ui/src/lib/api/types.ts
@@ -1,6 +1,5 @@
import type { WorktreeMetadata } from '@/types/worktree';
-import type { DraftStarterRef } from '@/lib/draftStarters';
-import type { InputHistoryScope } from '@/lib/inputHistoryScope';
+import type { DesktopSettings } from '@/lib/settings/registry';
type RuntimePlatform = 'web' | 'desktop' | 'vscode';
@@ -700,82 +699,11 @@ export interface ProjectEntry {
sidebarCollapsed?: boolean;
}
-export interface SettingsPayload {
- workStatusPanelEnabled?: boolean;
- workStatusHiddenSections?: string[];
- workStatusHiddenSectionsExplicit?: boolean;
- themeId?: string;
- useSystemTheme?: boolean;
- themeVariant?: 'light' | 'dark';
- lightThemeId?: string;
- darkThemeId?: string;
- lastDirectory?: string;
- homeDirectory?: string;
- opencodeBinary?: string;
- projects?: ProjectEntry[];
- activeProjectId?: string;
- sidebarProjectDisplayMode?: 'all' | 'single';
- sidebarSessionGroupingMode?: 'by-worktree' | 'flat';
- sidebarProjectSortOrder?: 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent';
- sidebarShowRecentSection?: boolean;
- securityScopedBookmarks?: string[];
- pinnedDirectories?: string[];
- showReasoningTraces?: boolean;
- collapsibleThinkingBlocks?: boolean;
- showDeletionDialog?: boolean;
- nativeNotificationsEnabled?: boolean;
- notificationMode?: 'always' | 'hidden-only';
- autoDeleteEnabled?: boolean;
- autoSaveEnabled?: boolean;
- autoDeleteAfterDays?: number;
- sessionRetentionAction?: 'archive' | 'delete';
- followUpBehavior?: 'steer' | 'queue';
- queueModeEnabled?: boolean;
- inputHistoryScope?: InputHistoryScope;
- inputHistoryLimit?: number;
- gitmojiEnabled?: boolean;
- inputSpellcheckEnabled?: boolean;
- enterToSend?: boolean;
- enterToSendConfigured?: boolean;
- showOpenCodeUpdateNotifications?: boolean;
- openCodeUpdateToastDismissedVersion?: string;
- showToolFileIcons?: boolean;
- codeBlockLineWrap?: boolean;
- showTurnChangedFiles?: boolean;
- showExpandedBashTools?: boolean;
- showExpandedEditTools?: boolean;
- chatRenderMode?: 'sorted' | 'live';
- messageStreamTransport?: 'auto' | 'ws' | 'sse';
- activityRenderMode?: 'collapsed' | 'summary';
- mermaidRenderingMode?: 'svg' | 'ascii';
- showSplitAssistantMessageActions?: boolean;
- fontSize?: number;
- terminalFontSize?: number;
- terminalShell?: TerminalShell;
- terminalLoginShells?: TerminalShell[];
- editorFontSize?: number;
- uiFont?: string;
- monoFont?: string;
- padding?: number;
- cornerRadius?: number;
- inputBarOffset?: number;
- shortcutOverrides?: Record;
- diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
- gitChangesViewMode?: 'flat' | 'tree';
- toolJsonViewMode?: 'summary' | 'formatted' | 'raw';
- directoryShowHidden?: boolean;
- filesViewShowGitignored?: boolean;
- openInAppId?: string;
- gitProviderId?: string;
- gitModelId?: string;
- pwaAppName?: string;
- mobileKeyboardMode?: 'native' | 'resize-content';
- draftStarters?: DraftStarterRef[];
- draftStartersVisible?: boolean;
- draftStartersCraftGoalAdded?: boolean;
-
- [key: string]: unknown;
-}
+/**
+ * The settings document on the wire. Defined once in the settings registry;
+ * this alias keeps the runtime `SettingsAPI` contract readable.
+ */
+export type SettingsPayload = DesktopSettings;
export interface SettingsLoadResult {
settings: SettingsPayload;
diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts
index 4418f328..a18d800e 100644
--- a/packages/ui/src/lib/appearanceAutoSave.ts
+++ b/packages/ui/src/lib/appearanceAutoSave.ts
@@ -1,282 +1,47 @@
import { useUIStore } from '@/stores/useUIStore';
-import { updateDesktopSettings } from '@/lib/persistence';
+import { isApplyingServerSettings, updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings } from '@/lib/desktop';
-import type { MonoFontOption, UiFontOption } from '@/lib/fontOptions';
-import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
-import type { TerminalShell } from '@/lib/api/types';
-
-type AppearanceSlice = {
- showReasoningTraces: boolean;
- streamingAutoFollowEnabled: boolean;
- workStatusPanelEnabled: boolean;
- workStatusHiddenSections: string[];
- workStatusHiddenSectionsExplicit: boolean;
- sessionRecapEnabled: boolean;
- sessionSuggestionEnabled: boolean;
- sessionGoalEnabled: boolean;
- sessionGoalDefaultBudgetEnabled: boolean;
- sessionGoalDefaultBudget: number;
- collapsibleThinkingBlocks: boolean;
- showDeletionDialog: boolean;
- nativeNotificationsEnabled: boolean;
- notificationMode: 'always' | 'hidden-only';
- notifyOnSubtasks: boolean;
- notifyOnCompletion: boolean;
- notifyOnError: boolean;
- notifyOnQuestion: boolean;
- notificationTemplates: {
- completion: { title: string; message: string };
- error: { title: string; message: string };
- question: { title: string; message: string };
- subtask: { title: string; message: string };
- };
- summarizeLastMessage: boolean;
- summaryThreshold: number;
- summaryLength: number;
- maxLastMessageLength: number;
- autoDeleteEnabled: boolean;
- autoSaveEnabled: boolean;
- autoDeleteAfterDays: number;
- sessionRetentionAction: 'archive' | 'delete';
- fontSize: number;
- terminalFontSize: number;
- terminalShell: TerminalShell;
- terminalLoginShells: TerminalShell[];
- editorFontSize: number;
- uiFont: UiFontOption;
- monoFont: MonoFontOption;
- padding: number;
- cornerRadius: number;
- inputBarOffset: number;
- mobileKeyboardMode: MobileKeyboardMode;
- diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
- gitChangesViewMode: 'flat' | 'tree';
- toolJsonViewMode: 'summary' | 'formatted' | 'raw';
-};
+import { AUTO_SAVE_KEYS, readAutoSaveSnapshot } from '@/lib/settings/registry';
let initialized = false;
+type SettingsValue = DesktopSettings[keyof DesktopSettings];
+
+const isSameValue = (left: SettingsValue, right: SettingsValue): boolean => {
+ if (left === right) return true;
+ if (left === undefined || right === undefined) return false;
+ return JSON.stringify(left) === JSON.stringify(right);
+};
+
+/**
+ * Mirrors user changes of the registry's auto-saved fields (`ui.autoSave`)
+ * from `useUIStore` to the server. Which fields take part is decided in the
+ * registry, not here; values the settings sync just copied in from the server
+ * become the new baseline instead of a write.
+ */
export const startAppearanceAutoSave = (): void => {
- if (initialized || typeof window === 'undefined') {
+ if (initialized || globalThis.window === undefined) {
return;
}
initialized = true;
- let previous: AppearanceSlice = {
- showReasoningTraces: useUIStore.getState().showReasoningTraces,
- streamingAutoFollowEnabled: useUIStore.getState().streamingAutoFollowEnabled,
- workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled,
- workStatusHiddenSections: useUIStore.getState().workStatusHiddenSections,
- workStatusHiddenSectionsExplicit: useUIStore.getState().workStatusHiddenSectionsExplicit,
- sessionRecapEnabled: useUIStore.getState().sessionRecapEnabled,
- sessionSuggestionEnabled: useUIStore.getState().sessionSuggestionEnabled,
- sessionGoalEnabled: useUIStore.getState().sessionGoalEnabled,
- sessionGoalDefaultBudgetEnabled: useUIStore.getState().sessionGoalDefaultBudgetEnabled,
- sessionGoalDefaultBudget: useUIStore.getState().sessionGoalDefaultBudget,
- collapsibleThinkingBlocks: useUIStore.getState().collapsibleThinkingBlocks,
- showDeletionDialog: useUIStore.getState().showDeletionDialog,
- nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
- notificationMode: useUIStore.getState().notificationMode,
- notifyOnSubtasks: useUIStore.getState().notifyOnSubtasks,
- notifyOnCompletion: useUIStore.getState().notifyOnCompletion,
- notifyOnError: useUIStore.getState().notifyOnError,
- notifyOnQuestion: useUIStore.getState().notifyOnQuestion,
- notificationTemplates: useUIStore.getState().notificationTemplates,
- summarizeLastMessage: useUIStore.getState().summarizeLastMessage,
- summaryThreshold: useUIStore.getState().summaryThreshold,
- summaryLength: useUIStore.getState().summaryLength,
- maxLastMessageLength: useUIStore.getState().maxLastMessageLength,
- autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
- autoSaveEnabled: useUIStore.getState().autoSaveEnabled,
- autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
- sessionRetentionAction: useUIStore.getState().sessionRetentionAction,
- fontSize: useUIStore.getState().fontSize,
- terminalFontSize: useUIStore.getState().terminalFontSize,
- terminalShell: useUIStore.getState().terminalShell,
- terminalLoginShells: useUIStore.getState().terminalLoginShells,
- editorFontSize: useUIStore.getState().editorFontSize,
- uiFont: useUIStore.getState().uiFont,
- monoFont: useUIStore.getState().monoFont,
- padding: useUIStore.getState().padding,
- cornerRadius: useUIStore.getState().cornerRadius,
- inputBarOffset: useUIStore.getState().inputBarOffset,
- mobileKeyboardMode: useUIStore.getState().mobileKeyboardMode,
- diffLayoutPreference: useUIStore.getState().diffLayoutPreference,
- gitChangesViewMode: useUIStore.getState().gitChangesViewMode,
- toolJsonViewMode: useUIStore.getState().toolJsonViewMode,
- };
+ let previous = readAutoSaveSnapshot();
- useUIStore.subscribe((state) => {
- const current: AppearanceSlice = {
- showReasoningTraces: state.showReasoningTraces,
- streamingAutoFollowEnabled: state.streamingAutoFollowEnabled,
- workStatusPanelEnabled: state.workStatusPanelEnabled,
- workStatusHiddenSections: state.workStatusHiddenSections,
- workStatusHiddenSectionsExplicit: state.workStatusHiddenSectionsExplicit,
- sessionRecapEnabled: state.sessionRecapEnabled,
- sessionSuggestionEnabled: state.sessionSuggestionEnabled,
- sessionGoalEnabled: state.sessionGoalEnabled,
- sessionGoalDefaultBudgetEnabled: state.sessionGoalDefaultBudgetEnabled,
- sessionGoalDefaultBudget: state.sessionGoalDefaultBudget,
- collapsibleThinkingBlocks: state.collapsibleThinkingBlocks,
- showDeletionDialog: state.showDeletionDialog,
- nativeNotificationsEnabled: state.nativeNotificationsEnabled,
- notificationMode: state.notificationMode,
- notifyOnSubtasks: state.notifyOnSubtasks,
- notifyOnCompletion: state.notifyOnCompletion,
- notifyOnError: state.notifyOnError,
- notifyOnQuestion: state.notifyOnQuestion,
- notificationTemplates: state.notificationTemplates,
- summarizeLastMessage: state.summarizeLastMessage,
- summaryThreshold: state.summaryThreshold,
- summaryLength: state.summaryLength,
- maxLastMessageLength: state.maxLastMessageLength,
- autoDeleteEnabled: state.autoDeleteEnabled,
- autoSaveEnabled: state.autoSaveEnabled,
- autoDeleteAfterDays: state.autoDeleteAfterDays,
- sessionRetentionAction: state.sessionRetentionAction,
- fontSize: state.fontSize,
- terminalFontSize: state.terminalFontSize,
- terminalShell: state.terminalShell,
- terminalLoginShells: state.terminalLoginShells,
- editorFontSize: state.editorFontSize,
- uiFont: state.uiFont,
- monoFont: state.monoFont,
- padding: state.padding,
- cornerRadius: state.cornerRadius,
- inputBarOffset: state.inputBarOffset,
- mobileKeyboardMode: state.mobileKeyboardMode,
- diffLayoutPreference: state.diffLayoutPreference,
- gitChangesViewMode: state.gitChangesViewMode,
- toolJsonViewMode: state.toolJsonViewMode,
- };
+ useUIStore.subscribe(() => {
+ const current = readAutoSaveSnapshot();
- const diff: Partial = {};
+ if (isApplyingServerSettings()) {
+ previous = current;
+ return;
+ }
- if (current.workStatusPanelEnabled !== previous.workStatusPanelEnabled) {
- diff.workStatusPanelEnabled = current.workStatusPanelEnabled;
- }
- // Compared by content: the store hands back a new array on every change,
- // so an identity check would push a write on unrelated store updates.
- if (current.workStatusHiddenSections.join('\u0000') !== previous.workStatusHiddenSections.join('\u0000')
- || current.workStatusHiddenSectionsExplicit !== previous.workStatusHiddenSectionsExplicit) {
- diff.workStatusHiddenSections = current.workStatusHiddenSections;
- diff.workStatusHiddenSectionsExplicit = current.workStatusHiddenSectionsExplicit;
- }
- if (current.showReasoningTraces !== previous.showReasoningTraces) {
- diff.showReasoningTraces = current.showReasoningTraces;
- }
- if (current.streamingAutoFollowEnabled !== previous.streamingAutoFollowEnabled) {
- diff.streamingAutoFollowEnabled = current.streamingAutoFollowEnabled;
- }
- if (current.sessionRecapEnabled !== previous.sessionRecapEnabled) {
- diff.sessionRecapEnabled = current.sessionRecapEnabled;
- }
- if (current.sessionSuggestionEnabled !== previous.sessionSuggestionEnabled) {
- diff.sessionSuggestionEnabled = current.sessionSuggestionEnabled;
- }
- if (current.sessionGoalEnabled !== previous.sessionGoalEnabled) {
- diff.sessionGoalEnabled = current.sessionGoalEnabled;
- }
- if (current.sessionGoalDefaultBudgetEnabled !== previous.sessionGoalDefaultBudgetEnabled) {
- diff.sessionGoalDefaultBudgetEnabled = current.sessionGoalDefaultBudgetEnabled;
- }
- if (current.sessionGoalDefaultBudget !== previous.sessionGoalDefaultBudget) {
- diff.sessionGoalDefaultBudget = current.sessionGoalDefaultBudget;
- }
- if (current.collapsibleThinkingBlocks !== previous.collapsibleThinkingBlocks) {
- diff.collapsibleThinkingBlocks = current.collapsibleThinkingBlocks;
- }
- if (current.showDeletionDialog !== previous.showDeletionDialog) {
- diff.showDeletionDialog = current.showDeletionDialog;
- }
- if (current.nativeNotificationsEnabled !== previous.nativeNotificationsEnabled) {
- diff.nativeNotificationsEnabled = current.nativeNotificationsEnabled;
- }
- if (current.notificationMode !== previous.notificationMode) {
- diff.notificationMode = current.notificationMode;
- }
- if (current.notifyOnSubtasks !== previous.notifyOnSubtasks) {
- diff.notifyOnSubtasks = current.notifyOnSubtasks;
- }
- if (current.notifyOnCompletion !== previous.notifyOnCompletion) {
- diff.notifyOnCompletion = current.notifyOnCompletion;
- }
- if (current.notifyOnError !== previous.notifyOnError) {
- diff.notifyOnError = current.notifyOnError;
- }
- if (current.notifyOnQuestion !== previous.notifyOnQuestion) {
- diff.notifyOnQuestion = current.notifyOnQuestion;
- }
- if (JSON.stringify(current.notificationTemplates) !== JSON.stringify(previous.notificationTemplates)) {
- diff.notificationTemplates = current.notificationTemplates;
- }
- if (current.summarizeLastMessage !== previous.summarizeLastMessage) {
- diff.summarizeLastMessage = current.summarizeLastMessage;
- }
- if (current.summaryThreshold !== previous.summaryThreshold) {
- diff.summaryThreshold = current.summaryThreshold;
- }
- if (current.summaryLength !== previous.summaryLength) {
- diff.summaryLength = current.summaryLength;
- }
- if (current.maxLastMessageLength !== previous.maxLastMessageLength) {
- diff.maxLastMessageLength = current.maxLastMessageLength;
- }
- if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) {
- diff.autoDeleteEnabled = current.autoDeleteEnabled;
- }
- if (current.autoSaveEnabled !== previous.autoSaveEnabled) {
- diff.autoSaveEnabled = current.autoSaveEnabled;
- }
- if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) {
- diff.autoDeleteAfterDays = current.autoDeleteAfterDays;
- }
- if (current.sessionRetentionAction !== previous.sessionRetentionAction) {
- diff.sessionRetentionAction = current.sessionRetentionAction;
- }
- if (current.fontSize !== previous.fontSize) {
- diff.fontSize = current.fontSize;
- }
- if (current.terminalFontSize !== previous.terminalFontSize) {
- diff.terminalFontSize = current.terminalFontSize;
- }
- if (current.terminalShell !== previous.terminalShell) {
- diff.terminalShell = current.terminalShell;
- }
- if (current.terminalLoginShells !== previous.terminalLoginShells) {
- diff.terminalLoginShells = current.terminalLoginShells;
- }
- if (current.editorFontSize !== previous.editorFontSize) {
- diff.editorFontSize = current.editorFontSize;
- }
- if (current.uiFont !== previous.uiFont) {
- diff.uiFont = current.uiFont;
- }
- if (current.monoFont !== previous.monoFont) {
- diff.monoFont = current.monoFont;
- }
- if (current.padding !== previous.padding) {
- diff.padding = current.padding;
- }
- if (current.cornerRadius !== previous.cornerRadius) {
- diff.cornerRadius = current.cornerRadius;
- }
- if (current.inputBarOffset !== previous.inputBarOffset) {
- diff.inputBarOffset = current.inputBarOffset;
- }
- if (current.mobileKeyboardMode !== previous.mobileKeyboardMode) {
- diff.mobileKeyboardMode = current.mobileKeyboardMode;
- }
- if (current.diffLayoutPreference !== previous.diffLayoutPreference) {
- diff.diffLayoutPreference = current.diffLayoutPreference;
- }
- if (current.gitChangesViewMode !== previous.gitChangesViewMode) {
- diff.gitChangesViewMode = current.gitChangesViewMode;
- }
- if (current.toolJsonViewMode !== previous.toolJsonViewMode) {
- diff.toolJsonViewMode = current.toolJsonViewMode;
+ const diff: DesktopSettings = {};
+ for (const key of AUTO_SAVE_KEYS) {
+ // Reference equality first: unchanged store slices keep their identity,
+ // so the structural compare only runs for the fields that moved.
+ if (isSameValue(current[key], previous[key])) continue;
+ Object.assign(diff, { [key]: current[key] });
}
previous = current;
@@ -285,5 +50,4 @@ export const startAppearanceAutoSave = (): void => {
void updateDesktopSettings(diff);
}
});
-
};
diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts
index bbf81739..ff055591 100644
--- a/packages/ui/src/lib/desktop.ts
+++ b/packages/ui/src/lib/desktop.ts
@@ -1,19 +1,10 @@
import { z } from 'zod';
-import type { ProjectEntry, RuntimeAPIs, TerminalShell } from '@/lib/api/types';
+import type { RuntimeAPIs } from '@/lib/api/types';
import { getInjectedBootOutcome } from '@/lib/desktopBoot';
-import type { DraftStarterRef } from '@/lib/draftStarters';
-import type { InputHistoryScope } from '@/lib/inputHistoryScope';
-import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { isVSCodeBootstrapPresent } from '@/lib/vscodeBootstrap';
-type ManagedRemoteTunnelPreset = {
- id: string;
- name: string;
- hostname: string;
-};
-
export type UpdateInfo = {
available: boolean;
version?: string;
@@ -33,13 +24,7 @@ export type UpdateProgress = {
total?: number;
};
-export type SkillCatalogConfig = {
- id: string;
- label: string;
- source: string;
- subpath?: string;
- gitIdentityId?: string;
-};
+export type { SkillCatalogConfig } from '@/lib/settings/parsers';
export type DesktopWindowControlsPosition = 'left' | 'right';
export type DesktopWindowControlsSide = 'left' | 'right';
@@ -47,199 +32,9 @@ export type DesktopWindowControlAction = 'close' | 'minimize' | 'maximize';
// No fixed-width constant: control width depends on the style (classic vs traffic-lights).
export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights';
-export type DesktopSettings = {
- themeId?: string;
- useSystemTheme?: boolean;
- themeVariant?: 'light' | 'dark';
- lightThemeId?: string;
- darkThemeId?: string;
- splashBgLight?: string;
- splashFgLight?: string;
- splashBgDark?: string;
- splashFgDark?: string;
- lastDirectory?: string;
- homeDirectory?: string;
- // Optional absolute path to `opencode` binary.
- opencodeBinary?: string;
- desktopLanAccessEnabled?: boolean;
- desktopKeepAwakeEnabled?: boolean;
- desktopMinimizeToTrayEnabled?: boolean;
- desktopMacMenuBarEnabled?: boolean;
- desktopUiPassword?: string;
- projects?: ProjectEntry[];
- activeProjectId?: string;
- sidebarProjectDisplayMode?: 'all' | 'single';
- sidebarSessionGroupingMode?: 'by-worktree' | 'flat';
- sidebarProjectSortOrder?: 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent';
- sidebarShowRecentSection?: boolean;
- securityScopedBookmarks?: string[];
- pinnedDirectories?: string[];
- showReasoningTraces?: boolean;
- /** Whether the in-chat work-status panel may render. */
- workStatusPanelEnabled?: boolean;
- /** Work-status panel sections the user switched off. */
- workStatusHiddenSections?: string[];
- /** True when the hidden-section list was explicitly chosen by the user. */
- workStatusHiddenSectionsExplicit?: boolean;
- collapsibleThinkingBlocks?: boolean;
- showDeletionDialog?: boolean;
- nativeNotificationsEnabled?: boolean;
- notificationMode?: 'always' | 'hidden-only';
- notifyOnSubtasks?: boolean;
-
- // Event toggles (which events trigger notifications)
- notifyOnCompletion?: boolean;
- notifyOnError?: boolean;
- notifyOnQuestion?: boolean;
-
- // Per-event notification templates
- notificationTemplates?: {
- completion: { title: string; message: string };
- error: { title: string; message: string };
- question: { title: string; message: string };
- subtask: { title: string; message: string };
- };
-
- // Summarization settings
- summarizeLastMessage?: boolean;
- summaryThreshold?: number;
- summaryLength?: number;
- maxLastMessageLength?: number;
-
- usageDisplayMode?: 'usage' | 'remaining';
- usageDropdownProviders?: string[];
- usageSelectedModels?: Record; // Map of providerId -> selected model names
- usageCollapsedFamilies?: Record; // Map of providerId -> collapsed family IDs (UsagePage)
- usageExpandedFamilies?: Record; // Map of providerId -> EXPANDED family IDs (header dropdown - inverted)
- usageModelGroups?: Record;
- modelAssignments?: Record; // modelName -> groupId
- renamedGroups?: Record; // groupId -> custom label
- }>; // Per-provider custom model groups configuration
- autoDeleteEnabled?: boolean;
- autoSaveEnabled?: boolean;
- autoDeleteAfterDays?: number;
- sessionRetentionAction?: 'archive' | 'delete';
- tunnelProvider?: string;
- tunnelMode?: 'quick' | 'managed-remote' | 'managed-local';
- tunnelBootstrapTtlMs?: number | null;
- tunnelSessionTtlMs?: number;
- managedLocalTunnelConfigPath?: string | null;
- managedRemoteTunnelHostname?: string;
- managedRemoteTunnelToken?: string | null;
- hasManagedRemoteTunnelToken?: boolean;
- managedRemoteTunnelPresets?: ManagedRemoteTunnelPreset[];
- managedRemoteTunnelSelectedPresetId?: string;
- managedRemoteTunnelPresetTokens?: Record;
- defaultModel?: string; // format: "provider/model"
- defaultVariant?: string;
- defaultAgent?: string;
- smallModelUseDefault?: boolean;
- streamingAutoFollowEnabled?: boolean;
- sessionRecapEnabled?: boolean;
- sessionSuggestionEnabled?: boolean;
- sessionGoalEnabled?: boolean;
- sessionGoalDefaultBudgetEnabled?: boolean;
- sessionGoalDefaultBudget?: number;
- smallModelOverride?: string; // format: "provider/model"
- // The walkthrough needs structured output and a roomy context, which the
- // small model is often deliberately not chosen for. Unset means "use the
- // small model"; a value replaces it for this feature only.
- walkthroughModelOverride?: string; // format: "provider/model"
- defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
- openInAppId?: string;
- autoCreateWorktree?: boolean;
- followUpBehavior?: 'steer' | 'queue';
- queueModeEnabled?: boolean;
- gitmojiEnabled?: boolean;
- defaultFileViewerPreview?: boolean;
- zenModel?: string;
- gitProviderId?: string;
- gitModelId?: string;
- pwaAppName?: string;
- pwaOrientation?: 'system' | 'portrait' | 'landscape';
- mobileKeyboardMode?: MobileKeyboardMode;
- desktopWindowControlsPosition?: DesktopWindowControlsPosition;
- desktopWindowControlsStyle?: DesktopWindowControlsStyle;
- inputSpellcheckEnabled?: boolean;
- enterToSend?: boolean;
- enterToSendConfigured?: boolean;
- showOpenCodeUpdateNotifications?: boolean;
- agentControlToolEnabled?: boolean;
- agentWebToolEnabled?: boolean;
- agentMemoryToolEnabled?: boolean;
- agentMemoryFeatureAvailable?: boolean;
- optimizeSystemPrompt?: boolean;
- openCodeUpdateToastDismissedVersion?: string;
- showToolFileIcons?: boolean;
- codeBlockLineWrap?: boolean;
- showTurnChangedFiles?: boolean;
- showExpandedBashTools?: boolean;
- showExpandedEditTools?: boolean;
- timeFormatPreference?: 'auto' | '12h' | '24h';
- weekStartPreference?: 'auto' | 'sunday' | 'monday';
- chatRenderMode?: 'sorted' | 'live';
- messageStreamTransport?: 'auto' | 'ws' | 'sse';
- inputHistoryScope?: InputHistoryScope;
- inputHistoryLimit?: number;
- activityRenderMode?: 'collapsed' | 'summary';
- mermaidRenderingMode?: 'svg' | 'ascii';
- userMessageRenderingMode?: 'markdown' | 'plain';
- collapsibleUserMessages?: boolean;
- stickyUserHeader?: boolean;
- promptNavigatorEnabled?: boolean;
- wideChatLayoutEnabled?: boolean;
- showSplitAssistantMessageActions?: boolean;
- fontSize?: number;
- terminalFontSize?: number;
- terminalShell?: TerminalShell;
- terminalLoginShells?: TerminalShell[];
- editorFontSize?: number;
- uiFont?: string;
- monoFont?: string;
- padding?: number;
- cornerRadius?: number;
- inputBarOffset?: number;
- shortcutOverrides?: Record;
-
- favoriteModels?: Array<{ providerID: string; modelID: string }>;
- hiddenModels?: Array<{ providerID: string; modelID: string }>;
- collapsedModelProviders?: string[];
- recentModels?: Array<{ providerID: string; modelID: string }>;
- recentAgents?: string[];
- recentEfforts?: Record;
- diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
- gitChangesViewMode?: 'flat' | 'tree';
- toolJsonViewMode?: 'summary' | 'formatted' | 'raw';
- directoryShowHidden?: boolean;
- filesViewShowGitignored?: boolean;
-
- // Message limit — controls fetch, trim, and Load More chunk size (default: 200)
- messageLimit?: number;
-
- // User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
- skillCatalogs?: SkillCatalogConfig[];
- // Opt-in to send anonymous usage reports for update checks (default: true)
- reportUsage?: boolean;
-
- // Global behavior prompt — synced to ~/.config/opencode/AGENTS.md
- globalBehaviorPrompt?: string;
- responseStyleEnabled?: boolean;
- responseStylePreset?: 'concise' | 'detailed' | 'mentor' | 'pushback' | 'noFiller' | 'matchEnergy' | 'warmPeer' | 'custom';
- responseStyleCustomInstructions?: string;
- dictationEnabled?: boolean;
- sttProvider?: 'local' | 'openai-compatible';
- sttServerUrl?: string;
- sttModel?: string;
- sttLocalModel?: string;
- sttLanguage?: string;
- // Global draft welcome starters (pinned commands/skills), persisted to settings.json
- draftStarters?: DraftStarterRef[];
- draftStartersVisible?: boolean;
- // One-time migration marker: Craft a Goal was offered in the starter row.
- draftStartersCraftGoalAdded?: boolean;
- draftStartersScheduleTaskAdded?: boolean;
-};
+// The settings document is defined once, in the registry, and re-exported here
+// so the many existing importers keep their path.
+export type { DesktopSettings } from '@/lib/settings/registry';
type DesktopBridgeGlobal = {
invoke?: (cmd: string, args?: Record) => Promise;
diff --git a/packages/ui/src/lib/desktopNative.ts b/packages/ui/src/lib/desktopNative.ts
index 945f64b0..ff887b5e 100644
--- a/packages/ui/src/lib/desktopNative.ts
+++ b/packages/ui/src/lib/desktopNative.ts
@@ -64,16 +64,29 @@ export const setDesktopWindowTitle = async (title: string): Promise => {
}
};
+export type DesktopSplashColors = {
+ bgLight: string;
+ fgLight: string;
+ bgDark: string;
+ fgDark: string;
+};
+
+/**
+ * Tell the shell which theme the window resolved. The splash colours ride
+ * along so main can paint the next startup splash from its own store; they
+ * are device state and never go through the shared settings document.
+ */
export const setDesktopWindowTheme = async (
themeMode?: string,
themeVariant?: string,
+ splash?: DesktopSplashColors,
): Promise => {
if (!isDesktopShell()) {
return;
}
try {
- await invokeDesktopCommand('desktop_set_window_theme', { themeMode, themeVariant });
+ await invokeDesktopCommand('desktop_set_window_theme', { themeMode, themeVariant, splash });
} catch {
// ignore
}
diff --git a/packages/ui/src/lib/directoryShowHidden.ts b/packages/ui/src/lib/directoryShowHidden.ts
index 0282b1b8..5cbef3b9 100644
--- a/packages/ui/src/lib/directoryShowHidden.ts
+++ b/packages/ui/src/lib/directoryShowHidden.ts
@@ -27,6 +27,9 @@ const notifyDirectoryShowHiddenChanged = () => {
window.dispatchEvent(new Event(SHOW_HIDDEN_EVENT));
};
+/** The device's current choice, read the same way the hook reads it. */
+export const getDirectoryShowHidden = (): boolean => readStoredShowHidden();
+
export const setDirectoryShowHidden = (
value: boolean,
options: { persist?: boolean } = {}
diff --git a/packages/ui/src/lib/filesViewShowGitignored.ts b/packages/ui/src/lib/filesViewShowGitignored.ts
index 210e517f..7e070c78 100644
--- a/packages/ui/src/lib/filesViewShowGitignored.ts
+++ b/packages/ui/src/lib/filesViewShowGitignored.ts
@@ -25,6 +25,9 @@ const notifyFilesViewShowGitignoredChanged = () => {
window.dispatchEvent(new Event(SHOW_GITIGNORED_EVENT));
};
+/** The device's current choice, read the same way the hook reads it. */
+export const getFilesViewShowGitignored = (): boolean => readStoredShowGitignored();
+
export const setFilesViewShowGitignored = (
value: boolean,
options: { persist?: boolean } = {}
diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts
index a6f0fc3f..5624b97d 100644
--- a/packages/ui/src/lib/i18n/messages/de.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/de.settings.ts
@@ -438,6 +438,34 @@ export const settingsDict = {
'settings.common.permission.deny': 'Ablehnen',
'settings.common.state.comingSoon': 'Demnächst verfügbar...',
'settings.projects.actions.title': 'Aktionen',
+ 'settings.projects.shared.badge': 'Im Repo',
+ 'settings.projects.shared.actionsFromRepo': 'Im Repository gespeichert ({path}). Alle, die es pullen, bekommen diese.',
+ 'settings.projects.shared.commandsFromRepo': 'Laufen zuerst, im Repository gespeichert ({path})',
+ 'settings.projects.shared.invalid': 'Die Projektkonfiguration in {path} konnte nicht gelesen werden: {reason}',
+ 'settings.projects.shared.trusted': 'Repository-Befehle auf dieser Instanz vertraut',
+ 'settings.projects.shared.resetTrust': 'Vertrauen zurücksetzen',
+ 'settings.projects.shared.title': 'Repository-Konfiguration',
+ 'settings.projects.shared.description': 'Setup, das im Repository selbst liegt, damit alle, die es pullen, dieselben Aktionen, Setup-Befehle, Starter und Pläne bekommen. Es wird nichts geschrieben, bis du etwas dorthin verschiebst.',
+ 'settings.projects.shared.file': 'Datei',
+ 'settings.projects.shared.status.missing': 'Noch nicht im Repository',
+ 'settings.projects.shared.status.ok': 'Im Repository',
+ 'settings.projects.shared.plansDir': 'Ordner für Pläne',
+ 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans',
+ 'settings.projects.shared.plansDirInfo': 'Wo Pläne im Repository liegen, relativ zum Repository. Leer bedeutet .openchamber/plans. Ein eigener Ordner wie docs/plans ersetzt den Standard vollständig: nur dieser Ordner wird gelesen und beschrieben. Vorhandene Dateien verschiebst du beim Wechsel selbst.',
+ 'settings.projects.shared.plansDirAria': 'Ordner für Pläne im Repository',
+ 'settings.projects.shared.actions.share': 'Ins Repository verschieben',
+ 'settings.projects.shared.actions.showTitle': 'Zeigt diese Repository-Aktion wieder in deinem Menü.',
+ 'settings.projects.shared.actions.hideTitle': 'Blendet diese Repository-Aktion nur in deinem Menü aus; das Repository bleibt unverändert.',
+ 'settings.projects.shared.actions.makePersonalTitle': 'Entfernt es aus dem Repository und behält es nur in deinen Einstellungen auf dieser Instanz.',
+ 'settings.projects.shared.actions.shareTitle': 'Speichert es in {path} im Repository, damit alle, die das Repository pullen, es bekommen. Es verlässt deine persönlichen Einstellungen.',
+ 'settings.projects.shared.actions.shareAfterSave': 'Speichert zuerst deine Änderungen, dann verschieben',
+ 'settings.projects.shared.actions.makePersonal': 'In meine Einstellungen verschieben',
+ 'settings.projects.shared.actions.hide': 'Für mich ausblenden',
+ 'settings.projects.shared.actions.show': 'Anzeigen',
+ 'settings.projects.shared.hiddenBadge': 'Ausgeblendet',
+ 'settings.projects.shared.replaceMode': 'Nur meine Setup-Befehle verwenden, die des Repositorys überspringen',
+ 'settings.projects.shared.replaceModeAria': 'Nur meine Setup-Befehle verwenden, die des Repositorys überspringen',
+ 'settings.projects.shared.toast.shareFailed': 'Repository-Konfiguration konnte nicht aktualisiert werden',
'settings.projects.actions.description': 'Projektspezifische Befehle im Header.',
'settings.projects.actions.validation.fillNameAndCommand': 'Bitte Aktionsname und Befehl ausfüllen.',
'settings.projects.actions.state.loading': 'Wird geladen...',
@@ -948,6 +976,8 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN-Zugriff erfordert ein Desktop-UI-Kennwort. Bis ein Kennwort festgelegt ist, startet die Desktop-App nur lokal.',
'settings.openchamber.desktopPassword.field.password': 'Desktop-UI-Kennwort',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Kein Kennwort erforderlich',
+ 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'Kennwort gesetzt. Neues eingeben, um es zu ersetzen.',
+ 'settings.openchamber.desktopPassword.actions.removePassword': 'Kennwort entfernen',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber fragt nach dem Neustart und dann, wenn die Anmeldesitzung abläuft: nach 12 Stunden oder 7 Tagen mit Vertrauen in dieses Gerät. Leer lassen, um die Anmeldung zu deaktivieren.',
'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Nach dem Neustart von einem anderen Gerät aus öffnen: ',
'settings.openchamber.desktopNetwork.hint.openNow': 'Von einem anderen Gerät aus öffnen: ',
@@ -2212,8 +2242,6 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.macMenuBarAria': 'OpenChamber in der macOS-Menüleiste anzeigen',
'settings.openchamber.desktopNetwork.field.macMenuBar': 'OpenChamber in der Menüleiste anzeigen',
'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'Erfordert einen Neustart der App. Wenn deaktiviert, erstellt OpenChamber weder den Menüleisten-Eintrag noch führt es dessen Sitzungs-, Genehmigungs- und Nutzungsaktualisierungen aus.',
- 'settings.openchamber.desktopPassword.actions.showPassword': 'Passwort anzeigen',
- 'settings.openchamber.desktopPassword.actions.hidePassword': 'Passwort verbergen',
'settings.openchamber.defaults.walkthroughModel.title': 'Walkthrough-Modell ändern',
'settings.openchamber.defaults.walkthroughModel.description': 'Die KI-Prüfung deiner Änderungen benötigt strukturierten Output und Platz für einen ganzen Diff, den ein günstiges kleines Modell oft nicht liefern kann. Modelle, die der Katalog als nicht in der Lage zu strukturiertem Output meldet, werden in diesem Auswahlfeld ausgeblendet. Lasse es leer, dann wird das kleine Modell verwendet.',
'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Walkthrough-Modell',
diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts
index afd84100..7fabf938 100644
--- a/packages/ui/src/lib/i18n/messages/de.ts
+++ b/packages/ui/src/lib/i18n/messages/de.ts
@@ -1515,6 +1515,9 @@ export const dict = {
'rightSidebar.contextNotesTodo.plans.importFromFile': 'Plan aus Datei importieren',
'rightSidebar.contextNotesTodo.plans.empty': 'Noch keine gespeicherten Pläne.',
'rightSidebar.contextNotesTodo.plans.deletePlan': 'Plan löschen',
+ 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'Im Repo',
+ 'rightSidebar.contextNotesTodo.plans.share': 'In den Plan-Ordner des Repositorys verschieben, damit alle, die es pullen, ihn sehen',
+ 'rightSidebar.contextNotesTodo.plans.makePersonal': 'Zu meinen Plänen verschieben, aus dem Repository heraus',
'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Lösche Plan "{title}"',
'rightSidebar.contextNotesTodo.sendDialog.title.newSession': 'An neue Sitzung senden',
'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': 'An neuen Worktree senden',
@@ -1533,6 +1536,7 @@ export const dict = {
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Fehler beim Senden des Todos',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Plan konnte nicht aktualisiert werden',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Fehler beim Löschen des Plans',
+ 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'Plan konnte nicht verschoben werden',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Plan-Datei ist leer',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Fehler beim Importieren des Plans',
'rightSidebar.contextNotesTodo.toast.planImported': 'Plan importiert',
@@ -1917,6 +1921,9 @@ export const dict = {
'chat.draftStarters.sectionCommands': 'Befehle',
'chat.draftStarters.sectionSkills': 'Fähigkeiten',
'chat.draftStarters.remove': 'Entfernen',
+ 'chat.draftStarters.sharedTitle': 'In der Repository-Konfiguration angeheftet; dort ändern',
+ 'chat.draftStarters.share': 'In die Repository-Konfiguration verschieben',
+ 'chat.draftStarters.makePersonal': 'In meine Einstellungen verschieben',
'chat.scrollToBottom.aria': 'Zum Ende scrollen',
'chat.promptNavigator.aria': 'Prompt-Navigation',
'chat.promptNavigator.currentPrompt': 'Aktueller Prompt',
@@ -2524,6 +2531,13 @@ export const dict = {
'projectActions.actions.addAction': 'Aktion hinzufügen',
'projectActions.actions.addNewAction': 'Neue Aktion hinzufügen',
'projectActions.actions.autoDiscover': 'Automatisch entdecken',
+ 'projectActions.menu.sharedBadge': 'Repo',
+ 'projects.sharedTrust.title': 'Die im Repository gespeicherten Befehle ausführen?',
+ 'projects.sharedTrust.description': '{path} in diesem Repository definiert Befehle, die auf diesem Rechner laufen. Einmal vertrauen, und OpenChamber fragt erst wieder, wenn sie sich ändern.',
+ 'projects.sharedTrust.setupCommands': 'Worktree-Setup-Befehle',
+ 'projects.sharedTrust.actions': 'Aktionen',
+ 'projects.sharedTrust.skip': 'Diesmal nicht',
+ 'projects.sharedTrust.trust': 'Vertrauen und ausführen',
'projectActions.actions.chooseActionAria': 'Projektaktion auswählen',
'projectActions.actions.openPreview': 'Vorschau öffnen',
'projectActions.actions.runNamedAria': '{name} ausführen',
diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts
index 2b911a70..5d097856 100644
--- a/packages/ui/src/lib/i18n/messages/en.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en.settings.ts
@@ -459,6 +459,34 @@ export const settingsDict = {
'settings.common.permission.deny': 'Deny',
'settings.common.state.comingSoon': 'Coming soon...',
'settings.projects.actions.title': 'Actions',
+ 'settings.projects.shared.badge': 'In repo',
+ 'settings.projects.shared.actionsFromRepo': 'Stored in the repository ({path}). Everyone who pulls it gets these.',
+ 'settings.projects.shared.commandsFromRepo': 'Run first, stored in the repository ({path})',
+ 'settings.projects.shared.invalid': 'The project config in {path} could not be read: {reason}',
+ 'settings.projects.shared.trusted': 'Repository commands trusted on this instance',
+ 'settings.projects.shared.resetTrust': 'Reset trust',
+ 'settings.projects.shared.title': 'Repository config',
+ 'settings.projects.shared.description': 'Setup stored in the repository itself, so everyone who pulls it gets the same actions, setup commands, starters, and plans. Nothing is written there until you move an item into it.',
+ 'settings.projects.shared.file': 'File',
+ 'settings.projects.shared.status.missing': 'Not in the repository yet',
+ 'settings.projects.shared.status.ok': 'In the repository',
+ 'settings.projects.shared.plansDir': 'Plans folder',
+ 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans',
+ 'settings.projects.shared.plansDirInfo': 'Where repository plans live, relative to the repository. Empty means .openchamber/plans. A custom folder such as docs/plans replaces the default entirely: only that folder is read and written. Move existing files yourself when you change it.',
+ 'settings.projects.shared.plansDirAria': 'Repository plans folder',
+ 'settings.projects.shared.actions.share': 'Move to repository',
+ 'settings.projects.shared.actions.showTitle': 'Shows this repository action in your menu again.',
+ 'settings.projects.shared.actions.hideTitle': 'Hides this repository action from your menu only; the repository is not changed.',
+ 'settings.projects.shared.actions.makePersonalTitle': 'Removes it from the repository and keeps it only in your settings on this instance.',
+ 'settings.projects.shared.actions.shareTitle': 'Stores it in {path} inside the repository, so everyone who pulls the repository gets it. It leaves your personal settings.',
+ 'settings.projects.shared.actions.shareAfterSave': 'Saves your edits first, then move',
+ 'settings.projects.shared.actions.makePersonal': 'Move to my settings',
+ 'settings.projects.shared.actions.hide': 'Hide for me',
+ 'settings.projects.shared.actions.show': 'Show',
+ 'settings.projects.shared.hiddenBadge': 'Hidden',
+ 'settings.projects.shared.replaceMode': 'Use only my setup commands, skip the repository\'s',
+ 'settings.projects.shared.replaceModeAria': 'Use only my setup commands, skip the repository\'s',
+ 'settings.projects.shared.toast.shareFailed': 'Failed to update the repository config',
'settings.projects.actions.description': 'Per-project commands shown in header next to project name.',
'settings.projects.actions.validation.fillNameAndCommand': 'Fill action name and command before saving.',
'settings.projects.actions.state.loading': 'Loading...',
@@ -1008,10 +1036,10 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Restarts the app so phones, tablets, and other computers on your Wi-Fi can open it.',
'settings.openchamber.desktopNetwork.field.warning': 'Warning: while enabled, the app is reachable by anyone on the same local network.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN access requires a Desktop UI Password. Until one is set, the desktop app starts local-only.',
- 'settings.openchamber.desktopPassword.actions.showPassword': 'Show password',
- 'settings.openchamber.desktopPassword.actions.hidePassword': 'Hide password',
'settings.openchamber.desktopPassword.field.password': 'Desktop UI Password',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'No password required',
+ 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'Password set. Type a new one to replace it.',
+ 'settings.openchamber.desktopPassword.actions.removePassword': 'Remove password',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber asks after restart, then when the login session expires: after 12 hours, or 7 days with Trust this device. Leave empty to disable login.',
'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'After restart, open from another device: ',
'settings.openchamber.desktopNetwork.hint.openNow': 'Open from another device: ',
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index 05c68d36..e0dac10e 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -1715,6 +1715,9 @@ export const dict = {
'rightSidebar.contextNotesTodo.plans.importFromFile': 'Import plan from file',
'rightSidebar.contextNotesTodo.plans.empty': 'No saved plans yet.',
'rightSidebar.contextNotesTodo.plans.deletePlan': 'Delete plan',
+ 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'In repo',
+ 'rightSidebar.contextNotesTodo.plans.share': 'Move to the repository plans folder, so everyone who pulls the repository sees it',
+ 'rightSidebar.contextNotesTodo.plans.makePersonal': 'Move to my plans, out of the repository',
'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Delete plan "{title}"',
'rightSidebar.contextNotesTodo.sendDialog.title.newSession': 'Send to new session',
'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': 'Send to new worktree',
@@ -1733,6 +1736,7 @@ export const dict = {
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Failed to send todo',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Failed to update plan',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Failed to delete plan',
+ 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'Failed to move plan',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Plan file is empty',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Failed to import plan',
'rightSidebar.contextNotesTodo.toast.planImported': 'Plan imported',
@@ -2122,6 +2126,9 @@ export const dict = {
'chat.draftStarters.sectionCommands': 'Commands',
'chat.draftStarters.sectionSkills': 'Skills',
'chat.draftStarters.remove': 'Remove',
+ 'chat.draftStarters.sharedTitle': 'Pinned in the repository config; change it there',
+ 'chat.draftStarters.share': 'Move to repository config',
+ 'chat.draftStarters.makePersonal': 'Move to my settings',
'chat.scrollToBottom.aria': 'Scroll to bottom',
'chat.promptNavigator.aria': 'Prompt navigation',
'chat.promptNavigator.currentPrompt': 'Current prompt',
@@ -2745,6 +2752,13 @@ export const dict = {
'projectActions.actions.addAction': 'Add action',
'projectActions.actions.addNewAction': 'Add new action',
'projectActions.actions.autoDiscover': 'Auto-discover',
+ 'projectActions.menu.sharedBadge': 'repo',
+ 'projects.sharedTrust.title': 'Run the commands stored in this repository?',
+ 'projects.sharedTrust.description': '{path} in this repository defines commands that run on this machine. Trust them once, and OpenChamber asks again only when they change.',
+ 'projects.sharedTrust.setupCommands': 'Worktree setup commands',
+ 'projects.sharedTrust.actions': 'Actions',
+ 'projects.sharedTrust.skip': 'Not this time',
+ 'projects.sharedTrust.trust': 'Trust and run',
'projectActions.actions.autoDiscoverTooltip': 'Automatically discover and run the development server',
'projectActions.actions.chooseActionAria': 'Choose project action',
'projectActions.actions.openPreview': 'Open Preview',
diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts
index 96d178bb..301df963 100644
--- a/packages/ui/src/lib/i18n/messages/es.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/es.settings.ts
@@ -427,6 +427,34 @@ export const settingsDict = {
"settings.common.permission.deny": "Denegar",
"settings.common.state.comingSoon": "Próximamente...",
"settings.projects.actions.title": "Acciones",
+ "settings.projects.shared.badge": "En el repo",
+ "settings.projects.shared.actionsFromRepo": "Guardadas en el repositorio ({path}). Todos los que lo clonen las tendrán.",
+ "settings.projects.shared.commandsFromRepo": "Se ejecutan primero, guardados en el repositorio ({path})",
+ "settings.projects.shared.invalid": "No se pudo leer la configuración del proyecto en {path}: {reason}",
+ "settings.projects.shared.trusted": "Comandos del repositorio de confianza en esta instancia",
+ "settings.projects.shared.resetTrust": "Restablecer confianza",
+ "settings.projects.shared.title": "Configuración en el repositorio",
+ "settings.projects.shared.description": "Configuración guardada en el propio repositorio, para que todos los que lo clonen tengan las mismas acciones, comandos de configuración, arranques y planes. No se escribe nada hasta que muevas un elemento allí.",
+ "settings.projects.shared.file": "Archivo",
+ "settings.projects.shared.status.missing": "Todavía no está en el repositorio",
+ "settings.projects.shared.status.ok": "En el repositorio",
+ "settings.projects.shared.plansDir": "Carpeta de planes",
+ "settings.projects.shared.plansDirPlaceholder": ".openchamber/plans",
+ "settings.projects.shared.plansDirInfo": "Dónde viven los planes del repositorio, relativo al repositorio. Vacío significa .openchamber/plans. Una carpeta propia como docs/plans reemplaza por completo la predeterminada: solo se lee y escribe esa carpeta. Mueve tú mismo los archivos existentes al cambiarla.",
+ "settings.projects.shared.plansDirAria": "Carpeta de planes en el repositorio",
+ "settings.projects.shared.actions.share": "Mover al repositorio",
+ "settings.projects.shared.actions.showTitle": "Vuelve a mostrar esta acción del repositorio en tu menú.",
+ "settings.projects.shared.actions.hideTitle": "Oculta esta acción del repositorio solo en tu menú; el repositorio no cambia.",
+ "settings.projects.shared.actions.makePersonalTitle": "Lo quita del repositorio y lo conserva solo en tus ajustes de esta instancia.",
+ "settings.projects.shared.actions.shareTitle": "Lo guarda en {path} dentro del repositorio, para que todos los que lo clonen lo tengan. Sale de tus ajustes personales.",
+ "settings.projects.shared.actions.shareAfterSave": "Primero se guardan tus cambios, luego mueve",
+ "settings.projects.shared.actions.makePersonal": "Mover a mis ajustes",
+ "settings.projects.shared.actions.hide": "Ocultar para mí",
+ "settings.projects.shared.actions.show": "Mostrar",
+ "settings.projects.shared.hiddenBadge": "Oculto",
+ "settings.projects.shared.replaceMode": "Usar solo mis comandos de configuración y omitir los del repositorio",
+ "settings.projects.shared.replaceModeAria": "Usar solo mis comandos de configuración y omitir los del repositorio",
+ "settings.projects.shared.toast.shareFailed": "No se pudo actualizar la configuración del repositorio",
"settings.projects.actions.description": "Comandos por proyecto mostrados en el encabezado junto al nombre del proyecto.",
"settings.projects.actions.validation.fillNameAndCommand": "Completa el nombre de la acción y el comando antes de guardar.",
"settings.projects.actions.state.loading": "Cargando...",
@@ -976,10 +1004,10 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Reinicia la aplicación para que los teléfonos, tablets y otros ordenadores en tu Wi-Fi puedan abrirla.",
"settings.openchamber.desktopNetwork.field.warning": "Advertencia: mientras esté habilitado, la aplicación es accesible por cualquiera en la misma red local.",
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "El acceso LAN requiere una contraseña de UI de escritorio. Hasta que se configure, la app de escritorio se inicia solo localmente.",
- "settings.openchamber.desktopPassword.actions.showPassword": "Mostrar contraseña",
- "settings.openchamber.desktopPassword.actions.hidePassword": "Ocultar contraseña",
"settings.openchamber.desktopPassword.field.password": "Contraseña de UI de escritorio",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "No se requiere contraseña",
+ "settings.openchamber.desktopPassword.field.passwordSetPlaceholder": "Contraseña establecida. Escribe una nueva para reemplazarla.",
+ "settings.openchamber.desktopPassword.actions.removePassword": "Quitar contraseña",
"settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber la pide después del reinicio y luego cuando vence la sesión: tras 12 horas, o 7 días con Confiar en este dispositivo. Déjalo vacío para desactivar el inicio de sesión.",
"settings.openchamber.desktopNetwork.hint.openAfterRestart": "Después del reinicio, abre desde otro dispositivo: ",
"settings.openchamber.desktopNetwork.hint.openNow": "Abrir desde otro dispositivo: ",
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index 54ee5067..1132c9c0 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -1693,6 +1693,9 @@ export const dict: Record = {
"rightSidebar.contextNotesTodo.plans.importFromFile": "Importar plan desde archivo",
"rightSidebar.contextNotesTodo.plans.empty": "Aún no hay plans guardados.",
"rightSidebar.contextNotesTodo.plans.deletePlan": "Eliminar plan",
+ "rightSidebar.contextNotesTodo.plans.sharedBadge": "En el repo",
+ "rightSidebar.contextNotesTodo.plans.share": "Mover a la carpeta de planes del repositorio, para que todos los que lo clonen lo vean",
+ "rightSidebar.contextNotesTodo.plans.makePersonal": "Mover a mis planes, fuera del repositorio",
"rightSidebar.contextNotesTodo.plans.deletePlanWithTitle": "Eliminar plan \"{title}\"",
"rightSidebar.contextNotesTodo.sendDialog.title.newSession": "Enviar a una nueva sesión",
"rightSidebar.contextNotesTodo.sendDialog.title.newWorktree": "Enviar a una nueva sesión de worktree",
@@ -1711,6 +1714,7 @@ export const dict: Record = {
"rightSidebar.contextNotesTodo.toast.sendTodoFailed": "No se pudo enviar la tarea",
"rightSidebar.contextNotesTodo.toast.updatePlanFailed": "No se pudo actualizar el plan",
"rightSidebar.contextNotesTodo.toast.deletePlanFailed": "No se pudo eliminar el plan",
+ "rightSidebar.contextNotesTodo.toast.movePlanFailed": "No se pudo mover el plan",
"rightSidebar.contextNotesTodo.toast.planFileEmpty": "El archivo del plan está vacío",
"rightSidebar.contextNotesTodo.toast.importPlanFailed": "No se pudo importar el plan",
"rightSidebar.contextNotesTodo.toast.planImported": "Plan importado",
@@ -2100,6 +2104,9 @@ export const dict: Record = {
"chat.draftStarters.sectionCommands": "Commands",
"chat.draftStarters.sectionSkills": "Skills",
"chat.draftStarters.remove": "Remove",
+ "chat.draftStarters.sharedTitle": "Fijado en la configuración del repositorio; cámbialo allí",
+ "chat.draftStarters.share": "Mover a la configuración del repositorio",
+ "chat.draftStarters.makePersonal": "Mover a mis ajustes",
"chat.scrollToBottom.aria": "Ir al final",
"chat.promptNavigator.aria": "Navegación de prompts",
"chat.promptNavigator.currentPrompt": "Prompt actual",
@@ -2711,6 +2718,13 @@ export const dict: Record = {
"projectActions.actions.addAction": "Añadir acción",
"projectActions.actions.addNewAction": "Añadir nueva acción",
"projectActions.actions.autoDiscover": "Autodetectar",
+ "projectActions.menu.sharedBadge": "repo",
+ "projects.sharedTrust.title": "¿Ejecutar los comandos guardados en este repositorio?",
+ "projects.sharedTrust.description": "{path} en este repositorio define comandos que se ejecutan en esta máquina. Confía una vez y OpenChamber solo volverá a preguntar cuando cambien.",
+ "projects.sharedTrust.setupCommands": "Comandos de configuración del worktree",
+ "projects.sharedTrust.actions": "Acciones",
+ "projects.sharedTrust.skip": "Esta vez no",
+ "projects.sharedTrust.trust": "Confiar y ejecutar",
"projectActions.actions.autoDiscoverTooltip": "Detecta y ejecuta automáticamente el servidor de desarrollo",
"projectActions.actions.chooseActionAria": "Elegir acción del proyecto",
"projectActions.actions.openPreview": "Abrir Preview",
diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts
index 1d4202e5..c72fe186 100644
--- a/packages/ui/src/lib/i18n/messages/fr.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts
@@ -350,6 +350,34 @@ export const settingsDict = {
'settings.common.permission.deny': 'Refuser',
'settings.common.state.comingSoon': 'À venir...',
'settings.projects.actions.title': 'Actions',
+ 'settings.projects.shared.badge': 'Dans le dépôt',
+ 'settings.projects.shared.actionsFromRepo': 'Enregistrées dans le dépôt ({path}). Tous ceux qui le récupèrent les ont.',
+ 'settings.projects.shared.commandsFromRepo': 'Exécutées en premier, enregistrées dans le dépôt ({path})',
+ 'settings.projects.shared.invalid': 'La configuration du projet dans {path} n\'a pas pu être lue : {reason}',
+ 'settings.projects.shared.trusted': 'Commandes du dépôt approuvées sur cette instance',
+ 'settings.projects.shared.resetTrust': 'Réinitialiser la confiance',
+ 'settings.projects.shared.title': 'Configuration du dépôt',
+ 'settings.projects.shared.description': 'Configuration enregistrée dans le dépôt lui-même, pour que tous ceux qui le récupèrent aient les mêmes actions, commandes de configuration, amorces et plans. Rien n\'est écrit tant que vous n\'y déplacez pas un élément.',
+ 'settings.projects.shared.file': 'Fichier',
+ 'settings.projects.shared.status.missing': 'Pas encore dans le dépôt',
+ 'settings.projects.shared.status.ok': 'Dans le dépôt',
+ 'settings.projects.shared.plansDir': 'Dossier des plans',
+ 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans',
+ 'settings.projects.shared.plansDirInfo': 'Où vivent les plans du dépôt, relativement au dépôt. Vide signifie .openchamber/plans. Un dossier personnalisé comme docs/plans remplace entièrement le dossier par défaut : seul ce dossier est lu et écrit. Déplacez vous-même les fichiers existants quand vous le changez.',
+ 'settings.projects.shared.plansDirAria': 'Dossier des plans du dépôt',
+ 'settings.projects.shared.actions.share': 'Déplacer vers le dépôt',
+ 'settings.projects.shared.actions.showTitle': 'Réaffiche cette action du dépôt dans votre menu.',
+ 'settings.projects.shared.actions.hideTitle': 'Masque cette action du dépôt dans votre menu seulement ; le dépôt n\'est pas modifié.',
+ 'settings.projects.shared.actions.makePersonalTitle': 'Le retire du dépôt et ne le garde que dans vos réglages sur cette instance.',
+ 'settings.projects.shared.actions.shareTitle': 'L\'enregistre dans {path} du dépôt, pour que tous ceux qui le récupèrent l\'aient. Il quitte vos réglages personnels.',
+ 'settings.projects.shared.actions.shareAfterSave': 'Enregistre d\'abord vos modifications, puis déplacez',
+ 'settings.projects.shared.actions.makePersonal': 'Déplacer vers mes réglages',
+ 'settings.projects.shared.actions.hide': 'Masquer pour moi',
+ 'settings.projects.shared.actions.show': 'Afficher',
+ 'settings.projects.shared.hiddenBadge': 'Masqué',
+ 'settings.projects.shared.replaceMode': 'Utiliser uniquement mes commandes de configuration et ignorer celles du dépôt',
+ 'settings.projects.shared.replaceModeAria': 'Utiliser uniquement mes commandes de configuration et ignorer celles du dépôt',
+ 'settings.projects.shared.toast.shareFailed': 'Impossible de mettre à jour la configuration du dépôt',
'settings.projects.actions.description': 'Commandes par projet affichées dans l\'en-tête à côté du nom du projet.',
'settings.projects.actions.validation.fillNameAndCommand': 'Remplissez le nom de l\'action et la commande avant d\'enregistrer.',
'settings.projects.actions.state.loading': 'Chargement...',
@@ -894,10 +922,10 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Redémarre l\'application afin que les téléphones, tablettes et autres ordinateurs connectés à votre réseau Wi-Fi puissent l\'ouvrir.',
'settings.openchamber.desktopNetwork.field.warning': 'Attention : lorsqu\'elle est activée, l\'application est accessible à toute personne se trouvant sur le même réseau local.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'L\'accès LAN nécessite un mot de passe de l\'interface utilisateur du bureau. Tant qu\'il n\'est pas défini, l\'application de bureau démarre en accès local uniquement.',
- 'settings.openchamber.desktopPassword.actions.showPassword': 'Afficher le mot de passe',
- 'settings.openchamber.desktopPassword.actions.hidePassword': 'Masquer le mot de passe',
'settings.openchamber.desktopPassword.field.password': 'Mot de passe de l\'interface utilisateur du bureau',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Aucun mot de passe requis',
+ 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'Mot de passe défini. Saisissez-en un nouveau pour le remplacer.',
+ 'settings.openchamber.desktopPassword.actions.removePassword': 'Supprimer le mot de passe',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber demande après le redémarrage, puis quand la session de connexion expire : après 12 heures, ou 7 jours avec Trust this device. Laissez vide pour désactiver la connexion.',
'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Après redémarrage, ouvrez depuis un autre appareil :',
'settings.openchamber.desktopNetwork.hint.openNow': 'Ouvrir depuis un autre appareil :',
diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts
index 71f2b51e..22b3f9a6 100644
--- a/packages/ui/src/lib/i18n/messages/fr.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.ts
@@ -1479,6 +1479,9 @@ export const dict = {
'rightSidebar.contextNotesTodo.plans.importFromFile': 'Importer un plan à partir d\'un fichier',
'rightSidebar.contextNotesTodo.plans.empty': 'Aucun plan enregistré pour l\'instant.',
'rightSidebar.contextNotesTodo.plans.deletePlan': 'Supprimer le forfait',
+ 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'Dans le dépôt',
+ 'rightSidebar.contextNotesTodo.plans.share': 'Déplacer vers le dossier des plans du dépôt, pour que tous ceux qui le récupèrent le voient',
+ 'rightSidebar.contextNotesTodo.plans.makePersonal': 'Déplacer vers mes plans, hors du dépôt',
'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Supprimer le plan "{title}"',
'rightSidebar.contextNotesTodo.sendDialog.title.newSession': 'Envoyer à une nouvelle session',
'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': 'Envoyer vers un nouvel worktree',
@@ -1497,6 +1500,7 @@ export const dict = {
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Échec de l\'envoi de la tâche',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Échec de la mise à jour du plan',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Échec de la suppression du plan',
+ 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'Impossible de déplacer le plan',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Le fichier de plan est vide',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Échec de l\'importation du plan',
'rightSidebar.contextNotesTodo.toast.planImported': 'Forfait importé',
@@ -2447,6 +2451,13 @@ export const dict = {
'projectActions.actions.addAction': 'Ajouter une action',
'projectActions.actions.addNewAction': 'Ajouter une nouvelle action',
'projectActions.actions.autoDiscover': 'Découverte automatique',
+ 'projectActions.menu.sharedBadge': 'dépôt',
+ 'projects.sharedTrust.title': 'Exécuter les commandes enregistrées dans ce dépôt ?',
+ 'projects.sharedTrust.description': '{path} dans ce dépôt définit des commandes qui s\'exécutent sur cette machine. Faites-leur confiance une fois, et OpenChamber ne redemandera que si elles changent.',
+ 'projects.sharedTrust.setupCommands': 'Commandes de configuration du worktree',
+ 'projects.sharedTrust.actions': 'Actions',
+ 'projects.sharedTrust.skip': 'Pas cette fois',
+ 'projects.sharedTrust.trust': 'Faire confiance et exécuter',
'projectActions.actions.autoDiscoverTooltip': 'Détecte et lance automatiquement le serveur de développement',
'projectActions.actions.chooseActionAria': 'Choisir l\'action du projet',
'projectActions.actions.openPreview': 'Ouvrir l\'aperçu',
@@ -3152,6 +3163,9 @@ export const dict = {
'chat.draftStarters.sectionCommands': 'Commandes',
'chat.draftStarters.sectionSkills': 'Skills',
'chat.draftStarters.remove': 'Retirer',
+ 'chat.draftStarters.sharedTitle': 'Épinglé dans la configuration du dépôt ; à modifier là-bas',
+ 'chat.draftStarters.share': 'Déplacer vers la configuration du dépôt',
+ 'chat.draftStarters.makePersonal': 'Déplacer vers mes réglages',
'chat.commandAutocomplete.command.handoffReviewDescription': 'Créer ou réutiliser une session de revue séparée à partir d’un handoff généré.',
'chat.commandAutocomplete.command.featurePlanDescription': 'Lancer une session guidée et interactive de planification pour une nouvelle fonctionnalité.',
'chat.commandAutocomplete.command.craftGoalDescription': 'Transformer une idée ou une tâche en Goal clair et vérifiable.',
diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts
index 1b2d43e9..502955c1 100644
--- a/packages/ui/src/lib/i18n/messages/ja.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts
@@ -460,6 +460,34 @@ export const settingsDict = {
'settings.common.permission.deny': '拒否',
'settings.common.state.comingSoon': '近日公開...',
'settings.projects.actions.title': 'アクション',
+ 'settings.projects.shared.badge': 'リポジトリ内',
+ 'settings.projects.shared.actionsFromRepo': 'リポジトリ内に保存 ({path})。pull した全員が使えます。',
+ 'settings.projects.shared.commandsFromRepo': '最初に実行。リポジトリ内に保存 ({path})',
+ 'settings.projects.shared.invalid': '{path} のプロジェクト設定を読み込めませんでした: {reason}',
+ 'settings.projects.shared.trusted': 'リポジトリのコマンドはこのインスタンスで信頼済み',
+ 'settings.projects.shared.resetTrust': '信頼をリセット',
+ 'settings.projects.shared.title': 'リポジトリ設定',
+ 'settings.projects.shared.description': 'リポジトリ自体に保存されるセットアップ。pull した全員が同じアクション、セットアップコマンド、スターター、プランを使えます。項目を移動するまで何も書き込まれません。',
+ 'settings.projects.shared.file': 'ファイル',
+ 'settings.projects.shared.status.missing': 'まだリポジトリにありません',
+ 'settings.projects.shared.status.ok': 'リポジトリにあります',
+ 'settings.projects.shared.plansDir': 'プランフォルダー',
+ 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans',
+ 'settings.projects.shared.plansDirInfo': 'リポジトリのプランを置く場所(リポジトリ相対)。空なら .openchamber/plans。docs/plans のようなカスタムフォルダーはデフォルトを完全に置き換え、そのフォルダーだけを読み書きします。変更時は既存ファイルを自分で移動してください。',
+ 'settings.projects.shared.plansDirAria': 'リポジトリのプランフォルダー',
+ 'settings.projects.shared.actions.share': 'リポジトリへ移動',
+ 'settings.projects.shared.actions.showTitle': 'このリポジトリのアクションを自分のメニューに再表示します。',
+ 'settings.projects.shared.actions.hideTitle': 'このリポジトリのアクションを自分のメニューだけで非表示にします。リポジトリは変更されません。',
+ 'settings.projects.shared.actions.makePersonalTitle': 'リポジトリから削除し、このインスタンスのあなたの設定にだけ残します。',
+ 'settings.projects.shared.actions.shareTitle': 'リポジトリ内の {path} に保存し、pull した全員が使えるようにします。あなたの個人設定からは外れます。',
+ 'settings.projects.shared.actions.shareAfterSave': '先に編集内容が保存されてから移動できます',
+ 'settings.projects.shared.actions.makePersonal': '自分の設定へ移動',
+ 'settings.projects.shared.actions.hide': '自分には非表示',
+ 'settings.projects.shared.actions.show': '表示',
+ 'settings.projects.shared.hiddenBadge': '非表示',
+ 'settings.projects.shared.replaceMode': '自分のセットアップコマンドのみ使用し、リポジトリのものはスキップ',
+ 'settings.projects.shared.replaceModeAria': '自分のセットアップコマンドのみ使用し、リポジトリのものはスキップ',
+ 'settings.projects.shared.toast.shareFailed': 'リポジトリ設定を更新できませんでした',
'settings.projects.actions.description': 'ヘッダーのプロジェクト名の横に表示されるプロジェクトごとのコマンド。',
'settings.projects.actions.validation.fillNameAndCommand': '保存する前にアクション名とコマンドを入力してください。',
'settings.projects.actions.state.loading': '読み込み中...',
@@ -1009,10 +1037,10 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'アプリを再起動して、Wi-Fi 上の他のデバイスから開けるようにします。',
'settings.openchamber.desktopNetwork.field.warning': '警告: 有効にすると、アプリは同じローカルネットワーク上の誰からもアクセス可能になります。',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN アクセスには Desktop UI パスワードが必要です。設定されるまで、Desktop アプリはローカルのみで起動します。',
- 'settings.openchamber.desktopPassword.actions.showPassword': 'パスワードを表示',
- 'settings.openchamber.desktopPassword.actions.hidePassword': 'パスワードを非表示',
'settings.openchamber.desktopPassword.field.password': 'Desktop UI パスワード',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'パスワード不要',
+ 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'パスワード設定済み。置き換えるには新しいパスワードを入力してください。',
+ 'settings.openchamber.desktopPassword.actions.removePassword': 'パスワードを削除',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber は再起動後、およびログイン Session の有効期限後(12時間、または「このデバイスを信頼」の場合は7日)に確認を求めます。空のままにするとログインが無効になります。',
'settings.openchamber.desktopNetwork.hint.openAfterRestart': '再起動後、別のデバイスから開く: ',
'settings.openchamber.desktopNetwork.hint.openNow': '別のデバイスから開く: ',
diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts
index 7da698fb..7357a56d 100644
--- a/packages/ui/src/lib/i18n/messages/ja.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.ts
@@ -1711,6 +1711,9 @@ export const dict: Record = {
'rightSidebar.contextNotesTodo.plans.importFromFile': 'ファイルから計画をインポート',
'rightSidebar.contextNotesTodo.plans.empty': 'まだ保存された計画はありません。',
'rightSidebar.contextNotesTodo.plans.deletePlan': '計画を削除',
+ 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'リポジトリ内',
+ 'rightSidebar.contextNotesTodo.plans.share': 'リポジトリのプランフォルダーへ移動し、pull した全員が見られるようにします',
+ 'rightSidebar.contextNotesTodo.plans.makePersonal': 'リポジトリから自分のプランへ移動',
'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': '計画「{title}」を削除',
'rightSidebar.contextNotesTodo.sendDialog.title.newSession': '新しいセッションに送信',
'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': '新しいワークツリーに送信',
@@ -1729,6 +1732,7 @@ export const dict: Record = {
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'TODOの送信に失敗しました',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '計画を更新できませんでした',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '計画の削除に失敗しました',
+ 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'プランを移動できませんでした',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': '計画ファイルが空です',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': '計画のインポートに失敗しました',
'rightSidebar.contextNotesTodo.toast.planImported': '計画をインポートしました',
@@ -2118,6 +2122,9 @@ export const dict: Record = {
'chat.draftStarters.sectionCommands': 'コマンド',
'chat.draftStarters.sectionSkills': 'スキル',
'chat.draftStarters.remove': '削除',
+ 'chat.draftStarters.sharedTitle': 'リポジトリ設定でピン留め。変更はそちらで',
+ 'chat.draftStarters.share': 'リポジトリ設定へ移動',
+ 'chat.draftStarters.makePersonal': '自分の設定へ移動',
'chat.scrollToBottom.aria': '一番下にスクロール',
'chat.promptNavigator.aria': 'プロンプトナビゲーション',
'chat.promptNavigator.currentPrompt': '現在のプロンプト',
@@ -2744,6 +2751,13 @@ export const dict: Record = {
'projectActions.actions.addAction': 'アクションを追加',
'projectActions.actions.addNewAction': '新しいアクションを追加',
'projectActions.actions.autoDiscover': '自動検出',
+ 'projectActions.menu.sharedBadge': 'リポジトリ',
+ 'projects.sharedTrust.title': 'このリポジトリに保存されたコマンドを実行しますか?',
+ 'projects.sharedTrust.description': 'このリポジトリの {path} には、このマシンで実行されるコマンドが定義されています。一度信頼すると、変更があった場合のみ再度確認します。',
+ 'projects.sharedTrust.setupCommands': 'ワークツリーのセットアップコマンド',
+ 'projects.sharedTrust.actions': 'アクション',
+ 'projects.sharedTrust.skip': '今回は実行しない',
+ 'projects.sharedTrust.trust': '信頼して実行',
'projectActions.actions.autoDiscoverTooltip': '開発サーバーを自動的に検出して実行します',
'projectActions.actions.chooseActionAria': 'プロジェクトアクションを選択',
'projectActions.actions.openPreview': 'プレビューを開く',
diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts
index 076e7983..bf318948 100644
--- a/packages/ui/src/lib/i18n/messages/ko.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts
@@ -427,6 +427,34 @@ export const settingsDict = {
'settings.common.permission.deny': '거부',
'settings.common.state.comingSoon': '곧 제공됩니다...',
'settings.projects.actions.title': '작업',
+ 'settings.projects.shared.badge': '저장소에 있음',
+ 'settings.projects.shared.actionsFromRepo': '저장소에 저장됨({path}). 저장소를 받는 모든 사람이 사용합니다.',
+ 'settings.projects.shared.commandsFromRepo': '먼저 실행됨, 저장소에 저장됨({path})',
+ 'settings.projects.shared.invalid': '{path}의 프로젝트 설정을 읽을 수 없습니다: {reason}',
+ 'settings.projects.shared.trusted': '이 인스턴스에서 저장소 명령을 신뢰함',
+ 'settings.projects.shared.resetTrust': '신뢰 초기화',
+ 'settings.projects.shared.title': '저장소 설정',
+ 'settings.projects.shared.description': '저장소 자체에 저장되는 설정입니다. 저장소를 받는 모든 사람이 같은 작업, 설정 명령, 스타터, 플랜을 사용합니다. 항목을 옮기기 전에는 아무것도 기록되지 않습니다.',
+ 'settings.projects.shared.file': '파일',
+ 'settings.projects.shared.status.missing': '아직 저장소에 없음',
+ 'settings.projects.shared.status.ok': '저장소에 있음',
+ 'settings.projects.shared.plansDir': '플랜 폴더',
+ 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans',
+ 'settings.projects.shared.plansDirInfo': '저장소 플랜이 있는 위치입니다(저장소 기준 상대 경로). 비어 있으면 .openchamber/plans입니다. docs/plans 같은 사용자 지정 폴더는 기본값을 완전히 대체하며 그 폴더만 읽고 씁니다. 변경할 때 기존 파일은 직접 옮기세요.',
+ 'settings.projects.shared.plansDirAria': '저장소 플랜 폴더',
+ 'settings.projects.shared.actions.share': '저장소로 이동',
+ 'settings.projects.shared.actions.showTitle': '이 저장소 작업을 내 메뉴에 다시 표시합니다.',
+ 'settings.projects.shared.actions.hideTitle': '이 저장소 작업을 내 메뉴에서만 숨깁니다. 저장소는 바뀌지 않습니다.',
+ 'settings.projects.shared.actions.makePersonalTitle': '저장소에서 제거하고 이 인스턴스의 내 설정에만 남깁니다.',
+ 'settings.projects.shared.actions.shareTitle': '저장소 안의 {path}에 저장하여 저장소를 받는 모든 사람이 사용하게 합니다. 개인 설정에서는 빠집니다.',
+ 'settings.projects.shared.actions.shareAfterSave': '먼저 변경 사항이 저장된 뒤 이동할 수 있습니다',
+ 'settings.projects.shared.actions.makePersonal': '내 설정으로 이동',
+ 'settings.projects.shared.actions.hide': '나에게 숨기기',
+ 'settings.projects.shared.actions.show': '표시',
+ 'settings.projects.shared.hiddenBadge': '숨김',
+ 'settings.projects.shared.replaceMode': '내 설정 명령만 사용하고 저장소의 명령은 건너뛰기',
+ 'settings.projects.shared.replaceModeAria': '내 설정 명령만 사용하고 저장소의 명령은 건너뛰기',
+ 'settings.projects.shared.toast.shareFailed': '저장소 설정을 업데이트하지 못했습니다',
'settings.projects.actions.description': '프로젝트 이름 옆 헤더에 표시할 프로젝트별 명령어입니다.',
'settings.projects.actions.validation.fillNameAndCommand': '저장하기 전에 작업 이름과 명령을 입력하세요.',
'settings.projects.actions.state.loading': '로딩 중...',
@@ -976,10 +1004,10 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '휴대폰, 태블릿, Wi-Fi의 다른 컴퓨터에서 열 수 있도록 앱을 다시 시작합니다.',
'settings.openchamber.desktopNetwork.field.warning': '경고: 활성화된 동안 같은 로컬 네트워크의 누구나 앱에 접속할 수 있습니다.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN 접속에는 Desktop UI 비밀번호가 필요합니다. 설정하기 전까지 desktop 앱은 로컬 전용으로 시작됩니다.',
- 'settings.openchamber.desktopPassword.actions.showPassword': '비밀번호 표시',
- 'settings.openchamber.desktopPassword.actions.hidePassword': '비밀번호 숨기기',
'settings.openchamber.desktopPassword.field.password': 'Desktop UI 비밀번호',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '비밀번호 필요 없음',
+ 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': '비밀번호가 설정되어 있습니다. 바꾸려면 새 비밀번호를 입력하세요.',
+ 'settings.openchamber.desktopPassword.actions.removePassword': '비밀번호 제거',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber는 다시 시작 후 비밀번호를 요청하고, 이후 로그인 세션이 만료되면 다시 요청합니다. 기본 12시간, 이 디바이스 신뢰 선택 시 7일입니다. 로그인을 끄려면 비워 두세요.',
'settings.openchamber.desktopNetwork.hint.openAfterRestart': '다시 시작 후 다른 기기에서 열기: ',
'settings.openchamber.desktopNetwork.hint.openNow': '다른 기기에서 열기: ',
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index e13277d1..40a7e856 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -1717,6 +1717,9 @@ export const dict: Record = {
'rightSidebar.contextNotesTodo.plans.importFromFile': '파일에서 플랜 가져오기',
'rightSidebar.contextNotesTodo.plans.empty': '아직 저장된 플랜 없음',
'rightSidebar.contextNotesTodo.plans.deletePlan': '플랜 삭제',
+ 'rightSidebar.contextNotesTodo.plans.sharedBadge': '저장소에 있음',
+ 'rightSidebar.contextNotesTodo.plans.share': '저장소 플랜 폴더로 이동하여 저장소를 받는 모든 사람이 보게 합니다',
+ 'rightSidebar.contextNotesTodo.plans.makePersonal': '저장소에서 내 플랜으로 이동',
'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': '플랜 "{title}" 삭제',
'rightSidebar.contextNotesTodo.sendDialog.title.newSession': '새 세션으로 보내기',
'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': '새 워크트리로 보내기',
@@ -1735,6 +1738,7 @@ export const dict: Record = {
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Todo 전송 실패',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '계획을 업데이트하지 못했습니다',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '플랜 삭제 실패',
+ 'rightSidebar.contextNotesTodo.toast.movePlanFailed': '플랜을 이동하지 못했습니다',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': '플랜 파일이 비어 있음',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': '플랜 가져오기 실패',
'rightSidebar.contextNotesTodo.toast.planImported': '플랜 가져옴',
@@ -2124,6 +2128,9 @@ export const dict: Record = {
'chat.draftStarters.sectionCommands': 'Commands',
'chat.draftStarters.sectionSkills': 'Skills',
'chat.draftStarters.remove': 'Remove',
+ 'chat.draftStarters.sharedTitle': '저장소 설정에 고정됨. 그곳에서 변경하세요',
+ 'chat.draftStarters.share': '저장소 설정으로 이동',
+ 'chat.draftStarters.makePersonal': '내 설정으로 이동',
'chat.scrollToBottom.aria': '맨 아래로 스크롤',
'chat.promptNavigator.aria': '프롬프트 탐색',
'chat.promptNavigator.currentPrompt': '현재 프롬프트',
@@ -2745,6 +2752,13 @@ export const dict: Record = {
'projectActions.actions.addAction': '작업 추가',
'projectActions.actions.addNewAction': '새 작업 추가',
'projectActions.actions.autoDiscover': '자동 검색',
+ 'projectActions.menu.sharedBadge': '저장소',
+ 'projects.sharedTrust.title': '이 저장소에 저장된 명령을 실행할까요?',
+ 'projects.sharedTrust.description': '이 저장소의 {path}에 이 컴퓨터에서 실행되는 명령이 정의되어 있습니다. 한 번 신뢰하면 명령이 바뀔 때만 다시 묻습니다.',
+ 'projects.sharedTrust.setupCommands': '워크트리 설정 명령',
+ 'projects.sharedTrust.actions': '작업',
+ 'projects.sharedTrust.skip': '이번에는 건너뛰기',
+ 'projects.sharedTrust.trust': '신뢰하고 실행',
'projectActions.actions.autoDiscoverTooltip': '개발 서버를 자동으로 검색하고 실행합니다',
'projectActions.actions.chooseActionAria': '프로젝트 작업 선택',
'projectActions.actions.openPreview': '미리보기 열기',
diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts
index 6ed2cc9b..860c013d 100644
--- a/packages/ui/src/lib/i18n/messages/pl.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts
@@ -789,10 +789,10 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'Aby telefony nadal mogły otwierać tę aplikację. Ekran nadal może się wyłączyć.',
'settings.openchamber.desktopNetwork.field.warning': 'Ostrzeżenie: po włączeniu aplikacja jest dostępna dla każdego w tej samej sieci lokalnej.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'Dostęp LAN wymaga hasła UI pulpitu. Dopóki go nie ustawisz, aplikacja pulpitu uruchamia się tylko lokalnie.',
- 'settings.openchamber.desktopPassword.actions.showPassword': 'Pokaż hasło',
- 'settings.openchamber.desktopPassword.actions.hidePassword': 'Ukryj hasło',
'settings.openchamber.desktopPassword.field.password': 'Hasło UI pulpitu',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Hasło nie jest wymagane',
+ 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'Hasło ustawione. Wpisz nowe, aby je zastąpić.',
+ 'settings.openchamber.desktopPassword.actions.removePassword': 'Usuń hasło',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber pyta po restarcie, a potem po wygaśnięciu sesji logowania: po 12 godzinach albo po 7 dniach z opcją Zaufaj temu urządzeniu. Zostaw puste, aby wyłączyć logowanie.',
'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Po restarcie otwórz z innego urządzenia: ',
'settings.openchamber.desktopNetwork.hint.openNow': 'Otwórz z innego urządzenia: ',
@@ -1384,6 +1384,34 @@ export const settingsDict = {
'settings.projects.actions.state.noDesktopSshForwards': 'Brak włączonych lokalnych przekierowań SSH.',
'settings.projects.actions.state.untitled': 'Akcja bez nazwy',
'settings.projects.actions.title': 'Akcje',
+ 'settings.projects.shared.badge': 'W repozytorium',
+ 'settings.projects.shared.actionsFromRepo': 'Zapisane w repozytorium ({path}). Każdy, kto je pobierze, je otrzyma.',
+ 'settings.projects.shared.commandsFromRepo': 'Uruchamiane najpierw, zapisane w repozytorium ({path})',
+ 'settings.projects.shared.invalid': 'Nie udało się odczytać konfiguracji projektu w {path}: {reason}',
+ 'settings.projects.shared.trusted': 'Polecenia z repozytorium zaufane w tej instancji',
+ 'settings.projects.shared.resetTrust': 'Resetuj zaufanie',
+ 'settings.projects.shared.title': 'Konfiguracja w repozytorium',
+ 'settings.projects.shared.description': 'Konfiguracja zapisana w samym repozytorium, dzięki czemu każdy, kto je pobierze, ma te same akcje, polecenia konfiguracji, startery i plany. Nic nie jest zapisywane, dopóki nie przeniesiesz tam elementu.',
+ 'settings.projects.shared.file': 'Plik',
+ 'settings.projects.shared.status.missing': 'Jeszcze nie ma w repozytorium',
+ 'settings.projects.shared.status.ok': 'W repozytorium',
+ 'settings.projects.shared.plansDir': 'Folder planów',
+ 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans',
+ 'settings.projects.shared.plansDirInfo': 'Gdzie leżą plany w repozytorium, względem repozytorium. Puste oznacza .openchamber/plans. Własny folder, np. docs/plans, całkowicie zastępuje domyślny: czytany i zapisywany jest tylko ten folder. Istniejące pliki przenieś samodzielnie przy zmianie.',
+ 'settings.projects.shared.plansDirAria': 'Folder planów w repozytorium',
+ 'settings.projects.shared.actions.share': 'Przenieś do repozytorium',
+ 'settings.projects.shared.actions.showTitle': 'Ponownie pokazuje tę akcję z repozytorium w Twoim menu.',
+ 'settings.projects.shared.actions.hideTitle': 'Ukrywa tę akcję z repozytorium tylko w Twoim menu; repozytorium się nie zmienia.',
+ 'settings.projects.shared.actions.makePersonalTitle': 'Usuwa to z repozytorium i zostawia tylko w Twoich ustawieniach w tej instancji.',
+ 'settings.projects.shared.actions.shareTitle': 'Zapisuje to w {path} w repozytorium, więc każdy, kto je pobierze, to otrzyma. Znika z Twoich osobistych ustawień.',
+ 'settings.projects.shared.actions.shareAfterSave': 'Najpierw zapisz zmiany, potem przenieś',
+ 'settings.projects.shared.actions.makePersonal': 'Przenieś do moich ustawień',
+ 'settings.projects.shared.actions.hide': 'Ukryj dla mnie',
+ 'settings.projects.shared.actions.show': 'Pokaż',
+ 'settings.projects.shared.hiddenBadge': 'Ukryte',
+ 'settings.projects.shared.replaceMode': 'Używaj tylko moich poleceń konfiguracji, pomiń te z repozytorium',
+ 'settings.projects.shared.replaceModeAria': 'Używaj tylko moich poleceń konfiguracji, pomiń te z repozytorium',
+ 'settings.projects.shared.toast.shareFailed': 'Nie udało się zaktualizować konfiguracji w repozytorium',
'settings.projects.actions.toast.saveFailed': 'Nie udało się zapisać akcji',
'settings.projects.actions.toast.saved': 'Akcje projektu zostały zapisane',
'settings.projects.actions.validation.fillNameAndCommand': 'Przed zapisaniem uzupełnij nazwę akcji i polecenie.',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index 58dea065..6a6946da 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -777,6 +777,9 @@ export const dict: Record = {
'chat.draftStarters.sectionCommands': 'Commands',
'chat.draftStarters.sectionSkills': 'Skills',
'chat.draftStarters.remove': 'Remove',
+ 'chat.draftStarters.sharedTitle': 'Przypięte w konfiguracji repozytorium; zmień to tam',
+ 'chat.draftStarters.share': 'Przenieś do konfiguracji repozytorium',
+ 'chat.draftStarters.makePersonal': 'Przenieś do moich ustawień',
'chat.scrollToBottom.aria': 'Przewiń na dół',
'chat.promptNavigator.aria': 'Nawigacja promptów',
'chat.promptNavigator.currentPrompt': 'Bieżący prompt',
@@ -2696,6 +2699,13 @@ export const dict: Record = {
'projectActions.actions.addActionAria': 'Dodaj akcję',
'projectActions.actions.addNewAction': 'Dodaj nową akcję',
'projectActions.actions.autoDiscover': 'Wykryj automatycznie',
+ 'projectActions.menu.sharedBadge': 'repo',
+ 'projects.sharedTrust.title': 'Uruchomić polecenia zapisane w tym repozytorium?',
+ 'projects.sharedTrust.description': '{path} w tym repozytorium definiuje polecenia uruchamiane na tym komputerze. Zaufaj raz, a OpenChamber zapyta ponownie tylko wtedy, gdy się zmienią.',
+ 'projects.sharedTrust.setupCommands': 'Polecenia konfiguracji worktree',
+ 'projects.sharedTrust.actions': 'Akcje',
+ 'projects.sharedTrust.skip': 'Nie tym razem',
+ 'projects.sharedTrust.trust': 'Zaufaj i uruchom',
'projectActions.actions.autoDiscoverTooltip': 'Automatycznie wykrywa i uruchamia serwer deweloperski',
'projectActions.actions.chooseActionAria': 'Wybierz akcję projektu',
'projectActions.actions.openPreview': 'Otwórz podgląd',
@@ -2741,6 +2751,9 @@ export const dict: Record = {
'rightSidebar.contextNotesTodo.notes.placeholder': 'Zapisz kontekst, przypomnienia lub linki',
'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan',
'rightSidebar.contextNotesTodo.plans.deletePlan': 'Usuń plan',
+ 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'W repozytorium',
+ 'rightSidebar.contextNotesTodo.plans.share': 'Przenieś do folderu planów w repozytorium, aby każdy, kto je pobierze, go widział',
+ 'rightSidebar.contextNotesTodo.plans.makePersonal': 'Przenieś do moich planów, poza repozytorium',
'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Usuń plan „{title}”',
'rightSidebar.contextNotesTodo.plans.empty': 'Brak zapisanych planów.',
'rightSidebar.contextNotesTodo.plans.importFromFile': 'Importuj plan z pliku',
@@ -2753,6 +2766,7 @@ export const dict: Record = {
'rightSidebar.contextNotesTodo.toast.createSessionFailed': 'Nie udało się utworzyć sesji',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Nie udało się zaktualizować planu',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Nie udało się usunąć planu',
+ 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'Nie udało się przenieść planu',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Nie udało się zaimportować planu',
'rightSidebar.contextNotesTodo.toast.loadNotesFailed': 'Nie udało się załadować notatek projektu',
'rightSidebar.contextNotesTodo.toast.noActiveSession': 'Nie wybrano aktywnej sesji',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
index 53d53d67..b12de763 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
@@ -427,6 +427,34 @@ export const settingsDict = {
"settings.common.permission.deny": "Negar",
"settings.common.state.comingSoon": "Em breve...",
"settings.projects.actions.title": "Ações",
+ "settings.projects.shared.badge": "No repositório",
+ "settings.projects.shared.actionsFromRepo": "Guardadas no repositório ({path}). Todos que o baixarem terão estas.",
+ "settings.projects.shared.commandsFromRepo": "Executados primeiro, guardados no repositório ({path})",
+ "settings.projects.shared.invalid": "A configuração do projeto em {path} não pôde ser lida: {reason}",
+ "settings.projects.shared.trusted": "Comandos do repositório confiáveis nesta instância",
+ "settings.projects.shared.resetTrust": "Redefinir confiança",
+ "settings.projects.shared.title": "Configuração no repositório",
+ "settings.projects.shared.description": "Configuração guardada no próprio repositório, para que todos que o baixarem tenham as mesmas ações, comandos de configuração, iniciadores e planos. Nada é gravado até você mover um item para lá.",
+ "settings.projects.shared.file": "Arquivo",
+ "settings.projects.shared.status.missing": "Ainda não está no repositório",
+ "settings.projects.shared.status.ok": "No repositório",
+ "settings.projects.shared.plansDir": "Pasta de planos",
+ "settings.projects.shared.plansDirPlaceholder": ".openchamber/plans",
+ "settings.projects.shared.plansDirInfo": "Onde ficam os planos do repositório, relativo ao repositório. Vazio significa .openchamber/plans. Uma pasta própria como docs/plans substitui a padrão por completo: só essa pasta é lida e gravada. Mova você mesmo os arquivos existentes ao trocar.",
+ "settings.projects.shared.plansDirAria": "Pasta de planos no repositório",
+ "settings.projects.shared.actions.share": "Mover para o repositório",
+ "settings.projects.shared.actions.showTitle": "Mostra esta ação do repositório novamente no seu menu.",
+ "settings.projects.shared.actions.hideTitle": "Oculta esta ação do repositório apenas no seu menu; o repositório não muda.",
+ "settings.projects.shared.actions.makePersonalTitle": "Remove do repositório e mantém apenas nas suas configurações nesta instância.",
+ "settings.projects.shared.actions.shareTitle": "Guarda em {path} dentro do repositório, para que todos que o baixarem tenham. Sai das suas configurações pessoais.",
+ "settings.projects.shared.actions.shareAfterSave": "Salva suas edições primeiro, depois mova",
+ "settings.projects.shared.actions.makePersonal": "Mover para minhas configurações",
+ "settings.projects.shared.actions.hide": "Ocultar para mim",
+ "settings.projects.shared.actions.show": "Mostrar",
+ "settings.projects.shared.hiddenBadge": "Oculto",
+ "settings.projects.shared.replaceMode": "Usar apenas meus comandos de configuração e ignorar os do repositório",
+ "settings.projects.shared.replaceModeAria": "Usar apenas meus comandos de configuração e ignorar os do repositório",
+ "settings.projects.shared.toast.shareFailed": "Falha ao atualizar a configuração no repositório",
"settings.projects.actions.description": "Comandos por projeto mostrados no cabeçalho junto ao nome do projeto.",
"settings.projects.actions.validation.fillNameAndCommand": "Preencha o nome da ação e o comando antes de salvar.",
"settings.projects.actions.state.loading": "Carregando...",
@@ -976,10 +1004,10 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Reinicia o aplicativo para que os telefones, tablets e outros computadores em seu Wi-Fi possam abri-lo.",
"settings.openchamber.desktopNetwork.field.warning": "Aviso: enquanto estiver habilitado, o aplicativo ficará acessível a qualquer pessoa na mesma rede local.",
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "O acesso LAN exige uma senha da UI do desktop. Até configurar uma, o app de desktop inicia apenas localmente.",
- "settings.openchamber.desktopPassword.actions.showPassword": "Mostrar senha",
- "settings.openchamber.desktopPassword.actions.hidePassword": "Ocultar senha",
"settings.openchamber.desktopPassword.field.password": "Senha da UI do desktop",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "Nenhuma senha obrigatória",
+ "settings.openchamber.desktopPassword.field.passwordSetPlaceholder": "Senha definida. Digite uma nova para substituí-la.",
+ "settings.openchamber.desktopPassword.actions.removePassword": "Remover senha",
"settings.openchamber.desktopPassword.field.passwordDescription": "O OpenChamber pede após reiniciar e depois quando a sessão expira: em 12 horas, ou 7 dias com Confiar neste dispositivo. Deixe vazio para desativar o login.",
"settings.openchamber.desktopNetwork.hint.openAfterRestart": "Depois do reinício, abra de outro dispositivo: ",
"settings.openchamber.desktopNetwork.hint.openNow": "Abrir de outro dispositivo: ",
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index 4f0c0a9b..97c1555e 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -1693,6 +1693,9 @@ export const dict: Record = {
"rightSidebar.contextNotesTodo.plans.importFromFile": "Importar plano de arquivo",
"rightSidebar.contextNotesTodo.plans.empty": "Ainda não há planos salvos.",
"rightSidebar.contextNotesTodo.plans.deletePlan": "Excluir plano",
+ "rightSidebar.contextNotesTodo.plans.sharedBadge": "No repositório",
+ "rightSidebar.contextNotesTodo.plans.share": "Mover para a pasta de planos do repositório, para que todos que o baixarem o vejam",
+ "rightSidebar.contextNotesTodo.plans.makePersonal": "Mover para meus planos, fora do repositório",
"rightSidebar.contextNotesTodo.plans.deletePlanWithTitle": "Excluir plano \"{title}\"",
"rightSidebar.contextNotesTodo.sendDialog.title.newSession": "Enviar a uma nova sessão",
"rightSidebar.contextNotesTodo.sendDialog.title.newWorktree": "Enviar a uma nova sessão de worktree",
@@ -1711,6 +1714,7 @@ export const dict: Record = {
"rightSidebar.contextNotesTodo.toast.sendTodoFailed": "Não foi possível enviar a tarefa",
"rightSidebar.contextNotesTodo.toast.updatePlanFailed": "Falha ao atualizar o plano",
"rightSidebar.contextNotesTodo.toast.deletePlanFailed": "Não foi possível excluir o plano",
+ "rightSidebar.contextNotesTodo.toast.movePlanFailed": "Falha ao mover o plano",
"rightSidebar.contextNotesTodo.toast.planFileEmpty": "O arquivo do plano está vazio",
"rightSidebar.contextNotesTodo.toast.importPlanFailed": "Não foi possível importar o plano",
"rightSidebar.contextNotesTodo.toast.planImported": "Plano importado",
@@ -2100,6 +2104,9 @@ export const dict: Record = {
"chat.draftStarters.sectionCommands": "Commands",
"chat.draftStarters.sectionSkills": "Skills",
"chat.draftStarters.remove": "Remove",
+ "chat.draftStarters.sharedTitle": "Fixado na configuração do repositório; altere lá",
+ "chat.draftStarters.share": "Mover para a configuração do repositório",
+ "chat.draftStarters.makePersonal": "Mover para minhas configurações",
"chat.scrollToBottom.aria": "Ir ao final",
"chat.promptNavigator.aria": "Navegação de prompts",
"chat.promptNavigator.currentPrompt": "Prompt atual",
@@ -2711,6 +2718,13 @@ export const dict: Record = {
"projectActions.actions.addAction": "Adicionar ação",
"projectActions.actions.addNewAction": "Adicionar nova ação",
"projectActions.actions.autoDiscover": "Detectar automaticamente",
+ "projectActions.menu.sharedBadge": "repo",
+ "projects.sharedTrust.title": "Executar os comandos guardados neste repositório?",
+ "projects.sharedTrust.description": "{path} neste repositório define comandos que rodam nesta máquina. Confie uma vez e o OpenChamber só perguntará de novo quando eles mudarem.",
+ "projects.sharedTrust.setupCommands": "Comandos de configuração do worktree",
+ "projects.sharedTrust.actions": "Ações",
+ "projects.sharedTrust.skip": "Agora não",
+ "projects.sharedTrust.trust": "Confiar e executar",
"projectActions.actions.autoDiscoverTooltip": "Detecta e executa automaticamente o servidor de desenvolvimento",
"projectActions.actions.chooseActionAria": "Escolher ação do projeto",
"projectActions.actions.openPreview": "Abrir Preview",
diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts
index 6ad8f82a..8bcfdcc1 100644
--- a/packages/ui/src/lib/i18n/messages/tr.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts
@@ -455,6 +455,34 @@ export const settingsDict = {
'settings.common.permission.deny': 'Reddet',
'settings.common.state.comingSoon': 'Yakında...',
'settings.projects.actions.title': 'Eylemler',
+ 'settings.projects.shared.badge': 'Depoda',
+ 'settings.projects.shared.actionsFromRepo': 'Depoda saklanır ({path}). Depoyu çeken herkes bunları alır.',
+ 'settings.projects.shared.commandsFromRepo': 'Önce çalışır, depoda saklanır ({path})',
+ 'settings.projects.shared.invalid': '{path} içindeki proje yapılandırması okunamadı: {reason}',
+ 'settings.projects.shared.trusted': 'Depo komutlarına bu örnekte güveniliyor',
+ 'settings.projects.shared.resetTrust': 'Güveni sıfırla',
+ 'settings.projects.shared.title': 'Depo yapılandırması',
+ 'settings.projects.shared.description': 'Deponun kendisinde saklanan kurulum; depoyu çeken herkes aynı eylemleri, kurulum komutlarını, başlatıcıları ve planları alır. Oraya bir öğe taşıyana kadar hiçbir şey yazılmaz.',
+ 'settings.projects.shared.file': 'Dosya',
+ 'settings.projects.shared.status.missing': 'Henüz depoda değil',
+ 'settings.projects.shared.status.ok': 'Depoda',
+ 'settings.projects.shared.plansDir': 'Planlar klasörü',
+ 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans',
+ 'settings.projects.shared.plansDirInfo': 'Depo planlarının bulunduğu yer (depoya göre). Boş bırakılırsa .openchamber/plans kullanılır. docs/plans gibi özel bir klasör varsayılanı tamamen değiştirir: yalnızca o klasör okunur ve yazılır. Değiştirdiğinde mevcut dosyaları kendin taşı.',
+ 'settings.projects.shared.plansDirAria': 'Depo planlar klasörü',
+ 'settings.projects.shared.actions.share': 'Depoya taşı',
+ 'settings.projects.shared.actions.showTitle': 'Bu depo eylemini menünde yeniden gösterir.',
+ 'settings.projects.shared.actions.hideTitle': 'Bu depo eylemini yalnızca senin menünden gizler; depo değişmez.',
+ 'settings.projects.shared.actions.makePersonalTitle': 'Depodan kaldırır ve yalnızca bu örnekteki ayarlarında tutar.',
+ 'settings.projects.shared.actions.shareTitle': 'Depodaki {path} içine kaydeder; depoyu çeken herkes alır. Kişisel ayarlarından çıkar.',
+ 'settings.projects.shared.actions.shareAfterSave': 'Önce değişikliklerin kaydedilir, sonra taşı',
+ 'settings.projects.shared.actions.makePersonal': 'Ayarlarıma taşı',
+ 'settings.projects.shared.actions.hide': 'Benim için gizle',
+ 'settings.projects.shared.actions.show': 'Göster',
+ 'settings.projects.shared.hiddenBadge': 'Gizli',
+ 'settings.projects.shared.replaceMode': 'Yalnızca kendi kurulum komutlarımı kullan, depodakileri atla',
+ 'settings.projects.shared.replaceModeAria': 'Yalnızca kendi kurulum komutlarımı kullan, depodakileri atla',
+ 'settings.projects.shared.toast.shareFailed': 'Depo yapılandırması güncellenemedi',
'settings.projects.actions.description': 'Üst bilgide proje adının yanında gösterilen proje bazlı komutlar.',
'settings.projects.actions.validation.fillNameAndCommand': 'Kaydetmeden önce eylem adını ve komutu doldurun.',
'settings.projects.actions.state.loading': 'Yükleniyor...',
@@ -1004,10 +1032,10 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Telefonlar, tabletler ve Wi-Fi ağınızdaki diğer bilgisayarların uygulamayı açabilmesi için uygulamayı yeniden başlatır.',
'settings.openchamber.desktopNetwork.field.warning': 'Uyarı: Etkinken uygulamaya aynı yerel ağdaki herkes erişebilir.',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN erişimi Masaüstü UI Şifresi gerektirir. Şifre ayarlanana kadar masaüstü uygulaması yalnızca yerel olarak başlar.',
- 'settings.openchamber.desktopPassword.actions.showPassword': 'Şifreyi göster',
- 'settings.openchamber.desktopPassword.actions.hidePassword': 'Şifreyi gizle',
'settings.openchamber.desktopPassword.field.password': 'Masaüstü UI Şifresi',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Şifre gerekmez',
+ 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': 'Şifre ayarlı. Değiştirmek için yeni bir şifre yazın.',
+ 'settings.openchamber.desktopPassword.actions.removePassword': 'Şifreyi kaldır',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber yeniden başlatma sonrasında sorar, ardından giriş session\'ı sona erdiğinde tekrar sorar: 12 saat sonra veya Trust this device ile 7 gün sonra. Girişi devre dışı bırakmak için boş bırakın.',
'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Yeniden başlatma sonrasında başka bir cihazdan açın: ',
'settings.openchamber.desktopNetwork.hint.openNow': 'Başka bir cihazdan açın: ',
diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts
index 09e824d1..9df18564 100644
--- a/packages/ui/src/lib/i18n/messages/tr.ts
+++ b/packages/ui/src/lib/i18n/messages/tr.ts
@@ -1677,6 +1677,9 @@ export const dict = {
'rightSidebar.contextNotesTodo.plans.importFromFile': 'Planı dosyadan içe aktar',
'rightSidebar.contextNotesTodo.plans.empty': 'Henüz kaydedilmiş plan yok.',
'rightSidebar.contextNotesTodo.plans.deletePlan': 'Planı sil',
+ 'rightSidebar.contextNotesTodo.plans.sharedBadge': 'Depoda',
+ 'rightSidebar.contextNotesTodo.plans.share': 'Depo planlar klasörüne taşı; depoyu çeken herkes görür',
+ 'rightSidebar.contextNotesTodo.plans.makePersonal': 'Depodan çıkarıp planlarıma taşı',
'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Planı sil: "{title}"',
'rightSidebar.contextNotesTodo.sendDialog.title.newSession': 'Yeni session\'a gönder',
'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': 'Yeni worktree\'ye gönder',
@@ -1695,6 +1698,7 @@ export const dict = {
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Yapılacak gönderilemedi',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': 'Plan güncellenemedi',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Plan silinemedi',
+ 'rightSidebar.contextNotesTodo.toast.movePlanFailed': 'Plan taşınamadı',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Plan dosyası boş',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Plan içe aktarılamadı',
'rightSidebar.contextNotesTodo.toast.planImported': 'Plan içe aktarıldı',
@@ -2082,6 +2086,9 @@ export const dict = {
'chat.draftStarters.sectionCommands': 'Komutlar',
'chat.draftStarters.sectionSkills': 'Skill\'ler',
'chat.draftStarters.remove': 'Kaldır',
+ 'chat.draftStarters.sharedTitle': 'Depo yapılandırmasında sabitlendi; orada değiştirin',
+ 'chat.draftStarters.share': 'Depo yapılandırmasına taşı',
+ 'chat.draftStarters.makePersonal': 'Ayarlarıma taşı',
'chat.scrollToBottom.aria': 'En alta kaydır',
'chat.promptNavigator.aria': 'Prompt gezinmesi',
'chat.promptNavigator.currentPrompt': 'Mevcut prompt',
@@ -2675,6 +2682,13 @@ export const dict = {
'projectActions.actions.addAction': 'Eylem ekle',
'projectActions.actions.addNewAction': 'Yeni eylem ekle',
'projectActions.actions.autoDiscover': 'Otomatik keşfet',
+ 'projectActions.menu.sharedBadge': 'depo',
+ 'projects.sharedTrust.title': 'Bu depoda saklanan komutlar çalıştırılsın mı?',
+ 'projects.sharedTrust.description': 'Bu depodaki {path}, bu makinede çalışan komutlar tanımlıyor. Bir kez güvenin; OpenChamber yalnızca değiştiklerinde yeniden sorar.',
+ 'projects.sharedTrust.setupCommands': 'Worktree kurulum komutları',
+ 'projects.sharedTrust.actions': 'Eylemler',
+ 'projects.sharedTrust.skip': 'Bu sefer değil',
+ 'projects.sharedTrust.trust': 'Güven ve çalıştır',
'projectActions.actions.autoDiscoverTooltip': 'Geliştirme sunucusunu otomatik keşfeder ve çalıştırır',
'projectActions.actions.chooseActionAria': 'Proje eylemini seç',
'projectActions.actions.openPreview': 'Önizlemeyi Aç',
diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts
index 82039ce5..4d2db472 100644
--- a/packages/ui/src/lib/i18n/messages/uk.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts
@@ -427,6 +427,34 @@ export const settingsDict = {
"settings.common.permission.deny": "Заборонити",
"settings.common.state.comingSoon": "Незабаром...",
"settings.projects.actions.title": "Дії",
+ "settings.projects.shared.badge": "У репозиторії",
+ "settings.projects.shared.actionsFromRepo": "Зберігаються в репозиторії ({path}). Їх отримує кожен, хто його клонує.",
+ "settings.projects.shared.commandsFromRepo": "Виконуються першими, зберігаються в репозиторії ({path})",
+ "settings.projects.shared.invalid": "Не вдалося прочитати конфіг проєкту в {path}: {reason}",
+ "settings.projects.shared.trusted": "Командам із репозиторію довірено на цьому інстансі",
+ "settings.projects.shared.resetTrust": "Скинути довіру",
+ "settings.projects.shared.title": "Конфіг у репозиторії",
+ "settings.projects.shared.description": "Налаштування, що лежать у самому репозиторії, тож кожен, хто його клонує, отримує ті самі дії, команди сетапу, стартери й плани. Туди нічого не записується, поки ви не перенесете елемент.",
+ "settings.projects.shared.file": "Файл",
+ "settings.projects.shared.status.missing": "Ще немає в репозиторії",
+ "settings.projects.shared.status.ok": "У репозиторії",
+ "settings.projects.shared.plansDir": "Тека планів",
+ "settings.projects.shared.plansDirPlaceholder": ".openchamber/plans",
+ "settings.projects.shared.plansDirInfo": "Де лежать плани репозиторію, відносно репозиторію. Порожнє означає .openchamber/plans. Своя тека, наприклад docs/plans, повністю замінює типову: читається й пишеться лише вона. Наявні файли при зміні перенесіть самі.",
+ "settings.projects.shared.plansDirAria": "Тека планів у репозиторії",
+ "settings.projects.shared.actions.share": "Перенести в репозиторій",
+ "settings.projects.shared.actions.showTitle": "Знову показує цю дію з репозиторію у вашому меню.",
+ "settings.projects.shared.actions.hideTitle": "Ховає цю дію з репозиторію лише у вашому меню; репозиторій не змінюється.",
+ "settings.projects.shared.actions.makePersonalTitle": "Прибирає з репозиторію і лишає лише у ваших налаштуваннях на цьому інстансі.",
+ "settings.projects.shared.actions.shareTitle": "Зберігає це в {path} у репозиторії, тож кожен, хто його клонує, це отримає. З ваших особистих налаштувань воно зникає.",
+ "settings.projects.shared.actions.shareAfterSave": "Спочатку збережуться ваші правки, потім перенести",
+ "settings.projects.shared.actions.makePersonal": "Перенести в мої налаштування",
+ "settings.projects.shared.actions.hide": "Сховати для мене",
+ "settings.projects.shared.actions.show": "Показати",
+ "settings.projects.shared.hiddenBadge": "Сховано",
+ "settings.projects.shared.replaceMode": "Використовувати лише мої команди налаштування, пропустити ті, що з репозиторію",
+ "settings.projects.shared.replaceModeAria": "Використовувати лише мої команди налаштування, пропустити ті, що з репозиторію",
+ "settings.projects.shared.toast.shareFailed": "Не вдалося оновити конфіг у репозиторії",
"settings.projects.actions.description": "Команди для кожного проєкту відображаються в заголовку біля назви проєкту.",
"settings.projects.actions.validation.fillNameAndCommand": "Введіть назву дії та команду перед збереженням.",
"settings.projects.actions.state.loading": "Завантаження...",
@@ -976,10 +1004,10 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Перезапускає застосунок, щоб телефони, планшети та інші комп’ютери в мережі Wi-Fi могли його відкрити.",
"settings.openchamber.desktopNetwork.field.warning": "Попередження: якщо це ввімкнено, застосунок доступний усім у тій самій локальній мережі.",
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "Для LAN-доступу потрібен пароль десктопного UI. Доки його не задано, десктопний застосунок запускається лише локально.",
- "settings.openchamber.desktopPassword.actions.showPassword": "Показати пароль",
- "settings.openchamber.desktopPassword.actions.hidePassword": "Приховати пароль",
"settings.openchamber.desktopPassword.field.password": "Пароль для десктопного UI",
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "Пароль не потрібен",
+ "settings.openchamber.desktopPassword.field.passwordSetPlaceholder": "Пароль встановлено. Введіть новий, щоб замінити.",
+ "settings.openchamber.desktopPassword.actions.removePassword": "Видалити пароль",
"settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber попросить пароль після перезапуску, а потім коли сесія логіну спливе: через 12 годин або через 7 днів із «Довіряти цьому пристрою». Залиште порожнім, щоб вимкнути логін.",
"settings.openchamber.desktopNetwork.hint.openAfterRestart": "Після перезавантаження відкрити з іншого пристрою: ",
"settings.openchamber.desktopNetwork.hint.openNow": "Відкрити з іншого пристрою: ",
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index 87625a6d..8352061e 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -1693,6 +1693,9 @@ export const dict: Record = {
"rightSidebar.contextNotesTodo.plans.importFromFile": "Імпортувати план із файлу",
"rightSidebar.contextNotesTodo.plans.empty": "Ще немає збережених планів.",
"rightSidebar.contextNotesTodo.plans.deletePlan": "Видалити план",
+ "rightSidebar.contextNotesTodo.plans.sharedBadge": "У репозиторії",
+ "rightSidebar.contextNotesTodo.plans.share": "Перенести в теку планів репозиторію, щоб його бачив кожен, хто клонує репозиторій",
+ "rightSidebar.contextNotesTodo.plans.makePersonal": "Перенести в мої плани, з репозиторію",
"rightSidebar.contextNotesTodo.plans.deletePlanWithTitle": "Видалити план \"{title}\"",
"rightSidebar.contextNotesTodo.sendDialog.title.newSession": "Надіслати до нової сесії",
"rightSidebar.contextNotesTodo.sendDialog.title.newWorktree": "Надіслати до нової сесії в worktree",
@@ -1711,6 +1714,7 @@ export const dict: Record = {
"rightSidebar.contextNotesTodo.toast.sendTodoFailed": "Не вдалося надіслати завдання",
"rightSidebar.contextNotesTodo.toast.updatePlanFailed": "Не вдалося оновити план",
"rightSidebar.contextNotesTodo.toast.deletePlanFailed": "Не вдалося видалити план",
+ "rightSidebar.contextNotesTodo.toast.movePlanFailed": "Не вдалося перемістити план",
"rightSidebar.contextNotesTodo.toast.planFileEmpty": "Файл плану порожній",
"rightSidebar.contextNotesTodo.toast.importPlanFailed": "Не вдалося імпортувати план",
"rightSidebar.contextNotesTodo.toast.planImported": "План імпортовано",
@@ -2100,6 +2104,9 @@ export const dict: Record = {
"chat.draftStarters.sectionCommands": "Команди",
"chat.draftStarters.sectionSkills": "Скіли",
"chat.draftStarters.remove": "Прибрати",
+ "chat.draftStarters.sharedTitle": "Закріплено в конфігу репозиторію; змінюйте там",
+ "chat.draftStarters.share": "Перенести в конфіг репозиторію",
+ "chat.draftStarters.makePersonal": "Перенести в мої налаштування",
"chat.scrollToBottom.aria": "Прокрутити вниз",
"chat.promptNavigator.aria": "Навігація за промптами",
"chat.promptNavigator.currentPrompt": "Поточний промпт",
@@ -2711,6 +2718,13 @@ export const dict: Record = {
"projectActions.actions.addAction": "Додати дію",
"projectActions.actions.addNewAction": "Додати нову дію",
"projectActions.actions.autoDiscover": "Автовиявлення",
+ "projectActions.menu.sharedBadge": "репо",
+ "projects.sharedTrust.title": "Виконати команди, збережені в цьому репозиторії?",
+ "projects.sharedTrust.description": "{path} у цьому репозиторії містить команди, які виконуються на цьому комп'ютері. Довірте їх один раз, і OpenChamber запитає знову лише коли вони зміняться.",
+ "projects.sharedTrust.setupCommands": "Команди налаштування worktree",
+ "projects.sharedTrust.actions": "Дії",
+ "projects.sharedTrust.skip": "Не цього разу",
+ "projects.sharedTrust.trust": "Довірити й виконати",
"projectActions.actions.autoDiscoverTooltip": "Автоматично знаходить і запускає сервер розробки",
"projectActions.actions.chooseActionAria": "Вибрати дію проєкту",
"projectActions.actions.openPreview": "Відкрити Preview",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
index 03934a11..20cde80c 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
@@ -427,6 +427,34 @@ export const settingsDict = {
'settings.common.permission.deny': '拒绝',
'settings.common.state.comingSoon': '即将推出...',
'settings.projects.actions.title': '操作',
+ 'settings.projects.shared.badge': '在仓库中',
+ 'settings.projects.shared.actionsFromRepo': '存储在仓库中({path})。拉取仓库的每个人都会获得。',
+ 'settings.projects.shared.commandsFromRepo': '首先运行,存储在仓库中({path})',
+ 'settings.projects.shared.invalid': '无法读取 {path} 中的项目配置:{reason}',
+ 'settings.projects.shared.trusted': '已在此实例上信任仓库命令',
+ 'settings.projects.shared.resetTrust': '重置信任',
+ 'settings.projects.shared.title': '仓库配置',
+ 'settings.projects.shared.description': '存储在仓库本身的设置,拉取仓库的每个人都会获得相同的操作、设置命令、启动项和计划。在你移入项目之前不会写入任何内容。',
+ 'settings.projects.shared.file': '文件',
+ 'settings.projects.shared.status.missing': '尚未在仓库中',
+ 'settings.projects.shared.status.ok': '已在仓库中',
+ 'settings.projects.shared.plansDir': '计划文件夹',
+ 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans',
+ 'settings.projects.shared.plansDirInfo': '仓库计划的存放位置(相对于仓库)。留空表示 .openchamber/plans。自定义文件夹(如 docs/plans)会完全替代默认值:只读写该文件夹。更改时请自行移动现有文件。',
+ 'settings.projects.shared.plansDirAria': '仓库计划文件夹',
+ 'settings.projects.shared.actions.share': '移至仓库',
+ 'settings.projects.shared.actions.showTitle': '在你的菜单中重新显示此仓库操作。',
+ 'settings.projects.shared.actions.hideTitle': '仅在你的菜单中隐藏此仓库操作;仓库不会改变。',
+ 'settings.projects.shared.actions.makePersonalTitle': '从仓库中移除,仅保留在此实例上你的设置中。',
+ 'settings.projects.shared.actions.shareTitle': '存储到仓库内的 {path},拉取仓库的每个人都会获得。它会从你的个人设置中移除。',
+ 'settings.projects.shared.actions.shareAfterSave': '先保存你的修改,然后再移动',
+ 'settings.projects.shared.actions.makePersonal': '移至我的设置',
+ 'settings.projects.shared.actions.hide': '对我隐藏',
+ 'settings.projects.shared.actions.show': '显示',
+ 'settings.projects.shared.hiddenBadge': '已隐藏',
+ 'settings.projects.shared.replaceMode': '仅使用我的设置命令,跳过仓库中的命令',
+ 'settings.projects.shared.replaceModeAria': '仅使用我的设置命令,跳过仓库中的命令',
+ 'settings.projects.shared.toast.shareFailed': '更新仓库配置失败',
'settings.projects.actions.description': '按项目显示在项目名旁边表头中的命令。',
'settings.projects.actions.validation.fillNameAndCommand': '保存前请填写操作名称和命令。',
'settings.projects.actions.state.loading': '加载中...',
@@ -976,10 +1004,10 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '会重启应用,以便手机、平板和同一 Wi‑Fi 下的其他电脑访问。',
'settings.openchamber.desktopNetwork.field.warning': '警告:启用后,同一本地网络中的任何人都可访问此应用。',
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': '局域网访问需要桌面 UI 密码。在设置密码之前,桌面应用只会以本机访问模式启动。',
- 'settings.openchamber.desktopPassword.actions.showPassword': '显示密码',
- 'settings.openchamber.desktopPassword.actions.hidePassword': '隐藏密码',
'settings.openchamber.desktopPassword.field.password': '桌面 UI 密码',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密码',
+ 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': '已设置密码。输入新密码以替换。',
+ 'settings.openchamber.desktopPassword.actions.removePassword': '移除密码',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 会在重启后要求输入密码,之后在登录会话过期时再次要求:12 小时后,或选择“信任此设备”后 7 天。留空可关闭登录。',
'settings.openchamber.desktopNetwork.hint.openAfterRestart': '重启后可在其他设备打开:',
'settings.openchamber.desktopNetwork.hint.openNow': '可在其他设备打开:',
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index dfdcc364..74b3db61 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -1681,6 +1681,9 @@ export const dict: Record = {
'rightSidebar.contextNotesTodo.plans.importFromFile': '从文件导入计划',
'rightSidebar.contextNotesTodo.plans.empty': '还没有已保存的计划。',
'rightSidebar.contextNotesTodo.plans.deletePlan': '删除计划',
+ 'rightSidebar.contextNotesTodo.plans.sharedBadge': '在仓库中',
+ 'rightSidebar.contextNotesTodo.plans.share': '移至仓库计划文件夹,拉取仓库的每个人都能看到',
+ 'rightSidebar.contextNotesTodo.plans.makePersonal': '移至我的计划,移出仓库',
'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': '删除计划“{title}”',
'rightSidebar.contextNotesTodo.sendDialog.title.newSession': '发送到新会话',
'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': '发送到新工作树',
@@ -1699,6 +1702,7 @@ export const dict: Record = {
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': '发送待办失败',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '更新计划失败',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '删除计划失败',
+ 'rightSidebar.contextNotesTodo.toast.movePlanFailed': '移动计划失败',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': '计划文件为空',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': '导入计划失败',
'rightSidebar.contextNotesTodo.toast.planImported': '计划已导入',
@@ -2088,6 +2092,9 @@ export const dict: Record = {
'chat.draftStarters.sectionCommands': 'Commands',
'chat.draftStarters.sectionSkills': 'Skills',
'chat.draftStarters.remove': 'Remove',
+ 'chat.draftStarters.sharedTitle': '固定在仓库配置中;请在那里修改',
+ 'chat.draftStarters.share': '移至仓库配置',
+ 'chat.draftStarters.makePersonal': '移至我的设置',
'chat.scrollToBottom.aria': '滚动到底部',
'chat.promptNavigator.aria': '提示词导航',
'chat.promptNavigator.currentPrompt': '当前提示',
@@ -2711,6 +2718,13 @@ export const dict: Record = {
'projectActions.actions.addAction': '添加操作',
'projectActions.actions.addNewAction': '添加新操作',
'projectActions.actions.autoDiscover': '自动发现',
+ 'projectActions.menu.sharedBadge': '仓库',
+ 'projects.sharedTrust.title': '运行此仓库中存储的命令?',
+ 'projects.sharedTrust.description': '此仓库中的 {path} 定义了会在本机运行的命令。信任一次后,只有当命令变更时 OpenChamber 才会再次询问。',
+ 'projects.sharedTrust.setupCommands': '工作树设置命令',
+ 'projects.sharedTrust.actions': '操作',
+ 'projects.sharedTrust.skip': '这次不运行',
+ 'projects.sharedTrust.trust': '信任并运行',
'projectActions.actions.autoDiscoverTooltip': '自动发现并运行开发服务器',
'projectActions.actions.chooseActionAria': '选择项目操作',
'projectActions.actions.openPreview': '打开 Preview',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
index 6ac83fb6..7165e9be 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
@@ -424,6 +424,34 @@ export const settingsDict = {
'settings.common.permission.deny': '拒絕',
'settings.common.state.comingSoon': '即將推出...',
'settings.projects.actions.title': '操作',
+ 'settings.projects.shared.badge': '在儲存庫中',
+ 'settings.projects.shared.actionsFromRepo': '儲存在儲存庫中({path})。拉取儲存庫的每個人都會取得。',
+ 'settings.projects.shared.commandsFromRepo': '優先執行,儲存在儲存庫中({path})',
+ 'settings.projects.shared.invalid': '無法讀取 {path} 中的專案設定:{reason}',
+ 'settings.projects.shared.trusted': '已在此執行個體上信任儲存庫命令',
+ 'settings.projects.shared.resetTrust': '重設信任',
+ 'settings.projects.shared.title': '儲存庫設定',
+ 'settings.projects.shared.description': '儲存在儲存庫本身的設定,拉取儲存庫的每個人都會取得相同的動作、設定命令、啟動項和計畫。在你移入項目之前不會寫入任何內容。',
+ 'settings.projects.shared.file': '檔案',
+ 'settings.projects.shared.status.missing': '尚未在儲存庫中',
+ 'settings.projects.shared.status.ok': '已在儲存庫中',
+ 'settings.projects.shared.plansDir': '計畫資料夾',
+ 'settings.projects.shared.plansDirPlaceholder': '.openchamber/plans',
+ 'settings.projects.shared.plansDirInfo': '儲存庫計畫的存放位置(相對於儲存庫)。留空表示 .openchamber/plans。自訂資料夾(如 docs/plans)會完全取代預設值:只讀寫該資料夾。變更時請自行移動現有檔案。',
+ 'settings.projects.shared.plansDirAria': '儲存庫計畫資料夾',
+ 'settings.projects.shared.actions.share': '移至儲存庫',
+ 'settings.projects.shared.actions.showTitle': '在你的選單中重新顯示此儲存庫動作。',
+ 'settings.projects.shared.actions.hideTitle': '僅在你的選單中隱藏此儲存庫動作;儲存庫不會改變。',
+ 'settings.projects.shared.actions.makePersonalTitle': '從儲存庫中移除,僅保留在此執行個體上你的設定中。',
+ 'settings.projects.shared.actions.shareTitle': '儲存到儲存庫內的 {path},拉取儲存庫的每個人都會取得。它會從你的個人設定中移除。',
+ 'settings.projects.shared.actions.shareAfterSave': '先儲存你的修改,然後再移動',
+ 'settings.projects.shared.actions.makePersonal': '移至我的設定',
+ 'settings.projects.shared.actions.hide': '對我隱藏',
+ 'settings.projects.shared.actions.show': '顯示',
+ 'settings.projects.shared.hiddenBadge': '已隱藏',
+ 'settings.projects.shared.replaceMode': '僅使用我的設定命令,略過儲存庫中的命令',
+ 'settings.projects.shared.replaceModeAria': '僅使用我的設定命令,略過儲存庫中的命令',
+ 'settings.projects.shared.toast.shareFailed': '更新儲存庫設定失敗',
'settings.projects.actions.description': '按專案顯示在專案名稱旁標題列中的命令。',
'settings.projects.actions.validation.fillNameAndCommand': '儲存前請填寫操作名稱和命令。',
'settings.projects.actions.state.loading': '載入中...',
@@ -2156,11 +2184,11 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'OpenChamber 執行時保持電腦喚醒',
'settings.openchamber.desktopNetwork.field.keepAwake': 'OpenChamber 執行時保持電腦喚醒',
'settings.openchamber.desktopNetwork.field.keepAwakeDescription': '讓手機可以持續開啟此應用程式。螢幕仍可關閉。',
- 'settings.openchamber.desktopPassword.actions.showPassword': '顯示密碼',
- 'settings.openchamber.desktopPassword.actions.hidePassword': '隱藏密碼',
'settings.openchamber.desktopPassword.field.password': '桌面 UI 密碼',
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 會在重新啟動後要求輸入密碼,之後會在登入工作階段過期時再次要求:12 小時後,或選擇「信任此裝置」後 7 天。留空可停用登入。',
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密碼',
+ 'settings.openchamber.desktopPassword.field.passwordSetPlaceholder': '已設定密碼。輸入新密碼以取代。',
+ 'settings.openchamber.desktopPassword.actions.removePassword': '移除密碼',
'settings.page.plugins.title': '外掛',
'settings.plugins.dialog.add.action.cancel': '取消',
'settings.plugins.dialog.add.action.submit': '新增',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts
index 5d99736d..136717a5 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts
@@ -1691,6 +1691,9 @@ export const dict: Record = {
'rightSidebar.contextNotesTodo.plans.importFromFile': '從檔案匯入計畫',
'rightSidebar.contextNotesTodo.plans.empty': '還沒有已儲存的計畫。',
'rightSidebar.contextNotesTodo.plans.deletePlan': '刪除計畫',
+ 'rightSidebar.contextNotesTodo.plans.sharedBadge': '在儲存庫中',
+ 'rightSidebar.contextNotesTodo.plans.share': '移至儲存庫計畫資料夾,拉取儲存庫的每個人都能看到',
+ 'rightSidebar.contextNotesTodo.plans.makePersonal': '移至我的計畫,移出儲存庫',
'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': '刪除計畫「{title}」',
'rightSidebar.contextNotesTodo.sendDialog.title.newSession': '傳送到新會話',
'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': '傳送到新 worktree',
@@ -1709,6 +1712,7 @@ export const dict: Record = {
'rightSidebar.contextNotesTodo.toast.sendTodoFailed': '傳送待辦失敗',
'rightSidebar.contextNotesTodo.toast.updatePlanFailed': '更新計畫失敗',
'rightSidebar.contextNotesTodo.toast.deletePlanFailed': '刪除計畫失敗',
+ 'rightSidebar.contextNotesTodo.toast.movePlanFailed': '移動計畫失敗',
'rightSidebar.contextNotesTodo.toast.planFileEmpty': '計畫檔案為空',
'rightSidebar.contextNotesTodo.toast.importPlanFailed': '匯入計畫失敗',
'rightSidebar.contextNotesTodo.toast.planImported': '計畫已匯入',
@@ -2092,6 +2096,9 @@ export const dict: Record = {
'chat.draftStarters.sectionCommands': 'Commands',
'chat.draftStarters.sectionSkills': 'Skills',
'chat.draftStarters.remove': 'Remove',
+ 'chat.draftStarters.sharedTitle': '釘選在儲存庫設定中;請在那裡修改',
+ 'chat.draftStarters.share': '移至儲存庫設定',
+ 'chat.draftStarters.makePersonal': '移至我的設定',
'chat.scrollToBottom.aria': '捲動到底部',
'chat.promptNavigator.aria': '提示詞導覽',
'chat.promptNavigator.currentPrompt': '目前提示',
@@ -2715,6 +2722,13 @@ export const dict: Record = {
'projectActions.actions.addAction': '新增操作',
'projectActions.actions.addNewAction': '新增新操作',
'projectActions.actions.autoDiscover': '自動發現',
+ 'projectActions.menu.sharedBadge': '儲存庫',
+ 'projects.sharedTrust.title': '執行此儲存庫中儲存的命令?',
+ 'projects.sharedTrust.description': '此儲存庫中的 {path} 定義了會在本機執行的命令。信任一次後,只有當命令變更時 OpenChamber 才會再次詢問。',
+ 'projects.sharedTrust.setupCommands': '工作樹設定命令',
+ 'projects.sharedTrust.actions': '動作',
+ 'projects.sharedTrust.skip': '這次不執行',
+ 'projects.sharedTrust.trust': '信任並執行',
'projectActions.actions.autoDiscoverTooltip': '自動探索並執行開發伺服器',
'projectActions.actions.chooseActionAria': '選擇專案操作',
'projectActions.actions.openPreview': '開啟預覽',
diff --git a/packages/ui/src/lib/modelPrefsAutoSave.ts b/packages/ui/src/lib/modelPrefsAutoSave.ts
index bef31d31..efb81af0 100644
--- a/packages/ui/src/lib/modelPrefsAutoSave.ts
+++ b/packages/ui/src/lib/modelPrefsAutoSave.ts
@@ -1,5 +1,5 @@
import { useUIStore } from '@/stores/useUIStore';
-import { updateDesktopSettings } from '@/lib/persistence';
+import { isApplyingServerSettings, updateDesktopSettings } from '@/lib/persistence';
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
type ModelRef = { providerID: string; modelID: string };
@@ -133,6 +133,12 @@ export const startModelPrefsAutoSave = () => {
if (modelPrefsEqual(next, prev)) {
return;
}
+ // Adopted from the server by the settings sync: that is the new baseline,
+ // not a change of this window's to send back.
+ if (isApplyingServerSettings()) {
+ lastSent = cloneModelPrefs(next);
+ return;
+ }
schedule();
});
diff --git a/packages/ui/src/lib/openchamberConfig.test.ts b/packages/ui/src/lib/openchamberConfig.test.ts
index 267b3e49..6bc34342 100644
--- a/packages/ui/src/lib/openchamberConfig.test.ts
+++ b/packages/ui/src/lib/openchamberConfig.test.ts
@@ -2,149 +2,197 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { createProjectIdFromPath } from './projectId';
-const homeDirectory = '/Users/test';
const project = { id: 'openchamber', path: '/workspace/openchamber' };
+const endpoint = `/api/projects/${encodeURIComponent(createProjectIdFromPath(project.path))}/config`;
-let files = new Map();
+const emptyPersonal = {
+ setupWorktree: [],
+ setupWorktreeWait: null,
+ setupWorktreeMode: 'append',
+ projectActions: [],
+ projectActionsPrimaryId: null,
+ draftStarters: [],
+ hiddenSharedActionIds: [],
+ sharedTrust: null,
+};
-mock.module('@/contexts/runtimeAPIRegistry', () => ({
- getRegisteredRuntimeAPIs: mock(() => ({
- files: {
- createDirectory: mock(async () => ({ success: true })),
- readFile: mock(async (path: string) => ({ content: files.get(path) ?? '' })),
- writeFile: mock(async (path: string, content: string) => {
- files.set(path, content);
- return { success: true };
- }),
- delete: mock(async (path: string) => {
- files.delete(path);
- }),
- },
- })),
-}));
+const emptyShared = {
+ status: 'missing',
+ path: '.openchamber/project.json',
+ setupWorktree: [],
+ setupWorktreeWait: null,
+ projectActions: [],
+ draftStarters: [],
+ plansDir: null,
+};
-mock.module('@/lib/desktop', () => ({
- getDesktopHomeDirectory: mock(async () => homeDirectory),
- isVSCodeRuntime: mock(() => false),
-}));
+// A minimal stand-in for the server: one personal document per project, the
+// PUT merges the patch and echoes the merged view back like the real route.
+let stored: Record = { ...emptyPersonal };
+let sharedOverride: Record | null = null;
+let viewOverride: Record | null = null;
+
+const viewOf = (): Record => {
+ if (viewOverride) return viewOverride;
+ const personal = { ...emptyPersonal, ...stored };
+ const shared = { ...emptyShared, ...(sharedOverride ?? {}) };
+ const actions = personal.projectActions as Array>;
+ // The real server sanitizes starters before merging; the stand-in does the same.
+ const starters = (personal.draftStarters as Array>)
+ .filter((starter) => starter.type === 'command' || starter.type === 'skill');
+ return {
+ trust: { hash: null, trusted: true },
+ setupWorktree: [...(shared.setupWorktree as string[]), ...(personal.setupWorktree as string[])],
+ setupWorktreeWait: personal.setupWorktreeWait ?? shared.setupWorktreeWait ?? false,
+ projectActions: [
+ ...(shared.projectActions as Array>).map((action) => ({ ...action, source: 'shared' })),
+ ...actions.map((action) => ({ ...action, source: 'personal' })),
+ ],
+ projectActionsPrimaryId: personal.projectActionsPrimaryId,
+ draftStarters: [
+ ...(shared.draftStarters as Array>).map((starter) => ({ ...starter, source: 'shared' })),
+ ...starters.map((starter) => ({ ...starter, source: 'personal' })),
+ ],
+ shared,
+ personal,
+ };
+};
+let requests: Array<{ url: string; method: string; body: unknown }> = [];
+let failWith: number | null = null;
mock.module('@/lib/runtime-fetch', () => ({
- runtimeFetch: mock(async (url: string) => {
- if (url.endsWith('/fs/home')) {
- return new Response(JSON.stringify({ home: homeDirectory }), {
- headers: { 'Content-Type': 'application/json' },
- });
+ runtimeFetch: mock(async (url: string, init?: RequestInit) => {
+ const method = init?.method ?? 'GET';
+ const body = typeof init?.body === 'string' ? JSON.parse(init.body) : null;
+ requests.push({ url, method, body });
+ if (failWith !== null) {
+ return new Response(JSON.stringify({ error: 'nope' }), { status: failWith });
}
-
- return new Response(JSON.stringify({ success: true }), {
- headers: { 'Content-Type': 'application/json' },
- });
+ if (method === 'PUT' && url.endsWith('/shared')) {
+ sharedOverride = { ...(sharedOverride ?? {}), status: 'ok', ...(body as Record) };
+ } else if (method === 'PUT') {
+ const patch = { ...(body as Record) };
+ delete patch.projectPath;
+ stored = { ...stored, ...patch };
+ }
+ return new Response(JSON.stringify(viewOf()), { headers: { 'Content-Type': 'application/json' } });
}),
}));
const {
getProjectActionsState,
+ getProjectDraftStarters,
+ getProjectSetup,
+ getWorktreeSetupCommands,
+ getWorktreeSetupWaitEnabled,
saveProjectActionsState,
+ saveWorktreeSetupCommands,
+ updateSharedProjectSetup,
} = await import('./openchamberConfig');
-const getConfigPath = (projectPath: string): string => (
- `${homeDirectory}/.config/openchamber/projects/${createProjectIdFromPath(projectPath)}.json`
-);
-
-describe('project actions config sanitization', () => {
+describe('project config client', () => {
beforeEach(() => {
- files = new Map();
+ stored = { ...emptyPersonal };
+ sharedOverride = null;
+ viewOverride = null;
+ requests = [];
+ failWith = null;
});
- test('round-trips runIn parent through saved project actions state', async () => {
+ test('reads and writes through the project config route, never a file path', async () => {
const saved = await saveProjectActionsState(project, {
- actions: [{
- id: 'action-1',
- name: 'Run action',
- command: 'pnpm dev',
- runIn: 'parent',
- }],
+ actions: [{ id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'parent' }],
primaryActionId: 'action-1',
});
-
expect(saved).toBe(true);
+ expect(requests[0]).toEqual({
+ url: endpoint,
+ method: 'PUT',
+ body: {
+ projectActions: [{ id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'parent' }],
+ projectActionsPrimaryId: 'action-1',
+ projectPath: project.path,
+ },
+ });
const state = await getProjectActionsState(project);
-
expect(state).toEqual({
- actions: [{
- id: 'action-1',
- name: 'Run action',
- command: 'pnpm dev',
- icon: null,
- runIn: 'parent',
- }],
+ actions: [{ id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'parent', source: 'personal' }],
primaryActionId: 'action-1',
});
+ expect(requests[1]).toEqual({ url: endpoint, method: 'GET', body: null });
});
- test('keeps runIn omitted when saving project actions in the current worktree', async () => {
- const saved = await saveProjectActionsState(project, {
- actions: [{
- id: 'action-1',
- name: 'Run action',
- command: 'pnpm dev',
- }],
- primaryActionId: 'action-1',
- });
-
- expect(saved).toBe(true);
-
- const state = await getProjectActionsState(project);
-
- expect(state).toEqual({
- actions: [{
- id: 'action-1',
- name: 'Run action',
- command: 'pnpm dev',
- icon: null,
- }],
- primaryActionId: 'action-1',
- });
+ test('exposes the merged view with the shared and personal blocks', async () => {
+ stored = { ...emptyPersonal, setupWorktree: ['cp .env.example .env'], draftStarters: [{ type: 'command', name: 'mine' }] };
+ sharedOverride = { status: 'ok', setupWorktree: ['bun install'], setupWorktreeWait: true, plansDir: 'docs/plans', draftStarters: [{ type: 'skill', name: 'triage-prs' }] };
+ const setup = await getProjectSetup(project);
+ expect(setup.setupWorktree).toEqual(['bun install', 'cp .env.example .env']);
+ expect(setup.setupWorktreeWait).toBe(true);
+ expect(setup.shared.status).toBe('ok');
+ expect(setup.shared.plansDir).toBe('docs/plans');
+ expect(setup.personal.setupWorktree).toEqual(['cp .env.example .env']);
+ expect(await getProjectDraftStarters(project)).toEqual([
+ { type: 'skill', name: 'triage-prs', source: 'shared' },
+ { type: 'command', name: 'mine', source: 'personal' },
+ ]);
});
- test('normalizes runIn worktree to omission when loading project actions state', async () => {
- files.set(getConfigPath(project.path), JSON.stringify({
+ test('never sends the source mark back when saving actions', async () => {
+ await saveProjectActionsState(project, {
+ actions: [{ id: 'a', name: 'A', command: 'x', source: 'personal' }],
+ primaryActionId: null,
+ });
+ expect(requests[0].body).toEqual({
+ projectActions: [{ id: 'a', name: 'A', command: 'x' }],
+ projectActionsPrimaryId: null,
projectPath: project.path,
- projectActions: [
- { id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'worktree' },
- ],
- projectActionsPrimaryId: 'action-1',
- }));
-
- const state = await getProjectActionsState(project);
-
- expect(state).toEqual({
- actions: [
- { id: 'action-1', name: 'Run action', command: 'pnpm dev', icon: null },
- ],
- primaryActionId: 'action-1',
});
});
- test('omits unsupported runIn values when loading project actions state', async () => {
- files.set(getConfigPath(project.path), JSON.stringify({
- projectPath: project.path,
- projectActions: [
- { id: 'action-project', name: 'Project', command: 'pnpm dev', runIn: 'project' },
- { id: 'action-number', name: 'Number', command: 'pnpm test', runIn: 123 },
- ],
- projectActionsPrimaryId: 'action-project',
- }));
+ test('drops empty setup commands before sending', async () => {
+ await saveWorktreeSetupCommands(project, ['bun install', '', ' ']);
+ expect(requests[0].body).toEqual({ setupWorktree: ['bun install'], projectPath: project.path });
+ expect(await getWorktreeSetupCommands(project)).toEqual(['bun install']);
+ });
- const state = await getProjectActionsState(project);
+ test('parses the personal starters defensively from the response', async () => {
+ stored = { ...emptyPersonal, draftStarters: [{ type: 'skill', name: 'triage-prs' }, { type: 'bogus', name: 'x' }] };
+ expect((await getProjectSetup(project)).personal.draftStarters).toEqual([{ type: 'skill', name: 'triage-prs' }]);
+ });
- expect(state).toEqual({
- actions: [
- { id: 'action-project', name: 'Project', command: 'pnpm dev', icon: null },
- { id: 'action-number', name: 'Number', command: 'pnpm test', icon: null },
- ],
- primaryActionId: 'action-project',
+ test('writes the shared file through its own route without source marks and returns the view', async () => {
+ const view = await updateSharedProjectSetup(project, {
+ projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', source: 'personal' }],
+ plansDir: 'docs/plans',
});
+ expect(requests[0]).toEqual({
+ url: `${endpoint}/shared`,
+ method: 'PUT',
+ body: { projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }], plansDir: 'docs/plans' },
+ });
+ expect(view?.shared.plansDir).toBe('docs/plans');
+ expect(view?.projectActions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev', source: 'shared' }]);
+ failWith = 500;
+ expect(await updateSharedProjectSetup(project, { plansDir: null })).toBeNull();
+ });
+
+ test('a failed read resolves to the empty value and a failed write to false', async () => {
+ failWith = 500;
+ expect(await getWorktreeSetupCommands(project)).toEqual([]);
+ expect(await getWorktreeSetupWaitEnabled(project)).toBe(false);
+ expect(await getProjectActionsState(project)).toEqual({ actions: [], primaryActionId: null });
+ expect(await saveWorktreeSetupCommands(project, ['x'])).toBe(false);
+ });
+
+ test('a response with an unexpected shape is not trusted', async () => {
+ viewOverride = { setupWorktree: 'bun install' };
+ expect(await getWorktreeSetupCommands(project)).toEqual([]);
+ });
+
+ test('a project without a path never hits the network', async () => {
+ expect(await getWorktreeSetupCommands({ id: 'x', path: '' })).toEqual([]);
+ expect(await saveWorktreeSetupCommands({ id: 'x', path: '' }, ['x'])).toBe(false);
+ expect(requests).toHaveLength(0);
});
});
diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts
index 4136b7ac..9888bd64 100644
--- a/packages/ui/src/lib/openchamberConfig.ts
+++ b/packages/ui/src/lib/openchamberConfig.ts
@@ -1,50 +1,36 @@
/**
- * OpenChamber project-level configuration service.
- * Stores per-project settings in ~/.config/openchamber/projects/.json.
- * Migrates from legacy /.openchamber/openchamber.json.
+ * Client for the project setup routes: worktree setup commands, project
+ * actions, and pinned draft starters.
*
- * Notes, todos, and plan files used to live here too. They are now server-owned
- * (`packages/web/server/lib/project-context`) and reached through
- * `@/lib/projectContextApi`; what remains here is the client-owned rest.
+ * A project's setup is the merge of two files the server (or the VS Code
+ * extension host) owns: the personal one in `~/.config/openchamber/projects/`
+ * and, when a team shares it, `/.openchamber/project.json`. The merged
+ * view says what runs; its `shared` and `personal` blocks say where each
+ * entry came from, so a Settings page edits the personal block and never
+ * copies a teammate's entry into it. This module only speaks HTTP: it
+ * resolves no home directory and composes no path, so the same code serves
+ * web, desktop, VS Code, and the phone, including a phone driving a remote
+ * instance.
+ *
+ * Reads keep the contract callers were written against: a failed read logs
+ * and resolves to the empty setup, because worktree creation and the new
+ * session screen must keep working when the config cannot be fetched.
+ * Writes resolve `false` on failure.
*/
-import type { FilesAPI } from './api/types';
-import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
-import { getDesktopHomeDirectory } from './desktop';
-import { isVSCodeRuntime } from './desktop';
+import { z } from 'zod';
+
import { sanitizeStarterRefs, type DraftStarterRef } from './draftStarters';
import { createProjectIdFromPath } from './projectId';
import { runtimeFetch } from './runtime-fetch';
type ProjectRef = { id: string; path: string };
-const CONFIG_FILENAME = 'openchamber.json';
-// LEGACY_PROJECT_CONFIG: legacy per-project config root inside repo.
-const LEGACY_CONFIG_DIR = '.openchamber';
-const USER_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects'];
-
-/**
- * Get the runtime Files API if available (Desktop/VSCode).
- */
-function getRuntimeFilesAPI(): FilesAPI | null {
- const apis = getRegisteredRuntimeAPIs();
- if (apis?.files) {
- return apis.files;
- }
- return null;
-}
-
-interface OpenChamberConfig {
- projectPath?: string;
- 'setup-worktree'?: string[];
- 'setup-worktree-wait'?: boolean;
- projectActions?: OpenChamberProjectAction[];
- projectActionsPrimaryId?: string;
- draftStarters?: DraftStarterRef[];
-}
-
type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows';
+/** Where a merged entry came from: the repo's shared file or the user's own file. */
+export type ProjectSetupSource = 'shared' | 'personal';
+
export interface OpenChamberProjectAction {
id: string;
name: string;
@@ -55,6 +41,8 @@ export interface OpenChamberProjectAction {
autoOpenUrl?: boolean;
openUrl?: string;
desktopOpenSshForward?: string;
+ /** Present on merged entries only. */
+ source?: ProjectSetupSource;
}
export interface OpenChamberProjectActionsState {
@@ -62,486 +50,261 @@ export interface OpenChamberProjectActionsState {
primaryActionId: string | null;
}
-const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80;
-const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000;
-const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000;
-const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300;
+export type ProjectDraftStarter = DraftStarterRef & { source: ProjectSetupSource };
-const OPENCHAMBER_ACTION_PLATFORM_SET = new Set(['macos', 'linux', 'windows']);
+/** The view the server returns; the server sanitizes, the client only checks the shape. */
+const sourceSchema = z.enum(['shared', 'personal']);
-const normalize = (value: string): string => {
- if (!value) return '';
- const replaced = value.replace(/\\/g, '/');
- return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
+const projectActionSchema = z.object({
+ id: z.string().min(1),
+ name: z.string().min(1),
+ command: z.string().min(1),
+ icon: z.string().nullable().optional(),
+ runIn: z.literal('parent').optional(),
+ platforms: z.array(z.enum(['macos', 'linux', 'windows'])).optional(),
+ autoOpenUrl: z.literal(true).optional(),
+ openUrl: z.string().optional(),
+ desktopOpenSshForward: z.string().optional(),
+});
+
+const starterRefsSchema = z.unknown().transform((value) => sanitizeStarterRefs(value));
+
+const sourcedStartersSchema = z.array(z.object({
+ type: z.enum(['command', 'skill']),
+ name: z.string().min(1),
+ source: sourceSchema,
+}));
+
+const sharedSchema = z.object({
+ status: z.enum(['missing', 'ok', 'invalid']),
+ reason: z.string().optional(),
+ path: z.string(),
+ setupWorktree: z.array(z.string()),
+ setupWorktreeWait: z.boolean().nullable(),
+ projectActions: z.array(projectActionSchema),
+ draftStarters: starterRefsSchema,
+ plansDir: z.string().nullable(),
+});
+
+const personalSchema = z.object({
+ setupWorktree: z.array(z.string()),
+ setupWorktreeWait: z.boolean().nullable(),
+ setupWorktreeMode: z.enum(['append', 'replace']),
+ projectActions: z.array(projectActionSchema),
+ projectActionsPrimaryId: z.string().nullable(),
+ draftStarters: starterRefsSchema,
+ hiddenSharedActionIds: z.array(z.string()),
+ sharedTrust: z.object({ hash: z.string(), trustedAt: z.number() }).nullable(),
+});
+
+const projectSetupSchema = z.object({
+ /** Nothing to trust when `hash` is null; otherwise trusted only for the recorded hash. */
+ trust: z.object({ hash: z.string().nullable(), trusted: z.boolean() }),
+ setupWorktree: z.array(z.string()),
+ setupWorktreeWait: z.boolean(),
+ projectActions: z.array(projectActionSchema.extend({ source: sourceSchema })),
+ projectActionsPrimaryId: z.string().nullable(),
+ draftStarters: sourcedStartersSchema,
+ shared: sharedSchema,
+ personal: personalSchema,
+});
+
+export type ProjectSetup = z.infer;
+
+/** What a client may change: the personal file only. */
+export type ProjectSetupPatch = Partial<{
+ setupWorktree: string[];
+ setupWorktreeWait: boolean;
+ setupWorktreeMode: 'append' | 'replace';
+ projectActions: OpenChamberProjectAction[];
+ projectActionsPrimaryId: string | null;
+ draftStarters: DraftStarterRef[];
+ hiddenSharedActionIds: string[];
+ /** The trust answer for the shared commands with this hash; `null` forgets it. */
+ sharedTrustHash: string | null;
+}>;
+
+const EMPTY_PROJECT_SETUP: ProjectSetup = {
+ trust: { hash: null, trusted: true },
+ setupWorktree: [],
+ setupWorktreeWait: false,
+ projectActions: [],
+ projectActionsPrimaryId: null,
+ draftStarters: [],
+ shared: {
+ status: 'missing',
+ path: '.openchamber/project.json',
+ setupWorktree: [],
+ setupWorktreeWait: null,
+ projectActions: [],
+ draftStarters: [],
+ plansDir: null,
+ },
+ personal: {
+ setupWorktree: [],
+ setupWorktreeWait: null,
+ setupWorktreeMode: 'append',
+ projectActions: [],
+ projectActionsPrimaryId: null,
+ draftStarters: [],
+ hiddenSharedActionIds: [],
+ sharedTrust: null,
+ },
};
-const joinPath = (base: string, segment: string): string => {
- const normalizedBase = normalize(base);
- const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
- if (!normalizedBase || normalizedBase === '/') {
- return `/${cleanSegment}`;
+/**
+ * The storage id is derived from the project path, not from `project.id`:
+ * project ids in settings have churned across versions, and the path-derived
+ * id is what names the config file on disk and locates the checkout.
+ */
+const resolveProjectSetupId = (project: ProjectRef): string => {
+ const projectPath = typeof project?.path === 'string' ? project.path.trim() : '';
+ return projectPath ? createProjectIdFromPath(projectPath) : '';
+};
+
+const endpointFor = (projectId: string): string => `/api/projects/${encodeURIComponent(projectId)}/config`;
+
+const parseSetupResponse = async (response: Response): Promise => {
+ const parsed = projectSetupSchema.safeParse(await response.json());
+ if (!parsed.success) {
+ throw new Error('Project config response has an unexpected shape');
}
- return `${normalizedBase}/${cleanSegment}`;
+ return parsed.data;
};
-const getLegacyConfigPath = (projectDirectory: string): string => {
- return joinPath(joinPath(projectDirectory, LEGACY_CONFIG_DIR), CONFIG_FILENAME);
-};
-
-const getBaseUrl = (): string => {
- const defaultBaseUrl = import.meta.env.VITE_OPENCODE_URL || '/api';
- if (defaultBaseUrl.startsWith('/')) {
- return defaultBaseUrl;
- }
- return defaultBaseUrl;
-};
-
-const postJson = async (url: string, body: unknown): Promise<{ ok: boolean; data: T | null }> => {
+/** The project's merged setup, or the empty setup when it cannot be read. */
+export async function getProjectSetup(project: ProjectRef): Promise {
+ const projectId = resolveProjectSetupId(project);
+ if (!projectId) return EMPTY_PROJECT_SETUP;
try {
- const response = await runtimeFetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- if (!response.ok) {
- return { ok: false, data: null };
- }
- const data = (await response.json().catch(() => null)) as T | null;
- return { ok: true, data };
- } catch {
- return { ok: false, data: null };
- }
-};
-
-const mkdirp = async (path: string): Promise => {
- const runtimeFiles = getRuntimeFilesAPI();
- if (runtimeFiles?.createDirectory) {
- try {
- const result = await runtimeFiles.createDirectory(path);
- if (result?.success) {
- return true;
- }
- } catch {
- // fall through
- }
- }
-
- const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/mkdir`, { path });
- return Boolean(res.ok);
-};
-
-const readTextFile = async (path: string): Promise => {
- const runtimeFiles = getRuntimeFilesAPI();
- if (runtimeFiles?.readFile) {
- try {
- const result = await runtimeFiles.readFile(path);
- const content = typeof result?.content === 'string' ? result.content : '';
- return content;
- } catch {
- return null;
- }
- }
-
- try {
- const response = await runtimeFetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`,
- {
- // Avoid conditional requests (304 + empty body).
- cache: 'no-store',
- }
- );
- if (!response.ok) {
- return null;
- }
- return await response.text();
- } catch {
- return null;
- }
-};
-
-const writeTextFile = async (path: string, content: string): Promise => {
- const runtimeFiles = getRuntimeFilesAPI();
- if (runtimeFiles?.writeFile) {
- try {
- const result = await runtimeFiles.writeFile(path, content);
- if (result?.success) {
- return true;
- }
- } catch {
- // fall through
- }
- }
-
- const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/write`, { path, content });
- return Boolean(res.ok);
-};
-
-const resolveHomeDirectory = async (): Promise => {
- // Use server-reported home as the source of truth for user config paths.
- // In some runtimes, window.__OPENCHAMBER_HOME__ can be workspace/project-root
- // scoped, which would incorrectly route writes into the project directory.
- try {
- const response = await runtimeFetch(`${getBaseUrl()}/fs/home`, {
- // Avoid conditional requests (304 + empty body).
+ const response = await runtimeFetch(endpointFor(projectId), {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!response.ok) {
- throw new Error('Failed to resolve home directory from API');
+ throw new Error(`HTTP ${response.status}`);
}
- const payload = await response.json().catch(() => null) as { home?: unknown } | null;
- const home = typeof payload?.home === 'string' ? payload.home.trim() : '';
- if (home) {
- return normalize(home);
- }
- } catch {
- // fall through
- }
-
- // Fallback for environments where /api/fs/home is unavailable.
- // VSCode intentionally avoids this because embedded home equals workspace path.
- if (!isVSCodeRuntime()) {
- const desktopHome = await getDesktopHomeDirectory().catch(() => null);
- if (desktopHome && desktopHome.trim().length > 0) {
- return normalize(desktopHome);
- }
- }
- return null;
-};
-
-const getUserProjectsDirectory = async (): Promise => {
- const home = await resolveHomeDirectory();
- if (!home) {
- return null;
- }
- return USER_PROJECTS_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home);
-};
-
-const resolveConfigProjectId = (project: ProjectRef): string | null => {
- const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
- const normalizedProject = projectDirectory ? normalize(projectDirectory) : '';
- if (!normalizedProject) return null;
- return createProjectIdFromPath(normalizedProject) || null;
-};
-
-const getUserConfigPath = async (project: ProjectRef): Promise => {
- const base = await getUserProjectsDirectory();
- if (!base) {
- return null;
- }
- const safeId = resolveConfigProjectId(project);
- if (!safeId) {
- return null;
- }
- return joinPath(base, `${safeId}.json`);
-};
-
-const trimToMaxLength = (value: string, maxLength: number): string => {
- if (value.length <= maxLength) {
- return value;
- }
- return value.slice(0, maxLength);
-};
-
-const sanitizeProjectActionPlatforms = (value: unknown): OpenChamberProjectActionPlatform[] => {
- if (!Array.isArray(value)) {
- return [];
- }
-
- const unique: OpenChamberProjectActionPlatform[] = [];
- const seen = new Set();
- for (const entry of value) {
- if (typeof entry !== 'string') {
- continue;
- }
- const normalized = entry.trim().toLowerCase() as OpenChamberProjectActionPlatform;
- if (!OPENCHAMBER_ACTION_PLATFORM_SET.has(normalized) || seen.has(normalized)) {
- continue;
- }
- seen.add(normalized);
- unique.push(normalized);
- }
-
- return unique;
-};
-
-const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
- if (!Array.isArray(value)) {
- return [];
- }
-
- const sanitized: OpenChamberProjectAction[] = [];
- const seenIds = new Set();
-
- for (const entry of value) {
- if (!entry || typeof entry !== 'object') {
- continue;
- }
-
- const record = entry as {
- id?: unknown;
- name?: unknown;
- command?: unknown;
- icon?: unknown;
- runIn?: unknown;
- platforms?: unknown;
- autoOpenUrl?: unknown;
- openUrl?: unknown;
- desktopOpenSshForward?: unknown;
- };
-
- const id = typeof record.id === 'string' ? record.id.trim() : '';
- const name = trimToMaxLength(typeof record.name === 'string' ? record.name.trim() : '', OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH);
- const command = trimToMaxLength(typeof record.command === 'string' ? record.command.trim() : '', OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH);
-
- if (!id || !name || !command || seenIds.has(id)) {
- continue;
- }
- seenIds.add(id);
-
- const iconRaw = typeof record.icon === 'string' ? record.icon.trim() : '';
- const runIn = record.runIn === 'parent' ? 'parent' : undefined;
- const platforms = sanitizeProjectActionPlatforms(record.platforms);
- const autoOpenUrl = record.autoOpenUrl === true;
- const openUrlRaw = typeof record.openUrl === 'string' ? record.openUrl.trim() : '';
- const openUrl = trimToMaxLength(openUrlRaw, OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH);
- const desktopOpenSshForwardRaw = typeof record.desktopOpenSshForward === 'string'
- ? record.desktopOpenSshForward.trim()
- : '';
- const desktopOpenSshForward = trimToMaxLength(
- desktopOpenSshForwardRaw,
- OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH
- );
-
- const sanitizedAction: OpenChamberProjectAction = {
- id,
- name,
- command,
- icon: iconRaw || null,
- ...(autoOpenUrl ? { autoOpenUrl: true } : {}),
- ...(openUrl ? { openUrl } : {}),
- ...(desktopOpenSshForward ? { desktopOpenSshForward } : {}),
- ...(platforms.length > 0 ? { platforms } : {}),
- };
- if (runIn) {
- sanitizedAction.runIn = runIn;
- }
- sanitized.push(sanitizedAction);
- }
-
- return sanitized;
-};
-
-const sanitizeProjectActionsState = (value: {
- actions?: unknown;
- primaryActionId?: unknown;
-} | null | undefined): OpenChamberProjectActionsState => {
- const actions = sanitizeProjectActions(value?.actions);
- const primaryRaw = typeof value?.primaryActionId === 'string' ? value.primaryActionId.trim() : '';
- const primaryActionId = primaryRaw && actions.some((entry) => entry.id === primaryRaw)
- ? primaryRaw
- : null;
-
- return {
- actions,
- primaryActionId,
- };
-};
-
-/**
- * Read the config for a project.
- * Returns null if file doesn't exist or is invalid.
- */
-async function readOpenChamberConfig(project: ProjectRef): Promise {
- const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
- if (!projectDirectory) {
- return null;
- }
-
- const configPath = await getUserConfigPath(project);
-
- const readText = async (path: string): Promise => {
- // Keep behavior consistent with other helpers.
- const text = await readTextFile(path);
- if (text === null) {
- return null;
- }
- return text;
- };
-
- const parseConfig = (text: string | null): OpenChamberConfig | null => {
- if (typeof text !== 'string') {
- return null;
- }
- const trimmed = text.trim();
- if (!trimmed) {
- return null;
- }
- try {
- const parsed = JSON.parse(trimmed);
- if (!parsed || typeof parsed !== 'object') {
- return null;
- }
- return parsed as OpenChamberConfig;
- } catch {
- return null;
- }
- };
-
- // 1) Prefer new per-user config.
- if (configPath) {
- const existing = parseConfig(await readText(configPath));
- if (existing) {
- return existing;
- }
- }
-
- // 2) Migrate legacy /.openchamber/openchamber.json.
- // LEGACY_PROJECT_CONFIG: migrate project-local openchamber.json -> ~/.config/openchamber/projects/.json
- const legacyPath = getLegacyConfigPath(projectDirectory);
- const legacyConfig = parseConfig(await readText(legacyPath));
- if (!legacyConfig) {
- return null;
- }
-
- // Best-effort write + delete legacy.
- try {
- const wrote = await writeOpenChamberConfig(project, legacyConfig);
- if (wrote) {
- await deleteLegacyOpenChamberConfig(projectDirectory);
- }
- } catch {
- // Ignore migration failures; still return legacy content.
- }
-
- return legacyConfig;
-}
-
-/**
- * Write the per-user config for a project.
- *
- * Server owns `version` and `scheduledTasks` keys; client reads them via their
- * dedicated route and never round-trips them through this config write path to
- * avoid a read-then-write race clobbering a concurrent server update.
- */
-async function writeOpenChamberConfig(
- project: ProjectRef,
- config: OpenChamberConfig
-): Promise {
- const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
- if (!projectDirectory) {
- return false;
- }
-
- const configDir = await getUserProjectsDirectory();
- const configPath = await getUserConfigPath(project);
- if (!configDir || !configPath) {
- return false;
- }
-
- try {
- const okDir = await mkdirp(configDir);
- if (!okDir) {
- return false;
- }
-
- const existingRaw = await readTextFile(configPath);
- let existing: Record = {};
- if (typeof existingRaw === 'string' && existingRaw.trim()) {
- try {
- const parsed = JSON.parse(existingRaw);
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
- existing = parsed as Record;
- }
- } catch {
- existing = {};
- }
- }
-
- const serverOwned: Record = {};
- if (existing.version !== undefined) serverOwned.version = existing.version;
- if (existing.scheduledTasks !== undefined) serverOwned.scheduledTasks = existing.scheduledTasks;
-
- const content = JSON.stringify({
- ...existing,
- ...config,
- ...serverOwned,
- projectPath: normalize(projectDirectory),
- }, null, 2);
- return await writeTextFile(configPath, content);
+ return await parseSetupResponse(response);
} catch (error) {
- console.error('Failed to write openchamber config:', error);
+ console.warn('Failed to read project config:', error);
+ return EMPTY_PROJECT_SETUP;
+ }
+}
+
+/** Change the personal part of the project's setup. */
+export async function updateProjectSetup(project: ProjectRef, patch: ProjectSetupPatch): Promise {
+ const projectId = resolveProjectSetupId(project);
+ if (!projectId) return false;
+ try {
+ const response = await runtimeFetch(endpointFor(projectId), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ ...patch, projectPath: project.path.trim() }),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ await parseSetupResponse(response);
+ return true;
+ } catch (error) {
+ console.warn('Failed to save project config:', error);
return false;
}
}
+/** What a client may change in the team's shared file; every named key replaces the current value. */
+export type SharedProjectSetupPatch = Partial<{
+ setupWorktree: string[];
+ setupWorktreeWait: boolean | null;
+ projectActions: OpenChamberProjectAction[];
+ draftStarters: DraftStarterRef[];
+ plansDir: string | null;
+}>;
+
/**
- * Update specific keys in the config, preserving other values.
+ * Change the team's shared file in the checkout (`/.openchamber/project.json`).
+ * The server removes the file when nothing is left in it, and records trust
+ * for the commands this instance just shared. Resolves the merged view, or
+ * `null` on failure so a caller can tell "saved nothing" from "saved and empty".
*/
-async function updateOpenChamberConfig(
- project: ProjectRef,
- updates: Partial
-): Promise {
- const existing = await readOpenChamberConfig(project) || {};
- const merged = { ...existing, ...updates };
- return writeOpenChamberConfig(project, merged);
+export async function updateSharedProjectSetup(project: ProjectRef, patch: SharedProjectSetupPatch): Promise {
+ const projectId = resolveProjectSetupId(project);
+ if (!projectId) return null;
+ const body: SharedProjectSetupPatch = { ...patch };
+ if (patch.projectActions) body.projectActions = patch.projectActions.map(withoutSource);
+ try {
+ const response = await runtimeFetch(`${endpointFor(projectId)}/shared`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ return await parseSetupResponse(response);
+ } catch (error) {
+ console.warn('Failed to save the shared project config:', error);
+ return null;
+ }
}
/**
- * Get worktree setup commands from config.
+ * The commands a new worktree runs: shared first, then personal (or personal
+ * only in replace mode). Code that is about to run them goes through
+ * `resolveWorktreeSetupCommands` in `lib/sharedTrustConfirmation.ts` instead,
+ * which asks for trust the first time the shared ones would run.
*/
export async function getWorktreeSetupCommands(project: ProjectRef): Promise {
- const config = await readOpenChamberConfig(project);
- return config?.['setup-worktree'] ?? [];
+ return (await getProjectSetup(project)).setupWorktree;
}
export async function saveWorktreeSetupCommands(project: ProjectRef, commands: string[]): Promise {
- const filtered = commands.filter((cmd) => cmd.trim().length > 0);
- return updateOpenChamberConfig(project, { 'setup-worktree': filtered });
+ return updateProjectSetup(project, { setupWorktree: commands.filter((cmd) => cmd.trim().length > 0) });
}
export async function getWorktreeSetupWaitEnabled(project: ProjectRef): Promise {
- const config = await readOpenChamberConfig(project);
- return config?.['setup-worktree-wait'] === true;
+ return (await getProjectSetup(project)).setupWorktreeWait;
}
export async function saveWorktreeSetupWaitEnabled(project: ProjectRef, enabled: boolean): Promise {
- return updateOpenChamberConfig(project, { 'setup-worktree-wait': enabled });
+ return updateProjectSetup(project, { setupWorktreeWait: enabled });
}
-/**
- * Get this project's pinned draft welcome starters.
- */
-export async function getProjectDraftStarters(project: ProjectRef): Promise {
- const config = await readOpenChamberConfig(project);
- return sanitizeStarterRefs(config?.draftStarters);
+/** The starters pinned for this project, shared ones first, each marked with its source. */
+export async function getProjectDraftStarters(project: ProjectRef): Promise {
+ return (await getProjectSetup(project)).draftStarters;
}
+/** Replace the user's own project starters; shared ones are untouched. */
export async function saveProjectDraftStarters(project: ProjectRef, starters: DraftStarterRef[]): Promise {
- return updateOpenChamberConfig(project, { draftStarters: sanitizeStarterRefs(starters) });
+ return updateProjectSetup(project, { draftStarters: sanitizeStarterRefs(starters) });
}
+/** The actions the project offers to run: merged, each marked with its source. */
export async function getProjectActionsState(project: ProjectRef): Promise {
- const config = await readOpenChamberConfig(project);
- return sanitizeProjectActionsState({
- actions: config?.projectActions,
- primaryActionId: config?.projectActionsPrimaryId,
- });
+ const setup = await getProjectSetup(project);
+ return { actions: setup.projectActions, primaryActionId: setup.projectActionsPrimaryId };
}
+/** Replace the user's own project actions; shared ones are untouched. */
export async function saveProjectActionsState(
project: ProjectRef,
- value: OpenChamberProjectActionsState
+ value: OpenChamberProjectActionsState,
): Promise {
- const sanitized = sanitizeProjectActionsState({
- actions: value.actions,
- primaryActionId: value.primaryActionId,
- });
-
- return updateOpenChamberConfig(project, {
- projectActions: sanitized.actions,
- projectActionsPrimaryId: sanitized.primaryActionId ?? undefined,
+ return updateProjectSetup(project, {
+ projectActions: value.actions.map(withoutSource),
+ projectActionsPrimaryId: value.primaryActionId,
});
}
+/** The source mark is the server's to add; it never travels back in a write. */
+const withoutSource = (action: OpenChamberProjectAction): OpenChamberProjectAction => {
+ const copy = { ...action };
+ delete copy.source;
+ return copy;
+};
+
/**
* Substitute variables in a command string.
* Supported variables:
@@ -561,24 +324,4 @@ export function substituteCommandVariables(
.replace(/\$\{ROOT_WORKTREE_PATH\}/g, variables.rootWorktreePath);
}
-async function deleteLegacyOpenChamberConfig(projectDirectory: string): Promise {
- const legacyPath = getLegacyConfigPath(projectDirectory);
- const runtimeFiles = getRuntimeFilesAPI();
-
- if (runtimeFiles?.delete) {
- try {
- await runtimeFiles.delete(legacyPath);
- return;
- } catch {
- // fall through
- }
- }
-
- try {
- await postJson(`${getBaseUrl()}/fs/delete`, { path: legacyPath });
- } catch {
- // ignored
- }
-}
-
export type { ProjectRef };
diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts
index 39c5554b..a49ae839 100644
--- a/packages/ui/src/lib/persistence.test.ts
+++ b/packages/ui/src/lib/persistence.test.ts
@@ -35,6 +35,18 @@ type TestWindow = {
let createdWindow = false;
let createdLocalStorage = false;
+let isolatedRuntimeCounter = 0;
+
+// Each test gets its own runtime identity so an in-flight load or save left
+// behind by the previous test is rejected as stale instead of leaking its
+// response into this test's stores or server-known values.
+const isolateRuntime = (): void => {
+ isolatedRuntimeCounter += 1;
+ switchRuntimeEndpoint({
+ apiBaseUrl: `https://isolated-${isolatedRuntimeCounter}.example`,
+ runtimeKey: `isolated-${isolatedRuntimeCounter}`,
+ });
+};
const originalInputHistoryApplyScope = useInputHistoryStore.getState().applyScope;
const originalInputHistoryApplyEntryLimit = useInputHistoryStore.getState().applyEntryLimit;
@@ -160,6 +172,7 @@ describe('applyPersistedHomeDirectoryToWindow', () => {
describe('updateDesktopSettings', () => {
beforeEach(() => {
getWindow();
+ isolateRuntime();
registerRuntimeAPIs(null);
invalidateSettingsCache();
resetModelPrefsState();
@@ -410,15 +423,22 @@ describe('updateDesktopSettings', () => {
expect(localStorage.getItem('selectedThemeId')).toBeNull();
expect(localStorage.getItem('directoryTreeShowHidden')).toBeNull();
expect(localStorage.getItem('sttModel')).toBeNull();
+ // The mirror carries every user-owned field the server returned, so the
+ // draft-starter markers ride along with the three values under test.
expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-a')) ?? '{}')).toEqual({
themeId: 'theme-a',
directoryShowHidden: true,
sttModel: 'model-a',
+ draftStartersCraftGoalAdded: true,
+ draftStartersScheduleTaskAdded: true,
+ });
+ expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-b')) ?? '{}')).toEqual({
+ draftStartersCraftGoalAdded: true,
+ draftStartersScheduleTaskAdded: true,
});
- expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-b')) ?? '{}')).toEqual({});
});
- test('resets in-memory preferences omitted by an authoritative runtime snapshot', async () => {
+ test('keeps in-memory preferences that an authoritative runtime snapshot omits', async () => {
getWindow();
switchRuntimeEndpoint({ apiBaseUrl: 'https://preferences-a.example', runtimeKey: 'preferences-a' });
registerSettingsApi(async () => ({}), async () => ({
@@ -451,13 +471,15 @@ describe('updateDesktopSettings', () => {
}));
await syncDesktopSettings();
- expect(useUIStore.getState().showReasoningTraces).toBe(true);
- expect(useUIStore.getState().terminalShell).toBe('auto');
- expect(useUIStore.getState().favoriteModels).toEqual([]);
- expect(useUIStore.getState().toolJsonViewMode).toBe('summary');
- expect(useUIStore.getState().globalDraftStarters).toBeNull();
- expect(useUIStore.getState().draftStartersVisible).toBe(true);
- expect(useMessageQueueStore.getState().followUpBehavior).toBe('queue');
+ // An omitted key is "unset", not "reset to default": the window keeps what
+ // it holds and nothing is written back.
+ expect(useUIStore.getState().showReasoningTraces).toBe(false);
+ expect(useUIStore.getState().terminalShell).toBe('fish');
+ expect(useUIStore.getState().favoriteModels).toHaveLength(1);
+ expect(useUIStore.getState().toolJsonViewMode).toBe('raw');
+ expect(useUIStore.getState().globalDraftStarters).toEqual([{ type: 'command', name: 'runtime-a' }]);
+ expect(useUIStore.getState().draftStartersVisible).toBe(false);
+ expect(useMessageQueueStore.getState().followUpBehavior).toBe('steer');
});
test('treats settings save responses as partial patches', async () => {
@@ -528,7 +550,7 @@ describe('updateDesktopSettings', () => {
});
});
- test('seeds missing shared sidebar preferences from the hydrated local cache', async () => {
+ test('keeps hydrated sidebar preferences the server omits and writes nothing back', async () => {
getWindow();
const saves: Array> = [];
useSessionDisplayStore.setState({
@@ -550,15 +572,21 @@ describe('updateDesktopSettings', () => {
}));
await syncDesktopSettings();
+ await delay(300);
- expect(saves).toEqual([{
- draftStartersCraftGoalAdded: true,
- draftStartersScheduleTaskAdded: true,
- sidebarProjectDisplayMode: 'single',
- sidebarSessionGroupingMode: 'flat',
- sidebarProjectSortOrder: 'a-z',
- sidebarShowRecentSection: false,
- }]);
+ expect(saves).toEqual([]);
+ const state = useSessionDisplayStore.getState();
+ expect({
+ projectDisplayMode: state.projectDisplayMode,
+ sessionGroupingMode: state.sessionGroupingMode,
+ projectSortOrder: state.projectSortOrder,
+ showRecentSection: state.showRecentSection,
+ }).toEqual({
+ projectDisplayMode: 'single',
+ sessionGroupingMode: 'flat',
+ projectSortOrder: 'a-z',
+ showRecentSection: false,
+ });
});
test('preserves local sidebar preferences when the authoritative load fails', async () => {
@@ -801,7 +829,6 @@ describe('updateDesktopSettings', () => {
expect(saveCalls).toHaveLength(1);
expect(saveCalls[0]).toEqual({
- draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true,
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }],
hiddenModels: [{ providerID: 'openai', modelID: 'gpt-5' }],
collapsedModelProviders: ['openai'],
@@ -818,6 +845,7 @@ describe('updateDesktopSettings', () => {
getWindow();
useUIStore.getState().setTerminalShell('auto');
useUIStore.getState().setTerminalLoginShells([]);
+ useUIStore.getState().setToolJsonViewMode('summary');
const saveCalls: Array> = [];
registerSettingsSave(async (changes) => {
saveCalls.push(changes);
@@ -862,8 +890,12 @@ describe('updateDesktopSettings', () => {
startAppearanceAutoSave();
useUIStore.getState().setWorkStatusSectionVisible('telemetry', true);
await delay(600);
- expect(saves.some((changes) => changes.workStatusHiddenSections?.length === 0 && changes.workStatusHiddenSectionsExplicit === true)).toBe(true);
+ // The list itself already matches the server ([]), so only the explicit
+ // marker needs to travel; the server merges per key, so the end state is
+ // the same as sending both.
+ expect(saves.some((changes) => changes.workStatusHiddenSectionsExplicit === true)).toBe(true);
expect(server.workStatusHiddenSections).toEqual([]);
+ expect(server.workStatusHiddenSectionsExplicit).toBe(true);
invalidateSettingsCache();
await syncDesktopSettings();
expect(useUIStore.getState().workStatusHiddenSections).toEqual([]);
@@ -923,7 +955,7 @@ describe('updateDesktopSettings', () => {
expect(useInputHistoryStore.getState().entryLimit).toBe(100);
});
- test('defaults omitted input history scope to global without writing a migration', async () => {
+ test('keeps the hydrated input history scope when the server omits it and writes nothing', async () => {
getWindow();
invalidateSettingsCache();
useInputHistoryStore.getState().applyScope('session');
@@ -942,11 +974,11 @@ describe('updateDesktopSettings', () => {
await syncDesktopSettings();
- expect(useInputHistoryStore.getState().scope).toBe(DEFAULT_INPUT_HISTORY_SCOPE);
+ expect(useInputHistoryStore.getState().scope).toBe('session');
expect(saveCalls.some((changes) => changes.inputHistoryScope !== undefined)).toBe(false);
});
- test('defaults omitted input history limit to forty without writing a migration', async () => {
+ test('keeps the hydrated input history limit when the server omits it and writes nothing', async () => {
getWindow();
invalidateSettingsCache();
useInputHistoryStore.getState().applyEntryLimit(100);
@@ -965,7 +997,7 @@ describe('updateDesktopSettings', () => {
await syncDesktopSettings();
- expect(useInputHistoryStore.getState().entryLimit).toBe(DEFAULT_INPUT_HISTORY_LIMIT);
+ expect(useInputHistoryStore.getState().entryLimit).toBe(100);
expect(saveCalls.some((changes) => changes.inputHistoryLimit !== undefined)).toBe(false);
});
@@ -1047,7 +1079,7 @@ describe('updateDesktopSettings', () => {
expect(saveCalls.some((changes) => changes.autoSaveEnabled === false)).toBe(true);
});
- test('seeds omitted autoSaveEnabled from the hydrated client preference', async () => {
+ test('keeps the hydrated autoSaveEnabled when the server omits it and writes nothing', async () => {
getWindow();
invalidateSettingsCache();
useUIStore.getState().setAutoSaveEnabled(false);
@@ -1064,27 +1096,110 @@ describe('updateDesktopSettings', () => {
await delay(500);
expect(useUIStore.getState().autoSaveEnabled).toBe(false);
- expect(saveCalls.some((changes) => changes.autoSaveEnabled === false)).toBe(true);
+ expect(saveCalls).toEqual([]);
});
- test('seeds default autoSaveEnabled when omitted and client still has the default', async () => {
+ test('a bootstrap that adopts server values produces zero writes even with the auto-savers running', async () => {
+ getWindow();
+ invalidateSettingsCache();
+ // The setup below is itself "a person changing things" as far as the
+ // auto-savers can tell; let those writes drain before recording.
+ const saveCalls: Array> = [];
+ let recording = false;
+ registerSettingsApi(async (changes) => {
+ if (recording) saveCalls.push(changes);
+ return { ...changes } as SettingsPayload;
+ }, async () => ({
+ settings: {
+ showReasoningTraces: false,
+ terminalShell: 'fish',
+ favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }],
+ // A legacy list the client normalises on read: the normalised copy is
+ // still not this window's change and must not be written back.
+ workStatusHiddenSections: ['mcp'],
+ draftStartersCraftGoalAdded: true,
+ draftStartersScheduleTaskAdded: true,
+ },
+ source: 'web',
+ }));
+ startAppearanceAutoSave();
+ const stopModelPrefs = startModelPrefsAutoSave();
+ useUIStore.getState().setShowReasoningTraces(true);
+ useUIStore.getState().setTerminalShell('auto');
+ resetModelPrefsState();
+ await delay(1500);
+ recording = true;
+
+ try {
+ await syncDesktopSettings();
+ await delay(1500);
+
+ expect(useUIStore.getState().showReasoningTraces).toBe(false);
+ expect(useUIStore.getState().terminalShell).toBe('fish');
+ expect(useUIStore.getState().favoriteModels).toHaveLength(1);
+ expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']);
+ expect(saveCalls).toEqual([]);
+ } finally {
+ stopModelPrefs();
+ }
+ });
+
+ test('drops a write whose value the server already holds', async () => {
getWindow();
invalidateSettingsCache();
- useUIStore.getState().setAutoSaveEnabled(true);
const saveCalls: Array> = [];
registerSettingsApi(async (changes) => {
saveCalls.push(changes);
return { ...changes } as SettingsPayload;
}, async () => ({
- settings: { draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true },
+ settings: { fontSize: 15, draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true },
source: 'web',
}));
-
await syncDesktopSettings();
- await delay(500);
- expect(useUIStore.getState().autoSaveEnabled).toBe(true);
- expect(saveCalls.some((changes) => changes.autoSaveEnabled === true)).toBe(true);
+ await updateDesktopSettings({ fontSize: 15 });
+ expect(saveCalls).toEqual([]);
+ expect(getSettingsSaveState()).toBe('idle');
+
+ await updateDesktopSettings({ fontSize: 16 });
+ expect(saveCalls).toEqual([{ fontSize: 16 }]);
+ });
+
+ test('toggling back to the server value inside the debounce window cancels the pending write', async () => {
+ getWindow();
+ invalidateSettingsCache();
+ const saveCalls: Array> = [];
+ registerSettingsApi(async (changes) => {
+ saveCalls.push(changes);
+ return { ...changes } as SettingsPayload;
+ }, async () => ({
+ settings: { showDeletionDialog: true, draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true },
+ source: 'web',
+ }));
+ await syncDesktopSettings();
+
+ void updateDesktopSettings({ showDeletionDialog: false, fontSize: 17 });
+ await updateDesktopSettings({ showDeletionDialog: true });
+
+ expect(saveCalls).toEqual([{ fontSize: 17 }]);
+ });
+
+ test('a failed save forgets its optimistic value so the retry is sent', async () => {
+ getWindow();
+ invalidateSettingsCache();
+ let fail = true;
+ const saveCalls: Array> = [];
+ registerSettingsSave(async (changes) => {
+ saveCalls.push(changes);
+ if (fail) throw new Error('offline');
+ return { ...changes } as SettingsPayload;
+ });
+
+ await updateDesktopSettings({ fontSize: 18 });
+ fail = false;
+ await updateDesktopSettings({ fontSize: 18 });
+
+ expect(saveCalls).toEqual([{ fontSize: 18 }, { fontSize: 18 }]);
});
test('does not invent theme defaults when the authoritative snapshot omits theme fields', async () => {
@@ -1185,6 +1300,7 @@ describe('updateDesktopSettings', () => {
describe('unload lifecycle flush (#2197)', () => {
beforeEach(() => {
getWindow();
+ isolateRuntime();
registerRuntimeAPIs(null);
invalidateSettingsCache();
});
diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts
index 2c4313f1..64336628 100644
--- a/packages/ui/src/lib/persistence.ts
+++ b/packages/ui/src/lib/persistence.ts
@@ -1,34 +1,21 @@
import type { DesktopSettings } from '@/lib/desktop';
-import { sanitizeWorkStatusHiddenSections } from '@/components/chat/work-status/sections';
-import { createProjectIdFromPath } from '@/lib/projectId';
import { useUIStore } from '@/stores/useUIStore';
-import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions';
-import {
- DEFAULT_FOLLOW_UP_BEHAVIOR,
- isFollowUpBehavior,
- normalizeFollowUpBehavior,
- useMessageQueueStore,
- type FollowUpBehavior,
-} from '@/stores/messageQueueStore';
-import { setDirectoryShowHidden } from '@/lib/directoryShowHidden';
-import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
-import { sanitizeStarterRefs } from '@/lib/draftStarters';
-import {
- DEFAULT_INPUT_HISTORY_LIMIT,
- DEFAULT_INPUT_HISTORY_SCOPE,
- isInputHistoryLimit,
- isInputHistoryScope,
-} from '@/lib/inputHistoryScope';
-import { useInputHistoryStore } from '@/stores/useInputHistoryStore';
-import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
+import { setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isCapacitorApp } from '@/lib/platform';
-import { isTerminalShell } from '@/lib/terminalShell';
import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
-import { DEFAULT_OPEN_IN_APP_ID } from '@/lib/openInApps';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
+import {
+ applySettingsToStores,
+ isDeviceSettingsKey,
+ isWritableSettingsKey,
+ MIRRORED_KEYS,
+ parseSettingsDocument,
+ SETTINGS_KEYS,
+} from '@/lib/settings/registry';
+import { SETTINGS_SURFACE_QUERY, getSettingsSurface } from '@/lib/settings/surface';
export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void => {
if (typeof window === 'undefined') {
@@ -46,6 +33,33 @@ export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void
};
const SETTINGS_MIRROR_INDEX_KEY = 'openchamber.settingsMirror.v2.index';
+// Set once a runtime's device fields have been read from the server document
+// (installs that predate the settings split still carry them there). After
+// that the local store is the only owner and the server copy is ignored.
+const DEVICE_SEED_KEY_PREFIX = 'openchamber.deviceSeeded.v1:';
+const getDeviceSeedStorageKey = (runtimeKey: string): string => `${DEVICE_SEED_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`;
+
+/**
+ * The part of a server document this window may apply: everything but device
+ * fields, plus the device fields exactly once per runtime as a migration seed.
+ */
+const withoutStaleDeviceFields = (settings: DesktopSettings, runtimeKey: string): DesktopSettings => {
+ const seedKey = getDeviceSeedStorageKey(runtimeKey);
+ let seedDevice = false;
+ try {
+ seedDevice = localStorage.getItem(seedKey) === null;
+ if (seedDevice) localStorage.setItem(seedKey, String(Date.now()));
+ } catch {
+ seedDevice = false;
+ }
+ if (seedDevice) return settings;
+ const next: DesktopSettings = {};
+ for (const key of SETTINGS_KEYS) {
+ if (settings[key] === undefined || isDeviceSettingsKey(key)) continue;
+ Object.assign(next, { [key]: settings[key] });
+ }
+ return next;
+};
const SETTINGS_MIRROR_KEY_PREFIX = 'openchamber.settingsMirror.v2:';
const MAX_SETTINGS_MIRROR_RUNTIMES = 5;
@@ -61,37 +75,13 @@ const setOrRemoveLocalStorage = (key: string, value: string | null): void => {
};
const persistRuntimeSettingsMirror = (settings: DesktopSettings, runtimeKey: string): void => {
- const mirror = {
- themeId: settings.themeId,
- themeVariant: settings.themeVariant,
- lightThemeId: settings.lightThemeId,
- darkThemeId: settings.darkThemeId,
- useSystemTheme: settings.useSystemTheme,
- lastDirectory: settings.lastDirectory,
- homeDirectory: settings.homeDirectory,
- projects: settings.projects,
- activeProjectId: settings.activeProjectId,
- sidebarProjectDisplayMode: settings.sidebarProjectDisplayMode,
- sidebarSessionGroupingMode: settings.sidebarSessionGroupingMode,
- sidebarProjectSortOrder: settings.sidebarProjectSortOrder,
- sidebarShowRecentSection: settings.sidebarShowRecentSection,
- pinnedDirectories: settings.pinnedDirectories,
- gitmojiEnabled: settings.gitmojiEnabled,
- directoryShowHidden: settings.directoryShowHidden,
- filesViewShowGitignored: settings.filesViewShowGitignored,
- openInAppId: settings.openInAppId,
- pwaAppName: settings.pwaAppName,
- mobileKeyboardMode: settings.mobileKeyboardMode,
- openCodeUpdateToastDismissedVersion: settings.openCodeUpdateToastDismissedVersion,
- inputHistoryScope: settings.inputHistoryScope,
- inputHistoryLimit: settings.inputHistoryLimit,
- dictationEnabled: settings.dictationEnabled,
- sttProvider: settings.sttProvider,
- sttServerUrl: settings.sttServerUrl,
- sttModel: settings.sttModel,
- sttLocalModel: settings.sttLocalModel,
- sttLanguage: settings.sttLanguage,
- };
+ // Every user-owned field the server holds for this runtime, so a later
+ // phase can serve the profile from the mirror; secrets and computed flags
+ // never land in browser storage.
+ const mirror: DesktopSettings = {};
+ for (const key of MIRRORED_KEYS) {
+ if (settings[key] !== undefined) Object.assign(mirror, { [key]: settings[key] });
+ }
localStorage.setItem(getRuntimeSettingsMirrorStorageKey(runtimeKey), JSON.stringify(mirror));
let previous: string[] = [];
@@ -279,262 +269,6 @@ type PersistApi = {
onFinishHydration?: (callback: () => void) => (() => void) | undefined;
};
-const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs'] | undefined => {
- if (!Array.isArray(value)) {
- return undefined;
- }
-
- const result: NonNullable = [];
- const seen = new Set();
-
- for (const entry of value) {
- if (!entry || typeof entry !== 'object') continue;
- const candidate = entry as Record;
-
- const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
- const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
- const source = typeof candidate.source === 'string' ? candidate.source.trim() : '';
- const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : '';
- const gitIdentityId = typeof candidate.gitIdentityId === 'string' ? candidate.gitIdentityId.trim() : '';
-
- if (!id || !label || !source) continue;
- if (seen.has(id)) continue;
- seen.add(id);
-
- const catalog: NonNullable[number] = {
- id,
- label,
- source,
- };
- if (subpath) catalog.subpath = subpath;
- if (gitIdentityId) catalog.gitIdentityId = gitIdentityId;
- result.push(catalog);
- }
-
- return result;
-};
-
-const sanitizeShortcutOverrides = (value: unknown): Record | undefined => {
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
- return undefined;
- }
- const result: Record = {};
- for (const [key, combo] of Object.entries(value)) {
- const normalizedKey = typeof key === 'string' ? key.trim() : '';
- const normalizedCombo = typeof combo === 'string' ? combo.trim() : '';
- if (!normalizedKey || !normalizedCombo) continue;
- result[normalizedKey] = normalizedCombo;
- }
- return result;
-};
-
-const areStringRecordsEqual = (left: Record, right: Record): boolean => {
- const leftEntries = Object.entries(left);
- const rightEntries = Object.entries(right);
- if (leftEntries.length !== rightEntries.length) return false;
- return leftEntries.every(([key, value]) => right[key] === value);
-};
-
-const areModelRefsEqual = (
- left: Array<{ providerID: string; modelID: string }>,
- right: Array<{ providerID: string; modelID: string }>,
-): boolean => (
- left.length === right.length &&
- left.every((item, idx) => item.providerID === right[idx]?.providerID && item.modelID === right[idx]?.modelID)
-);
-
-const areStringArraysEqual = (left: string[], right: string[]): boolean => (
- left.length === right.length && left.every((value, idx) => value === right[idx])
-);
-
-const sanitizeStringArray = (value: unknown): string[] | undefined => {
- if (!Array.isArray(value)) return undefined;
- return Array.from(new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)));
-};
-
-const sanitizeRecentEfforts = (value: unknown): Record | undefined => {
- if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
- const result: Record = {};
- for (const [key, variants] of Object.entries(value)) {
- if (!key || !Array.isArray(variants)) continue;
- const sanitized = sanitizeStringArray(variants);
- if (sanitized && sanitized.length > 0) {
- result[key] = sanitized.slice(0, 5);
- }
- }
- return Object.keys(result).length > 0 ? result : undefined;
-};
-
-const areRecentEffortsEqual = (left: Record, right: Record): boolean => {
- const leftKeys = Object.keys(left);
- if (leftKeys.length !== Object.keys(right).length) return false;
- return leftKeys.every((key) => Array.isArray(right[key]) && areStringArraysEqual(left[key], right[key]));
-};
-
-const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
-
-const normalizeIconBackground = (value: unknown): string | null => {
- if (typeof value !== 'string') {
- return null;
- }
- const trimmed = value.trim();
- if (!trimmed) {
- return null;
- }
- return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
-};
-
-const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefined => {
- if (!Array.isArray(value)) {
- return undefined;
- }
-
- const result: NonNullable = [];
- const seenIds = new Set();
- const seenPaths = new Set();
-
- for (const entry of value) {
- if (!entry || typeof entry !== 'object') continue;
- const candidate = entry as Record;
-
- const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
- if (!rawPath) continue;
-
- const normalizedPath = rawPath === '/' ? rawPath : rawPath.replace(/\\/g, '/').replace(/\/+$/, '');
- if (!normalizedPath) continue;
-
- const id = createProjectIdFromPath(normalizedPath);
- if (!id) continue;
-
- if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue;
- seenIds.add(id);
- seenPaths.add(normalizedPath);
-
- const project: NonNullable[number] = {
- id,
- path: normalizedPath,
- };
-
- if (typeof candidate.label === 'string' && candidate.label.trim().length > 0) {
- project.label = candidate.label.trim();
- }
- if (typeof candidate.icon === 'string' && candidate.icon.trim().length > 0) {
- project.icon = candidate.icon.trim();
- }
- if (candidate.iconImage === null) {
- project.iconImage = null;
- } else if (candidate.iconImage && typeof candidate.iconImage === 'object') {
- const iconImage = candidate.iconImage as Record;
- const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : '';
- const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt)
- ? Math.max(0, Math.round(iconImage.updatedAt))
- : 0;
- const source = iconImage.source === 'custom' || iconImage.source === 'auto'
- ? iconImage.source
- : null;
- if (mime && updatedAt > 0 && source) {
- project.iconImage = { mime, updatedAt, source };
- }
- }
- if (typeof candidate.color === 'string' && candidate.color.trim().length > 0) {
- project.color = candidate.color.trim();
- }
- if (candidate.iconBackground === null) {
- project.iconBackground = null;
- } else {
- const iconBackground = normalizeIconBackground(candidate.iconBackground);
- if (iconBackground) {
- project.iconBackground = iconBackground;
- }
- }
- if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) {
- project.addedAt = candidate.addedAt;
- }
- if (
- typeof candidate.lastOpenedAt === 'number' &&
- Number.isFinite(candidate.lastOpenedAt) &&
- candidate.lastOpenedAt >= 0
- ) {
- project.lastOpenedAt = candidate.lastOpenedAt;
- }
- if (typeof candidate.sidebarCollapsed === 'boolean') {
- project.sidebarCollapsed = candidate.sidebarCollapsed;
- }
- result.push(project);
- }
-
- return result.length > 0 ? result : undefined;
-};
-
-const sanitizeManagedRemoteTunnelPresets = (value: unknown): DesktopSettings['managedRemoteTunnelPresets'] | undefined => {
- if (!Array.isArray(value)) {
- return undefined;
- }
-
- const result: NonNullable = [];
- const seenIds = new Set();
- const seenHostnames = new Set();
-
- for (const entry of value) {
- if (!entry || typeof entry !== 'object') continue;
- const candidate = entry as Record;
-
- const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
- const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
- const hostname = typeof candidate.hostname === 'string' ? candidate.hostname.trim().toLowerCase() : '';
-
- if (!id || !name || !hostname) continue;
- if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
- seenIds.add(id);
- seenHostnames.add(hostname);
-
- result.push({ id, name, hostname });
- }
-
- return result;
-};
-
-const sanitizeManagedRemoteTunnelPresetTokens = (value: unknown): DesktopSettings['managedRemoteTunnelPresetTokens'] | undefined => {
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
- return undefined;
- }
-
- const candidate = value as Record;
- const result: Record = {};
- for (const [key, tokenValue] of Object.entries(candidate)) {
- const id = key.trim();
- const token = typeof tokenValue === 'string' ? tokenValue.trim() : '';
- if (!id || !token) continue;
- result[id] = token;
- }
-
- return Object.keys(result).length > 0 ? result : undefined;
-};
-
-const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: string; modelID: string }> | undefined => {
- if (!Array.isArray(value)) {
- return undefined;
- }
-
- const result: Array<{ providerID: string; modelID: string }> = [];
- const seen = new Set();
-
- for (const entry of value) {
- if (!entry || typeof entry !== 'object') continue;
- const candidate = entry as Record;
- const providerID = typeof candidate.providerID === 'string' ? candidate.providerID.trim() : '';
- const modelID = typeof candidate.modelID === 'string' ? candidate.modelID.trim() : '';
- if (!providerID || !modelID) continue;
- const key = `${providerID}/${modelID}`;
- if (seen.has(key)) continue;
- seen.add(key);
- result.push({ providerID, modelID });
- if (result.length >= limit) break;
- }
-
- return result;
-};
-
const getPersistApi = (): PersistApi | undefined => {
const candidate = useUIStore.persist;
if (candidate && typeof candidate === 'object') {
@@ -545,1217 +279,19 @@ const getPersistApi = (): PersistApi | undefined => {
const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null;
-const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopSettings => {
- const defaults = useUIStore.getInitialState();
+const settingsEndpointForSurface = (): string => `/api/config/settings?${SETTINGS_SURFACE_QUERY}=${getSettingsSurface()}`;
- return {
- // Theme fields are deliberately NOT defaulted: the theme authority is the
- // ThemeSystemContext (scoped per-runtime entry + bootstrap syncs). A
- // server document without theme fields means "not set" — inventing
- // defaults here would clobber the window's theme and write it back to the
- // server. Absent fields keep the current preferences.
- openInAppId: DEFAULT_OPEN_IN_APP_ID,
- showReasoningTraces: defaults.showReasoningTraces,
- streamingAutoFollowEnabled: defaults.streamingAutoFollowEnabled,
- workStatusPanelEnabled: defaults.workStatusPanelEnabled,
- workStatusHiddenSections: defaults.workStatusHiddenSections,
- workStatusHiddenSectionsExplicit: defaults.workStatusHiddenSectionsExplicit,
- sessionRecapEnabled: defaults.sessionRecapEnabled,
- sessionSuggestionEnabled: defaults.sessionSuggestionEnabled,
- sessionGoalEnabled: defaults.sessionGoalEnabled,
- sessionGoalDefaultBudgetEnabled: defaults.sessionGoalDefaultBudgetEnabled,
- sessionGoalDefaultBudget: defaults.sessionGoalDefaultBudget,
- collapsibleThinkingBlocks: defaults.collapsibleThinkingBlocks,
- autoDeleteEnabled: defaults.autoDeleteEnabled,
- autoSaveEnabled: defaults.autoSaveEnabled,
- autoDeleteAfterDays: defaults.autoDeleteAfterDays,
- sessionRetentionAction: defaults.sessionRetentionAction,
- followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
- showDeletionDialog: defaults.showDeletionDialog,
- nativeNotificationsEnabled: defaults.nativeNotificationsEnabled,
- notificationMode: defaults.notificationMode,
- notifyOnSubtasks: defaults.notifyOnSubtasks,
- notifyOnCompletion: defaults.notifyOnCompletion,
- notifyOnError: defaults.notifyOnError,
- notifyOnQuestion: defaults.notifyOnQuestion,
- notificationTemplates: defaults.notificationTemplates,
- summarizeLastMessage: defaults.summarizeLastMessage,
- summaryThreshold: defaults.summaryThreshold,
- summaryLength: defaults.summaryLength,
- maxLastMessageLength: defaults.maxLastMessageLength,
- inputSpellcheckEnabled: defaults.inputSpellcheckEnabled,
- enterToSend: defaults.enterToSend,
- enterToSendConfigured: defaults.enterToSendConfigured,
- showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications,
- agentControlToolEnabled: defaults.agentControlToolEnabled,
- agentWebToolEnabled: defaults.agentWebToolEnabled,
- agentMemoryToolEnabled: defaults.agentMemoryToolEnabled,
- showToolFileIcons: defaults.showToolFileIcons,
- codeBlockLineWrap: defaults.codeBlockLineWrap,
- showTurnChangedFiles: defaults.showTurnChangedFiles,
- showExpandedBashTools: defaults.showExpandedBashTools,
- showExpandedEditTools: defaults.showExpandedEditTools,
- timeFormatPreference: defaults.timeFormatPreference,
- weekStartPreference: defaults.weekStartPreference,
- desktopWindowControlsPosition: defaults.desktopWindowControlsPosition,
- desktopWindowControlsStyle: defaults.desktopWindowControlsStyle,
- chatRenderMode: defaults.chatRenderMode,
- activityRenderMode: defaults.activityRenderMode,
- mermaidRenderingMode: defaults.mermaidRenderingMode,
- userMessageRenderingMode: defaults.userMessageRenderingMode,
- collapsibleUserMessages: defaults.collapsibleUserMessages,
- messageStreamTransport: 'auto',
- inputHistoryScope: DEFAULT_INPUT_HISTORY_SCOPE,
- inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT,
- stickyUserHeader: defaults.stickyUserHeader,
- promptNavigatorEnabled: defaults.promptNavigatorEnabled,
- wideChatLayoutEnabled: defaults.wideChatLayoutEnabled,
- showSplitAssistantMessageActions: defaults.showSplitAssistantMessageActions,
- draftStartersVisible: defaults.draftStartersVisible,
- reportUsage: defaults.reportUsage,
- fontSize: defaults.fontSize,
- terminalFontSize: defaults.terminalFontSize,
- terminalShell: defaults.terminalShell,
- terminalLoginShells: defaults.terminalLoginShells,
- editorFontSize: defaults.editorFontSize,
- uiFont: defaults.uiFont,
- monoFont: defaults.monoFont,
- padding: defaults.padding,
- cornerRadius: defaults.cornerRadius,
- inputBarOffset: defaults.inputBarOffset,
- shortcutOverrides: defaults.shortcutOverrides,
- mobileKeyboardMode: 'resize-content',
- favoriteModels: defaults.favoriteModels,
- hiddenModels: defaults.hiddenModels,
- collapsedModelProviders: defaults.collapsedModelProviders,
- recentModels: defaults.recentModels,
- recentAgents: defaults.recentAgents,
- recentEfforts: defaults.recentEfforts,
- diffLayoutPreference: defaults.diffLayoutPreference,
- gitChangesViewMode: defaults.gitChangesViewMode,
- toolJsonViewMode: defaults.toolJsonViewMode,
- directoryShowHidden: true,
- filesViewShowGitignored: false,
- dictationEnabled: true,
- sttProvider: 'local',
- sttServerUrl: 'http://localhost:8001/v1',
- sttModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
- sttLocalModel: 'parakeet-tdt-0.6b-v2-int8',
- sttLanguage: '',
- ...settings,
- };
+/** Copy a parsed snapshot into the live stores. Omitted keys stay as they are. */
+const applyDesktopUiPreferences = (settings: DesktopSettings): void => {
+ applySettingsToStores(settings);
};
-const applyDesktopUiPreferences = (settings: DesktopSettings) => {
- const store = useUIStore.getState();
- const configStore = typeof window !== 'undefined'
- ? window.__zustand_config_store__?.getState?.() ?? null
- : null;
- const configStoreApi = typeof window !== 'undefined'
- ? window.__zustand_config_store__ ?? null
- : null;
- const queueStore = useMessageQueueStore.getState();
- const inputHistoryStore = useInputHistoryStore.getState();
-
- if (typeof settings.workStatusPanelEnabled === 'boolean'
- && settings.workStatusPanelEnabled !== store.workStatusPanelEnabled) {
- store.setWorkStatusPanelEnabled(settings.workStatusPanelEnabled);
- }
- if (Array.isArray(settings.workStatusHiddenSections)) {
- const explicit = settings.workStatusHiddenSectionsExplicit === true;
- const next = sanitizeWorkStatusHiddenSections(settings.workStatusHiddenSections, explicit);
- if (next.join('\u0000') !== store.workStatusHiddenSections.join('\u0000') || explicit !== store.workStatusHiddenSectionsExplicit) {
- useUIStore.setState({ workStatusHiddenSections: next, workStatusHiddenSectionsExplicit: explicit });
- }
- }
- if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
- store.setShowReasoningTraces(settings.showReasoningTraces);
- }
- if (typeof settings.streamingAutoFollowEnabled === 'boolean' && settings.streamingAutoFollowEnabled !== store.streamingAutoFollowEnabled) {
- store.setStreamingAutoFollowEnabled(settings.streamingAutoFollowEnabled);
- }
- if (typeof settings.sessionRecapEnabled === 'boolean' && settings.sessionRecapEnabled !== store.sessionRecapEnabled) {
- store.setSessionRecapEnabled(settings.sessionRecapEnabled);
- }
- if (typeof settings.sessionSuggestionEnabled === 'boolean' && settings.sessionSuggestionEnabled !== store.sessionSuggestionEnabled) {
- store.setSessionSuggestionEnabled(settings.sessionSuggestionEnabled);
- }
- if (typeof settings.sessionGoalEnabled === 'boolean' && settings.sessionGoalEnabled !== store.sessionGoalEnabled) {
- store.setSessionGoalEnabled(settings.sessionGoalEnabled);
- }
- if (typeof settings.sessionGoalDefaultBudgetEnabled === 'boolean' && settings.sessionGoalDefaultBudgetEnabled !== store.sessionGoalDefaultBudgetEnabled) {
- store.setSessionGoalDefaultBudgetEnabled(settings.sessionGoalDefaultBudgetEnabled);
- }
- if (typeof settings.sessionGoalDefaultBudget === 'number' && Number.isFinite(settings.sessionGoalDefaultBudget) && settings.sessionGoalDefaultBudget !== store.sessionGoalDefaultBudget) {
- store.setSessionGoalDefaultBudget(settings.sessionGoalDefaultBudget);
- }
- if (typeof settings.collapsibleThinkingBlocks === 'boolean' && settings.collapsibleThinkingBlocks !== store.collapsibleThinkingBlocks) {
- store.setCollapsibleThinkingBlocks(settings.collapsibleThinkingBlocks);
- }
- if (typeof settings.autoDeleteEnabled === 'boolean' && settings.autoDeleteEnabled !== store.autoDeleteEnabled) {
- store.setAutoDeleteEnabled(settings.autoDeleteEnabled);
- }
- if (typeof settings.autoSaveEnabled === 'boolean' && settings.autoSaveEnabled !== store.autoSaveEnabled) {
- store.setAutoSaveEnabled(settings.autoSaveEnabled);
- }
- if (typeof settings.autoDeleteAfterDays === 'number' && Number.isFinite(settings.autoDeleteAfterDays)) {
- const normalized = Math.max(1, Math.min(365, settings.autoDeleteAfterDays));
- if (normalized !== store.autoDeleteAfterDays) {
- store.setAutoDeleteAfterDays(normalized);
- }
- }
- if (settings.sessionRetentionAction === 'archive' || settings.sessionRetentionAction === 'delete') {
- if (settings.sessionRetentionAction !== store.sessionRetentionAction) {
- store.setSessionRetentionAction(settings.sessionRetentionAction);
- }
- }
-
- let nextFollowUpBehavior: FollowUpBehavior | null = null;
- if (isFollowUpBehavior(settings.followUpBehavior)) {
- nextFollowUpBehavior = settings.followUpBehavior;
- } else if (typeof settings.queueModeEnabled === 'boolean') {
- nextFollowUpBehavior = normalizeFollowUpBehavior(undefined, settings.queueModeEnabled);
- }
- if (nextFollowUpBehavior && nextFollowUpBehavior !== queueStore.followUpBehavior) {
- queueStore.setFollowUpBehavior(nextFollowUpBehavior);
- }
-
- if (typeof settings.showDeletionDialog === 'boolean' && settings.showDeletionDialog !== store.showDeletionDialog) {
- store.setShowDeletionDialog(settings.showDeletionDialog);
- }
- if (typeof settings.nativeNotificationsEnabled === 'boolean' && settings.nativeNotificationsEnabled !== store.nativeNotificationsEnabled) {
- store.setNativeNotificationsEnabled(settings.nativeNotificationsEnabled);
- }
- if (typeof settings.notificationMode === 'string' && (settings.notificationMode === 'always' || settings.notificationMode === 'hidden-only')) {
- if (settings.notificationMode !== store.notificationMode) {
- store.setNotificationMode(settings.notificationMode);
- }
- }
- if (typeof settings.notifyOnSubtasks === 'boolean' && settings.notifyOnSubtasks !== store.notifyOnSubtasks) {
- store.setNotifyOnSubtasks(settings.notifyOnSubtasks);
- }
- if (typeof settings.notifyOnCompletion === 'boolean' && settings.notifyOnCompletion !== store.notifyOnCompletion) {
- store.setNotifyOnCompletion(settings.notifyOnCompletion);
- }
- if (typeof settings.notifyOnError === 'boolean' && settings.notifyOnError !== store.notifyOnError) {
- store.setNotifyOnError(settings.notifyOnError);
- }
- if (typeof settings.notifyOnQuestion === 'boolean' && settings.notifyOnQuestion !== store.notifyOnQuestion) {
- store.setNotifyOnQuestion(settings.notifyOnQuestion);
- }
- if (settings.notificationTemplates && typeof settings.notificationTemplates === 'object') {
- store.setNotificationTemplates(settings.notificationTemplates);
- }
- if (typeof settings.summarizeLastMessage === 'boolean' && settings.summarizeLastMessage !== store.summarizeLastMessage) {
- store.setSummarizeLastMessage(settings.summarizeLastMessage);
- }
- if (typeof settings.summaryThreshold === 'number' && Number.isFinite(settings.summaryThreshold)) {
- store.setSummaryThreshold(settings.summaryThreshold);
- }
- if (typeof settings.summaryLength === 'number' && Number.isFinite(settings.summaryLength)) {
- store.setSummaryLength(settings.summaryLength);
- }
- if (typeof settings.maxLastMessageLength === 'number' && Number.isFinite(settings.maxLastMessageLength)) {
- store.setMaxLastMessageLength(settings.maxLastMessageLength);
- }
- if (typeof settings.inputSpellcheckEnabled === 'boolean' && settings.inputSpellcheckEnabled !== store.inputSpellcheckEnabled) {
- store.setInputSpellcheckEnabled(settings.inputSpellcheckEnabled);
- }
- if (settings.enterToSend === true || settings.enterToSend === false) {
- if (settings.enterToSend !== store.enterToSend) {
- store.setEnterToSend(settings.enterToSend);
- }
- }
- if (settings.enterToSendConfigured === true || settings.enterToSendConfigured === false) {
- if (settings.enterToSendConfigured !== store.enterToSendConfigured) {
- store.setEnterToSendConfigured(settings.enterToSendConfigured);
- }
- }
- if (
- typeof settings.showOpenCodeUpdateNotifications === 'boolean'
- && settings.showOpenCodeUpdateNotifications !== store.showOpenCodeUpdateNotifications
- ) {
- store.setShowOpenCodeUpdateNotifications(settings.showOpenCodeUpdateNotifications);
- }
- if (
- typeof settings.agentControlToolEnabled === 'boolean'
- && settings.agentControlToolEnabled !== store.agentControlToolEnabled
- ) {
- store.setAgentControlToolEnabled(settings.agentControlToolEnabled);
- }
- if (
- typeof settings.agentWebToolEnabled === 'boolean'
- && settings.agentWebToolEnabled !== store.agentWebToolEnabled
- ) {
- store.setAgentWebToolEnabled(settings.agentWebToolEnabled);
- }
- if (
- typeof settings.agentMemoryToolEnabled === 'boolean'
- && settings.agentMemoryToolEnabled !== store.agentMemoryToolEnabled
- ) {
- store.setAgentMemoryToolEnabled(settings.agentMemoryToolEnabled);
- }
- // Server-owned: it says whether this build has the feature at all.
- if (
- typeof settings.agentMemoryFeatureAvailable === 'boolean'
- && settings.agentMemoryFeatureAvailable !== store.agentMemoryFeatureAvailable
- ) {
- store.setAgentMemoryFeatureAvailable(settings.agentMemoryFeatureAvailable);
- }
- if (typeof settings.showToolFileIcons === 'boolean' && settings.showToolFileIcons !== store.showToolFileIcons) {
- store.setShowToolFileIcons(settings.showToolFileIcons);
- }
- if (typeof settings.codeBlockLineWrap === 'boolean' && settings.codeBlockLineWrap !== store.codeBlockLineWrap) {
- store.setCodeBlockLineWrap(settings.codeBlockLineWrap);
- }
- if (typeof settings.showTurnChangedFiles === 'boolean' && settings.showTurnChangedFiles !== store.showTurnChangedFiles) {
- store.setShowTurnChangedFiles(settings.showTurnChangedFiles);
- }
- if (typeof settings.showExpandedBashTools === 'boolean' && settings.showExpandedBashTools !== store.showExpandedBashTools) {
- store.setShowExpandedBashTools(settings.showExpandedBashTools);
- }
- if (typeof settings.showExpandedEditTools === 'boolean' && settings.showExpandedEditTools !== store.showExpandedEditTools) {
- store.setShowExpandedEditTools(settings.showExpandedEditTools);
- }
- if (typeof settings.timeFormatPreference === 'string'
- && (settings.timeFormatPreference === 'auto' || settings.timeFormatPreference === '12h' || settings.timeFormatPreference === '24h')) {
- if (settings.timeFormatPreference !== store.timeFormatPreference) {
- store.setTimeFormatPreference(settings.timeFormatPreference);
- }
- }
- if (typeof settings.weekStartPreference === 'string'
- && (settings.weekStartPreference === 'auto' || settings.weekStartPreference === 'sunday' || settings.weekStartPreference === 'monday')) {
- if (settings.weekStartPreference !== store.weekStartPreference) {
- store.setWeekStartPreference(settings.weekStartPreference);
- }
- }
- if (typeof settings.desktopWindowControlsPosition === 'string') {
- const nextPosition = settings.desktopWindowControlsPosition === 'left'
- ? 'left'
- : (settings.desktopWindowControlsPosition === 'right' || settings.desktopWindowControlsPosition === 'auto')
- ? 'right'
- : null;
- if (nextPosition && nextPosition !== store.desktopWindowControlsPosition) {
- store.setDesktopWindowControlsPosition(nextPosition);
- }
- }
- if (typeof settings.desktopWindowControlsStyle === 'string') {
- const nextStyle = settings.desktopWindowControlsStyle === 'traffic-lights'
- ? 'traffic-lights'
- : settings.desktopWindowControlsStyle === 'classic'
- ? 'classic'
- : null;
- if (nextStyle && nextStyle !== store.desktopWindowControlsStyle) {
- store.setDesktopWindowControlsStyle(nextStyle);
- }
- }
- if (typeof settings.chatRenderMode === 'string'
- && (settings.chatRenderMode === 'sorted' || settings.chatRenderMode === 'live')) {
- if (settings.chatRenderMode !== store.chatRenderMode) {
- store.setChatRenderMode(settings.chatRenderMode);
- }
- }
- if (typeof settings.activityRenderMode === 'string'
- && (settings.activityRenderMode === 'collapsed' || settings.activityRenderMode === 'summary')) {
- if (settings.activityRenderMode !== store.activityRenderMode) {
- store.setActivityRenderMode(settings.activityRenderMode);
- }
- }
- if (typeof settings.mermaidRenderingMode === 'string'
- && (settings.mermaidRenderingMode === 'svg' || settings.mermaidRenderingMode === 'ascii')) {
- if (settings.mermaidRenderingMode !== store.mermaidRenderingMode) {
- store.setMermaidRenderingMode(settings.mermaidRenderingMode);
- }
- }
- if (typeof settings.userMessageRenderingMode === 'string'
- && (settings.userMessageRenderingMode === 'markdown' || settings.userMessageRenderingMode === 'plain')) {
- if (settings.userMessageRenderingMode !== store.userMessageRenderingMode) {
- store.setUserMessageRenderingMode(settings.userMessageRenderingMode);
- }
- }
- if (typeof settings.collapsibleUserMessages === 'boolean' && settings.collapsibleUserMessages !== store.collapsibleUserMessages) {
- store.setCollapsibleUserMessages(settings.collapsibleUserMessages);
- }
- if (typeof settings.messageStreamTransport === 'string'
- && (settings.messageStreamTransport === 'auto' || settings.messageStreamTransport === 'ws' || settings.messageStreamTransport === 'sse')) {
- if (configStore && settings.messageStreamTransport !== configStore.settingsMessageStreamTransport) {
- configStore.setSettingsMessageStreamTransport(settings.messageStreamTransport);
- }
- }
- if (
- typeof settings.inputHistoryScope === 'string'
- && isInputHistoryScope(settings.inputHistoryScope)
- && settings.inputHistoryScope !== inputHistoryStore.scope
- ) {
- inputHistoryStore.applyScope(settings.inputHistoryScope);
- }
- if (isInputHistoryLimit(settings.inputHistoryLimit) && settings.inputHistoryLimit !== inputHistoryStore.entryLimit) {
- inputHistoryStore.applyEntryLimit(settings.inputHistoryLimit);
- }
- if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) {
- store.setStickyUserHeader(settings.stickyUserHeader);
- }
- if (typeof settings.promptNavigatorEnabled === 'boolean' && settings.promptNavigatorEnabled !== store.promptNavigatorEnabled) {
- store.setPromptNavigatorEnabled(settings.promptNavigatorEnabled);
- }
- if (typeof settings.wideChatLayoutEnabled === 'boolean' && settings.wideChatLayoutEnabled !== store.wideChatLayoutEnabled) {
- store.setWideChatLayoutEnabled(settings.wideChatLayoutEnabled);
- }
- if (
- typeof settings.showSplitAssistantMessageActions === 'boolean'
- && settings.showSplitAssistantMessageActions !== store.showSplitAssistantMessageActions
- ) {
- store.setShowSplitAssistantMessageActions(settings.showSplitAssistantMessageActions);
- }
- if (typeof settings.reportUsage === 'boolean' && settings.reportUsage !== store.reportUsage) {
- store.setReportUsage(settings.reportUsage);
- }
- if (typeof settings.fontSize === 'number' && Number.isFinite(settings.fontSize) && settings.fontSize !== store.fontSize) {
- store.setFontSize(settings.fontSize);
- }
- if (Array.isArray(settings.draftStarters)) {
- let nextStarters = sanitizeStarterRefs(settings.draftStarters);
- if (settings.draftStartersCraftGoalAdded !== true && !nextStarters.some((starter) => starter.type === 'command' && starter.name === 'craft-goal')) {
- const planIndex = nextStarters.findIndex((starter) => starter.type === 'command' && starter.name === 'plan-feature');
- const insertAt = planIndex >= 0 ? planIndex + 1 : nextStarters.length;
- nextStarters = [
- ...nextStarters.slice(0, insertAt),
- { type: 'command', name: 'craft-goal' },
- ...nextStarters.slice(insertAt),
- ];
- }
- if (settings.draftStartersScheduleTaskAdded !== true && !nextStarters.some((starter) => starter.type === 'command' && starter.name === 'schedule-task')) {
- const goalIndex = nextStarters.findIndex((starter) => starter.type === 'command' && starter.name === 'craft-goal');
- const insertAt = goalIndex >= 0 ? goalIndex + 1 : nextStarters.length;
- nextStarters = [
- ...nextStarters.slice(0, insertAt),
- { type: 'command', name: 'schedule-task' },
- ...nextStarters.slice(insertAt),
- ];
- }
- if (JSON.stringify(store.globalDraftStarters) !== JSON.stringify(nextStarters)) {
- store.setGlobalDraftStarters(nextStarters);
- }
- if (settings.draftStartersCraftGoalAdded !== true || settings.draftStartersScheduleTaskAdded !== true) {
- settings.draftStarters = nextStarters;
- settings.draftStartersCraftGoalAdded = true;
- settings.draftStartersScheduleTaskAdded = true;
- }
- } else {
- // The built-in default already contains Craft a Goal and Schedule a Task;
- // only persist the markers so removing them later remains a durable user
- // choice.
- if (settings.draftStartersCraftGoalAdded !== true) {
- settings.draftStartersCraftGoalAdded = true;
- }
- if (settings.draftStartersScheduleTaskAdded !== true) {
- settings.draftStartersScheduleTaskAdded = true;
- }
- }
- if (typeof settings.draftStartersVisible === 'boolean' && settings.draftStartersVisible !== store.draftStartersVisible) {
- store.setDraftStartersVisible(settings.draftStartersVisible);
- }
- if (typeof settings.terminalFontSize === 'number' && Number.isFinite(settings.terminalFontSize) && settings.terminalFontSize !== store.terminalFontSize) {
- store.setTerminalFontSize(settings.terminalFontSize);
- }
- if (isTerminalShell(settings.terminalShell) && settings.terminalShell !== store.terminalShell) {
- store.setTerminalShell(settings.terminalShell);
- }
- if (
- Array.isArray(settings.terminalLoginShells)
- && (
- settings.terminalLoginShells.length !== store.terminalLoginShells.length
- || settings.terminalLoginShells.some((shell, index) => shell !== store.terminalLoginShells[index])
- )
- ) {
- store.setTerminalLoginShells(settings.terminalLoginShells);
- }
- if (typeof settings.editorFontSize === 'number' && Number.isFinite(settings.editorFontSize) && settings.editorFontSize !== store.editorFontSize) {
- store.setEditorFontSize(settings.editorFontSize);
- }
- if (isUiFontOption(settings.uiFont) && settings.uiFont !== store.uiFont) {
- store.setUiFont(settings.uiFont);
- }
- if (isMonoFontOption(settings.monoFont) && settings.monoFont !== store.monoFont) {
- store.setMonoFont(settings.monoFont);
- }
- if (typeof settings.padding === 'number' && Number.isFinite(settings.padding) && settings.padding !== store.padding) {
- store.setPadding(settings.padding);
- }
- if (typeof settings.cornerRadius === 'number' && Number.isFinite(settings.cornerRadius) && settings.cornerRadius !== store.cornerRadius) {
- store.setCornerRadius(settings.cornerRadius);
- }
- if (typeof settings.inputBarOffset === 'number' && Number.isFinite(settings.inputBarOffset) && settings.inputBarOffset !== store.inputBarOffset) {
- store.setInputBarOffset(settings.inputBarOffset);
- }
- if (settings.shortcutOverrides && !areStringRecordsEqual(settings.shortcutOverrides, store.shortcutOverrides)) {
- useUIStore.setState({ shortcutOverrides: settings.shortcutOverrides });
- }
- if (typeof settings.mobileKeyboardMode === 'string') {
- const mode = normalizeMobileKeyboardMode(settings.mobileKeyboardMode, store.mobileKeyboardMode);
- if (mode !== store.mobileKeyboardMode) {
- store.setMobileKeyboardMode(mode);
- }
- }
- if (configStoreApi && configStore) {
- const nextConfigState: Partial = {};
- if (typeof settings.dictationEnabled === 'boolean' && settings.dictationEnabled !== configStore.dictationEnabled) {
- nextConfigState.dictationEnabled = settings.dictationEnabled;
- }
- if ((settings.sttProvider === 'local' || settings.sttProvider === 'openai-compatible') && settings.sttProvider !== configStore.sttProvider) {
- nextConfigState.sttProvider = settings.sttProvider;
- }
- if (typeof settings.sttServerUrl === 'string' && settings.sttServerUrl !== configStore.sttServerUrl) {
- nextConfigState.sttServerUrl = settings.sttServerUrl;
- }
- if (typeof settings.sttModel === 'string' && settings.sttModel !== configStore.sttModel) {
- nextConfigState.sttModel = settings.sttModel;
- }
- if (typeof settings.sttLocalModel === 'string' && settings.sttLocalModel !== configStore.sttLocalModel) {
- nextConfigState.sttLocalModel = settings.sttLocalModel;
- }
- if (typeof settings.sttLanguage === 'string' && settings.sttLanguage !== configStore.sttLanguage) {
- nextConfigState.sttLanguage = settings.sttLanguage;
- }
- if (Object.keys(nextConfigState).length > 0) {
- configStoreApi.setState(nextConfigState);
- }
- }
-
- if (Array.isArray(settings.favoriteModels)) {
- const current = store.favoriteModels;
- const next = settings.favoriteModels;
- if (!areModelRefsEqual(current, next)) {
- useUIStore.setState({ favoriteModels: next });
- }
- }
-
- if (Array.isArray(settings.hiddenModels)) {
- const current = store.hiddenModels;
- const next = settings.hiddenModels;
- if (!areModelRefsEqual(current, next)) {
- useUIStore.setState({ hiddenModels: next });
- }
- }
-
- if (Array.isArray(settings.collapsedModelProviders)) {
- const current = store.collapsedModelProviders;
- const next = settings.collapsedModelProviders;
- if (!areStringArraysEqual(current, next)) {
- useUIStore.setState({ collapsedModelProviders: next });
- }
- }
-
- if (Array.isArray(settings.recentModels)) {
- const current = store.recentModels;
- const next = settings.recentModels;
- if (!areModelRefsEqual(current, next)) {
- useUIStore.setState({ recentModels: next });
- }
- }
-
- if (Array.isArray(settings.recentAgents)) {
- const current = store.recentAgents;
- const next = settings.recentAgents;
- if (!areStringArraysEqual(current, next)) {
- useUIStore.setState({ recentAgents: next });
- }
- }
-
- if (settings.recentEfforts && typeof settings.recentEfforts === 'object') {
- const current = store.recentEfforts;
- const next = settings.recentEfforts;
- if (!areRecentEffortsEqual(current, next)) {
- useUIStore.setState({ recentEfforts: next });
- }
- }
- if (typeof settings.diffLayoutPreference === 'string'
- && (settings.diffLayoutPreference === 'dynamic' || settings.diffLayoutPreference === 'inline' || settings.diffLayoutPreference === 'side-by-side')) {
- if (settings.diffLayoutPreference !== store.diffLayoutPreference) {
- store.setDiffLayoutPreference(settings.diffLayoutPreference);
- }
- }
- if (typeof settings.gitChangesViewMode === 'string'
- && (settings.gitChangesViewMode === 'flat' || settings.gitChangesViewMode === 'tree')) {
- if (settings.gitChangesViewMode !== store.gitChangesViewMode) {
- store.setGitChangesViewMode(settings.gitChangesViewMode);
- }
- }
- if (typeof settings.toolJsonViewMode === 'string'
- && (settings.toolJsonViewMode === 'summary' || settings.toolJsonViewMode === 'formatted' || settings.toolJsonViewMode === 'raw')) {
- if (settings.toolJsonViewMode !== store.toolJsonViewMode) {
- store.setToolJsonViewMode(settings.toolJsonViewMode);
- }
- }
- if (typeof settings.directoryShowHidden === 'boolean') {
- setDirectoryShowHidden(settings.directoryShowHidden, { persist: false });
- }
- if (typeof settings.filesViewShowGitignored === 'boolean') {
- setFilesViewShowGitignored(settings.filesViewShowGitignored, { persist: false });
- }
- const sessionDisplayChanges: Partial> = {};
- if (settings.sidebarProjectDisplayMode === 'all' || settings.sidebarProjectDisplayMode === 'single') {
- sessionDisplayChanges.projectDisplayMode = settings.sidebarProjectDisplayMode;
- }
- if (settings.sidebarSessionGroupingMode === 'by-worktree' || settings.sidebarSessionGroupingMode === 'flat') {
- sessionDisplayChanges.sessionGroupingMode = settings.sidebarSessionGroupingMode;
- }
- if (settings.sidebarProjectSortOrder === 'manual'
- || settings.sidebarProjectSortOrder === 'a-z'
- || settings.sidebarProjectSortOrder === 'z-a'
- || settings.sidebarProjectSortOrder === 'date-added'
- || settings.sidebarProjectSortOrder === 'recent') {
- sessionDisplayChanges.projectSortOrder = settings.sidebarProjectSortOrder;
- }
- if (typeof settings.sidebarShowRecentSection === 'boolean') {
- sessionDisplayChanges.showRecentSection = settings.sidebarShowRecentSection;
- }
- if (Object.keys(sessionDisplayChanges).length > 0) {
- useSessionDisplayStore.setState(sessionDisplayChanges);
- }
-};
-
-const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
- if (!payload || typeof payload !== 'object') {
- return null;
- }
-
- const candidate = payload as Record;
- const result: DesktopSettings = {};
-
- if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) {
- result.themeId = candidate.themeId;
- }
- if (candidate.useSystemTheme === true || candidate.useSystemTheme === false) {
- result.useSystemTheme = candidate.useSystemTheme;
- }
- if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
- result.themeVariant = candidate.themeVariant;
- }
- if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) {
- result.lightThemeId = candidate.lightThemeId;
- }
- if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
- result.darkThemeId = candidate.darkThemeId;
- }
- if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
- result.lastDirectory = candidate.lastDirectory;
- }
- if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
- result.homeDirectory = candidate.homeDirectory;
- }
-
- if (typeof candidate.opencodeBinary === 'string') {
- const trimmed = candidate.opencodeBinary.trim();
- result.opencodeBinary = trimmed.length > 0 ? trimmed : undefined;
- }
- if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
- result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
- }
- if (typeof candidate.desktopKeepAwakeEnabled === 'boolean') {
- result.desktopKeepAwakeEnabled = candidate.desktopKeepAwakeEnabled;
- }
- if (typeof candidate.desktopMinimizeToTrayEnabled === 'boolean') {
- result.desktopMinimizeToTrayEnabled = candidate.desktopMinimizeToTrayEnabled;
- }
- if (typeof candidate.desktopMacMenuBarEnabled === 'boolean') {
- result.desktopMacMenuBarEnabled = candidate.desktopMacMenuBarEnabled;
- }
-
- const projects = sanitizeProjects(candidate.projects);
- if (projects) {
- result.projects = projects;
- }
- if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
- result.activeProjectId = candidate.activeProjectId;
- }
- if (candidate.sidebarProjectDisplayMode === 'all' || candidate.sidebarProjectDisplayMode === 'single') {
- result.sidebarProjectDisplayMode = candidate.sidebarProjectDisplayMode;
- }
- if (candidate.sidebarSessionGroupingMode === 'by-worktree' || candidate.sidebarSessionGroupingMode === 'flat') {
- result.sidebarSessionGroupingMode = candidate.sidebarSessionGroupingMode;
- }
- if (candidate.sidebarProjectSortOrder === 'manual'
- || candidate.sidebarProjectSortOrder === 'a-z'
- || candidate.sidebarProjectSortOrder === 'z-a'
- || candidate.sidebarProjectSortOrder === 'date-added'
- || candidate.sidebarProjectSortOrder === 'recent') {
- result.sidebarProjectSortOrder = candidate.sidebarProjectSortOrder;
- }
- if (typeof candidate.sidebarShowRecentSection === 'boolean') {
- result.sidebarShowRecentSection = candidate.sidebarShowRecentSection;
- }
-
- if (Array.isArray(candidate.securityScopedBookmarks)) {
- result.securityScopedBookmarks = candidate.securityScopedBookmarks.filter(
- (entry): entry is string => typeof entry === 'string' && entry.length > 0
- );
- }
- if (Array.isArray(candidate.pinnedDirectories)) {
- result.pinnedDirectories = Array.from(
- new Set(
- candidate.pinnedDirectories.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
- )
- );
- }
- if (Array.isArray(candidate.draftStarters)) {
- result.draftStarters = sanitizeStarterRefs(candidate.draftStarters);
- }
- if (typeof candidate.draftStartersVisible === 'boolean') {
- result.draftStartersVisible = candidate.draftStartersVisible;
- }
- if (typeof candidate.draftStartersCraftGoalAdded === 'boolean') {
- result.draftStartersCraftGoalAdded = candidate.draftStartersCraftGoalAdded;
- }
- if (typeof candidate.draftStartersScheduleTaskAdded === 'boolean') {
- result.draftStartersScheduleTaskAdded = candidate.draftStartersScheduleTaskAdded;
- }
- if (typeof candidate.workStatusPanelEnabled === 'boolean') {
- result.workStatusPanelEnabled = candidate.workStatusPanelEnabled;
- }
- if (Array.isArray(candidate.workStatusHiddenSections)) {
- // Unknown ids are dropped rather than kept: they would hide nothing and
- // accumulate forever as sections get renamed.
- result.workStatusHiddenSections = sanitizeWorkStatusHiddenSections(candidate.workStatusHiddenSections);
- }
- if (typeof candidate.workStatusHiddenSectionsExplicit === 'boolean') {
- result.workStatusHiddenSectionsExplicit = candidate.workStatusHiddenSectionsExplicit;
- }
- if (typeof candidate.showReasoningTraces === 'boolean') {
- result.showReasoningTraces = candidate.showReasoningTraces;
- }
- if (typeof candidate.streamingAutoFollowEnabled === 'boolean') {
- result.streamingAutoFollowEnabled = candidate.streamingAutoFollowEnabled;
- }
- if (typeof candidate.inputHistoryScope === 'string' && isInputHistoryScope(candidate.inputHistoryScope)) {
- result.inputHistoryScope = candidate.inputHistoryScope;
- }
- if (typeof candidate.inputHistoryLimit === 'number' && isInputHistoryLimit(candidate.inputHistoryLimit)) {
- result.inputHistoryLimit = candidate.inputHistoryLimit;
- }
- if (typeof candidate.sessionRecapEnabled === 'boolean') {
- result.sessionRecapEnabled = candidate.sessionRecapEnabled;
- }
- if (typeof candidate.sessionSuggestionEnabled === 'boolean') {
- result.sessionSuggestionEnabled = candidate.sessionSuggestionEnabled;
- }
- if (typeof candidate.sessionGoalEnabled === 'boolean') {
- result.sessionGoalEnabled = candidate.sessionGoalEnabled;
- }
- if (typeof candidate.sessionGoalDefaultBudgetEnabled === 'boolean') {
- result.sessionGoalDefaultBudgetEnabled = candidate.sessionGoalDefaultBudgetEnabled;
- }
- if (typeof candidate.sessionGoalDefaultBudget === 'number' && Number.isFinite(candidate.sessionGoalDefaultBudget) && candidate.sessionGoalDefaultBudget > 0) {
- result.sessionGoalDefaultBudget = Math.floor(candidate.sessionGoalDefaultBudget);
- }
- if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
- result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
- }
- if (typeof candidate.autoDeleteEnabled === 'boolean') {
- result.autoDeleteEnabled = candidate.autoDeleteEnabled;
- }
- if (typeof candidate.autoSaveEnabled === 'boolean') {
- result.autoSaveEnabled = candidate.autoSaveEnabled;
- }
- if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
- result.autoDeleteAfterDays = candidate.autoDeleteAfterDays;
- }
- if (candidate.sessionRetentionAction === 'archive' || candidate.sessionRetentionAction === 'delete') {
- result.sessionRetentionAction = candidate.sessionRetentionAction;
- }
- if (typeof candidate.tunnelProvider === 'string') {
- const provider = candidate.tunnelProvider.trim().toLowerCase();
- if (provider.length > 0) {
- result.tunnelProvider = provider;
- }
- }
- if (typeof candidate.tunnelMode === 'string') {
- const mode = candidate.tunnelMode.trim().toLowerCase();
- if (mode === 'quick' || mode === 'managed-remote' || mode === 'managed-local') {
- result.tunnelMode = mode;
- }
- }
- if (candidate.tunnelBootstrapTtlMs === null) {
- result.tunnelBootstrapTtlMs = null;
- } else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
- result.tunnelBootstrapTtlMs = candidate.tunnelBootstrapTtlMs;
- }
- if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) {
- result.tunnelSessionTtlMs = candidate.tunnelSessionTtlMs;
- }
- if (candidate.managedLocalTunnelConfigPath === null) {
- result.managedLocalTunnelConfigPath = null;
- } else if (typeof candidate.managedLocalTunnelConfigPath === 'string') {
- const trimmed = candidate.managedLocalTunnelConfigPath.trim();
- result.managedLocalTunnelConfigPath = trimmed.length > 0 ? trimmed : null;
- }
- if (typeof candidate.managedRemoteTunnelHostname === 'string') {
- result.managedRemoteTunnelHostname = candidate.managedRemoteTunnelHostname.trim();
- }
- if (candidate.managedRemoteTunnelToken === null) {
- result.managedRemoteTunnelToken = null;
- } else if (typeof candidate.managedRemoteTunnelToken === 'string') {
- result.managedRemoteTunnelToken = candidate.managedRemoteTunnelToken.trim();
- }
- const managedRemoteTunnelPresets = sanitizeManagedRemoteTunnelPresets(candidate.managedRemoteTunnelPresets);
- if (managedRemoteTunnelPresets) {
- result.managedRemoteTunnelPresets = managedRemoteTunnelPresets;
- }
- if (typeof candidate.managedRemoteTunnelSelectedPresetId === 'string') {
- const trimmed = candidate.managedRemoteTunnelSelectedPresetId.trim();
- result.managedRemoteTunnelSelectedPresetId = trimmed.length > 0 ? trimmed : undefined;
- }
- const managedRemoteTunnelPresetTokens = sanitizeManagedRemoteTunnelPresetTokens(candidate.managedRemoteTunnelPresetTokens);
- if (managedRemoteTunnelPresetTokens) {
- result.managedRemoteTunnelPresetTokens = managedRemoteTunnelPresetTokens;
- }
- if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) {
- result.defaultModel = candidate.defaultModel;
- }
- if (typeof candidate.defaultVariant === 'string' && candidate.defaultVariant.length > 0) {
- result.defaultVariant = candidate.defaultVariant;
- }
- if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
- result.defaultAgent = candidate.defaultAgent;
- }
- if (typeof candidate.smallModelUseDefault === 'boolean') {
- result.smallModelUseDefault = candidate.smallModelUseDefault;
- }
- if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) {
- result.smallModelOverride = candidate.smallModelOverride;
- }
- if (typeof candidate.walkthroughModelOverride === 'string' && candidate.walkthroughModelOverride.length > 0) {
- result.walkthroughModelOverride = candidate.walkthroughModelOverride;
- }
- if (typeof candidate.autoCreateWorktree === 'boolean') {
- result.autoCreateWorktree = candidate.autoCreateWorktree;
- }
- if (typeof candidate.gitmojiEnabled === 'boolean') {
- result.gitmojiEnabled = candidate.gitmojiEnabled;
- }
- if (isFollowUpBehavior(candidate.followUpBehavior)) {
- result.followUpBehavior = candidate.followUpBehavior;
- } else if (typeof candidate.queueModeEnabled === 'boolean') {
- result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled);
- }
- if (typeof candidate.showDeletionDialog === 'boolean') {
- result.showDeletionDialog = candidate.showDeletionDialog;
- }
- if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
- result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
- }
- if (typeof candidate.notificationMode === 'string' && (candidate.notificationMode === 'always' || candidate.notificationMode === 'hidden-only')) {
- result.notificationMode = candidate.notificationMode;
- }
- if (typeof candidate.notifyOnSubtasks === 'boolean') {
- result.notifyOnSubtasks = candidate.notifyOnSubtasks;
- }
- if (typeof candidate.notifyOnCompletion === 'boolean') {
- result.notifyOnCompletion = candidate.notifyOnCompletion;
- }
- if (typeof candidate.notifyOnError === 'boolean') {
- result.notifyOnError = candidate.notifyOnError;
- }
- if (typeof candidate.notifyOnQuestion === 'boolean') {
- result.notifyOnQuestion = candidate.notifyOnQuestion;
- }
- if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') {
- const templates = candidate.notificationTemplates as Record;
- const validateTemplate = (key: string): { title: string; message: string } | undefined => {
- const value = templates[key];
- if (!value || typeof value !== 'object') return undefined;
- const obj = value as Record;
- const title = typeof obj.title === 'string' ? obj.title : '';
- const message = typeof obj.message === 'string' ? obj.message : '';
- return { title, message };
- };
- const completion = validateTemplate('completion');
- const error = validateTemplate('error');
- const question = validateTemplate('question');
- const subtask = validateTemplate('subtask');
- if (completion || error || question || subtask) {
- result.notificationTemplates = {
- completion: completion ?? { title: 'Task Complete', message: 'Your task has finished.' },
- error: error ?? { title: 'Error Occurred', message: 'An error occurred while processing your task.' },
- question: question ?? { title: 'Input Needed', message: 'Please provide input to continue.' },
- subtask: subtask ?? { title: 'Subtask Complete', message: 'A subtask has finished.' },
- };
- }
- }
- if (typeof candidate.summarizeLastMessage === 'boolean') {
- result.summarizeLastMessage = candidate.summarizeLastMessage;
- }
- if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) {
- result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold));
- }
- if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) {
- result.summaryLength = Math.max(10, Math.round(candidate.summaryLength));
- }
- if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) {
- result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength));
- }
- if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') {
- result.usageDisplayMode = candidate.usageDisplayMode;
- }
- if (Array.isArray(candidate.usageDropdownProviders)) {
- result.usageDropdownProviders = candidate.usageDropdownProviders.filter(
- (entry): entry is string => typeof entry === 'string' && entry.length > 0
- );
- }
-
- // Parse usageSelectedModels (Record)
- if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') {
- const selectedModels: Record = {};
- for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) {
- if (Array.isArray(models)) {
- selectedModels[providerId] = models.filter((m): m is string => typeof m === 'string');
- }
- }
- if (Object.keys(selectedModels).length > 0) {
- result.usageSelectedModels = selectedModels;
- }
- }
-
- // Parse usageCollapsedFamilies (Record)
- if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') {
- const collapsedFamilies: Record = {};
- for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) {
- if (Array.isArray(families)) {
- collapsedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string');
- }
- }
- if (Object.keys(collapsedFamilies).length > 0) {
- result.usageCollapsedFamilies = collapsedFamilies;
- }
- }
-
- // Parse usageExpandedFamilies (Record) - inverted collapsed logic for header dropdown
- if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') {
- const expandedFamilies: Record = {};
- for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) {
- if (Array.isArray(families)) {
- expandedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string');
- }
- }
- if (Object.keys(expandedFamilies).length > 0) {
- result.usageExpandedFamilies = expandedFamilies;
- }
- }
-
- // Parse usageModelGroups - custom model groups configuration per provider
- if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') {
- const modelGroups: Record;
- modelAssignments?: Record;
- renamedGroups?: Record;
- }> = {};
- for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) {
- if (config && typeof config === 'object') {
- const typedConfig = config as Record;
- const providerConfig: NonNullable[string] = {};
-
- // Parse customGroups
- if (Array.isArray(typedConfig.customGroups)) {
- providerConfig.customGroups = typedConfig.customGroups
- .filter((g): g is Record => g && typeof g === 'object')
- .map((g) => ({
- id: String(g.id ?? ''),
- label: String(g.label ?? ''),
- models: Array.isArray(g.models)
- ? g.models.filter((m): m is string => typeof m === 'string')
- : [],
- order: typeof g.order === 'number' ? g.order : 0,
- }));
- }
-
- // Parse modelAssignments
- if (typedConfig.modelAssignments && typeof typedConfig.modelAssignments === 'object') {
- providerConfig.modelAssignments = Object.fromEntries(
- Object.entries(typedConfig.modelAssignments as Record)
- .filter(([, v]) => typeof v === 'string')
- .map(([k, v]) => [k, String(v)])
- );
- }
-
- // Parse renamedGroups
- if (typedConfig.renamedGroups && typeof typedConfig.renamedGroups === 'object') {
- providerConfig.renamedGroups = Object.fromEntries(
- Object.entries(typedConfig.renamedGroups as Record)
- .filter(([, v]) => typeof v === 'string')
- .map(([k, v]) => [k, String(v)])
- );
- }
-
- if (Object.keys(providerConfig).length > 0) {
- modelGroups[providerId] = providerConfig;
- }
- }
- }
- if (Object.keys(modelGroups).length > 0) {
- result.usageModelGroups = modelGroups;
- }
- }
-
- if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
- result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
- }
- if (candidate.enterToSend === true || candidate.enterToSend === false) {
- result.enterToSend = candidate.enterToSend;
- }
- if (candidate.enterToSendConfigured === true || candidate.enterToSendConfigured === false) {
- result.enterToSendConfigured = candidate.enterToSendConfigured;
- }
- if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') {
- result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications;
- }
- if (typeof candidate.agentControlToolEnabled === 'boolean') {
- result.agentControlToolEnabled = candidate.agentControlToolEnabled;
- }
- if (typeof candidate.agentWebToolEnabled === 'boolean') {
- result.agentWebToolEnabled = candidate.agentWebToolEnabled;
- }
- if (typeof candidate.agentMemoryToolEnabled === 'boolean') {
- result.agentMemoryToolEnabled = candidate.agentMemoryToolEnabled;
- }
- if (typeof candidate.openCodeUpdateToastDismissedVersion === 'string') {
- result.openCodeUpdateToastDismissedVersion = candidate.openCodeUpdateToastDismissedVersion.trim().slice(0, 128);
- }
- if (typeof candidate.showToolFileIcons === 'boolean') {
- result.showToolFileIcons = candidate.showToolFileIcons;
- }
- if (typeof candidate.codeBlockLineWrap === 'boolean') {
- result.codeBlockLineWrap = candidate.codeBlockLineWrap;
- }
- if (typeof candidate.showTurnChangedFiles === 'boolean') {
- result.showTurnChangedFiles = candidate.showTurnChangedFiles;
- }
- if (typeof candidate.showExpandedBashTools === 'boolean') {
- result.showExpandedBashTools = candidate.showExpandedBashTools;
- }
- if (typeof candidate.showExpandedEditTools === 'boolean') {
- result.showExpandedEditTools = candidate.showExpandedEditTools;
- }
- if (typeof candidate.timeFormatPreference === 'string'
- && (candidate.timeFormatPreference === 'auto' || candidate.timeFormatPreference === '12h' || candidate.timeFormatPreference === '24h')) {
- result.timeFormatPreference = candidate.timeFormatPreference;
- }
- if (typeof candidate.weekStartPreference === 'string'
- && (candidate.weekStartPreference === 'auto' || candidate.weekStartPreference === 'sunday' || candidate.weekStartPreference === 'monday')) {
- result.weekStartPreference = candidate.weekStartPreference;
- }
- if (typeof candidate.desktopWindowControlsPosition === 'string') {
- if (candidate.desktopWindowControlsPosition === 'left') {
- result.desktopWindowControlsPosition = 'left';
- } else if (
- candidate.desktopWindowControlsPosition === 'right'
- || candidate.desktopWindowControlsPosition === 'auto'
- ) {
- // Legacy "auto" never read OS chrome config; treat as right.
- result.desktopWindowControlsPosition = 'right';
- }
- }
- if (typeof candidate.desktopWindowControlsStyle === 'string') {
- if (candidate.desktopWindowControlsStyle === 'classic' || candidate.desktopWindowControlsStyle === 'traffic-lights') {
- result.desktopWindowControlsStyle = candidate.desktopWindowControlsStyle;
- }
- }
- if (typeof candidate.chatRenderMode === 'string'
- && (candidate.chatRenderMode === 'sorted' || candidate.chatRenderMode === 'live')) {
- result.chatRenderMode = candidate.chatRenderMode;
- }
- if (typeof candidate.messageStreamTransport === 'string'
- && (candidate.messageStreamTransport === 'auto' || candidate.messageStreamTransport === 'ws' || candidate.messageStreamTransport === 'sse')) {
- result.messageStreamTransport = candidate.messageStreamTransport;
- }
- if (typeof candidate.activityRenderMode === 'string'
- && (candidate.activityRenderMode === 'collapsed' || candidate.activityRenderMode === 'summary')) {
- result.activityRenderMode = candidate.activityRenderMode;
- }
- if (typeof candidate.mermaidRenderingMode === 'string'
- && (candidate.mermaidRenderingMode === 'svg' || candidate.mermaidRenderingMode === 'ascii')) {
- result.mermaidRenderingMode = candidate.mermaidRenderingMode;
- }
- if (typeof candidate.userMessageRenderingMode === 'string'
- && (candidate.userMessageRenderingMode === 'markdown' || candidate.userMessageRenderingMode === 'plain')) {
- result.userMessageRenderingMode = candidate.userMessageRenderingMode;
- }
- if (typeof candidate.collapsibleUserMessages === 'boolean') {
- result.collapsibleUserMessages = candidate.collapsibleUserMessages;
- }
- if (typeof candidate.stickyUserHeader === 'boolean') {
- result.stickyUserHeader = candidate.stickyUserHeader;
- }
- if (typeof candidate.promptNavigatorEnabled === 'boolean') {
- result.promptNavigatorEnabled = candidate.promptNavigatorEnabled;
- }
- if (typeof candidate.wideChatLayoutEnabled === 'boolean') {
- result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled;
- }
- if (typeof candidate.showSplitAssistantMessageActions === 'boolean') {
- result.showSplitAssistantMessageActions = candidate.showSplitAssistantMessageActions;
- }
- if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
- result.fontSize = candidate.fontSize;
- }
- if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) {
- result.terminalFontSize = candidate.terminalFontSize;
- }
- if (isTerminalShell(candidate.terminalShell)) {
- result.terminalShell = candidate.terminalShell;
- }
- if (Array.isArray(candidate.terminalLoginShells)) {
- result.terminalLoginShells = [...new Set(candidate.terminalLoginShells.filter(isTerminalShell))];
- }
- if (typeof candidate.editorFontSize === 'number' && Number.isFinite(candidate.editorFontSize)) {
- result.editorFontSize = candidate.editorFontSize;
- }
- if (isUiFontOption(candidate.uiFont)) {
- result.uiFont = candidate.uiFont;
- }
- if (isMonoFontOption(candidate.monoFont)) {
- result.monoFont = candidate.monoFont;
- }
- if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
- result.padding = candidate.padding;
- }
- if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) {
- result.cornerRadius = candidate.cornerRadius;
- }
- if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
- result.inputBarOffset = candidate.inputBarOffset;
- }
- const shortcutOverrides = sanitizeShortcutOverrides(candidate.shortcutOverrides);
- if (shortcutOverrides) {
- result.shortcutOverrides = shortcutOverrides;
- }
- if (typeof candidate.mobileKeyboardMode === 'string') {
- if (candidate.mobileKeyboardMode === 'native' || candidate.mobileKeyboardMode === 'resize-content') {
- result.mobileKeyboardMode = candidate.mobileKeyboardMode;
- }
- }
-
- const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64);
- if (favoriteModels) {
- result.favoriteModels = favoriteModels;
- }
-
- const hiddenModels = sanitizeModelRefs(candidate.hiddenModels, 1024);
- if (hiddenModels) {
- result.hiddenModels = hiddenModels;
- }
-
- const collapsedModelProviders = sanitizeStringArray(candidate.collapsedModelProviders);
- if (collapsedModelProviders) {
- result.collapsedModelProviders = collapsedModelProviders;
- }
-
- const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
- if (recentModels) {
- result.recentModels = recentModels;
- }
-
- const recentAgents = sanitizeStringArray(candidate.recentAgents);
- if (recentAgents) {
- result.recentAgents = recentAgents;
- }
-
- const recentEfforts = sanitizeRecentEfforts(candidate.recentEfforts);
- if (recentEfforts) {
- result.recentEfforts = recentEfforts;
- }
- if (
- typeof candidate.diffLayoutPreference === 'string'
- && (candidate.diffLayoutPreference === 'dynamic'
- || candidate.diffLayoutPreference === 'inline'
- || candidate.diffLayoutPreference === 'side-by-side')
- ) {
- result.diffLayoutPreference = candidate.diffLayoutPreference;
- }
- if (
- typeof candidate.gitChangesViewMode === 'string'
- && (candidate.gitChangesViewMode === 'flat' || candidate.gitChangesViewMode === 'tree')
- ) {
- result.gitChangesViewMode = candidate.gitChangesViewMode;
- }
- if (
- typeof candidate.toolJsonViewMode === 'string'
- && (candidate.toolJsonViewMode === 'summary' || candidate.toolJsonViewMode === 'formatted' || candidate.toolJsonViewMode === 'raw')
- ) {
- result.toolJsonViewMode = candidate.toolJsonViewMode;
- }
- if (typeof candidate.directoryShowHidden === 'boolean') {
- result.directoryShowHidden = candidate.directoryShowHidden;
- }
- if (typeof candidate.filesViewShowGitignored === 'boolean') {
- result.filesViewShowGitignored = candidate.filesViewShowGitignored;
- }
- if (typeof candidate.openInAppId === 'string' && candidate.openInAppId.length > 0) {
- result.openInAppId = candidate.openInAppId;
- }
- if (typeof candidate.pwaAppName === 'string') {
- const normalized = candidate.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
- result.pwaAppName = normalized.length > 0 ? normalized : '';
- }
-
- const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
- if (skillCatalogs) {
- result.skillCatalogs = skillCatalogs;
- }
-
- if (typeof candidate.reportUsage === 'boolean') {
- result.reportUsage = candidate.reportUsage;
- }
-
- if (typeof candidate.globalBehaviorPrompt === 'string') {
- result.globalBehaviorPrompt = candidate.globalBehaviorPrompt;
- }
- if (typeof candidate.responseStyleEnabled === 'boolean') {
- result.responseStyleEnabled = candidate.responseStyleEnabled;
- }
- if (
- typeof candidate.responseStylePreset === 'string'
- && (candidate.responseStylePreset === 'concise'
- || candidate.responseStylePreset === 'detailed'
- || candidate.responseStylePreset === 'mentor'
- || candidate.responseStylePreset === 'pushback'
- || candidate.responseStylePreset === 'noFiller'
- || candidate.responseStylePreset === 'matchEnergy'
- || candidate.responseStylePreset === 'warmPeer'
- || candidate.responseStylePreset === 'custom')
- ) {
- result.responseStylePreset = candidate.responseStylePreset;
- }
- if (typeof candidate.responseStyleCustomInstructions === 'string') {
- result.responseStyleCustomInstructions = candidate.responseStyleCustomInstructions;
- }
- if (typeof candidate.dictationEnabled === 'boolean') {
- result.dictationEnabled = candidate.dictationEnabled;
- }
- if (candidate.sttProvider === 'local' || candidate.sttProvider === 'openai-compatible') {
- result.sttProvider = candidate.sttProvider;
- } else if (candidate.sttProvider === 'server') {
- // Legacy provider migration: 'server' was the OpenAI-compatible endpoint.
- result.sttProvider = 'openai-compatible';
- } else if (candidate.sttProvider === 'browser' || candidate.sttProvider === 'wasm') {
- result.sttProvider = 'local';
- }
- if (typeof candidate.sttServerUrl === 'string') {
- result.sttServerUrl = candidate.sttServerUrl.trim();
- }
- if (typeof candidate.sttModel === 'string') {
- result.sttModel = candidate.sttModel.trim();
- }
- if (typeof candidate.sttLocalModel === 'string') {
- result.sttLocalModel = candidate.sttLocalModel.trim();
- }
- if (typeof candidate.sttLanguage === 'string') {
- result.sttLanguage = candidate.sttLanguage.trim();
- }
-
- return result;
-};
+/** Parse an untrusted settings document at the boundary; `null` when it is not an object at all. */
+const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => parseSettingsDocument(payload);
type SettingsRuntimeContext = { runtimeKey: string; generation: number };
+/** Whether a settings write reached its store. A no-op (nothing to send) counts as ok. */
+export type SettingsWriteResult = { ok: boolean };
type SettingsMutation = { revision: number; changes: Partial };
type SettingsOperation = { revision: number };
@@ -1816,11 +352,33 @@ class SettingsMutationTracker {
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
let _settingsRuntimeGeneration = 0;
let _settingsCache: { value: DesktopSettings | null; at: number; context: SettingsRuntimeContext } | null = null;
+// The last value the server was seen holding for each key, for the current
+// runtime. A write whose value equals it is redundant and is dropped before it
+// reaches the wire — this is what turns "the store changed because we adopted
+// the server's value" into zero PUTs instead of an echo (appearanceAutoSave and
+// the model-prefs auto-save both subscribe to the store, not to intent).
+let _serverKnownSettings: Partial = {};
+// True while server values are being copied into the stores. Store
+// subscribers that mirror changes back to the server (appearanceAutoSave,
+// modelPrefsAutoSave) read this to tell "a person changed it" from "we just
+// adopted it" — the second must never become a write.
+let _applyingServerSettings = false;
+
+export const isApplyingServerSettings = (): boolean => _applyingServerSettings;
+
+const applyServerSettings = (settings: DesktopSettings): void => {
+ _applyingServerSettings = true;
+ try {
+ applyDesktopUiPreferences(settings);
+ } finally {
+ _applyingServerSettings = false;
+ }
+};
let _settingsInflight: { promise: Promise; context: SettingsRuntimeContext } | null = null;
let _pendingSettingsChanges: Partial | null = null;
let _pendingSettingsContext: SettingsRuntimeContext | null = null;
let _settingsFlushTimer: ReturnType | null = null;
-let _settingsFlushWaiters: Array<() => void> = [];
+let _settingsFlushWaiters: Array<(result: SettingsWriteResult) => void> = [];
let _settingsLifecycleInitialized = false;
let _pendingSettingsRevision = 0;
const _settingsMutationTracker = new SettingsMutationTracker();
@@ -1832,6 +390,39 @@ const captureSettingsRuntimeContext = (): SettingsRuntimeContext => ({
generation: _settingsRuntimeGeneration,
});
+type SettingsKey = keyof DesktopSettings;
+type SettingsValue = DesktopSettings[SettingsKey];
+
+// SAFETY: a Partial here always comes from the typed stores or
+// from `sanitizeWebSettings`, both of which only ever set DesktopSettings keys.
+const settingsKeysOf = (changes: Partial): SettingsKey[] => Object.keys(changes) as SettingsKey[];
+
+const isSameSettingValue = (left: SettingsValue | undefined, right: SettingsValue | undefined): boolean => {
+ if (left === right) return true;
+ if (left === undefined || right === undefined) return false;
+ return JSON.stringify(left) === JSON.stringify(right);
+};
+
+const rememberServerSettings = (settings: Partial): void => {
+ _serverKnownSettings = { ..._serverKnownSettings, ...settings };
+};
+
+const forgetServerSettings = (keys: SettingsKey[]): void => {
+ const next: Partial = { ..._serverKnownSettings };
+ for (const key of keys) delete next[key];
+ _serverKnownSettings = next;
+};
+
+/** Keys of `changes` whose value differs from what the server is known to hold. */
+const withoutRedundantSettings = (changes: Partial): Partial => {
+ const next: Partial = {};
+ for (const key of settingsKeysOf(changes)) {
+ if (isSameSettingValue(changes[key], _serverKnownSettings[key])) continue;
+ Object.assign(next, { [key]: changes[key] });
+ }
+ return next;
+};
+
const isSameSettingsRuntimeContext = (left: SettingsRuntimeContext, right: SettingsRuntimeContext): boolean => (
left.runtimeKey === right.runtimeKey && left.generation === right.generation
);
@@ -1876,6 +467,7 @@ const ensureSettingsRuntimeLifecycle = (): void => {
_pendingSettingsRevision = 0;
_settingsCache = null;
_settingsInflight = null;
+ _serverKnownSettings = {};
});
// Mirror the deferred safe-storage lifecycle: without these listeners, a
@@ -1926,6 +518,7 @@ const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Prom
if (!isSettingsRuntimeContextCurrent(context)) return null;
const settings = sanitizeWebSettings(result.settings);
_settingsCache = { value: settings, at: Date.now(), context };
+ if (settings) rememberServerSettings(settings);
return settings;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return null;
@@ -1935,7 +528,10 @@ const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Prom
if (!isSettingsRuntimeContextCurrent(context)) return null;
try {
- const response = await runtimeFetch('/api/config/settings', {
+ // The surface kind travels as a query parameter, not a header: a header
+ // would turn the request into a CORS preflight, which older instances
+ // (and the packaged desktop's cross-origin shell) refuse.
+ const response = await runtimeFetch(settingsEndpointForSurface(), {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -1947,6 +543,7 @@ const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Prom
if (!isSettingsRuntimeContextCurrent(context)) return null;
const settings = sanitizeWebSettings(data);
_settingsCache = { value: settings, at: Date.now(), context };
+ if (settings) rememberServerSettings(settings);
return settings;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return null;
@@ -1963,9 +560,11 @@ const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Prom
return inflight.promise;
};
-/** Invalidate cached settings (call after a successful PUT) */
+/** Forget everything cached about the server document: the GET cache and the
+ * last-known per-key values used to drop redundant writes. */
export const invalidateSettingsCache = (): void => {
_settingsCache = null;
+ _serverKnownSettings = {};
};
export const syncDesktopSettings = async (options?: { bootstrap?: boolean; adoptTheme?: boolean }): Promise => {
@@ -2028,78 +627,26 @@ export const syncDesktopSettings = async (options?: { bootstrap?: boolean; adopt
let settings = overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation));
await waitForHydration();
if (!isSettingsRuntimeContextCurrent(context)) return;
- settings = overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation));
- const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
- || settings.draftStartersScheduleTaskAdded !== true;
- // `autoSaveEnabled` is new to the settings backend. Until the server has a
- // value, materialize would invent the client default (true) and overwrite a
- // deliberate legacy "off" preference migrated from
- // `openchamber:files:auto-save-enabled`. Prefer the hydrated store value and
- // seed the backend once so later omitted→default authority is correct.
- const shouldSeedAutoSaveEnabled = typeof settings.autoSaveEnabled !== 'boolean';
- const shouldSeedSidebarProjectDisplayMode = settings.sidebarProjectDisplayMode === undefined;
- const shouldSeedSidebarSessionGroupingMode = settings.sidebarSessionGroupingMode === undefined;
- const shouldSeedSidebarProjectSortOrder = settings.sidebarProjectSortOrder === undefined;
- const shouldSeedSidebarShowRecentSection = settings.sidebarShowRecentSection === undefined;
- const authoritativeSettings = materializeAuthoritativeUiSettings(settings);
+ settings = withoutStaleDeviceFields(
+ overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation)),
+ context.runtimeKey,
+ );
+ // Keys the server omits are "unset", not "reset": this window keeps
+ // whatever it already holds for them and nothing is written back. A
+ // bootstrap therefore never seeds the server from local state — a write
+ // only ever carries a change a person made in this window.
try {
persistToLocalStorage(settings);
} catch (error) {
console.warn('persistToLocalStorage failed:', error);
}
- if (shouldSeedAutoSaveEnabled) {
- authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
- }
- const sessionDisplayState = useSessionDisplayStore.getState();
- if (shouldSeedSidebarProjectDisplayMode) {
- authoritativeSettings.sidebarProjectDisplayMode = sessionDisplayState.projectDisplayMode;
- }
- if (shouldSeedSidebarSessionGroupingMode) {
- authoritativeSettings.sidebarSessionGroupingMode = sessionDisplayState.sessionGroupingMode;
- }
- if (shouldSeedSidebarProjectSortOrder) {
- authoritativeSettings.sidebarProjectSortOrder = sessionDisplayState.projectSortOrder;
- }
- if (shouldSeedSidebarShowRecentSection) {
- authoritativeSettings.sidebarShowRecentSection = sessionDisplayState.showRecentSection;
- }
- if (settings.draftStarters === undefined) {
- useUIStore.setState({ globalDraftStarters: null });
- }
try {
- applyDesktopUiPreferences(authoritativeSettings);
+ applyServerSettings(settings);
} catch (error) {
console.warn('applyDesktopUiPreferences failed:', error);
}
- const migrationPatch: Partial = {};
- if (shouldPersistCraftGoalMigration) {
- if (authoritativeSettings.draftStarters) {
- migrationPatch.draftStarters = authoritativeSettings.draftStarters;
- }
- migrationPatch.draftStartersCraftGoalAdded = true;
- migrationPatch.draftStartersScheduleTaskAdded = true;
- }
- if (shouldSeedAutoSaveEnabled) {
- migrationPatch.autoSaveEnabled = authoritativeSettings.autoSaveEnabled;
- }
- if (shouldSeedSidebarProjectDisplayMode) {
- migrationPatch.sidebarProjectDisplayMode = authoritativeSettings.sidebarProjectDisplayMode;
- }
- if (shouldSeedSidebarSessionGroupingMode) {
- migrationPatch.sidebarSessionGroupingMode = authoritativeSettings.sidebarSessionGroupingMode;
- }
- if (shouldSeedSidebarProjectSortOrder) {
- migrationPatch.sidebarProjectSortOrder = authoritativeSettings.sidebarProjectSortOrder;
- }
- if (shouldSeedSidebarShowRecentSection) {
- migrationPatch.sidebarShowRecentSection = authoritativeSettings.sidebarShowRecentSection;
- }
- if (Object.keys(migrationPatch).length > 0) {
- await updateDesktopSettings(migrationPatch);
- if (!isSettingsRuntimeContextCurrent(context)) return;
- }
- dispatchSettingsSynced(authoritativeSettings, bootstrap, adoptTheme);
+ dispatchSettingsSynced(settings, bootstrap, adoptTheme);
};
try {
@@ -2118,6 +665,7 @@ export const syncDesktopSettings = async (options?: { bootstrap?: boolean; adopt
// `keepalive` is set only on the lifecycle-suspend path, where the document may
// be torn down mid-request; the ordinary debounced write uses a plain fetch.
async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean } = {}): Promise {
+ let ok = false;
const changes = _pendingSettingsChanges;
const context = _pendingSettingsContext;
const revision = _pendingSettingsRevision;
@@ -2130,23 +678,33 @@ async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean
try {
if (!changes || !context || Object.keys(changes).length === 0 || !isSettingsRuntimeContextCurrent(context)) {
// Nothing will be written — clear any pending "Saving…" indicator.
+ ok = true;
dispatchSettingsSaveState('saved');
return;
}
const operation = _settingsMutationTracker.begin(revision);
+ // Assume the merge lands so a same-value write arriving mid-flight is not
+ // sent twice; a failed request forgets these keys so a retry goes through.
+ rememberServerSettings(changes);
+ const forgetSentSettings = () => forgetServerSettings(settingsKeysOf(changes));
try {
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
- const updated = await runtimeSettings.save(changes);
+ // The runtime API hands back whatever the bridge or server returned;
+ // it is parsed here like any other boundary payload.
+ const updated = sanitizeWebSettings(await runtimeSettings.save(changes));
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
+ rememberServerSettings(updated);
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
- applyDesktopUiPreferences(reconciled);
+ applyServerSettings(reconciled);
dispatchSettingsSynced(reconciled, false);
_settingsCache = null;
}
+ if (!updated) forgetSentSettings();
+ ok = Boolean(updated);
dispatchSettingsSaveState(updated ? 'saved' : 'error');
return;
} catch (error) {
@@ -2157,7 +715,7 @@ async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean
if (!isSettingsRuntimeContextCurrent(context)) return;
try {
- const response = await runtimeFetch('/api/config/settings', {
+ const response = await runtimeFetch(settingsEndpointForSurface(), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -2170,6 +728,7 @@ async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean
if (!isSettingsRuntimeContextCurrent(context)) return;
if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
+ forgetSentSettings();
dispatchSettingsSaveState('error');
return;
}
@@ -2177,18 +736,22 @@ async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean
const updated = sanitizeWebSettings(await response.json().catch(() => null));
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
+ rememberServerSettings(updated);
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
- applyDesktopUiPreferences(reconciled);
+ applyServerSettings(reconciled);
dispatchSettingsSynced(reconciled, false);
+ ok = true;
dispatchSettingsSaveState('saved');
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
} else {
+ forgetSentSettings();
dispatchSettingsSaveState('error');
}
} catch (error) {
if (isSettingsRuntimeContextCurrent(context)) {
console.warn('Failed to update shared settings via API:', error);
+ forgetSentSettings();
dispatchSettingsSaveState('error');
}
}
@@ -2196,13 +759,26 @@ async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean
_settingsMutationTracker.finish(operation);
}
} finally {
- waiters.forEach((resolve) => resolve());
+ waiters.forEach((resolve) => resolve({ ok }));
}
}
-export const updateDesktopSettings = async (changes: Partial): Promise => {
+/**
+ * Load the shared settings document for the current runtime (cached briefly
+ * during startup bursts). Pages that need a field the stores do not carry read
+ * it from here instead of fetching the endpoint themselves. `null` is a load
+ * failure, never an empty document.
+ */
+export const loadDesktopSettings = (): Promise => fetchWebSettings();
+
+/**
+ * Queue a change a person made in this window for the debounced write. Keys
+ * whose value the server already holds are dropped; computed server flags are
+ * never sent. Resolves once the write (or the decision not to write) settled.
+ */
+export const updateDesktopSettings = async (changes: Partial): Promise => {
if (typeof window === 'undefined') {
- return;
+ return { ok: false };
}
ensureSettingsRuntimeLifecycle();
const context = captureSettingsRuntimeContext();
@@ -2212,15 +788,36 @@ export const updateDesktopSettings = async (changes: Partial):
void _flushSettingsUpdate();
}
- _pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
+ // Merge first, then drop keys that now equal the server: a toggle back to
+ // the server's value inside the debounce window cancels the pending write
+ // for that key instead of leaving the earlier value queued.
+ const writable: Partial = {};
+ for (const key of settingsKeysOf(changes)) {
+ if (isWritableSettingsKey(key)) Object.assign(writable, { [key]: changes[key] });
+ }
+ const pending = withoutRedundantSettings({ ...(_pendingSettingsChanges ?? {}), ...writable });
+ if (Object.keys(pending).length === 0) {
+ _pendingSettingsChanges = null;
+ _pendingSettingsContext = null;
+ if (_settingsFlushTimer) {
+ clearTimeout(_settingsFlushTimer);
+ _settingsFlushTimer = null;
+ }
+ const waiters = _settingsFlushWaiters;
+ _settingsFlushWaiters = [];
+ waiters.forEach((resolve) => resolve({ ok: true }));
+ dispatchSettingsSaveState('saved');
+ return { ok: true };
+ }
+ _pendingSettingsChanges = pending;
_pendingSettingsContext = context;
- _pendingSettingsRevision = _settingsMutationTracker.record(changes);
+ _pendingSettingsRevision = _settingsMutationTracker.record(withoutRedundantSettings(writable));
dispatchSettingsSaveState('saving');
if (_settingsFlushTimer) {
clearTimeout(_settingsFlushTimer);
}
- const flushed = new Promise((resolve) => {
+ const flushed = new Promise((resolve) => {
_settingsFlushWaiters.push(resolve);
});
_settingsFlushTimer = setTimeout(() => void _flushSettingsUpdate(), SETTINGS_DEBOUNCE_MS);
diff --git a/packages/ui/src/lib/projectContextApi.ts b/packages/ui/src/lib/projectContextApi.ts
index a1f991f9..0d8c3b12 100644
--- a/packages/ui/src/lib/projectContextApi.ts
+++ b/packages/ui/src/lib/projectContextApi.ts
@@ -26,6 +26,8 @@ export interface ProjectPlanLink {
title: string;
createdAt: number;
pinned: boolean;
+ /** The user's own plan, or one from the team's shared plans folder. */
+ source?: 'shared' | 'personal';
}
export type ProjectNoteSource = 'manual' | 'selection' | 'agent';
@@ -45,6 +47,8 @@ interface ProjectContextData {
notes: ProjectNote[];
todos: ProjectTodoItem[];
plans: ProjectPlanLink[];
+ /** Absolute path of the team's shared plans folder when the project has one. */
+ sharedPlansDir: string | null;
}
interface ProjectPlanContent extends ProjectPlanLink {
@@ -136,6 +140,7 @@ const parseContext = (payload: unknown): ProjectContextData => {
notes: Array.isArray(record.notes) ? record.notes : [],
todos: Array.isArray(record.todos) ? record.todos : [],
plans: Array.isArray(record.plans) ? record.plans : [],
+ sharedPlansDir: typeof record.sharedPlansDir === 'string' ? record.sharedPlansDir : null,
};
};
@@ -348,3 +353,33 @@ export const deleteProjectPlan = async (
}
return parseContext(await response.json());
};
+
+/**
+ * Move a plan between the user's folder and the team's shared plans folder.
+ * The plan gets a new id on the other side; resolves `null` when it is gone.
+ * Sharing needs a shared plans folder set for the project (Project settings).
+ */
+const movePlan = async (
+ project: ProjectRef,
+ planId: string,
+ direction: 'share' | 'unshare',
+): Promise<{ plan: ProjectPlanLink; context: ProjectContextData } | null> => {
+ const response = await runtimeFetch(
+ `${basePath(requireProjectId(project))}/plans/${encodeURIComponent(planId)}/${direction}`,
+ { method: 'POST' },
+ );
+ if (response.status === 404) {
+ return null;
+ }
+ if (!response.ok) {
+ throw new Error(await readErrorMessage(response, direction === 'share' ? 'Failed to share plan' : 'Failed to make plan personal'));
+ }
+ const payload = await response.json() as { plan?: ProjectPlanLink; context?: unknown };
+ if (!payload?.plan) {
+ throw new Error('Malformed plan move response');
+ }
+ return { plan: payload.plan, context: parseContext(payload.context) };
+};
+
+export const shareProjectPlan = (project: ProjectRef, planId: string) => movePlan(project, planId, 'share');
+export const unshareProjectPlan = (project: ProjectRef, planId: string) => movePlan(project, planId, 'unshare');
diff --git a/packages/ui/src/lib/responseStyle.ts b/packages/ui/src/lib/responseStyle.ts
index 1aae6b31b..6ce58cd2 100644
--- a/packages/ui/src/lib/responseStyle.ts
+++ b/packages/ui/src/lib/responseStyle.ts
@@ -1,4 +1,4 @@
-import { runtimeFetch } from './runtime-fetch';
+import { loadDesktopSettings } from './persistence';
export const RESPONSE_STYLE_PRESETS = ['concise', 'detailed', 'mentor', 'pushback', 'noFiller', 'matchEnergy', 'warmPeer'] as const;
export type ResponseStylePreset = typeof RESPONSE_STYLE_PRESETS[number];
@@ -45,16 +45,7 @@ const buildResponseStyleInstruction = ({
};
export const fetchResponseStyleInstruction = async (): Promise => {
- const response = await runtimeFetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
- if (!response.ok) return null;
- const settings = await response.json().catch(() => null) as {
- responseStyleEnabled?: unknown;
- responseStylePreset?: unknown;
- responseStyleCustomInstructions?: unknown;
- } | null;
+ const settings = await loadDesktopSettings();
if (!settings) return null;
return buildResponseStyleInstruction({
enabled: settings.responseStyleEnabled === true,
diff --git a/packages/ui/src/lib/settings/DOCUMENTATION.md b/packages/ui/src/lib/settings/DOCUMENTATION.md
new file mode 100644
index 00000000..8b9f3515
--- /dev/null
+++ b/packages/ui/src/lib/settings/DOCUMENTATION.md
@@ -0,0 +1,33 @@
+# Settings
+
+## Purpose
+
+`packages/ui/src/lib/settings` owns what an OpenChamber setting *is*: its key, its scope, how a value is parsed at the boundary, and where the UI keeps its live copy. The storage and sync mechanics (debounced writes, mirrors, bootstrap adoption) live in `lib/persistence.ts` and consume this module; the Settings pages consume the stores.
+
+## Modules
+
+- `registry.ts` — the settings registry. One `SETTINGS_REGISTRY` table plus two key lists (`LOCAL_DEVICE_KEYS`, `DESKTOP_SHELL_KEYS`) and the derived helpers other modules use: `DesktopSettings` (the document type), `parseSettingsDocument`, `applySettingsToStores`, `AUTO_SAVE_KEYS` / `readAutoSaveSnapshot`, `MIRRORED_KEYS`, `buildSettingsRegistrySnapshot`.
+- `parsers.ts` — value-level boundary parsers (zod schemas wrapped as `SettingsParser`). `undefined` means "reject", never "default".
+- `registry-snapshot.ts` — renders the plain-JSON snapshot for the two consumers that cannot import the UI's TypeScript: the OpenChamber server (`packages/web/server/lib/opencode/settings-registry.json`) and the VS Code extension host (`packages/vscode/src/settings-registry.json`). Regenerate with `bun run settings-registry:generate`; `registry.test.ts` fails when a checked-in copy is stale.
+- `metadata.ts`, `search.ts` — Settings page metadata and the search index (unchanged by the registry; see `.agents/skills/settings-ui-patterns`).
+
+## Invariants
+
+- **A key that is not in the registry does not persist.** `parseSettingsDocument` drops unknown keys on the way in; `updateDesktopSettings` sends only registry keys that are not `computed`; the server and the VS Code bridge drop anything the snapshot does not list.
+- **Every key has exactly one scope.** `instance` (a fact about the machine the server runs on, never synced), `profile` (the person's preference, shared by every client of the instance), `device` (state of this install/surface). `LOCAL_DEVICE_KEYS` are device fields that only ever lived in `useUIStore`'s persisted slice; `DESKTOP_SHELL_KEYS` are instance facts the Electron main process writes straight into `settings.json` and no client reads.
+- **Per-surface profile fields** (`perSurface: true`) are a fixed, owner-decided set: the theme ids and mode, the chat-layout switches that depend on screen size, and the typography sizes. A change made on one surface kind is stored for that kind only: every settings request carries the client's kind as the `surface` query parameter, never a header, so the request needs no CORS preflight from the cross-origin desktop and phone shells and works against older instances (`surface.ts`: `vscode`, `desktop`, `mobile` for the phone app and the hosted mobile shell alike, else `web`), the store writes the value under `fields[key].surfaces[]` and leaves the base untouched, and a read resolves that kind's value first, the base value otherwise, or nothing (the client keeps what it holds). Writes without a surface (migrations, the one-time seed) set the base. The Settings UI is unchanged; the difference is only where the value lands.
+- **Missing is not default.** `applySettingsToStores` writes only the fields the snapshot carries; an omitted field leaves the store as it is. Defaults live in the stores' initial state, not in the registry.
+- **Writes carry intent.** Fields with `ui.autoSave` are watched by `lib/appearanceAutoSave.ts`; changes made while `isApplyingServerSettings()` is true (a sync copying server values in) are a new baseline, not a write. The six model-preference fields are watched by `lib/modelPrefsAutoSave.ts` with its own debounce and are therefore `autoSave: false` here.
+- **Sibling-dependent applies are explicit.** A `ui.write` receives the parsed snapshot as `SettingsSiblingView`, which names the only siblings a write may consult (`draftStarters*Added`, `workStatusHiddenSectionsExplicit`). Extend the view when a new field needs one.
+- **Device fields never cross the wire.** `updateDesktopSettings` drops `device` keys before the debounce, the server and the VS Code bridge drop them again, and the mirror never held them. Their home is the local store (`useUIStore` persisted slice, `mobileKeyboardMode` browser storage, `desktopSplashColors` in the desktop shell's own store via the window-theme IPC). A server document that still carries device keys from before the split is applied exactly once per runtime as a seed (`openchamber.deviceSeeded.v1:` in browser storage) and ignored afterwards.
+- **Two files on the instance.** `settings.json` keeps instance facts and legacy keys; `preferences.json` beside it holds every `profile` key as `{ value, updatedAt }` (`version: 1`). The server (`packages/web/server/lib/opencode/settings-files.js`) and the VS Code bridge (`packages/vscode/src/settings-files.ts`) seed `preferences.json` once from an existing `settings.json`, keep a copy of the profile's base values in `settings.json` on every write (a build from before the split reads only that file, so a rollback keeps the user's preferences; current builds ignore the copy because `preferences.json` wins), and never touch a `preferences.json` they cannot parse; clients see one merged document and never address the files. Server modules that read a profile key off the disk (small model, session goal/assist, walkthrough) use `readMergedSettingsSync`.
+- **Markers, not code, carry the special cases.** `adopt: 'bootstrap-only'` (workspace pointers), `derived` (computed by the writer from other fields), `secret` (accepted on write, never returned), `computed` (server-emitted, never persisted), `surfaces` (which surface kinds have the field).
+
+## Adding a setting
+
+1. Add one entry to `SETTINGS_REGISTRY` with `scope`, a parser from `parsers.ts`, and a `ui` binding when a store holds the live value. Use an existing setter so its side effects run.
+2. Run `bun run settings-registry:generate` and commit both JSON snapshots.
+3. If the server must validate the value beyond the registry gate, add its branch to `sanitizeSettingsUpdate` in `packages/web/server/lib/opencode/settings-helpers.js`; the drift test in `settings-helpers.test.js` needs a valid sample value for the new key.
+4. Add the Settings control and search entry per `.agents/skills/settings-ui-patterns`.
+
+`DesktopSettings`, `SettingsPayload`, the client sanitizer, the mirror, the apply step and the auto-save all follow from step 1; there is no second list to update.
diff --git a/packages/ui/src/lib/settings/parsers.ts b/packages/ui/src/lib/settings/parsers.ts
new file mode 100644
index 00000000..ddb11992
--- /dev/null
+++ b/packages/ui/src/lib/settings/parsers.ts
@@ -0,0 +1,417 @@
+/**
+ * Boundary parsers for settings values. Every value that arrives from the
+ * server, the VS Code bridge, or browser storage passes through one of these
+ * before it is trusted; `undefined` means "reject", never "default".
+ *
+ * These are the value-level rules the registry (`./registry.ts`) attaches to
+ * each key. They are zod schemas wrapped into one function shape so the
+ * registry can hold hand-written and schema-derived parsers alike, and so the
+ * registry can be evaluated for its shape (the generated JSON snapshot)
+ * without a browser.
+ */
+import { z, type ZodType } from 'zod';
+
+import type { ProjectEntry } from '@/lib/api/types';
+import { createProjectIdFromPath } from '@/lib/projectId';
+
+/**
+ * `raw` is the whole untrusted document, for the few legacy keys whose value
+ * is derived from a sibling (`queueModeEnabled` → `followUpBehavior`).
+ */
+export type SettingsParser = (value: unknown, raw: SettingsRawDocument) => T | undefined;
+
+/** The untrusted document as received; only ever read through a parser. */
+export type SettingsRawDocument = Readonly>;
+
+export type ModelRef = { providerID: string; modelID: string };
+
+export type NotificationTemplates = {
+ completion: { title: string; message: string };
+ error: { title: string; message: string };
+ question: { title: string; message: string };
+ subtask: { title: string; message: string };
+};
+
+export type UsageModelGroups = Record;
+ modelAssignments?: Record;
+ renamedGroups?: Record;
+}>;
+
+export type ManagedRemoteTunnelPreset = { id: string; name: string; hostname: string };
+
+export type SkillCatalogConfig = {
+ id: string;
+ label: string;
+ source: string;
+ subpath?: string;
+ gitIdentityId?: string;
+};
+
+/** Wrap a schema as a parser: success yields the parsed value, failure yields `undefined`. */
+export const fromSchema = (schema: ZodType): SettingsParser => (value) => {
+ const result = schema.safeParse(value);
+ return result.success ? result.data : undefined;
+};
+
+const finiteNumber = z.number().refine(Number.isFinite);
+const trimmed = z.string().transform((value) => value.trim());
+const nonEmptyTrimmed = trimmed.pipe(z.string().min(1));
+const looseObject = z.record(z.string(), z.unknown());
+
+export const parseBoolean = fromSchema(z.boolean());
+
+/** A non-empty string, kept verbatim. */
+export const parseNonEmptyString = fromSchema(z.string().min(1));
+
+/** Any string, trimmed; empty stays empty (some keys use '' as "unset"). */
+export const parseTrimmedString = fromSchema(trimmed);
+
+/** A trimmed string that is only accepted when something is left after trimming. */
+export const parseNonEmptyTrimmedString = fromSchema(nonEmptyTrimmed);
+
+/** Free text with an upper bound, kept verbatim (whitespace is content here). */
+export const parseTextUpTo = (maxLength: number): SettingsParser => fromSchema(z.string().max(maxLength));
+
+export const parseTrimmedStringUpTo = (maxLength: number): SettingsParser => fromSchema(
+ trimmed.transform((value) => value.slice(0, maxLength)),
+);
+
+export const parseOneOf = (options: T): SettingsParser => fromSchema(
+ trimmed.pipe(z.enum(options)),
+);
+
+export const parseFiniteNumber = fromSchema(finiteNumber);
+
+export const parseIntegerInRange = (min: number, max: number): SettingsParser => fromSchema(
+ finiteNumber.transform((value) => Math.max(min, Math.min(max, Math.round(value)))),
+);
+
+export const parseIntegerAtLeast = (min: number): SettingsParser => fromSchema(
+ finiteNumber.transform((value) => Math.max(min, Math.round(value))),
+);
+
+export const parsePositiveInteger = fromSchema(finiteNumber.positive().transform(Math.floor));
+
+/** `null` clears the value; a finite number keeps it. */
+export const parseNullableFiniteNumber = fromSchema(z.union([z.null(), finiteNumber]));
+
+/** `null` clears the value; a non-empty trimmed string keeps it; '' becomes null. */
+export const parseNullableTrimmedPath = fromSchema(
+ z.union([z.null(), trimmed.transform((value) => (value.length > 0 ? value : null))]),
+);
+
+export const parseNullableTrimmedString = fromSchema(z.union([z.null(), trimmed]));
+
+const stringEntries = z.array(z.unknown()).transform((entries) => entries.filter((entry) => z.string().min(1).safeParse(entry).success));
+
+/** Distinct non-empty strings, order preserved. */
+export const parseStringSet = fromSchema(stringEntries.transform((entries) => Array.from(new Set(entries.map(String)))));
+
+/** Non-empty strings, duplicates kept (order is the user's). */
+export const parseStringList = fromSchema(stringEntries.transform((entries) => entries.map(String)));
+
+const stringListRecord = looseObject.transform((record) => {
+ const result: Record = {};
+ for (const [key, entries] of Object.entries(record)) {
+ const parsed = z.array(z.unknown()).safeParse(entries);
+ if (parsed.success) {
+ result[key] = parsed.data.filter((entry) => z.string().safeParse(entry).success).map(String);
+ }
+ }
+ return result;
+});
+
+export const parseStringRecordOfStringLists = fromSchema(
+ stringListRecord.pipe(z.record(z.string(), z.array(z.string())).refine((record) => Object.keys(record).length > 0)),
+);
+
+const modelRefSchema = z.object({
+ providerID: nonEmptyTrimmed,
+ modelID: nonEmptyTrimmed,
+});
+
+export const parseModelRefs = (limit: number): SettingsParser => fromSchema(
+ z.array(z.unknown()).transform((entries) => {
+ const result: ModelRef[] = [];
+ const seen = new Set();
+ for (const entry of entries) {
+ const parsed = modelRefSchema.safeParse(entry);
+ if (!parsed.success) continue;
+ const key = `${parsed.data.providerID}/${parsed.data.modelID}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ result.push(parsed.data);
+ if (result.length >= limit) break;
+ }
+ return result;
+ }),
+);
+
+export const parseRecentEfforts = fromSchema(
+ looseObject.transform((record) => {
+ const result: Record = {};
+ for (const [key, variants] of Object.entries(record)) {
+ if (!key) continue;
+ const parsed = stringEntries.safeParse(variants);
+ if (!parsed.success) continue;
+ const unique = Array.from(new Set(parsed.data.map(String)));
+ if (unique.length > 0) result[key] = unique.slice(0, 5);
+ }
+ return result;
+ }).refine((record) => Object.keys(record).length > 0),
+);
+
+export const parseShortcutOverrides = fromSchema(
+ looseObject.transform((record) => {
+ const result: Record = {};
+ for (const [key, combo] of Object.entries(record)) {
+ const normalizedKey = key.trim();
+ const normalizedCombo = nonEmptyTrimmed.safeParse(combo);
+ if (!normalizedKey || !normalizedCombo.success) continue;
+ result[normalizedKey] = normalizedCombo.data;
+ }
+ return result;
+ }),
+);
+
+const DEFAULT_NOTIFICATION_TEMPLATES: NotificationTemplates = {
+ completion: { title: 'Task Complete', message: 'Your task has finished.' },
+ error: { title: 'Error Occurred', message: 'An error occurred while processing your task.' },
+ question: { title: 'Input Needed', message: 'Please provide input to continue.' },
+ subtask: { title: 'Subtask Complete', message: 'A subtask has finished.' },
+};
+
+const notificationTemplateSchema = z.object({
+ title: z.string().catch(''),
+ message: z.string().catch(''),
+});
+
+export const parseNotificationTemplates = fromSchema(
+ looseObject.transform((record) => {
+ const read = (key: keyof NotificationTemplates) => {
+ const parsed = notificationTemplateSchema.safeParse(record[key]);
+ return parsed.success ? parsed.data : undefined;
+ };
+ const completion = read('completion');
+ const error = read('error');
+ const question = read('question');
+ const subtask = read('subtask');
+ if (!completion && !error && !question && !subtask) return undefined;
+ return {
+ completion: completion ?? DEFAULT_NOTIFICATION_TEMPLATES.completion,
+ error: error ?? DEFAULT_NOTIFICATION_TEMPLATES.error,
+ question: question ?? DEFAULT_NOTIFICATION_TEMPLATES.question,
+ subtask: subtask ?? DEFAULT_NOTIFICATION_TEMPLATES.subtask,
+ };
+ }).pipe(z.custom((value) => value !== undefined)),
+);
+
+const stringMap = looseObject.transform((record) => Object.fromEntries(
+ Object.entries(record).flatMap(([key, value]) => {
+ const parsed = z.string().safeParse(value);
+ return parsed.success ? [[key, parsed.data] as const] : [];
+ }),
+));
+
+const customGroupSchema = z.object({
+ id: z.unknown().transform((value) => String(value ?? '')),
+ label: z.unknown().transform((value) => String(value ?? '')),
+ models: z.array(z.unknown()).transform((models) => models.filter((model) => z.string().safeParse(model).success).map(String)).catch([]),
+ order: z.number().catch(0),
+});
+
+export const parseUsageModelGroups = fromSchema(
+ looseObject.transform((record) => {
+ const result: UsageModelGroups = {};
+ for (const [providerId, config] of Object.entries(record)) {
+ const parsedConfig = looseObject.safeParse(config);
+ if (!parsedConfig.success) continue;
+ const providerConfig: UsageModelGroups[string] = {};
+ const customGroups = z.array(z.unknown()).safeParse(parsedConfig.data.customGroups);
+ if (customGroups.success) {
+ providerConfig.customGroups = customGroups.data.flatMap((group) => {
+ const parsed = customGroupSchema.safeParse(group);
+ return parsed.success ? [parsed.data] : [];
+ });
+ }
+ const modelAssignments = stringMap.safeParse(parsedConfig.data.modelAssignments);
+ if (modelAssignments.success) providerConfig.modelAssignments = modelAssignments.data;
+ const renamedGroups = stringMap.safeParse(parsedConfig.data.renamedGroups);
+ if (renamedGroups.success) providerConfig.renamedGroups = renamedGroups.data;
+ if (Object.keys(providerConfig).length > 0) result[providerId] = providerConfig;
+ }
+ return result;
+ }).refine((record) => Object.keys(record).length > 0),
+);
+
+const managedRemoteTunnelPresetSchema = z.object({
+ id: nonEmptyTrimmed,
+ name: nonEmptyTrimmed,
+ hostname: nonEmptyTrimmed.transform((value) => value.toLowerCase()),
+});
+
+export const parseManagedRemoteTunnelPresets = fromSchema(
+ z.array(z.unknown()).transform((entries) => {
+ const result: ManagedRemoteTunnelPreset[] = [];
+ const seenIds = new Set();
+ const seenHostnames = new Set();
+ for (const entry of entries) {
+ const parsed = managedRemoteTunnelPresetSchema.safeParse(entry);
+ if (!parsed.success) continue;
+ if (seenIds.has(parsed.data.id) || seenHostnames.has(parsed.data.hostname)) continue;
+ seenIds.add(parsed.data.id);
+ seenHostnames.add(parsed.data.hostname);
+ result.push(parsed.data);
+ }
+ return result;
+ }),
+);
+
+export const parseManagedRemoteTunnelPresetTokens = fromSchema(
+ looseObject.transform((record) => {
+ const result: Record = {};
+ for (const [key, token] of Object.entries(record)) {
+ const id = key.trim();
+ const parsedToken = nonEmptyTrimmed.safeParse(token);
+ if (!id || !parsedToken.success) continue;
+ result[id] = parsedToken.data;
+ }
+ return result;
+ }).refine((record) => Object.keys(record).length > 0),
+);
+
+const skillCatalogSchema = z.object({
+ id: nonEmptyTrimmed,
+ label: nonEmptyTrimmed,
+ source: nonEmptyTrimmed,
+ subpath: trimmed.optional().catch(undefined),
+ gitIdentityId: trimmed.optional().catch(undefined),
+});
+
+export const parseSkillCatalogs = fromSchema(
+ z.array(z.unknown()).transform((entries) => {
+ const result: SkillCatalogConfig[] = [];
+ const seen = new Set();
+ for (const entry of entries) {
+ const parsed = skillCatalogSchema.safeParse(entry);
+ if (!parsed.success || seen.has(parsed.data.id)) continue;
+ seen.add(parsed.data.id);
+ const catalog: SkillCatalogConfig = { id: parsed.data.id, label: parsed.data.label, source: parsed.data.source };
+ if (parsed.data.subpath) catalog.subpath = parsed.data.subpath;
+ if (parsed.data.gitIdentityId) catalog.gitIdentityId = parsed.data.gitIdentityId;
+ result.push(catalog);
+ }
+ return result;
+ }),
+);
+
+const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
+
+const nonNegativeFinite = finiteNumber.nonnegative();
+
+const projectEntrySchema = z.object({
+ path: nonEmptyTrimmed,
+ label: nonEmptyTrimmed.optional().catch(undefined),
+ icon: nonEmptyTrimmed.optional().catch(undefined),
+ iconImage: z.union([
+ z.null(),
+ z.object({
+ mime: nonEmptyTrimmed,
+ updatedAt: nonNegativeFinite.transform(Math.round).pipe(z.number().positive()),
+ source: z.enum(['custom', 'auto']),
+ }),
+ ]).optional().catch(undefined),
+ color: nonEmptyTrimmed.optional().catch(undefined),
+ iconBackground: z.union([
+ z.null(),
+ trimmed.pipe(z.string().regex(HEX_COLOR_PATTERN)).transform((value) => value.toLowerCase()),
+ ]).optional().catch(undefined),
+ addedAt: nonNegativeFinite.optional().catch(undefined),
+ lastOpenedAt: nonNegativeFinite.optional().catch(undefined),
+ sidebarCollapsed: z.boolean().optional().catch(undefined),
+});
+
+export const parseProjects = fromSchema(
+ z.array(z.unknown()).transform((entries) => {
+ const result: ProjectEntry[] = [];
+ const seenIds = new Set