Merge pull request #1352 from Runfusion/gsxdsm/localization

feat: Add UI localization (i18n) across dashboard and terminal UI
This commit is contained in:
gsxdsm
2026-06-03 21:50:43 -07:00
committed by GitHub
276 changed files with 56676 additions and 7632 deletions

View File

@@ -0,0 +1,9 @@
---
"@runfusion/fusion": minor
---
Add a localization (i18n) foundation across the UI. Introduces react-i18next-backed translation for both the dashboard and the terminal UI, with English as the source language and Simplified Chinese, Traditional Chinese, French, and Spanish as target locales.
- New `@fusion/i18n` package holding the authored catalogs and shared i18next configuration (namespace split, script-aware zh-CN/zh-TW fallback, plural setup).
- A `language` preference (`fusion settings`) and a Settings language switcher; the CLI resolves locale from `--lang`, settings, then environment.
- An `i18next-cli` workflow (`extract`/`sync`/`types`/`status`/`lint`) so adding a future language is a translate-only, near-zero-code operation.

4
.gitignore vendored
View File

@@ -11,6 +11,10 @@ dist/
dist-electron/
*.tsbuildinfo
# Generated i18n catalogs synced into the dashboard tree from @fusion/i18n
# (authored source-of-truth lives in packages/i18n/locales/)
packages/dashboard/app/locales/
# Desktop packaging artifacts
*.dmg
*.dmg.blockmap

View File

@@ -174,7 +174,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
- `./docs/soft-delete-verification-matrix.md` — mandatory soft-delete verification matrix.
- `./docs/cli-reference.md` — CLI and terminal UI reference.
- `./docs/contributing.md` — contributing conventions and release-adjacent context.
- `./docs/solutions/` — documented solutions to past problems (bugs, patterns, conventions), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in documented areas.
- `./docs/solutions/` — documented solutions to past problems (bugs, architecture patterns, best practices, conventions), organized by category with YAML frontmatter (`category`, `module`, `tags`, `problem_type`, `applies_when`). Relevant when implementing or debugging in documented areas.
- `./CONCEPTS.md` — shared domain vocabulary (entities, named processes, status concepts). Relevant when orienting to the codebase or discussing domain concepts.
### Lazy-Loaded Heavy Views

View File

@@ -2,6 +2,20 @@
Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all.
## Settings & Localization
### Surface
One of Fusion's user-facing frontends — the browser dashboard and the terminal TUI. Surfaces have independent runtimes and rendering stacks but are expected to share user-level state: a setting changed on one surface (theme, language) carries to the other.
### Global Settings
User-level settings persisted server-side that apply across all Surfaces and all projects, as opposed to per-project settings. Values are validated at the write boundary — an invalid value is dropped rather than persisted — so every reader can trust what it loads.
### Three-Tier Setting
The named persistence pattern for a user preference on the dashboard: a device-local cache for instant reads, a write-through to Global Settings so other Surfaces see it, and a hydrate-on-mount from the server when no local value exists. A local or in-flight user choice always wins over server hydration, and changes propagate to other open tabs.
### Supported Locale
A language tag in the closed set Fusion ships translations for. Any external tag (browser, environment, flag) is normalized into this set or rejected — never passed through raw. Chinese tags route by script and region so Traditional-script users are never silently served Simplified, and the two Chinese variants never collapse into a generic base tag.
## Missions
### Relationships

469
README.es.md Normal file
View File

@@ -0,0 +1,469 @@
<div align="center">
<img src="./demo/assets/fusion-logo.png" alt="Fusion" width="120" />
# Fusion
### De idea inicial a código en producción — automáticamente.
**Orquestador de agentes multinodo** — tareas, agentes, misiones, git, archivos y worktrees, con cualquier modelo, local o en la nube.
[**runfusion.ai →**](https://runfusion.ai) · [Docs](./docs/README.md) · [GitHub](https://github.com/Runfusion/Fusion) · [npm](https://www.npmjs.com/package/@runfusion/fusion) · [Discord](https://discord.gg/ksrfuy7WYR)
[English](./README.md) · [简体中文](./README.zh-CN.md) · [繁體中文](./README.zh-TW.md) · [Français](./README.fr.md) · **Español** · [한국어](./README.ko.md)
*Esta es una traducción automática; el README en inglés es el documento canónico.*
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](./LICENSE)
[![npm](https://img.shields.io/npm/v/@runfusion/fusion.svg?color=58a6ff)](https://www.npmjs.com/package/@runfusion/fusion)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/ksrfuy7WYR)
![Status](https://img.shields.io/badge/status-early%20preview-d29922.svg)
![Shipping](https://img.shields.io/badge/shipping-weekly-bc8cff.svg)
<br />
<img src="./demo/assets/fusion-reel.gif" alt="Demo de Fusion: de idea inicial a código en producción" width="900" />
<br />
<br />
<a href="https://runfusion.ai">
<img src="https://runfusion.ai/fusion-dashboard.png" alt="Panel de Fusion: columnas kanban de Planificación, Pendiente, En progreso, En revisión y Listo con tarjetas de tarea activas" width="900" />
</a>
</div>
---
## Todo tu entorno de desarrollo. En una sola pantalla.
Describe una tarea en lenguaje natural. Un agente de planificación lee tu proyecto, comprende el contexto y escribe un plan completo en `PROMPT.md` — pasos, alcance de archivos, criterios de aceptación. Luego Fusion planifica, revisa, ejecuta y vuelve a revisar, en un worktree de git aislado, con una aprobación humana donde tú la necesites.
Un tablero. Controlado desde cualquier lugar. Laptop, Mac mini, servidor Linux, VM en la nube, teléfono — todo conectado.
> Como Trello, pero tus tareas son especificadas, ejecutadas y entregadas por IA. Construido sobre el gran trabajo de [dustinbyrne/kb](https://github.com/dustinbyrne/kb).
---
## El flujo
```
① Describir ② Planificación ③ El tablero ④ Worktree aislado
───────────── ───────────── ───────────── ─────────────────────
"Agregar modo → El agente escribe → Plan → Revisión → → rama fusion/FN-123
oscuro al PROMPT.md Ejecución → concurrente, cero
panel de (pasos, alcance, Revisión (por conflictos de
configuración" aceptación) paso, hasta archivos
terminar)
```
### Ve cada paso antes del merge
<div align="center">
<img src="https://runfusion.ai/screenshot-task-detail.png" alt="Detalle de tarea en Fusion: pasos del flujo visibles en una tarea en progreso con diffs y cambios de archivos" width="820" />
</div>
Cada tarea muestra su plan, sus revisiones, sus diffs y sus cambios de archivos en tiempo real. Entra a una tarea activa y ajusta la dirección, refina las restricciones, pausa o reescribe el prompt.
---
## Qué lo hace diferente
| | |
|---|---|
| 🧠 **Planificación con IA** | Describe una tarea en lenguaje natural. Los agentes de planificación la convierten en un plan `PROMPT.md` con pasos, alcance de archivos y criterios de aceptación. |
| 🔁 **Puertas de flujo** | Plan → Revisión → Ejecución → Revisión en cada paso. Las puertas previas al merge bloquean código deficiente; las posteriores ejecutan verificaciones informativas. |
| 🌳 **Aislamiento con worktrees** | Cada tarea corre en su propia rama y worktree (`fusion/{task-id}`). Tareas en paralelo. Cero conflictos. Delegación opcional a [worktrunk](https://github.com/max-sixty/worktrunk) mediante [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings) (ver [abstracción WorktreeBackend](./docs/architecture.md#worktreebackend-abstraction)). |
| ⚡ **Merge inteligente** | ¿Pasa todas las puertas? Fusion hace squash-merge y avanza. Habilita aprobación manual en cualquier punto. |
| 🛰️ **Malla multinodo** | Laptop, Mac mini, servidor Linux, VM en la nube, teléfono — todos sincronizados. Escritorio, móvil, web. |
| 🧩 **Cualquier modelo** | Anthropic, OpenAI, Ollama y más. Local y en la nube coexisten. |
| 🏢 **Empresas de agentes** | Importa equipos predefinidos — más de 440 agentes en 16 empresas — y ejecútalos de forma autónoma durante semanas. |
| 📬 **Mensajería entre agentes** | Buzón incorporado entre agentes. Delega, aclara, coordina. |
| 🗨️ **Salas de chat multiagente** | Conversaciones grupales con alcance de proyecto donde varios miembros de la sala pueden responder: los miembros mencionados son respondedores directos, y miembros ambientales adicionales pueden responder hasta un límite. Actualmente **experimental** — habilita `chatRooms` en **Configuración → Funciones experimentales → Salas de chat**. ([Documentación de salas de chat](./docs/dashboard-guide.md#chat-rooms)) |
| 🗺️ **Misiones** | Planificación jerárquica (Misión → Hito → Slice → Característica → Tarea) con piloto automático y contratos de validación. |
| 🔬 **Investigación** | Ejecuciones de investigación delimitadas con búsqueda web, GitHub, documentación local y síntesis con LLM (además de soporte integrado en tiempo de ejecución para WebSearch/WebFetch en flujos de planificación y síntesis cuando está disponible). Convierte los hallazgos en tareas. ([Documentación](./docs/research.md)) |
| 🧪 **Automejora** | Los agentes reflexionan sobre su propio resultado y actualizan sus prompts a medida que aprenden tu base de código. |
| 🔓 **Código abierto. MIT.** | Sin dependencia de proveedor. Ejecútalo en tu propio hardware. Se actualiza semanalmente. |
---
## Cómo funciona
```mermaid
graph TD
H((Tú)) -->|idea inicial| T["Planificación<br/><i>planificación automática</i>"]
T --> TD["Pendiente<br/><i>programado para ejecución</i>"]
TD --> IP["En progreso<br/><i>para cada paso:<br/>planificar, revisar, ejecutar, revisar</i>"]
subgraph IP["En progreso"]
direction TD
NS([Iniciar paso]) --> P[Planificar]
P --> R1{Revisar}
R1 -->|revisar| P
R1 -->|aprobar| E[Ejecutar]
E --> R2{Revisar}
R2 -->|revisar| E
R2 -->|siguiente paso| NS
R2 -->|repensar| P
end
R2 -->|listo| IR["En revisión<br/><i>listo para merge,<br/>o completado automáticamente</i>"]
IR -->|squash merge directo<br/>o PR mergeado| D["Listo"]
style H fill:#161b22,stroke:#8b949e,color:#e6edf3
style T fill:#2d2006,stroke:#d29922,color:#d29922
style TD fill:#0d2044,stroke:#58a6ff,color:#58a6ff
style IP fill:#1a0d2e,stroke:#bc8cff,color:#bc8cff
style P fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style R1 fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style E fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style R2 fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style NS fill:#1a0d2e,stroke:#bc8cff,color:#bc8cff
style IR fill:#0d2d16,stroke:#3fb950,color:#3fb950
style D fill:#1a1a1a,stroke:#8b949e,color:#8b949e
```
Las tareas con dependencias se procesan secuencialmente. Las tareas independientes se ejecutan en paralelo. Opcionalmente puedes requerir aprobación manual antes de que las tareas pasen de Planificación a Pendiente (configuración `requirePlanApproval`).
---
## Multinodo. Un tablero. Todas las plataformas.
<div align="center">
<img src="./demo/assets/fusion-mesh.gif" alt="Malla de Fusion: laptop, Mac mini, servidor Linux, VM en la nube, teléfono — todos sincronizados" width="820" />
<br />
![macOS](https://img.shields.io/badge/macOS-000000?style=for-the-badge&logo=apple&logoColor=white)
![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)
![Web](https://img.shields.io/badge/Web-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-000000?style=for-the-badge&logo=apple&logoColor=white)
![Android](https://img.shields.io/badge/Android-3DDC84?style=for-the-badge&logo=android&logoColor=white)
</div>
Laptop, Mac mini, servidor Linux, VM en la nube, teléfono — cada nodo es un par. El estado de tus tareas, agentes, registros y diffs permanecen sincronizados en toda la malla. El mismo Fusion se distribuye como:
- 🖥️ **Aplicación de escritorio** — Electron para **macOS** (Intel + Apple Silicon), **Windows** 10/11 y **Linux**
- 📱 **Aplicación móvil** — Capacitor para **iOS/iPadOS** y **Android** ([MOBILE.md](./MOBILE.md))
- 🌐 **Panel web** — cualquier navegador moderno, servido desde el daemon `fn dashboard`
- 🔌 **CLI** — binario `fn` + extensión para flujos de trabajo centrados en la terminal
Inicia el daemon en cualquier nodo, conecta tus otros dispositivos y el tablero te sigue a todas partes.
---
## Ejecuta una empresa de agentes
<div align="center">
<img src="./demo/assets/fusion-company-reel.gif" alt="Empresa de agentes Fusion: importa un equipo y ejecútalo de forma autónoma durante semanas" width="820" />
</div>
Importa un equipo. Ejecútalo de forma autónoma durante semanas. **Más de 440 agentes en 16 empresas**, preparados para misiones, buzones y delegación entre agentes.
```bash
npx companies.sh add paperclipai/companies/gstack
```
---
## Compatible con las herramientas que ya usas.
Fusion se integra con las herramientas que ya conoces. **Hermes**, **Paperclip** y **OpenClaw** vienen como plugins de primera clase — enruta cualquier espacio de trabajo al entorno de ejecución que mejor se adapte a la tarea. Y cualquier empresa de agentes de Paperclip se importa con un solo comando.
<div align="center">
<img src="./demo/assets/hermes-logo.svg" alt="Hermes" height="56" />
</div>
### [Hermes](https://hermes-agent.nousresearch.com) <sub>`experimental`</sub>
<sub>Nous Research</sub>
El agente autónomo de código abierto de **Nous Research**. Instala el plugin de Hermes y ejecuta agentes a través de Hermes para trabajos de larga duración con contexto creciente — enruta cualquier espacio de trabajo de Fusion hacia él.
### OpenClaw <sub>`experimental`</sub>
El soporte del entorno de ejecución de OpenClaw está disponible como plugin experimental (`fusion-plugin-openclaw-runtime`) para paridad en el descubrimiento y la configuración del entorno. Configura los agentes con `runtimeConfig.runtimeHint: "openclaw"` después de instalar el plugin.
<br />
<div align="center">
<img src="./demo/assets/paperclip-logo.svg" alt="Paperclip" height="56" />
</div>
### [Paperclip](https://paperclip.ing) <sub>`experimental`</sub>
<sub>paperclip.ing</sub>
El plano de control humano para la mano de obra de IA. Instala el plugin de Paperclip para ejecutar agentes a través de Paperclip dentro de Fusion.
Fusion también soporta nativamente el estándar de empresa de agentes **[`companies.sh`](https://github.com/paperclipai/companies)**: importa un equipo predefinido — **más de 440 agentes en 16 empresas** — y déjalos coordinarse a través del buzón, las misiones y las puertas de flujo de Fusion durante semanas de trabajo autónomo. El mismo formato de empresa, los mismos agentes, las mismas habilidades que Paperclip.
```bash
npx companies.sh add paperclipai/companies/gstack
```
<br />
> **Hermes**, **Paperclip** y **OpenClaw** son plugins de entorno de ejecución **experimentales** — las APIs y los formatos de comunicación pueden cambiar entre versiones menores.
---
## Inicio rápido
**Sin instalación, directo desde npm:**
```bash
npx runfusion.ai
```
Esto lanza el panel. Los subcomandos se pasan directamente: `npx runfusion.ai task create "fix X"`, `npx runfusion.ai --help`, etc. (O de forma explícita: `npx @runfusion/fusion dashboard`.)
**Instalador en una línea** (macOS y Linux — usa Homebrew automáticamente, recurre a npm como alternativa):
```bash
curl -fsSL https://runfusion.ai/install.sh | sh
fusion dashboard
```
**Homebrew** (macOS y Linux):
```bash
brew tap runfusion/fusion
brew install fusion
fusion dashboard # o: fn dashboard
```
O en una sola línea (añade el tap automáticamente): `brew install runfusion/fusion/fusion`.
**npm global**:
```bash
npm install -g @runfusion/fusion
fn dashboard # o: fusion dashboard
```
**Desde un clon** (para desarrollo):
```bash
pnpm dev dashboard
```
Luego haz clic en la URL `Open:` que aparece en la terminal. Incorpora un token de portador
(`http://localhost:4040/?token=fn_...`) que el navegador guarda en
`localStorage` en la primera visita y reutiliza automáticamente. En el lado del
servidor, Fusion ahora persiste el token del panel/daemon en
`~/.fusion/settings.json` en la primera ejecución autenticada y lo reutiliza en
inicios posteriores a menos que lo sobreescribas (`--token`, `FUSION_DASHBOARD_TOKEN`,
`FUSION_DAEMON_TOKEN`) o deshabilites la autenticación con `--no-auth`. Consulta
[Referencia CLI → fn dashboard → Autenticación](./docs/cli-reference.md#fn-dashboard)
para conocer la precedencia completa y las opciones de restablecimiento/revocación.
### Configuración inicial
En el primer lanzamiento, Fusion abre el **asistente de incorporación** con tres pasos guiados:
1. **Configuración de IA** — Usa una lista de proveedores simplificada para el inicio rápido (proveedores recomendados más los ya conectados), luego expande la **Configuración avanzada de proveedores** solo si necesitas proveedores adicionales o detalles de configuración. Solo necesitas un proveedor para comenzar. Las entradas de proveedor obsoletas de Google Gemini CLI / Antigravity están intencionalmente ocultas; las rutas de clave API de Google/Gemini, Google Generative AI, Vertex y Cloud Code permanecen disponibles.
2. **GitHub (Opcional)** — Conecta GitHub para importar issues y gestionar PRs
3. **Primera tarea** — Crea tu primera tarea o impórtala desde GitHub (si no hay ningún proyecto activo, la incorporación primero te pedirá que registres/selecciones un directorio de proyecto)
El asistente se puede **descartar y no bloquea** — haz clic en **Omitir por ahora** para usar el panel de inmediato. Vuélvelo a activar más tarde desde **Configuración → Autenticación → Reabrir guía de incorporación**.
### Móvil
Para el flujo de trabajo con Capacitor + PWA, consulta [MOBILE.md](./MOBILE.md).
---
## Documentación
| Guía | Qué cubre |
|---|---|
| [Primeros pasos](./docs/getting-started.md) | Instalación e incorporación |
| [Guía del panel](./docs/dashboard-guide.md) | Vistas de tablero/lista, terminal, gestor de git |
| [Gestión de tareas](./docs/task-management.md) | Ciclo de vida de la tarea y comandos CLI |
| [Referencia CLI](./docs/cli-reference.md) | Referencia completa de comandos y daemon |
| [Referencia de configuración](./docs/settings-reference.md) | Opciones de configuración |
| [Arquitectura](./docs/architecture.md) | Funcionamiento interno del sistema |
| [Agentes](./docs/agents.md) | Gestión de agentes, creación, latido |
| [Pasos del flujo](./docs/workflow-steps.md) | Puertas de calidad, plantillas, fases |
| [Misiones](./docs/missions.md) | Jerarquía de misiones, planificación, piloto automático |
| [Multiproyecto](./docs/multi-project.md) | Registro central, modos de aislamiento |
| [Docker](./docs/docker.md) | Despliegue en contenedores |
---
## Características principales
- **Planificación con IA** — El agente de planificación genera un `PROMPT.md` detallado con pasos, alcance de archivos y criterios de aceptación
- **Ejecución paso a paso** — Ciclo Plan → Revisión → Ejecución → Revisión para cada paso de la tarea
- **Aislamiento con worktrees de git** — Cada tarea corre en su propio worktree (rama `fusion/{task-id}`)
- **Pasos del flujo** — Puertas de calidad configurables (previas al merge: bloquean el merge; posteriores al merge: informativas)
- **Integración con GitHub** — Importar issues, crear PRs, insignias en tiempo real de PR/issue
- **Panel** — Tablero kanban en tiempo real, gestión de agentes, terminal, gestor de git, planificador de misiones
- **Misiones** — Planificación jerárquica (Misión → Hito → Slice → Característica → Tarea) con piloto automático, contratos de validación, reintentos de corrección de características y semántica de entrega bloqueada
- **Multiproyecto** — Gestiona múltiples proyectos desde una sola instalación con aislamiento de proyectos
- **Mensajería entre agentes** — Sistema de mensajería integrado para la coordinación entre agentes y usuarios
- **Salas de chat (experimental)** — Chat grupal con alcance de proyecto donde los miembros mencionados se enrutan como respondedores directos y miembros ambientales adicionales pueden responder hasta un límite (habilitar en **Configuración → Funciones experimentales → Salas de chat**; detalles en [Guía del panel → Salas de chat](./docs/dashboard-guide.md#chat-rooms))
### Autenticación de proveedores
Fusion soporta autenticación basada en OAuth para proveedores de IA configurados en **Configuración → Autenticación**. Para la mayoría de los proveedores OAuth, cuando se accede al panel desde un host que no es localhost (nodo remoto, host/IP de LAN o proxy inverso), las URLs de inicio de sesión del proveedor se reescriben para enrutar los callbacks de OAuth a través de un endpoint puente (`/api/auth/oauth-callback`) de modo que las redirecciones lleguen a la sesión de navegador activa.
- **Anthropic (Claude)** — Usa un flujo de código de autorización pegado en Configuración/incorporación: inicia sesión y pega la URL de redirección final (o el código) de vuelta en Fusion para completar el inicio de sesión
- **OpenAI Codex** — Usa el mismo flujo de código de autorización pegado con validación segura de estado
- **Factory AI — mediante Droid CLI** *(opcional)* — requiere instalación local de Droid CLI + `droid auth login`; la detección sigue la ruta efectiva del binario en tiempo de ejecución (predeterminado `droid`, o `droidBinaryPath` del plugin cuando está configurado), luego habilita en **Configuración → Autenticación** y reinicia Fusion
- **llama.cpp — mediante servidor HTTP** *(opcional)* — configura la URL de tu servidor llama.cpp (predeterminado `http://127.0.0.1:8080`) y la clave API opcional, luego habilita en **Configuración → Autenticación**
- **Otros proveedores** — Autentícate mediante entrada de clave API en Configuración (incluidas la clave API de Google/Gemini, Google Generative AI, Vertex y los alias de Cloud Code)
### Sistema de modelos
Fusion usa una jerarquía de modelos de doble alcance con cinco canales independientes. La configuración global define los valores predeterminados de referencia; la configuración de proyecto proporciona sobreescrituras por proyecto.
| Canal | Propósito | Claves de referencia global | Claves de sobreescritura por proyecto |
|------|---------|---------------------|----------------------|
| Executor | Agente de ejecución de tareas | `executionGlobalProvider` + `executionGlobalModelId` | `executionProvider` + `executionModelId` |
| Planning | Agente de planificación de tareas | `planningGlobalProvider` + `planningGlobalModelId` | `planningProvider` + `planningModelId` |
| Validator | Revisor de plan/código | `validatorGlobalProvider` + `validatorGlobalModelId` | `validatorProvider` + `validatorModelId` |
| Title Summarization | Generación automática de títulos | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` |
| Workflow Step Refinement | Refinamiento de prompts con IA | (usa `defaultProvider`/`defaultModelId`) | (usa `modelProvider`/`modelId` en WorkflowStep) |
**Sobreescrituras por tarea:** Las tareas pueden sobreescribir los canales de executor, validator y planning con campos de modelo por tarea (`modelProvider`/`modelId`, `validatorModelProvider`/`validatorModelId`, `planningModelProvider`/`planningModelId`).
**Precedencia:** Por tarea → Sobreescritura de proyecto → Canal global → `defaultProvider`/`defaultModelId` → Resolución automática.
Para la documentación completa de configuración, consulta la [Referencia de configuración](./docs/settings-reference.md).
### Tareas programadas / automatizaciones
Fusion soporta automatización de tareas programadas a través de los endpoints `/api/automations`. Las automatizaciones pueden ejecutar comandos de shell o flujos de trabajo de múltiples pasos en un horario configurable.
#### Alcance de la programación
Las automatizaciones y rutinas pueden ejecutarse en dos alcances:
- **Global** — Se ejecuta en todos los proyectos. Úsalo para mantenimiento entre proyectos, copias de seguridad o reportes unificados.
- **Proyecto** — Se ejecuta solo dentro de un proyecto específico. Úsalo para CI, pruebas o tareas de despliegue específicas del proyecto.
Cuando creas una programación sin elegir un alcance, Fusion usa por defecto el **alcance de proyecto** con el ID de proyecto `default` por compatibilidad con versiones anteriores.
Para especificar un alcance de forma explícita:
- En el modal de **Tareas programadas** del panel, usa el selector **Global / Proyecto**.
- Mediante la API, pasa `?scope=global` o `?scope=project&projectId=<id>` en los endpoints de automatización/rutina.
**Reglas de resolución de alcance:**
- `scope=global` siempre se resuelve en el canal de automatización/rutina global, independientemente del proyecto activo.
- `scope=project` requiere un `projectId`. Si se omite, vuelve a `"default"`.
- Las operaciones de CRUD, ejecución, activación/desactivación y webhook están estrictamente aisladas por alcance: una programación global no puede ser modificada desde una solicitud de alcance de proyecto, y viceversa.
**Orientación operativa para configuraciones multiproyecto:**
- Prefiere programaciones **globales** para infraestructura compartida (p. ej., copias de seguridad nocturnas, extracción de información de memoria).
- Prefiere programaciones de **proyecto** para automatización por repositorio (p. ej., ejecutores de pruebas por proyecto, hooks de despliegue).
- Los canales global y de proyecto son sondeados de forma independiente por el motor, por lo que las ejecuciones pendientes en un canal no bloquean al otro.
#### Automatizaciones
| Endpoint | Método | Descripción |
|---------|--------|-------------|
| `/api/automations` | GET | Listar todas las automatizaciones (filtradas por alcance si se especifica) |
| `/api/automations` | POST | Crear automatización (el alcance predeterminado es `project`) |
| `/api/automations/:id` | GET | Obtener automatización por ID |
| `/api/automations/:id` | PATCH | Actualizar automatización |
| `/api/automations/:id` | DELETE | Eliminar automatización |
| `/api/automations/:id/run` | POST | Activar ejecución manual |
| `/api/automations/:id/toggle` | POST | Activar/desactivar |
| `/api/automations/:id/steps/reorder` | POST | Reordenar pasos de la automatización |
#### Rutinas
Las rutinas son tareas de agente de IA activadas por programaciones cron, webhooks o ejecución manual. Las rutinas comparten el mismo modelo de alcance global/proyecto que las automatizaciones.
| Endpoint | Método | Descripción |
|---------|--------|-------------|
| `/api/routines` | GET | Listar todas las rutinas (filtradas por alcance si se especifica) |
| `/api/routines` | POST | Crear rutina (el alcance predeterminado es `project`) |
| `/api/routines/:id` | GET | Obtener rutina por ID |
| `/api/routines/:id` | PATCH | Actualizar rutina |
| `/api/routines/:id` | DELETE | Eliminar rutina |
| `/api/routines/:id/run` | POST | Activar manualmente |
| `/api/routines/:id/trigger` | POST | Activación manual canónica |
| `/api/routines/:id/runs` | GET | Obtener historial de ejecuciones |
| `/api/routines/:id/webhook` | POST | Activación por webhook (verificación de firma compatible) |
---
## Ejemplos rápidos de CLI
```bash
fn task create "Fix the login bug" # Entrada rápida → planificación
fn task plan "Build auth system" # Planificación guiada por IA
fn task import owner/repo --labels bug # Importar issues de GitHub
fn task show FN-001 # Ver detalles de la tarea
fn task logs FN-001 --follow # Transmitir registros de ejecución
fn task steer FN-001 "Use TypeScript" # Guiar al agente durante la ejecución
fn project add my-app /path/to/app # Registrar un proyecto
fn project list # Listar todos los proyectos
fn settings set maxConcurrent 4 # Configurar ajustes
fn settings export # Exportar configuración
fn mission create "Auth System" "Build auth" # Crear misión
fn mission activate-slice <slice-id> # Activar un slice
fn skills search react # Buscar en skills.sh
fn skills install firebase/agent-skills # Instalar habilidades de agente
```
---
## Paquetes
| Paquete | Descripción |
|---------|-------------|
| `@fusion/core` | Modelo de dominio — tareas, columnas del tablero, almacén SQLite |
| `@fusion/dashboard` | Interfaz web — servidor Express + tablero kanban con SSE |
| `@fusion/engine` | Motor de IA — planificación, ejecución, programación, pasos del flujo |
| `@runfusion/fusion` | CLI + extensión — publicado en npm |
---
## Desarrollo
```bash
pnpm install # Instalar dependencias
pnpm local # Iniciar panel/API local en un puerto distinto al 4040
pnpm local -- --engine # Iniciar panel local con el motor de IA
pnpm build # Compilar paquetes del espacio de trabajo predeterminado (excluye escritorio/móvil)
pnpm build:all # Compilar todos los paquetes (incluyendo escritorio/móvil)
pnpm dev dashboard # Ejecutar panel + motor de IA
pnpm dev:ui # Solo el panel (sin motor de IA)
pnpm lint # Verificar estilo de código en todos los paquetes
pnpm typecheck # Verificar tipos en todos los paquetes
pnpm test # Ejecutar todas las pruebas
```
### Compilar un ejecutable independiente
Compila un binario `fn` autocontenido usando [Bun](https://bun.sh/):
```bash
pnpm build:exe # Compilar para la plataforma actual
pnpm build:exe:all # Compilación cruzada para todas las plataformas
```
---
## Licencia
MIT — código abierto, sin dependencia de proveedor. Ver [LICENSE](./LICENSE).
<div align="center">
**[runfusion.ai →](https://runfusion.ai)**
</div>

469
README.fr.md Normal file
View File

@@ -0,0 +1,469 @@
<div align="center">
<img src="./demo/assets/fusion-logo.png" alt="Fusion" width="120" />
# Fusion
### De l'idée brute au code de production — automatiquement.
**Orchestrateur d'agents multi-nœuds** — tâches, agents, missions, git, fichiers et worktrees, avec n'importe quel modèle, local ou cloud.
[**runfusion.ai →**](https://runfusion.ai) · [Docs](./docs/README.md) · [GitHub](https://github.com/Runfusion/Fusion) · [npm](https://www.npmjs.com/package/@runfusion/fusion) · [Discord](https://discord.gg/ksrfuy7WYR)
[English](./README.md) · [简体中文](./README.zh-CN.md) · [繁體中文](./README.zh-TW.md) · **Français** · [Español](./README.es.md) · [한국어](./README.ko.md)
*Traduction automatique — le README anglais fait foi.*
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](./LICENSE)
[![npm](https://img.shields.io/npm/v/@runfusion/fusion.svg?color=58a6ff)](https://www.npmjs.com/package/@runfusion/fusion)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/ksrfuy7WYR)
![Status](https://img.shields.io/badge/status-early%20preview-d29922.svg)
![Shipping](https://img.shields.io/badge/shipping-weekly-bc8cff.svg)
<br />
<img src="./demo/assets/fusion-reel.gif" alt="Démo Fusion : de l'idée brute au code de production" width="900" />
<br />
<br />
<a href="https://runfusion.ai">
<img src="https://runfusion.ai/fusion-dashboard.png" alt="Tableau de bord Fusion : colonnes kanban Planification, À faire, En cours, En révision, Terminé avec des cartes de tâches actives" width="900" />
</a>
</div>
---
## Tout votre environnement de développement. Sur un seul écran.
Décrivez une tâche en langage naturel. Un agent de planification lit votre projet, comprend le contexte et rédige un plan `PROMPT.md` complet — étapes, périmètre des fichiers, critères d'acceptation. Fusion planifie ensuite, révise, exécute et révise à nouveau, dans un worktree git isolé, avec une validation humaine où vous le souhaitez.
Un tableau. Contrôlé de n'importe où. Laptop, Mac mini, serveur Linux, VM cloud, téléphone — tous connectés.
> Comme Trello, mais vos tâches sont spécifiées, exécutées et livrées par l'IA. Construit sur l'excellent travail de [dustinbyrne/kb](https://github.com/dustinbyrne/kb).
---
## Le flux
```
① Décrire ② Planification ③ Le tableau ④ Worktree isolé
───────────── ───────────── ───────────── ─────────────────────
"Ajouter un → L'agent rédige → Plan → Révision → → branche fusion/FN-123
bouton mode PROMPT.md Exécution → Révision concurrent, zéro
sombre dans le (étapes, périmètre, (par étape, jusqu'à conflit de fichiers
panneau réglages" acceptation) la fin)
```
### Voir chaque étape avant la fusion
<div align="center">
<img src="https://runfusion.ai/screenshot-task-detail.png" alt="Détail d'une tâche Fusion : étapes du workflow visibles sur une tâche en cours avec diffs et modifications de fichiers" width="820" />
</div>
Chaque tâche affiche son plan, ses révisions, ses diffs et ses modifications de fichiers en temps réel. Intervenez sur une tâche active pour réorienter, affiner les contraintes, mettre en pause ou reformuler.
---
## Ce qui la différencie
| | |
|---|---|
| 🧠 **Planification IA** | Décrivez une tâche en langage naturel. Les agents de planification la transforment en plan `PROMPT.md` avec étapes, périmètre des fichiers et critères d'acceptation. |
| 🔁 **Portes de workflow** | Plan → Révision → Exécution → Révision à chaque étape. Les portes pré-fusion bloquent le mauvais code ; les portes post-fusion effectuent des vérifications informatives. |
| 🌳 **Isolation par worktree** | Chaque tâche s'exécute dans sa propre branche et son propre worktree (`fusion/{task-id}`). Tâches parallèles. Zéro conflit. Délégation [worktrunk](https://github.com/max-sixty/worktrunk) optionnelle via [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings) (voir [abstraction WorktreeBackend](./docs/architecture.md#worktreebackend-abstraction)). |
| ⚡ **Fusion intelligente** | Toutes les portes franchies ? Fusion effectue un squash-merge et passe à la suite. Activez la validation manuelle où vous le souhaitez. |
| 🛰️ **Maillage multi-nœuds** | Laptop, Mac mini, serveur Linux, VM cloud, téléphone — tout synchronisé. Bureau, mobile, web. |
| 🧩 **N'importe quel modèle** | Anthropic, OpenAI, Ollama et plus encore. Local et cloud coexistent. |
| 🏢 **Entreprises d'agents** | Importez des équipes prédéfinies — plus de 440 agents répartis dans 16 entreprises — et faites-les fonctionner de façon autonome pendant des semaines. |
| 📬 **Messagerie inter-agents** | Boîte aux lettres intégrée entre agents. Déléguer, clarifier, coordonner. |
| 🗨️ **Salles de discussion multi-agents** | Conversations de groupe à portée de projet où plusieurs membres peuvent répondre : les membres mentionnés sont des répondants directs, et des membres ambiants supplémentaires peuvent répondre jusqu'à un certain plafond. Actuellement **expérimental** — activez `chatRooms` dans **Paramètres → Fonctionnalités expérimentales → Salles de discussion**. ([Documentation des salles de discussion](./docs/dashboard-guide.md#chat-rooms)) |
| 🗺️ **Missions** | Planification hiérarchique (Mission → Jalon → Tranche → Fonctionnalité → Tâche) avec pilotage automatique et contrats de validation. |
| 🔬 **Recherche** | Exécutions de recherche délimitées avec recherche web, GitHub, docs locaux et synthèse LLM (plus prise en charge intégrée de WebSearch/WebFetch dans les flux de planification et de synthèse lorsque disponible). Transformez les résultats en tâches. ([Docs](./docs/research.md)) |
| 🧪 **Auto-amélioration** | Les agents réfléchissent à leurs propres résultats et mettent à jour leurs prompts au fur et à mesure qu'ils apprennent votre base de code. |
| 🔓 **Open source. MIT.** | Pas d'enfermement propriétaire. Faites-le tourner sur votre propre matériel. Livraisons hebdomadaires. |
---
## Comment ça fonctionne
```mermaid
graph TD
H((Vous)) -->|idée brute| T["Planification<br/><i>planification automatique</i>"]
T --> TD["À faire<br/><i>programmé pour l'exécution</i>"]
TD --> IP["En cours<br/><i>pour chaque étape :<br/>plan, révision, exécution, révision</i>"]
subgraph IP["En cours"]
direction TD
NS([Commencer l'étape]) --> P[Plan]
P --> R1{Révision}
R1 -->|réviser| P
R1 -->|approuver| E[Exécuter]
E --> R2{Révision}
R2 -->|réviser| E
R2 -->|étape suivante| NS
R2 -->|repenser| P
end
R2 -->|terminé| IR["En révision<br/><i>prêt à fusionner,<br/>ou auto-complétion</i>"]
IR -->|squash merge direct<br/>ou PR fusionnée| D["Terminé"]
style H fill:#161b22,stroke:#8b949e,color:#e6edf3
style T fill:#2d2006,stroke:#d29922,color:#d29922
style TD fill:#0d2044,stroke:#58a6ff,color:#58a6ff
style IP fill:#1a0d2e,stroke:#bc8cff,color:#bc8cff
style P fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style R1 fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style E fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style R2 fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style NS fill:#1a0d2e,stroke:#bc8cff,color:#bc8cff
style IR fill:#0d2d16,stroke:#3fb950,color:#3fb950
style D fill:#1a1a1a,stroke:#8b949e,color:#8b949e
```
Les tâches avec dépendances sont traitées séquentiellement. Les tâches indépendantes s'exécutent en parallèle. Vous pouvez exiger une validation manuelle avant que les tâches passent de Planification à À faire (paramètre `requirePlanApproval`).
---
## Multi-nœuds. Un tableau. Toutes les plateformes.
<div align="center">
<img src="./demo/assets/fusion-mesh.gif" alt="Maillage Fusion : laptop, Mac mini, serveur Linux, VM cloud, téléphone — tous synchronisés" width="820" />
<br />
![macOS](https://img.shields.io/badge/macOS-000000?style=for-the-badge&logo=apple&logoColor=white)
![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)
![Web](https://img.shields.io/badge/Web-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-000000?style=for-the-badge&logo=apple&logoColor=white)
![Android](https://img.shields.io/badge/Android-3DDC84?style=for-the-badge&logo=android&logoColor=white)
</div>
Laptop, Mac mini, serveur Linux, VM cloud, téléphone — chaque nœud est un pair. L'état de vos tâches, vos agents, vos journaux et vos diffs restent synchronisés sur tout le maillage. Le même Fusion est livré sous forme de :
- 🖥️ **Application de bureau** — Electron pour **macOS** (Intel + Apple Silicon), **Windows** 10/11 et **Linux**
- 📱 **Application mobile** — Capacitor pour **iOS/iPadOS** et **Android** ([MOBILE.md](./MOBILE.md))
- 🌐 **Tableau de bord web** — tout navigateur moderne, servi par le démon `fn dashboard`
- 🔌 **CLI** — binaire `fn` + extension pour les workflows orientés terminal
Démarrez le démon sur n'importe quel nœud, connectez vos autres appareils, et le tableau vous suit partout.
---
## Faire tourner une entreprise d'agents
<div align="center">
<img src="./demo/assets/fusion-company-reel.gif" alt="Entreprise d'agents Fusion : importer une équipe, la faire tourner de façon autonome pendant des semaines" width="820" />
</div>
Importez une équipe. Faites-la tourner de façon autonome pendant des semaines. **Plus de 440 agents répartis dans 16 entreprises**, câblés pour les missions, les boîtes aux lettres et la délégation inter-agents.
```bash
npx companies.sh add paperclipai/companies/gstack
```
---
## Compatible avec les outils que vous utilisez déjà.
Fusion s'intègre avec les outils que vous aimez. **Hermes**, **Paperclip** et **OpenClaw** sont tous livrés comme plugins de première classe — routez n'importe quel espace de travail vers le runtime qui convient à la tâche. Et n'importe quelle entreprise d'agents Paperclip s'importe en une seule commande.
<div align="center">
<img src="./demo/assets/hermes-logo.svg" alt="Hermes" height="56" />
</div>
### [Hermes](https://hermes-agent.nousresearch.com) <sub>`experimental`</sub>
<sub>Nous Research</sub>
L'agent autonome open source de **Nous Research**. Installez le plugin Hermes et exécutez des agents via Hermes pour les travaux de longue durée à contexte croissant — routez n'importe quel espace de travail Fusion vers lui.
### OpenClaw <sub>`experimental`</sub>
La prise en charge du runtime OpenClaw est disponible sous forme de plugin expérimental (`fusion-plugin-openclaw-runtime`) pour la parité de découverte/configuration du runtime. Configurez les agents avec `runtimeConfig.runtimeHint: "openclaw"` après installation du plugin.
<br />
<div align="center">
<img src="./demo/assets/paperclip-logo.svg" alt="Paperclip" height="56" />
</div>
### [Paperclip](https://paperclip.ing) <sub>`experimental`</sub>
<sub>paperclip.ing</sub>
Le plan de contrôle humain pour le travail IA. Installez le plugin Paperclip pour exécuter des agents via Paperclip dans Fusion.
Fusion prend également en charge nativement le standard d'entreprises d'agents **[`companies.sh`](https://github.com/paperclipai/companies)** : importez une équipe prédéfinie — **plus de 440 agents répartis dans 16 entreprises** — et laissez-les se coordonner via la boîte aux lettres, les missions et les portes de workflow de Fusion pendant des semaines de travail autonome. Même format d'entreprise, mêmes agents, mêmes compétences que Paperclip.
```bash
npx companies.sh add paperclipai/companies/gstack
```
<br />
> **Hermes**, **Paperclip** et **OpenClaw** sont des plugins de runtime **expérimentaux** — les API et les formats de communication peuvent évoluer entre les versions mineures.
---
## Démarrage rapide
**Sans installation, directement depuis npm :**
```bash
npx runfusion.ai
```
Cela lance le tableau de bord. Les sous-commandes passent directement : `npx runfusion.ai task create "fix X"`, `npx runfusion.ai --help`, etc. (Ou de façon verbeuse : `npx @runfusion/fusion dashboard`.)
**Installateur en une ligne** (macOS et Linux — choisit automatiquement Homebrew, bascule sur npm en secours) :
```bash
curl -fsSL https://runfusion.ai/install.sh | sh
fusion dashboard
```
**Homebrew** (macOS et Linux) :
```bash
brew tap runfusion/fusion
brew install fusion
fusion dashboard # ou : fn dashboard
```
Ou en une ligne (tap automatique) : `brew install runfusion/fusion/fusion`.
**npm global** :
```bash
npm install -g @runfusion/fusion
fn dashboard # ou : fusion dashboard
```
**Depuis un clone** (pour le développement) :
```bash
pnpm dev dashboard
```
Cliquez ensuite sur l'URL `Open:` affichée dans le terminal. Elle intègre un jeton bearer
(`http://localhost:4040/?token=fn_...`) que le navigateur capture dans
`localStorage` à la première visite et réutilise automatiquement par la suite. Côté
serveur, Fusion persiste désormais le jeton du tableau de bord/démon dans
`~/.fusion/settings.json` à la première exécution authentifiée et le réutilise
lors des démarrages ultérieurs, sauf si vous le remplacez (`--token`,
`FUSION_DASHBOARD_TOKEN`, `FUSION_DAEMON_TOKEN`) ou désactivez l'authentification
avec `--no-auth`. Voir
[Référence CLI → fn dashboard → Authentification](./docs/cli-reference.md#fn-dashboard)
pour la précédence complète et les options de réinitialisation/révocation.
### Configuration au premier lancement
Au premier lancement, Fusion ouvre l'**assistant d'intégration** en trois étapes guidées :
1. **Configuration IA** — Utilisez la liste simplifiée de fournisseurs de démarrage rapide (fournisseurs recommandés et fournisseurs déjà connectés), puis développez les **Paramètres avancés du fournisseur** uniquement si vous avez besoin de fournisseurs supplémentaires ou de détails de configuration. Un seul fournisseur suffit pour commencer. Les entrées de fournisseurs dépréciés Google Gemini CLI / Antigravity sont intentionnellement masquées ; les chemins clé API Google/Gemini, Google Generative AI, Vertex et Cloud Code restent pris en charge.
2. **GitHub (optionnel)** — Connectez GitHub pour l'import de tickets et la gestion des PR
3. **Première tâche** — Créez votre première tâche ou importez depuis GitHub (si aucun projet n'est actif, l'assistant vous invite d'abord à enregistrer/sélectionner un répertoire de projet)
L'assistant est **dismissable et non bloquant** — cliquez sur **Ignorer pour l'instant** pour utiliser le tableau de bord immédiatement. Relancez-le plus tard depuis **Paramètres → Authentification → Rouvrir le guide d'intégration**.
### Mobile
Pour le workflow Capacitor + PWA, voir [MOBILE.md](./MOBILE.md).
---
## Documentation
| Guide | Ce qu'il couvre |
|---|---|
| [Premiers pas](./docs/getting-started.md) | Installation et intégration |
| [Guide du tableau de bord](./docs/dashboard-guide.md) | Vues tableau/liste, terminal, gestionnaire git |
| [Gestion des tâches](./docs/task-management.md) | Cycle de vie des tâches et commandes CLI |
| [Référence CLI](./docs/cli-reference.md) | Référence complète des commandes et du démon |
| [Référence des paramètres](./docs/settings-reference.md) | Options de configuration |
| [Architecture](./docs/architecture.md) | Internals du système |
| [Agents](./docs/agents.md) | Gestion des agents, instanciation, heartbeat |
| [Étapes de workflow](./docs/workflow-steps.md) | Portes de qualité, modèles, phases |
| [Missions](./docs/missions.md) | Hiérarchie de missions, planification, pilotage automatique |
| [Multi-projet](./docs/multi-project.md) | Registre central, modes d'isolation |
| [Docker](./docs/docker.md) | Déploiement en conteneur |
---
## Fonctionnalités principales
- **Planification IA** — L'agent de planification génère un `PROMPT.md` détaillé avec étapes, périmètre des fichiers et critères d'acceptation
- **Exécution pas à pas** — Cycle Plan → Révision → Exécution → Révision pour chaque étape de tâche
- **Isolation par worktree git** — Chaque tâche s'exécute dans son propre worktree (branche `fusion/{task-id}`)
- **Étapes de workflow** — Portes de qualité configurables (pré-fusion : bloque la fusion ; post-fusion : informatif)
- **Intégration GitHub** — Import de tickets, création de PR, badges PR/ticket en temps réel
- **Tableau de bord** — Tableau kanban en temps réel, gestion des agents, terminal, gestionnaire git, planificateur de missions
- **Missions** — Planification hiérarchique (Mission → Jalon → Tranche → Fonctionnalité → Tâche) avec pilotage automatique, contrats de validation, nouvelles tentatives sur correctifs/fonctionnalités et sémantique de transfert en cas de blocage
- **Multi-projet** — Gérez plusieurs projets depuis une installation unique avec isolation des projets
- **Messagerie inter-agents** — Messagerie intégrée pour la coordination entre agents et utilisateurs
- **Salles de discussion (Expérimental)** — Discussion de groupe à portée de projet où les membres mentionnés sont routés comme répondants directs et des membres ambiants supplémentaires peuvent répondre jusqu'à un certain plafond (activer via **Paramètres → Fonctionnalités expérimentales → Salles de discussion** ; détails dans [Guide du tableau de bord → Salles de discussion](./docs/dashboard-guide.md#chat-rooms))
### Authentification des fournisseurs
Fusion prend en charge l'authentification OAuth pour les fournisseurs IA configurée via **Paramètres → Authentification**. Pour la plupart des fournisseurs OAuth, lorsque le tableau de bord est accédé via un hôte non-localhost (nœud distant, hôte/IP LAN ou proxy inverse), les URL de connexion du fournisseur sont réécrites pour router les callbacks OAuth via un endpoint bridge (`/api/auth/oauth-callback`) afin que les redirections atteignent la session navigateur active.
- **Anthropic (Claude)** — Utilise un flux de code d'autorisation collé dans Paramètres/l'assistant : connectez-vous, puis collez l'URL de redirection finale (ou le code) dans Fusion pour terminer la connexion
- **OpenAI Codex** — Utilise le même flux de code d'autorisation collé avec validation d'état sécurisée
- **Factory AI — via Droid CLI** *(optionnel)* — nécessite une installation locale de Droid CLI + `droid auth login` ; la détection suit le chemin du binaire runtime effectif (par défaut `droid`, ou `droidBinaryPath` du plugin si configuré), puis activez dans **Paramètres → Authentification** et redémarrez Fusion
- **llama.cpp — via serveur HTTP** *(optionnel)* — configurez l'URL de votre serveur llama.cpp (par défaut `http://127.0.0.1:8080`) et la clé API optionnelle, puis activez dans **Paramètres → Authentification**
- **Autres fournisseurs** — Authentifiez via la saisie de clé API dans Paramètres (y compris clé API Google/Gemini, Google Generative AI, Vertex et alias Cloud Code)
### Système de modèles
Fusion utilise une hiérarchie de modèles à double portée avec cinq voies indépendantes. Les paramètres globaux définissent les valeurs par défaut de base ; les paramètres de projet fournissent des remplacements par projet.
| Voie | Objectif | Clés globales de base | Clés de remplacement par projet |
|------|---------|---------------------|----------------------|
| Exécuteur | Agent d'exécution des tâches | `executionGlobalProvider` + `executionGlobalModelId` | `executionProvider` + `executionModelId` |
| Planification | Agent de planification des tâches | `planningGlobalProvider` + `planningGlobalModelId` | `planningProvider` + `planningModelId` |
| Validateur | Réviseur de plan/code | `validatorGlobalProvider` + `validatorGlobalModelId` | `validatorProvider` + `validatorModelId` |
| Résumé de titre | Génération automatique de titre | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` |
| Raffinement des étapes de workflow | Raffinement de prompt IA | (utilise `defaultProvider`/`defaultModelId`) | (utilise `modelProvider`/`modelId` sur WorkflowStep) |
**Remplacements par tâche :** Les tâches peuvent remplacer les voies exécuteur, validateur et planification avec des champs de modèle par tâche (`modelProvider`/`modelId`, `validatorModelProvider`/`validatorModelId`, `planningModelProvider`/`planningModelId`).
**Précédence :** Par tâche → Remplacement projet → Voie globale → `defaultProvider`/`defaultModelId` → Résolution automatique.
Pour la documentation complète des paramètres, voir la [Référence des paramètres](./docs/settings-reference.md).
### Tâches planifiées / automatisations
Fusion prend en charge l'automatisation de tâches planifiées via les endpoints `/api/automations`. Les automatisations peuvent exécuter des commandes shell ou des workflows multi-étapes selon un calendrier configurable.
#### Portée de la planification
Les automatisations et les routines peuvent s'exécuter dans deux portées :
- **Globale** — S'exécute sur tous les projets. À utiliser pour la maintenance inter-projets, les sauvegardes ou les rapports unifiés.
- **Projet** — S'exécute uniquement dans un projet spécifique. À utiliser pour la CI, les tests ou les déploiements spécifiques au projet.
Lorsque vous créez une planification sans choisir de portée, Fusion utilise par défaut la **portée projet** avec l'ID de projet `default` pour la compatibilité ascendante.
Pour cibler explicitement une portée :
- Dans le modal **Tâches planifiées** du tableau de bord, utilisez le bouton **Global / Projet**.
- Via l'API, passez `?scope=global` ou `?scope=project&projectId=<id>` sur les endpoints d'automatisation/routine.
**Règles de résolution de portée :**
- `scope=global` se résout toujours vers la voie d'automatisation/routine globale, indépendamment du projet actif.
- `scope=project` nécessite un `projectId`. S'il est omis, il bascule sur `"default"`.
- Les opérations CRUD, exécution, bascule et webhook sont strictement isolées par portée : une planification globale ne peut pas être modifiée depuis une requête à portée projet, et vice versa.
**Conseils opérationnels pour les configurations multi-projets :**
- Préférez les planifications **globales** pour l'infrastructure partagée (ex. : sauvegardes nocturnes, extraction de résumés mémorisés).
- Préférez les planifications **projet** pour l'automatisation par dépôt (ex. : lanceurs de tests par projet, hooks de déploiement).
- Les voies globale et projet sont interrogées indépendamment par le moteur, donc les exécutions dues dans une voie ne bloquent pas l'autre.
#### Automatisations
| Endpoint | Méthode | Description |
|---------|--------|-------------|
| `/api/automations` | GET | Lister toutes les automatisations (filtrées par portée si spécifiée) |
| `/api/automations` | POST | Créer une automatisation (portée par défaut : `project`) |
| `/api/automations/:id` | GET | Obtenir une automatisation par ID |
| `/api/automations/:id` | PATCH | Mettre à jour une automatisation |
| `/api/automations/:id` | DELETE | Supprimer une automatisation |
| `/api/automations/:id/run` | POST | Déclencher une exécution manuelle |
| `/api/automations/:id/toggle` | POST | Activer/désactiver |
| `/api/automations/:id/steps/reorder` | POST | Réordonner les étapes d'automatisation |
#### Routines
Les routines sont des tâches d'agents IA déclenchées par des planifications cron, des webhooks ou une exécution manuelle. Les routines partagent le même modèle de portée global/projet que les automatisations.
| Endpoint | Méthode | Description |
|---------|--------|-------------|
| `/api/routines` | GET | Lister toutes les routines (filtrées par portée si spécifiée) |
| `/api/routines` | POST | Créer une routine (portée par défaut : `project`) |
| `/api/routines/:id` | GET | Obtenir une routine par ID |
| `/api/routines/:id` | PATCH | Mettre à jour une routine |
| `/api/routines/:id` | DELETE | Supprimer une routine |
| `/api/routines/:id/run` | POST | Déclenchement manuel |
| `/api/routines/:id/trigger` | POST | Déclenchement manuel canonique |
| `/api/routines/:id/runs` | GET | Obtenir l'historique d'exécution |
| `/api/routines/:id/webhook` | POST | Déclenchement par webhook (vérification de signature prise en charge) |
---
## Exemples CLI rapides
```bash
fn task create "Fix the login bug" # Entrée rapide → planification
fn task plan "Build auth system" # Planification guidée par IA
fn task import owner/repo --labels bug # Importer des tickets GitHub
fn task show FN-001 # Voir les détails d'une tâche
fn task logs FN-001 --follow # Suivre les journaux d'exécution
fn task steer FN-001 "Use TypeScript" # Guider l'agent en cours d'exécution
fn project add my-app /path/to/app # Enregistrer un projet
fn project list # Lister tous les projets
fn settings set maxConcurrent 4 # Configurer les paramètres
fn settings export # Exporter la configuration
fn mission create "Auth System" "Build auth" # Créer une mission
fn mission activate-slice <slice-id> # Activer une tranche
fn skills search react # Rechercher dans skills.sh
fn skills install firebase/agent-skills # Installer des compétences d'agent
```
---
## Paquets
| Paquet | Description |
|---------|-------------|
| `@fusion/core` | Modèle de domaine — tâches, colonnes du tableau, store SQLite |
| `@fusion/dashboard` | Interface web — serveur Express + tableau kanban avec SSE |
| `@fusion/engine` | Moteur IA — planification, exécution, ordonnancement, étapes de workflow |
| `@runfusion/fusion` | CLI + extension — publié sur npm |
---
## Développement
```bash
pnpm install # Installer les dépendances
pnpm local # Démarrer le tableau de bord/API local sur un port différent de 4040
pnpm local -- --engine # Démarrer le tableau de bord local avec le moteur IA
pnpm build # Construire les paquets du workspace par défaut (exclut bureau/mobile)
pnpm build:all # Construire tous les paquets (y compris bureau/mobile)
pnpm dev dashboard # Exécuter le tableau de bord + le moteur IA
pnpm dev:ui # Tableau de bord seul (sans moteur IA)
pnpm lint # Linter tous les paquets
pnpm typecheck # Vérifier les types de tous les paquets
pnpm test # Exécuter tous les tests
```
### Construire un exécutable autonome
Construisez un binaire `fn` autonome et auto-contenu avec [Bun](https://bun.sh/) :
```bash
pnpm build:exe # Construire pour la plateforme actuelle
pnpm build:exe:all # Compilation croisée pour toutes les plateformes
```
---
## Licence
MIT — open source, sans enfermement propriétaire. Voir [LICENSE](./LICENSE).
<div align="center">
**[runfusion.ai →](https://runfusion.ai)**
</div>

468
README.ko.md Normal file
View File

@@ -0,0 +1,468 @@
<div align="center">
<img src="./demo/assets/fusion-logo.png" alt="Fusion" width="120" />
# Fusion
### 거친 아이디어에서 프로덕션 코드까지 — 자동으로.
**멀티 노드 에이전트 오케스트레이터** — 태스크, 에이전트, 미션, git, 파일, 워크트리를 어떤 모델에서도, 로컬 또는 클라우드에서 실행합니다.
[**runfusion.ai →**](https://runfusion.ai) · [문서](./docs/README.md) · [GitHub](https://github.com/Runfusion/Fusion) · [npm](https://www.npmjs.com/package/@runfusion/fusion) · [Discord](https://discord.gg/ksrfuy7WYR)
[English](./README.md) · [简体中文](./README.zh-CN.md) · [繁體中文](./README.zh-TW.md) · [Français](./README.fr.md) · [Español](./README.es.md) · **한국어**
*이 문서는 기계 번역본입니다. 공식 원본은 [영문 README](./README.md)를 참조하세요.*
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](./LICENSE)
[![npm](https://img.shields.io/npm/v/@runfusion/fusion.svg?color=58a6ff)](https://www.npmjs.com/package/@runfusion/fusion)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/ksrfuy7WYR)
![Status](https://img.shields.io/badge/status-early%20preview-d29922.svg)
![Shipping](https://img.shields.io/badge/shipping-weekly-bc8cff.svg)
<br />
<img src="./demo/assets/fusion-reel.gif" alt="Fusion 릴: 거친 아이디어에서 프로덕션 코드까지" width="900" />
<br />
<br />
<a href="https://runfusion.ai">
<img src="https://runfusion.ai/fusion-dashboard.png" alt="Fusion 대시보드: Planning, Todo, In Progress, In Review, Done 칸반 컬럼과 활성 태스크 카드" width="900" />
</a>
</div>
---
## 전체 개발 환경을 하나의 화면에서.
평문으로 태스크를 설명하면, 계획 에이전트가 프로젝트를 읽고 컨텍스트를 파악한 뒤 단계, 파일 범위, 완료 기준이 담긴 `PROMPT.md` 계획서를 작성합니다. 이후 Fusion은 격리된 git 워크트리에서 계획, 검토, 실행, 재검토를 순서대로 수행하며, 원하는 곳마다 사람의 승인 단계를 추가할 수 있습니다.
보드 하나로. 어디서든 제어. 노트북, Mac mini, Linux 서버, 클라우드 VM, 휴대폰 — 모두 연결됩니다.
> Trello와 유사하지만, 태스크의 명세 작성, 실행, 납품을 AI가 수행합니다. [dustinbyrne/kb](https://github.com/dustinbyrne/kb)의 훌륭한 작업을 기반으로 구축되었습니다.
---
## 흐름
```
① 설명 ② 계획 ③ 보드 ④ 격리된 워크트리
───────────── ───────────── ───────────── ─────────────────────
"설정 패널에 → 에이전트가 → 계획 → 검토 → → fusion/FN-123 브랜치
다크 모드 토글 PROMPT.md 작성 실행 → 검토 동시 실행,
추가" (단계, 범위, (각 단계마다, 파일 충돌 없음
완료 기준) 완료까지)
```
### 머지 전에 모든 단계를 확인하세요
<div align="center">
<img src="https://runfusion.ai/screenshot-task-detail.png" alt="Fusion 태스크 상세: 진행 중인 태스크의 워크플로우 단계, 차이점, 파일 변경 내역이 실시간으로 표시" width="820" />
</div>
모든 태스크는 계획, 검토, 차이점, 파일 변경 내역을 실시간으로 보여줍니다. 진행 중인 태스크에 들어가 방향을 조정하거나, 제약을 강화하거나, 일시 정지하거나, 재프롬프트할 수 있습니다.
---
## 차별점
| | |
|---|---|
| 🧠 **AI 계획** | 평문으로 태스크를 설명하면, 계획 에이전트가 단계, 파일 범위, 완료 기준이 포함된 `PROMPT.md` 계획서로 변환합니다. |
| 🔁 **워크플로우 게이트** | 모든 단계마다 계획 → 검토 → 실행 → 검토 주기를 거칩니다. 사전 머지 게이트는 불량 코드를 차단하고, 사후 머지 게이트는 정보성 검사를 실행합니다. |
| 🌳 **워크트리 격리** | 각 태스크는 자체 브랜치와 워크트리(`fusion/{task-id}`)에서 실행됩니다. 병렬 태스크. 충돌 없음. [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings)를 통한 선택적 [worktrunk](https://github.com/max-sixty/worktrunk) 위임 지원([WorktreeBackend 추상화](./docs/architecture.md#worktreebackend-abstraction) 참조). |
| ⚡ **스마트 머지** | 모든 게이트 통과 시 Fusion이 스쿼시 머지하고 다음으로 넘어갑니다. 어디서든 수동 승인을 선택할 수 있습니다. |
| 🛰️ **멀티 노드 메시** | 노트북, Mac mini, Linux 서버, 클라우드 VM, 휴대폰 — 모두 동기화됩니다. 데스크톱, 모바일, 웹. |
| 🧩 **모든 모델** | Anthropic, OpenAI, Ollama 등 다양한 모델을 지원합니다. 로컬과 클라우드가 공존합니다. |
| 🏢 **에이전트 컴퍼니** | 사전 구축된 팀 — 16개 컴퍼니에 걸쳐 440개 이상의 에이전트 — 을 임포트하여 몇 주 동안 자율적으로 실행합니다. |
| 📬 **에이전트 간 메시징** | 에이전트 간 내장 메일박스. 위임, 확인, 조율이 가능합니다. |
| 🗨️ **멀티 에이전트 채팅 룸** | 여러 룸 구성원이 답할 수 있는 프로젝트 범위 그룹 대화: 언급된 구성원은 직접 응답자로, 추가 주변 구성원은 최대 한도까지 응답할 수 있습니다. 현재 **실험적** — **설정 → 실험적 기능 → 채팅 룸**에서 `chatRooms`를 활성화하세요. ([채팅 룸 문서](./docs/dashboard-guide.md#chat-rooms)) |
| 🗺️ **미션** | 계층적 계획(미션 → 마일스톤 → 슬라이스 → 기능 → 태스크), 자동 조종, 검증 계약 포함. |
| 🔬 **리서치** | 웹 검색, GitHub, 로컬 문서, LLM 합성을 활용한 경계 있는 리서치 실행(계획 및 합성 흐름에서 런타임 내장 WebSearch/WebFetch 지원 포함). 결과를 태스크로 전환합니다. ([문서](./docs/research.md)) |
| 🧪 **자기 개선** | 에이전트가 자신의 출력물을 돌아보고 코드베이스를 학습하면서 프롬프트를 업데이트합니다. |
| 🔓 **오픈 소스. MIT.** | 벤더 종속 없음. 자체 하드웨어에서 실행. 매주 배포. |
---
## 작동 방식
```mermaid
graph TD
H((사용자)) -->|거친 아이디어| T["계획 중<br/><i>자동 계획</i>"]
T --> TD["할 일<br/><i>실행 예약됨</i>"]
TD --> IP["진행 중<br/><i>각 단계마다:<br/>계획, 검토, 실행, 검토</i>"]
subgraph IP["진행 중"]
direction TD
NS([단계 시작]) --> P[계획]
P --> R1{검토}
R1 -->|수정| P
R1 -->|승인| E[실행]
E --> R2{검토}
R2 -->|수정| E
R2 -->|다음 단계| NS
R2 -->|재검토| P
end
R2 -->|완료| IR["검토 중<br/><i>머지 준비,<br/>또는 자동 완료</i>"]
IR -->|직접 스쿼시 머지<br/>또는 PR 머지| D["완료"]
style H fill:#161b22,stroke:#8b949e,color:#e6edf3
style T fill:#2d2006,stroke:#d29922,color:#d29922
style TD fill:#0d2044,stroke:#58a6ff,color:#58a6ff
style IP fill:#1a0d2e,stroke:#bc8cff,color:#bc8cff
style P fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style R1 fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style E fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style R2 fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style NS fill:#1a0d2e,stroke:#bc8cff,color:#bc8cff
style IR fill:#0d2d16,stroke:#3fb950,color:#3fb950
style D fill:#1a1a1a,stroke:#8b949e,color:#8b949e
```
의존성이 있는 태스크는 순차적으로 처리됩니다. 독립적인 태스크는 병렬로 실행됩니다. 태스크가 계획 단계에서 할 일로 이동하기 전에 수동 승인을 요구하도록 설정할 수 있습니다(`requirePlanApproval` 설정).
---
## 멀티 노드. 하나의 보드. 모든 플랫폼.
<div align="center">
<img src="./demo/assets/fusion-mesh.gif" alt="Fusion 메시: 노트북, Mac mini, Linux 서버, 클라우드 VM, 휴대폰 — 모두 동기화" width="820" />
<br />
![macOS](https://img.shields.io/badge/macOS-000000?style=for-the-badge&logo=apple&logoColor=white)
![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)
![Web](https://img.shields.io/badge/Web-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-000000?style=for-the-badge&logo=apple&logoColor=white)
![Android](https://img.shields.io/badge/Android-3DDC84?style=for-the-badge&logo=android&logoColor=white)
</div>
노트북, Mac mini, Linux 서버, 클라우드 VM, 휴대폰 — 모든 노드가 동등한 피어입니다. 태스크 상태, 에이전트, 로그, 차이점이 메시 전반에 걸쳐 동기화된 상태로 유지됩니다. 동일한 Fusion이 다음과 같이 제공됩니다:
- 🖥️ **데스크톱 앱** — **macOS**(Intel + Apple Silicon), **Windows** 10/11, **Linux**용 Electron
- 📱 **모바일 앱** — **iOS/iPadOS** 및 **Android**용 Capacitor ([MOBILE.md](./MOBILE.md))
- 🌐 **웹 대시보드** — `fn dashboard` 데몬에서 제공되는 모든 최신 브라우저
- 🔌 **CLI** — 터미널 중심 워크플로우를 위한 `fn` 바이너리 + 확장
임의의 노드에서 데몬을 시작하고 다른 기기를 연결하면, 보드가 어디서든 따라다닙니다.
---
## 에이전트 컴퍼니 운영
<div align="center">
<img src="./demo/assets/fusion-company-reel.gif" alt="Fusion 에이전트 컴퍼니: 팀을 임포트하여 몇 주 동안 자율적으로 운영" width="820" />
</div>
팀을 임포트하세요. 몇 주 동안 자율적으로 운영하세요. 미션, 메일박스, 에이전트 간 위임을 위해 연결된 **16개 컴퍼니에 걸쳐 440개 이상의 에이전트**.
```bash
npx companies.sh add paperclipai/companies/gstack
```
---
## 이미 사용 중인 도구와 호환됩니다.
Fusion은 여러분이 좋아하는 도구와 통합됩니다. **Hermes**, **Paperclip**, **OpenClaw**는 모두 일급 플러그인으로 제공되어 — 작업에 맞는 런타임으로 워크스페이스를 라우팅할 수 있습니다. 그리고 모든 Paperclip 에이전트 컴퍼니는 단일 명령으로 임포트됩니다.
<div align="center">
<img src="./demo/assets/hermes-logo.svg" alt="Hermes" height="56" />
</div>
### [Hermes](https://hermes-agent.nousresearch.com) <sub>`experimental`</sub>
<sub>Nous Research</sub>
**Nous Research**의 오픈 소스 자율 에이전트. Hermes 플러그인을 설치하고 장시간 실행되는 컨텍스트가 증가하는 작업에 Hermes를 통해 에이전트를 실행하세요 — 임의의 Fusion 워크스페이스를 라우팅할 수 있습니다.
### OpenClaw <sub>`experimental`</sub>
OpenClaw 런타임 지원은 런타임 탐색/설정 동등성을 위한 실험적 플러그인(`fusion-plugin-openclaw-runtime`)으로 제공됩니다. 플러그인을 설치한 후 `runtimeConfig.runtimeHint: "openclaw"`로 에이전트를 설정하세요.
<br />
<div align="center">
<img src="./demo/assets/paperclip-logo.svg" alt="Paperclip" height="56" />
</div>
### [Paperclip](https://paperclip.ing) <sub>`experimental`</sub>
<sub>paperclip.ing</sub>
AI 노동을 위한 사람 제어 플레인. Paperclip 플러그인을 설치하여 Fusion 내에서 Paperclip을 통해 에이전트를 실행하세요.
Fusion은 **[`companies.sh`](https://github.com/paperclipai/companies)** 에이전트 컴퍼니 표준을 네이티브로 지원합니다: 사전 구축된 팀 — **16개 컴퍼니에 걸쳐 440개 이상의 에이전트** — 을 임포트하고, 수 주 동안의 자율 작업을 위해 Fusion의 메일박스, 미션, 워크플로우 게이트를 통해 조율하게 하세요. Paperclip과 동일한 컴퍼니 형식, 동일한 에이전트, 동일한 스킬.
```bash
npx companies.sh add paperclipai/companies/gstack
```
<br />
> **Hermes**, **Paperclip**, **OpenClaw**는 **실험적** 런타임 플러그인입니다 — API와 와이어 형식은 마이너 릴리스 사이에 변경될 수 있습니다.
---
## 빠른 시작
**설치 없이 npm에서 바로:**
```bash
npx runfusion.ai
```
이 명령은 대시보드를 실행합니다. 하위 명령은 다음과 같이 전달됩니다: `npx runfusion.ai task create "fix X"`, `npx runfusion.ai --help` 등. (또는 명시적으로: `npx @runfusion/fusion dashboard`.)
**원라인 설치 프로그램** (macOS & Linux — Homebrew를 자동 선택하고, 없으면 npm으로 대체):
```bash
curl -fsSL https://runfusion.ai/install.sh | sh
fusion dashboard
```
**Homebrew** (macOS & Linux):
```bash
brew tap runfusion/fusion
brew install fusion
fusion dashboard # 또는: fn dashboard
```
또는 원라인(자동 탭 추가): `brew install runfusion/fusion/fusion`.
**npm 전역 설치**:
```bash
npm install -g @runfusion/fusion
fn dashboard # 또는: fusion dashboard
```
**클론으로 시작** (개발용):
```bash
pnpm dev dashboard
```
터미널에 출력되는 `Open:` URL을 클릭하세요. URL에는 베어러 토큰
(`http://localhost:4040/?token=fn_...`)이 포함되어 있으며, 브라우저가 첫 방문 시
`localStorage`에 캡처하여 이후 자동으로 재사용합니다. 서버 측에서 Fusion은
첫 번째 인증된 실행 시 `~/.fusion/settings.json`에 대시보드/데몬 토큰을
저장하고, 이후 시작 시 재사용합니다(`--token`, `FUSION_DASHBOARD_TOKEN`,
`FUSION_DAEMON_TOKEN`으로 재정의하거나 `--no-auth`로 인증을 비활성화하지 않는 한).
전체 우선순위 및 재설정/취소 옵션은
[CLI 참조 → fn dashboard → Authentication](./docs/cli-reference.md#fn-dashboard)을
참조하세요.
### 최초 실행 설정
Fusion을 처음 시작하면 세 단계로 안내하는 **온보딩 마법사**가 열립니다:
1. **AI 설정** — 간소화된 빠른 시작 공급자 목록(권장 공급자 및 이미 연결된 공급자)을 사용하고, 추가 공급자나 설정 세부 사항이 필요한 경우에만 **고급 공급자 설정**을 펼칩니다. 시작하려면 공급자 하나만 있으면 됩니다. 더 이상 사용되지 않는 Google Gemini CLI / Antigravity 공급자 항목은 의도적으로 숨겨져 있으며, Google/Gemini API 키, Google Generative AI, Vertex, Cloud Code 경로는 계속 지원됩니다.
2. **GitHub (선택 사항)** — 이슈 임포트 및 PR 관리를 위해 GitHub 연결
3. **첫 번째 태스크** — 첫 번째 태스크를 생성하거나 GitHub에서 임포트(활성 프로젝트가 없는 경우, 온보딩이 먼저 프로젝트 디렉터리 등록/선택을 안내합니다)
마법사는 **해제 가능하며 비차단적** — **지금 건너뛰기**를 클릭하면 즉시 대시보드를 사용할 수 있습니다. 나중에 **설정 → 인증 → 온보딩 가이드 다시 열기**에서 재실행할 수 있습니다.
### 모바일
Capacitor + PWA 워크플로우는 [MOBILE.md](./MOBILE.md)를 참조하세요.
---
## 문서
| 가이드 | 내용 |
|---|---|
| [시작하기](./docs/getting-started.md) | 설치 및 온보딩 |
| [대시보드 가이드](./docs/dashboard-guide.md) | 보드/목록 뷰, 터미널, git 관리자 |
| [태스크 관리](./docs/task-management.md) | 태스크 수명 주기 및 CLI 명령 |
| [CLI 참조](./docs/cli-reference.md) | 전체 명령 및 데몬 참조 |
| [설정 참조](./docs/settings-reference.md) | 구성 옵션 |
| [아키텍처](./docs/architecture.md) | 시스템 내부 구조 |
| [에이전트](./docs/agents.md) | 에이전트 관리, 스폰, 하트비트 |
| [워크플로우 단계](./docs/workflow-steps.md) | 품질 게이트, 템플릿, 단계 |
| [미션](./docs/missions.md) | 미션 계층 구조, 계획, 자동 조종 |
| [멀티 프로젝트](./docs/multi-project.md) | 중앙 레지스트리, 격리 모드 |
| [Docker](./docs/docker.md) | 컨테이너 배포 |
---
## 핵심 기능
- **AI 계획** — 계획 에이전트가 단계, 파일 범위, 완료 기준이 담긴 상세한 `PROMPT.md`를 생성합니다
- **단계별 실행** — 각 태스크 단계마다 계획 → 검토 → 실행 → 검토 주기를 진행합니다
- **Git 워크트리 격리** — 각 태스크는 자체 워크트리(`fusion/{task-id}` 브랜치)에서 실행됩니다
- **워크플로우 단계** — 구성 가능한 품질 게이트(사전 머지: 머지 차단; 사후 머지: 정보 제공)
- **GitHub 연동** — 이슈 임포트, PR 생성, 실시간 PR/이슈 배지
- **대시보드** — 실시간 칸반 보드, 에이전트 관리, 터미널, git 관리자, 미션 플래너
- **미션** — 계층적 계획(미션 → 마일스톤 → 슬라이스 → 기능 → 태스크), 자동 조종, 검증 계약, 수정-기능 재시도, 차단 핸드오프 시맨틱 포함
- **멀티 프로젝트** — 단일 설치에서 여러 프로젝트를 프로젝트 격리로 관리
- **에이전트 간 메시징** — 에이전트와 사용자 간 조율을 위한 내장 메시징
- **채팅 룸 (실험적)** — 언급된 구성원이 직접 응답자로 라우팅되고 추가 주변 구성원이 최대 한도까지 답할 수 있는 프로젝트 범위 그룹 채팅(**설정 → 실험적 기능 → 채팅 룸**에서 활성화; [대시보드 가이드 → 채팅 룸](./docs/dashboard-guide.md#chat-rooms)에서 자세히 확인)
### 공급자 인증
Fusion은 **설정 → 인증**을 통해 구성된 AI 공급자의 OAuth 기반 인증을 지원합니다. 대부분의 OAuth 공급자의 경우, 대시보드가 비 localhost 호스트(원격 노드, LAN 호스트/IP, 또는 리버스 프록시)를 통해 접근될 때, 공급자 로그인 URL이 OAuth 콜백을 브리지 엔드포인트(`/api/auth/oauth-callback`)를 통해 라우팅하도록 재작성되어 리다이렉트가 활성 브라우저 세션에 도달합니다.
- **Anthropic (Claude)** — 설정/온보딩에서 붙여넣기 인가 코드 흐름을 사용합니다: 로그인한 후 최종 리다이렉트 URL(또는 코드)을 Fusion에 붙여넣어 로그인을 완료합니다
- **OpenAI Codex** — 안전한 상태 검증이 포함된 동일한 붙여넣기 인가 코드 흐름을 사용합니다
- **Factory AI — Droid CLI 경유** *(선택 사항)* — 로컬 Droid CLI 설치 + `droid auth login` 필요; 탐지는 유효한 런타임 바이너리 경로를 따릅니다(기본값 `droid`, 또는 플러그인 설정 시 `droidBinaryPath`), 이후 **설정 → 인증**에서 활성화하고 Fusion을 재시작하세요
- **llama.cpp — HTTP 서버 경유** *(선택 사항)* — llama.cpp 서버 URL(기본값 `http://127.0.0.1:8080`)과 선택적 API 키를 설정한 후 **설정 → 인증**에서 활성화하세요
- **기타 공급자** — 설정에서 API 키 입력으로 인증합니다(Google/Gemini API 키, Google Generative AI, Vertex, Cloud Code 별칭 포함)
### 모델 시스템
Fusion은 다섯 개의 독립적인 레인을 가진 이중 범위 모델 계층 구조를 사용합니다. 전역 설정은 기준 기본값을 정의하고, 프로젝트 설정은 프로젝트별 재정의를 제공합니다.
| 레인 | 목적 | 전역 기준 키 | 프로젝트 재정의 키 |
|------|---------|---------------------|----------------------|
| Executor | 태스크 실행 에이전트 | `executionGlobalProvider` + `executionGlobalModelId` | `executionProvider` + `executionModelId` |
| Planning | 태스크 계획 에이전트 | `planningGlobalProvider` + `planningGlobalModelId` | `planningProvider` + `planningModelId` |
| Validator | 계획/코드 검토자 | `validatorGlobalProvider` + `validatorGlobalModelId` | `validatorProvider` + `validatorModelId` |
| Title Summarization | 자동 제목 생성 | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` |
| Workflow Step Refinement | AI 프롬프트 개선 | (`defaultProvider`/`defaultModelId` 사용) | (WorkflowStep의 `modelProvider`/`modelId` 사용) |
**태스크별 재정의:** 태스크는 태스크별 모델 필드(`modelProvider`/`modelId`, `validatorModelProvider`/`validatorModelId`, `planningModelProvider`/`planningModelId`)로 executor, validator, planning 레인을 재정의할 수 있습니다.
**우선순위:** 태스크별 → 프로젝트 재정의 → 전역 레인 → `defaultProvider`/`defaultModelId` → 자동 해결.
전체 설정 문서는 [설정 참조](./docs/settings-reference.md)를 참조하세요.
### 예약된 태스크 / 자동화
Fusion은 `/api/automations` 엔드포인트를 통해 예약된 태스크 자동화를 지원합니다. 자동화는 구성 가능한 일정에 따라 셸 명령 또는 다단계 워크플로우를 실행할 수 있습니다.
#### 스케줄링 범위
자동화와 루틴은 두 가지 범위에서 실행될 수 있습니다:
- **전역** — 모든 프로젝트에 걸쳐 실행됩니다. 프로젝트 간 유지 관리, 백업, 또는 통합 보고에 사용하세요.
- **프로젝트** — 특정 프로젝트 내에서만 실행됩니다. 프로젝트별 CI, 테스트, 또는 배포 태스크에 사용하세요.
범위를 선택하지 않고 일정을 생성하면, Fusion은 하위 호환성을 위해 `default` 프로젝트 ID로 **프로젝트 범위**를 기본값으로 사용합니다.
범위를 명시적으로 지정하려면:
- 대시보드 **예약된 태스크** 모달에서 **전역 / 프로젝트** 토글을 사용하세요.
- API를 통해서는 자동화/루틴 엔드포인트에 `?scope=global` 또는 `?scope=project&projectId=<id>`를 전달하세요.
**범위 해결 규칙:**
- `scope=global`은 항상 활성 프로젝트와 독립적으로 전역 자동화/루틴 레인으로 해결됩니다.
- `scope=project`는 `projectId`를 필요로 합니다. 생략하면 `"default"`로 대체됩니다.
- CRUD, 실행, 토글, 웹훅 작업은 엄격하게 범위 격리됩니다: 전역 일정은 프로젝트 범위 요청에서 변경될 수 없고, 그 반대도 마찬가지입니다.
**멀티 프로젝트 설정을 위한 운영 지침:**
- 공유 인프라(예: 야간 백업, 메모리 인사이트 추출)에는 **전역** 일정을 선호하세요.
- 저장소별 자동화(예: 프로젝트별 테스트 실행기, 배포 훅)에는 **프로젝트** 일정을 선호하세요.
- 전역 및 프로젝트 레인은 엔진에 의해 독립적으로 폴링되므로, 한 레인의 예정된 실행이 다른 레인을 차단하지 않습니다.
#### 자동화
| 엔드포인트 | 메서드 | 설명 |
|---------|--------|-------------|
| `/api/automations` | GET | 모든 자동화 목록 조회 (범위가 지정된 경우 필터링됨) |
| `/api/automations` | POST | 자동화 생성 (범위 기본값: `project`) |
| `/api/automations/:id` | GET | ID로 자동화 조회 |
| `/api/automations/:id` | PATCH | 자동화 업데이트 |
| `/api/automations/:id` | DELETE | 자동화 삭제 |
| `/api/automations/:id/run` | POST | 수동 실행 트리거 |
| `/api/automations/:id/toggle` | POST | 활성화/비활성화 토글 |
| `/api/automations/:id/steps/reorder` | POST | 자동화 단계 순서 변경 |
#### 루틴
루틴은 크론 일정, 웹훅, 또는 수동 실행으로 트리거되는 AI 에이전트 태스크입니다. 루틴은 자동화와 동일한 전역/프로젝트 범위 모델을 공유합니다.
| 엔드포인트 | 메서드 | 설명 |
|---------|--------|-------------|
| `/api/routines` | GET | 모든 루틴 목록 조회 (범위가 지정된 경우 필터링됨) |
| `/api/routines` | POST | 루틴 생성 (범위 기본값: `project`) |
| `/api/routines/:id` | GET | ID로 루틴 조회 |
| `/api/routines/:id` | PATCH | 루틴 업데이트 |
| `/api/routines/:id` | DELETE | 루틴 삭제 |
| `/api/routines/:id/run` | POST | 수동 트리거 |
| `/api/routines/:id/trigger` | POST | 정식 수동 트리거 |
| `/api/routines/:id/runs` | GET | 실행 기록 조회 |
| `/api/routines/:id/webhook` | POST | 웹훅 트리거 (서명 검증 지원) |
---
## CLI 빠른 예제
```bash
fn task create "Fix the login bug" # 빠른 입력 → 계획 단계
fn task plan "Build auth system" # AI 안내 계획 수립
fn task import owner/repo --labels bug # GitHub 이슈 임포트
fn task show FN-001 # 태스크 세부 정보 보기
fn task logs FN-001 --follow # 실행 로그 스트리밍
fn task steer FN-001 "Use TypeScript" # 실행 중 에이전트 방향 안내
fn project add my-app /path/to/app # 프로젝트 등록
fn project list # 모든 프로젝트 목록
fn settings set maxConcurrent 4 # 설정 구성
fn settings export # 설정 내보내기
fn mission create "Auth System" "Build auth" # 미션 생성
fn mission activate-slice <slice-id> # 슬라이스 활성화
fn skills search react # skills.sh 검색
fn skills install firebase/agent-skills # 에이전트 스킬 설치
```
---
## 패키지
| 패키지 | 설명 |
|---------|-------------|
| `@fusion/core` | 도메인 모델 — 태스크, 보드 컬럼, SQLite 저장소 |
| `@fusion/dashboard` | 웹 UI — Express 서버 + SSE가 포함된 칸반 보드 |
| `@fusion/engine` | AI 엔진 — 계획, 실행, 스케줄링, 워크플로우 단계 |
| `@runfusion/fusion` | CLI + 확장 — npm에 게시됨 |
---
## 개발
```bash
pnpm install # 의존성 설치
pnpm local # 비 4040 포트에서 로컬 대시보드/API 시작
pnpm local -- --engine # AI 엔진과 함께 로컬 대시보드 시작
pnpm build # 기본 워크스페이스 패키지 빌드 (데스크톱/모바일 제외)
pnpm build:all # 모든 패키지 빌드 (데스크톱/모바일 포함)
pnpm dev dashboard # 대시보드 + AI 엔진 실행
pnpm dev:ui # 대시보드만 실행 (AI 엔진 없음)
pnpm lint # 모든 패키지 린트
pnpm typecheck # 모든 패키지 타입 검사
pnpm test # 모든 테스트 실행
```
### 독립 실행 파일 빌드
[Bun](https://bun.sh/)을 사용하여 단일 자급자족 `fn` 바이너리를 빌드합니다:
```bash
pnpm build:exe # 현재 플랫폼용 빌드
pnpm build:exe:all # 모든 플랫폼용 크로스 컴파일
```
---
## 라이선스
MIT — 오픈 소스, 벤더 종속 없음. [LICENSE](./LICENSE) 참조.
<div align="center">
**[runfusion.ai →](https://runfusion.ai)**
</div>

View File

@@ -10,6 +10,8 @@
[**runfusion.ai →**](https://runfusion.ai) · [Docs](./docs/README.md) · [GitHub](https://github.com/Runfusion/Fusion) · [npm](https://www.npmjs.com/package/@runfusion/fusion) · [Discord](https://discord.gg/ksrfuy7WYR)
**English** · [简体中文](./README.zh-CN.md) · [繁體中文](./README.zh-TW.md) · [Français](./README.fr.md) · [Español](./README.es.md) · [한국어](./README.ko.md)
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](./LICENSE)
[![npm](https://img.shields.io/npm/v/@runfusion/fusion.svg?color=58a6ff)](https://www.npmjs.com/package/@runfusion/fusion)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/ksrfuy7WYR)

466
README.zh-CN.md Normal file
View File

@@ -0,0 +1,466 @@
<div align="center">
<img src="./demo/assets/fusion-logo.png" alt="Fusion" width="120" />
# Fusion
### 从粗糙想法到生产代码——全程自动化。
**多节点智能体编排器** — 任务、智能体、任务群、Git、文件与工作树,支持任意模型,本地与云端皆可。
[**runfusion.ai →**](https://runfusion.ai) · [文档](./docs/README.md) · [GitHub](https://github.com/Runfusion/Fusion) · [npm](https://www.npmjs.com/package/@runfusion/fusion) · [Discord](https://discord.gg/ksrfuy7WYR)
[English](./README.md) · **简体中文** · [繁體中文](./README.zh-TW.md) · [Français](./README.fr.md) · [Español](./README.es.md) · [한국어](./README.ko.md)
*本文档为社区机器翻译版本,英文 README 为权威原版。*
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](./LICENSE)
[![npm](https://img.shields.io/npm/v/@runfusion/fusion.svg?color=58a6ff)](https://www.npmjs.com/package/@runfusion/fusion)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/ksrfuy7WYR)
![Status](https://img.shields.io/badge/status-early%20preview-d29922.svg)
![Shipping](https://img.shields.io/badge/shipping-weekly-bc8cff.svg)
<br />
<img src="./demo/assets/fusion-reel.gif" alt="Fusion 演示:从粗糙想法到生产代码" width="900" />
<br />
<br />
<a href="https://runfusion.ai">
<img src="https://runfusion.ai/fusion-dashboard.png" alt="Fusion 仪表板:包含待规划、待办、进行中、审核中、已完成看板列及活跃任务卡片" width="900" />
</a>
</div>
---
## 你的整个开发环境。尽在一块面板之上。
用自然语言描述一个任务。规划智能体会读取你的项目、理解上下文,并撰写完整的 `PROMPT.md` 计划——包括步骤、文件范围和验收标准。随后 Fusion 在隔离的 Git 工作树中完成规划、审核、执行与再审核,你可以在任意环节设置人工审批门控。
一块看板。随处操控。笔记本、Mac mini、Linux 服务器、云虚拟机、手机——全部互联。
> 就像 Trello,只不过你的任务由 AI 来定义、执行和交付。基于 [dustinbyrne/kb](https://github.com/dustinbyrne/kb) 的出色工作构建。
---
## 工作流程
```
① 描述 ② 规划 ③ 看板 ④ 隔离工作树
───────────── ───────────── ───────────── ─────────────────────
"在设置面板 → 智能体撰写 → 规划 → 审核 → → fusion/FN-123 分支
添加深色模式 PROMPT.md 执行 → 审核 并行运行,零
切换按钮" (步骤、范围、 (逐步进行,直到 文件冲突
验收标准) 完成)
```
### 合并前看清每一步
<div align="center">
<img src="https://runfusion.ai/screenshot-task-detail.png" alt="Fusion 任务详情:进行中的任务显示工作流步骤、差异对比和文件变更" width="820" />
</div>
每个任务都会实时展示其计划、审核记录、差异对比和文件变更。你可以随时介入活跃任务,调整方向、收紧约束、暂停执行或重新提示。
---
## 与众不同之处
| | |
|---|---|
| 🧠 **AI 规划** | 用自然语言描述任务。规划智能体将其转化为包含步骤、文件范围和验收标准的 `PROMPT.md` 计划。 |
| 🔁 **工作流门控** | 每个步骤均经历:规划 → 审核 → 执行 → 审核。合并前门控阻止劣质代码,合并后门控执行信息性检查。 |
| 🌳 **工作树隔离** | 每个任务在独立分支和工作树(`fusion/{task-id}`)中运行,支持并行任务,零冲突。可通过 [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings) 选择性启用 [worktrunk](https://github.com/max-sixty/worktrunk) 委托(参见 [WorktreeBackend 抽象](./docs/architecture.md#worktreebackend-abstraction))。 |
| ⚡ **智能合并** | 通过所有门控后,Fusion 自动压缩合并并继续推进。你也可以在任意环节开启手动审批。 |
| 🛰️ **多节点网格** | 笔记本、Mac mini、Linux 服务器、云虚拟机、手机——全部同步。桌面端、移动端、Web 端均支持。 |
| 🧩 **任意模型** | 支持 Anthropic、OpenAI、Ollama 等,本地与云端并存。 |
| 🏢 **智能体公司** | 导入预构建团队——16 家公司共 440+ 个智能体——自主运行数周。 |
| 📬 **智能体间消息** | 内置智能体间邮箱,支持委派、澄清与协调。 |
| 🗨️ **多智能体聊天室** | 项目范围内的群组会话,多位成员可以回复:被提及成员为直接响应者,其他旁听成员在上限内也可参与回复。当前为**实验性**功能——在**设置 → 实验性功能 → 聊天室**中启用 `chatRooms`。([聊天室文档](./docs/dashboard-guide.md#chat-rooms)) |
| 🗺️ **任务群** | 层级式规划(任务群 → 里程碑 → 切片 → 功能 → 任务),支持自动驾驶和验证契约。 |
| 🔬 **调研** | 有边界的调研运行,集成网络搜索、GitHub、本地文档和 LLM 综合分析(规划与综合流程中还支持运行时内置 WebSearch/WebFetch)。将调研发现直接转化为任务。([文档](./docs/research.md)) |
| 🧪 **自我改进** | 智能体反思自身输出,并在熟悉你的代码库后持续更新其提示词。 |
| 🔓 **开源,MIT 协议。** | 无供应商锁定。可在自有硬件上运行。每周持续更新。 |
---
## 工作原理
```mermaid
graph TD
H((你)) -->|粗糙想法| T["规划中<br/><i>自动规划</i>"]
T --> TD["待办<br/><i>已安排执行</i>"]
TD --> IP["进行中<br/><i>每步骤:<br/>规划、审核、执行、审核</i>"]
subgraph IP["进行中"]
direction TD
NS([开始步骤]) --> P[规划]
P --> R1{审核}
R1 -->|修改| P
R1 -->|批准| E[执行]
E --> R2{审核}
R2 -->|修改| E
R2 -->|下一步| NS
R2 -->|重新规划| P
end
R2 -->|完成| IR["审核中<br/><i>待合并,<br/>或自动完成</i>"]
IR -->|直接压缩合并<br/>或 PR 合并| D["已完成"]
style H fill:#161b22,stroke:#8b949e,color:#e6edf3
style T fill:#2d2006,stroke:#d29922,color:#d29922
style TD fill:#0d2044,stroke:#58a6ff,color:#58a6ff
style IP fill:#1a0d2e,stroke:#bc8cff,color:#bc8cff
style P fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style R1 fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style E fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style R2 fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style NS fill:#1a0d2e,stroke:#bc8cff,color:#bc8cff
style IR fill:#0d2d16,stroke:#3fb950,color:#3fb950
style D fill:#1a1a1a,stroke:#8b949e,color:#8b949e
```
有依赖关系的任务按顺序处理,相互独立的任务并行运行。可选择在任务从规划移至待办前要求手动审批(`requirePlanApproval` 设置)。
---
## 多节点。一块看板。全平台覆盖。
<div align="center">
<img src="./demo/assets/fusion-mesh.gif" alt="Fusion 网格:笔记本、Mac mini、Linux 服务器、云虚拟机、手机——全部同步" width="820" />
<br />
![macOS](https://img.shields.io/badge/macOS-000000?style=for-the-badge&logo=apple&logoColor=white)
![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)
![Web](https://img.shields.io/badge/Web-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-000000?style=for-the-badge&logo=apple&logoColor=white)
![Android](https://img.shields.io/badge/Android-3DDC84?style=for-the-badge&logo=android&logoColor=white)
</div>
笔记本、Mac mini、Linux 服务器、云虚拟机、手机——每个节点都是对等方。你的任务状态、智能体、日志和差异对比在整个网格中保持同步。同一个 Fusion 提供以下形态:
- 🖥️ **桌面应用** — 基于 Electron,支持 **macOS**(Intel + Apple Silicon)、**Windows** 10/11 和 **Linux**
- 📱 **移动应用** — 基于 Capacitor,支持 **iOS/iPadOS** 和 **Android**([MOBILE.md](./MOBILE.md))
- 🌐 **Web 仪表板** — 任意现代浏览器,由 `fn dashboard` 守护进程提供服务
- 🔌 **CLI** — `fn` 二进制文件 + 扩展,面向终端优先的工作流
在任意节点启动守护进程,连接其他设备,看板随你所在。
---
## 运行一个智能体公司
<div align="center">
<img src="./demo/assets/fusion-company-reel.gif" alt="Fusion 智能体公司:导入团队,自主运行数周" width="820" />
</div>
导入一个团队,自主运行数周。**16 家公司共 440+ 个智能体**,预置了任务群、邮箱和智能体间委派机制。
```bash
npx companies.sh add paperclipai/companies/gstack
```
---
## 与你已在使用的工具兼容。
Fusion 与你喜爱的工具深度集成。**Hermes**、**Paperclip** 和 **OpenClaw** 均作为一等公民插件发布——将任意工作区路由到最适合该任务的运行时。任何 Paperclip 智能体公司均可通过单条命令导入。
<div align="center">
<img src="./demo/assets/hermes-logo.svg" alt="Hermes" height="56" />
</div>
### [Hermes](https://hermes-agent.nousresearch.com) <sub>`experimental`</sub>
<sub>Nous Research</sub>
**Nous Research** 出品的开源自主智能体。安装 Hermes 插件后,可通过 Hermes 运行智能体以处理长期运行、上下文持续增长的工作——将任意 Fusion 工作区路由至其上。
### OpenClaw <sub>`experimental`</sub>
OpenClaw 运行时支持以实验性插件(`fusion-plugin-openclaw-runtime`)的形式提供,用于运行时发现与配置对等。安装插件后,使用 `runtimeConfig.runtimeHint: "openclaw"` 配置智能体。
<br />
<div align="center">
<img src="./demo/assets/paperclip-logo.svg" alt="Paperclip" height="56" />
</div>
### [Paperclip](https://paperclip.ing) <sub>`experimental`</sub>
<sub>paperclip.ing</sub>
AI 劳动力的人工控制平面。安装 Paperclip 插件后,可在 Fusion 内部通过 Paperclip 运行智能体。
Fusion 还原生支持 **[`companies.sh`](https://github.com/paperclipai/companies)** 智能体公司标准:导入预构建团队——**16 家公司共 440+ 个智能体**——让它们通过 Fusion 的邮箱、任务群和工作流门控协作,自主运行数周。与 Paperclip 共用相同的公司格式、相同的智能体和相同的技能。
```bash
npx companies.sh add paperclipai/companies/gstack
```
<br />
> **Hermes**、**Paperclip** 和 **OpenClaw** 均为**实验性**运行时插件——API 和通信格式可能在次要版本间发生变更。
---
## 快速上手
**无需安装,直接通过 npm 运行:**
```bash
npx runfusion.ai
```
这将启动仪表板。子命令可透传:`npx runfusion.ai task create "fix X"`、`npx runfusion.ai --help` 等(或完整写法:`npx @runfusion/fusion dashboard`)。
**一键安装脚本**(macOS 和 Linux——自动选用 Homebrew,失败则回退到 npm):
```bash
curl -fsSL https://runfusion.ai/install.sh | sh
fusion dashboard
```
**Homebrew**(macOS 和 Linux):
```bash
brew tap runfusion/fusion
brew install fusion
fusion dashboard # 或:fn dashboard
```
或使用一行命令(自动添加 tap):`brew install runfusion/fusion/fusion`。
**npm 全局安装**:
```bash
npm install -g @runfusion/fusion
fn dashboard # 或:fusion dashboard
```
**从克隆仓库启动**(用于开发):
```bash
pnpm dev dashboard
```
然后点击终端输出的 `Open:` URL。该 URL 内嵌了一个持有者令牌
(`http://localhost:4040/?token=fn_...`),浏览器首次访问时会将其捕获并存入
`localStorage`,此后自动复用。在服务端,Fusion 会在首次经过身份验证的运行时将
仪表板/守护进程令牌持久化至 `~/.fusion/settings.json`,并在后续启动时复用,
除非你通过 `--token`、`FUSION_DASHBOARD_TOKEN`、`FUSION_DAEMON_TOKEN` 覆盖,
或使用 `--no-auth` 禁用鉴权。完整的优先级规则及重置/吊销选项,请参见
[CLI 参考 → fn dashboard → 身份验证](./docs/cli-reference.md#fn-dashboard)。
### 首次运行向导
首次启动时,Fusion 会打开**引导向导**,分三步引导:
1. **AI 配置** — 使用简化的快速启动提供商列表(推荐提供商加上已连接的提供商),如需添加更多提供商或查看详细设置,展开**高级提供商设置**即可。入门只需一个提供商。已弃用的 Google Gemini CLI / Antigravity 提供商条目已被有意隐藏;Google/Gemini API 密钥、Google Generative AI、Vertex 和 Cloud Code 路径仍受支持。
2. **GitHub(可选)** — 连接 GitHub 以导入 Issue 和管理 PR
3. **第一个任务** — 创建你的第一个任务,或从 GitHub 导入(若当前无活跃项目,引导向导会先提示你注册/选择项目目录)
向导**可关闭且不阻塞**——点击**暂时跳过**即可立即使用仪表板。稍后可从**设置 → 身份验证 → 重新打开引导向导**再次触发。
### 移动端
Capacitor + PWA 工作流,请参见 [MOBILE.md](./MOBILE.md)。
---
## 文档
| 指南 | 内容 |
|---|---|
| [入门指南](./docs/getting-started.md) | 安装与引导 |
| [仪表板指南](./docs/dashboard-guide.md) | 看板/列表视图、终端、Git 管理器 |
| [任务管理](./docs/task-management.md) | 任务生命周期与 CLI 命令 |
| [CLI 参考](./docs/cli-reference.md) | 完整命令与守护进程参考 |
| [设置参考](./docs/settings-reference.md) | 配置选项 |
| [架构](./docs/architecture.md) | 系统内部机制 |
| [智能体](./docs/agents.md) | 智能体管理、生成与心跳 |
| [工作流步骤](./docs/workflow-steps.md) | 质量门控、模板与阶段 |
| [任务群](./docs/missions.md) | 任务群层级、规划与自动驾驶 |
| [多项目](./docs/multi-project.md) | 中央注册表与隔离模式 |
| [Docker](./docs/docker.md) | 容器部署 |
---
## 核心功能
- **AI 规划** — 规划智能体生成包含步骤、文件范围和验收标准的详细 `PROMPT.md`
- **逐步执行** — 每个任务步骤均经历规划 → 审核 → 执行 → 审核循环
- **Git 工作树隔离** — 每个任务在独立工作树(`fusion/{task-id}` 分支)中运行
- **工作流步骤** — 可配置的质量门控(合并前:阻止合并;合并后:信息性检查)
- **GitHub 集成** — 导入 Issue、创建 PR、实时 PR/Issue 徽章
- **仪表板** — 实时看板、智能体管理、终端、Git 管理器、任务群规划器
- **任务群** — 层级式规划(任务群 → 里程碑 → 切片 → 功能 → 任务),支持自动驾驶、验证契约、修复功能重试和阻塞移交语义
- **多项目** — 从单一安装管理多个项目,项目间相互隔离
- **智能体间消息** — 内置消息机制,用于智能体与用户之间的协调
- **聊天室(实验性)** — 项目范围内的群组聊天,被提及成员作为直接响应者路由,其他旁听成员在上限内可回复(通过**设置 → 实验性功能 → 聊天室**启用;详情见[仪表板指南 → 聊天室](./docs/dashboard-guide.md#chat-rooms))
### 提供商身份验证
Fusion 支持通过**设置 → 身份验证**为 AI 提供商配置基于 OAuth 的身份验证。对于大多数 OAuth 提供商,当仪表板通过非 localhost 主机访问(远程节点、局域网主机/IP 或反向代理)时,提供商登录 URL 会被重写,通过桥接端点(`/api/auth/oauth-callback`)路由 OAuth 回调,以确保重定向能到达活跃的浏览器会话。
- **Anthropic (Claude)** — 在设置/引导向导中使用粘贴授权码流程:登录后,将最终重定向 URL(或授权码)粘贴回 Fusion 以完成登录
- **OpenAI Codex** — 使用相同的粘贴授权码流程,附带安全状态验证
- **Factory AI — 通过 Droid CLI** *(可选)* — 需要本地安装 Droid CLI 并执行 `droid auth login`;检测遵循有效运行时二进制路径(默认为 `droid`,或配置了插件 `droidBinaryPath` 时使用该路径),然后在**设置 → 身份验证**中启用并重启 Fusion
- **llama.cpp — 通过 HTTP 服务器** *(可选)* — 配置你的 llama.cpp 服务器 URL(默认 `http://127.0.0.1:8080`)和可选 API 密钥,然后在**设置 → 身份验证**中启用
- **其他提供商** — 在设置中通过 API 密钥条目进行身份验证(包括 Google/Gemini API 密钥、Google Generative AI、Vertex 和 Cloud Code 别名)
### 模型系统
Fusion 使用双作用域模型层级,包含五条独立通道。全局设置定义基准默认值,项目设置提供每个项目的覆盖配置。
| 通道 | 用途 | 全局基准键 | 项目覆盖键 |
|------|---------|---------------------|----------------------|
| 执行器 | 任务执行智能体 | `executionGlobalProvider` + `executionGlobalModelId` | `executionProvider` + `executionModelId` |
| 规划器 | 任务规划智能体 | `planningGlobalProvider` + `planningGlobalModelId` | `planningProvider` + `planningModelId` |
| 验证器 | 计划/代码审核 | `validatorGlobalProvider` + `validatorGlobalModelId` | `validatorProvider` + `validatorModelId` |
| 标题摘要 | 自动标题生成 | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` |
| 工作流步骤优化 | AI 提示词优化 | (使用 `defaultProvider`/`defaultModelId`) | (使用 WorkflowStep 上的 `modelProvider`/`modelId`) |
**任务级覆盖:** 任务可通过任务级模型字段(`modelProvider`/`modelId`、`validatorModelProvider`/`validatorModelId`、`planningModelProvider`/`planningModelId`)覆盖执行器、验证器和规划器通道。
**优先级:** 任务级 → 项目覆盖 → 全局通道 → `defaultProvider`/`defaultModelId` → 自动解析。
完整设置文档,请参见[设置参考](./docs/settings-reference.md)。
### 计划任务 / 自动化
Fusion 通过 `/api/automations` 端点支持计划任务自动化。自动化任务可按可配置的计划运行 Shell 命令或多步骤工作流。
#### 调度范围
自动化任务和例程可在两种范围内运行:
- **全局** — 跨所有项目运行。适用于跨项目维护、备份或统一报告。
- **项目** — 仅在特定项目内运行。适用于项目特定的 CI、测试或部署任务。
创建计划时若未选择范围,Fusion 默认使用 **project 范围**并以 `default` 项目 ID,以保持向后兼容。
显式指定范围的方式:
- 在仪表板的**计划任务**模态框中,使用**全局 / 项目**切换开关。
- 通过 API,在自动化/例程端点上传递 `?scope=global` 或 `?scope=project&projectId=<id>`。
**范围解析规则:**
- `scope=global` 始终解析到全局自动化/例程通道,与活跃项目无关。
- `scope=project` 需要 `projectId`。若省略,则回退到 `"default"`。
- 增删改查、运行、切换和 Webhook 操作严格按范围隔离:全局计划不能通过项目范围请求修改,反之亦然。
**多项目环境操作建议:**
- 共享基础设施(如夜间备份、记忆洞察提取)优先使用**全局**计划。
- 仓库级自动化(如每项目测试运行器、部署钩子)优先使用**项目**计划。
- 全局通道和项目通道由引擎独立轮询,一个通道中到期的运行不会阻塞另一个。
#### 自动化任务
| 端点 | 方法 | 说明 |
|---------|--------|-------------|
| `/api/automations` | GET | 列出所有自动化任务(若指定范围则按范围过滤) |
| `/api/automations` | POST | 创建自动化任务(范围默认为 `project`) |
| `/api/automations/:id` | GET | 按 ID 获取自动化任务 |
| `/api/automations/:id` | PATCH | 更新自动化任务 |
| `/api/automations/:id` | DELETE | 删除自动化任务 |
| `/api/automations/:id/run` | POST | 触发手动运行 |
| `/api/automations/:id/toggle` | POST | 切换启用/禁用 |
| `/api/automations/:id/steps/reorder` | POST | 重排自动化步骤顺序 |
#### 例程
例程是由 Cron 计划、Webhook 或手动执行触发的 AI 智能体任务。例程与自动化任务共用相同的全局/项目范围模型。
| 端点 | 方法 | 说明 |
|---------|--------|-------------|
| `/api/routines` | GET | 列出所有例程(若指定范围则按范围过滤) |
| `/api/routines` | POST | 创建例程(范围默认为 `project`) |
| `/api/routines/:id` | GET | 按 ID 获取例程 |
| `/api/routines/:id` | PATCH | 更新例程 |
| `/api/routines/:id` | DELETE | 删除例程 |
| `/api/routines/:id/run` | POST | 手动触发 |
| `/api/routines/:id/trigger` | POST | 规范化手动触发 |
| `/api/routines/:id/runs` | GET | 获取执行历史 |
| `/api/routines/:id/webhook` | POST | Webhook 触发(支持签名验证) |
---
## CLI 快速示例
```bash
fn task create "Fix the login bug" # 快速录入 → 规划
fn task plan "Build auth system" # AI 辅助规划
fn task import owner/repo --labels bug # 导入 GitHub Issue
fn task show FN-001 # 查看任务详情
fn task logs FN-001 --follow # 流式查看执行日志
fn task steer FN-001 "Use TypeScript" # 在执行中途引导智能体
fn project add my-app /path/to/app # 注册项目
fn project list # 列出所有项目
fn settings set maxConcurrent 4 # 配置设置
fn settings export # 导出配置
fn mission create "Auth System" "Build auth" # 创建任务群
fn mission activate-slice <slice-id> # 激活切片
fn skills search react # 搜索 skills.sh
fn skills install firebase/agent-skills # 安装智能体技能
```
---
## 包结构
| 包 | 说明 |
|---------|-------------|
| `@fusion/core` | 领域模型——任务、看板列、SQLite 存储 |
| `@fusion/dashboard` | Web UI——Express 服务器 + 带 SSE 的看板 |
| `@fusion/engine` | AI 引擎——规划、执行、调度、工作流步骤 |
| `@runfusion/fusion` | CLI + 扩展——发布至 npm |
---
## 开发
```bash
pnpm install # 安装依赖
pnpm local # 在非 4040 端口启动本地仪表板/API
pnpm local -- --engine # 启动带 AI 引擎的本地仪表板
pnpm build # 构建默认工作区包(不含桌面端/移动端)
pnpm build:all # 构建所有包(含桌面端/移动端)
pnpm dev dashboard # 运行仪表板 + AI 引擎
pnpm dev:ui # 仅仪表板(无 AI 引擎)
pnpm lint # 对所有包执行代码检查
pnpm typecheck # 对所有包执行类型检查
pnpm test # 运行所有测试
```
### 构建独立可执行文件
使用 [Bun](https://bun.sh/) 构建单个自包含的 `fn` 二进制文件:
```bash
pnpm build:exe # 为当前平台构建
pnpm build:exe:all # 跨平台编译所有目标
```
---
## 许可证
MIT — 开源,无供应商锁定。详见 [LICENSE](./LICENSE)。
<div align="center">
**[runfusion.ai →](https://runfusion.ai)**
</div>

467
README.zh-TW.md Normal file
View File

@@ -0,0 +1,467 @@
<div align="center">
<img src="./demo/assets/fusion-logo.png" alt="Fusion" width="120" />
# Fusion
### 從粗略想法到正式上線的程式碼——全自動完成。
**多節點代理人協調器** — 任務、代理人、任務群組、git、檔案與工作樹,支援任何模型,本地或雲端皆可。
[**runfusion.ai →**](https://runfusion.ai) · [文件](./docs/README.md) · [GitHub](https://github.com/Runfusion/Fusion) · [npm](https://www.npmjs.com/package/@runfusion/fusion) · [Discord](https://discord.gg/ksrfuy7WYR)
[English](./README.md) · [简体中文](./README.zh-CN.md) · **繁體中文** · [Français](./README.fr.md) · [Español](./README.es.md) · [한국어](./README.ko.md)
*本文件為機器翻譯,英文版 README 為權威版本。*
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](./LICENSE)
[![npm](https://img.shields.io/npm/v/@runfusion/fusion.svg?color=58a6ff)](https://www.npmjs.com/package/@runfusion/fusion)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/ksrfuy7WYR)
![Status](https://img.shields.io/badge/status-early%20preview-d29922.svg)
![Shipping](https://img.shields.io/badge/shipping-weekly-bc8cff.svg)
<br />
<img src="./demo/assets/fusion-reel.gif" alt="Fusion 展示影片:從粗略想法到正式上線的程式碼" width="900" />
<br />
<br />
<a href="https://runfusion.ai">
<img src="https://runfusion.ai/fusion-dashboard.png" alt="Fusion 儀表板:規劃中、待辦、進行中、審閱中、已完成看板欄位,以及作用中的任務卡" width="900" />
</a>
</div>
---
## 整個開發環境。盡在一個畫面。
用白話文描述一個任務。規劃代理人讀取你的專案、理解脈絡,並撰寫完整的 `PROMPT.md` 計畫——包含步驟、檔案範圍與驗收條件。接著 Fusion 在獨立的 git 工作樹中依序規劃、審閱、執行、再審閱,並在你指定的任何環節設置人工核准關卡。
一個看板。從任何地方操控。筆電、Mac mini、Linux 伺服器、雲端虛擬機、手機——全部連線。
> 就像 Trello,但你的任務由 AI 負責規格撰寫、執行與交付。基於 [dustinbyrne/kb](https://github.com/dustinbyrne/kb) 的優秀成果打造。
---
## 工作流程
```
① 描述 ② 規劃 ③ 看板 ④ 獨立工作樹
───────────── ───────────── ───────────── ─────────────────────
「在設定面板 → 代理人撰寫 → 規劃 → 審閱 → → fusion/FN-123 分支
加入深色模式 PROMPT.md 執行 → 審閱 並行執行,零
切換開關」 (步驟、範圍、 (每步驟,直到 檔案衝突
驗收條件) 完成)
```
### 合併前看清每個步驟
<div align="center">
<img src="https://runfusion.ai/screenshot-task-detail.png" alt="Fusion 任務詳情:進行中的任務顯示工作流程步驟、差異比較與檔案變更" width="820" />
</div>
每個任務都即時顯示其計畫、審閱紀錄、差異比較與檔案變更。進入作用中的任務,調整方向、收緊限制條件、暫停或重新提示。
---
## 與眾不同之處
| | |
|---|---|
| 🧠 **AI 規劃** | 用白話文描述任務。規劃代理人將其轉換為含步驟、檔案範圍與驗收條件的 `PROMPT.md` 計畫。 |
| 🔁 **工作流程關卡** | 每個步驟皆執行:規劃 → 審閱 → 執行 → 審閱。合併前關卡阻擋劣質程式碼;合併後關卡執行資訊性檢查。 |
| 🌳 **工作樹隔離** | 每個任務在各自的分支與工作樹(`fusion/{task-id}`)中執行。任務並行執行,零衝突。可選用 [worktrunk](https://github.com/max-sixty/worktrunk) 委派,透過 [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings) 設定(詳見 [WorktreeBackend 抽象層](./docs/architecture.md#worktreebackend-abstraction))。 |
| ⚡ **智慧合併** | 通過所有關卡後,Fusion 自動壓縮合併並繼續執行。可在任何環節選擇手動核准。 |
| 🛰️ **多節點網狀架構** | 筆電、Mac mini、Linux 伺服器、雲端虛擬機、手機——全部同步。桌面、行動裝置、網頁皆支援。 |
| 🧩 **任意模型** | 支援 Anthropic、OpenAI、Ollama 等。本地與雲端模型共存。 |
| 🏢 **代理人公司** | 匯入預建團隊——橫跨 16 家公司的 440+ 個代理人——自主運行數週。 |
| 📬 **代理人間訊息傳遞** | 代理人之間內建郵件信箱。委派、釐清、協調。 |
| 🗨️ **多代理人聊天室** | 專案範圍的群組對話,多位成員可回覆:被提及的成員為直接回應者,其餘環境成員最多可回應至上限。目前為**實驗性功能**——在**設定 → 實驗性功能 → 聊天室**中啟用 `chatRooms`。([聊天室文件](./docs/dashboard-guide.md#chat-rooms)) |
| 🗺️ **任務群組** | 層級式規劃(任務群組 → 里程碑 → 切片 → 功能 → 任務),具備自動駕駛模式與驗證合約。 |
| 🔬 **研究** | 有界研究執行,整合網頁搜尋、GitHub、本地文件與 LLM 合成(規劃與合成流程中亦支援執行時內建的 WebSearch/WebFetch)。將研究結果轉換為任務。([文件](./docs/research.md)) |
| 🧪 **自我改善** | 代理人反思自身輸出,並隨著對你的程式碼庫的了解更新自身提示詞。 |
| 🔓 **開放原始碼,MIT 授權。** | 無廠商綁定。在自己的硬體上執行。每週持續更新。 |
---
## 運作原理
```mermaid
graph TD
H((你)) -->|粗略想法| T["規劃中<br/><i>自動規劃</i>"]
T --> TD["待辦<br/><i>排定執行</i>"]
TD --> IP["進行中<br/><i>每個步驟:<br/>規劃、審閱、執行、審閱</i>"]
subgraph IP["進行中"]
direction TD
NS([開始步驟]) --> P[規劃]
P --> R1{審閱}
R1 -->|修訂| P
R1 -->|核准| E[執行]
E --> R2{審閱}
R2 -->|修訂| E
R2 -->|下一步驟| NS
R2 -->|重新思考| P
end
R2 -->|完成| IR["審閱中<br/><i>準備合併<br/>或自動完成</i>"]
IR -->|直接壓縮合併<br/>或已合併的 PR| D["已完成"]
style H fill:#161b22,stroke:#8b949e,color:#e6edf3
style T fill:#2d2006,stroke:#d29922,color:#d29922
style TD fill:#0d2044,stroke:#58a6ff,color:#58a6ff
style IP fill:#1a0d2e,stroke:#bc8cff,color:#bc8cff
style P fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style R1 fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style E fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style R2 fill:#1a0d2e,stroke:#bc8cff,color:#e6edf3
style NS fill:#1a0d2e,stroke:#bc8cff,color:#bc8cff
style IR fill:#0d2d16,stroke:#3fb950,color:#3fb950
style D fill:#1a1a1a,stroke:#8b949e,color:#8b949e
```
有相依關係的任務依序處理;獨立任務並行執行。可選擇在任務從「規劃中」移至「待辦」前要求手動核准(`requirePlanApproval` 設定)。
---
## 多節點。一個看板。全平台支援。
<div align="center">
<img src="./demo/assets/fusion-mesh.gif" alt="Fusion 網狀架構:筆電、Mac mini、Linux 伺服器、雲端虛擬機、手機——全部同步" width="820" />
<br />
![macOS](https://img.shields.io/badge/macOS-000000?style=for-the-badge&logo=apple&logoColor=white)
![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)
![Web](https://img.shields.io/badge/Web-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-000000?style=for-the-badge&logo=apple&logoColor=white)
![Android](https://img.shields.io/badge/Android-3DDC84?style=for-the-badge&logo=android&logoColor=white)
</div>
筆電、Mac mini、Linux 伺服器、雲端虛擬機、手機——每個節點都是對等節點。你的任務狀態、代理人、日誌與差異比較在整個網狀架構中保持同步。同一個 Fusion 以下列形式發布:
- 🖥️ **桌面應用程式** — 支援 **macOS**(Intel + Apple Silicon)、**Windows** 10/11 與 **Linux** 的 Electron 應用程式
- 📱 **行動應用程式** — 支援 **iOS/iPadOS** 與 **Android** 的 Capacitor 應用程式([MOBILE.md](./MOBILE.md))
- 🌐 **網頁儀表板** — 任何現代瀏覽器,由 `fn dashboard` 背景程式提供服務
- 🔌 **命令列介面** — `fn` 執行檔 + 適合以終端機為主要工作流程的擴充功能
在任意節點啟動背景程式,連接其他裝置,看板就會跟著你到任何地方。
---
## 執行代理人公司
<div align="center">
<img src="./demo/assets/fusion-company-reel.gif" alt="Fusion 代理人公司:匯入團隊,自主執行數週" width="820" />
</div>
匯入一個團隊,自主執行數週。**橫跨 16 家公司的 440+ 個代理人**,專為任務群組、信箱與代理人間委派而設計。
```bash
npx companies.sh add paperclipai/companies/gstack
```
---
## 與你已在使用的工具相容。
Fusion 整合你喜愛的工具。**Hermes**、**Paperclip** 與 **OpenClaw** 皆作為一等公民外掛程式發布——將任何工作區路由至最適合該任務的執行環境。任何 Paperclip 代理人公司都能以單一指令匯入。
<div align="center">
<img src="./demo/assets/hermes-logo.svg" alt="Hermes" height="56" />
</div>
### [Hermes](https://hermes-agent.nousresearch.com) <sub>`experimental`</sub>
<sub>Nous Research</sub>
來自 **Nous Research** 的開放原始碼自主代理人。安裝 Hermes 外掛程式,透過 Hermes 執行代理人,適合需要長時間執行、上下文持續增長的工作——可將任何 Fusion 工作區路由至 Hermes。
### OpenClaw <sub>`experimental`</sub>
OpenClaw 執行環境支援作為實驗性外掛程式(`fusion-plugin-openclaw-runtime`)提供,可進行執行環境探索與設定對等。安裝外掛程式後,以 `runtimeConfig.runtimeHint: "openclaw"` 設定代理人。
<br />
<div align="center">
<img src="./demo/assets/paperclip-logo.svg" alt="Paperclip" height="56" />
</div>
### [Paperclip](https://paperclip.ing) <sub>`experimental`</sub>
<sub>paperclip.ing</sub>
AI 勞動力的人類控制平面。安裝 Paperclip 外掛程式,在 Fusion 內透過 Paperclip 執行代理人。
Fusion 也原生支援 **[`companies.sh`](https://github.com/paperclipai/companies)** 代理人公司標準:匯入預建團隊——**橫跨 16 家公司的 440+ 個代理人**——讓他們透過 Fusion 的信箱、任務群組與工作流程關卡協調作業,自主工作數週。相同的公司格式、相同的代理人、相同的技能,與 Paperclip 一致。
```bash
npx companies.sh add paperclipai/companies/gstack
```
<br />
> **Hermes**、**Paperclip** 與 **OpenClaw** 為**實驗性**執行環境外掛程式——API 與通訊格式可能在次要版本之間有所變動。
---
## 快速開始
**免安裝,直接從 npm 執行:**
```bash
npx runfusion.ai
```
這會啟動儀表板。子指令可直接傳遞:`npx runfusion.ai task create "fix X"`、`npx runfusion.ai --help` 等。(或完整形式:`npx @runfusion/fusion dashboard`。)
**單行安裝程式**(macOS 與 Linux——自動選用 Homebrew,若無則退回 npm):
```bash
curl -fsSL https://runfusion.ai/install.sh | sh
fusion dashboard
```
**Homebrew**(macOS 與 Linux):
```bash
brew tap runfusion/fusion
brew install fusion
fusion dashboard # 或:fn dashboard
```
或使用單行指令(自動新增 tap):`brew install runfusion/fusion/fusion`。
**npm 全域安裝**:
```bash
npm install -g @runfusion/fusion
fn dashboard # 或:fusion dashboard
```
**從複本執行**(供開發使用):
```bash
pnpm dev dashboard
```
然後點擊終端機中顯示的 `Open:` 網址。該網址內嵌一個不記名令牌
(`http://localhost:4040/?token=fn_...`),瀏覽器會在首次造訪時擷取並存入
`localStorage`,之後自動重複使用。在伺服器端,Fusion 會在首次驗證執行時
將儀表板與背景程式令牌持久化至 `~/.fusion/settings.json`,並在後續啟動時
重複使用,除非你覆蓋它(`--token`、`FUSION_DASHBOARD_TOKEN`、
`FUSION_DAEMON_TOKEN`)或以 `--no-auth` 停用驗證。完整的優先順序與
重設/撤銷選項,請參閱
[命令列參考 → fn dashboard → 驗證](./docs/cli-reference.md#fn-dashboard)。
### 首次執行設定
首次啟動時,Fusion 會開啟**引導精靈**,提供三個引導步驟:
1. **AI 設定** — 使用簡化的快速啟動供應商清單(建議的供應商,加上已連線的供應商),只有在需要其他供應商或詳細設定時,才展開**進階供應商設定**。只需一個供應商即可開始使用。已棄用的 Google Gemini CLI / Antigravity 供應商項目已刻意隱藏;Google/Gemini API 金鑰、Google Generative AI、Vertex 與 Cloud Code 路徑仍受支援。
2. **GitHub(選填)** — 連結 GitHub 以匯入議題並管理 PR
3. **第一個任務** — 建立你的第一個任務或從 GitHub 匯入(若無作用中的專案,引導精靈會先提示你註冊/選取專案目錄)
精靈**可關閉且不阻擋操作**——點擊**暫時略過**即可立即使用儀表板。之後可從**設定 → 驗證 → 重新開啟引導指南**再次觸發。
### 行動裝置
Capacitor + PWA 工作流程,請參閱 [MOBILE.md](./MOBILE.md)。
---
## 文件
| 指南 | 涵蓋內容 |
|---|---|
| [入門指南](./docs/getting-started.md) | 安裝與引導 |
| [儀表板指南](./docs/dashboard-guide.md) | 看板/清單檢視、終端機、git 管理器 |
| [任務管理](./docs/task-management.md) | 任務生命週期與命令列指令 |
| [命令列參考](./docs/cli-reference.md) | 完整指令與背景程式參考 |
| [設定參考](./docs/settings-reference.md) | 組態選項 |
| [系統架構](./docs/architecture.md) | 系統內部運作 |
| [代理人](./docs/agents.md) | 代理人管理、生成與心跳 |
| [工作流程步驟](./docs/workflow-steps.md) | 品質關卡、範本、階段 |
| [任務群組](./docs/missions.md) | 任務群組層級、規劃、自動駕駛模式 |
| [多專案](./docs/multi-project.md) | 中央登錄表、隔離模式 |
| [Docker](./docs/docker.md) | 容器部署 |
---
## 核心功能
- **AI 規劃** — 規劃代理人產生詳細的 `PROMPT.md`,包含步驟、檔案範圍與驗收條件
- **逐步執行** — 每個任務步驟執行「規劃 → 審閱 → 執行 → 審閱」循環
- **Git 工作樹隔離** — 每個任務在各自的工作樹(`fusion/{task-id}` 分支)中執行
- **工作流程步驟** — 可設定的品質關卡(合併前:阻擋合併;合併後:資訊性)
- **GitHub 整合** — 匯入議題、建立 PR、即時 PR/議題徽章
- **儀表板** — 即時看板、代理人管理、終端機、git 管理器、任務群組規劃器
- **任務群組** — 層級式規劃(任務群組 → 里程碑 → 切片 → 功能 → 任務),具備自動駕駛模式、驗證合約、修復功能重試與封鎖交接語意
- **多專案** — 從單一安裝管理多個專案,具備專案隔離
- **代理人間訊息傳遞** — 代理人與使用者之間協調用的內建訊息傳遞
- **聊天室(實驗性)** — 專案範圍的群組對話,被提及的成員為直接回應者,其餘環境成員最多可回覆至上限(在**設定 → 實驗性功能 → 聊天室**中啟用;詳見[儀表板指南 → 聊天室](./docs/dashboard-guide.md#chat-rooms))
### 供應商驗證
Fusion 透過**設定 → 驗證**支援 AI 供應商的 OAuth 驗證。對大多數 OAuth 供應商而言,當儀表板透過非 localhost 主機存取(遠端節點、區域網路主機/IP 或反向代理),供應商登入網址會被重寫,以透過橋接端點(`/api/auth/oauth-callback`)路由 OAuth 回呼,確保重導向能到達作用中的瀏覽器工作階段。
- **Anthropic (Claude)** — 在設定/引導精靈中使用貼上授權碼流程:登入後,將最終的重導向網址(或授權碼)貼回 Fusion 以完成登入
- **OpenAI Codex** — 使用相同的貼上授權碼流程,搭配安全的狀態驗證
- **Factory AI — 透過 Droid CLI** *(選填)* — 需要本地安裝 Droid CLI 並執行 `droid auth login`;偵測依照有效的執行環境二進位路徑(預設為 `droid`,或設定後的外掛程式 `droidBinaryPath`),然後在**設定 → 驗證**中啟用並重新啟動 Fusion
- **llama.cpp — 透過 HTTP 伺服器** *(選填)* — 設定你的 llama.cpp 伺服器網址(預設 `http://127.0.0.1:8080`)與選填的 API 金鑰,然後在**設定 → 驗證**中啟用
- **其他供應商** — 在設定中透過輸入 API 金鑰進行驗證(包含 Google/Gemini API 金鑰、Google Generative AI、Vertex 與 Cloud Code 別名)
### 模型系統
Fusion 使用具備五條獨立通道的雙範圍模型層級。全域設定定義基準預設值;專案設定提供每個專案的覆蓋值。
| 通道 | 用途 | 全域基準金鑰 | 專案覆蓋金鑰 |
|------|---------|---------------------|----------------------|
| Executor | 任務執行代理人 | `executionGlobalProvider` + `executionGlobalModelId` | `executionProvider` + `executionModelId` |
| Planning | 任務規劃代理人 | `planningGlobalProvider` + `planningGlobalModelId` | `planningProvider` + `planningModelId` |
| Validator | 計畫/程式碼審閱者 | `validatorGlobalProvider` + `validatorGlobalModelId` | `validatorProvider` + `validatorModelId` |
| Title Summarization | 自動標題產生 | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` |
| Workflow Step Refinement | AI 提示詞精煉 | (使用 `defaultProvider`/`defaultModelId`) | (使用 WorkflowStep 上的 `modelProvider`/`modelId`) |
**每任務覆蓋:** 任務可透過每任務模型欄位(`modelProvider`/`modelId`、`validatorModelProvider`/`validatorModelId`、`planningModelProvider`/`planningModelId`)覆蓋執行器、驗證器與規劃通道。
**優先順序:** 每任務 → 專案覆蓋 → 全域通道 → `defaultProvider`/`defaultModelId` → 自動解析。
完整的設定文件,請參閱[設定參考](./docs/settings-reference.md)。
### 排程任務/自動化
Fusion 透過 `/api/automations` 端點支援排程任務自動化。自動化可按設定的排程執行 shell 指令或多步驟工作流程。
#### 排程範圍
自動化與常式可在兩種範圍中執行:
- **全域** — 跨所有專案執行。適用於跨專案維護、備份或統一報告。
- **專案** — 僅在特定專案中執行。適用於特定專案的 CI、測試或部署任務。
當你建立排程而未選擇範圍時,Fusion 為了向後相容,預設使用 `default` 專案 ID 的**專案範圍**。
明確指定範圍的方式:
- 在儀表板的**排程任務**對話框中,使用**全域 / 專案**切換。
- 透過 API,在自動化/常式端點上傳遞 `?scope=global` 或 `?scope=project&projectId=<id>`。
**範圍解析規則:**
- `scope=global` 永遠解析至全域自動化/常式通道,與作用中的專案無關。
- `scope=project` 需要 `projectId`。若省略,退回至 `"default"`。
- CRUD、執行、切換與 webhook 操作嚴格進行範圍隔離:全域排程無法從專案範圍的請求進行變更,反之亦然。
**多專案設定的操作指引:**
- 偏好使用**全域**排程處理共用基礎設施(例如每夜備份、記憶體洞察擷取)。
- 偏好使用**專案**排程處理每個儲存庫的自動化(例如每個專案的測試執行器、部署鉤子)。
- 全域與專案通道由引擎獨立輪詢,因此一個通道中的到期執行不會阻擋另一個通道。
#### 自動化
| 端點 | 方法 | 說明 |
|---------|--------|-------------|
| `/api/automations` | GET | 列出所有自動化(若指定範圍則依範圍篩選) |
| `/api/automations` | POST | 建立自動化(範圍預設為 `project`) |
| `/api/automations/:id` | GET | 依 ID 取得自動化 |
| `/api/automations/:id` | PATCH | 更新自動化 |
| `/api/automations/:id` | DELETE | 刪除自動化 |
| `/api/automations/:id/run` | POST | 觸發手動執行 |
| `/api/automations/:id/toggle` | POST | 切換啟用/停用 |
| `/api/automations/:id/steps/reorder` | POST | 重新排序自動化步驟 |
#### 常式
常式是由 cron 排程、webhook 或手動執行觸發的 AI 代理人任務。常式與自動化共用相同的全域/專案範圍模型。
| 端點 | 方法 | 說明 |
|---------|--------|-------------|
| `/api/routines` | GET | 列出所有常式(若指定範圍則依範圍篩選) |
| `/api/routines` | POST | 建立常式(範圍預設為 `project`) |
| `/api/routines/:id` | GET | 依 ID 取得常式 |
| `/api/routines/:id` | PATCH | 更新常式 |
| `/api/routines/:id` | DELETE | 刪除常式 |
| `/api/routines/:id/run` | POST | 手動觸發 |
| `/api/routines/:id/trigger` | POST | 正式手動觸發端點 |
| `/api/routines/:id/runs` | GET | 取得執行歷史 |
| `/api/routines/:id/webhook` | POST | Webhook 觸發(支援簽章驗證) |
---
## 命令列快速範例
```bash
fn task create "Fix the login bug" # 快速輸入 → 規劃
fn task plan "Build auth system" # AI 引導規劃
fn task import owner/repo --labels bug # 匯入 GitHub 議題
fn task show FN-001 # 檢視任務詳情
fn task logs FN-001 --follow # 串流執行日誌
fn task steer FN-001 "Use TypeScript" # 執行中引導代理人
fn project add my-app /path/to/app # 註冊專案
fn project list # 列出所有專案
fn settings set maxConcurrent 4 # 設定組態
fn settings export # 匯出組態
fn mission create "Auth System" "Build auth" # 建立任務群組
fn mission activate-slice <slice-id> # 啟動切片
fn skills search react # 搜尋 skills.sh
fn skills install firebase/agent-skills # 安裝代理人技能
```
---
## 套件
| 套件 | 說明 |
|---------|-------------|
| `@fusion/core` | 領域模型——任務、看板欄位、SQLite 儲存庫 |
| `@fusion/dashboard` | 網頁 UI——Express 伺服器 + 搭配 SSE 的看板 |
| `@fusion/engine` | AI 引擎——規劃、執行、排程、工作流程步驟 |
| `@runfusion/fusion` | 命令列介面 + 擴充功能——發布至 npm |
---
## 開發
```bash
pnpm install # 安裝相依套件
pnpm local # 在非 4040 連接埠啟動本地儀表板/API
pnpm local -- --engine # 啟動本地儀表板並包含 AI 引擎
pnpm build # 建置預設工作區套件(不含桌面/行動裝置)
pnpm build:all # 建置所有套件(包含桌面/行動裝置)
pnpm dev dashboard # 執行儀表板 + AI 引擎
pnpm dev:ui # 僅儀表板(不含 AI 引擎)
pnpm lint # 對所有套件執行 lint
pnpm typecheck # 對所有套件進行型別檢查
pnpm test # 執行所有測試
```
### 建置獨立執行檔
使用 [Bun](https://bun.sh/) 建置單一自包含的 `fn` 執行檔:
```bash
pnpm build:exe # 為目前平台建置
pnpm build:exe:all # 跨平台編譯所有平台
```
---
## 授權條款
MIT——開放原始碼,無廠商綁定。請參閱 [LICENSE](./LICENSE)。
<div align="center">
**[runfusion.ai →](https://runfusion.ai)**
</div>

View File

@@ -207,8 +207,14 @@ fn dashboard --no-auth # disable bearer auth (local only)
fn dashboard --interactive
fn dashboard --paused
fn dashboard --dev
fn dashboard --lang zh-TW # force a UI locale for this run
```
The terminal UI is localized. `--lang <code>` (one of `en`, `zh-CN`, `zh-TW`,
`fr`, `es`, `ko`) takes precedence over the saved dashboard language setting and the
`LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE` environment. See
[Localization contributor guide](./i18n-contributing.md).
| Option | Description |
|---|---|
| `--port`, `-p` | Dashboard HTTP port (default `4040`). |

View File

@@ -237,6 +237,15 @@ Fusion can automatically extract insights from memory and prune transient conten
See [Settings Reference](./settings-reference.md#background-memory-summarization--audit) for configuration details.
## Localization (i18n)
User-facing strings are localized with react-i18next across both the dashboard
and the terminal UI. English is the source-of-truth; catalogs and shared config
live in the `@fusion/i18n` package. To translate, add a language, or wire new
strings, see the [Localization contributor guide](./i18n-contributing.md). When
adding user-facing copy, prefer `t("namespace:key", "English default")` over a
hardcoded string and run `pnpm i18n:extract` to register the key.
## Dashboard CSS Organization
The dashboard's CSS has been modularized:

73
docs/i18n-contributing.md Normal file
View File

@@ -0,0 +1,73 @@
# Localization (i18n) contributor guide
Fusion's UI is localized with [react-i18next]. English (`en`) is the
source-of-truth language; everything else is a translation of it. Both UI
surfaces — the React dashboard and the Ink terminal UI — share one set of
catalogs and config in the `@fusion/i18n` package.
## Where things live
| Path | What it is |
| ---- | ---------- |
| `packages/i18n/locales/{locale}/{namespace}.json` | Authored catalogs (translators edit here). `en` is the source. |
| `packages/i18n/src/config.ts` | Shared i18next config: namespaces, fallback chain, plural setup. |
| `packages/core` (`SUPPORTED_LOCALES`, `Locale`) | The single list of supported locale codes. |
| `i18next.config.ts` (repo root) | `i18next-cli` workflow config. |
Namespaces: `common` (shared), `app` (dashboard-only), `errors`, and `cli`
(terminal-only). The dashboard loads `common`/`app`/`errors`; the CLI loads
`common`/`cli`/`errors`.
## The workflow
All commands run from the repo root:
```bash
pnpm i18n:extract # pull t()/<Trans> keys from source into the en catalogs
pnpm i18n:sync # propagate the en key structure to every other locale
pnpm i18n:types # regenerate key types from the en catalogs
pnpm i18n:status # per-locale completion report
pnpm i18n:lint # flag hardcoded user-facing strings
pnpm i18n:gen-cli # regenerate the CLI static catalog import map
```
## Translating an existing language
1. Run `pnpm i18n:sync` so every catalog has the current `en` keys (untranslated
entries are empty strings).
2. Fill the empty strings in `packages/i18n/locales/{locale}/*.json`.
3. Keep interpolation placeholders verbatim: `{{brand}}`, `{{detail}}`,
`{{key}}`. Never translate a `[{{key}}]` keybinding accelerator — only the
words around it.
4. `pnpm i18n:status` to confirm the locale is complete.
`zh-CN` and `zh-TW` are independent — different script **and** vocabulary. Do
not machine-convert one into the other.
## Adding a new language (near-zero code)
1. Add the locale code to `SUPPORTED_LOCALES` in `packages/core/src/types.ts`
and to `locales` in `i18next.config.ts`.
2. `pnpm i18n:sync` — scaffolds a full set of catalog files for the new locale
with the correct plural categories.
3. `pnpm i18n:gen-cli` — adds the locale to the CLI's static import map.
4. Translate the new catalogs, then `pnpm i18n:status` to verify.
No feature code changes are required: the dashboard discovers the locale through
the generated `app/locales/` tree, and the CLI through the regenerated import
map. Add the language's endonym to `ENDONYMS` in
`packages/dashboard/app/components/LanguageSelector.tsx` so it appears in the
Settings switcher.
## Using a non-English locale
- **Dashboard / mobile:** Settings → Appearance → Language. The choice persists
to `localStorage` and to server settings.
- **Terminal UI:** resolved from `--lang <code>` → the saved dashboard language
setting → the `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE` environment → `en`.
```bash
fusion dashboard --lang zh-TW
```
[react-i18next]: https://react.i18next.com/

View File

@@ -0,0 +1,428 @@
---
title: "feat: Add UI localization (i18n) across dashboard and terminal UI"
type: feat
status: completed
date: 2026-06-03
deepened: 2026-06-03
---
# feat: Add UI localization (i18n) across dashboard and terminal UI
## Summary
Stand up a react-i18next localization foundation that serves both Fusion UI surfaces — the React web dashboard (`packages/dashboard/app`, inherited by the Capacitor mobile wrapper) and the Ink terminal UI (`packages/cli`) — migrate user-facing strings into translation catalogs, ship four non-English locales (`zh-CN`, `zh-TW`, `fr`, `es`) atop an English source-of-truth base, and establish an `i18next-cli`-driven workflow so adding a future language is a near-zero-code, translate-only operation.
---
## Problem Frame
Fusion's UI is currently English-only with strings hardcoded inline across ~464 dashboard `.tsx` files and the Ink TUI components. There is **no i18n library anywhere** in the monorepo (greenfield — confirmed across `packages/` and `plugins/`), and locale-sensitive formatting at ~45 dashboard call sites relies on the implicit browser default (`toLocaleDateString(undefined, …)`), with zero `navigator.language` or `Intl.*` usage.
Fusion's strategy targets a developer audience "across surfaces and machines," and the published CLI (`@runfusion/fusion`) plus the dashboard are the two surfaces users actually read. Supporting Simplified Chinese, Traditional Chinese, French, and Spanish — and lowering the cost of every future language — widens reach without betting the product on any one locale. Because this is greenfield, there is no prior library to reconcile or migrate away from; the cost is entirely in standing up the foundation and the one-time string extraction.
---
## Requirements
### Localization foundation
- R1. A single localization runtime (`react-i18next` + `i18next`) powers both the dashboard and the Ink TUI from one shared catalog source-of-truth, with English as the source language.
- R2. Translation catalogs are organized as `{locale}/{namespace}.json` with nested, ID-style keys (not natural-language keys), split into namespaces so feature areas and the CLI-only surface can be loaded independently.
- R3. Supported locales are a single typed list — `en`, `zh-CN`, `zh-TW`, `fr`, `es` — defined once in `@fusion/core` and consumed by every surface. `en` is the fallback for all.
- R4. Pluralization uses i18next's native CLDR plural categories (via `Intl.PluralRules`); date/number/relative-time formatting uses i18next's built-in `Intl` formatters bound to the active locale. No ICU message format.
### Dashboard (web + mobile)
- R5. The dashboard resolves the active locale at startup with precedence `localStorage → navigator → en`, constrained to `supportedLngs`, and sets `document.documentElement.lang`.
- R6. Only the active locale's catalogs load on first paint; the other four locales are code-split and fetched on demand, consistent with the existing lazy-load/prefetch bundle discipline.
- R7. Users can change language from the Settings UI; the choice persists to `localStorage` and writes through to server-side `GlobalSettings.language`, mirroring the existing theme-preference pattern.
- R8. Locale-sensitive date/number formatting in the dashboard reflects the active i18n locale rather than the implicit browser default.
### Terminal UI (CLI)
- R9. The Ink TUI renders translated strings via the same library, using a separate Node-side i18next instance with statically bundled catalogs and synchronous init (first frame is localized).
- R10. The CLI resolves locale with precedence `--lang flag → persisted GlobalSettings → environment (LC_ALL/LANG/…) → en`.
- R11. CJK and accented text render without breaking the TUI's width-sensitive layouts; single-letter keybinding accelerators are never translated.
### Translations and contributor workflow
- R12. Complete `zh-CN`, `zh-TW`, `fr`, and `es` catalogs are shipped, each independently localized (no auto-conversion between the two Chinese scripts).
- R13. An `i18next-cli` workflow extracts keys from source, syncs missing/orphaned keys across all locales, generates key types, and reports per-locale completion; CI fails on missing keys, stale types, or incomplete catalogs.
- R14. Adding a new language requires no feature code beyond registering the locale code and providing translated catalogs; the procedure is documented for contributors.
---
## Key Technical Decisions
- KTD1. **Library: `react-i18next` + `i18next` v26 for both surfaces.** react-i18next is framework-agnostic and runs in Node, so the same stack powers the Vite SPA and the Ink React tree — no second i18n system. v26's built-in `Intl` formatter is always active and its native pluralization uses CLDR categories, covering our formatting and plural needs without `i18next-icu`. (Note v26 breaking changes: the legacy `interpolation.format` function is removed; `TFunction` imports from `i18next`, not `react-i18next`.)
- KTD2. **Catalogs: `{locale}/{namespace}.json`, nested ID-style keys, multiple namespaces.** Namespaces are i18next's lazy-load unit, so splitting by feature area enables per-surface and per-route code-splitting (a `cli` namespace the web bundle skips; an `errors`/`common`/`app` split for the dashboard). ID-style keys (`settings.appearance.languageLabel`) survive English copy rewrites without orphaning translations — the maintainability the request asks for. English text lives as `defaultValue`/extraction, not as the key.
- KTD3. **Single catalog source-of-truth in a dedicated `@fusion/i18n` package, consumed differently per surface.** Catalogs live in a new `packages/i18n` workspace package (`@fusion/i18n`) — *not* in `@fusion/core`. Putting presentation strings in the domain/settings root would be a layering inversion (core sits under engine and CLI and has no other UI-string responsibility). `@fusion/i18n` owns the authored `locales/{lng}/{ns}.json` source-of-truth, the shared i18next config (namespace list, fallback chain, plural setup), and namespace key types. It `import type`s `Locale` from `@fusion/core` (type-only, no runtime cycle); locale primitives and the persisted `GlobalSettings.language` field stay in core (KTD8). The dashboard lazy-loads the active locale via Vite code-splitting from catalogs generated into its own tree (KTD3a); the CLI statically inlines all catalogs directly from `@fusion/i18n` via tsup (`noExternal: [/^@fusion\//]` already bundles `@fusion/*` from source). Rejected alternatives: catalogs *authored* in `@fusion/core` (layering inversion — the domain root has no UI-string responsibility); catalogs *authored* in `packages/dashboard/app/locales/` and imported across the boundary by the CLI (dependency inversion — terminal UI reaching into the SPA's internals). Note these are distinct from KTD3a's primary wiring, which *generates* catalogs into the dashboard tree from the `@fusion/i18n` source — the dashboard never authors them and the CLI never imports the generated copy. The dashboard's i18n init calls `i18next-resources-to-backend` directly at the (app-relative) call site; no separate factory abstraction is introduced.
- KTD3a. **Dashboard per-locale code-splitting requires a *relative*, package-local dynamic-import template; the authored source-of-truth stays in `@fusion/i18n` and is generated into the dashboard tree at build time.** Vite 6's variable-dynamic-import analysis (via `@rollup/plugin-dynamic-import-vars`) only code-splits `import(\`./locales/${lng}/${ns}.json\`)` when the prefix is relative *to the importing file* and each variable is exactly one path segment. An aliased, bare, or cross-package/workspace specifier in a *variable* `import()` defeats static analysis — Vite eager-bundles with a build warning or fails at runtime — and `import.meta.glob` refuses to cross the `node_modules` boundary. A relative template living inside `@fusion/i18n`'s own source would still resolve through the workspace symlink at the dashboard build; that *can* slip through but is **not a documented guarantee**, and the dashboard has zero existing variable-dynamic-import precedent to lean on. **Decision (primary wiring):** `@fusion/i18n` remains the single authored source-of-truth, but `i18next-cli sync` generates the catalog tree into a gitignored `packages/dashboard/app/locales/` (mirroring the engine `dist` convention — a `predev`/`prebuild` step in the dashboard package), and the dashboard's backend factory imports them with a plainly app-relative `import(\`./locales/${lng}/${ns}.json\`)`. This makes per-locale splitting *guaranteed* rather than dependent on undocumented symlink behavior. The cross-package symlink path (import template inside `@fusion/i18n`, no generation step) is recorded only as an optional optimization to prove out later, not the shipped path. The CLI is unaffected — tsup statically inlines catalogs directly from `@fusion/i18n` across the package boundary (KTD5). U3 keeps a build assertion that per-locale chunks emit, as a regression guard on the generated-catalog wiring.
- KTD4. **Dashboard lazy-loading via `i18next-resources-to-backend` + dynamic `import()`.** Preferred over `i18next-http-backend` for a Vite SPA: hashed assets, offline-capable, no separate static-serving concern. Only the active locale's loaded namespaces touch the main path; switching language fetches the new chunk. Add a `vendor-i18n` `manualChunk` alongside the existing `vendor-react`/`vendor-xterm` splits.
- KTD5. **CLI init is synchronous with statically bundled catalogs (`initImmediate: false`).** Lazy loading is pointless in Node; synchronous init guarantees the first rendered frame is already localized. The CLI gets its own i18next instance (no DOM, no HTTP backend, locale from env/flag/settings — not `localStorage`).
- KTD6. **Tooling: `i18next-cli`, not `i18next-parser`.** `i18next-parser` was archived Feb 2026; `i18next-cli` (SWC-based) unifies `extract`, `sync`, `types`, `status`, and `lint`. Its AST-based `lint` for hardcoded strings is the primary guardrail (CI, warn-first), avoiding a noisy 464-file ESLint sweep. A scoped `eslint-plugin-i18next`/`no-restricted-syntax` rule is deferred (see Scope Boundaries) — the repo's existing `no-restricted-syntax` precedent makes it easy to add later if editor squiggles are wanted.
- KTD7. **zh-CN and zh-TW are fully independent catalogs.** Different script *and* vocabulary — never auto-convert one to the other. Configure script-aware fallback (`zh-Hans→zh-CN`, `zh-Hant→zh-TW`, `zh→zh-CN`, default `en`) and keep `load: 'currentOnly'` so the two never collapse into a generic `zh`. Both have a single CLDR plural category (`other`); French and Spanish carry `one`/`many`/`other`.
- KTD8. **Persisted-locale plumbing extends the existing theme-preference pattern.** Add `language?: Locale` to `GlobalSettings` in core (registered in `DEFAULT_GLOBAL_SETTINGS`, `GLOBAL_SETTINGS_KEYS`, schema/validation). Dashboard persistence mirrors `useTheme.ts`: a `localStorage` cache (using the neighbor-consistent `kb-dashboard-*` key prefix — do **not** pre-empt the kb→fn rename here) plus `updateGlobalSettings` write-through. The CLI reads the same `GlobalSettings.language` server-side.
- KTD9. **Brand tokens stay as interpolation variables in catalogs.** Because the kb→fn rename is in flight, extract product-name occurrences as `{{brand}}`-style variables (or leave brand-only keys untranslated) so the rename can sweep them later without churning all five catalogs.
---
## High-Level Technical Design
### Surface and catalog topology
```mermaid
flowchart TB
subgraph core["@fusion/core (domain/settings root)"]
LOC["Locale, SUPPORTED_LOCALES, DEFAULT_LOCALE,<br/>GlobalSettings.language"]
end
subgraph i18n["@fusion/i18n (new package — authored source-of-truth)"]
CAT["locales/{lng}/{ns}.json"]
CFG["shared config + generated cli import map"]
end
subgraph dash["packages/dashboard/app (Vite SPA)"]
DI["i18n init (browser instance)"]
DP["I18nextProvider in App.tsx stack"]
SW["Language switcher (SettingsModal)"]
FMT["Intl formatters / toLocale* sites"]
end
subgraph cli["packages/cli (Ink TUI)"]
CI["i18n init (Node instance, sync)"]
CP["I18nextProvider at Ink render()"]
end
mobile["@fusion/mobile (Capacitor)"]
LOC -.->|"import type Locale"| i18n
LOC --> DI & CI & SW
CAT -->|"i18next-cli sync → gitignored app/locales/<br/>app-relative import() → per-locale chunks"| DI
CAT -->|"statically inlined via tsup (all locales)"| CI
DI --> DP --> FMT
CI --> CP
dash -->|"built dist inherited"| mobile
```
### Dashboard language-switch flow
```mermaid
sequenceDiagram
participant U as User
participant S as SettingsModal (Appearance)
participant H as useLanguage hook
participant I as i18next (browser)
participant LS as localStorage
participant API as updateGlobalSettings
U->>S: select locale (e.g. zh-TW)
S->>H: setLanguage("zh-TW")
H->>I: changeLanguage("zh-TW")
I-->>I: lazy-fetch zh-TW namespace chunk(s)
I-->>S: re-render translated tree
H->>LS: cache "zh-TW" (kb-dashboard-language)
H->>API: write-through GlobalSettings.language
H->>I: set document.documentElement.lang
```
### CLI locale resolution precedence
```mermaid
flowchart LR
A["--lang flag"] -->|set| Z["active locale"]
A -->|unset| B["GlobalSettings.language"]
B -->|set| Z
B -->|unset| C["env LC_ALL/LC_MESSAGES/LANG/LANGUAGE"]
C -->|parsed & supported| Z
C -->|none| D["en (fallback)"]
D --> Z
```
---
## Output Structure
New and notably-modified files (per-unit `Files` sections remain authoritative):
```
packages/
i18n/ (new @fusion/i18n package — authored source-of-truth)
package.json (source export condition, JSON support)
locales/ (authored catalogs — translators edit here)
en/ common.json app.json errors.json cli.json
zh-CN/ common.json app.json errors.json cli.json
zh-TW/ ...
fr/ ...
es/ ...
src/
config.ts (shared i18next config, namespace + key types)
cli-catalogs.ts (generated static import map for the CLI, from SUPPORTED_LOCALES)
index.ts
core/
src/
types.ts (+ Locale, SUPPORTED_LOCALES, DEFAULT_LOCALE, GlobalSettings.language)
settings-schema.ts (+ language default & key)
settings-validation.ts
index.ts (+ exports)
dashboard/
app/
locales/ (GITIGNORED — generated from @fusion/i18n by i18next-cli sync)
en/ … zh-CN/ … zh-TW/ … fr/ … es/
i18n/
index.ts (browser init: app-relative resources-to-backend, detector, formatters, fallback)
useLanguage.ts (switch + persist hook)
components/
LanguageSelector.tsx
package.json (predev/prebuild → i18next-cli sync into app/locales)
vite.config.ts (vendor-i18n chunk)
cli/
src/
i18n/
index.ts (Node sync init, static catalogs, env/flag detection)
i18next.config.ts (extract/sync/types/status/lint config)
docs/
i18n-contributing.md (adding a language)
```
---
## Implementation Units
Phased: **A. Foundation** (U1–U2) → **B. Dashboard** (U3–U5) → **C. Terminal UI** (U6–U7) → **D. Translations & workflow** (U8). Each unit is independently landable.
### U1. Core locale primitives and settings field
- Goal: Define the single source of locale truth and the persisted preference field in `@fusion/core`.
- Requirements: R3, R7 (settings field), R10 (shared field for CLI).
- Dependencies: none.
- Files:
- `packages/core/src/types.ts` — add `Locale` union, `SUPPORTED_LOCALES` tuple (`["en","zh-CN","zh-TW","fr","es"]`), `DEFAULT_LOCALE`, and `language?: Locale` on `GlobalSettings` (near line 2439, beside `dashboardFontScalePct`). Locale primitives must live in `types.ts` specifically — the dashboard Vite alias resolves `@fusion/core` to `types.ts` only.
- `packages/core/src/settings-schema.ts` — register `language` in `DEFAULT_GLOBAL_SETTINGS` (line ~18) so it flows into `GLOBAL_SETTINGS_KEYS`.
- `packages/core/src/settings-validation.ts` — validate `language` against `SUPPORTED_LOCALES`.
- `packages/core/src/index.ts` — export `Locale`, `SUPPORTED_LOCALES`, `DEFAULT_LOCALE`.
- `packages/core/src/__tests__/settings-schema.test.ts` (or matching existing settings test) — coverage.
- Approach: Pure type + constant additions; no runtime behavior beyond settings validation. `language` is optional so existing settings files remain valid (absence → resolve at runtime, not persisted default-en, to keep "follow the browser/env" behavior until the user chooses).
- Patterns to follow: `THEME_MODES`/`COLOR_THEMES` const tuples and the `themeMode`/`colorTheme` optional fields; `isGlobalSettingsKey` validation flow.
- Test scenarios:
- `SUPPORTED_LOCALES` contains exactly the five codes; `DEFAULT_LOCALE` is `en`.
- `isGlobalSettingsKey("language")` returns true; `language` appears in `GLOBAL_SETTINGS_KEYS`.
- Validation accepts each supported code and rejects an unsupported code (e.g. `"de"`, `"zh"`).
- A settings object without `language` round-trips through load/merge unchanged (backward compatibility).
- Verification: `@fusion/core` typechecks and its settings tests pass; the new symbols are importable from the barrel.
### U2. `@fusion/i18n` package, catalog source-of-truth, and i18next-cli tooling
- Goal: Create the `@fusion/i18n` package with shared config and authored `en` base catalogs, the generated CLI import map, and the extract/sync/types/status/lint workflow with a CI gate.
- Requirements: R1 (shared source), R2 (layout/keys/namespaces), R13 (tooling + CI), R14 (drop-in language config).
- Dependencies: U1.
- Files:
- `packages/i18n/package.json` — new `@fusion/i18n` workspace package; `source` export condition (so the CLI's tsup `conditions: ["source"]` inlines `.ts`/`.json` from source like other `@fusion/*`); JSON import support in its build. Zero runtime internal deps; `import type` only from `@fusion/core` for `Locale`.
- `packages/i18n/src/config.ts` (shared i18next config: namespace list, script-aware fallback chain, plural setup), `packages/i18n/src/index.ts`.
- `packages/i18n/src/cli-catalogs.ts` — **generated** static import map (one `import` per `{locale}/{ns}`) produced from `SUPPORTED_LOCALES` by a codegen step, so the CLI's static-bundle path (KTD5) gains a new locale with no hand-edited import block — this is what keeps R14's "near-zero-code" promise true for the terminal surface, not just the dashboard.
- `packages/i18n/locales/en/{common,app,errors,cli}.json` — initial namespaces (seeded from already-centralized constants like `COLUMN_LABELS`/`COLUMN_DESCRIPTIONS` as the first real keys).
- `packages/i18n/locales/{zh-CN,zh-TW,fr,es}/*.json` — scaffolded (synced) placeholders so structure exists before U8 fills them.
- `i18next.config.ts` (repo root) — `defineConfig` with `locales`, `extract.input` globs covering `packages/dashboard/app/**` and `packages/cli/src/**`, `extract.output` → `packages/i18n/locales/{{language}}/{{namespace}}.json`, `types.output`, per-input-tree default-namespace mapping (dashboard globs → `app`/`common`/`errors`; CLI globs → `cli`) so extracted keys land in the right namespace, and `lint` accepted tags/attrs to cut false positives.
- root `package.json` — scripts: `i18n:extract`, `i18n:sync`, `i18n:types`, `i18n:status`, `i18n:lint`, plus the CLI import-map codegen.
- `packages/cli/scripts/prepare-publish-manifest.mjs` — add `@fusion/i18n` to the private-dep strip list (alongside the existing `@fusion/core`/`@fusion/engine` handling) so `npm install @runfusion/fusion` does not 404 on the private workspace dep; declare `@fusion/i18n` as a CLI devDependency (inlined via `noExternal`), matching the existing pattern.
- CI workflow (existing `.github/workflows/*`) — add a job running `extract --ci`, `types --ci`, and `status`. (`lint` runs here too but is the same `i18next-cli lint` named the primary guardrail in KTD6 — not a duplicate ESLint pass.)
- `.changeset/*.md` — `@runfusion/fusion` minor (published surface gains i18n; `@fusion/i18n` is private/bundled, so no changeset for it per AGENTS.md).
- Approach: `en` is primary; `i18next-cli sync` scaffolds the other four with correct per-locale plural suffixes so contributors never hand-author plural categories. Keep the published-CLI footprint lean (`i18next` + `react-i18next` + `@fusion/i18n` catalogs only; tooling is devDependencies). **CI gate vs. incremental key growth:** because U5/U7 continuously add `en` keys (each re-opening gaps in the four locales), the 100%-completion gate (U8) must not deadlock CI during the migration window. Resolve by having `status` gate at 100% only for a tracked *shipped* set, with newly-added-but-untranslated keys allowed via an explicit pending-keys allowlist (or a per-namespace freeze) until U8 — `extract --ci` (no missing `en` key) and `types --ci` (no stale types) stay hard gates throughout.
- Patterns to follow: existing `@fusion/core` package shape (`source` export condition, tsc build); the engine `dist` gitignored-generated-artifact convention for the dashboard catalog generation (U3); existing root scripts and the docs-sync self-checking test idiom (`app/__tests__/lazy-loaded-views-docs.test.ts`).
- Test scenarios:
- `i18n:status` exits non-zero when a non-`en` catalog is missing a *shipped* key present in `en`; a key on the pending allowlist does not trip the gate.
- `i18n:extract --ci` exits non-zero when a `t()` key in source is absent from the `en` catalog.
- `i18n:sync` adds a newly-introduced `en` key to all four locales and removes an orphaned key from all; a dashboard-namespace key and a `cli`-namespace key route to their correct namespace files.
- Generated key types include a representative nested key and reject a typo'd key (compile-fail fixture).
- The generated `cli-catalogs.ts` import map contains exactly one entry per `SUPPORTED_LOCALES × namespace`; regenerating after adding a locale code adds its entries with no hand edit.
- Covers R13. Covers R14: adding a sixth locale code to `i18next.config.ts` + `SUPPORTED_LOCALES`, then running `sync` + codegen, produces a fully-scaffolded catalog set AND a CLI import map that picks up the locale — no hand-edited feature code on either surface.
- Verification: All `i18n:*` scripts run locally; the CI job is green with `en` populated and the four locales synced (even if untranslated, structure matches); `npm pack` on the CLI produces a manifest with no unresolved `@fusion/*` deps.
### U3. Dashboard i18n runtime
- Goal: Initialize the browser i18next instance with lazy catalog loading, detection, fallback, and Intl formatters; mount the provider.
- Requirements: R1, R4, R5, R6.
- Dependencies: U1, U2.
- Files:
- `packages/dashboard/app/i18n/index.ts` — init: `initReactI18next`, `i18next-browser-languagedetector` (order `localStorage → navigator → htmlTag`, `supportedLngs`, `caches: ['localStorage']`), `i18next-resources-to-backend` with an **app-relative** `import(\`./locales/${lng}/${ns}.json\`)` over the generated `app/locales/` tree (KTD3a), script-aware `fallbackLng` (KTD7), `load: 'currentOnly'`, and shared config imported from `@fusion/i18n`.
- `packages/dashboard/package.json` — `predev`/`prebuild` step running `i18next-cli sync` to generate `app/locales/` from the `@fusion/i18n` source (gitignored), mirroring the engine `dist` convention.
- `packages/dashboard/app/main.tsx` — import the i18n side-effect before `createRoot`; gate first paint on i18next `ready` (Suspense boundary or a top-level loading state) so the UI never renders raw keys during the first catalog fetch (avoids flash-of-untranslated-content); set `document.documentElement.lang` on `languageChanged`.
- `packages/dashboard/app/App.tsx` — wrap the provider stack (around lines 2044–2058) with `<I18nextProvider>` so toasts/dialogs are translatable.
- `packages/dashboard/vite.config.ts` — add a `vendor-i18n` `manualChunk` for the `i18next`/`react-i18next` runtime. Note: catalogs are natural async chunks (not `node_modules`), so `manualChunks` needs **no** change to keep them split — record this as an explicit non-change.
- `packages/dashboard/app/i18n/__tests__/i18n.test.ts` — init/detection/fallback coverage.
- Build-assertion (the KTD3a verification gate): a check (test or build script) asserting per-locale catalog chunks are emitted to `dist/client/assets` and are NOT folded into the main/entry chunk.
- Approach: The active locale's namespaces load for first paint; other locales become separate chunks fetched on `changeLanguage`. The per-locale `import()` template is **app-relative** over the generated `app/locales/` tree (KTD3a), so Vite static-analyzes and splits it — no dynamic-string specifier or `@vite-ignore` in app code (AGENTS.md), and no dependency on undocumented cross-package symlink resolution. The build assertion is a regression guard that the split actually holds. First paint gates on i18next `ready` so no raw-key flash occurs (the catalog fetch is the only async step before the tree is translatable).
- Patterns to follow: `prefetchLazyViews()` idle-prefetch idiom and `React.lazy("./...")` relative-literal import style in `App.tsx` (the exact shape the locale `import()` mirrors); existing provider-nesting convention (`context/XContext.tsx` + `use*` hook); the engine `dist` gitignored-generated convention for `app/locales/`.
- Test scenarios:
- With no stored preference and `navigator.language = "fr"`, the resolved locale is `fr`; with `navigator.language = "de"` (unsupported), it falls back to `en`.
- A stored `localStorage` locale wins over `navigator`.
- `zh` (generic) resolves to `zh-CN` via fallback; `zh-Hant` resolves to `zh-TW`; `zh-CN` does not collapse to a generic `zh`.
- Switching to a not-yet-loaded locale triggers exactly one catalog fetch and re-renders translated text.
- A missing key in `fr` falls back to the `en` value, not the raw key.
- First paint shows the loading state (not raw keys) until the active catalog resolves.
- Build assertion: each non-`en` locale produces its own async chunk; the main entry chunk contains no non-`en` catalog payload.
- Covers R5, R6.
- Verification: Dashboard boots showing the loading state then the resolved locale (no raw-key flash); switching locale at runtime updates rendered strings; the build assertion confirms per-locale chunks exist and the initial chunk carries only the active locale.
### U4. Dashboard language switcher and persistence
- Goal: Let users pick a language in Settings and persist it across reloads and to the server.
- Requirements: R7.
- Dependencies: U3.
- Files:
- `packages/dashboard/app/hooks/useLanguage.ts` — read/set active locale; `localStorage` cache (`kb-dashboard-language`, neighbor-consistent prefix per KTD8) + `updateGlobalSettings({ language })` write-through; hydrate from `fetchGlobalSettings` on mount.
- `packages/dashboard/app/components/LanguageSelector.tsx` — option list modeled on `ThemeSelector.tsx` (`{value, label}` with native locale endonyms: 简体中文 / 繁體中文 / Français / Español / English).
- `packages/dashboard/app/components/SettingsModal.tsx` — render `LanguageSelector` in the Appearance section (case `"appearance"`, line ~3691, beside `ThemeSelector`).
- `packages/dashboard/app/hooks/__tests__/useLanguage.test.ts`, `packages/dashboard/app/components/__tests__/LanguageSelector.test.tsx` (lands in the `settings-*` test shard).
- Approach: Three-tier persistence exactly as `useTheme.ts`: instant `localStorage` pre-hydration read, server write-through, hydrate-from-server on mount. **Switching applies in place via `changeLanguage` — no full-page reload** (a reload would drop unsaved form state and disrupt in-flight agent views; the sequence diagram reflects this). This requires components to read copy through `useTranslation`/`t()` rather than caching resolved strings outside the React tree, so the re-render propagates. Endonyms are intentionally untranslated (each language names itself). Placement beside `ThemeSelector` in Appearance is the pragmatic home; if Settings grows a General/Preferences section later, language is a content (not visual) preference and would migrate there — noted, not blocking.
- Patterns to follow: `useTheme.ts` (three-tier persistence) and `ThemeSelector.tsx` (switcher UI); Appearance-section rendering in `SettingsModal.tsx`.
- Test scenarios:
- Selecting a locale calls `changeLanguage`, writes the `localStorage` key, and calls `updateGlobalSettings` once with the new code.
- Switching language updates rendered copy in place with no `window.location.reload` (assert no reload occurs).
- On mount, a server `GlobalSettings.language` value hydrates the active locale when no fresher `localStorage` value exists.
- `localStorage`-unavailable path degrades gracefully (no throw; server write still attempted).
- Selector shows all five endonyms and marks the active one.
- Covers R7.
- Verification: Changing language in Settings persists across a reload and is reflected in server settings; mobile (Capacitor) build inherits the switcher with no extra work.
### U5. Dashboard string migration and locale-aware formatting
- Goal: Replace hardcoded user-facing strings with `t()` keys and thread the active locale into date/number formatting — incrementally.
- Requirements: R8, and the dashboard portion of R2 (real keys in catalogs).
- Dependencies: U3, U4.
- Files (incremental — representative, not exhaustive):
- `packages/core/src/index.ts` consumers of `COLUMN_LABELS`/`COLUMN_DESCRIPTIONS` — route through i18n keys first (already centralized → cheapest win).
- `packages/dashboard/app/components/SettingsModal.tsx`, `ThemeSelector.tsx`, and Appearance/Settings copy — first migrated view cluster.
- High-traffic shells: `App.tsx` toast/error strings (e.g. lines ~1036, ~1327), primary navigation and board column UI.
- The ~45 `toLocale*` call sites (e.g. `ActivityFeed.tsx`, `AgentDetailView.tsx`, `ActivityLogModal.tsx`) — pass the active locale or route through a shared `formatDate`/`formatNumber` helper bound to i18next's Intl formatters.
- `packages/i18n/locales/en/*.json` — grows with each migrated cluster.
- Approach: **Incremental per-view migration**, starting with already-centralized constants and the Settings surface, then high-traffic views. Do not attempt a big-bang sweep of all 464 files in one unit — land clusters as separate commits under this unit. Keep diffs JSX-surgical (text → `t("key")`); do not disturb component CSS imports. Run `i18n:extract` after each cluster to keep `en` in sync. Brand tokens become `{{brand}}` variables (KTD9).
- Completion criterion (unit exit gate): this unit is done when a **named set of high-traffic/centralized surfaces** — board column UI (`COLUMN_LABELS`/`COLUMN_DESCRIPTIONS`), Settings, primary navigation, and `App.tsx` toast/error strings — are fully migrated and `i18n:status` reports the agreed coverage threshold for that named set. The low-traffic long tail is explicitly out of this unit (Scope Boundaries). Without this gate U5 has no reviewable done state; the threshold is the exit signal, not "all 464 files."
- Execution note: Lead each cluster by adding the keys to the `en` catalog (via `i18n:extract`) and a render test asserting the translated output, then swap the source strings.
- Patterns to follow: existing `useTranslation`-style hook usage; the centralized-constants pattern in core.
- Test scenarios:
- A migrated component renders the `en` value identical to its prior hardcoded string (no visible regression).
- The same component renders the `fr` value when locale is `fr` (fixture catalog).
- A date rendered via the shared helper formats per active locale (`en` vs `fr` vs `zh-CN` differ as expected).
- A pluralized string (e.g. "N tasks") selects the correct CLDR category in `en`, `fr`, and `zh-CN` (single `other`).
- A width-constrained component (board column header, sidebar item, badge/chip) with a long translated string (e.g. a ~35% longer `fr`/`de`-shaped fixture) truncates/wraps per its CSS rather than overflowing or breaking layout.
- Covers R8.
- Verification: Migrated clusters show no English regression in `en`; switching to a populated fixture locale changes the rendered copy and date/number formatting; width-constrained components hold their layout under longer strings; `i18n:status` hits the named-set threshold.
### U6. CLI i18n runtime and CJK-safe rendering
- Goal: Stand up the Node-side i18next instance for the Ink TUI with env/flag detection and width-safe layout.
- Requirements: R9, R10, R11.
- Dependencies: U1, U2.
- Files:
- `packages/cli/src/i18n/index.ts` — synchronous init (`initImmediate: false`), `initReactI18next`, catalogs from the **generated** `@fusion/i18n` `cli-catalogs.ts` import map (`cli` + shared namespaces; tsup inlines via existing `noExternal: [/^@fusion\//]` + `splitting: false`), `fallbackLng: 'en'`, env detection (`LC_ALL → LC_MESSAGES → LANG → LANGUAGE`). Using the generated map (not a hand-edited import block) is what makes a new locale drop-in here, satisfying R14 on the CLI surface.
- `packages/cli/src/commands/dashboard-tui/controller.ts` — wrap `DashboardApp` with `<I18nextProvider>` at the `render(...)` call (line 685); resolve locale via precedence flag → `GlobalSettings.language` → env → `en`.
- `packages/cli/src/bin.ts` and the relevant command(s) — accept a `--lang` flag and pass it into init.
- Ink width handling: decide between **upgrading `ink` 6.8 → 7.0** (correct CJK double-width measurement built in) or staying on 6.8 with explicit `string-width` math at hand-built layout sites. If upgrading: bump `react`/`@types/react` in `packages/cli/package.json` from `^19.0.0` to `^19.2.0` to match Ink 7's declared peer floor, and add `string-width` only if staying on 6.8. See Risks for the trade-off.
- `packages/cli/src/i18n/__tests__/i18n.test.ts`, plus a TUI width/snapshot test.
- Execution note: **Spike first** — before committing the runtime, verify `useTranslation` re-renders on `changeLanguage` and `<Trans>` interpolation behave under Ink's custom reconciler (not react-dom). The single-runtime decision (KTD1) assumes this works; there is no cited precedent. If `<Trans>` misbehaves under Ink, prefer plain `t()` calls over `<Trans>` in TUI components (the synchronous `initImmediate: false` init already avoids the Suspense path).
- Approach: Separate instance from the browser (no DOM/HTTP backend). Catalogs bundle into the published binary via tsup; keep them small. Detection precedence is explicit and unit-tested. Never translate single-letter keybinding accelerators (`C`/`V`/`X`/`P`/…) — translate only their labels.
- Patterns to follow: existing Ink render setup in `controller.ts`; existing command/flag parsing in `commands/`.
- Test scenarios:
- `--lang zh-TW` overrides a conflicting `GlobalSettings.language` and a conflicting `LANG` env value.
- With no flag and no setting, `LANG=fr_FR.UTF-8` resolves to `fr`; an unsupported `LANG=de_DE` falls back to `en`.
- First rendered frame is already localized (synchronous init — no English flash).
- `changeLanguage` under the Ink reconciler re-renders translated labels (validates the single-runtime assumption).
- A CJK-translated label in a bordered/columnar layout does not misalign borders (width measured as double-width).
- Keybinding accelerators remain ASCII single letters regardless of locale.
- Covers R9, R10, R11.
- Verification: `fusion` TUI launches localized per env/flag/settings; CJK labels render without breaking section layouts; a newly-added locale appears in the TUI after regen with no hand-edited CLI source; published CLI dependency footprint stays minimal.
### U7. CLI string migration
- Goal: Migrate Ink TUI user-facing labels into the `cli` (and shared) namespaces.
- Requirements: R12 readiness for the CLI surface; CLI portion of R2.
- Dependencies: U6.
- Files (representative):
- `packages/cli/src/commands/dashboard-tui/app.tsx` and TUI sub-components — `<Text>` literals → `t()`.
- `packages/cli/src/commands/dashboard-tui/state.ts` — `SECTION_ORDER`/section labels routed through keys.
- `packages/cli/src/commands/*.ts` — top-level CLI command output strings (help/status lines) as warranted.
- `packages/i18n/locales/en/cli.json` — grows with migrated keys.
- Approach: Same JSX-surgical discipline as U5. Translate labels and prose; leave accelerators, file paths, and special-cased QR/SVG payloads untouched. Run `i18n:extract` after each cluster.
- Patterns to follow: U5's migration approach; existing Ink component structure.
- Test scenarios:
- A migrated TUI screen renders identical `en` output to its prior hardcoded form.
- The same screen renders translated output under a fixture locale.
- Width-sensitive sections still align with the longest translated label (CJK fixture).
- Covers the CLI portion of R2.
- Verification: TUI screens render correctly in `en` and a populated fixture locale; `i18n:status` reflects `cli` namespace coverage.
### U8. Translations and contributor workflow
- Goal: Ship complete `zh-CN`, `zh-TW`, `fr`, `es` catalogs and document the drop-in language process.
- Requirements: R12, R14 (documentation), R13 (status gate enforced full).
- Dependencies: U5, U7 (catalogs must be key-complete before translation).
- Files:
- `packages/i18n/locales/{zh-CN,zh-TW,fr,es}/*.json` — filled translations (each independently localized; zh-CN and zh-TW never auto-converted).
- `docs/i18n-contributing.md` — "Adding a language" + "Updating translations" guide: the `i18next.config.ts` + `SUPPORTED_LOCALES` two-line registration, `i18n:sync` to scaffold, translate, `i18n:status` to verify.
- `docs/contributing.md` and `docs/cli-reference.md` — cross-link the i18n guide; document the `--lang` flag and the Settings language control.
- CI — flip the `status` gate to require 100% completion for the **shipped key set** per locale; newly-added migration keys land on the pending allowlist (U2) until translated, so adding `en` keys never deadlocks CI mid-migration.
- Approach: Translations may be sourced however the team prefers (in-house, vendor, community) — the unit's engineering deliverable is key-complete, plural-correct, structurally-synced catalogs plus the gate and docs. **Precondition:** because the 100% gate cannot pass without real translations, name the sourcing owner/path before U8 starts; until then the four locales stay on the pending allowlist (not "shipped") so the rest of the plan ships an English-only-but-localization-ready product without a red CI gate. Endonyms and brand `{{brand}}` variables handled per KTD9.
- Patterns to follow: existing `docs/` topical-guide structure.
- Test scenarios:
- `i18n:status` reports 100% for all four locales (CI gate passes).
- Each locale loads and renders in the dashboard and TUI without missing-key fallback to `en` for shipped namespaces.
- French/Spanish plural-bearing keys carry the `one`/`many`/`other` forms; both Chinese carry only `other`.
- Test expectation for the docs file: none -- documentation only.
- Covers R12, R14.
- Verification: All four locales render end-to-end on both surfaces; the "add a language" doc walks a contributor from zero to a synced catalog set with no feature-code edits.
---
## Scope Boundaries
In scope: the localization runtime for both surfaces, English extraction of user-facing strings (incremental), the four target locales, locale-aware formatting, persistence, and the contributor workflow.
### Deferred to Follow-Up Work
- A dedicated `eslint-plugin-i18next`/`no-restricted-syntax` editor-time guardrail for hardcoded strings (KTD6 uses `i18next-cli lint` in CI as the primary guard; the ESLint rule is additive polish and noisy across 464 files).
- Migrating the **long tail** of low-traffic dashboard views — U5 establishes the pattern and covers high-traffic/centralized surfaces; remaining views are mechanical follow-ups gated by `i18n:status` rather than blocking this plan.
- Server-side / API error message localization beyond what surfaces in the UI (the Express API in `packages/dashboard/src` is not a user-reading surface).
- RTL language support (Arabic/Hebrew) — none of the target locales are RTL; layout mirroring is out of scope until an RTL locale is requested.
- Switching the persisted-locale `localStorage` key from the `kb-` to `fn-` prefix — owned by the kb→fn rename track, not this plan (KTD8).
### Outside this work
- Translating documentation, marketing, or README content (this is product-UI localization).
- A translation-management SaaS integration (Locize, Crowdin, etc.) — the `i18next-cli` file-based workflow is the chosen path.
---
## Risks & Dependencies
- **Ink 6.8 → 7.0 upgrade for CJK width (U6).** Ink 7.0 measures CJK as double-width (via `string-width`) and fixes column/border alignment, but it also reworked input handling (a major bump needing a TUI regression pass) and raises the React peer floor to 19.2. Mitigation: prefer the upgrade for correctness with the React peer bump (U6); fallback is staying on 6.8 with explicit `string-width` math at hand-built layout sites if the input-handling changes prove disruptive. Decide early in U6; treat as the unit's primary risk.
- **Vite variable dynamic-import code-splitting (KTD3a/U3).** Vite 6 only code-splits a variable `import()` when the prefix is *relative to the importing file*; an aliased, bare, or cross-package specifier defeats `@rollup/plugin-dynamic-import-vars`, and `import.meta.glob` won't cross `node_modules`. The plan resolves this by making the **app-local generated-catalog path primary** (catalogs synced into a gitignored `app/locales/`, imported by a plainly app-relative template) rather than relying on undocumented cross-package symlink resolution. Residual risk: the dashboard has no existing variable-dynamic-import precedent, so U3 keeps a build assertion that per-locale chunks actually emit, as a regression guard before U5 builds on the runtime.
- **react-i18next under the Ink reconciler (KTD1/U6).** The single-runtime decision assumes `useTranslation`/`<Trans>` work under Ink's custom (non-react-dom) reconciler; there is no cited precedent. Mitigation: U6 spikes this before committing the runtime; synchronous `initImmediate: false` init already sidesteps Suspense, and plain `t()` is the fallback if `<Trans>` misbehaves.
- **CLI ships all locales in the published binary.** tsup `splitting: false` + `noExternal` inlines every catalog into `@runfusion/fusion`'s `dist/bin.js`, growing linearly with translation volume and locale count. Mitigation: accepted tradeoff for v1; keep the `cli` namespace separate so dashboard strings aren't dragged in; lazy fs-loading of copied catalog assets is a future optimization if size becomes material.
- **String-extraction surface is large (~464 dashboard files).** Mitigation: incremental per-view migration (U5/U7) with a named-set completion gate, not a big-bang sweep; the plan ships a working foundation + high-traffic coverage and defers the long tail.
- **kb→fn rename collision.** Touching user-facing strings during the in-flight rename risks churn. Mitigation: KTD9 (brand tokens as `{{brand}}` variables) and KTD8 (reuse `kb-` localStorage prefix) keep this plan rename-neutral.
- **Published-package resolution.** `@fusion/i18n` is a private workspace dep inlined into the published CLI; without stripping it from the publish manifest, `npm install @runfusion/fusion` would 404. Mitigation: U2 adds `@fusion/i18n` to `prepare-publish-manifest.mjs` and verifies via `npm pack`; core `i18next` + `react-i18next` only in the CLI runtime, tooling in devDependencies, plus the required changeset.
- **Translation sourcing & quality / zh-CN vs zh-TW.** The two Chinese catalogs must be independently localized; auto-conversion produces unnatural text (KTD7). The 100% CI gate cannot pass without real translations and every migrated `en` key re-opens locale gaps — mitigated by the pending-keys allowlist (U2/U8) and by naming a sourcing owner before U8 starts. Dependency: actual translation sourcing (process, not engineering).
---
## Sources / Research
- i18next v26 (built-in Intl formatter always active; legacy `interpolation.format` removed; `TFunction` from `i18next`) — i18next migration guide & TypeScript docs.
- `i18next-cli` (SWC-based extract/sync/types/status/lint) replaces the archived `i18next-parser` (archived Feb 2026) — i18next-cli GitHub.
- Lazy catalogs via `i18next-resources-to-backend` + Vite dynamic import — i18next "add or load translations"; lazy-loading guidance.
- Script-aware Chinese fallback + `load: 'currentOnly'` for zh-CN/zh-TW separation — i18next fallback principles, issue #1467.
- Ink 7.0 CJK/`string-width` rendering fixes — Ink GitHub; Ink 7.0 release writeup.
- Env-based CLI detection (`LC_ALL/LANG/…`) — `i18next-cli-language-detector`.
- Vite 6 variable-dynamic-import code-splitting rules (relative-prefix requirement, one `*` per segment, no alias/bare/node_modules resolution via `@rollup/plugin-dynamic-import-vars`; `import.meta.glob` cross-package limitation) — Vite Features docs; `vitejs/vite` issues #2390, #5728, #12180; `i18next-resources-to-backend` recommended loader shape (i18next docs). Drives KTD3a and the U3 build-assertion gate.
- `@fusion/i18n` as the catalog home (vs. `@fusion/core` layering inversion, vs. dashboard-app-tree dependency inversion) — repo seams: dashboard `@fusion/core` type-only alias `packages/dashboard/vite.config.ts:124`; CLI `noExternal: [/^@fusion\//]` + `splitting:false` + `conditions:["source"]` in `packages/cli/tsup.config.ts`; core `files:["dist","README.md"]` in `packages/core/package.json`.
- Repo seams: provider stack `packages/dashboard/app/App.tsx:2044`; settings `packages/core/src/types.ts:2433`, `packages/core/src/settings-schema.ts:18`; theme-persistence pattern `packages/dashboard/app/hooks/useTheme.ts`; Settings Appearance `packages/dashboard/app/components/SettingsModal.tsx:3691`; Ink mount `packages/cli/src/commands/dashboard-tui/controller.ts:685`; ESLint `no-restricted-syntax` precedent `eslint.config.mjs`; AGENTS.md changeset (published-only) and static-import rules.

View File

@@ -33,6 +33,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|---|---|---:|---|
| `themeMode` | `"dark" \| "light" \| "system"` | `"dark"` | Dashboard theme mode. |
| `colorTheme` | `ColorTheme` | `"default"` | Dashboard color theme preset. |
| `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). |
| `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. |
| `defaultProvider` | `string` | `undefined` | Default AI provider. |
| `defaultModelId` | `string` | `undefined` | Default AI model ID. |

View File

@@ -0,0 +1,185 @@
---
title: "i18n foundation for a Vite + Ink monorepo with code-split catalogs"
date: 2026-06-03
category: architecture-patterns
module: "@fusion/i18n"
problem_type: architecture_pattern
component: tooling
severity: medium
related_components:
- "@fusion/i18n"
- "@fusion/core"
- dashboard
- cli
applies_when:
- "Adding i18n to a pnpm monorepo that ships both a Vite/React surface and an Ink/CJK TUI"
- "Code-split locale catalogs are needed and Vite dynamic-import-vars rejects cross-package or aliased specifiers"
- "A single source of truth for locale + namespace lists must be shared across packages"
- "Supporting Chinese locale pairs where zh-CN and zh-TW must never collapse into a generic zh"
- "Upgrading i18next to v26 (initImmediate removed; synchronous init with inline resources)"
symptoms:
- "Locale catalogs leak into the main bundle instead of code-splitting into per-locale chunks"
- "vi.mock of @fusion/core breaks CI after adding a new SUPPORTED_LOCALES export"
- "i18next v26 rejects initImmediate; convertDetectedLanguage silently ignored at top level"
tags:
- i18n
- vite
- ink
- react-i18next
- code-splitting
- monorepo
- cjk
- dynamic-import
---
# i18n foundation for a Vite + Ink monorepo with code-split catalogs
## Context
Fusion ships two UI surfaces from one pnpm monorepo: a React/Vite browser dashboard and an Ink-based terminal UI. The goal was full internationalization (en, zh-CN, zh-TW, fr, es) across both surfaces with `react-i18next` v17 / `i18next` v26, while keeping "add a new locale" a near-zero-code operation and ensuring per-locale catalogs never bloat the initial bundle. The two surfaces have different runtimes (browser with lazy code-splitting vs. Node with synchronous startup), so each builds its own `i18next` instance but they share locale list, namespace split, fallback chain, and base options from a single `@fusion/i18n` package. Shipped in PR #1352; this doc captures the load-bearing constraints and failure modes the plan and contributor guide do not cover.
## Guidance
**Single source of truth for namespaces.** `packages/i18n/namespaces.json` is the one file that defines which namespaces exist and which surface loads which subset. The runtime config and all three build scripts read it, so they can't drift:
```json
{ "all": ["common","app","errors","cli"],
"dashboard": ["common","app","errors"],
"cli": ["common","cli","errors"] }
```
**Relative dynamic-import + sync-script pattern (dashboard code-splitting).** `@rollup/plugin-dynamic-import-vars` only splits a variable dynamic import if the specifier is app-relative with one variable per path segment. A cross-package or aliased specifier silently defeats it and inlines every catalog into the main bundle. So a predev/prebuild script (`packages/dashboard/scripts/sync-locales.mjs`) copies catalogs from `packages/i18n/locales/` into the gitignored `packages/dashboard/app/locales/`, and the runtime imports them app-relatively:
```ts
// packages/dashboard/app/i18n/index.ts
i18next.use(resourcesToBackend((language: string, namespace: string) =>
import(`../locales/${language}/${namespace}.json`)))
```
A build-time guard, `packages/dashboard/scripts/assert-locale-chunks.mjs`, derives the expected chunk count from the authored locale directory (no hardcoded count) and fails the build if any locale/namespace chunk is missing or catalog content leaks into `index-*.js`. The failure mode is silent otherwise — the guard is what makes the split durable.
**`normalizeToSupportedLocale` — shared Chinese script/region routing.** One helper, used by both dashboard navigator detection (`convertDetectedLanguage`) and CLI env detection, so zh resolution is identical on both surfaces:
```ts
// packages/i18n/src/config.ts
export function normalizeToSupportedLocale(tag: string): Locale | undefined {
if (!tag) return undefined;
const norm = tag.replaceAll("_", "-"); // POSIX zh_CN → zh-CN
if (isLocale(norm)) return norm;
const lower = norm.toLowerCase();
if (lower.startsWith("zh")) {
if (lower.includes("hans")) return "zh-CN"; // explicit script wins over region (zh-Hans-HK)
if (lower.includes("hant") || lower.includes("-tw") ||
lower.includes("-hk") || lower.includes("-mo")) return "zh-TW";
return "zh-CN";
}
const base = lower.split("-")[0]; // fr-FR → fr
return isLocale(base) ? base : undefined;
}
```
Paired with a script-aware fallback chain plus `load: "currentOnly"` in `baseInitOptions`, so `zh-CN`/`zh-TW` never collapse into a generic `zh`:
```ts
export const FALLBACK_LNG = { "zh-Hans": ["zh-CN"], "zh-Hant": ["zh-TW"],
zh: ["zh-CN"], default: [DEFAULT_LOCALE] };
```
**Three-tier persistence (`useLanguage`, mirrored from `useTheme`).** localStorage cache (`kb-dashboard-language`) + server write-through to `GlobalSettings.language` + hydrate-on-mount, with a `userSetRef` guard so in-flight hydration can't clobber a concurrent user choice, plus a cross-tab `storage` listener:
```ts
// packages/dashboard/app/hooks/useLanguage.ts
const setLanguage = useCallback((locale: Locale) => {
userSetRef.current = true; // user choice now wins over hydration
void i18n.changeLanguage(locale);
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
void updateGlobalSettings({ language: locale }).catch(/* warn, don't throw */);
}, [i18n]);
// hydrate effect — the guard the ref protects:
void fetchGlobalSettings().then((settings) => {
if (cancelled || userSetRef.current) return; // never clobber a user choice
...
});
```
The write boundary is also guarded server-side in `packages/core/src/store.ts` via `validateLocale` (invalid values are dropped, never persisted).
**CLI inline-resources init (synchronous startup).** The TUI bundles all catalogs statically via a generated `cliResources` map and inits synchronously, so the first Ink frame is already localized — no async backend, no flash of raw keys:
```ts
// packages/cli/src/i18n/index.ts
void i18next.use(initReactI18next).init({
...baseInitOptions(),
lng: locale,
ns: [...CLI_NAMESPACES],
resources: cliResources as unknown as Resource, // inline → sync init
react: { useSuspense: false },
});
```
`cliResources` is generated by `packages/i18n/scripts/gen-cli-catalogs.mjs` from the `locales/` directory — adding a locale is "add the folder, regenerate, done." CLI locale precedence: `--lang` flag → `GlobalSettings.language` → env (`LC_ALL`/`LANG`/…) → `en`.
**Locale primitives live in `packages/core/src/types.ts`.** The dashboard's Vite alias resolves the entire `@fusion/core` package to that single file:
```ts
// packages/dashboard/vite.config.ts
"@fusion/core": resolve(__dirname, "../core/src/types.ts"),
```
Anything the dashboard imports from `@fusion/core` must be reachable from `types.ts` — `SUPPORTED_LOCALES`, `Locale`, `DEFAULT_LOCALE`, and `isLocale` were placed there, not in a new module.
## What Didn't Work
1. **`initImmediate: true` at top level** — rejected by i18next v26's `InitOptions` types. v26 removed the flag; with inline `resources` and no async backend, init is already synchronous, so the fix was to drop the option entirely.
2. **`convertDetectedLanguage` as a top-level `InitOptions` field** — silently ignored. It is an `i18next-browser-languagedetector` option and belongs inside the `detection` block. Moving it there made navigator normalization (e.g. `zh-Hant-TW` → `zh-TW`) actually fire.
3. **Vitest couldn't resolve `@fusion/core`** without a per-package alias-to-source — the dashboard's runtime Vite alias does not carry into the test runner, so each consuming package needed its own `vitest.config.ts` alias.
4. **CI shard 1: `vi.mock("@fusion/core")` in `settings.test.ts` was incomplete** — the hand-written mock lacked the newly added `SUPPORTED_LOCALES` export, so the locale enum resolved to `undefined` and the shard failed deterministically (initially misread locally as flaky). Lesson: a manual module mock is a second source of truth that must be updated alongside the real module's public surface.
5. **CI shard 2: `LanguageSelector.css` used the banned `--text-secondary` token** — the FN-4286 text-token-canonicalization guard rejects it; canonical is `--text-muted`.
## Why This Matters
- **Bundle-size correctness.** The relative-import + assert-chunks guard is the difference between lazily-fetched per-locale chunks and one fat main bundle carrying all five languages — and the regression is silent without the guard.
- **zh-CN / zh-TW correctness.** Simplified vs. Traditional Chinese are not interchangeable. The normalizer + script-aware fallback + `load: "currentOnly"` guarantee Traditional-script users never silently get Simplified.
- **Single source of truth prevents drift.** `namespaces.json` is read by config, sync-locales, assert-locale-chunks, and gen-cli-catalogs; chunk-count expectations are derived, not hardcoded.
- **Settings parity across surfaces.** Both surfaces detect via the same normalizer, persist to the same `GlobalSettings.language`, and validate at the same store boundary — a locale chosen in the dashboard carries into the TUI and vice versa.
## When to Apply
- Adding i18n to a Vite app that relies on code-splitting catalogs — the relative-import constraint and a chunk-leak guard apply directly.
- Supporting script/region-split language pairs (zh-CN/zh-TW, sr-Latn/sr-Cyrl) where a generic base tag must not collapse the variants.
- Terminal/CJK UIs: budget for double-width rendering (Ink 6.8→7.0 here) and prefer inline-resource synchronous init so the first frame is localized.
- Monorepos where catalogs live in a shared package but the consuming app's bundler only statically analyzes app-relative specifiers — copy into the app tree (gitignored) rather than importing cross-package.
## Examples
**Cross-package import that fails vs. app-relative that splits:**
```ts
// ✗ defeats @rollup/plugin-dynamic-import-vars — all catalogs inline into main bundle
import(`@fusion/i18n/locales/${language}/${namespace}.json`)
// ✓ app-relative, one var per segment — Vite emits one chunk per locale/namespace
import(`../locales/${language}/${namespace}.json`)
```
**i18next v26 migration:**
```ts
// ✗ i18next ≤25
i18next.init({ initImmediate: false, convertDetectedLanguage: normalize, ... })
// ✓ i18next 26: initImmediate gone (inline resources are sync anyway);
// convertDetectedLanguage lives in the languagedetector detection block
i18next.use(LanguageDetector).init({
detection: { order: [...], convertDetectedLanguage: normalize },
...
})
```
## Related
- `docs/i18n-contributing.md` — the how-to guide for adding locales/strings (workflow; this doc covers the failure modes behind it)
- `docs/plans/2026-06-03-001-feat-ui-localization-i18n-plan.md` — the implementation plan (status: completed)
- GitHub issue #64 — "[i18n] Chinese (Simplified) Language Support for Dashboard" (originating request)
- PR #1352 — the implementation

View File

@@ -0,0 +1,77 @@
---
title: "Orchestrating a mass code migration with an agent fleet"
date: 2026-06-04
category: architecture-patterns
module: workflow-orchestration
problem_type: architecture_pattern
component: development_workflow
severity: medium
applies_when:
- "A mechanical change must land across hundreds of files (string migration, API rename, codemod-with-judgment)"
- "Many agents would otherwise contend on one shared artifact (catalog/registry/index file)"
- "Agent-made changes must be verified against a test suite with pre-existing local failures"
tags:
- multi-agent
- workflow
- i18n-migration
- test-triage
- structured-output
---
# Orchestrating a mass code migration with an agent fleet
## Context
The i18n full sweep (PR #1352) migrated ~6,900 strings across 216 files to i18next keys with 6-locale machine drafts, using ~190 subagents over three workflow rounds. The orchestration itself failed twice before working, and the verification phase initially looked like 1,051 test failures. These are the patterns that made it land.
## Guidance
**Partition by file ownership; size batches by work density, not file count.** Build file-disjoint batches from a scouted work-list (grep-count translatable strings per file); heavy files (here ≥40 hits) get a dedicated agent, light files get batched 3–8 per agent. No two agents ever own the same file → no worktree isolation or merge step needed.
**Never let N agents write one shared artifact.** All 60 migrators needed to add keys to the same 20 catalog JSONs. Instead: each agent writes its own fragment file (`/tmp/frags/batch-N.json`) and the orchestrator merges deterministically afterward (with a quote-escaping repair pass — about 1 in 50 LLM-written JSON fragments has unescaped quotes inside values).
**Keep the mechanical change behavior-invariant so tests stay meaningful.** Here: `t("key", "Exact original English")` inline defaults made `en` rendering byte-identical, so the existing suite verified the migration for free.
**Pair every migrator with a read-only verifier, and require honesty fields.** A second-stage agent re-reads the files and reports residuals; the migrator schema includes `files_partial`/`files_skipped`. Round 1 verifiers found 457 residual strings the migrators had claimed done or silently skipped — that report became the exact work-list for round 2 (whose agents also got the regressed test names and self-verified by running them).
**Workflow plumbing that failed before it worked:**
- All 60 agents "completed without calling StructuredOutput" → the prompt must say the FINAL action is the StructuredOutput call and that ending without it discards the work.
- `args` can arrive JSON-stringified → parse defensively: `const A = typeof args === 'string' ? JSON.parse(args) : args`.
- A "loader agent" asked to read the work-list file returned an empty list — pass payloads inline in args, don't delegate orchestration inputs to an agent.
- Concurrency cap below the runtime default: chunked `parallel()` groups with a barrier per chunk (`for (i; i += CHUNK) await parallel(group...)`).
**Triage agent-caused test failures with a clean-baseline diff, not absolute counts.** Run the suite on the branch AND on a clean `origin/main` worktree in the same environment; only the set difference is yours. Here 276 local failures shrank to 23 real regressions (the rest were local-environment fake-timer/`waitFor` hangs that CI's Linux runners never see). Without the baseline, days would have been wasted "fixing" pre-existing noise — with it, CI went green on the first push of the full sweep.
## Why This Matters
Fleet output quality is bimodal: most batches are clean, a predictable tail is partial or subtly wrong (flattened inline JSX markup, helper functions left unmigrated, hook added above an early return). The verify-stage + honesty-fields + round-2 loop converts that tail from "silent regressions" into a bounded, enumerable work-list. And the baseline-diff discipline keeps the operator from drowning in failures the fleet didn't cause.
## When to Apply
- Any 100+-file mechanical migration where per-file judgment is still needed (so a codemod alone won't do).
- Whenever multiple agents must contribute entries to one registry/catalog/index — fragment-and-merge, never shared writes.
- Any time a large agent-made diff meets a test suite that isn't green locally — build the clean-baseline failure set first.
## Examples
Migrator/verifier pipeline stage shape (workflow script):
```js
const results = await pipeline(batches,
(b) => agent(migratePrompt(b), { schema: MIGRATE_SCHEMA, model: big(b) ? 'sonnet' : 'haiku' }),
(m, b) => m && agent(verifyPrompt(b, m), { schema: VERIFY_SCHEMA, model: 'haiku' })
.then((v) => ({ b, m, v })))
```
Baseline-diff triage:
```bash
git worktree add /tmp/main-probe origin/main && (cd /tmp/main-probe && pnpm i && vitest run > baseline.txt)
# delta = failures(branch) − failures(baseline) → only fix the delta
```
## Related
- `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md` — the "N stabilization passes means product race" rule used during triage
- AGENTS.md → "Fix the Invariant, Not the Repro (FN-5893)"
- PR #1352 — the i18n full sweep this pattern shipped

View File

@@ -0,0 +1,120 @@
---
title: "Skill autocomplete highlight reset on SWR cache revalidation"
date: 2026-06-03
category: ui-bugs
module: packages/dashboard/app/components/ChatView
problem_type: ui_bug
component: frontend_stimulus
symptoms:
- "Open slash-skill autocomplete menu loses keyboard highlight position when SWR cache revalidation lands mid-interaction"
- "ChatView arrow-navigation test fails intermittently on CI shards: expected chat-skill-menu-item--highlighted class missing"
- "Test passes locally but flakes on slow CI; three prior stabilization commits (FN-5864, FN-5745, FN-5725) patched symptoms without finding the cause"
- "Highlight resets to index 0 whenever useDiscoveredSkillsCache re-delivers a content-identical list with a fresh array identity"
root_cause: async_timing
resolution_type: code_fix
severity: medium
related_components:
- testing_framework
tags:
- swr-cache
- react-useeffect
- array-identity
- flaky-test
- async-race
- highlight-state
- test-isolation
- chatview
---
# Skill autocomplete highlight reset on SWR cache revalidation
## Problem
A recurring "flaky" CI test in the Fusion dashboard — ArrowUp in the slash-skill menu failing to highlight the wrapped-to-last item — was root-caused as a real product bug, not a test-environment quirk: the SWR skills cache re-delivers content-identical skill lists with fresh array *identities*, and a highlight-reset `useEffect` keyed on that identity wiped the user's (and the test's) keyboard position whenever a revalidation landed mid-navigation. Fixed in commit `87d044f18`.
## Symptoms
- CI shard intermittently failed asserting a menu item carried the `chat-skill-menu-item--highlighted` class after `ArrowUp` (expected wrap to the last item); the class was missing.
- Passed locally every run; the failure only surfaced on CI shards.
- A repeat-stabilization history: the same test family (`packages/dashboard/app/components/__tests__/ChatView.test.tsx`) had been "stabilized" three separate times without the flake going away — a tell-tale sign the cause was upstream of the assertions.
## What Didn't Work
The initial instinct — treat it as a flaky environment and rerun the shard — only masked it, because the race window simply didn't reopen every run. Three prior commits each patched the *test*, never the product:
- **FN-5725** (`158f74962`, "streamline ChatView room-creation and slash-autocomplete tests") — reduced setup and tightened slash-autocomplete assertions; a net deletion of test code. Touched assertions/structure only.
- **FN-5745** (`8e2c19285`, "optimize ChatView agent-mentions test timing") — swapped to a local user-event instance and replaced async DOM lookup with immediate assertion. Pure timing/interaction tuning.
- **FN-5864** (`04e1701d9`, "stabilize ChatView skill-menu keyboard assertions") — wrapped the post-`ArrowUp` highlighted-option assertion in `waitFor` to avoid synchronous timing races.
None could fix it: `waitFor` retries an assertion, but here a *later* async event (revalidation) actively resets state back to `0` after the highlight is correct, so waiting longer can land on the wrong side of the reset. The bug was a product state race, and no amount of assertion/timing massaging in the test addresses product state.
## Solution
Key the reset effect on a *semantic* derived value (the joined skill-id list) instead of array identity. `packages/dashboard/app/components/ChatView.tsx`:
Before:
```tsx
useEffect(() => {
setHighlightedSkillIndex(0);
}, [filteredSkills]);
```
After:
```tsx
const filteredSkillsKey = useMemo(
() => filteredSkills.map((skill) => skill.id).join(" "),
[filteredSkills],
);
useEffect(() => {
setHighlightedSkillIndex(0);
}, [filteredSkillsKey]);
```
Now the effect only fires when the *contents* of the skill list change, so an identity-only revalidation no longer resets the keyboard position.
**Regression test technique** (`ChatView.test.tsx`, "keeps the keyboard highlight when revalidation re-delivers an identical skill list"): seed the SWR cache so the menu renders from cache, defer the revalidation fetch behind a manually-held resolver, navigate, then resolve with identical content but a fresh identity and assert the highlight survives:
```tsx
writeCache(`${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}proj-123`, skillsList);
let resolveFetch!: (skills: DiscoveredSkill[]) => void;
mockFetchDiscoveredSkills.mockImplementationOnce(
() => new Promise((resolve) => { resolveFetch = resolve; }),
);
// ... open menu, ArrowUp to /gamma/, assert it's highlighted ...
await act(async () => {
resolveFetch(JSON.parse(JSON.stringify(skillsList)) as DiscoveredSkill[]);
});
expect(screen.getByRole("option", { name: /gamma/i }))
.toHaveClass("chat-skill-menu-item--highlighted");
```
The `JSON.parse(JSON.stringify(...))` is the key move: same content, new object identities — exactly what the SWR layer produces. The test fails on the old identity-keyed reset in all three vitest projects.
## Why This Works
React `useEffect` dependency comparison is `Object.is` (reference identity), not deep equality. `filteredSkills` is a fresh array on every identity-changing render, so the old effect re-ran on churn and re-fired `setHighlightedSkillIndex(0)`. The derived `filteredSkillsKey` is a string; it is `Object.is`-equal across identity-only churn, so the effect stays inert until the actual skill set changes.
The identity churn comes from three places in the SWR layer:
- **Re-parse on read** — `swrCache.ts` `readCache` does `JSON.parse(raw)` every call, minting a brand-new object graph each time (`useDiscoveredSkillsCache` reads via `readCachedSkills` repeatedly: in state init, in the effect, for `loading`, and for `hasCachedStateRef`).
- **Fresh array on notify** — revalidation calls `notifyListeners(projectKey, nextSkills)` delivering a newly-fetched array to every subscriber, replacing the in-state array with a new identity even when contents match.
- **Module-level shared maps** — `inflightByProject` and `listenersByProject` are module-scoped `Map`s, so revalidation state bleeds across renders and, in tests, across cases sharing the module — making a stray revalidation more likely to fire at an inconvenient moment.
**Why CI-only:** the reset is a genuine race between the keypress-driven highlight and an async revalidation. Slower, more contended CI shards widen the window between "ArrowUp set the highlight" and "the assertion reads it," giving the revalidation room to land in between and zero it out. Fast local machines almost always finish the assertion before revalidation resolves.
## Prevention
- **Never key a state-reset `useEffect` on the identity of an array/object that originates from an SWR/cache hook.** Cache layers legitimately hand back content-identical-but-new references. Key the effect on a derived *semantic* value (`xs.map(x => x.id).join(" ")`, a count, a hash) so it fires only on real content change.
- **Treat "this test needed N stabilization passes" as a product-race smell, not a timing smell.** Three assertion/timing patches that don't stick means the test is correctly catching a real race; stop patching the test and find the state mutation that fights it. Resist the "flaky env, just rerun" reflex. (This is the AGENTS.md "Fix the Invariant, Not the Repro" rule, FN-5893, applied to flake triage.)
- **Identity-churn regression pattern (reusable):** seed the cache (`writeCache`), defer the revalidation fetch behind a captured `resolve`, drive the user interaction, then resolve with `JSON.parse(JSON.stringify(original))` to simulate identical-content/new-identity revalidation landing mid-interaction, and assert the user-state invariant holds. This deterministically reproduces a race that only flaked under CI timing.
- **Grep heuristic** for other reset effects fed by cache/array identity: `git grep -nE -A1 'set\w*Index\(0\)'` (search the setter-call line; the dep array is on the following line) and inspect any dep that is a memoized array from a cache/SWR hook. The reset effects span multiple lines, so a single-line `useEffect(...)` pattern matches nothing — search the setter call, not the hook call. This audit at fix time found the same bug class in `QuickChatFAB.tsx` (fixed alongside), while `setMentionHighlightIndex` in both components is keyed on `[mentionFilter, mentionPopupVisible]` — primitives — so the bug class does not apply there. That is the shape to keep: reset on primitive/semantic inputs, never on a cached array's identity.
## Related Issues
- AGENTS.md → "Standing Rule: Fix the Invariant, Not the Repro (FN-5893)" — this fix is a direct application of that rule to flaky-test triage.
- `docs/testing.md` — fake-timer guidance covers timer flakes; this doc covers the *identity-churn* flake class.
- Prior stabilization attempts: FN-5864 (`04e1701d9`), FN-5745 (`8e2c19285`), FN-5725 (`158f74962`).
- Distinct from the engine timeout-flake family (FN-5573/FN-5542/FN-5518) — those are real-timer/subprocess timeouts, a different class.
- Fix: commit `87d044f18` on PR #1352.

46
i18next.config.ts Normal file
View File

@@ -0,0 +1,46 @@
import {
defineConfig,
recommendedAcceptedAttributes,
recommendedAcceptedTags,
} from "i18next-cli";
/**
* i18next-cli workflow config for the whole monorepo.
*
* - `extract` pulls t()/<Trans> keys from the dashboard and CLI source into the
* authored `en` catalogs under @fusion/i18n.
* - `sync` propagates the `en` key structure to the four other locales.
* - `types` regenerates key types from the `en` catalogs.
* - `status` reports per-locale completion (CI gate).
* - `lint` flags hardcoded user-facing strings (primary guardrail).
*
* Namespaces are routed by the `ns:` prefix in keys / `useTranslation(ns)` in
* source, not by file path. `common` is the default namespace.
*/
export default defineConfig({
locales: ["en", "zh-CN", "zh-TW", "fr", "es", "ko"],
extract: {
input: [
"packages/dashboard/app/**/*.{ts,tsx}",
"packages/cli/src/**/*.{ts,tsx}",
"!**/__tests__/**",
"!**/*.test.*",
],
output: "packages/i18n/locales/{{language}}/{{namespace}}.json",
primaryLanguage: "en",
defaultNS: "common",
keySeparator: ".",
nsSeparator: ":",
// Untranslated secondary-locale keys stay empty so `status` can measure
// real completion; the active locale falls back to `en` at runtime.
defaultValue: "",
},
types: {
input: ["packages/i18n/locales/en/*.json"],
output: "packages/i18n/src/i18next-resources.d.ts",
},
lint: {
acceptedTags: recommendedAcceptedTags,
acceptedAttributes: recommendedAcceptedAttributes,
},
});

View File

@@ -46,6 +46,12 @@
"test:coverage:dashboard": "pnpm --filter @fusion/dashboard exec vitest run --silent=passed-only --reporter=dot --coverage",
"test:slow-cli": "pnpm --filter @runfusion/fusion test:slow-cli",
"typecheck": "pnpm -r --filter=!@fusion/desktop --filter=!@fusion/mobile typecheck",
"i18n:extract": "i18next-cli extract",
"i18n:sync": "i18next-cli sync",
"i18n:types": "i18next-cli types",
"i18n:status": "i18next-cli status",
"i18n:lint": "i18next-cli lint",
"i18n:gen-cli": "pnpm --filter @fusion/i18n gen:cli-catalogs",
"changeset": "changeset",
"version": "changeset version",
"release": "node scripts/release.mjs",
@@ -85,6 +91,7 @@
"@eslint/js": "^9.0.0",
"eslint": "^9.0.0",
"fast-glob": "^3.3.3",
"i18next-cli": "^1.59.1",
"react-devtools-core": "^7.0.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0",

View File

@@ -60,13 +60,15 @@
"@earendil-works/pi-coding-agent": "^0.78.0",
"dockerode": "^4.0.12",
"express": "^5.1.0",
"ink": "^6.8.0",
"i18next": "^26.3.1",
"ink": "^7.0.5",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"ioredis": "^5.6.0",
"multer": "^2.1.1",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"react": "^19.0.0"
"react": "^19.2.0",
"react-i18next": "^17.0.8"
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
@@ -88,10 +90,11 @@
"@fusion/core": "workspace:*",
"@fusion/dashboard": "workspace:*",
"@fusion/engine": "workspace:*",
"@fusion/i18n": "workspace:*",
"@fusion/pi-claude-cli": "workspace:*",
"@fusion/pi-llama-cpp": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react": "^19.2.0",
"@vitest/coverage-v8": "^3.1.0",
"cross-env": "^7.0.0",
"esbuild": "^0.25.12",

View File

@@ -8,6 +8,7 @@ export function applyPrepackTransform(pkg) {
delete devDependencies["@fusion/core"];
delete devDependencies["@fusion/dashboard"];
delete devDependencies["@fusion/engine"];
delete devDependencies["@fusion/i18n"];
delete devDependencies["@fusion/pi-claude-cli"];
delete devDependencies["@fusion/pi-llama-cpp"];
delete devDependencies["@fusion-plugin-examples/roadmap"];

View File

@@ -431,6 +431,7 @@ Options:
--interactive Interactive mode (port selection for dashboard, issue selection for import)
--paused Start with engine paused (automation disabled)
--dev Start dashboard only (no AI engine)
--lang <locale> Terminal-UI locale for this run (en, zh-CN, zh-TW, fr, es, ko); the browser dashboard resolves its own language
--attach <file> Attach file(s) on task create (repeatable)
--depends <id> Declare dependency on task create (repeatable)
--no-dedup Bypass deterministic duplicate guard on task create
@@ -748,7 +749,18 @@ async function main() {
const noAuth = args.includes("--no-auth");
const dashTokenIdx = args.indexOf("--token");
const token = dashTokenIdx !== -1 && dashTokenIdx + 1 < args.length ? args[dashTokenIdx + 1] : undefined;
await runDashboard(port, { paused, dev, interactive, host, noAuth, token });
const dashLangIdx = args.indexOf("--lang");
const lang = dashLangIdx !== -1 && dashLangIdx + 1 < args.length ? args[dashLangIdx + 1] : undefined;
if (lang !== undefined) {
// Fail loudly on a bad explicit flag instead of silently falling back
// to setting/env resolution inside the TUI.
const { isLocale, SUPPORTED_LOCALES } = await import("@fusion/core");
if (!isLocale(lang)) {
console.error(`Invalid --lang "${lang}". Supported: ${SUPPORTED_LOCALES.join(", ")}`);
process.exit(1);
}
}
await runDashboard(port, { paused, dev, interactive, host, noAuth, token, lang });
break;
}

View File

@@ -25,6 +25,7 @@ vi.mock("@fusion/core", () => {
return {
GlobalSettingsStore: vi.fn(),
DEFAULT_SETTINGS,
SUPPORTED_LOCALES: ["en", "zh-CN", "zh-TW", "fr", "es", "ko"],
resolveWorktrunkSettings: (globalValue: any, projectValue: any) => ({
enabled: projectValue?.enabled ?? globalValue?.enabled ?? false,
...(projectValue?.binaryPath ?? globalValue?.binaryPath ? { binaryPath: projectValue?.binaryPath ?? globalValue?.binaryPath } : {}),
@@ -155,6 +156,34 @@ describe("settings commands", () => {
expect(resolveProject).not.toHaveBeenCalled();
});
it("runSettingsSet language persists a supported locale globally", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ language: "zh-TW" } as any));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ language: "zh-TW" } as any));
(GlobalSettingsStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
updateSettings,
getSettings,
}));
await runSettingsSet("language", "zh-TW");
expect(updateSettings).toHaveBeenCalledWith({ language: "zh-TW" });
});
it("runSettingsSet language auto clears the persisted locale (null-as-delete)", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({}));
const getSettings = vi.fn().mockResolvedValue(makeSettings({}));
(GlobalSettingsStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
updateSettings,
getSettings,
}));
await runSettingsSet("language", "auto");
expect(updateSettings).toHaveBeenCalledWith({ language: null });
});
it("runSettingsSet with project updates project-only settings", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 6 }));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 6 }));

View File

@@ -1,7 +1,9 @@
import React from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import { I18nextProvider } from "react-i18next";
import { DashboardApp } from "../app.js";
import { initCliI18n } from "../../../i18n/index.js";
import { DashboardTUI } from "../controller.js";
import { createInitialState } from "../state.js";
import type { ProjectItem, TaskItem, AgentItem, AgentDetailItem, ModelItem, SettingsValues, TaskDetailData } from "../state.js";
@@ -10,8 +12,17 @@ function newController(): DashboardTUI {
return new DashboardTUI();
}
// Initialize the real CLI i18n instance so t() interpolation runs in tests —
// without a provider, react-i18next's fallback returns defaults with literal
// {{placeholders}}. Mirrors the production wrap in controller.render().
const testI18n = initCliI18n("en");
function renderDashboardAppNode(controller: DashboardTUI) {
return React.createElement(DashboardApp, { controller });
return React.createElement(
I18nextProvider,
{ i18n: testI18n },
React.createElement(DashboardApp, { controller }),
);
}
function makeSystemInfo() {

File diff suppressed because it is too large Load Diff

View File

@@ -172,6 +172,9 @@ export class DashboardTUI {
// will reapply the auto policy.
mouseEnabled: boolean = false;
// Optional `--lang` override; highest-precedence locale source for the TUI.
lang?: string;
constructor() {
this.logBuffer = new LogRingBuffer();
}
@@ -675,6 +678,20 @@ export class DashboardTUI {
const { render } = await import("ink");
const { createElement } = await import("react");
const { DashboardApp } = await import("./app.js");
const { I18nextProvider } = await import("react-i18next");
const { initCliI18n, resolveCliLocale } = await import("../../i18n/index.js");
const { GlobalSettingsStore } = await import("@fusion/core");
// Resolve locale: --lang flag → persisted GlobalSettings → env → en.
let settingLanguage: string | undefined;
try {
settingLanguage = (await new GlobalSettingsStore().getSettings())?.language;
} catch {
// Settings unreadable — fall back to env/default.
}
const i18n = initCliI18n(
resolveCliLocale({ flag: this.lang, setting: settingLanguage, env: process.env }),
);
// Enter the terminal's alternate-screen buffer before mounting Ink so
// the TUI gets a dedicated fullscreen surface that doesn't share
@@ -689,7 +706,7 @@ export class DashboardTUI {
}
this.inkInstance = render(
createElement(DashboardApp, { controller: this }),
createElement(I18nextProvider, { i18n }, createElement(DashboardApp, { controller: this })),
);
// Mouse mode must be enabled AFTER Ink mounts (which calls

View File

@@ -688,7 +688,7 @@ async function resolveDashboardAuthToken(opts: { noAuth?: boolean; token?: strin
return tokenManager.generateToken();
}
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string; noAuth?: boolean; token?: string } = {}) {
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string; noAuth?: boolean; token?: string; lang?: string } = {}) {
// Default to localhost so the dashboard (and its shell-capable terminal API)
// is not exposed on the LAN. Pass --host 0.0.0.0 explicitly to opt-in.
const selectedHost = opts.host ?? "127.0.0.1";
@@ -754,6 +754,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (isTTY) {
tui = new DashboardTUI();
tui.lang = opts.lang;
void startupUpdateStatusPromise.then((updateStatus) => {
tui?.setUpdateStatus(updateStatus);
});

View File

@@ -4,6 +4,7 @@ import {
type GlobalSettings,
DEFAULT_SETTINGS,
resolveWorktrunkSettings,
SUPPORTED_LOCALES,
} from "@fusion/core";
import { probeWorktrunk, resolveWorktrunkBinary } from "@fusion/engine";
import { resolveProject } from "../project-context.js";
@@ -28,9 +29,10 @@ export const VALID_SETTINGS = [
"worktrunk.enabled",
"worktrunk.binaryPath",
"worktrunk.onFailure",
"language",
] as const;
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel"] as const;
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel", "language"] as const;
const PROJECT_ONLY_SETTINGS = [
"maxConcurrent",
"maxWorktrees",
@@ -64,6 +66,8 @@ const ENUM_SETTINGS: Record<string, readonly string[]> = {
worktreeNaming: ["random", "task-id", "task-title"],
unavailableNodePolicy: ["block", "fallback-local"],
"worktrunk.onFailure": ["fail", "fallback-native"],
// "auto" clears the persisted locale and reverts to runtime detection.
language: [...SUPPORTED_LOCALES, "auto"],
};
const STRING_SETTINGS: readonly string[] = [
@@ -336,6 +340,16 @@ export async function runSettingsSet(key: string, value: string, projectName?: s
try {
const parsedValue = parseValue(validKey, value);
if (key === "language" && parsedValue === "auto") {
// null-as-delete: removes the persisted key so the dashboard re-detects
// from the browser and the TUI falls back to the environment locale.
await globalStore!.updateSettings({ language: null } as unknown as Partial<GlobalSettings>);
console.log();
console.log(" ✓ Language reset to auto-detect (browser/environment locale)");
console.log();
return;
}
if (key === "defaultModel") {
const parts = (parsedValue as string).split("/");
if (parts.length !== 2) {

View File

@@ -0,0 +1,142 @@
import { Text } from "ink";
import { render } from "ink-testing-library";
import { createElement } from "react";
import { I18nextProvider, useTranslation } from "react-i18next";
import { afterEach, describe, expect, it } from "vitest";
import { cliI18n, detectEnvLocale, initCliI18n, resolveCliLocale } from "../index.js";
// cliI18n is a module singleton: restore the locale after each test so cases
// that switch language (zh-CN/fr) can't leak state into later tests.
afterEach(async () => {
if (cliI18n.isInitialized && cliI18n.language !== "en") {
await cliI18n.changeLanguage("en");
}
});
describe("detectEnvLocale", () => {
it("parses POSIX locale env values to a supported locale", () => {
expect(detectEnvLocale({ LANG: "fr_FR.UTF-8" })).toBe("fr");
expect(detectEnvLocale({ LC_ALL: "es_ES.UTF-8" })).toBe("es");
expect(detectEnvLocale({ LANG: "zh_CN.UTF-8" })).toBe("zh-CN");
expect(detectEnvLocale({ LANG: "zh_TW" })).toBe("zh-TW");
});
it("honors precedence LC_ALL > LC_MESSAGES > LANG > LANGUAGE", () => {
expect(detectEnvLocale({ LC_ALL: "fr_FR", LANG: "es_ES" })).toBe("fr");
expect(detectEnvLocale({ LC_MESSAGES: "es_ES", LANG: "fr_FR" })).toBe("es");
});
it("falls back to a bare language and undefined for unsupported", () => {
expect(detectEnvLocale({ LANG: "zh" })).toBe("zh-CN");
expect(detectEnvLocale({ LANG: "de_DE.UTF-8" })).toBeUndefined();
expect(detectEnvLocale({})).toBeUndefined();
});
it("resolves Traditional-script Chinese tags to zh-TW, not Simplified", () => {
expect(detectEnvLocale({ LANG: "zh_Hant_TW.UTF-8" })).toBe("zh-TW");
expect(detectEnvLocale({ LANG: "zh_Hant" })).toBe("zh-TW");
expect(detectEnvLocale({ LANG: "zh_HK.UTF-8" })).toBe("zh-TW");
expect(detectEnvLocale({ LANG: "zh_MO" })).toBe("zh-TW");
// Simplified-script and mainland tags stay Simplified.
expect(detectEnvLocale({ LANG: "zh_Hans_CN" })).toBe("zh-CN");
expect(detectEnvLocale({ LANG: "zh_SG" })).toBe("zh-CN");
});
it("normalizes multi-underscore POSIX tags", () => {
expect(detectEnvLocale({ LANG: "en_US_POSIX" })).toBe("en");
expect(detectEnvLocale({ LANGUAGE: "fr_FR.UTF-8" })).toBe("fr");
});
});
describe("resolveCliLocale precedence", () => {
it("flag overrides setting and env", () => {
expect(resolveCliLocale({ flag: "zh-TW", setting: "fr", env: { LANG: "es_ES" } })).toBe("zh-TW");
});
it("setting overrides env", () => {
expect(resolveCliLocale({ setting: "fr", env: { LANG: "es_ES" } })).toBe("fr");
});
it("env used when no flag/setting", () => {
expect(resolveCliLocale({ env: { LANG: "es_ES.UTF-8" } })).toBe("es");
});
it("defaults to en", () => {
expect(resolveCliLocale({ env: {} })).toBe("en");
expect(resolveCliLocale({ flag: "de", env: {} })).toBe("en");
});
});
// The load-bearing spike: react-i18next must work under Ink's custom reconciler.
function Loading() {
const { t } = useTranslation("cli");
return createElement(Text, null, t("tui.loading", "Loading…"));
}
describe("react-i18next under the Ink reconciler", () => {
it("renders a localized first frame synchronously", () => {
const i18n = initCliI18n("en");
const { lastFrame } = render(
createElement(I18nextProvider, { i18n }, createElement(Loading)),
);
expect(lastFrame()).toContain("Loading…");
});
it("renders from a real catalog, not the defaultValue fallback", async () => {
// t("tui.loading", "Loading…") can't distinguish a loaded catalog from the
// inline defaultValue. A non-en lookup with no defaultValue proves the
// bundled catalog itself resolved.
const i18n = initCliI18n("en");
await i18n.changeLanguage("fr");
function FrLoading() {
const { t } = useTranslation("cli");
return createElement(Text, null, t("tui.loading"));
}
const { lastFrame } = render(
createElement(I18nextProvider, { i18n }, createElement(FrLoading)),
);
expect(lastFrame()).toContain("Chargement…");
await i18n.changeLanguage("en");
});
it("re-renders on changeLanguage", async () => {
const i18n = initCliI18n("en");
cliI18n.addResourceBundle("zh-CN", "cli", { tui: { loading: "加载中…" } }, true, true);
const { lastFrame } = render(
createElement(I18nextProvider, { i18n }, createElement(Loading)),
);
expect(lastFrame()).toContain("Loading…");
await i18n.changeLanguage("zh-CN");
expect(lastFrame()).toContain("加载中…");
});
});
// Keybinding accelerators must stay literal even inside translated hints.
function ProjectHint() {
const { t } = useTranslation("cli");
return createElement(
Text,
null,
t("tui.switchProjectsHint", { key: "p", defaultValue: "Press [{{key}}] to switch projects." }),
);
}
describe("CLI string migration", () => {
it("renders the migrated en label", () => {
const i18n = initCliI18n("en");
function Tasks() {
const { t } = useTranslation("cli");
return createElement(Text, null, t("tui.loadingTasks", "Loading tasks…"));
}
const { lastFrame } = render(
createElement(I18nextProvider, { i18n }, createElement(Tasks)),
);
expect(lastFrame()).toContain("Loading tasks…");
});
it("keeps the [p] accelerator literal in an interpolated hint", () => {
const i18n = initCliI18n("en");
const { lastFrame } = render(
createElement(I18nextProvider, { i18n }, createElement(ProjectHint)),
);
expect(lastFrame()).toContain("[p]");
expect(lastFrame()).toContain("Press");
});
});

View File

@@ -0,0 +1,77 @@
import { DEFAULT_LOCALE, isLocale, type Locale } from "@fusion/core";
import {
baseInitOptions,
CLI_NAMESPACES,
cliResources,
DEFAULT_NAMESPACE,
normalizeToSupportedLocale,
} from "@fusion/i18n";
import i18next, { type i18n as I18nInstance, type Resource } from "i18next";
import { initReactI18next } from "react-i18next";
/**
* Terminal-UI i18next instance.
*
* Unlike the dashboard, the CLI bundles all catalogs statically (via the
* generated @fusion/i18n cli map) and initializes synchronously (inline
* resources, no async backend) so the very first rendered Ink frame is
* already localized — no flash of untranslated keys.
*/
/**
* Parse a POSIX locale environment value (e.g. `fr_FR.UTF-8`, `zh_CN`,
* `zh-Hant`) into a supported {@link Locale}, or undefined when none matches.
*/
export function detectEnvLocale(env: NodeJS.ProcessEnv = process.env): Locale | undefined {
const raw = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE;
if (!raw) return undefined;
// Strip encoding/modifier (`.UTF-8`, `@euro`), then normalize via the shared
// helper so env detection matches the dashboard's navigator detection
// (incl. Traditional-script Chinese → zh-TW).
return normalizeToSupportedLocale(raw.split(/[.:@\s]/)[0]);
}
/**
* Resolve the active CLI locale with precedence:
* `--lang flag → persisted GlobalSettings.language → environment → en`.
*/
export function resolveCliLocale(opts: {
flag?: string | undefined;
setting?: string | undefined;
env?: NodeJS.ProcessEnv;
} = {}): Locale {
const { flag, setting, env = process.env } = opts;
if (isLocale(flag)) return flag;
if (isLocale(setting)) return setting;
return detectEnvLocale(env) ?? DEFAULT_LOCALE;
}
let initialized = false;
/** Initialize (synchronously) or switch the CLI i18next instance to `locale`. */
export function initCliI18n(locale: Locale): I18nInstance {
if (!initialized) {
void i18next.use(initReactI18next).init({
...baseInitOptions(),
lng: locale,
ns: [...CLI_NAMESPACES],
defaultNS: DEFAULT_NAMESPACE,
// Inline resources + no async backend => init completes synchronously,
// so the first rendered Ink frame is already localized (i18next v26
// dropped the old `initImmediate` flag; this is now the default for
// backend-less, resource-inlined init).
resources: cliResources as unknown as Resource,
react: { useSuspense: false },
});
initialized = true;
} else if (i18next.language !== locale) {
// changeLanguage is async; the instance carries the previous locale until
// the microtask queue drains. Benign in production (called once per
// process) — callers that need the settled locale before rendering should
// await i18next.changeLanguage(locale) directly.
void i18next.changeLanguage(locale);
}
return i18next;
}
export { i18next as cliI18n };

View File

@@ -0,0 +1,67 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import {
DEFAULT_GLOBAL_SETTINGS,
DEFAULT_LOCALE,
GLOBAL_SETTINGS_KEYS,
isGlobalSettingsKey,
isLocale,
isProjectSettingsKey,
type Locale,
SUPPORTED_LOCALES,
validateLocale,
} from "../index.js";
describe("locale primitives", () => {
it("exposes exactly the six supported locale codes", () => {
expect([...SUPPORTED_LOCALES]).toEqual(["en", "zh-CN", "zh-TW", "fr", "es", "ko"]);
});
it("uses en as the default/source locale", () => {
expect(DEFAULT_LOCALE).toBe("en");
expectTypeOf<Locale>().toEqualTypeOf<"en" | "zh-CN" | "zh-TW" | "fr" | "es" | "ko">();
});
it("narrows supported codes and rejects everything else via isLocale", () => {
for (const code of SUPPORTED_LOCALES) {
expect(isLocale(code)).toBe(true);
}
expect(isLocale("zh")).toBe(false);
expect(isLocale("de")).toBe(false);
expect(isLocale("")).toBe(false);
expect(isLocale(undefined)).toBe(false);
expect(isLocale(42)).toBe(false);
});
});
describe("language global setting", () => {
it("registers language as a global-only settings key", () => {
expect(isGlobalSettingsKey("language")).toBe(true);
expect(isProjectSettingsKey("language")).toBe(false);
expect(GLOBAL_SETTINGS_KEYS).toContain("language");
});
it("defaults language to undefined (resolve-at-runtime, not persisted en)", () => {
expect(DEFAULT_GLOBAL_SETTINGS.language).toBeUndefined();
});
it("validates supported codes and rejects unsupported ones", () => {
expect(validateLocale("en")).toBe("en");
expect(validateLocale("zh-CN")).toBe("zh-CN");
expect(validateLocale("zh-TW")).toBe("zh-TW");
expect(validateLocale("fr")).toBe("fr");
expect(validateLocale("es")).toBe("es");
expect(validateLocale(undefined)).toBeUndefined();
expect(validateLocale("zh")).toBeUndefined();
expect(validateLocale("de")).toBeUndefined();
expect(validateLocale("")).toBeUndefined();
expect(validateLocale(null)).toBeUndefined();
expect(validateLocale(42)).toBeUndefined();
});
it("round-trips a settings object without language unchanged (backward compat)", () => {
const legacy = { themeMode: "dark", colorTheme: "default" } as const;
const roundTripped = JSON.parse(JSON.stringify(legacy)) as Record<string, unknown>;
expect("language" in roundTripped).toBe(false);
expect(validateLocale(roundTripped.language)).toBeUndefined();
});
});

View File

@@ -38,4 +38,18 @@ describe("settings precedence", () => {
});
expect(scoped.project.worktrunk).toEqual({ enabled: false });
});
it("validates language at the global write boundary and clears it via null", async () => {
// Valid locale persists.
await harness.store().updateGlobalSettings({ language: "fr" });
expect((await harness.store().getSettings()).language).toBe("fr");
// Invalid value is dropped at the boundary — prior choice survives.
await harness.store().updateGlobalSettings({ language: "klingon" } as never);
expect((await harness.store().getSettings()).language).toBe("fr");
// Explicit null clears the persisted key (reset to runtime auto-detect).
await harness.store().updateGlobalSettings({ language: null } as never);
expect((await harness.store().getSettings()).language).toBeUndefined();
});
});

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskBranchContext, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export {
resolveEntryPointBranchAssignment,
@@ -472,6 +472,7 @@ export {
validateDirectMergeCommitStrategy,
validateGithubAuthMode,
validateGithubRepoSlug,
validateLocale,
validateSandboxBackendName,
validateSandboxFailureMode,
validateSandboxPolicy,

View File

@@ -19,6 +19,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
themeMode: "dark",
colorTheme: "default",
dashboardFontScalePct: 100,
language: undefined,
defaultProvider: undefined,
defaultModelId: undefined,
testMode: undefined,

View File

@@ -3,12 +3,14 @@ import type {
GithubAuthMode,
HeartbeatPromptTemplate,
HeartbeatScopeDisciplineMode,
Locale,
SandboxBackendName,
SandboxFailureMode,
SandboxPolicy,
SandboxProjectSettings,
UnavailableNodePolicy,
} from "./types.js";
import { isLocale } from "./types.js";
const UNAVAILABLE_NODE_POLICIES: readonly UnavailableNodePolicy[] = ["block", "fallback-local"] as const;
const DIRECT_MERGE_COMMIT_STRATEGIES: readonly DirectMergeCommitStrategy[] = ["auto", "always-squash", "always-rebase"] as const;
@@ -52,6 +54,14 @@ export function validateUnavailableNodePolicy(value: unknown): UnavailableNodePo
: undefined;
}
/** Returns a validated UI locale for global settings, otherwise undefined. */
export function validateLocale(value: unknown): Locale | undefined {
if (value === undefined) {
return undefined;
}
return isLocale(value) ? value : undefined;
}
/** Returns a validated direct-merge commit strategy for project settings, otherwise undefined. */
export function validateDirectMergeCommitStrategy(value: unknown): DirectMergeCommitStrategy | undefined {
if (value === undefined) {

View File

@@ -27,6 +27,7 @@ import {
* filtered out of the user-facing step manager and cleaned up on re-selection. */
const WORKFLOW_COMPILED_STEP_TEMPLATE_PREFIX = "workflow:";
import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.js";
import { validateLocale } from "./settings-validation.js";
import { normalizeTaskPriority } from "./task-priority.js";
import { validateBranchGroupBranchName, filterTasksByBranchGroup } from "./branch-assignment.js";
import { allowsAutoMergeProcessing } from "./task-merge.js";
@@ -3323,6 +3324,23 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
(globalPatch as Record<string, unknown>)["experimentalFeatures"] = mergedMap;
}
// Validate the optional UI locale at the write boundary: drop unrecognized
// values rather than persisting junk into settings.json. Runtime consumers
// also guard via isLocale, but the contract is `language?: Locale`.
// `null` passes through intact — GlobalSettingsStore treats null as
// "delete this key", which reverts the language to runtime auto-detect.
if ("language" in globalPatch) {
const rawLanguage = (globalPatch as Record<string, unknown>)["language"];
if (rawLanguage !== null) {
const validatedLanguage = validateLocale(rawLanguage);
if (validatedLanguage === undefined) {
delete (globalPatch as Record<string, unknown>)["language"];
} else {
globalPatch.language = validatedLanguage;
}
}
}
const updatedGlobal = await this.globalSettingsStore.updateSettings(globalPatch);
const merged: Settings = { ...DEFAULT_SETTINGS, ...updatedGlobal, ...config.settings } as Settings;
try {

View File

@@ -207,6 +207,24 @@ export const COLOR_THEMES = [
] as const;
export type ColorTheme = (typeof COLOR_THEMES)[number];
/** UI locales supported across the dashboard and terminal UI. `en` is the
* source-of-truth language and the fallback for all others. Adding a locale
* here (plus translated catalogs) is the only code change a new language
* needs — see `@fusion/i18n`. zh-CN and zh-TW are independent catalogs and
* are never auto-converted between scripts. */
export const SUPPORTED_LOCALES = ["en", "zh-CN", "zh-TW", "fr", "es", "ko"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
/** Source-of-truth language and the fallback for all locales. */
export const DEFAULT_LOCALE: Locale = "en";
/** Narrow an arbitrary value to a supported `Locale`. */
export function isLocale(value: unknown): value is Locale {
return (
typeof value === "string" &&
(SUPPORTED_LOCALES as readonly string[]).includes(value)
);
}
export type PrStatus = "open" | "closed" | "merged" | "draft";
export type MergeStrategy = "direct" | "pull-request";
export type MergeIntegrationWorktreeMode =
@@ -2444,6 +2462,10 @@ export interface GlobalSettings {
colorTheme?: ColorTheme;
/** Dashboard font size scale percentage. Bounded to 85-125. Default: 100. */
dashboardFontScalePct?: number;
/** Active UI locale (e.g. `"en"`, `"zh-CN"`, `"fr"`). One of `SUPPORTED_LOCALES`.
* When unset, each surface resolves the locale at runtime (browser/env
* detection) and falls back to `DEFAULT_LOCALE` ("en"). */
language?: Locale;
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
* Must be set together with `defaultModelId`. When both are undefined,
* the engine uses pi's automatic model resolution. */

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef, lazy, Suspense } from "react";
import { useTranslation } from "react-i18next";
import {
computeCapacityRisk,
DEFAULT_CAPACITY_RISK_TODO_THRESHOLD,
@@ -49,6 +50,8 @@ import { useProjects } from "./hooks/useProjects";
import { useAgents } from "./hooks/useAgents";
import { useNodes } from "./hooks/useNodes";
import { useCurrentProject } from "./hooks/useCurrentProject";
import { I18nextProvider } from "react-i18next";
import i18n from "./i18n";
import { ToastProvider, useToast } from "./hooks/useToast";
import { ConfirmDialogProvider } from "./hooks/useConfirm";
import { useTheme } from "./hooks/useTheme";
@@ -234,6 +237,7 @@ export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectC
}
function AppInner() {
const { t } = useTranslation("app");
const { toasts, addToast, removeToast } = useToast();
const { shellApi, state: shellState, ready: shellReady, openConnectionManagerSignal } = useShellConnection();
const shellHost = useShellHostContext();
@@ -1361,7 +1365,7 @@ function AppInner() {
if (showBackendConnectionErrorPage) {
return (
<BackendConnectionErrorPage
errorMessage={projectsError ?? "Failed to fetch projects"}
errorMessage={projectsError ?? t("app.backendError.failedFetch", "Failed to fetch projects")}
isRetrying={retryingProjects}
onRetry={handleRetryProjects}
onManageConnection={shellApi ? () => {
@@ -2085,16 +2089,18 @@ function AppInner() {
export function App() {
return (
<ToastProvider>
<ShellHostProvider>
<ShellProvider>
<NodeProvider>
<ConfirmDialogProvider>
<AppInner />
</ConfirmDialogProvider>
</NodeProvider>
</ShellProvider>
</ShellHostProvider>
</ToastProvider>
<I18nextProvider i18n={i18n}>
<ToastProvider>
<ShellHostProvider>
<ShellProvider>
<NodeProvider>
<ConfirmDialogProvider>
<AppInner />
</ConfirmDialogProvider>
</NodeProvider>
</ShellProvider>
</ShellHostProvider>
</ToastProvider>
</I18nextProvider>
);
}

View File

@@ -132,8 +132,10 @@ describe("Agent runs UI — static analysis", () => {
it("wires a stop run handler", () => {
expect(agentDetailViewContent).toMatch(/handleStopRun|handleStop/);
expect(agentDetailViewContent).toMatch(/title:\s*"Stop Active Run"/);
expect(agentDetailViewContent).toMatch(/message:\s*"Stop the active run\? The agent's work will be interrupted\."/);
// Title may be a raw string or an i18n t() call whose default is "Stop Active Run"
expect(agentDetailViewContent).toMatch(/title:.*"Stop Active Run"/);
// Message may be a raw string or an i18n t() call whose default is the stop confirmation
expect(agentDetailViewContent).toMatch(/message:.*"Stop the active run\? The agent's work will be interrupted\."/);
});
it("references stopAgentRun and stop button copy", () => {

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Activity, FileText } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Agent } from "../api";
import type { TaskDetail } from "@fusion/core";
import { fetchTaskDetail } from "../api";
@@ -17,6 +18,7 @@ interface LiveAgentCardProps {
const TASK_STATUS_POLL_MS = 5000;
function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgentCardProps) {
const { t } = useTranslation("app");
const { entries, isConnected } = useLiveTranscript(agent.taskId, projectId);
const [task, setTask] = useState<TaskDetail | null>(null);
@@ -64,8 +66,8 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
const nextMs = new Date(agent.lastHeartbeatAt).getTime() + intervalMs;
const deltaSec = Math.round((nextMs - Date.now()) / 1000);
if (!Number.isFinite(deltaSec)) return null;
if (deltaSec <= 0) return `Heartbeat overdue ${formatElapsed(-deltaSec)}`;
return `Next heartbeat in ${formatElapsed(deltaSec)}`;
if (deltaSec <= 0) return t("agents.heartbeatOverdue", "Heartbeat overdue {{elapsed}}", { elapsed: formatElapsed(-deltaSec) });
return t("agents.nextHeartbeat", "Next heartbeat in {{elapsed}}", { elapsed: formatElapsed(deltaSec) });
})();
const currentStep = task?.steps?.[task.currentStep ?? 0];
@@ -100,7 +102,7 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-label={`Select agent ${agent.name}`}
aria-label={t("agents.selectAgent", "Select agent {{name}}", { name: agent.name })}
>
<div className="live-agent-card-header">
<div className="live-agent-card-name">
@@ -122,12 +124,11 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
// SSE stream to attach to; useLiveTranscript bails out with
// isConnected=false. Showing "Connecting..." here is misleading
// — the agent is just idle.
<span>{agent.state === "running" ? "Starting..." : "Idle — no task assigned"}</span>
<span>{agent.state === "running" ? t("agents.starting", "Starting...") : t("agents.idleNoTask", "Idle — no task assigned")}</span>
) : currentStep ? (
<>
<div className="live-agent-card-status">
Step {stepNumber}
{totalSteps ? `/${totalSteps}` : ""}: {currentStep.name}
{t("agents.step", "Step {{number}}{{total}}: {{name}}", { number: stepNumber, total: totalSteps ? `/${totalSteps}` : "", name: currentStep.name })}
</div>
{executorModel && (
<div className="live-agent-card-status-sub">
@@ -135,11 +136,11 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
</div>
)}
<div className="live-agent-card-status-sub">
{isConnected ? "Waiting for output..." : "Connecting to log stream..."}
{isConnected ? t("agents.waitingOutput", "Waiting for output...") : t("agents.connectingStream", "Connecting to log stream...")}
</div>
</>
) : (
<span>{isConnected ? "Waiting for output..." : "Connecting..."}</span>
<span>{isConnected ? t("agents.waitingOutput", "Waiting for output...") : t("agents.connecting", "Connecting...")}</span>
)}
</div>
) : (
@@ -167,11 +168,11 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
type="button"
className="live-agent-card-logs-btn"
onClick={handleViewLogs}
title="View live run logs"
aria-label={`View live logs for ${agent.taskId}`}
title={t("agents.viewLiveLogs", "View live run logs")}
aria-label={t("agents.viewLogsFor", "View live logs for {{taskId}}", { taskId: agent.taskId })}
>
<FileText size={12} />
<span>Live logs</span>
<span>{t("agents.liveLogs", "Live logs")}</span>
</button>
)}
{isConnected && <Activity size={12} className="live-agent-streaming-dot" />}
@@ -196,6 +197,7 @@ interface ActiveAgentsPanelProps {
}
export function ActiveAgentsPanel({ agents, projectId, onAgentSelect, onOpenTaskLogs, className = "" }: ActiveAgentsPanelProps) {
const { t } = useTranslation("app");
// Dedupe by id defensively. The store should return unique agents but a race
// between the initial fetch and an SSE refresh can briefly surface the same
// agent twice — without this guard React floods the console with duplicate
@@ -208,7 +210,7 @@ export function ActiveAgentsPanel({ agents, projectId, onAgentSelect, onOpenTask
<div className={`active-agents-panel ${className}`.trim()}>
<div className="active-agents-panel-header">
<Activity size={16} />
<span>Active Agents ({uniqueAgents.length})</span>
<span>{t("agents.activeAgents", "Active Agents ({{count}})", { count: uniqueAgents.length })}</span>
</div>
<div className="active-agents-grid">
{uniqueAgents.map(agent => (

View File

@@ -2,6 +2,8 @@
// in ScriptsModal.css. Until extracted, import that file so this eager modal is styled.
import "./ScriptsModal.css";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2, Folder } from "lucide-react";
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
import { useActivityLog } from "../hooks/useActivityLog";
@@ -23,23 +25,25 @@ interface ActivityLogModalProps {
currentProject?: ProjectInfo | null;
}
const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
"task:created": "Task Created",
"task:moved": "Task Moved",
"task:updated": "Task Updated",
"task:deleted": "Task Deleted",
"task:merged": "Task Merged",
"task:failed": "Task Failed",
"task:duplicate-warning-overridden": "Duplicate Warning Overridden",
"task:auto-archived-ghost-bug": "Task Auto-Archived (Ghost Bug)",
"task:auto-archived-duplicate": "Task Auto-Archived (Duplicate)",
"task:merge-worktree-reacquired": "Merge Worktree Reacquired",
"task:auto-archived-deterministic-duplicate": "Task Auto-Archived (Deterministic Duplicate)",
"task:auto-archived-near-duplicate": "Task Auto-Archived (Near-Duplicate)",
"task:near-duplicate-flagged": "Near-Duplicate Flagged",
"settings:updated": "Settings Updated",
"project:isolation-transition": "Project Isolation Transition",
};
function getEventTypeLabels(t: TFunction<"app">): Record<ActivityEventType, string> {
return {
"task:created": t("activityLog.eventType.taskCreated", "Task Created"),
"task:moved": t("activityLog.eventType.taskMoved", "Task Moved"),
"task:updated": t("activityLog.eventType.taskUpdated", "Task Updated"),
"task:deleted": t("activityLog.eventType.taskDeleted", "Task Deleted"),
"task:merged": t("activityLog.eventType.taskMerged", "Task Merged"),
"task:failed": t("activityLog.eventType.taskFailed", "Task Failed"),
"task:duplicate-warning-overridden": t("activityLog.eventType.duplicateWarningOverridden", "Duplicate Warning Overridden"),
"task:auto-archived-ghost-bug": t("activityLog.eventType.autoArchivedGhostBug", "Task Auto-Archived (Ghost Bug)"),
"task:auto-archived-duplicate": t("activityLog.eventType.autoArchivedDuplicate", "Task Auto-Archived (Duplicate)"),
"task:merge-worktree-reacquired": t("activityLog.eventType.mergeWorktreeReacquired", "Merge Worktree Reacquired"),
"task:auto-archived-deterministic-duplicate": t("activityLog.eventType.autoArchivedDeterministicDuplicate", "Task Auto-Archived (Deterministic Duplicate)"),
"task:auto-archived-near-duplicate": t("activityLog.eventType.autoArchivedNearDuplicate", "Task Auto-Archived (Near-Duplicate)"),
"task:near-duplicate-flagged": t("activityLog.eventType.nearDuplicateFlagged", "Near-Duplicate Flagged"),
"settings:updated": t("activityLog.eventType.settingsUpdated", "Settings Updated"),
"project:isolation-transition": t("activityLog.eventType.projectIsolationTransition", "Project Isolation Transition"),
};
}
const EVENT_TYPE_ICONS: Record<ActivityEventType, React.ReactNode> = {
"task:created": <Plus size={14} className="activity-icon created" />,
@@ -59,7 +63,7 @@ const EVENT_TYPE_ICONS: Record<ActivityEventType, React.ReactNode> = {
"project:isolation-transition": <Folder size={14} className="activity-icon settings" />,
};
function formatTimestamp(timestamp: string): string {
function formatTimestamp(timestamp: string, t: TFunction<"app">): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
@@ -67,10 +71,10 @@ function formatTimestamp(timestamp: string): string {
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffMins < 1) return t("activityLog.time.justNow", "Just now");
if (diffMins < 60) return t("activityLog.time.minutesAgo", "{{count}}m ago", { count: diffMins });
if (diffHours < 24) return t("activityLog.time.hoursAgo", "{{count}}h ago", { count: diffHours });
if (diffDays < 7) return t("activityLog.time.daysAgo", "{{count}}d ago", { count: diffDays });
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
@@ -91,7 +95,7 @@ function formatTimestamp(timestamp: string): string {
* - Event type filter
* - Real-time updates via useActivityLog hook
*/
export function ActivityLogModal({
export function ActivityLogModal({
isOpen,
onClose,
tasks: _tasks,
@@ -101,6 +105,8 @@ export function ActivityLogModal({
onProjectFilterChange,
currentProject,
}: ActivityLogModalProps) {
const { t } = useTranslation("app");
const EVENT_TYPE_LABELS = getEventTypeLabels(t);
const [filteredType, setFilteredType] = useState<ActivityEventType | "all">("all");
const [filteredProjectId, setFilteredProjectId] = useState<string | "all">(projectId || "all");
const [showConfirmClear, setShowConfirmClear] = useState(false);
@@ -209,7 +215,7 @@ export function ActivityLogModal({
<div className="modal-header activity-log-header">
<div className="activity-log-title">
<History size={18} />
<span>Activity Log</span>
<span>{t("activityLog.title", "Activity Log")}</span>
</div>
<div className="activity-log-actions">
{/* Project filter dropdown (when projects provided) */}
@@ -222,7 +228,7 @@ export function ActivityLogModal({
className="activity-log-filter-select"
data-testid="activity-project-filter"
>
<option value="all">All Projects</option>
<option value="all">{t("activityLog.allProjects", "All Projects")}</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
@@ -241,7 +247,7 @@ export function ActivityLogModal({
className="activity-log-filter-select"
data-testid="activity-filter"
>
<option value="all">All Events</option>
<option value="all">{t("activityLog.allEvents", "All Events")}</option>
{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => (
<option key={type} value={type}>
{label}
@@ -254,7 +260,7 @@ export function ActivityLogModal({
<button
className="activity-log-refresh"
onClick={() => refresh()}
title="Refresh"
title={t("activityLog.refresh", "Refresh")}
data-testid="activity-refresh"
>
{isLoading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
@@ -265,7 +271,7 @@ export function ActivityLogModal({
<button
className="activity-log-clear"
onClick={() => setShowConfirmClear(true)}
title="Clear Log"
title={t("activityLog.clearLog", "Clear Log")}
data-testid="activity-clear"
>
<Trash2 size={14} />
@@ -276,8 +282,8 @@ export function ActivityLogModal({
<button
className="modal-close"
onClick={onClose}
aria-label="Close"
title="Close"
aria-label={t("actions.close", "Close")}
title={t("actions.close", "Close")}
data-testid="activity-close"
>
×
@@ -287,7 +293,7 @@ export function ActivityLogModal({
{/* Active filters display */}
{isFilterActive && (
<div className="activity-log-active-filters">
<span className="activity-log-filter-label">Active filters:</span>
<span className="activity-log-filter-label">{t("activityLog.activeFilters", "Active filters:")}</span>
{filteredProjectId !== "all" && (
<span className="activity-log-filter-badge">
Project: {projects.find(p => p.id === filteredProjectId)?.name || filteredProjectId}
@@ -306,7 +312,7 @@ export function ActivityLogModal({
onProjectFilterChange?.(undefined);
}}
>
Clear all
{t("activityLog.clearFilters", "Clear all")}
</button>
</div>
)}
@@ -324,9 +330,9 @@ export function ActivityLogModal({
<div className="activity-log-empty" data-testid="activity-empty">
<History size={48} className="activity-log-empty-icon" />
<p>
{isFilterActive
? "No activity matches the current filters"
: "No activity recorded yet"}
{isFilterActive
? t("activityLog.noMatchingActivity", "No activity matches the current filters")
: t("activityLog.noActivityRecorded", "No activity recorded yet")}
</p>
{isFilterActive && (
<button
@@ -337,7 +343,7 @@ export function ActivityLogModal({
onProjectFilterChange?.(undefined);
}}
>
Clear Filters
{t("activityLog.clearFiltersBtnLabel", "Clear Filters")}
</button>
)}
</div>
@@ -359,7 +365,7 @@ export function ActivityLogModal({
{EVENT_TYPE_LABELS[entry.type]}
</span>
<span className="activity-log-entry-time">
{formatTimestamp(entry.timestamp)}
{formatTimestamp(entry.timestamp, t)}
</span>
</div>
<div className="activity-log-entry-details">
@@ -386,7 +392,9 @@ export function ActivityLogModal({
)}
{typeof entry.metadata.merged === "boolean" && (
<span className={`activity-log-metadata-item ${entry.metadata.merged ? "success" : "error"}`}>
{entry.metadata.merged ? "Merged" : "Not merged"}
{entry.metadata.merged
? t("activityLog.merged", "Merged")
: t("activityLog.notMerged", "Not merged")}
</span>
)}
</div>
@@ -402,7 +410,7 @@ export function ActivityLogModal({
onClick={refresh}
data-testid="activity-load-more"
>
Load More
{t("activityLog.loadMore", "Load More")}
</button>
)}
@@ -417,20 +425,20 @@ export function ActivityLogModal({
{showConfirmClear && (
<div className="activity-log-confirm-overlay">
<div className="activity-log-confirm-dialog">
<h3>Clear Activity Log?</h3>
<p>This will permanently delete all activity log entries. This action cannot be undone.</p>
<h3>{t("activityLog.confirmClear", "Clear Activity Log?")}</h3>
<p>{t("activityLog.confirmClearMessage", "This will permanently delete all activity log entries. This action cannot be undone.")}</p>
<div className="activity-log-confirm-actions">
<button
className="activity-log-confirm-cancel"
onClick={() => setShowConfirmClear(false)}
>
Cancel
{t("actions.cancel", "Cancel")}
</button>
<button
className="activity-log-confirm-clear"
onClick={handleClearLog}
>
Clear Log
{t("activityLog.confirmClearButton", "Clear Log")}
</button>
</div>
</div>

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import type { NodeProjectMappingInput, ProjectInfo, RemoteNodeDiscoveredProject, RemoteNodeProjectDiscoveryResult } from "../api";
import { validateProjectPath } from "../utils/projectDetection";
import type { ToastType } from "../hooks/useToast";
@@ -47,25 +49,25 @@ interface FormErrors {
const MAX_CONCURRENT_MIN = 1;
const MAX_CONCURRENT_MAX = 10;
function validateInput(input: AddNodeInput): FormErrors {
function validateInput(input: AddNodeInput, t: TFunction<"app">): FormErrors {
const errors: FormErrors = { projectMappings: {} };
if (!input.name.trim()) {
errors.name = "Name is required";
errors.name = t("nodes.nameRequired", "Name is required");
}
if (input.type === "remote" && !input.url?.trim()) {
errors.url = "URL is required for remote nodes";
errors.url = t("nodes.urlRequired", "URL is required for remote nodes");
}
if (!Number.isFinite(input.maxConcurrent) || input.maxConcurrent < MAX_CONCURRENT_MIN || input.maxConcurrent > MAX_CONCURRENT_MAX) {
errors.maxConcurrent = `Concurrency must be between ${MAX_CONCURRENT_MIN} and ${MAX_CONCURRENT_MAX}`;
errors.maxConcurrent = t("nodes.concurrencyRange", "Concurrency must be between {{min}} and {{max}}", { min: MAX_CONCURRENT_MIN, max: MAX_CONCURRENT_MAX });
}
for (const mapping of input.projectMappings) {
const validation = validateProjectPath(mapping.path);
if (!validation.valid) {
errors.projectMappings[mapping.projectId] = validation.error ?? "Path is invalid";
errors.projectMappings[mapping.projectId] = validation.error ?? t("nodes.pathInvalid", "Path is invalid");
}
}
@@ -75,6 +77,7 @@ function validateInput(input: AddNodeInput): FormErrors {
type DiscoveryState = "idle" | "loading" | "success" | "error";
export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjects, addToast, projects }: AddNodeModalProps) {
const { t } = useTranslation("app");
useMobileScrollLock(isOpen);
const [name, setName] = useState("");
const [type, setType] = useState<"local" | "remote">("local");
@@ -143,7 +146,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
const trimmedUrl = url.trim();
if (!trimmedUrl) {
setErrors((current) => ({ ...current, url: "URL is required for remote nodes" }));
setErrors((current) => ({ ...current, url: t("nodes.urlRequired", "URL is required for remote nodes") }));
return;
}
@@ -175,9 +178,9 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
} catch (error) {
setDiscoveryState("error");
setDiscoveredProjects([]);
setDiscoveryError(error instanceof Error ? error.message : "Failed to discover remote projects");
setDiscoveryError(error instanceof Error ? error.message : t("nodes.discoveryFailed", "Failed to discover remote projects"));
}
}, [apiKey, apiKeyMode, discoveryState, isSubmitting, onDiscoverRemoteProjects, projects, url]);
}, [apiKey, apiKeyMode, discoveryState, isSubmitting, onDiscoverRemoteProjects, projects, t, url]);
useEffect(() => {
if (type !== "remote") {
@@ -195,7 +198,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
const handleSubmit = useCallback(async () => {
if (isSubmitting) return;
const validationErrors = validateInput(input);
const validationErrors = validateInput(input, t);
setErrors(validationErrors);
if (
@@ -208,7 +211,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
}
if (input.type === "remote" && discoveryState !== "success") {
setDiscoveryError("Discover remote projects before adding this node.");
setDiscoveryError(t("nodes.discoverBeforeAdding", "Discover remote projects before adding this node."));
return;
}
@@ -216,15 +219,15 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
try {
await onSubmit(input);
addToast(`Node "${input.name}" registered`, "success");
addToast(t("nodes.registered", "Node \"{{name}}\" registered", { name: input.name }), "success");
closeModal();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to register node";
const message = error instanceof Error ? error.message : t("nodes.registerFailed", "Failed to register node");
addToast(message, "error");
} finally {
setIsSubmitting(false);
}
}, [addToast, closeModal, discoveryState, input, isSubmitting, onSubmit]);
}, [addToast, closeModal, discoveryState, input, isSubmitting, onSubmit, t]);
const toggleProjectSelection = (project: ProjectInfo) => {
setSelectedProjectPaths((current) => {
@@ -253,25 +256,25 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
return (
<div className="modal-overlay open" onClick={closeModal}>
<div className="modal modal-md add-node-modal" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add Node">
<div className="modal modal-md add-node-modal" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={t("nodes.addNode", "Add Node")}>
<div className="modal-header">
<h3>Add Node</h3>
<button className="modal-close" onClick={closeModal} disabled={isSubmitting} aria-label="Close add node modal">
<h3>{t("nodes.addNode", "Add Node")}</h3>
<button className="modal-close" onClick={closeModal} disabled={isSubmitting} aria-label={t("nodes.closeNodeModal", "Close add node modal")}>
&times;
</button>
</div>
<div className="modal-body add-node-modal__body">
<p className="add-node-modal__description">Register an existing Fusion node by providing its connection details and concurrency settings.</p>
<p className="add-node-modal__description">{t("nodes.description", "Register an existing Fusion node by providing its connection details and concurrency settings.")}</p>
<label className="add-node-modal__field">
<span>Name</span>
<span>{t("nodes.name", "Name")}</span>
<input
className="input"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Build Machine"
placeholder={t("nodes.namePlaceholder", "Build Machine")}
disabled={isSubmitting}
aria-invalid={Boolean(errors.name)}
autoFocus
@@ -288,7 +291,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
disabled={isSubmitting}
aria-pressed={type === "local"}
>
Local
{t("nodes.local", "Local")}
</button>
<button
type="button"
@@ -298,14 +301,14 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
disabled={isSubmitting}
aria-pressed={type === "remote"}
>
Remote
{t("nodes.remote", "Remote")}
</button>
</div>
{type === "remote" && (
<div className="add-node-modal__remote-fields" data-testid="remote-fields-container" data-visible>
<label className="add-node-modal__field">
<span>Reachable URL / Hostname</span>
<span>{t("nodes.reachableUrl", "Reachable URL / Hostname")}</span>
<input
className="input"
type="text"
@@ -319,27 +322,27 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
</label>
<label className="add-node-modal__field">
<span>API Key Mode</span>
<span>{t("nodes.apiKeyMode", "API Key Mode")}</span>
<select
className="select"
value={apiKeyMode}
onChange={(event) => setApiKeyMode(event.target.value as "auto-generate" | "provide")}
disabled={isSubmitting}
>
<option value="auto-generate">Auto-generate</option>
<option value="provide">Provide key manually</option>
<option value="auto-generate">{t("nodes.autoGenerate", "Auto-generate")}</option>
<option value="provide">{t("nodes.provideManually", "Provide key manually")}</option>
</select>
</label>
{apiKeyMode === "provide" && (
<label className="add-node-modal__field">
<span>API Key</span>
<span>{t("nodes.apiKey", "API Key")}</span>
<input
className="input"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Enter node API key"
placeholder={t("nodes.apiKeyPlaceholder", "Enter node API key")}
disabled={isSubmitting}
/>
</label>
@@ -352,18 +355,18 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
onClick={() => void handleDiscoverProjects()}
disabled={isSubmitting || discoveryState === "loading"}
>
{discoveryState === "loading" ? "Discovering..." : "Discover Remote Projects"}
{discoveryState === "loading" ? t("nodes.discovering", "Discovering...") : t("nodes.discoverRemoteProjects", "Discover Remote Projects")}
</button>
{discoveryState === "success" && (
<span className="add-node-modal__discovery-state" data-state="success">
{discoveredProjects.length > 0 ? `Discovered ${discoveredProjects.length} remote project${discoveredProjects.length === 1 ? "" : "s"}.` : "No projects discovered on remote node."}
{discoveredProjects.length > 0 ? t("nodes.discoveredCount", "Discovered {{count}} remote project{{plural}}", { count: discoveredProjects.length, plural: discoveredProjects.length === 1 ? "" : "s" }) : t("nodes.noProjectsDiscovered", "No projects discovered on remote node.")}
</span>
)}
{discoveryState === "error" && discoveryError && (
<span className="form-error add-node-modal__error">{discoveryError}</span>
)}
{discoveryState === "idle" && (
<span className="add-node-modal__hint">Discover remote projects before adding this node.</span>
<span className="add-node-modal__hint">{t("nodes.discoverBeforeAdding", "Discover remote projects before adding this node.")}</span>
)}
</div>
@@ -384,7 +387,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
)}
<label className="add-node-modal__field">
<span>Max Concurrent</span>
<span>{t("nodes.maxConcurrent", "Max Concurrent")}</span>
<input
className="input"
type="number"
@@ -395,15 +398,15 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
disabled={isSubmitting}
aria-invalid={Boolean(errors.maxConcurrent)}
/>
<span className="add-node-modal__hint">Max simultaneous task agents (1–10)</span>
<span className="add-node-modal__hint">{t("nodes.maxConcurrentHint", "Max simultaneous task agents (1–10)")}</span>
{errors.maxConcurrent && <span className="form-error add-node-modal__error">{errors.maxConcurrent}</span>}
</label>
<section className="add-node-modal__projects" aria-label="Project path mappings">
<h4 className="add-node-modal__projects-title">Attach Existing Projects</h4>
<p className="add-node-modal__hint">Select existing projects to run on this node and provide the node-specific absolute path for each one.</p>
<h4 className="add-node-modal__projects-title">{t("nodes.attachProjects", "Attach Existing Projects")}</h4>
<p className="add-node-modal__hint">{t("nodes.attachProjectsHint", "Select existing projects to run on this node and provide the node-specific absolute path for each one.")}</p>
{projects.length === 0 ? (
<p className="add-node-modal__hint">No projects are currently registered.</p>
<p className="add-node-modal__hint">{t("nodes.noProjects", "No projects are currently registered.")}</p>
) : (
<div className="add-node-modal__project-list">
{projects.map((project) => {
@@ -422,18 +425,18 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
</label>
{selected && (
<label className="add-node-modal__field">
<span>Path on this node</span>
<span>{t("nodes.pathOnNode", "Path on this node")}</span>
{type === "remote" && discoveryState === "success" && (
<span className="add-node-modal__hint">
{(() => {
const matches = discoveredProjects.filter((remoteProject) => remoteProject.name === project.name);
if (matches.length === 1) {
return `Remote-authoritative path discovered: ${matches[0].path}`;
return t("nodes.pathDiscovered", "Remote-authoritative path discovered: {{path}}", { path: matches[0].path });
}
if (matches.length > 1) {
return "Multiple remote projects matched this name. Enter the correct path manually.";
return t("nodes.multipleMatches", "Multiple remote projects matched this name. Enter the correct path manually.");
}
return "No exact remote name match. Enter this path manually.";
return t("nodes.noMatch", "No exact remote name match. Enter this path manually.");
})()}
</span>
)}
@@ -459,9 +462,9 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
</div>
<div className="modal-actions">
<button className="btn btn-sm" onClick={closeModal} disabled={isSubmitting}>Cancel</button>
<button className="btn btn-sm" onClick={closeModal} disabled={isSubmitting}>{t("common.cancel", "Cancel")}</button>
<button className="btn btn-primary btn-sm" data-testid="add-node-submit" onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Adding..." : "Add Node"}
{isSubmitting ? t("nodes.adding", "Adding...") : t("nodes.addNode", "Add Node")}
</button>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import "./AgentErrorDetailsModal.css";
import { useMemo, useState } from "react";
import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
const DEFAULT_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
@@ -50,6 +51,7 @@ export function buildAgentErrorIssueUrl(errorText: string, context: AgentErrorIs
export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext }: AgentErrorDetailsModalProps) {
const [copied, setCopied] = useState(false);
const { t } = useTranslation("app");
const issueUrl = useMemo(() => buildAgentErrorIssueUrl(errorText, issueContext), [errorText, issueContext]);
if (!open) {
@@ -57,14 +59,14 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext
}
return (
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()} role="dialog" aria-modal="true" aria-label="Agent error details">
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()} role="dialog" aria-modal="true" aria-label={t("agentError.dialogLabel", "Agent error details")}>
<div className="modal agent-error-modal">
<div className="modal-header">
<h2 className="modal-title">
<AlertCircle size={16} />
Agent Error Details
{t("agentError.title", "Agent Error Details")}
</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">&times;</button>
<button className="modal-close" onClick={onClose} aria-label={t("common.close", "Close")}>&times;</button>
</div>
<div className="agent-error-modal__content">
<pre className="agent-error-modal__error">{errorText}</pre>
@@ -79,10 +81,10 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext
setTimeout(() => setCopied(false), 1500);
});
}}
aria-label={copied ? "Copied error to clipboard" : "Copy error to clipboard"}
aria-label={copied ? t("agentError.copiedLabel", "Copied error to clipboard") : t("agentError.copyLabel", "Copy error to clipboard")}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? "Copied" : "Copy"}
{copied ? t("agentError.copied", "Copied") : t("agentError.copy", "Copy")}
</button>
<a
className="btn btn-sm btn-warning"
@@ -95,7 +97,7 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext
}}
>
<ExternalLink size={14} />
Report on GitHub
{t("agentError.reportOnGithub", "Report on GitHub")}
</a>
</div>
</div>
@@ -111,10 +113,11 @@ interface AgentErrorIndicatorProps {
export function AgentErrorIndicator({ errorText, issueContext, summaryPrefix = "Error" }: AgentErrorIndicatorProps) {
const [open, setOpen] = useState(false);
const { t } = useTranslation("app");
return (
<>
<button type="button" className="agent-error-indicator" onClick={() => setOpen(true)} aria-label="Open error details">
<button type="button" className="agent-error-indicator" onClick={() => setOpen(true)} aria-label={t("agentError.openDetails", "Open error details")}>
<AlertCircle size={14} />
<span className="agent-error-indicator__label">{summaryPrefix}</span>
</button>

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { AgentGenerationSpec } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import {
@@ -38,6 +39,7 @@ export function AgentGenerationModal({
onGenerated,
projectId,
}: AgentGenerationModalProps) {
const { t } = useTranslation("app");
useMobileScrollLock(isOpen);
const [roleDescription, setRoleDescription] = useState("");
const [view, setView] = useState<ViewState>({ type: "input" });
@@ -112,7 +114,7 @@ export function AgentGenerationModal({
err instanceof Error ? err.message : "Failed to generate agent specification";
// Handle rate limit errors with user-friendly message
if (message.includes("429") || message.toLowerCase().includes("rate limit")) {
setError("Too many requests. Please wait a moment and try again.");
setError(t("agents.generation.rateLimited", "Too many requests. Please wait a moment and try again."));
} else {
setError(message);
}
@@ -172,12 +174,12 @@ export function AgentGenerationModal({
<div className="agent-dialog-header">
<span className="agent-dialog-header-title">
<span className="agent-dialog-header-sparkle">✨</span>
Generate Agent
{t("agents.generation.title", "Generate Agent")}
</span>
<button
className="modal-close"
onClick={handleCancel}
aria-label="Close"
aria-label={t("actions.close", "Close")}
>
&times;
</button>
@@ -190,18 +192,16 @@ export function AgentGenerationModal({
{view.type === "input" && (
<div>
<p className="agent-dialog-info">
Describe your agent&apos;s role and the AI will generate a complete
specification including system prompt, suggested configuration, and
more.
{t("agents.generation.info", "Describe your agent's role and the AI will generate a complete specification including system prompt, suggested configuration, and more.")}
</p>
<div className="agent-dialog-field">
<label htmlFor="agent-role-description">Role Description</label>
<label htmlFor="agent-role-description">{t("agents.generation.roleLabel", "Role Description")}</label>
<textarea
ref={textareaRef}
id="agent-role-description"
className="input agent-dialog-textarea"
rows={4}
placeholder='e.g. "Senior frontend code reviewer who specializes in React accessibility"'
placeholder={t("agents.generation.rolePlaceholder", 'e.g. "Senior frontend code reviewer who specializes in React accessibility"')}
value={roleDescription}
onChange={(e) => setRoleDescription(e.target.value)}
onKeyDown={(e) => {
@@ -217,7 +217,7 @@ export function AgentGenerationModal({
id="role-description-hint"
className="agent-dialog-hint"
>
<span>Describe what your agent should do</span>
<span>{t("agents.generation.roleHint", "Describe what your agent should do")}</span>
<span>
{roleDescription.length}/{MAX_ROLE_LENGTH}
</span>
@@ -230,7 +230,7 @@ export function AgentGenerationModal({
<div className="agent-dialog-loading-center">
<div className="agent-dialog-spinner spin" />
<p className="agent-dialog-loading-text">
Generating agent specification...
{t("agents.generation.loading", "Generating agent specification...")}
</p>
</div>
)}
@@ -240,7 +240,7 @@ export function AgentGenerationModal({
<div className="agent-dialog-summary agent-dialog-summary--spaced">
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
Title
{t("agents.generation.previewTitle", "Title")}
</span>
<span className="agent-dialog-summary-row-value">
{view.spec.icon} {view.spec.title}
@@ -248,19 +248,19 @@ export function AgentGenerationModal({
</div>
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
Role
{t("agents.generation.previewRole", "Role")}
</span>
<span>{view.spec.role}</span>
</div>
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
Description
{t("agents.generation.previewDescription", "Description")}
</span>
<span className="agent-dialog-summary-row-value agent-dialog-summary-row-value--body">{view.spec.description}</span>
</div>
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
Thinking
{t("agents.generation.previewThinking", "Thinking")}
</span>
<span className="agent-dialog-summary-row-value agent-dialog-summary-row-value--capitalize">
{view.spec.thinkingLevel}
@@ -268,7 +268,7 @@ export function AgentGenerationModal({
</div>
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
Max Turns
{t("agents.generation.previewMaxTurns", "Max Turns")}
</span>
<span>{view.spec.maxTurns}</span>
</div>
@@ -277,13 +277,13 @@ export function AgentGenerationModal({
{/* System prompt preview */}
<div className="agent-dialog-field">
<label>
System Prompt
{t("agents.generation.systemPrompt", "System Prompt")}
<button
type="button"
className="agent-dialog-expand-btn"
onClick={() => setSystemPromptExpanded(!systemPromptExpanded)}
>
{systemPromptExpanded ? "Collapse" : "Expand"}
{systemPromptExpanded ? t("agents.generation.collapse", "Collapse") : t("agents.generation.expand", "Expand")}
</button>
</label>
<div
@@ -303,7 +303,7 @@ export function AgentGenerationModal({
{/* Footer */}
<div className="agent-dialog-footer">
<button className="btn" onClick={handleCancel}>
Cancel
{t("actions.cancel", "Cancel")}
</button>
{view.type === "input" && (
<button
@@ -311,7 +311,7 @@ export function AgentGenerationModal({
onClick={() => void handleGenerate()}
disabled={!canGenerate}
>
Generate
{t("agents.generation.generate", "Generate")}
</button>
)}
{view.type === "preview" && (
@@ -320,10 +320,10 @@ export function AgentGenerationModal({
className="btn"
onClick={() => void handleRegenerate()}
>
Regenerate
{t("agents.generation.regenerate", "Regenerate")}
</button>
<button className="btn btn-task-create" onClick={handleUseSpec}>
Use This
{t("agents.generation.useThis", "Use This")}
</button>
</>
)}

View File

@@ -1,5 +1,6 @@
import "./AgentImportModal.css";
import { useState, useRef, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, Globe, Search, RefreshCw } from "lucide-react";
import { fetchCompanies, type CompanyEntry } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
@@ -129,6 +130,7 @@ function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
* Flow: Input → Preview parsed agents → Import → Show results
*/
export function AgentImportModal({ isOpen, onClose, onImported, projectId, initialInputMethod = "paste" }: AgentImportModalProps) {
const { t } = useTranslation("app");
useMobileScrollLock(isOpen);
const [step, setStep] = useState<ModalStep>("input");
const [inputMethod, setInputMethod] = useState<InputMethod>(initialInputMethod);
@@ -171,17 +173,17 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
} else if (data.companies.length > 0) {
setCompanies(data.companies);
} else {
setCompaniesError("No companies available");
setCompaniesError(t("agents.noCompaniesAvailable", "No companies available"));
}
})
.catch((err) => {
setCompaniesError(err instanceof Error ? err.message : "Failed to load companies");
setCompaniesError(err instanceof Error ? err.message : t("agents.failedToLoadCompanies", "Failed to load companies"));
})
.finally(() => {
setIsLoadingCompanies(false);
});
}
}, [inputMethod, isLoadingCompanies]);
}, [inputMethod, isLoadingCompanies, t]);
/** Retry fetching companies after an error - calls fetch directly to bypass useEffect */
const handleRetryFetchCompanies = useCallback(() => {
@@ -197,16 +199,16 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
} else if (data.companies.length > 0) {
setCompanies(data.companies);
} else {
setCompaniesError("No companies available");
setCompaniesError(t("agents.noCompaniesAvailable", "No companies available"));
}
})
.catch((err) => {
setCompaniesError(err instanceof Error ? err.message : "Failed to load companies");
setCompaniesError(err instanceof Error ? err.message : t("agents.failedToLoadCompanies", "Failed to load companies"));
})
.finally(() => {
setIsLoadingCompanies(false);
});
}, []);
}, [t]);
const reset = useCallback(() => {
setStep("input");
@@ -250,13 +252,13 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
setParseError(null);
};
reader.onerror = () => {
setParseError("Failed to read file");
setParseError(t("agents.failedToReadFile", "Failed to read file"));
};
reader.readAsText(file);
// Reset file input so the same file can be re-selected
e.target.value = "";
}, []);
}, [t]);
const handleDirectoryChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
@@ -272,7 +274,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
});
if (agentFiles.length === 0) {
setParseError("Selected directory has no AGENTS.md files");
setParseError(t("agents.noAgentsMdFiles", "Selected directory has no AGENTS.md files"));
return;
}
@@ -287,11 +289,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
setManifestContent("");
setParseError(null);
} catch {
setParseError("Failed to parse AGENTS.md files from selected directory");
setParseError(t("agents.failedToParseDirectory", "Failed to parse AGENTS.md files from selected directory"));
} finally {
e.target.value = "";
}
}, []);
}, [t]);
/** Build the API URL with optional projectId */
function buildUrl(path: string): string {
@@ -303,15 +305,15 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
/** Parse the manifest content by calling the API with dryRun=true */
const handleParse = useCallback(async () => {
if (inputMethod === "directory" && directoryAgents.length === 0) {
setParseError("Please select a directory containing AGENTS.md files");
setParseError(t("agents.selectDirectory", "Please select a directory containing AGENTS.md files"));
return;
}
if (inputMethod === "browse" && !selectedCompany) {
setParseError("Please select a company from the catalog");
setParseError(t("agents.selectCompany", "Please select a company from the catalog"));
return;
}
if (inputMethod !== "directory" && inputMethod !== "browse" && !manifestContent.trim()) {
setParseError("Please provide manifest content");
setParseError(t("agents.provideManifest", "Please provide manifest content"));
return;
}
@@ -363,11 +365,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
setSelectedSkillNames(previewSkills.map((skill) => skill.name));
setStep("preview");
} catch (err) {
setParseError(err instanceof Error ? err.message : "Failed to parse manifest");
setParseError(err instanceof Error ? err.message : t("agents.failedToParseManifest", "Failed to parse manifest"));
} finally {
setIsParsing(false);
}
}, [inputMethod, directoryAgents, manifestContent, selectedCompany, projectId]);
}, [inputMethod, directoryAgents, manifestContent, selectedCompany, projectId, t]);
/** Execute the actual import */
const handleImport = useCallback(async () => {
@@ -417,7 +419,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
setStep("result");
onImported();
} catch (err) {
setImportError(err instanceof Error ? err.message : "Failed to import agents");
setImportError(err instanceof Error ? err.message : t("agents.failedToImport", "Failed to import agents"));
} finally {
setIsImporting(false);
}
@@ -430,22 +432,23 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
selectedSkillNames,
projectId,
onImported,
t,
]);
const selectedAgentCount = selectedAgentNames.length;
const selectedSkillCount = selectedSkillNames.length;
const selectedAgentLabel = `${selectedAgentCount} Agent${selectedAgentCount !== 1 ? "s" : ""}`;
const selectedSkillLabel = `${selectedSkillCount} Skill${selectedSkillCount !== 1 ? "s" : ""}`;
const selectedAgentLabel = t("agents.selectedAgentLabel", "{{count}} Agent{{plural}}", { count: selectedAgentCount, plural: selectedAgentCount !== 1 ? "s" : "" });
const selectedSkillLabel = t("agents.selectedSkillLabel", "{{count}} Skill{{plural}}", { count: selectedSkillCount, plural: selectedSkillCount !== 1 ? "s" : "" });
const importActionLabel = selectedAgentCount > 0 && selectedSkillCount > 0
? `${selectedAgentLabel} + ${selectedSkillLabel}`
: selectedSkillCount > 0
? selectedSkillLabel
: selectedAgentLabel;
const importLoadingLabel = selectedAgentCount > 0 && selectedSkillCount > 0
? `Importing ${selectedAgentCount} agent${selectedAgentCount !== 1 ? "s" : ""} and ${selectedSkillCount} skill${selectedSkillCount !== 1 ? "s" : ""}...`
? t("agents.importingAgentsAndSkills", "Importing {{agentCount}} agent{{agentPlural}} and {{skillCount}} skill{{skillPlural}}...", { agentCount: selectedAgentCount, agentPlural: selectedAgentCount !== 1 ? "s" : "", skillCount: selectedSkillCount, skillPlural: selectedSkillCount !== 1 ? "s" : "" })
: selectedSkillCount > 0
? `Importing ${selectedSkillCount} skill${selectedSkillCount !== 1 ? "s" : ""}...`
: `Importing ${selectedAgentCount} agent${selectedAgentCount !== 1 ? "s" : ""}...`;
? t("agents.importingSkills", "Importing {{count}} skill{{plural}}...", { count: selectedSkillCount, plural: selectedSkillCount !== 1 ? "s" : "" })
: t("agents.importingAgents", "Importing {{count}} agent{{plural}}...", { count: selectedAgentCount, plural: selectedAgentCount !== 1 ? "s" : "" });
const toggleAgentSelection = (name: string) => {
setSelectedAgentNames((current) => (
@@ -467,11 +470,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
return (
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
<div className="agent-dialog agent-import-dialog" role="dialog" aria-modal="true" aria-label="Import agents">
<div className="agent-dialog agent-import-dialog" role="dialog" aria-modal="true" aria-label={t("agents.importAgents", "Import agents")}>
{/* Header */}
<div className="agent-dialog-header">
<span className="agent-dialog-header-title">Import Agents</span>
<button className="modal-close" onClick={handleClose} aria-label="Close">
<span className="agent-dialog-header-title">{t("agents.importAgents", "Import Agents")}</span>
<button className="modal-close" onClick={handleClose} aria-label={t("agents.close", "Close")}>
&times;
</button>
</div>
@@ -482,7 +485,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
{step === "input" && (
<div className="agent-import-input">
<p className="agent-import-description">
Import agents from an Agent Companies package. Browse the companies.sh catalog to discover published agents, upload an AGENTS.md file, select a directory, or paste manifest content.
{t("agents.importDescription", "Import agents from an Agent Companies package. Browse the companies.sh catalog to discover published agents, upload an AGENTS.md file, select a directory, or paste manifest content.")}
</p>
{/* File upload */}
@@ -511,7 +514,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
onClick={() => fileInputRef.current?.click()}
>
<Upload size={16} />
Choose File
{t("agents.chooseFile", "Choose File")}
</button>
<button
type="button"
@@ -519,7 +522,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
onClick={() => directoryInputRef.current?.click()}
>
<FolderOpen size={16} />
Select Directory
{t("agents.selectDirectory", "Select Directory")}
</button>
<button
type="button"
@@ -533,9 +536,9 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
}}
>
<Globe size={16} />
Browse Catalog
{t("agents.browseCatalog", "Browse Catalog")}
</button>
<span className="agent-import-file-hint">.md and .txt files supported</span>
<span className="agent-import-file-hint">{t("agents.fileHint", ".md and .txt files supported")}</span>
</div>
{/* Browse Catalog Mode */}
@@ -547,22 +550,22 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
<input
type="text"
className="agent-import-browse-search-input"
placeholder="Search companies..."
placeholder={t("agents.searchCompanies", "Search companies...")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label="Search companies"
aria-label={t("agents.searchCompanies", "Search companies")}
/>
</div>
{selectedCompany && (
<div className="agent-import-browse-selected">
<span className="agent-import-browse-selected-label">Selected:</span>
<span className="agent-import-browse-selected-label">{t("agents.selected", "Selected:")} </span>
<span className="agent-import-browse-selected-name">{selectedCompany.name}</span>
<button
type="button"
className="btn btn-sm"
onClick={() => setSelectedCompany(null)}
>
Change
{t("agents.change", "Change")}
</button>
</div>
)}
@@ -571,7 +574,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
{isLoadingCompanies && (
<div className="agent-import-browse-loading">
<Loader2 size={20} className="spin" />
<span>Loading companies...</span>
<span>{t("agents.loadingCompanies", "Loading companies...")}</span>
</div>
)}
@@ -585,7 +588,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
onClick={handleRetryFetchCompanies}
>
<RefreshCw size={14} />
Retry
{t("agents.retry", "Retry")}
</button>
</div>
)}
@@ -614,7 +617,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
<div className="agent-import-browse-item-header">
<span className="agent-import-browse-item-name">{company.name}</span>
{company.installs !== undefined && (
<span className="agent-import-browse-item-installs">{company.installs.toLocaleString()} installs</span>
<span className="agent-import-browse-item-installs">{company.installs.toLocaleString()} {t("agents.installs", "installs")}</span>
)}
</div>
{company.tagline && (
@@ -631,7 +634,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
(company.tagline?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false)
).length === 0 && (
<p className="agent-import-browse-empty">
{searchQuery ? "No companies match your search" : "No companies available"}
{searchQuery ? t("agents.noCompaniesMatch", "No companies match your search") : t("agents.noCompaniesAvailable", "No companies available")}
</p>
)}
</div>
@@ -643,13 +646,13 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
{inputMethod !== "browse" && (
<>
<div className="agent-import-divider">
<span>or paste manifest content</span>
<span>{t("agents.orPasteManifest", "or paste manifest content")}</span>
</div>
{/* Text area for paste */}
<textarea
className="agent-import-textarea"
placeholder={"---\nname: CEO\ntitle: Chief Executive Officer\nreportsTo: null\nskills:\n - review\n---\nAgent instructions go here..."}
placeholder={t("agents.manifestPlaceholder", "---\nname: CEO\ntitle: Chief Executive Officer\nreportsTo: null\nskills:\n - review\n---\nAgent instructions go here...")}
value={manifestContent}
onChange={(e) => {
setInputMethod("paste");
@@ -658,12 +661,12 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
setParseError(null);
}}
rows={8}
aria-label="Manifest content"
aria-label={t("agents.manifestContent", "Manifest content")}
/>
</>
)}
<p className="agent-import-file-hint">Current input: {inputMethod}</p>
<p className="agent-import-file-hint">{t("agents.currentInput", "Current input: {{method}}", { method: inputMethod })}</p>
{parseError && (
<p className="agent-dialog-error">
@@ -678,7 +681,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
{step === "preview" && (
<div className="agent-import-preview">
<div className="agent-import-company">
<span className="agent-import-company-label">Company</span>
<span className="agent-import-company-label">{t("agents.company", "Company")}</span>
<span className="agent-import-company-name">{companyName}</span>
</div>
@@ -695,7 +698,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
<div className="agent-import-count">
<FileText size={14} />
<span>{agents.length} agent{agents.length !== 1 ? "s" : ""} found</span>
<span>{t("agents.agentsFound", "{{count}} agent{{plural}} found", { count: agents.length, plural: agents.length !== 1 ? "s" : "" })}</span>
</div>
{agents.length > 0 && (
@@ -705,14 +708,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
className="btn btn-sm"
onClick={() => setSelectedAgentNames(agents.map((agent) => agent.name))}
>
Select all agents
{t("agents.selectAllAgents", "Select all agents")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => setSelectedAgentNames([])}
>
Clear agents
{t("agents.clearAgents", "Clear agents")}
</button>
</div>
)}
@@ -724,7 +727,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
<label className="checkbox-label">
<input
type="checkbox"
aria-label={`Select agent ${agent.name}`}
aria-label={t("agents.selectAgent", "Select agent {{name}}", { name: agent.name })}
checked={selectedAgentNames.includes(agent.name)}
onChange={() => toggleAgentSelection(agent.name)}
/>
@@ -752,14 +755,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
))}
</div>
) : (
<p className="agent-import-empty">No agents found in the manifest.</p>
<p className="agent-import-empty">{t("agents.noAgentsFound", "No agents found in the manifest.")}</p>
)}
{skills.length > 0 && (
<div className="agent-import-skills-section">
<div className="agent-import-count">
<FileText size={14} />
<span>{skills.length} skill{skills.length !== 1 ? "s" : ""} found</span>
<span>{t("agents.skillsFound", "{{count}} skill{{plural}} found", { count: skills.length, plural: skills.length !== 1 ? "s" : "" })}</span>
</div>
<div className="agent-import-selection-controls">
<button
@@ -767,14 +770,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
className="btn btn-sm"
onClick={() => setSelectedSkillNames(skills.map((skill) => skill.name))}
>
Select all skills
{t("agents.selectAllSkills", "Select all skills")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => setSelectedSkillNames([])}
>
Clear skills
{t("agents.clearSkills", "Clear skills")}
</button>
</div>
<div className="agent-import-skill-list">
@@ -783,7 +786,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
<label className="checkbox-label">
<input
type="checkbox"
aria-label={`Select skill ${skill.name}`}
aria-label={t("agents.selectSkill", "Select skill {{name}}", { name: skill.name })}
checked={selectedSkillNames.includes(skill.name)}
onChange={() => toggleSkillSelection(skill.name)}
/>
@@ -816,25 +819,25 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
<div className="agent-import-result-icon">
<CheckCircle size={32} />
</div>
<h3 className="agent-import-result-title">Import Complete</h3>
<h3 className="agent-import-result-title">{t("agents.importComplete", "Import Complete")}</h3>
<p className="agent-import-result-company">
From <strong>{importResult.companyName ?? "Unknown"}</strong>
{t("agents.from", "From")} <strong>{importResult.companyName ?? "Unknown"}</strong>
</p>
<div className="agent-import-result-stats">
{importResult.created.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--success">
<span>{importResult.created.length} created</span>
<span>{t("agents.resultCreated", "{{count}} created", { count: importResult.created.length })}</span>
</div>
)}
{importResult.skipped.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--skipped">
<span>{importResult.skipped.length} skipped (already exist)</span>
<span>{t("agents.resultSkipped", "{{count}} skipped (already exist)", { count: importResult.skipped.length })}</span>
</div>
)}
{importResult.errors.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--error">
<span>{importResult.errors.length} error{importResult.errors.length !== 1 ? "s" : ""}</span>
<span>{t("agents.resultErrors", "{{count}} error{{plural}}", { count: importResult.errors.length, plural: importResult.errors.length !== 1 ? "s" : "" })}</span>
</div>
)}
</div>
@@ -875,26 +878,26 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
{importResult.skills && (
<>
<div className="agent-import-result-divider" />
<h4 className="agent-import-result-section-title">Skills</h4>
<h4 className="agent-import-result-section-title">{t("agents.skills", "Skills")}</h4>
<div className="agent-import-result-stats">
{importResult.skills.imported.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--success">
<span>{importResult.skills.imported.length} skill{importResult.skills.imported.length !== 1 ? "s" : ""} imported</span>
<span>{t("agents.skillsImported", "{{count}} skill{{plural}} imported", { count: importResult.skills.imported.length, plural: importResult.skills.imported.length !== 1 ? "s" : "" })}</span>
</div>
)}
{importResult.skills.skipped.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--skipped">
<span>{importResult.skills.skipped.length} skill{importResult.skills.skipped.length !== 1 ? "s" : ""} skipped (already exist)</span>
<span>{t("agents.skillsSkipped", "{{count}} skill{{plural}} skipped (already exist)", { count: importResult.skills.skipped.length, plural: importResult.skills.skipped.length !== 1 ? "s" : "" })}</span>
</div>
)}
{importResult.skills.errors.length > 0 && (
<div className="agent-import-result-stat agent-import-result-stat--error">
<span>{importResult.skills.errors.length} skill{importResult.skills.errors.length !== 1 ? "s" : ""} error{importResult.skills.errors.length !== 1 ? "s" : ""}</span>
<span>{t("agents.skillsErrors", "{{count}} skill{{plural}} error{{pluralError}}", { count: importResult.skills.errors.length, plural: importResult.skills.errors.length !== 1 ? "s" : "", pluralError: importResult.skills.errors.length !== 1 ? "s" : "" })}</span>
</div>
)}
{importResult.skills.imported.length === 0 && importResult.skills.skipped.length === 0 && importResult.skills.errors.length === 0 && (
<div className="agent-import-result-stat agent-import-result-stat--skipped">
<span>No skills in package</span>
<span>{t("agents.noSkillsInPackage", "No skills in package")}</span>
</div>
)}
</div>
@@ -930,11 +933,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
<div className="agent-dialog-footer">
{step === "preview" && (
<button className="btn" onClick={() => setStep("input")} disabled={isImporting}>
Back
{t("agents.back", "Back")}
</button>
)}
<button className="btn" onClick={handleClose} disabled={isImporting}>
{step === "result" ? "Close" : "Cancel"}
{step === "result" ? t("agents.close", "Close") : t("agents.cancel", "Cancel")}
</button>
{step === "input" && (
<button
@@ -953,10 +956,10 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
{isParsing ? (
<>
<Loader2 size={14} className="spin" />
Parsing...
{t("agents.parsing", "Parsing...")}
</>
) : (
"Preview"
t("agents.preview", "Preview")
)}
</button>
)}
@@ -972,7 +975,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
{importLoadingLabel}
</>
) : (
`Import ${importActionLabel}`
t("agents.importButton", "Import {{label}}", { label: importActionLabel })
)}
</button>
)}

View File

@@ -4,6 +4,7 @@ import "./AgentListModal.css";
// import the styles eagerly here to avoid the modal rendering unstyled.
import "./AgentsView.css";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Play, Pause, Square, Trash2, RefreshCw, Bot, LayoutGrid, List, Filter } from "lucide-react";
import type { Agent, AgentCapability, AgentState } from "../api";
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
@@ -33,6 +34,7 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
];
export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentListModalProps) {
const { t } = useTranslation("app");
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
@@ -110,7 +112,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
if (gen !== loadAgentsGenRef.current) return;
setAgents(data);
} catch (err) {
addToast(`Failed to load agents: ${getErrorMessage(err)}`, "error");
addToast(t("agents.loadError", "Failed to load agents: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
if (gen === loadAgentsGenRef.current) setIsLoading(false);
}
@@ -140,12 +142,12 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
if (!newAgentName.trim()) return;
try {
await createAgent({ name: newAgentName.trim(), role: newAgentRole }, projectId);
addToast(`Agent "${newAgentName}" created`, "success");
addToast(t("agents.createSuccess", "Agent \"{{name}}\" created", { name: newAgentName }), "success");
setNewAgentName("");
setIsCreating(false);
void loadAgents(true);
} catch (err) {
addToast(`Failed to create agent: ${getErrorMessage(err)}`, "error");
addToast(t("agents.createError", "Failed to create agent: {{error}}", { error: getErrorMessage(err) }), "error");
}
};
@@ -161,7 +163,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
try {
await updateAgentState(agentId, newState, projectId);
addToast(`Agent state updated to ${newState}`, "success");
addToast(t("agents.stateUpdateSuccess", "Agent state updated to {{state}}", { state: newState }), "success");
await loadAgents(true);
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
@@ -174,7 +176,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
next.delete(agentId);
return next;
});
addToast(`Failed to update state: ${getErrorMessage(err)}`, "error");
addToast(t("agents.stateUpdateError", "Failed to update state: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
setTransitioningAgentIds((prev) => {
const next = new Set(prev);
@@ -186,17 +188,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
const handleDelete = async (agentId: string, agentName: string) => {
const shouldDelete = await confirm({
title: "Delete Agent",
message: `Delete agent "${agentName}"? This cannot be undone.`,
title: t("agents.deleteTitle", "Delete Agent"),
message: t("agents.deleteMessage", "Delete agent \"{{name}}\"? This cannot be undone.", { name: agentName }),
danger: true,
});
if (!shouldDelete) return;
try {
await deleteAgent(agentId, projectId);
addToast(`Agent "${agentName}" deleted`, "success");
addToast(t("agents.deleteSuccess", "Agent \"{{name}}\" deleted", { name: agentName }), "success");
void loadAgents(true);
} catch (err) {
addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error");
addToast(t("agents.deleteError", "Failed to delete agent: {{error}}", { error: getErrorMessage(err) }), "error");
}
};
@@ -212,11 +214,11 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
try {
await updateAgent(agentId, { role: newRole }, projectId);
addToast(`Agent role updated to ${AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole}`, "success");
addToast(t("agents.roleUpdateSuccess", "Agent role updated to {{role}}", { role: getRoleLabel(newRole) }), "success");
setEditingRoleForAgent(null);
void loadAgents(true);
} catch (err) {
addToast(`Failed to update role: ${getErrorMessage(err)}`, "error");
addToast(t("agents.roleUpdateError", "Failed to update role: {{error}}", { error: getErrorMessage(err) }), "error");
}
};
@@ -226,7 +228,20 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
}
};
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
const ROLE_LABEL_KEYS: Record<string, { key: string; defaultValue: string }> = {
triage: { key: "agents.roleTriage", defaultValue: "Triage" },
executor: { key: "agents.roleExecutor", defaultValue: "Executor" },
reviewer: { key: "agents.roleReviewer", defaultValue: "Reviewer" },
merger: { key: "agents.roleMerger", defaultValue: "Merger" },
scheduler: { key: "agents.roleScheduler", defaultValue: "Scheduler" },
engineer: { key: "agents.roleEngineer", defaultValue: "Engineer" },
custom: { key: "agents.roleCustom", defaultValue: "Custom" },
};
const getRoleLabel = (role: AgentCapability) => {
const entry = ROLE_LABEL_KEYS[role];
if (entry) return t(entry.key, entry.defaultValue);
return role;
};
// Use centralized health status utility for consistent labels across all views
// This fixes the previous hardcoded 60s timeout that was inconsistent with other views
@@ -243,7 +258,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
const getHealthSummary = (agent: Agent, health: AgentHealthStatus): { title: string | undefined; label: string | null } => {
if (agent.state === "error") {
return { title: undefined, label: "Error" };
return { title: undefined, label: t("agents.healthError", "Error") };
}
return {
@@ -260,15 +275,15 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<div className="modal-header">
<h2 className="modal-title">
<Bot size={20} />
Agents
{t("agents.modalTitle", "Agents")}
</h2>
<div className="modal-actions">
<div className="view-toggle">
<button
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
onClick={() => setView("board")}
title="Board view"
aria-label="Board view"
title={t("agents.boardView", "Board view")}
aria-label={t("agents.boardView", "Board view")}
aria-pressed={view === "board"}
>
<LayoutGrid size={16} />
@@ -276,8 +291,8 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className={`view-toggle-btn${view === "list" ? " active" : ""}`}
onClick={() => setView("list")}
title="List view"
aria-label="List view"
title={t("agents.listView", "List view")}
aria-label={t("agents.listView", "List view")}
aria-pressed={view === "list"}
>
<List size={16} />
@@ -286,12 +301,12 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<button
className="btn-icon"
onClick={() => void loadAgents()}
title="Refresh"
title={t("agents.refresh", "Refresh")}
disabled={isLoading}
>
<RefreshCw size={16} className={isLoading ? "spin" : ""} />
</button>
<button className="modal-close" onClick={onClose} aria-label="Close">
<button className="modal-close" onClick={onClose} aria-label={t("agents.close", "Close")}>
&times;
</button>
</div>
@@ -306,14 +321,14 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="agent-state-filter-select"
value={filterState}
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
aria-label="Filter agents by state"
aria-label={t("agents.filterByState", "Filter agents by state")}
>
<option value="all">All States</option>
<option value="idle">Idle</option>
<option value="active">Active</option>
<option value="running">Running</option>
<option value="paused">Paused</option>
<option value="error">Error</option>
<option value="all">{t("agents.filterAll", "All States")}</option>
<option value="idle">{t("agents.stateIdle", "Idle")}</option>
<option value="active">{t("agents.stateActive", "Active")}</option>
<option value="running">{t("agents.stateRunning", "Running")}</option>
<option value="paused">{t("agents.statePaused", "Paused")}</option>
<option value="error">{t("agents.stateError", "Error")}</option>
</select>
</div>
@@ -322,7 +337,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
onClick={() => setIsCreating(!isCreating)}
>
<Plus size={16} />
{isCreating ? "Cancel" : "New Agent"}
{isCreating ? t("agents.cancel", "Cancel") : t("agents.newAgent", "New Agent")}
</button>
</div>
@@ -331,7 +346,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<div className="agent-create-form">
<input
type="text"
placeholder="Agent name..."
placeholder={t("agents.namePlaceholder", "Agent name...")}
value={newAgentName}
onChange={(e) => setNewAgentName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
@@ -345,12 +360,12 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
>
{AGENT_ROLES.map(role => (
<option key={role.value} value={role.value}>
{role.icon} {role.label}
{role.icon} {getRoleLabel(role.value)}
</option>
))}
</select>
<button className="btn btn-task-create btn-sm" onClick={() => void handleCreate()}>
Create
{t("agents.create", "Create")}
</button>
</div>
)}
@@ -360,8 +375,8 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
{displayAgents.length === 0 ? (
<div className="agent-empty">
<Bot size={48} opacity={0.3} />
<p>No agents found</p>
<p className="text-secondary">Create an agent to get started</p>
<p>{t("agents.emptyTitle", "No agents found")}</p>
<p className="text-secondary">{t("agents.emptySubtitle", "Create an agent to get started")}</p>
</div>
) : view === "board" ? (
// Board view: compact grid layout
@@ -394,14 +409,14 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Activate"
title={t("agents.activate", "Activate")}
>
<Play size={14} />
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleDelete(agent.id, agent.name)}
title="Delete"
title={t("agents.delete", "Delete")}
>
<Trash2 size={14} />
</button>
@@ -413,7 +428,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
title={t("agents.pause", "Pause")}
>
<Pause size={14} />
</button>
@@ -421,7 +436,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
title={t("agents.stop", "Stop")}
>
<Square size={14} />
</button>
@@ -433,14 +448,14 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Resume"
title={t("agents.resume", "Resume")}
>
<Play size={14} />
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleDelete(agent.id, agent.name)}
title="Delete"
title={t("agents.delete", "Delete")}
>
<Trash2 size={14} />
</button>
@@ -452,7 +467,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
title={t("agents.pause", "Pause")}
>
<Pause size={14} />
</button>
@@ -460,7 +475,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
title={t("agents.stop", "Stop")}
>
<Square size={14} />
</button>
@@ -472,7 +487,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Retry"
title={t("agents.retry", "Retry")}
>
<Play size={14} />
</button>
@@ -480,7 +495,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
title={t("agents.stop", "Stop")}
>
<Square size={14} />
</button>
@@ -512,7 +527,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
>
{AGENT_ROLES.map(role => (
<option key={role.value} value={role.value}>
{role.icon} {role.label}
{role.icon} {getRoleLabel(role.value)}
</option>
))}
</select>
@@ -520,7 +535,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<span
className="agent-icon agent-icon--clickable"
onClick={() => setEditingRoleForAgent(agent.id)}
title="Click to change role"
title={t("agents.changeRole", "Click to change role")}
role="button"
tabIndex={0}
onKeyDown={(e) => {
@@ -568,13 +583,13 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
) : null}
{agent.taskId && (
<div className="agent-task">
<span className="text-secondary">Working on:</span>
<span className="text-secondary">{t("agents.workingOn", "Working on:")}</span>
<span className="badge">{agent.taskId}</span>
</div>
)}
{agent.lastHeartbeatAt && (
<div className="agent-heartbeat">
<span className="text-secondary">Last heartbeat:</span>
<span className="text-secondary">{t("agents.lastHeartbeat", "Last heartbeat:")}</span>
<span>{new Date(agent.lastHeartbeatAt).toLocaleString()}</span>
</div>
)}
@@ -587,16 +602,16 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Activate"
title={t("agents.activate", "Activate")}
>
<Play size={14} /> Start
<Play size={14} /> {t("agents.start", "Start")}
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleDelete(agent.id, agent.name)}
title="Delete"
title={t("agents.delete", "Delete")}
>
<Trash2 size={14} /> Delete
<Trash2 size={14} /> {t("agents.delete", "Delete")}
</button>
</>
)}
@@ -606,17 +621,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
title={t("agents.pause", "Pause")}
>
<Pause size={14} /> Pause
<Pause size={14} /> {t("agents.pause", "Pause")}
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
title={t("agents.stop", "Stop")}
>
<Square size={14} /> Stop
<Square size={14} /> {t("agents.stop", "Stop")}
</button>
</>
)}
@@ -626,16 +641,16 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Resume"
title={t("agents.resume", "Resume")}
>
<Play size={14} /> Resume
<Play size={14} /> {t("agents.resume", "Resume")}
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleDelete(agent.id, agent.name)}
title="Delete"
title={t("agents.delete", "Delete")}
>
<Trash2 size={14} /> Delete
<Trash2 size={14} /> {t("agents.delete", "Delete")}
</button>
</>
)}
@@ -645,17 +660,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
title={t("agents.pause", "Pause")}
>
<Pause size={14} /> Pause
<Pause size={14} /> {t("agents.pause", "Pause")}
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
title={t("agents.stop", "Stop")}
>
<Square size={14} /> Stop
<Square size={14} /> {t("agents.stop", "Stop")}
</button>
</>
)}
@@ -665,17 +680,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Retry"
title={t("agents.retry", "Retry")}
>
<Play size={14} /> Retry
<Play size={14} /> {t("agents.retry", "Retry")}
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Stop"
title={t("agents.stop", "Stop")}
>
<Square size={14} /> Stop
<Square size={14} /> {t("agents.stop", "Stop")}
</button>
</>
)}

View File

@@ -1,4 +1,6 @@
import type { AgentLogEntry } from "@fusion/core";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { ProviderIcon } from "./ProviderIcon";
import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, useId, type ReactElement } from "react";
import ReactMarkdown from "react-markdown";
@@ -31,7 +33,7 @@ function writeBooleanPref(key: string, value: boolean): void {
}
}
function formatTimestamp(iso: string): string {
function formatTimestamp(iso: string, t: TFunction<"app">): string {
const date = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
@@ -39,10 +41,10 @@ function formatTimestamp(iso: string): string {
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
if (diffMin < 1) return t("agentLog.timeJustNow", "just now");
if (diffMin < 60) return t("agentLog.timeMinutesAgo", "{{count}}m ago", { count: diffMin });
if (diffHr < 24) return t("agentLog.timeHoursAgo", "{{count}}h ago", { count: diffHr });
if (diffDay < 7) return t("agentLog.timeDaysAgo", "{{count}}d ago", { count: diffDay });
return date.toLocaleDateString();
}
@@ -86,9 +88,10 @@ const markdownComponents: Components = {
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
const AGENT_DISPLAY_NAMES: Record<string, string> = {
triage: "Plan",
};
function getAgentDisplayName(agent: string, t: TFunction<"app">): string {
if (agent === "triage") return t("agentLog.agentNameTriage", "Plan");
return agent;
}
function isNearBottom(container: HTMLDivElement): boolean {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX;
@@ -125,12 +128,13 @@ interface CollapsibleToolDetailProps {
}
function CollapsibleToolDetail({ detail }: CollapsibleToolDetailProps): ReactElement {
const { t } = useTranslation("app");
const [expanded, setExpanded] = useState(false);
const contentId = useId();
const lineCount = detail.split("\n").length;
const toggleLabel = expanded
? "Hide output"
: `Show output${lineCount > 1 ? ` (${lineCount} lines)` : ""}`;
? t("agentLog.hideOutput", "Hide output")
: t("agentLog.showOutput", `Show output${lineCount > 1 ? ` (${lineCount} lines)` : ""}`);
return (
<div className="agent-log-tool-detail-wrapper">
@@ -281,6 +285,7 @@ export function AgentLogViewer({
loadingMore = false,
totalCount = null,
}: AgentLogViewerProps) {
const { t } = useTranslation("app");
const containerRef = useRef<HTMLDivElement>(null);
const previousEntryCountRef = useRef<number>(0);
const previousScrollHeightRef = useRef<number>(0);
@@ -470,7 +475,7 @@ export function AgentLogViewer({
if (loading && entries.length === 0) {
return (
<div className="agent-log-viewer" data-testid="agent-log-viewer">
<div className="agent-log-loading">Loading agent logs…</div>
<div className="agent-log-loading">{t("agentLog.loading", "Loading agent logs…")}</div>
</div>
);
}
@@ -478,7 +483,7 @@ export function AgentLogViewer({
if (entries.length === 0) {
return (
<div className="agent-log-viewer" data-testid="agent-log-viewer">
<div className="agent-log-empty">No agent output yet.</div>
<div className="agent-log-empty">{t("agentLog.empty", "No agent output yet.")}</div>
</div>
);
}
@@ -501,7 +506,7 @@ export function AgentLogViewer({
<button
className="agent-log-model-expand-btn"
onClick={() => setModelHeaderExpanded((prev) => !prev)}
aria-label={modelHeaderExpanded ? "Collapse model details" : "Expand model details"}
aria-label={modelHeaderExpanded ? t("agentLog.collapseModelDetails", "Collapse model details") : t("agentLog.expandModelDetails", "Expand model details")}
aria-expanded={modelHeaderExpanded}
aria-controls="agent-log-model-details"
data-testid="agent-log-model-expand"
@@ -515,29 +520,29 @@ export function AgentLogViewer({
<button
className="agent-log-mode-toggle"
onClick={() => setRenderMarkdown((prev) => !prev)}
aria-label={renderMarkdown ? "Switch to plain text mode" : "Switch to markdown mode"}
aria-label={renderMarkdown ? t("agentLog.switchPlainText", "Switch to plain text mode") : t("agentLog.switchMarkdown", "Switch to markdown mode")}
aria-pressed={renderMarkdown}
data-testid="agent-log-mode-toggle"
title={renderMarkdown ? "Show raw text" : "Show formatted markdown"}
title={renderMarkdown ? t("agentLog.showRawText", "Show raw text") : t("agentLog.showFormattedMarkdown", "Show formatted markdown")}
>
{renderMarkdown ? "Markdown" : "Plain"}
{renderMarkdown ? t("agentLog.markdown", "Markdown") : t("agentLog.plain", "Plain")}
</button>
<button
className="agent-log-mode-toggle"
onClick={() => setShowToolOutput((prev) => !prev)}
aria-label={showToolOutput ? "Hide tool output" : "Show tool output"}
aria-label={showToolOutput ? t("agentLog.hideToolOutput", "Hide tool output") : t("agentLog.showToolOutput", "Show tool output")}
aria-pressed={showToolOutput}
data-testid="agent-log-tool-output-toggle"
title={showToolOutput ? "Hide tool calls and results" : "Show tool calls and results"}
title={showToolOutput ? t("agentLog.hideToolCallsResults", "Hide tool calls and results") : t("agentLog.showToolCallsResults", "Show tool calls and results")}
>
{showToolOutput ? "Tools: On" : "Tools: Off"}
{showToolOutput ? t("agentLog.toolsOn", "Tools: On") : t("agentLog.toolsOff", "Tools: Off")}
</button>
<button
className="agent-log-mode-toggle"
onClick={() => setIsFullscreen((prev) => !prev)}
aria-label={isFullscreen ? "Exit full screen" : "Expand agent log to full screen"}
aria-label={isFullscreen ? t("agentLog.exitFullscreen", "Exit full screen") : t("agentLog.expandFullscreen", "Expand agent log to full screen")}
data-testid="agent-log-fullscreen-toggle"
title={isFullscreen ? "Exit full screen" : "Expand agent log to full screen"}
title={isFullscreen ? t("agentLog.exitFullscreen", "Exit full screen") : t("agentLog.expandFullscreen", "Expand agent log to full screen")}
>
{isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
@@ -546,36 +551,36 @@ export function AgentLogViewer({
{modelHeaderExpanded && (
<div id="agent-log-model-details" className="agent-log-model-details">
<div className="agent-log-model-group">
<span className="agent-log-model-label">Executor:</span>
<span className="agent-log-model-label">{t("agentLog.executor", "Executor")}:</span>
{hasExecutorOverride ? (
<span className="agent-log-model-value">
<ProviderIcon provider={executorModel.provider!} size="sm" />
<span>{executorModel.provider}/{executorModel.modelId}</span>
</span>
) : (
<span className="model-badge-default">Using default</span>
<span className="model-badge-default">{t("agentLog.usingDefault", "Using default")}</span>
)}
</div>
<div className="agent-log-model-group">
<span className="agent-log-model-label">Reviewer:</span>
<span className="agent-log-model-label">{t("agentLog.reviewer", "Reviewer")}:</span>
{hasValidatorOverride ? (
<span className="agent-log-model-value">
<ProviderIcon provider={validatorModel.provider!} size="sm" />
<span>{validatorModel.provider}/{validatorModel.modelId}</span>
</span>
) : (
<span className="model-badge-default">Using default</span>
<span className="model-badge-default">{t("agentLog.usingDefault", "Using default")}</span>
)}
</div>
<div className="agent-log-model-group">
<span className="agent-log-model-label">Planning:</span>
<span className="agent-log-model-label">{t("agentLog.planning", "Planning")}:</span>
{hasPlanningOverride ? (
<span className="agent-log-model-value">
<ProviderIcon provider={planningModel.provider!} size="sm" />
<span>{planningModel.provider}/{planningModel.modelId}</span>
</span>
) : (
<span className="model-badge-default">Using default</span>
<span className="model-badge-default">{t("agentLog.usingDefault", "Using default")}</span>
)}
</div>
</div>
@@ -590,9 +595,9 @@ export function AgentLogViewer({
{/* Pagination summary */}
{totalCount !== null && (
<div className="agent-log-summary" data-testid="agent-log-summary">
Showing {visibleEntries.length} of {totalCount} entries
{t("agentLog.showing", "Showing {{visible}} of {{total}} entries", { visible: visibleEntries.length, total: totalCount })}
{!showToolOutput && entries.length !== visibleEntries.length
? ` (${entries.length - visibleEntries.length} tool entries hidden)`
? ` (${t("agentLog.toolEntriesHidden", "{{count}} tool entries hidden", { count: entries.length - visibleEntries.length })})`
: ""}
</div>
)}
@@ -608,10 +613,10 @@ export function AgentLogViewer({
{loadingMore ? (
<>
<Loader2 size={14} className="animate-spin" />
Loading…
{t("agentLog.loadingMore", "Loading…")}
</>
) : (
"Load More"
t("agentLog.loadMore", "Load More")
)}
</button>
</div>
@@ -621,13 +626,13 @@ export function AgentLogViewer({
const firstEntry = group.kind === "single" ? group.entry : group.entries[0];
const timestampSpan = group.showBadge ? (
<span className="agent-log-timestamp" data-testid="agent-log-timestamp">
{formatTimestamp(firstEntry.timestamp)}
{formatTimestamp(firstEntry.timestamp, t as TFunction<"app">)}
</span>
) : null;
const agentBadge = group.showBadge ? (
<span className="agent-log-badge-row">
<span className="agent-log-agent-badge">[{AGENT_DISPLAY_NAMES[firstEntry.agent!] ?? firstEntry.agent}]</span>
<span className="agent-log-agent-badge">[{getAgentDisplayName(firstEntry.agent!, t as TFunction<"app">)}]</span>
{timestampSpan}
</span>
) : null;
@@ -711,7 +716,7 @@ export function AgentLogViewer({
data-testid="agent-log-return-to-live"
>
<ChevronDown size={12} />
<span>Live</span>
<span>{t("agentLog.live", "Live")}</span>
</button>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AgentAvatar } from "./AgentAvatar";
import "./AgentMentionPopup.css";
import type { Agent } from "@fusion/core";
@@ -33,6 +34,7 @@ export function AgentMentionPopup({
roomMemberIds,
roomName,
}: AgentMentionPopupProps) {
const { t } = useTranslation("app");
const filteredAgents = useMemo(() => agents.filter((agent) => matchesAgentMentionFilter(agent.name, filter)), [agents, filter]);
const roomMode = Boolean(roomMemberIds);
@@ -57,15 +59,15 @@ export function AgentMentionPopup({
className={`agent-mention-popup agent-mention-popup--${position}`}
data-testid="agent-mention-popup"
role="listbox"
aria-label="Agent mention suggestions"
aria-label={t("agentMention.suggestionsLabel", "Agent mention suggestions")}
>
{visibleAgents.length === 0 ? (
<div className="agent-mention-empty">No agents found</div>
<div className="agent-mention-empty">{t("agentMention.noAgentsFound", "No agents found")}</div>
) : (
<>
{roomMode && (
<div className="agent-mention-section-header" data-testid="agent-mention-members-header">
{roomName ? `Members of #${roomName}` : "Room members"}
{roomName ? t("agentMention.membersOf", "Members of #{{roomName}}", { roomName }) : t("agentMention.roomMembers", "Room members")}
</div>
)}
{memberAgents.map((agent, index) => (
@@ -80,17 +82,17 @@ export function AgentMentionPopup({
aria-selected={index === highlightedIndex}
>
<AgentAvatar agent={agent} size={20} />
{roomMode && <span className="status-dot agent-mention-member-dot" aria-label="Room member" />}
{roomMode && <span className="status-dot agent-mention-member-dot" aria-label={t("agentMention.roomMemberBadge", "Room member")} />}
<span className="agent-mention-name">{agent.name}</span>
<span className="agent-mention-role">{agent.role}</span>
</button>
))}
{roomMode && !showOtherSection && otherAgents.length > 0 && (
<div className="agent-mention-hint" data-testid="agent-mention-other-hint">Type to search other agents</div>
<div className="agent-mention-hint" data-testid="agent-mention-other-hint">{t("agentMention.typeToSearch", "Type to search other agents")}</div>
)}
{roomMode && showOtherSection && otherAgents.length > 0 && (
<>
<div className="agent-mention-section-header" data-testid="agent-mention-others-header">Other agents</div>
<div className="agent-mention-section-header" data-testid="agent-mention-others-header">{t("agentMention.otherAgents", "Other agents")}</div>
{otherAgents.map((agent, index) => {
const globalIndex = memberAgents.length + index;
return (

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { AgentCapability, ConversationHistoryEntry } from "../api";
import {
startAgentOnboardingStreaming,
@@ -31,6 +32,7 @@ interface AgentOnboardingModalProps {
}
export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, projectId, existingAgents }: AgentOnboardingModalProps) {
const { t } = useTranslation("app");
const [viewState, setViewState] = useState<ViewState>("initial");
const [intent, setIntent] = useState("");
const [sessionId, setSessionId] = useState<string | null>(null);
@@ -97,7 +99,7 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
},
onConnectionStateChange: (state) => {
if (state === "reconnecting") {
setError("Connection lost. Retrying...");
setError(t("agents.connectionLost", "Connection lost. Retrying..."));
}
},
});
@@ -179,7 +181,7 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
},
projectId,
);
addToast(`Agent "${summary.name}" created`, "success");
addToast(t("agents.created", "Agent \"{{name}}\" created", { name: summary.name }), "success");
onCreated();
} catch (err) {
setError((err as Error).message);
@@ -191,79 +193,79 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
<div className="modal-overlay open" role="presentation">
<div className="modal modal-lg agent-onboarding-modal" role="dialog" aria-modal="true" aria-label="Agent onboarding">
<div className="modal-header">
<h3>Agent Onboarding</h3>
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">×</button>
<h3>{t("agents.onboarding", "Agent Onboarding")}</h3>
<button className="modal-close" onClick={() => void handleClose()} aria-label={t("common.close", "Close")}>×</button>
</div>
{history.length > 0 && <ConversationHistory entries={history} />}
{viewState === "initial" && (
<div className="form-group">
<label htmlFor="agent-onboarding-intent">What do you want this agent to do?</label>
<label htmlFor="agent-onboarding-intent">{t("agents.intentPrompt", "What do you want this agent to do?")}</label>
<textarea ref={setIntentRef} id="agent-onboarding-intent" className="input" value={intent} onChange={(e) => setIntent(e.target.value)} />
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>Start onboarding</button>
<button className="btn" onClick={() => void handleClose()}>{t("common.cancel", "Cancel")}</button>
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>{t("agents.startOnboarding", "Start onboarding")}</button>
</div>
</div>
)}
{(viewState === "loading" || viewState === "question") && (
<div className="form-group">
<label htmlFor="agent-onboarding-answer">{currentQuestion || "Waiting for AI question..."}</label>
<label htmlFor="agent-onboarding-answer">{currentQuestion || t("agents.waitingForQuestion", "Waiting for AI question...")}</label>
<textarea ref={setAnswerRef} id="agent-onboarding-answer" className="input" value={answer} onChange={(e) => setAnswer(e.target.value)} />
<div className="modal-actions">
<button className="btn" onClick={() => sessionId && void stopAgentOnboardingGeneration(sessionId, projectId)}>Stop</button>
<button className="btn btn-primary" disabled={viewState === "loading" || !answer.trim()} onClick={() => void submitAnswer()}>Continue</button>
<button className="btn" onClick={() => sessionId && void stopAgentOnboardingGeneration(sessionId, projectId)}>{t("common.stop", "Stop")}</button>
<button className="btn btn-primary" disabled={viewState === "loading" || !answer.trim()} onClick={() => void submitAnswer()}>{t("common.continue", "Continue")}</button>
</div>
</div>
)}
{viewState === "summary" && summary && (
<div className="form-group">
<label>Review generated configuration</label>
<label>{t("agents.reviewConfiguration", "Review generated configuration")}</label>
<div className="agent-onboarding-summary">
<p><strong>Name:</strong> {summary.name}</p>
<p><strong>Role:</strong> {summary.role}</p>
<label htmlFor="thinking-level">Thinking level</label>
<p><strong>{t("agents.name", "Name")}:</strong> {summary.name}</p>
<p><strong>{t("agents.role", "Role")}:</strong> {summary.role}</p>
<label htmlFor="thinking-level">{t("agents.thinkingLevel", "Thinking level")}</label>
<input id="thinking-level" className="input" value={summary.thinkingLevel} onChange={() => {}} readOnly />
<label htmlFor="max-turns">Max turns</label>
<label htmlFor="max-turns">{t("agents.maxTurns", "Max turns")}</label>
<input id="max-turns" className="input" type="number" value={summary.maxTurns} onChange={() => {}} readOnly />
<label htmlFor="runtime-mode">Runtime mode</label>
<select id="runtime-mode" className="select" value={runtimeMode} onChange={(e) => setRuntimeMode(e.target.value as "model" | "runtime")}>
<option value="model">Model</option>
<option value="runtime">Runtime</option>
<label htmlFor="runtime-mode">{t("agents.runtimeMode", "Runtime mode")}</label>
<select id="runtime-mode" className="select" value={runtimeMode} onChange={(e) => setRuntimeMode(e.target.value as "model" | "runtime")}>
<option value="model">{t("agents.model", "Model")}</option>
<option value="runtime">{t("agents.runtime", "Runtime")}</option>
</select>
{runtimeMode === "model" && (
<>
<label>Model</label>
<label>{t("agents.selectModel", "Model")}</label>
<CustomModelDropdown
id="agent-onboarding-model"
label="Model"
label={t("agents.selectModel", "Model")}
value={model}
onChange={setModel}
models={availableModels}
placeholder="Select a model…"
placeholder={t("agents.selectModelPlaceholder", "Select a model…")}
/>
</>
)}
</div>
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
<button className="btn btn-primary" onClick={() => void createFromSummary()}>Create agent</button>
<button className="btn" onClick={() => void handleClose()}>{t("common.cancel", "Cancel")}</button>
<button className="btn btn-primary" onClick={() => void createFromSummary()}>{t("agents.createAgent", "Create agent")}</button>
</div>
</div>
)}
{viewState === "creating" && (
<div className="form-group agent-onboarding-creating">Creating agent...</div>
<div className="form-group agent-onboarding-creating">{t("agents.creatingAgent", "Creating agent...")}</div>
)}
{viewState === "error" && error && (
<div className="form-group">
<div className="form-error">{error}</div>
<div className="modal-actions">
<button className="btn" onClick={() => sessionId && void retryAgentOnboardingSession(sessionId, projectId)}>Retry</button>
<button className="btn" onClick={() => sessionId && void retryAgentOnboardingSession(sessionId, projectId)}>{t("common.retry", "Retry")}</button>
</div>
</div>
)}

View File

@@ -1,4 +1,6 @@
import "./AgentPermissionPolicyEditor.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import {
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES,
@@ -20,13 +22,24 @@ interface Props {
const DISPOSITIONS: AgentPermissionPolicyDisposition[] = ["allow", "require-approval", "block"];
const CATEGORY_LABELS: Record<string, { label: string; description: string }> = {
git_write: { label: "Git writes", description: "Commits, branch updates, and merge-affecting git changes." },
file_write_delete: { label: "File writes/deletes", description: "Create, edit, or remove files in the workspace." },
command_execution: { label: "Command execution", description: "Runs shell commands and scripts." },
network_api: { label: "Network/API", description: "Outbound network or API access." },
task_agent_mutation: { label: "Task/agent mutation", description: "Task state changes, delegation, or agent lifecycle actions." },
};
function getCategoryLabels(t: TFunction<"app">): Record<string, { label: string; description: string }> {
return {
git_write: { label: t("agentPolicy.category.gitWrite.label", "Git writes"), description: t("agentPolicy.category.gitWrite.description", "Commits, branch updates, and merge-affecting git changes.") },
file_write_delete: { label: t("agentPolicy.category.fileWriteDelete.label", "File writes/deletes"), description: t("agentPolicy.category.fileWriteDelete.description", "Create, edit, or remove files in the workspace.") },
command_execution: { label: t("agentPolicy.category.commandExecution.label", "Command execution"), description: t("agentPolicy.category.commandExecution.description", "Runs shell commands and scripts.") },
network_api: { label: t("agentPolicy.category.networkApi.label", "Network/API"), description: t("agentPolicy.category.networkApi.description", "Outbound network or API access.") },
task_agent_mutation: { label: t("agentPolicy.category.taskAgentMutation.label", "Task/agent mutation"), description: t("agentPolicy.category.taskAgentMutation.description", "Task state changes, delegation, or agent lifecycle actions.") },
};
}
function getDispositionLabel(t: TFunction<"app">, disposition: AgentPermissionPolicyDisposition): string {
if (disposition === "require-approval") return t("agentPolicy.requireApproval", "Require approval");
if (disposition === "allow") return t("agentPolicy.allow", "Allow");
if (disposition === "block") return t("agentPolicy.block", "Block");
// Forward-compat fallback if the disposition union ever grows.
const raw: string = disposition;
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
function buildAllowRules(): AgentPermissionPolicyRules {
return {
@@ -78,6 +91,8 @@ function derivePresetFromRules(rules: AgentPermissionPolicyRules): AgentPermissi
}
export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onChange, disabled = false }: Props) {
const { t } = useTranslation("app");
const categoryLabels = getCategoryLabels(t as TFunction<"app">);
const derivedPreset = value ? derivePresetFromRules(value.rules) : "unrestricted";
const currentPreset = mode === "agent-override" && !value ? "inherit" : (value?.presetId === "custom" ? derivedPreset : (value?.presetId ?? "unrestricted"));
const rules = value?.rules ?? buildAllowRules();
@@ -109,7 +124,7 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
return (
<div className="agent-policy-editor card">
<div className="form-group">
<label htmlFor="agent-policy-preset">Preset</label>
<label htmlFor="agent-policy-preset">{t("agentPolicy.preset", "Preset")}</label>
<select
id="agent-policy-preset"
className="select"
@@ -117,17 +132,17 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
onChange={(event) => setPreset(event.target.value)}
disabled={disabled}
>
{mode === "agent-override" ? <option value="inherit">Inherit project default</option> : null}
<option value="unrestricted">Unrestricted</option>
<option value="approval-required">Approval Required</option>
<option value="locked-down">Locked Down</option>
<option value="custom">Custom</option>
{mode === "agent-override" ? <option value="inherit">{t("agentPolicy.inheritDefault", "Inherit project default")}</option> : null}
<option value="unrestricted">{t("agentPolicy.unrestricted", "Unrestricted")}</option>
<option value="approval-required">{t("agentPolicy.approvalRequired", "Approval Required")}</option>
<option value="locked-down">{t("agentPolicy.lockedDown", "Locked Down")}</option>
<option value="custom">{t("agentPolicy.custom", "Custom")}</option>
</select>
</div>
<div className="agent-policy-table" role="table" aria-label="Permission policy categories">
<div className="agent-policy-table" role="table" aria-label={t("agentPolicy.ariaLabel", "Permission policy categories")}>
{AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.map((category) => {
const meta = CATEGORY_LABELS[category] ?? { label: category, description: "" };
const meta = categoryLabels[category] ?? { label: category, description: "" };
const inherited = projectDefault?.[category] ?? "allow";
const effective = rules[category] ?? "allow";
const rowValue = mode === "agent-override" && !value ? "inherit" : effective;
@@ -136,7 +151,7 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
<div className="agent-policy-cell">
<strong>{meta.label}</strong>
<div className="agent-policy-description">{meta.description}</div>
<ul className="agent-policy-examples" aria-label={`${meta.label} examples`}>
<ul className="agent-policy-examples" aria-label={t("agentPolicy.categoryExamples", "{{label}} examples", { label: meta.label })}>
{(AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES[category] ?? []).map((toolName) => (
<li key={`${category}-${toolName}`}>
<code>{toolName}</code>
@@ -151,15 +166,15 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
onChange={(event) => setRule(category, event.target.value)}
disabled={disabled}
>
{mode === "agent-override" ? <option value="inherit">Inherit</option> : null}
{mode === "agent-override" ? <option value="inherit">{t("agentPolicy.inherit", "Inherit")}</option> : null}
{DISPOSITIONS.map((disposition) => (
<option key={disposition} value={disposition}>
{disposition === "require-approval" ? "Require approval" : disposition[0]?.toUpperCase() + disposition.slice(1)}
{getDispositionLabel(t as TFunction<"app">, disposition)}
</option>
))}
</select>
{mode === "agent-override" && rowValue === "inherit" ? (
<div className="agent-policy-inherit-note">from project default: {inherited === "require-approval" ? "Require approval" : inherited}</div>
<div className="agent-policy-inherit-note">{t("agentPolicy.fromProjectDefault", "from project default")}: {getDispositionLabel(t as TFunction<"app">, inherited)}</div>
) : null}
</div>
</div>
@@ -168,10 +183,9 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
</div>
<details className="agent-policy-exempt" open={false}>
<summary>Tools exempt from approval policy</summary>
<summary>{t("agentPolicy.exemptTools", "Tools exempt from approval policy")}</summary>
<p>
These coordination tools bypass approval policy so heartbeats and inter-agent messaging cannot deadlock. They
are not user-configurable.
{t("agentPolicy.exemptToolsDescription", "These coordination tools bypass approval policy so heartbeats and inter-agent messaging cannot deadlock. They are not user-configurable.")}
</p>
<ul className="agent-policy-exempt-list">
{AGENT_PERMISSION_POLICY_EXEMPT_TOOL_EXAMPLES.map((toolName) => (

View File

@@ -1,5 +1,6 @@
import "./AgentPromptsManager.css";
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { BUILTIN_AGENT_PROMPTS, PROMPT_KEY_CATALOG } from "../utils/builtinPrompts";
import type { AgentPromptTemplate, AgentPromptsConfig, AgentCapability } from "@fusion/core";
import type { PromptKey } from "@fusion/core";
@@ -30,15 +31,15 @@ type TabId = "templates" | "assignments" | "overrides";
/** Core agent roles that have built-in templates */
const CORE_ROLES: AgentCapability[] = ["executor", "triage", "reviewer", "merger"];
/** Role display labels */
const ROLE_LABELS: Record<AgentCapability, string> = {
executor: "Executor Agent",
triage: "Triage Agent",
reviewer: "Reviewer Agent",
merger: "Merger Agent",
scheduler: "Scheduler Agent",
engineer: "Engineer Agent",
custom: "Custom Agent",
/** Role display label keys for i18n */
const ROLE_LABEL_KEYS: Record<AgentCapability, { key: string; defaultValue: string }> = {
executor: { key: "agentPrompts.roles.executor", defaultValue: "Executor Agent" },
triage: { key: "agentPrompts.roles.triage", defaultValue: "Triage Agent" },
reviewer: { key: "agentPrompts.roles.reviewer", defaultValue: "Reviewer Agent" },
merger: { key: "agentPrompts.roles.merger", defaultValue: "Merger Agent" },
scheduler: { key: "agentPrompts.roles.scheduler", defaultValue: "Scheduler Agent" },
engineer: { key: "agentPrompts.roles.engineer", defaultValue: "Engineer Agent" },
custom: { key: "agentPrompts.roles.custom", defaultValue: "Custom Agent" },
};
const getRoleToneClassName = (role: AgentCapability): string => {
@@ -113,6 +114,13 @@ export function AgentPromptsManager({
promptOverrides,
onPromptOverridesChange,
}: AgentPromptsManagerProps) {
const { t } = useTranslation("app");
const getRoleLabel = (role: AgentCapability): string => {
const entry = ROLE_LABEL_KEYS[role];
return t(entry.key, entry.defaultValue);
};
// Tab state
const [activeTab, setActiveTab] = useState<TabId>("templates");
@@ -200,7 +208,7 @@ export function AgentPromptsManager({
const handleSaveTemplate = useCallback(() => {
const trimmedName = templateForm.name.trim();
if (!trimmedName) {
setTemplateIdError("Template name is required");
setTemplateIdError(t("agentPrompts.errors.nameRequired", "Template name is required"));
return;
}
@@ -212,7 +220,7 @@ export function AgentPromptsManager({
// Check for collision with built-in IDs (shouldn't happen with generateTemplateId, but be defensive)
const builtinIds = new Set(BUILTIN_AGENT_PROMPTS.map((t) => t.id));
if (builtinIds.has(templateId)) {
setTemplateIdError(`Template ID "${templateId}" conflicts with a built-in template. Please use a different name.`);
setTemplateIdError(t("agentPrompts.errors.idConflict", "Template ID \"{{templateId}}\" conflicts with a built-in template. Please use a different name.", { templateId }));
return;
}
} else {
@@ -384,7 +392,7 @@ export function AgentPromptsManager({
data-testid="tab-templates"
>
<BookOpen size={14} />
Templates
{t("agentPrompts.tabs.templates", "Templates")}
</button>
<button
type="button"
@@ -393,7 +401,7 @@ export function AgentPromptsManager({
data-testid="tab-assignments"
>
<Users size={14} />
Assignments
{t("agentPrompts.tabs.assignments", "Assignments")}
</button>
<button
type="button"
@@ -402,7 +410,7 @@ export function AgentPromptsManager({
data-testid="tab-overrides"
>
<Settings2 size={14} />
Overrides
{t("agentPrompts.tabs.overrides", "Overrides")}
</button>
</div>
@@ -415,12 +423,12 @@ export function AgentPromptsManager({
{(isCreating || editingTemplateId !== null) && (
<div className="prompt-template-editor" data-testid="template-editor">
<h4 className="prompt-template-editor-title">
{isCreating ? "New Custom Template" : "Edit Custom Template"}
{isCreating ? t("agentPrompts.editor.newTemplate", "New Custom Template") : t("agentPrompts.editor.editTemplate", "Edit Custom Template")}
</h4>
<div className="prompt-template-editor-fields">
<div className="prompt-template-field">
<label htmlFor="template-name">Name</label>
<label htmlFor="template-name">{t("agentPrompts.labels.name", "Name")}</label>
<input
id="template-name"
type="text"
@@ -428,13 +436,13 @@ export function AgentPromptsManager({
onChange={(e) =>
setTemplateForm((f) => ({ ...f, name: e.target.value }))
}
placeholder="e.g. My Custom Executor"
placeholder={t("agentPrompts.placeholders.templateName", "e.g. My Custom Executor")}
data-testid="template-name-input"
/>
</div>
<div className="prompt-template-field">
<label htmlFor="template-description">Description</label>
<label htmlFor="template-description">{t("agentPrompts.labels.description", "Description")}</label>
<input
id="template-description"
type="text"
@@ -442,13 +450,13 @@ export function AgentPromptsManager({
onChange={(e) =>
setTemplateForm((f) => ({ ...f, description: e.target.value }))
}
placeholder="Brief description of this template"
placeholder={t("agentPrompts.placeholders.description", "Brief description of this template")}
data-testid="template-description-input"
/>
</div>
<div className="prompt-template-field">
<label htmlFor="template-role">Role</label>
<label htmlFor="template-role">{t("agentPrompts.labels.role", "Role")}</label>
<select
id="template-role"
value={templateForm.role}
@@ -462,7 +470,7 @@ export function AgentPromptsManager({
>
{CORE_ROLES.map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]}
{getRoleLabel(role)}
</option>
))}
</select>
@@ -470,12 +478,12 @@ export function AgentPromptsManager({
<div className="prompt-template-field">
<div className="prompt-template-prompt-label-row">
<label htmlFor="template-prompt">Prompt</label>
<label htmlFor="template-prompt">{t("agentPrompts.labels.prompt", "Prompt")}</label>
<button
type="button"
className="btn-icon prompt-template-fullscreen-btn"
onClick={toggleTemplatePromptFullscreen}
aria-label="Expand prompt to fullscreen"
aria-label={t("agentPrompts.ariaLabels.expandPrompt", "Expand prompt to fullscreen")}
data-testid="template-prompt-fullscreen"
>
<Maximize2 size={14} />
@@ -492,7 +500,7 @@ export function AgentPromptsManager({
}}
>
<div className="prompt-override-fullscreen-header">
<div className="prompt-override-fullscreen-title">Edit Prompt</div>
<div className="prompt-override-fullscreen-title">{t("agentPrompts.titles.editPrompt", "Edit Prompt")}</div>
<button
type="button"
className="prompt-override-fullscreen-close"
@@ -500,18 +508,18 @@ export function AgentPromptsManager({
data-testid="template-prompt-collapse"
>
<Minimize2 size={14} />
Collapse
{t("agentPrompts.actions.collapse", "Collapse")}
</button>
</div>
<textarea
id="template-prompt-fullscreen"
aria-label="Template prompt - fullscreen"
aria-label={t("agentPrompts.ariaLabels.templatePromptFullscreen", "Template prompt - fullscreen")}
className="prompt-template-prompt-textarea"
value={templateForm.prompt}
onChange={(e) =>
setTemplateForm((f) => ({ ...f, prompt: e.target.value }))
}
placeholder="Enter the system prompt for this template..."
placeholder={t("agentPrompts.placeholders.prompt", "Enter the system prompt for this template...")}
rows={30}
autoFocus
data-testid="template-prompt-input-fullscreen"
@@ -529,7 +537,7 @@ export function AgentPromptsManager({
onChange={(e) =>
setTemplateForm((f) => ({ ...f, prompt: e.target.value }))
}
placeholder="Enter the system prompt for this template..."
placeholder={t("agentPrompts.placeholders.prompt", "Enter the system prompt for this template...")}
rows={8}
className="prompt-template-prompt-textarea"
data-testid="template-prompt-input"
@@ -550,7 +558,7 @@ export function AgentPromptsManager({
onClick={handleCancelEdit}
data-testid="cancel-template-btn"
>
Cancel
{t("agentPrompts.actions.cancel", "Cancel")}
</button>
<button
type="button"
@@ -558,7 +566,7 @@ export function AgentPromptsManager({
onClick={handleSaveTemplate}
data-testid="save-template-btn"
>
{isCreating ? "Create" : "Save"}
{isCreating ? t("agentPrompts.actions.create", "Create") : t("agentPrompts.actions.save", "Save")}
</button>
</div>
</div>
@@ -567,9 +575,9 @@ export function AgentPromptsManager({
{/* Built-in Templates Section */}
<div className="prompt-template-section" data-testid="builtin-templates">
<h4 className="prompt-template-section-title">Built-in Templates</h4>
<h4 className="prompt-template-section-title">{t("agentPrompts.sections.builtinTemplates", "Built-in Templates")}</h4>
<p className="prompt-template-section-desc">
These templates are provided by Fusion and cannot be modified.
{t("agentPrompts.descriptions.builtinTemplates", "These templates are provided by Fusion and cannot be modified.")}
</p>
<div className="prompt-template-list">
{BUILTIN_AGENT_PROMPTS.map((template) => (
@@ -586,12 +594,12 @@ export function AgentPromptsManager({
<span
className={`prompt-template-badge-built-in ${getRoleToneClassName(template.role)}`}
>
Built-in
{t("agentPrompts.badges.builtin", "Built-in")}
</span>
<span
className={`prompt-template-badge-role ${getRoleToneClassName(template.role)}`}
>
{ROLE_LABELS[template.role]}
{getRoleLabel(template.role)}
</span>
</div>
<div className="prompt-template-card-actions">
@@ -599,8 +607,8 @@ export function AgentPromptsManager({
type="button"
className="btn-icon prompt-template-fullscreen-btn"
onClick={() => openTemplateViewFullscreen("builtin", template.id)}
title="View full prompt"
aria-label={`View full prompt for ${template.name}`}
title={t("agentPrompts.titles.viewFullPrompt", "View full prompt")}
aria-label={t("agentPrompts.ariaLabels.viewFullPromptFor", "View full prompt for {{name}}", { name: template.name })}
data-testid={`expand-view-${template.id}`}
>
<Maximize2 size={14} />
@@ -620,14 +628,14 @@ export function AgentPromptsManager({
{/* Custom Templates Section */}
<div className="prompt-template-section" data-testid="custom-templates">
<h4 className="prompt-template-section-title">Custom Templates</h4>
<h4 className="prompt-template-section-title">{t("agentPrompts.sections.customTemplates", "Custom Templates")}</h4>
<p className="prompt-template-section-desc">
Create custom templates to override built-in prompts for specific roles.
{t("agentPrompts.descriptions.customTemplates", "Create custom templates to override built-in prompts for specific roles.")}
</p>
{customTemplates.length === 0 && !isCreating && (
<div className="prompt-template-empty">
No custom templates yet. Create one to get started.
{t("agentPrompts.emptyStates.noCustomTemplates", "No custom templates yet. Create one to get started.")}
</div>
)}
@@ -641,7 +649,7 @@ export function AgentPromptsManager({
>
{deleteConfirmId === template.id ? (
<div className="prompt-template-delete-confirm">
<p>Delete "{template.name}"?</p>
<p>{t("agentPrompts.confirmations.deleteTemplate", "Delete \"{{name}}\"?", { name: template.name })}</p>
<div className="prompt-template-delete-actions">
<button
type="button"
@@ -649,7 +657,7 @@ export function AgentPromptsManager({
onClick={() => handleDeleteTemplate(template.id)}
data-testid={`confirm-delete-${template.id}`}
>
Delete
{t("agentPrompts.actions.delete", "Delete")}
</button>
<button
type="button"
@@ -657,7 +665,7 @@ export function AgentPromptsManager({
onClick={() => setDeleteConfirmId(null)}
data-testid={`cancel-delete-${template.id}`}
>
Cancel
{t("agentPrompts.actions.cancel", "Cancel")}
</button>
</div>
</div>
@@ -669,17 +677,17 @@ export function AgentPromptsManager({
{template.name}
</span>
<span className="prompt-template-badge-custom">
Custom
{t("agentPrompts.badges.custom", "Custom")}
</span>
<span
className={`prompt-template-badge-role ${getRoleToneClassName(template.role)}`}
>
{ROLE_LABELS[template.role]}
{getRoleLabel(template.role)}
</span>
{/* Show override indicator if this custom template overrides a built-in */}
{isBuiltinId(template.id) && (
<span className="prompt-template-badge-override">
Overrides built-in
{t("agentPrompts.badges.overridesBuiltin", "Overrides built-in")}
</span>
)}
</div>
@@ -688,8 +696,8 @@ export function AgentPromptsManager({
type="button"
className="btn-icon prompt-template-fullscreen-btn"
onClick={() => openTemplateViewFullscreen("custom", template.id)}
title="View full prompt"
aria-label={`View full prompt for ${template.name}`}
title={t("agentPrompts.titles.viewFullPrompt", "View full prompt")}
aria-label={t("agentPrompts.ariaLabels.viewFullPromptFor", "View full prompt for {{name}}", { name: template.name })}
data-testid={`expand-view-${template.id}`}
>
<Maximize2 size={14} />
@@ -698,8 +706,8 @@ export function AgentPromptsManager({
type="button"
className="btn-icon"
onClick={() => handleStartEdit(template)}
title="Edit"
aria-label={`Edit ${template.name}`}
title={t("agentPrompts.titles.edit", "Edit")}
aria-label={t("agentPrompts.ariaLabels.editTemplate", "Edit {{name}}", { name: template.name })}
data-testid={`edit-${template.id}`}
>
<Pencil size={14} />
@@ -708,8 +716,8 @@ export function AgentPromptsManager({
type="button"
className="btn-icon"
onClick={() => setDeleteConfirmId(template.id)}
title="Delete"
aria-label={`Delete ${template.name}`}
title={t("agentPrompts.titles.delete", "Delete")}
aria-label={t("agentPrompts.ariaLabels.deleteTemplate", "Delete {{name}}", { name: template.name })}
data-testid={`delete-${template.id}`}
>
<Trash2 size={14} />
@@ -738,7 +746,7 @@ export function AgentPromptsManager({
data-testid="add-template-btn"
>
<Plus size={14} />
Add Custom Template
{t("agentPrompts.actions.addCustomTemplate", "Add Custom Template")}
</button>
)}
</div>
@@ -763,7 +771,7 @@ export function AgentPromptsManager({
<span
className={`prompt-template-badge-role ${getRoleToneClassName(fullscreenTemplate.role)}`}
>
{ROLE_LABELS[fullscreenTemplate.role]}
{getRoleLabel(fullscreenTemplate.role)}
</span>
</div>
<button
@@ -773,7 +781,7 @@ export function AgentPromptsManager({
data-testid={`collapse-view-${fullscreenTemplate.id}`}
>
<Minimize2 size={14} />
Collapse
{t("agentPrompts.actions.collapse", "Collapse")}
</button>
</div>
<pre className="prompt-template-fullscreen-pre">{fullscreenTemplate.prompt}</pre>
@@ -786,8 +794,7 @@ export function AgentPromptsManager({
{activeTab === "assignments" && (
<div className="prompt-manager-assignments-tab" data-testid="assignments-tab">
<p className="prompt-assignments-desc">
Assign specific templates to agent roles. When a role has an assignment, that
template will be used instead of the default built-in.
{t("agentPrompts.descriptions.assignments", "Assign specific templates to agent roles. When a role has an assignment, that template will be used instead of the default built-in.")}
</p>
<div className="prompt-role-assignment-list">
@@ -809,11 +816,11 @@ export function AgentPromptsManager({
<span
className={`prompt-role-badge ${getRoleToneClassName(role)}`}
>
{ROLE_LABELS[role]}
{getRoleLabel(role)}
</span>
{isOverriding && (
<span className="prompt-role-assignment-status">
{selectedTemplate?.name ?? "Custom"} (overrides default)
{selectedTemplate?.name ?? t("agentPrompts.labels.custom", "Custom")} ({t("agentPrompts.labels.overridesDefault", "overrides default")})
</span>
)}
</div>
@@ -825,11 +832,11 @@ export function AgentPromptsManager({
}
data-testid={`select-${role}`}
>
<option value="">Use default</option>
<option value="">{t("agentPrompts.options.useDefault", "Use default")}</option>
{availableTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
{isBuiltinId(template.id) ? " (built-in)" : " (custom)"}
{isBuiltinId(template.id) ? ` (${t("agentPrompts.labels.builtIn", "built-in")})` : ` (${t("agentPrompts.labels.custom", "custom")})`}
</option>
))}
</select>
@@ -840,8 +847,7 @@ export function AgentPromptsManager({
{Object.keys(roleAssignments).length > 0 && (
<div className="prompt-assignments-note">
<strong>Note:</strong> Role assignments are stored in the agentPrompts
configuration. Custom templates override built-ins by ID.
<strong>{t("agentPrompts.labels.note", "Note")}:</strong> {t("agentPrompts.descriptions.assignmentNote", "Role assignments are stored in the agentPrompts configuration. Custom templates override built-ins by ID.")}
</div>
)}
</div>
@@ -851,9 +857,7 @@ export function AgentPromptsManager({
{activeTab === "overrides" && (
<div className="prompt-manager-overrides-tab" data-testid="overrides-tab">
<p className="prompt-overrides-desc">
Customize specific segments of AI agent prompts. Edits override built-in
defaults. Use the Reset button to restore the original default for any
prompt.
{t("agentPrompts.descriptions.overrides", "Customize specific segments of AI agent prompts. Edits override built-in defaults. Use the Reset button to restore the original default for any prompt.")}
</p>
<div className="prompt-overrides-list">
@@ -880,7 +884,7 @@ export function AgentPromptsManager({
</span>
<code className="prompt-override-key">{key}</code>
{hasOverride && (
<span className="prompt-override-badge">customized</span>
<span className="prompt-override-badge">{t("agentPrompts.badges.customized", "customized")}</span>
)}
</button>
<div className="prompt-override-header-actions">
@@ -892,7 +896,7 @@ export function AgentPromptsManager({
e.stopPropagation();
toggleFullscreenOverride(key);
}}
aria-label="Expand to fullscreen"
aria-label={t("agentPrompts.ariaLabels.expandToFullscreen", "Expand to fullscreen")}
data-testid={`fullscreen-${key}`}
>
<Maximize2 size={14} />
@@ -901,7 +905,7 @@ export function AgentPromptsManager({
<button
type="button"
className="prompt-override-expand-btn"
aria-label={isExpanded ? "Collapse" : "Expand"}
aria-label={isExpanded ? t("agentPrompts.ariaLabels.collapse", "Collapse") : t("agentPrompts.ariaLabels.expand", "Expand")}
data-testid={`expand-${key}`}
onClick={(e) => {
e.stopPropagation();
@@ -939,12 +943,12 @@ export function AgentPromptsManager({
data-testid={`collapse-fullscreen-${key}`}
>
<Minimize2 size={14} />
Collapse
{t("agentPrompts.actions.collapse", "Collapse")}
</button>
</div>
<textarea
id={`prompt-${key}-fullscreen`}
aria-label={`${promptMeta.name} prompt override (${key}) - fullscreen`}
aria-label={t("agentPrompts.ariaLabels.promptOverrideFullscreen", "{{name}} prompt override ({{key}}) - fullscreen", { name: promptMeta.name, key })}
className="prompt-override-textarea"
value={currentOverride}
onChange={(e) => {
@@ -963,13 +967,13 @@ export function AgentPromptsManager({
onClick={() => handleResetOverride(key)}
data-testid={`reset-fullscreen-${key}`}
>
Reset
{t("agentPrompts.actions.reset", "Reset")}
</button>
)}
<small className="prompt-override-hint">
{hasOverride
? "Custom override active. Click Reset to restore default."
: `No override set. Using built-in default (${promptMeta.defaultContent.length} chars).`}
? t("agentPrompts.hints.customOverrideActive", "Custom override active. Click Reset to restore default.")
: t("agentPrompts.hints.noOverrideSet", "No override set. Using built-in default ({{chars}} chars).", { chars: promptMeta.defaultContent.length })}
</small>
</div>
</div>
@@ -977,13 +981,13 @@ export function AgentPromptsManager({
<div className="prompt-override-editor">
<textarea
id={`prompt-${key}`}
aria-label={`${promptMeta.name} prompt override (${key})`}
aria-label={t("agentPrompts.ariaLabels.promptOverride", "{{name}} prompt override ({{key}})", { name: promptMeta.name, key })}
className="prompt-override-textarea"
value={currentOverride}
onChange={(e) => {
handlePromptOverrideChange(key, e.target.value);
}}
placeholder={`Default: ${promptMeta.defaultContent.slice(0, 100)}${promptMeta.defaultContent.length > 100 ? "..." : ""}`}
placeholder={t("agentPrompts.placeholders.promptDefault", "Default: {{preview}}", { preview: promptMeta.defaultContent.slice(0, 100) + (promptMeta.defaultContent.length > 100 ? "..." : "") })}
rows={4}
data-testid={`override-input-${key}`}
/>
@@ -998,13 +1002,13 @@ export function AgentPromptsManager({
}}
data-testid={`reset-${key}`}
>
Reset
{t("agentPrompts.actions.reset", "Reset")}
</button>
)}
<small className="prompt-override-hint">
{hasOverride
? "Custom override active. Click Reset to restore default."
: `No override set. Using built-in default (${promptMeta.defaultContent.length} chars).`}
? t("agentPrompts.hints.customOverrideActive", "Custom override active. Click Reset to restore default.")
: t("agentPrompts.hints.noOverrideSet", "No override set. Using built-in default ({{chars}} chars).", { chars: promptMeta.defaultContent.length })}
</small>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import "./AgentProvisioningPolicyEditor.css";
import { Trans, useTranslation } from "react-i18next";
import {
AGENT_PROVISIONING_APPROVAL_MODES,
type AgentProvisioningApprovalMode,
@@ -11,20 +12,7 @@ interface Props {
disabled?: boolean;
}
const MODE_LABELS: Record<AgentProvisioningApprovalMode, { label: string; description: string }> = {
always: {
label: "Always require approval",
description: "All fn_agent_create/fn_agent_delete requests require approval unless caller is trusted.",
},
"trusted-only": {
label: "Trusted-only",
description: "Trusted roles/agent IDs bypass approval; other callers require approval.",
},
never: {
label: "Never require approval",
description: "Allow provisioning without approval for non-privileged callers.",
},
};
// Note: MODE_LABELS moved into component for i18n access
function tokenizeList(value: string): string[] {
return Array.from(
@@ -38,9 +26,25 @@ function tokenizeList(value: string): string[] {
}
export function AgentProvisioningPolicyEditor({ value, onChange, disabled = false }: Props) {
const { t } = useTranslation("app");
const approvalMode = value?.approvalMode ?? "trusted-only";
const alwaysApproveDelete = value?.alwaysApproveDelete ?? true;
const MODE_LABELS: Record<AgentProvisioningApprovalMode, { label: string; description: string }> = {
always: {
label: t("agentProvisioning.always", "Always require approval"),
description: t("agentProvisioning.alwaysDesc", "All fn_agent_create/fn_agent_delete requests require approval unless caller is trusted."),
},
"trusted-only": {
label: t("agentProvisioning.trustedOnly", "Trusted-only"),
description: t("agentProvisioning.trustedOnlyDesc", "Trusted roles/agent IDs bypass approval; other callers require approval."),
},
never: {
label: t("agentProvisioning.never", "Never require approval"),
description: t("agentProvisioning.neverDesc", "Allow provisioning without approval for non-privileged callers."),
},
};
const update = (patch: Partial<NonNullable<ProjectSettings["agentProvisioning"]>>) => {
onChange({ ...(value ?? {}), ...patch });
};
@@ -48,7 +52,7 @@ export function AgentProvisioningPolicyEditor({ value, onChange, disabled = fals
return (
<div className="agent-provisioning-policy-editor card">
<div className="form-group">
<label htmlFor="agent-provisioning-approval-mode">Approval mode</label>
<label htmlFor="agent-provisioning-approval-mode">{t("agentProvisioning.approvalMode", "Approval mode")}</label>
<select
id="agent-provisioning-approval-mode"
className="select"
@@ -74,39 +78,42 @@ export function AgentProvisioningPolicyEditor({ value, onChange, disabled = fals
onChange={(event) => update({ alwaysApproveDelete: event.target.checked })}
disabled={disabled}
/>
Always require approval for fn_agent_delete
{t("agentProvisioning.alwaysApproveDelete", "Always require approval for fn_agent_delete")}
</label>
</div>
<div className="form-group">
<label htmlFor="agent-provisioning-trusted-roles">Trusted roles</label>
<label htmlFor="agent-provisioning-trusted-roles">{t("agentProvisioning.trustedRoles", "Trusted roles")}</label>
<textarea
id="agent-provisioning-trusted-roles"
className="input agent-provisioning-policy-textarea"
value={(value?.trustedRoles ?? []).join(", ")}
onChange={(event) => update({ trustedRoles: tokenizeList(event.target.value) })}
disabled={disabled}
placeholder="reviewer, ceo"
placeholder={t("agentProvisioning.trustedRolesPlaceholder", "reviewer, ceo")}
rows={2}
/>
</div>
<div className="form-group">
<label htmlFor="agent-provisioning-trusted-agent-ids">Trusted agent IDs</label>
<label htmlFor="agent-provisioning-trusted-agent-ids">{t("agentProvisioning.trustedAgentIds", "Trusted agent IDs")}</label>
<textarea
id="agent-provisioning-trusted-agent-ids"
className="input agent-provisioning-policy-textarea"
value={(value?.trustedAgentIds ?? []).join(", ")}
onChange={(event) => update({ trustedAgentIds: tokenizeList(event.target.value) })}
disabled={disabled}
placeholder="agent-abc123"
placeholder={t("agentProvisioning.trustedAgentIdsPlaceholder", "agent-abc123")}
rows={2}
/>
</div>
<p className="agent-provisioning-policy-help">
These settings govern durable provisioning tools only: <code>fn_agent_create</code> and <code>fn_agent_delete</code>.
Ephemeral <code>fn_spawn_agent</code> requests stay under the task/agent mutation approval gate (FN-3973).
<Trans
i18nKey="app:agentProvisioning.helpText"
defaults="These settings govern durable provisioning tools only: <code>fn_agent_create</code> and <code>fn_agent_delete</code>. Ephemeral <code2>fn_spawn_agent</code2> requests stay under the task/agent mutation approval gate (FN-3973)."
components={{ code: <code />, code2: <code /> }}
/>
</p>
</div>
);

View File

@@ -1,5 +1,6 @@
import "./AgentReflectionsTab.css";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart3,
ChevronDown,
@@ -48,36 +49,36 @@ function formatPercent(rate: number): string {
}
/** Format an ISO timestamp to a relative time string */
function relativeTime(iso: string): string {
function relativeTime(iso: string, t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string): string {
const now = Date.now();
const then = new Date(iso).getTime();
const diffMs = now - then;
if (diffMs < 0) {
const absDiff = Math.abs(diffMs);
if (absDiff < 60_000) return "in a moment";
if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`;
if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`;
return `in ${Math.floor(absDiff / 86_400_000)}d`;
if (absDiff < 60_000) return t("agents.time.inAMoment", "in a moment");
if (absDiff < 3_600_000) return t("agents.time.inMinutes", "in {{count}}m", { count: Math.floor(absDiff / 60_000) });
if (absDiff < 86_400_000) return t("agents.time.inHours", "in {{count}}h", { count: Math.floor(absDiff / 3_600_000) });
return t("agents.time.inDays", "in {{count}}d", { count: Math.floor(absDiff / 86_400_000) });
}
if (diffMs < 60_000) return "just now";
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
return `${Math.floor(diffMs / 86_400_000)}d ago`;
if (diffMs < 60_000) return t("agents.time.justNow", "just now");
if (diffMs < 3_600_000) return t("agents.time.minutesAgo", "{{count}}m ago", { count: Math.floor(diffMs / 60_000) });
if (diffMs < 86_400_000) return t("agents.time.hoursAgo", "{{count}}h ago", { count: Math.floor(diffMs / 3_600_000) });
return t("agents.time.daysAgo", "{{count}}d ago", { count: Math.floor(diffMs / 86_400_000) });
}
/** Get display label for a trigger type */
function getTriggerLabel(trigger: string): string {
function getTriggerLabel(trigger: string, t: (key: string, defaultValue: string) => string): string {
switch (trigger) {
case "periodic":
return "Periodic";
return t("agents.reflections.triggerPeriodic", "Periodic");
case "post-task":
return "Post-Task";
return t("agents.reflections.triggerPostTask", "Post-Task");
case "manual":
return "Manual";
return t("agents.reflections.triggerManual", "Manual");
case "user-requested":
return "User Requested";
return t("agents.reflections.triggerUserRequested", "User Requested");
default:
return trigger;
}
@@ -90,16 +91,16 @@ function getErrorMessage(err: unknown): string {
return String(err);
}
function getTrendLabel(trend: string): string {
function getTrendLabel(trend: string, t: (key: string, defaultValue: string) => string): string {
switch (trend) {
case "improving":
return "↑ Improving";
return t("agents.ratings.trendImproving", "↑ Improving");
case "declining":
return "↓ Declining";
return t("agents.ratings.trendDeclining", "↓ Declining");
case "stable":
return "→ Stable";
return t("agents.ratings.trendStable", "→ Stable");
default:
return "Insufficient data";
return t("agents.ratings.trendInsufficient", "Insufficient data");
}
}
@@ -132,6 +133,7 @@ function renderStars(score: number, maxScore: number = 5) {
}
export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentReflectionsTabProps) {
const { t } = useTranslation("app");
const [reflections, setReflections] = useState<AgentReflection[]>([]);
const [performance, setPerformance] = useState<AgentPerformanceSummary | null>(null);
const [ratingSummary, setRatingSummary] = useState<AgentRatingSummary | null>(null);
@@ -154,7 +156,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
setReflections(reflectionsData);
setPerformance(performanceData);
} catch (err) {
addToast(`Failed to load reflections: ${getErrorMessage(err)}`, "error");
addToast(t("agents.reflections.loadError", "Failed to load reflections: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
setIsLoadingReflections(false);
}
@@ -169,7 +171,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
setRatingSummary(summaryData);
setRatings(ratingsData);
} catch (err) {
addToast(`Failed to load ratings: ${getErrorMessage(err)}`, "error");
addToast(t("agents.ratings.loadError", "Failed to load ratings: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
setIsLoadingRatings(false);
}
@@ -185,11 +187,11 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
try {
const reflection = await triggerAgentReflection(agentId, projectId);
if (!reflection) {
addToast("Not enough history to generate a reflection yet", "error");
addToast(t("agents.reflections.insufficientHistory", "Not enough history to generate a reflection yet"), "error");
return;
}
addToast("Reflection generated successfully", "success");
addToast(t("agents.reflections.generateSuccess", "Reflection generated successfully"), "success");
setIsLoadingReflections(true);
await loadReflectionData();
} catch (err: unknown) {
@@ -197,11 +199,11 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
const normalizedMessage = message.toLowerCase();
if (normalizedMessage.includes("agent not found") || normalizedMessage.includes("not found")) {
addToast("This agent is no longer available. It may have been deleted.", "error");
addToast(t("agents.reflections.agentNotFound", "This agent is no longer available. It may have been deleted."), "error");
} else if (normalizedMessage.includes("insufficient history")) {
addToast("Not enough history to generate a reflection yet", "error");
addToast(t("agents.reflections.insufficientHistory", "Not enough history to generate a reflection yet"), "error");
} else {
addToast(`Failed to generate reflection: ${message}`, "error");
addToast(t("agents.reflections.generateError", "Failed to generate reflection: {{error}}", { error: message }), "error");
}
} finally {
setIsReflecting(false);
@@ -224,10 +226,10 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
setNewScore(0);
setNewCategory("");
setNewComment("");
addToast("Rating added", "success");
addToast(t("agents.ratings.addSuccess", "Rating added"), "success");
await loadRatingsData();
} catch (err) {
addToast(`Failed to add rating: ${getErrorMessage(err)}`, "error");
addToast(t("agents.ratings.addError", "Failed to add rating: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
setIsSubmittingRating(false);
}
@@ -236,10 +238,10 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
const handleDeleteRating = async (ratingId: string) => {
try {
await deleteAgentRating(agentId, ratingId, projectId);
addToast("Rating deleted", "success");
addToast(t("agents.ratings.deleteSuccess", "Rating deleted"), "success");
await loadRatingsData();
} catch (err) {
addToast(`Failed to delete rating: ${getErrorMessage(err)}`, "error");
addToast(t("agents.ratings.deleteError", "Failed to delete rating: {{error}}", { error: getErrorMessage(err) }), "error");
}
};
@@ -252,7 +254,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
<div className="reflections-tab">
<div className="reflections-loading-indicator">
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading evaluation...</span>
<span className="text-muted">{t("agents.reflections.loadingEvaluation", "Loading evaluation...")}</span>
</div>
</div>
);
@@ -269,23 +271,23 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
<div className="reflections-header">
<h3>
<BarChart3 size={16} />
Performance, Reflections & Ratings
{t("agents.reflections.sectionTitle", "Performance, Reflections & Ratings")}
</h3>
<button
className="btn btn-secondary"
onClick={handleReflectNow}
disabled={isReflecting}
title="Generate a manual reflection"
title={t("agents.reflections.reflectNowTitle", "Generate a manual reflection")}
>
{isReflecting ? (
<>
<Loader2 size={14} className="animate-spin" />
Reflecting...
{t("agents.reflections.reflecting", "Reflecting...")}
</>
) : (
<>
<RefreshCw size={14} />
Reflect Now
{t("agents.reflections.reflectNow", "Reflect Now")}
</>
)}
</button>
@@ -298,7 +300,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
<TrendingUp size={16} style={{ color: "var(--color-success)" }} />
{performance.totalTasksCompleted}
</div>
<div className="stat-label">Tasks Completed</div>
<div className="stat-label">{t("agents.performance.tasksCompleted", "Tasks Completed")}</div>
</div>
<div className="reflections-stat-card">
@@ -306,7 +308,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
<TrendingDown size={16} style={{ color: "var(--color-error)" }} />
{performance.totalTasksFailed}
</div>
<div className="stat-label">Tasks Failed</div>
<div className="stat-label">{t("agents.performance.tasksFailed", "Tasks Failed")}</div>
</div>
<div className="reflections-stat-card">
@@ -314,7 +316,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
<Zap size={16} style={{ color: "var(--in-progress)" }} />
{formatDuration(performance.avgDurationMs)}
</div>
<div className="stat-label">Avg Duration</div>
<div className="stat-label">{t("agents.performance.avgDuration", "Avg Duration")}</div>
</div>
<div className="reflections-stat-card">
@@ -332,7 +334,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
/>
{formatPercent(performance.successRate)}
</div>
<div className="stat-label">Success Rate</div>
<div className="stat-label">{t("agents.performance.successRate", "Success Rate")}</div>
</div>
<div className="reflections-stat-card">
@@ -340,7 +342,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
<Lightbulb size={16} style={{ color: "var(--color-info)" }} />
{performance.recentReflectionCount}
</div>
<div className="stat-label">Reflections</div>
<div className="stat-label">{t("agents.performance.reflections", "Reflections")}</div>
</div>
</div>
)}
@@ -348,17 +350,17 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
{hasNoPerformanceData && (
<div className="reflections-no-data">
<BarChart3 size={24} opacity={0.3} />
<p>No performance data yet</p>
<p>{t("agents.performance.noData", "No performance data yet")}</p>
</div>
)}
<div className="reflections-ratings-section">
<h4>User Ratings</h4>
<h4>{t("agents.ratings.title", "User Ratings")}</h4>
{isLoadingRatings ? (
<div className="reflections-loading-indicator">
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading ratings...</span>
<span className="text-muted">{t("agents.ratings.loading", "Loading ratings...")}</span>
</div>
) : (
<>
@@ -369,9 +371,9 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
{renderStars(Math.round(ratingSummary.averageScore))}
</div>
<div className="rating-stats">
<span className="rating-count">{ratingSummary.totalRatings} ratings</span>
<span className="rating-count">{t("agents.ratings.count", "{{count}} ratings", { count: ratingSummary.totalRatings })}</span>
<span className={`rating-trend-badge ${getTrendClass(ratingSummary.trend)}`}>
{getTrendLabel(ratingSummary.trend)}
{getTrendLabel(ratingSummary.trend, t)}
</span>
</div>
</div>
@@ -379,7 +381,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
{ratingSummary && Object.keys(ratingSummary.categoryAverages).length > 0 && (
<div className="category-breakdown">
<h4>Category Averages</h4>
<h4>{t("agents.ratings.categoryAverages", "Category Averages")}</h4>
{Object.entries(ratingSummary.categoryAverages as Record<string, number>).map(([category, avg]) => (
<div key={category} className="category-item">
<span className="category-name">{category}</span>
@@ -390,7 +392,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
)}
<form className="add-rating-form" onSubmit={handleSubmitRating}>
<h4>Add Rating</h4>
<h4>{t("agents.ratings.addRating", "Add Rating")}</h4>
<div className="star-selector">
{[1, 2, 3, 4, 5].map((score) => (
<button
@@ -398,7 +400,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
type="button"
className="star-btn touch-target"
onClick={() => setNewScore(score)}
title={`${score} star${score > 1 ? "s" : ""}`}
title={t("agents.ratings.starCount", "{{count}} star", { count: score, defaultValue_one: "{{count}} star", defaultValue_other: "{{count}} stars" })}
>
<Star
size={24}
@@ -413,17 +415,17 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
onChange={(e) => setNewCategory(e.target.value)}
className="select add-rating-category-select"
>
<option value="">Select category...</option>
<option value="quality">Quality</option>
<option value="speed">Speed</option>
<option value="communication">Communication</option>
<option value="reliability">Reliability</option>
<option value="other">Other</option>
<option value="">{t("agents.ratings.categorySelect", "Select category...")}</option>
<option value="quality">{t("agents.ratings.categoryQuality", "Quality")}</option>
<option value="speed">{t("agents.ratings.categorySpeed", "Speed")}</option>
<option value="communication">{t("agents.ratings.categoryCommunication", "Communication")}</option>
<option value="reliability">{t("agents.ratings.categoryReliability", "Reliability")}</option>
<option value="other">{t("agents.ratings.categoryOther", "Other")}</option>
</select>
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Optional comment..."
placeholder={t("agents.ratings.commentPlaceholder", "Optional comment...")}
className="input add-rating-comment-input"
rows={3}
/>
@@ -432,14 +434,14 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
className="btn btn-task-create"
disabled={newScore === 0 || isSubmittingRating}
>
{isSubmittingRating ? "Submitting..." : "Submit Rating"}
{isSubmittingRating ? t("agents.ratings.submitting", "Submitting...") : t("agents.ratings.submitRating", "Submit Rating")}
</button>
</form>
<div className="rating-history">
<h4>Rating History</h4>
<h4>{t("agents.ratings.historyTitle", "Rating History")}</h4>
{ratings.length === 0 ? (
<p className="no-ratings">No ratings yet</p>
<p className="no-ratings">{t("agents.ratings.noRatings", "No ratings yet")}</p>
) : (
ratings.map((rating) => (
<div key={rating.id} className="rating-history-item">
@@ -448,12 +450,12 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
{rating.category && (
<span className="rating-category-badge">{rating.category}</span>
)}
<span className="rating-time">{relativeTime(rating.createdAt)}</span>
<span className="rating-time">{relativeTime(rating.createdAt, t)}</span>
<button
type="button"
className="rating-delete-btn touch-target"
onClick={() => void handleDeleteRating(rating.id)}
title="Delete rating"
title={t("agents.ratings.deleteRating", "Delete rating")}
>
<Trash2 size={14} />
</button>
@@ -470,18 +472,18 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
</div>
<div className="reflections-list">
<h4>Reflection History</h4>
<h4>{t("agents.reflections.historyTitle", "Reflection History")}</h4>
{isLoadingReflections ? (
<div className="reflections-loading-indicator">
<Loader2 size={16} className="animate-spin" />
<span className="text-muted">Loading reflections...</span>
<span className="text-muted">{t("agents.reflections.loading", "Loading reflections...")}</span>
</div>
) : reflections.length === 0 ? (
<div className="reflection-empty">
<Lightbulb size={32} opacity={0.3} />
<p>No reflections yet</p>
<p className="text-secondary">Trigger a reflection to get started</p>
<p>{t("agents.reflections.noReflections", "No reflections yet")}</p>
<p className="text-secondary">{t("agents.reflections.getStarted", "Trigger a reflection to get started")}</p>
</div>
) : (
<div className="reflection-cards">
@@ -498,9 +500,9 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
>
<div className="reflection-card-header">
<span className={`reflection-trigger-badge reflection-trigger-${reflection.trigger}`}>
{getTriggerLabel(reflection.trigger)}
{getTriggerLabel(reflection.trigger, t)}
</span>
<span className="reflection-timestamp">{relativeTime(reflection.timestamp)}</span>
<span className="reflection-timestamp">{relativeTime(reflection.timestamp, t)}</span>
<span className="reflection-chevron">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
@@ -513,7 +515,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
{reflection.insights.length > 0 && (
<div className="reflection-insights">
<h5>
<Lightbulb size={14} /> Insights
<Lightbulb size={14} /> {t("agents.reflections.insights", "Insights")}
</h5>
<ul>
{reflection.insights.map((insight, i) => (
@@ -526,7 +528,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
{reflection.suggestedImprovements.length > 0 && (
<div className="reflection-suggestions">
<h5>
<TrendingUp size={14} /> Suggested Improvements
<TrendingUp size={14} /> {t("agents.reflections.suggestedImprovements", "Suggested Improvements")}
</h5>
<ul>
{reflection.suggestedImprovements.map((suggestion, i) => (
@@ -538,29 +540,29 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
{reflection.metrics && (
<div className="reflection-metrics">
<h5>Metrics</h5>
<h5>{t("agents.reflections.metrics", "Metrics")}</h5>
<div className="metrics-grid">
{reflection.metrics.tasksCompleted !== undefined && (
<div className="metric">
<span className="metric-label">Tasks:</span>
<span className="metric-label">{t("agents.reflections.metricTasks", "Tasks:")}</span>
<span className="metric-value">{reflection.metrics.tasksCompleted}</span>
</div>
)}
{reflection.metrics.tasksFailed !== undefined && (
<div className="metric">
<span className="metric-label">Failed:</span>
<span className="metric-label">{t("agents.reflections.metricFailed", "Failed:")}</span>
<span className="metric-value">{reflection.metrics.tasksFailed}</span>
</div>
)}
{reflection.metrics.avgDurationMs !== undefined && (
<div className="metric">
<span className="metric-label">Avg Duration:</span>
<span className="metric-label">{t("agents.reflections.metricAvgDuration", "Avg Duration:")}</span>
<span className="metric-value">{formatDuration(reflection.metrics.avgDurationMs)}</span>
</div>
)}
{reflection.metrics.errorCount !== undefined && (
<div className="metric">
<span className="metric-label">Errors:</span>
<span className="metric-label">{t("agents.reflections.metricErrors", "Errors:")}</span>
<span className="metric-value">{reflection.metrics.errorCount}</span>
</div>
)}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle, XCircle, Loader2, Square, Clock } from "lucide-react";
import type { AgentHeartbeatRun } from "../api";
import { fetchAgentRuns, stopAgentRun } from "../api";
@@ -19,6 +20,7 @@ const STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }>
};
export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHistoryProps) {
const { t } = useTranslation("app");
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
const { confirm } = useConfirm();
const [isLoading, setIsLoading] = useState(true);
@@ -41,8 +43,8 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist
const handleStop = useCallback(async () => {
const shouldStop = await confirm({
title: "Stop Run",
message: "Stop this run?",
title: t("agents.runs.stopTitle", "Stop Run"),
message: t("agents.runs.stopMessage", "Stop this run?"),
danger: true,
});
if (!shouldStop) {
@@ -58,11 +60,11 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist
}, [agentId, projectId, loadRuns, confirm]);
if (isLoading) {
return <div className="agent-run-loading"><Loader2 className="animate-spin" size={20} /> Loading runs...</div>;
return <div className="agent-run-loading"><Loader2 className="animate-spin" size={20} /> {t("agents.runs.loading", "Loading runs...")}</div>;
}
if (runs.length === 0) {
return <div className="agent-run-empty">No runs yet</div>;
return <div className="agent-run-empty">{t("agents.runs.empty", "No runs yet")}</div>;
}
return (
@@ -116,10 +118,10 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist
e.stopPropagation();
void handleStop();
}}
aria-label="Stop run"
aria-label={t("agents.runs.stopAriaLabel", "Stop run")}
style={{ marginLeft: "8px" }}
>
<Square size={12} /> Stop
<Square size={12} /> {t("agents.runs.stop", "Stop")}
</button>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Agent } from "../api";
import "./AgentTokenStatsPanel.css";
@@ -23,6 +24,7 @@ function formatTokenCount(value: number): string {
}
export function AgentTokenStatsPanel({ agents }: AgentTokenStatsPanelProps) {
const { t } = useTranslation("app");
const { rows, totalInputTokens, totalOutputTokens, totalTokens } = useMemo(() => {
const computedRows = agents
.map((agent): AgentTokenRow => {
@@ -49,22 +51,22 @@ export function AgentTokenStatsPanel({ agents }: AgentTokenStatsPanelProps) {
const hasUsageData = totalTokens > 0;
return (
<section className="agent-token-stats-panel" aria-label="Agent token usage statistics">
<section className="agent-token-stats-panel" aria-label={t("agents.tokenStatistics", "Agent token usage statistics")}>
<header className="agent-token-stats-panel__header">
<h3 className="agent-token-stats-panel__title">Token Usage by Agent</h3>
<h3 className="agent-token-stats-panel__title">{t("agents.tokenUsageByAgent", "Token Usage by Agent")}</h3>
</header>
<div className="agent-token-stats-panel__totals" role="list" aria-label="Token usage totals">
<div className="agent-token-stats-panel__totals" role="list" aria-label={t("agents.tokenUsageTotals", "Token usage totals")}>
<div className="agent-token-stats-panel__total-card" role="listitem">
<span className="agent-token-stats-panel__total-label">Input Tokens</span>
<span className="agent-token-stats-panel__total-label">{t("agents.inputTokens", "Input Tokens")}</span>
<span className="agent-token-stats-panel__total-value">{formatTokenCount(totalInputTokens)}</span>
</div>
<div className="agent-token-stats-panel__total-card" role="listitem">
<span className="agent-token-stats-panel__total-label">Output Tokens</span>
<span className="agent-token-stats-panel__total-label">{t("agents.outputTokens", "Output Tokens")}</span>
<span className="agent-token-stats-panel__total-value">{formatTokenCount(totalOutputTokens)}</span>
</div>
<div className="agent-token-stats-panel__total-card" role="listitem">
<span className="agent-token-stats-panel__total-label">Combined Tokens</span>
<span className="agent-token-stats-panel__total-label">{t("agents.combinedTokens", "Combined Tokens")}</span>
<span className="agent-token-stats-panel__total-value">{formatTokenCount(totalTokens)}</span>
</div>
</div>
@@ -74,10 +76,10 @@ export function AgentTokenStatsPanel({ agents }: AgentTokenStatsPanelProps) {
<table className="agent-token-stats-panel__table">
<thead>
<tr>
<th scope="col">Agent</th>
<th scope="col">Input</th>
<th scope="col">Output</th>
<th scope="col">Total</th>
<th scope="col">{t("agents.agent", "Agent")}</th>
<th scope="col">{t("agents.input", "Input")}</th>
<th scope="col">{t("agents.output", "Output")}</th>
<th scope="col">{t("agents.total", "Total")}</th>
</tr>
</thead>
<tbody>
@@ -97,7 +99,7 @@ export function AgentTokenStatsPanel({ agents }: AgentTokenStatsPanelProps) {
</div>
) : (
<div className="agent-token-stats-panel__empty" role="status">
No token usage recorded yet. Token totals appear here once agents run.
{t("agents.noTokenUsageYet", "No token usage recorded yet. Token totals appear here once agents run.")}
</div>
)}
</section>

View File

@@ -1,4 +1,5 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AgentMetricsBar } from "./AgentMetricsBar";
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
import type { Agent, AgentStats } from "../api";
@@ -23,6 +24,10 @@ export function AgentsOverviewBar({
onSelectAgent,
onOpenTaskLogs,
}: AgentsOverviewBarProps) {
const { t } = useTranslation("app");
const activeCount = activeAgents.filter((a) => a.state === "active").length;
const runningCount = activeAgents.filter((a) => a.state === "running").length;
return (
<section className="agents-overview-bar" aria-label="Agents overview">
<button
@@ -33,10 +38,10 @@ export function AgentsOverviewBar({
>
<span className="agents-overview-bar__title-wrap">
{isOpen ? <ChevronDown size={16} aria-hidden="true" /> : <ChevronRight size={16} aria-hidden="true" />}
<span className="agents-overview-bar__title">Overview</span>
<span className="agents-overview-bar__title">{t("agents.overview", "Overview")}</span>
</span>
<span className="agents-overview-bar__meta text-secondary">
{activeAgents.filter((a) => a.state === "active").length} active · {activeAgents.filter((a) => a.state === "running").length} running
{t("agents.statusCount", "{{activeCount}} active · {{runningCount}} running", { activeCount, runningCount })}
</span>
</button>
{isOpen ? (

View File

@@ -1,4 +1,6 @@
import "./AgentsView.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useState, useEffect, useCallback, useRef, useMemo, useId, useLayoutEffect, lazy, Suspense, type CSSProperties, type ReactNode, type MutableRefObject, type RefObject, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2, Move, Info } from "lucide-react";
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
@@ -42,15 +44,17 @@ export interface AgentsViewProps {
agentOnboardingEnabled?: boolean;
}
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
{ value: "triage", label: "Triage", icon: "⊕" },
{ value: "executor", label: "Executor", icon: "▶" },
{ value: "reviewer", label: "Reviewer", icon: "⊙" },
{ value: "merger", label: "Merger", icon: "⊞" },
{ value: "scheduler", label: "Scheduler", icon: "◷" },
{ value: "engineer", label: "Engineer", icon: "⎔" },
{ value: "custom", label: "Custom", icon: "✦" },
];
function getAgentRoles(t: TFunction<"app">): { value: AgentCapability; label: string; icon: string }[] {
return [
{ value: "triage", label: t("agents.roleTriage", "Triage"), icon: "⊕" },
{ value: "executor", label: t("agents.roleExecutor", "Executor"), icon: "▶" },
{ value: "reviewer", label: t("agents.roleReviewer", "Reviewer"), icon: "⊙" },
{ value: "merger", label: t("agents.roleMerger", "Merger"), icon: "⊞" },
{ value: "scheduler", label: t("agents.roleScheduler", "Scheduler"), icon: "◷" },
{ value: "engineer", label: t("agents.roleEngineer", "Engineer"), icon: "⎔" },
{ value: "custom", label: t("agents.roleCustom", "Custom"), icon: "✦" },
];
}
const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const;
@@ -103,9 +107,9 @@ function getOrgChartLeafCount(node: OrgTreeNode): number {
return node.children.reduce((sum, child) => sum + getOrgChartLeafCount(child), 0);
}
function getHealthSummary(agent: Agent, health: AgentHealthStatus): { title: string | undefined; label: string | null } {
function getHealthSummary(agent: Agent, health: AgentHealthStatus, t: TFunction<"app">): { title: string | undefined; label: string | null } {
if (agent.state === "error") {
return { title: undefined, label: "Error" };
return { title: undefined, label: t("agents.healthError", "Error") };
}
return {
@@ -127,9 +131,10 @@ type OrgChartNodeProps = {
};
function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, registerNodeElement, linksRef }: OrgChartNodeProps) {
const { t } = useTranslation("app");
const { agent, children } = node;
const health = getHealthStatus(agent);
const healthSummary = getHealthSummary(agent, health);
const healthSummary = getHealthSummary(agent, health, t);
const stateBadgeClass = getStateBadgeClass(agent.state);
const stateNodeClass = getStateCardClass("org-chart-node-card", agent.state);
const subtreeLeafCount = getOrgChartLeafCount(node);
@@ -166,7 +171,7 @@ function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, regist
</div>
</div>
{children.length > 0 && (
<div className="org-chart-children" role="group" aria-label={`${agent.name} employees`}>
<div className="org-chart-children" role="group" aria-label={t("agents.orgChartEmployees", "{{name}} employees", { name: agent.name })}>
{children.map((child) => {
linksRef.current.push({ parentId: agent.id, childId: child.agent.id });
return (
@@ -262,6 +267,8 @@ function OrgChartConnectors({
}
export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardingEnabled = false }: AgentsViewProps) {
const { t } = useTranslation("app");
const agentRoles = getAgentRoles(t);
const [showSystemAgents, setShowSystemAgents] = useState(false);
const viewportMode = useViewportMode();
const isMobileViewport = viewportMode === "mobile";
@@ -379,9 +386,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
setIsSavingMultiplier(true);
try {
await updateSettings({ heartbeatMultiplier: clampedValue }, projectId);
addToast(`Heartbeat speed set to ×${clampedValue.toFixed(1)}`, "success");
addToast(t("agents.heartbeatSpeedSet", "Heartbeat speed set to ×{{value}}", { value: clampedValue.toFixed(1) }), "success");
} catch (err) {
addToast(`Failed to save heartbeat multiplier: ${getErrorMessage(err)}`, "error");
addToast(t("agents.heartbeatSpeedSaveFailed", "Failed to save heartbeat multiplier: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
if (isMountedRef.current) {
setIsSavingMultiplier(false);
@@ -472,7 +479,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
})
.catch((err) => {
if (!cancelled) {
addToast(`Failed to load org chart: ${getErrorMessage(err)}`, "error");
addToast(t("agents.orgChartLoadFailed", "Failed to load org chart: {{error}}", { error: getErrorMessage(err) }), "error");
setOrgTree([]);
}
})
@@ -518,7 +525,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
if (cancelled) return;
setBulkPauseEligibleCount(0);
setBulkResumeEligibleCount(0);
addToast(`Failed to load bulk agent actions: ${getErrorMessage(err)}`, "error");
addToast(t("agents.bulkActionsLoadFailed", "Failed to load bulk agent actions: {{error}}", { error: getErrorMessage(err) }), "error");
})
.finally(() => {
if (!cancelled) {
@@ -567,13 +574,20 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const skippedCount = nonEphemeralAgents.length - eligibleAgents.length;
if (eligibleAgents.length === 0) {
addToast(`No agents eligible to ${targetState === "paused" ? "pause" : "resume"}`, "error");
addToast(
targetState === "paused"
? t("agents.noAgentsToPause", "No agents eligible to pause")
: t("agents.noAgentsToResume", "No agents eligible to resume"),
"error",
);
return;
}
const confirmed = await confirm({
title: targetState === "paused" ? "Pause All Agents" : "Resume All Agents",
message: `${targetState === "paused" ? "Pause" : "Resume"} ${eligibleAgents.length} agent${eligibleAgents.length === 1 ? "" : "s"} in this project?`,
title: targetState === "paused" ? t("agents.pauseAllTitle", "Pause All Agents") : t("agents.resumeAllTitle", "Resume All Agents"),
message: targetState === "paused"
? t("agents.pauseAllConfirm", { count: eligibleAgents.length, defaultValue_one: "Pause {{count}} agent in this project?", defaultValue_other: "Pause {{count}} agents in this project?" })
: t("agents.resumeAllConfirm", { count: eligibleAgents.length, defaultValue_one: "Resume {{count}} agent in this project?", defaultValue_other: "Resume {{count}} agents in this project?" }),
danger: targetState === "paused",
});
if (!confirmed) return;
@@ -586,7 +600,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
.filter((entry): entry is { result: PromiseRejectedResult; agent: Agent } => entry.result.status === "rejected");
const successCount = results.length - failedResults.length;
const failureCount = failedResults.length;
const baseSummary = `${targetState === "paused" ? "Paused" : "Resumed"} ${successCount} agent${successCount === 1 ? "" : "s"}; skipped ${skippedCount}`;
const baseSummary = targetState === "paused"
? t("agents.pausedSummary", { count: successCount, skipped: skippedCount, defaultValue_one: "Paused {{count}} agent; skipped {{skipped}}", defaultValue_other: "Paused {{count}} agents; skipped {{skipped}}" })
: t("agents.resumedSummary", { count: successCount, skipped: skippedCount, defaultValue_one: "Resumed {{count}} agent; skipped {{skipped}}", defaultValue_other: "Resumed {{count}} agents; skipped {{skipped}}" });
if (failureCount > 0) {
const failureSummary = failedResults
@@ -600,7 +616,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
await loadAgents();
} catch (err) {
addToast(`Failed to ${targetState === "paused" ? "pause" : "resume"} agents: ${getErrorMessage(err)}`, "error");
addToast(
targetState === "paused"
? t("agents.pauseAgentsFailed", "Failed to pause agents: {{error}}", { error: getErrorMessage(err) })
: t("agents.resumeAgentsFailed", "Failed to resume agents: {{error}}", { error: getErrorMessage(err) }),
"error",
);
} finally {
setIsBulkActionRunning(false);
}
@@ -618,7 +639,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
try {
await updateAgentState(agentId, newState, projectId);
addToast(`Agent state updated to ${newState}`, "success");
addToast(t("agents.stateUpdated", "Agent state updated to {{state}}", { state: newState }), "success");
await loadAgents();
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
@@ -631,7 +652,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
next.delete(agentId);
return next;
});
addToast(`Failed to update state: ${getErrorMessage(err)}`, "error");
addToast(t("agents.stateUpdateFailed", "Failed to update state: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
setTransitioningAgentIds((prev) => {
const next = new Set(prev);
@@ -643,17 +664,17 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const handleDelete = async (agentId: string, agentName: string) => {
const shouldDelete = await confirm({
title: "Delete Agent",
message: `Delete agent "${agentName}"? This cannot be undone.`,
title: t("agents.deleteTitle", "Delete Agent"),
message: t("agents.deleteConfirm", "Delete agent \"{{name}}\"? This cannot be undone.", { name: agentName }),
danger: true,
});
if (!shouldDelete) return;
try {
await deleteAgent(agentId, projectId);
addToast(`Agent "${agentName}" deleted`, "success");
addToast(t("agents.deleted", "Agent \"{{name}}\" deleted", { name: agentName }), "success");
await loadAgents();
} catch (err) {
addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error");
addToast(t("agents.deleteFailed", "Failed to delete agent: {{error}}", { error: getErrorMessage(err) }), "error");
}
};
@@ -669,11 +690,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
try {
await updateAgent(agentId, { role: newRole }, projectId);
addToast(`Agent role updated to ${AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole}`, "success");
addToast(t("agents.roleUpdated", "Agent role updated to {{role}}", { role: agentRoles.find(r => r.value === newRole)?.label ?? newRole }), "success");
setEditingRoleForAgent(null);
void loadAgents();
} catch (err) {
addToast(`Failed to update role: ${getErrorMessage(err)}`, "error");
addToast(t("agents.roleUpdateFailed", "Failed to update role: {{error}}", { error: getErrorMessage(err) }), "error");
}
};
@@ -706,10 +727,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
},
projectId,
);
addToast(`Heartbeat interval updated to ${formatHeartbeatInterval(newIntervalMs)} for ${agent.name}`, "success");
addToast(t("agents.heartbeatIntervalUpdated", "Heartbeat interval updated to {{interval}} for {{name}}", { interval: formatHeartbeatInterval(newIntervalMs), name: agent.name }), "success");
void loadAgents();
} catch (err) {
addToast(`Failed to update heartbeat interval: ${getErrorMessage(err)}`, "error");
addToast(t("agents.heartbeatIntervalUpdateFailed", "Failed to update heartbeat interval: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
setUpdatingHeartbeatAgentId(null);
}
@@ -729,20 +750,20 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
// Validate: empty value
if (inputValue.trim() === "") {
addToast("Please enter a heartbeat interval in minutes", "error");
addToast(t("agents.heartbeatEnterMinutes", "Please enter a heartbeat interval in minutes"), "error");
return;
}
// Validate: non-numeric value
const minutes = Number(inputValue);
if (isNaN(minutes)) {
addToast("Heartbeat interval must be a valid number", "error");
addToast(t("agents.heartbeatMustBeNumber", "Heartbeat interval must be a valid number"), "error");
return;
}
// Validate: zero or negative
if (minutes <= 0) {
addToast("Heartbeat interval must be greater than 0", "error");
addToast(t("agents.heartbeatMustBePositive", "Heartbeat interval must be greater than 0"), "error");
return;
}
@@ -760,7 +781,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
},
projectId,
);
addToast(`Heartbeat interval set to 5 minutes (minimum). ${minutes} minute${minutes !== 1 ? "s" : ""} was below the 5-minute minimum.`, "success");
addToast(t("agents.heartbeatClampedToMin", { count: minutes, defaultValue_one: "Heartbeat interval set to 5 minutes (minimum). {{count}} minute was below the 5-minute minimum.", defaultValue_other: "Heartbeat interval set to 5 minutes (minimum). {{count}} minutes was below the 5-minute minimum." }), "success");
setCustomHeartbeatAgentId(null);
setCustomHeartbeatMinutes((prev) => {
const next = { ...prev };
@@ -769,7 +790,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
});
void loadAgents();
} catch (err) {
addToast(`Failed to update heartbeat interval: ${getErrorMessage(err)}`, "error");
addToast(t("agents.heartbeatIntervalUpdateFailed", "Failed to update heartbeat interval: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
setUpdatingHeartbeatAgentId(null);
}
@@ -790,7 +811,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
},
projectId,
);
addToast(`Heartbeat interval updated to ${formatHeartbeatInterval(intervalMs)} for ${agent.name}`, "success");
addToast(t("agents.heartbeatIntervalUpdated", "Heartbeat interval updated to {{interval}} for {{name}}", { interval: formatHeartbeatInterval(intervalMs), name: agent.name }), "success");
setCustomHeartbeatAgentId(null);
setCustomHeartbeatMinutes((prev) => {
const next = { ...prev };
@@ -799,7 +820,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
});
void loadAgents();
} catch (err) {
addToast(`Failed to update heartbeat interval: ${getErrorMessage(err)}`, "error");
addToast(t("agents.heartbeatIntervalUpdateFailed", "Failed to update heartbeat interval: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
setUpdatingHeartbeatAgentId(null);
}
@@ -867,7 +888,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
});
try {
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
addToast(`Heartbeat run started for ${agentName}`, "success");
addToast(t("agents.heartbeatRunStarted", "Heartbeat run started for {{name}}", { name: agentName }), "success");
await loadAgents();
setOptimisticStateOverrides((prev) => {
const next = new Map(prev);
@@ -880,7 +901,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
next.delete(agentId);
return next;
});
addToast(`Failed to start heartbeat run: ${getErrorMessage(err)}`, "error");
addToast(t("agents.heartbeatRunFailed", "Failed to start heartbeat run: {{error}}", { error: getErrorMessage(err) }), "error");
}
};
@@ -949,7 +970,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
}
}, [handleCloseDetail, isMobileViewport, selectedAgentId]);
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
const getRoleLabel = (role: AgentCapability) => agentRoles.find(r => r.value === role)?.label ?? role;
const orgChartLayoutMode: OrgChartLayoutMode = useMemo(() => resolveOrgChartLayoutMode({
tree: displayOrgTree,
availableWidth: orgChartViewportWidth,
@@ -981,20 +1002,20 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const renderOrgChartZoomControls = useCallback(() => (
<>
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 0.9 }))} aria-label="Zoom out org chart" title="Zoom out">
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 0.9 }))} aria-label={t("agents.orgChartZoomOut", "Zoom out org chart")} title={t("agents.zoomOut", "Zoom out")}>
<ZoomOut size={16} />
</button>
<span className="agent-org-chart-controls__zoom-label" aria-live="polite">{Math.round(orgChartTransform.scale * 100)}%</span>
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 1.1 }))} aria-label="Zoom in org chart" title="Zoom in">
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 1.1 }))} aria-label={t("agents.orgChartZoomIn", "Zoom in org chart")} title={t("agents.zoomIn", "Zoom in")}>
<ZoomIn size={16} />
</button>
<button type="button" className="btn touch-target btn-sm agent-org-chart-controls__fit-btn" onClick={fitToViewport} aria-label="Fit org chart" title="Fit org chart">
<button type="button" className="btn touch-target btn-sm agent-org-chart-controls__fit-btn" onClick={fitToViewport} aria-label={t("agents.orgChartFit", "Fit org chart")} title={t("agents.orgChartFit", "Fit org chart")}>
<Minimize2 size={16} />
Fit
{t("agents.fit", "Fit")}
</button>
<button type="button" className="btn touch-target btn-sm" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, x: 0, y: 0 }))} aria-label="Center org chart" title="Center org chart">
<button type="button" className="btn touch-target btn-sm" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, x: 0, y: 0 }))} aria-label={t("agents.orgChartCenter", "Center org chart")} title={t("agents.orgChartCenter", "Center org chart")}>
<Move size={16} />
Center
{t("agents.center", "Center")}
</button>
</>
), [clampTransform, fitToViewport, orgChartTransform.scale]);
@@ -1074,9 +1095,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const renderOrgChartLayoutToggle = useCallback(() => {
const options: Array<{ value: OrgChartLayoutPreference; label: string; icon: ReactNode; ariaLabel: string }> = [
{ value: "horizontal", label: "Horizontal", icon: <Network size={16} />, ariaLabel: "Horizontal layout" },
{ value: "vertical", label: "Vertical", icon: <List size={16} />, ariaLabel: "Vertical layout" },
{ value: "auto", label: "Auto", icon: <RefreshCw size={16} />, ariaLabel: "Automatic layout" },
{ value: "horizontal", label: t("agents.layoutHorizontal", "Horizontal"), icon: <Network size={16} />, ariaLabel: t("agents.layoutHorizontalAria", "Horizontal layout") },
{ value: "vertical", label: t("agents.layoutVertical", "Vertical"), icon: <List size={16} />, ariaLabel: t("agents.layoutVerticalAria", "Vertical layout") },
{ value: "auto", label: t("agents.layoutAuto", "Auto"), icon: <RefreshCw size={16} />, ariaLabel: t("agents.layoutAutoAria", "Automatic layout") },
];
return (
@@ -1129,15 +1150,15 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<div className="agents-view-header">
<div className="agents-view-title">
<Bot size={24} />
<h2>Agents</h2>
<h2>{t("agents.title", "Agents")}</h2>
</div>
<div className="agents-view-controls">
<div className="view-toggle">
<button
className={`view-toggle-btn${agentView === "list" ? " active" : ""}`}
onClick={() => handleAgentViewChange("list")}
title="List view"
aria-label="List view"
title={t("agents.listView", "List view")}
aria-label={t("agents.listView", "List view")}
aria-pressed={agentView === "list"}
>
<List size={16} />
@@ -1145,8 +1166,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<button
className={`view-toggle-btn${agentView === "board" ? " active" : ""}`}
onClick={() => handleAgentViewChange("board")}
title="Board view"
aria-label="Board view"
title={t("agents.boardView", "Board view")}
aria-label={t("agents.boardView", "Board view")}
aria-pressed={agentView === "board"}
>
<Activity size={16} />
@@ -1154,8 +1175,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<button
className={`view-toggle-btn${agentView === "org" ? " active" : ""}`}
onClick={() => handleAgentViewChange("org")}
title="Org Chart view"
aria-label="Org Chart view"
title={t("agents.orgChartView", "Org Chart view")}
aria-label={t("agents.orgChartView", "Org Chart view")}
aria-pressed={agentView === "org"}
>
<Network size={16} />
@@ -1166,8 +1187,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
ref={controlsTriggerRef}
className={`btn-icon agent-controls-trigger${isControlsPanelOpen ? " agent-controls-trigger--active" : ""}`}
onClick={() => setIsControlsPanelOpen((open) => !open)}
title="Controls"
aria-label="Controls"
title={t("agents.controls", "Controls")}
aria-label={t("agents.controls", "Controls")}
aria-haspopup="dialog"
aria-expanded={isControlsPanelOpen}
aria-controls={controlsPanelId}
@@ -1177,8 +1198,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<button
className="btn-icon"
onClick={() => void loadAgents()}
title="Refresh"
aria-label="Refresh"
title={t("agents.refresh", "Refresh")}
aria-label={t("agents.refresh", "Refresh")}
>
<RefreshCw size={16} className={isLoading ? "spin" : undefined} />
</button>
@@ -1190,11 +1211,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
setIsImporting(true);
setIsControlsPanelOpen(false);
}}
aria-label="Import"
title="Import"
aria-label={t("agents.import", "Import")}
title={t("agents.import", "Import")}
>
<Upload size={16} />
Import
{t("agents.import", "Import")}
</button>
<button
className="btn btn-task-create btn-sm"
@@ -1202,11 +1223,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
handleOpenNewAgent();
setIsControlsPanelOpen(false);
}}
aria-label="New Agent"
title="New Agent"
aria-label={t("agents.newAgent", "New Agent")}
title={t("agents.newAgent", "New Agent")}
>
<Plus size={16} />
New Agent
{t("agents.newAgent", "New Agent")}
</button>
</>
)}
@@ -1216,7 +1237,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
id={controlsPanelId}
className="agent-controls-panel agent-controls-panel--scrollable"
role="dialog"
aria-label="Agent controls"
aria-label={t("agents.agentControls", "Agent controls")}
aria-modal="false"
>
<div className="agent-controls">
@@ -1227,14 +1248,14 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
className="agent-state-filter-select"
value={filterState}
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
aria-label="Filter agents by state"
aria-label={t("agents.filterByState", "Filter agents by state")}
>
<option value="all">All States</option>
<option value="idle">Idle</option>
<option value="active">Active</option>
<option value="running">Running</option>
<option value="paused">Paused</option>
<option value="error">Error</option>
<option value="all">{t("agents.stateAll", "All States")}</option>
<option value="idle">{t("agents.stateIdle", "Idle")}</option>
<option value="active">{t("agents.stateActive", "Active")}</option>
<option value="running">{t("agents.stateRunning", "Running")}</option>
<option value="paused">{t("agents.statePaused", "Paused")}</option>
<option value="error">{t("agents.stateError", "Error")}</option>
</select>
</div>
@@ -1243,9 +1264,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
type="checkbox"
checked={showSystemAgents}
onChange={(e) => setShowSystemAgents(e.target.checked)}
aria-label="Show system agents"
aria-label={t("agents.showSystemAgents", "Show system agents")}
/>
Show system agents
{t("agents.showSystemAgents", "Show system agents")}
</label>
</div>
</div>
@@ -1258,11 +1279,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
setIsImporting(true);
setIsControlsPanelOpen(false);
}}
aria-label="Import"
title="Import"
aria-label={t("agents.import", "Import")}
title={t("agents.import", "Import")}
>
<Upload size={16} />
Import
{t("agents.import", "Import")}
</button>
<button
className="btn btn-task-create btn-sm"
@@ -1270,16 +1291,16 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
handleOpenNewAgent();
setIsControlsPanelOpen(false);
}}
aria-label="New Agent"
title="New Agent"
aria-label={t("agents.newAgent", "New Agent")}
title={t("agents.newAgent", "New Agent")}
>
<Plus size={16} />
New Agent
{t("agents.newAgent", "New Agent")}
</button>
</div>
)}
<div className="agent-controls-bulk-actions" role="menu" aria-label="Bulk agent actions">
<div className="agent-controls-bulk-actions" role="menu" aria-label={t("agents.bulkAgentActions", "Bulk agent actions")}>
<button
type="button"
className="agent-detail-bulk-menu-item"
@@ -1292,14 +1313,14 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
>
<span className="agent-controls-bulk-actions__label">
<Pause />
<span>Pause All Agents</span>
<span>{t("agents.pauseAllAgents", "Pause All Agents")}</span>
</span>
<span className="agent-detail-bulk-menu-item-hint">
{isBulkEligibilityLoading
? "Loading eligibility…"
? t("agents.loadingEligibility", "Loading eligibility…")
: bulkPauseEligibleCount === 0
? "No active or running project agents to pause"
: `Pause ${bulkPauseEligibleCount} active/running agent${bulkPauseEligibleCount === 1 ? "" : "s"}`}
? t("agents.noAgentsToPauseHint", "No active or running project agents to pause")
: t("agents.pauseCountHint", { count: bulkPauseEligibleCount, defaultValue_one: "Pause {{count}} active/running agent", defaultValue_other: "Pause {{count}} active/running agents" })}
</span>
</button>
<button
@@ -1314,14 +1335,14 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
>
<span className="agent-controls-bulk-actions__label">
<Play />
<span>Resume All Agents</span>
<span>{t("agents.resumeAllAgents", "Resume All Agents")}</span>
</span>
<span className="agent-detail-bulk-menu-item-hint">
{isBulkEligibilityLoading
? "Loading eligibility…"
? t("agents.loadingEligibility", "Loading eligibility…")
: bulkResumeEligibleCount === 0
? "No paused project agents to resume"
: `Resume ${bulkResumeEligibleCount} paused agent${bulkResumeEligibleCount === 1 ? "" : "s"}`}
? t("agents.noAgentsToResumeHint", "No paused project agents to resume")
: t("agents.resumeCountHint", { count: bulkResumeEligibleCount, defaultValue_one: "Resume {{count}} paused agent", defaultValue_other: "Resume {{count}} paused agents" })}
</span>
</button>
</div>
@@ -1330,7 +1351,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<div className="heartbeat-multiplier-group">
<div className="heartbeat-multiplier-controls">
<label htmlFor="globalHeartbeatMultiplier" className="heartbeat-multiplier-label">
Heartbeat Speed
{t("agents.heartbeatSpeed", "Heartbeat Speed")}
</label>
<input
id="globalHeartbeatMultiplier"
@@ -1359,7 +1380,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
}}
disabled={isSavingMultiplier}
aria-label="Heartbeat speed preset"
aria-label={t("agents.heartbeatSpeedPreset", "Heartbeat speed preset")}
>
{HEARTBEAT_MULTIPLIER_PRESETS.map((multiplier) => (
<option key={multiplier} value={String(multiplier)}>
@@ -1369,7 +1390,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
</select>
</div>
<small className="text-secondary">
Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0
{t("agents.heartbeatSpeedHint", "Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0")}
</small>
</div>
</div>
@@ -1421,9 +1442,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
type="button"
className="btn btn-sm agents-org-detail-back"
onClick={handleCloseDetail}
aria-label="Back to org chart"
aria-label={t("agents.backToOrgChart", "Back to org chart")}
>
Back to org chart
{t("agents.backToOrgChart", "Back to org chart")}
</button>
<Suspense fallback={null}>
<AgentDetailView
@@ -1444,7 +1465,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
) : showInitialAgentsLoading ? (
<div className="agents-view-loading" role="status" aria-live="polite">
<RefreshCw size={18} className="spin" />
<span>Loading agents...</span>
<span>{t("agents.loadingAgents", "Loading agents...")}</span>
</div>
) : (
<div className="agent-org-chart-shell" data-testid="agent-org-chart-shell">
@@ -1467,7 +1488,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
data-testid="agent-org-chart-viewport"
tabIndex={0}
role="region"
aria-label="Org chart canvas"
aria-label={t("agents.orgChartCanvas", "Org chart canvas")}
onWheel={handleOrgChartWheel}
onPointerDown={handleOrgChartPointerDown}
onPointerMove={handleOrgChartPointerMove}
@@ -1490,7 +1511,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{isOrgTreeLoading ? (
<div className="agent-org-chart__loading" role="status" aria-live="polite">
<RefreshCw size={18} className="spin" />
<span>Loading org chart...</span>
<span>{t("agents.loadingOrgChart", "Loading org chart...")}</span>
</div>
) : displayOrgTree.length === 0 ? (
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
@@ -1533,7 +1554,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{showInitialAgentsLoading ? (
<div className="agents-view-loading" role="status" aria-live="polite">
<RefreshCw size={18} className="spin" />
<span>Loading agents...</span>
<span>{t("agents.loadingAgents", "Loading agents...")}</span>
</div>
) : agentView === "board" ? (
<div className="agent-board">
@@ -1542,7 +1563,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
) : (
displayAgents.map((agent) => {
const health = getHealthStatus(agent);
const healthSummary = getHealthSummary(agent, health);
const healthSummary = getHealthSummary(agent, health, t);
const stateBadgeClass = getStateBadgeClass(agent.state);
const stateCardClass = getStateCardClass("agent-board-card", agent.state);
return (
@@ -1566,7 +1587,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<span className="agent-board-badge badge text-secondary">{getRoleLabel(agent.role)}</span>
<span className={`agent-board-badge badge ${stateBadgeClass}`}>{agent.state}</span>
{(agent.pendingApprovalCount ?? 0) > 0 ? (
<span className="agent-board-badge badge agent-approval-badge" title="Pending approvals">
<span className="agent-board-badge badge agent-approval-badge" title={t("agents.pendingApprovals", "Pending approvals")}>
<span className="status-dot status-dot--pending" />
{agent.pendingApprovalCount}
</span>
@@ -1591,7 +1612,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
// List view: detailed card layout
displayAgents.map(agent => {
const health = getHealthStatus(agent);
const healthSummary = getHealthSummary(agent, health);
const healthSummary = getHealthSummary(agent, health, t);
const stateBadgeClass = getStateBadgeClass(agent.state);
const stateCardClass = getStateCardClass("agent-card", agent.state);
const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs);
@@ -1627,7 +1648,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
openAgentDetail(agent.id);
}
}}
aria-label={`Open details for ${agent.name}`}
aria-label={t("agents.openDetails", "Open details for {{name}}", { name: agent.name })}
>
<div className="agent-card-header">
<div className="agent-info">
@@ -1641,7 +1662,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
onBlur={() => setEditingRoleForAgent(null)}
autoFocus
>
{AGENT_ROLES.map(role => (
{agentRoles.map(role => (
<option key={role.value} value={role.value}>
{role.icon} {role.label}
</option>
@@ -1654,7 +1675,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
e.stopPropagation();
setEditingRoleForAgent(agent.id);
}}
title="Click to change role"
title={t("agents.clickToChangeRole", "Click to change role")}
role="button"
tabIndex={0}
onKeyDown={(e) => {
@@ -1686,7 +1707,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{getRoleLabel(agent.role)}
</span>
{(agent.pendingApprovalCount ?? 0) > 0 ? (
<span className="badge agent-approval-badge" title="Pending approvals">
<span className="badge agent-approval-badge" title={t("agents.pendingApprovals", "Pending approvals")}>
<span className="status-dot status-dot--pending" />
{agent.pendingApprovalCount}
</span>
@@ -1724,12 +1745,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
) : null}
{agent.taskId && (
<div className="agent-task">
<span className="text-secondary">Working on:</span>
<span className="text-secondary">{t("agents.workingOn", "Working on:")}</span>
<span className="badge">{agent.taskId}</span>
</div>
)}
<div className="agent-heartbeat-control">
<span className="text-secondary">Heartbeat:</span>
<span className="text-secondary">{t("agents.heartbeat", "Heartbeat:")}</span>
{customHeartbeatAgentId === agent.id ? (
// Custom input mode
<>
@@ -1756,16 +1777,16 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
}
}}
disabled={isUpdatingHeartbeat}
aria-label={`Custom heartbeat interval in minutes for ${agent.name}`}
aria-label={t("agents.customHeartbeatAria", "Custom heartbeat interval in minutes for {{name}}", { name: agent.name })}
/>
<span className="text-secondary">min</span>
<span className="text-secondary">{t("agents.minutesUnit", "min")}</span>
<button
className="btn btn-sm"
onClick={() => void handleCustomHeartbeatSave(agent)}
disabled={isUpdatingHeartbeat}
title="Save custom interval"
title={t("agents.saveCustomInterval", "Save custom interval")}
>
Save
{t("agents.save", "Save")}
</button>
<button
className="btn btn-sm"
@@ -1778,9 +1799,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
});
}}
disabled={isUpdatingHeartbeat}
title="Cancel custom interval"
title={t("agents.cancelCustomInterval", "Cancel custom interval")}
>
Cancel
{t("agents.cancel", "Cancel")}
</button>
</>
) : (
@@ -1798,7 +1819,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
}
}}
disabled={isUpdatingHeartbeat}
aria-label={`Set heartbeat interval for ${agent.name}`}
aria-label={t("agents.setHeartbeatAria", "Set heartbeat interval for {{name}}", { name: agent.name })}
>
{heartbeatOptions.map((option) => (
<option key={option.value} value={option.value}>
@@ -1807,12 +1828,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
))}
{/* Only show "Custom..." if current value is a preset; if it's already custom, it's already in the list */}
{HEARTBEAT_INTERVAL_PRESETS.some((p) => p.value === configuredIntervalMs) && (
<option value="__custom__">Custom...</option>
<option value="__custom__">{t("agents.customHeartbeatOption", "Custom...")}</option>
)}
</select>
</>
)}
{isUpdatingHeartbeat && <span className="agent-heartbeat-saving text-secondary">Saving…</span>}
{isUpdatingHeartbeat && <span className="agent-heartbeat-saving text-secondary">{t("agents.saving", "Saving…")}</span>}
{agent.lastHeartbeatAt && (() => {
const lastAt = new Date(agent.lastHeartbeatAt);
const nextAt = new Date(lastAt.getTime() + configuredIntervalMs);
@@ -1820,11 +1841,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
return (
<>
<span className="agent-heartbeat-last text-secondary" title={lastAt.toLocaleString()}>
Last: {lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
{t("agents.lastHeartbeat", "Last: {{time}}", { time: lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })}
</span>
{isTicking && (
<span className="agent-heartbeat-next text-secondary" title={nextAt.toLocaleString()}>
Next: {nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
{t("agents.nextHeartbeat", "Next: {{time}}", { time: nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })}
</span>
)}
</>
@@ -1840,9 +1861,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
className="btn btn-sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Activate"
title={t("agents.activate", "Activate")}
>
<Play size={14} /> <span className="agent-card-action-label">Start</span>
<Play size={14} /> <span className="agent-card-action-label">{t("agents.start", "Start")}</span>
</button>
)}
{agent.state === "active" && (
@@ -1851,18 +1872,18 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
className="btn btn-sm"
onClick={() => void handleRunHeartbeat(agent.id, agent.name)}
disabled={transitioningAgentIds.has(agent.id)}
title="Run Now"
aria-label={`Run now for ${agent.name}`}
title={t("agents.runNow", "Run Now")}
aria-label={t("agents.runNowAria", "Run now for {{name}}", { name: agent.name })}
>
<Activity size={14} /> <span className="agent-card-action-label">Run Now</span>
<Activity size={14} /> <span className="agent-card-action-label">{t("agents.runNow", "Run Now")}</span>
</button>
<button
className="btn btn-sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
title={t("agents.pause", "Pause")}
>
<Pause size={14} /> <span className="agent-card-action-label">Pause</span>
<Pause size={14} /> <span className="agent-card-action-label">{t("agents.pause", "Pause")}</span>
</button>
</>
)}
@@ -1871,9 +1892,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
className="btn btn-sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Resume"
title={t("agents.resume", "Resume")}
>
<Play size={14} /> <span className="agent-card-action-label">Resume</span>
<Play size={14} /> <span className="agent-card-action-label">{t("agents.resume", "Resume")}</span>
</button>
)}
{agent.state === "running" && (
@@ -1881,18 +1902,18 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<button
className="btn btn-sm"
onClick={() => openAgentDetail(agent.id, { initialTab: "runs", initialRunId: null, preferActiveRun: true })}
title="View live run details"
aria-label={`View live run details for ${agent.name}`}
title={t("agents.viewLiveRun", "View live run details")}
aria-label={t("agents.viewLiveRunAria", "View live run details for {{name}}", { name: agent.name })}
>
<Activity size={14} /> <span className="agent-card-action-label">Running</span>
<Activity size={14} /> <span className="agent-card-action-label">{t("agents.running", "Running")}</span>
</button>
<button
className="btn btn-sm"
onClick={() => void handleStateChange(agent.id, "paused")}
disabled={transitioningAgentIds.has(agent.id)}
title="Pause"
title={t("agents.pause", "Pause")}
>
<Pause size={14} /> <span className="agent-card-action-label">Pause</span>
<Pause size={14} /> <span className="agent-card-action-label">{t("agents.pause", "Pause")}</span>
</button>
</>
)}
@@ -1901,9 +1922,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
className="btn btn-sm"
onClick={() => void handleStateChange(agent.id, "active")}
disabled={transitioningAgentIds.has(agent.id)}
title="Retry"
title={t("agents.retry", "Retry")}
>
<Play size={14} /> <span className="agent-card-action-label">Retry</span>
<Play size={14} /> <span className="agent-card-action-label">{t("agents.retry", "Retry")}</span>
</button>
)}
</div>
@@ -1911,18 +1932,18 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<button
className="btn btn-sm agent-card-details-btn"
onClick={() => openAgentDetail(agent.id)}
title={`View details for ${agent.name}`}
aria-label={`View details for ${agent.name}`}
title={t("agents.viewDetailsFor", "View details for {{name}}", { name: agent.name })}
aria-label={t("agents.viewDetailsFor", "View details for {{name}}", { name: agent.name })}
>
<Info size={14} /> <span className="agent-card-action-label">Details</span>
<Info size={14} /> <span className="agent-card-action-label">{t("agents.details", "Details")}</span>
</button>
{(agent.state === "idle" || agent.state === "paused") && (
<button
className="btn btn-sm btn-danger"
onClick={() => void handleDelete(agent.id, agent.name)}
title="Delete"
title={t("agents.delete", "Delete")}
>
<Trash2 size={14} /> <span className="agent-card-action-label">Delete</span>
<Trash2 size={14} /> <span className="agent-card-action-label">{t("agents.delete", "Delete")}</span>
</button>
)}
</div>
@@ -1958,8 +1979,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
) : (
<div className="agents-detail-empty-state">
<Bot size={48} />
<h3>Select an agent</h3>
<p>Choose an agent from the sidebar to view details</p>
<h3>{t("agents.selectAnAgent", "Select an agent")}</h3>
<p>{t("agents.selectAgentHint", "Choose an agent from the sidebar to view details")}</p>
</div>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { AlertTriangle, Inbox, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import "./ApprovalNotificationBanner.css";
interface ApprovalNotificationBannerProps {
@@ -12,22 +13,23 @@ export function ApprovalNotificationBanner({
onOpenMailbox,
onDismiss,
}: ApprovalNotificationBannerProps) {
const noun = pendingCount === 1 ? "request" : "requests";
const { t } = useTranslation("app");
const noun = pendingCount === 1 ? t("approval.requestSingular", "request") : t("approval.requestPlural", "requests");
return (
<section className="approval-notification-banner" role="region" aria-live="polite" aria-label="Approval requests">
<section className="approval-notification-banner" role="region" aria-live="polite" aria-label={t("approval.requests", "Approval requests")}>
<div className="approval-notification-banner__content">
<div className="approval-notification-banner__headline">
<span className="status-dot" aria-hidden="true" />
<AlertTriangle aria-hidden="true" />
<span>{pendingCount} approval {noun} need your attention</span>
<span>{t("approval.needAttention", "{{count}} approval {{noun}} need your attention", { count: pendingCount, noun })}</span>
</div>
<div className="approval-notification-banner__actions">
<button type="button" className="btn btn-sm" onClick={onOpenMailbox}>
<Inbox aria-hidden="true" />
<span>Open Mailbox</span>
<span>{t("approval.openMailbox", "Open Mailbox")}</span>
</button>
<button type="button" className="btn-icon approval-notification-banner__dismiss" onClick={onDismiss} aria-label="Dismiss approval notification banner">
<button type="button" className="btn-icon approval-notification-banner__dismiss" onClick={onDismiss} aria-label={t("approval.dismissBanner", "Dismiss approval notification banner")}>
<X aria-hidden="true" />
</button>
</div>

View File

@@ -1,5 +1,6 @@
import "./AuthTokenRecoveryDialog.css";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { clearAuthToken, setAuthToken } from "../auth";
export interface AuthTokenRecoveryDialogProps {
@@ -7,6 +8,7 @@ export interface AuthTokenRecoveryDialogProps {
}
export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps) {
const { t } = useTranslation("app");
const [tokenInput, setTokenInput] = useState("");
const tokenInputRef = useRef<HTMLInputElement>(null);
@@ -51,17 +53,16 @@ export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps)
aria-describedby="auth-token-recovery-description"
>
<div className="modal-header auth-token-recovery-header">
<h3 id="auth-token-recovery-title">Authentication token required</h3>
<h3 id="auth-token-recovery-title">{t("auth.tokenRequired", "Authentication token required")}</h3>
</div>
<div className="auth-token-recovery-content">
<p id="auth-token-recovery-description">
This dashboard session can&apos;t authenticate with the daemon. Set a replacement token or clear the
current token and retry.
{t("auth.tokenRecoveryDescription", "This dashboard session can't authenticate with the daemon. Set a replacement token or clear the current token and retry.")}
</p>
<div className="auth-token-recovery-field">
<label htmlFor="auth-token-recovery-input">Replacement token</label>
<label htmlFor="auth-token-recovery-input">{t("auth.replacementToken", "Replacement token")}</label>
<input
ref={tokenInputRef}
id="auth-token-recovery-input"
@@ -69,7 +70,7 @@ export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps)
type="password"
value={tokenInput}
onChange={(event) => setTokenInput(event.target.value)}
placeholder="Paste token"
placeholder={t("auth.pasteToken", "Paste token")}
autoComplete="off"
spellCheck={false}
/>
@@ -82,7 +83,7 @@ export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps)
className="btn"
onClick={handleClearAndRetry}
>
Clear token and retry
{t("auth.clearAndRetry", "Clear token and retry")}
</button>
<button
type="button"
@@ -90,7 +91,7 @@ export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps)
onClick={handleSetToken}
disabled={tokenInput.trim().length === 0}
>
Set token and reload
{t("auth.setAndReload", "Set token and reload")}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import "./BackendConnectionErrorPage.css";
import { useTranslation } from "react-i18next";
interface BackendConnectionErrorPageProps {
errorMessage: string;
@@ -31,26 +32,27 @@ export function BackendConnectionErrorPage({
onRetry,
onManageConnection,
}: BackendConnectionErrorPageProps) {
const { t } = useTranslation("app");
const showChangeLaunchMode = isDesktopShell();
return (
<div className="project-overview-empty" role="alert" aria-live="polite">
<h2>Can&apos;t reach the Fusion backend</h2>
<h2>{t("backend.connectionError", "Can't reach the Fusion backend")}</h2>
<p className="settings-muted">
Fusion couldn&apos;t load your projects right now. Please make sure the backend is running and try again.
{t("backend.couldNotLoad", "Fusion couldn't load your projects right now. Please make sure the backend is running and try again.")}
</p>
<p className="settings-muted">Error: {errorMessage}</p>
<p className="settings-muted">{t("backend.error", "Error: {{error}}", { error: errorMessage })}</p>
<div className="modal-actions">
<button type="button" className="btn btn-primary" onClick={onRetry} disabled={isRetrying}>
{isRetrying ? "Retrying…" : "Retry Connection"}
{isRetrying ? t("backend.retrying", "Retrying…") : t("backend.retryConnection", "Retry Connection")}
</button>
{showChangeLaunchMode && (
<button type="button" className="btn" onClick={() => void changeLaunchMode()}>
Change Launch Mode…
{t("backend.changeLaunchMode", "Change Launch Mode…")}
</button>
)}
{onManageConnection && (
<button type="button" className="btn" onClick={onManageConnection}>
Manage Connection
{t("backend.manageConnection", "Manage Connection")}
</button>
)}
</div>

View File

@@ -1,6 +1,7 @@
import "./BackgroundTasksIndicator.css";
import { useState, useRef, useEffect, useMemo } from "react";
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AiSessionSummary } from "../api";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useConfirm } from "../hooks/useConfirm";
@@ -22,14 +23,6 @@ const TYPE_ICONS = {
slice_interview: Target,
} as const;
const TYPE_LABELS = {
planning: "Planning",
subtask: "Subtask Breakdown",
mission_interview: "Mission Interview",
milestone_interview: "Milestone Interview",
slice_interview: "Slice Interview",
} as const;
export function BackgroundTasksIndicator({
sessions,
generating,
@@ -37,6 +30,7 @@ export function BackgroundTasksIndicator({
onOpenSession,
onDismissSession,
}: BackgroundTasksIndicatorProps) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const [popoverOpen, setPopoverOpen] = useState(false);
const [recentlyUpdated, setRecentlyUpdated] = useState<Set<string>>(new Set());
@@ -47,6 +41,18 @@ export function BackgroundTasksIndicator({
const { activeTabMap } = useAiSessionSync();
const localSessionTabId = useMemo(() => getSessionTabId(), []);
// Type labels that are translatable
const TYPE_LABELS = useMemo(
() => ({
planning: t("backgroundTasks.typeLabel.planning", "Planning"),
subtask: t("backgroundTasks.typeLabel.subtask", "Subtask Breakdown"),
mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"),
milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"),
slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"),
}),
[t],
);
// Close popover on outside click
useEffect(() => {
if (!popoverOpen) return;
@@ -116,19 +122,23 @@ export function BackgroundTasksIndicator({
<button
className={`background-tasks-indicator__pill${hasAttention ? " background-tasks-indicator__pill--attention" : ""}`}
onClick={() => setPopoverOpen((prev) => !prev)}
title={`${total} background AI task${total !== 1 ? "s" : ""}${needsInput > 0 ? ` (${needsInput} need${needsInput !== 1 ? "" : "s"} input)` : ""}`}
title={
needsInput > 0
? t("backgroundTasks.pillTitleWithInput", "{{count}} background AI task ({{needsInput}} needs input)", { count: total, needsInput })
: t("backgroundTasks.pillTitle", "{{count}} background AI task", { count: total })
}
>
{generating > 0 && (
<Loader2 size={12} className="animate-spin" />
)}
{needsInput > 0 && generating === 0 && <HelpCircle size={12} />}
<span>AI {total}</span>
<span>{t("backgroundTasks.pillLabel", "AI {{count}}", { count: total })}</span>
</button>
{popoverOpen && (
<div className="background-tasks-indicator__popover">
<div className="background-tasks-indicator__popover-header">
Background Tasks
{t("backgroundTasks.popoverHeader", "Background Tasks")}
</div>
<div className="background-tasks-indicator__popover-list">
{sessions.map((session) => {
@@ -157,8 +167,8 @@ export function BackgroundTasksIndicator({
onClick={async () => {
if (activeElsewhere) {
const shouldOpen = await confirm({
title: "Open Active Session",
message: "This session is active in another tab. Open anyway?",
title: t("backgroundTasks.confirmTitle", "Open Active Session"),
message: t("backgroundTasks.confirmMessage", "This session is active in another tab. Open anyway?"),
});
if (!shouldOpen) {
return;
@@ -179,10 +189,10 @@ export function BackgroundTasksIndicator({
{session.title}
</div>
<div className="background-tasks-indicator__session-meta">
{isError ? "Failed" : TYPE_LABELS[session.type]}
{isGenerating && " — generating..."}
{isAwaiting && !activeElsewhere && " — needs input"}
{isAwaiting && activeElsewhere && " — active in another tab"}
{isError ? t("backgroundTasks.status.failed", "Failed") : TYPE_LABELS[session.type]}
{isGenerating && ` — ${t("backgroundTasks.status.generating", "generating...")}`}
{isAwaiting && !activeElsewhere && ` — ${t("backgroundTasks.status.needsInput", "needs input")}`}
{isAwaiting && activeElsewhere && ` — ${t("backgroundTasks.status.activeElsewhere", "active in another tab")}`}
</div>
</div>
{isGenerating && (
@@ -212,7 +222,7 @@ export function BackgroundTasksIndicator({
e.stopPropagation();
onDismissSession(session.id);
}}
title="Dismiss"
title={t("backgroundTasks.dismissButton", "Dismiss")}
>
<X size={12} />
</button>

View File

@@ -1,5 +1,6 @@
import "./BranchGroupCard.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
import type { BranchGroupSummary } from "../api";
import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
@@ -11,6 +12,7 @@ interface BranchGroupCardProps {
}
export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
const { t } = useTranslation("app");
const [group, setGroup] = useState<BranchGroupSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -24,12 +26,12 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
setGroup(response.group);
setError(null);
} catch (loadError) {
const message = loadError instanceof Error ? loadError.message : "Failed to load branch group";
const message = loadError instanceof Error ? loadError.message : t("branchGroup.loadError", "Failed to load branch group");
setError(message);
} finally {
setLoading(false);
}
}, [groupId, projectId]);
}, [groupId, projectId, t]);
useEffect(() => {
setLoading(true);
@@ -75,8 +77,11 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
const completionText = useMemo(() => {
if (!group) return "";
return `${group.completion.landed} of ${group.completion.total} members finished`;
}, [group]);
return t("branchGroup.completionText", "{{landed}} of {{total}} members finished", {
landed: group.completion.landed,
total: group.completion.total,
});
}, [group, t]);
const onPromote = useCallback(async () => {
setPromoting(true);
@@ -99,11 +104,11 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
}, [groupId, loadGroup, projectId]);
if (loading) {
return <div className="card branch-group-card"><Loader2 className="spin" size={14} /> Loading branch group…</div>;
return <div className="card branch-group-card"><Loader2 className="spin" size={14} /> {t("branchGroup.loading", "Loading branch group…")}</div>;
}
if (error || !group) {
return <div className="card branch-group-card branch-group-card-error">{error ?? "Branch group unavailable"}</div>;
return <div className="card branch-group-card branch-group-card-error">{error ?? t("branchGroup.unavailable", "Branch group unavailable")}</div>;
}
const completionPercent = group.completion.total > 0
@@ -119,13 +124,13 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
<strong>{group.branchName}</strong>
</div>
<div className="branch-group-card-header-meta">
<span className="badge branch-group-card-badge">Group {group.id}</span>
<span className="badge branch-group-card-badge">{t("branchGroup.groupLabel", "Group {{id}}", { id: group.id })}</span>
<button
type="button"
className="btn btn-icon"
onClick={() => setCollapsed((value) => !value)}
aria-expanded={!collapsed}
aria-label={collapsed ? "Expand branch group" : "Collapse branch group"}
aria-label={collapsed ? t("branchGroup.expandLabel", "Expand branch group") : t("branchGroup.collapseLabel", "Collapse branch group")}
>
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
@@ -172,22 +177,22 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
can only be promoted once every member has landed. Abandon below is
reachable whenever the PR is open, even if completion later reverts. */}
{complete && (group.autoMerge ? (
<span className="badge">Auto-merge enabled</span>
<span className="badge">{t("branchGroup.autoMergeEnabled", "Auto-merge enabled")}</span>
) : group.prState === "none" ? (
<button type="button" className="btn" onClick={() => void onPromote()} disabled={promoting}>
{promoting ? <Loader2 size={14} className="spin" /> : <GitPullRequest size={14} />}
Open PR
{t("branchGroup.openPr", "Open PR")}
</button>
) : (
<button type="button" className="btn" onClick={() => void onPromote()} disabled={promoting}>
{promoting ? <Loader2 size={14} className="spin" /> : <GitPullRequest size={14} />}
Merge group into main
{t("branchGroup.mergeIntoMain", "Merge group into main")}
</button>
))}
{group.prState === "open" && (
<button type="button" className="btn btn-danger" onClick={() => void onAbandon()} disabled={abandoning}>
{abandoning ? <Loader2 size={14} className="spin" /> : null}
Abandon group
{t("branchGroup.abandonGroup", "Abandon group")}
</button>
)}
</div>

View File

@@ -1,4 +1,5 @@
import type { CapacityRiskSignal } from "@fusion/core";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import "./CapacityRiskBanner.css";
@@ -8,6 +9,7 @@ interface CapacityRiskBannerProps {
}
export function CapacityRiskBanner({ signal, onDismiss }: CapacityRiskBannerProps) {
const { t } = useTranslation("app");
if (!signal || !signal.atRisk) {
return null;
}
@@ -15,13 +17,13 @@ export function CapacityRiskBanner({ signal, onDismiss }: CapacityRiskBannerProp
return (
<div className={`capacity-risk-banner${onDismiss ? " capacity-risk-banner--dismissible" : ""}`} role="status" aria-live="polite">
<div className="capacity-risk-banner__content">
<strong>Capacity risk:</strong> Todo {signal.todoCount} (threshold {signal.threshold}) · In Progress {signal.inProgressCount} · In Review {signal.inReviewCount} · Idle agents {signal.idleNonEphemeralAgentCount}
<strong>{t("capacity.risk", "Capacity risk:")}</strong> {t("capacity.status", "Todo {{todoCount}} (threshold {{threshold}}) · In Progress {{inProgress}} · In Review {{inReview}} · Idle agents {{idleAgents}}", { todoCount: signal.todoCount, threshold: signal.threshold, inProgress: signal.inProgressCount, inReview: signal.inReviewCount, idleAgents: signal.idleNonEphemeralAgentCount })}
</div>
{onDismiss ? (
<button
type="button"
className="capacity-risk-banner__dismiss touch-target"
aria-label="Dismiss capacity warning"
aria-label={t("capacity.dismiss", "Dismiss capacity warning")}
onClick={onDismiss}
>
<X aria-hidden="true" />

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import {
@@ -66,6 +67,7 @@ export function ChangesDiffModal({
onClose,
onRefresh,
}: ChangesDiffModalProps) {
const { t } = useTranslation("app");
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [wordWrap, setWordWrap] = useState(true);
const modalRef = useRef<HTMLDivElement>(null);
@@ -128,7 +130,7 @@ export function ChangesDiffModal({
<div className="modal-header changes-diff-modal-header">
<div className="changes-diff-header-title">
<FileCode size={18} />
<span>Changes — {taskId}</span>
<span>{t("changes.title", "Changes")} — {taskId}</span>
<span className="changes-stat-summary">
<span className="diff-add">+{stats.additions}</span>{" "}
<span className="diff-del">-{stats.deletions}</span>
@@ -141,8 +143,8 @@ export function ChangesDiffModal({
className="btn btn-sm btn-icon"
onClick={navigatePrev}
disabled={selectedIndex === null || selectedIndex <= 0}
title="Previous file (Ctrl+↑)"
aria-label="Previous file"
title={t("changes.previousFile", "Previous file (Ctrl+↑)")}
aria-label={t("changes.previousFileAria", "Previous file")}
>
<ChevronLeft />
</button>
@@ -157,8 +159,8 @@ export function ChangesDiffModal({
disabled={
selectedIndex === null || selectedIndex >= files.length - 1
}
title="Next file (Ctrl+↓)"
aria-label="Next file"
title={t("changes.nextFile", "Next file (Ctrl+↓)")}
aria-label={t("changes.nextFileAria", "Next file")}
>
<ChevronRight />
</button>
@@ -167,18 +169,18 @@ export function ChangesDiffModal({
<button
className={`btn btn-sm ${wordWrap ? "btn-primary" : ""}`}
onClick={() => setWordWrap((prev) => !prev)}
title={wordWrap ? "Disable word wrap" : "Enable word wrap"}
aria-label="Toggle word wrap"
title={wordWrap ? t("changes.disableWrap", "Disable word wrap") : t("changes.enableWrap", "Enable word wrap")}
aria-label={t("changes.toggleWrap", "Toggle word wrap")}
>
<WrapText size={14} />
</button>
{onRefresh && (
<button className="btn btn-sm" onClick={onRefresh}>
<RefreshCw size={14} />
Refresh
{t("actions.refresh", "Refresh")}
</button>
)}
<button className="modal-close" onClick={onClose} aria-label="Close">
<button className="modal-close" onClick={onClose} aria-label={t("actions.close", "Close")}>
<X size={20} />
</button>
</div>
@@ -204,7 +206,7 @@ export function ChangesDiffModal({
)}
{mergeDetails.mergedAt && (
<div className="commit-diff-timestamp">
Merged {new Date(mergeDetails.mergedAt).toLocaleString()}
{t("changes.merged", "Merged {{date}}", { date: new Date(mergeDetails.mergedAt).toLocaleString() })}
</div>
)}
</div>
@@ -255,14 +257,14 @@ export function ChangesDiffModal({
</div>
) : (
<div className="changes-diff-empty">
No diff available for this file.
{t("changes.noDiff", "No diff available for this file.")}
</div>
)}
</>
) : (
<div className="changes-diff-empty">
<FileCode size={48} opacity={0.3} />
<p>Select a file to view its diff</p>
<p>{t("changes.selectFile", "Select a file to view its diff")}</p>
</div>
)}
</div>

View File

@@ -47,6 +47,8 @@ import { matchesAgentMentionFilter } from "./mentionMatching";
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
export interface ChatViewProps {
projectId?: string;
@@ -72,7 +74,7 @@ export function clampChatInputHeight(scrollHeight: number): number {
return Math.max(40, Math.min(scrollHeight, CHAT_INPUT_MAX_HEIGHT_PX));
}
function formatRelativeTime(dateStr: string): string {
function formatRelativeTime(dateStr: string, t: TFunction<"app">): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
@@ -81,10 +83,10 @@ function formatRelativeTime(dateStr: string): string {
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffSecs < 60) return t("chat.relativeTimeJustNow", "just now");
if (diffMins < 60) return t("chat.relativeTimeMinutes", "{{count}}m ago", { count: diffMins });
if (diffHours < 24) return t("chat.relativeTimeHours", "{{count}}h ago", { count: diffHours });
if (diffDays < 7) return t("chat.relativeTimeDays", "{{count}}d ago", { count: diffDays });
return date.toLocaleDateString();
}
@@ -207,7 +209,7 @@ function buildFailureReferenceHref(reference: FailureInfo["reference"]): string
return null;
}
function renderFailureReference(reference: FailureInfo["reference"]): ReactNode {
function renderFailureReference(reference: FailureInfo["reference"], t: (key: string, defaultValue: string) => string): ReactNode {
if (!reference) {
return null;
}
@@ -220,27 +222,27 @@ function renderFailureReference(reference: FailureInfo["reference"]): ReactNode
return (
<div className="chat-message-failure-reference">
<span className="chat-message-failure-reference-label">Reference</span>
<span className="chat-message-failure-reference-label">{t("chat.failureReferenceLabel", "Reference")}</span>
<span className="chat-message-failure-reference-value">{referenceLabel}</span>
{referenceHref ? (
<a className="btn btn-sm chat-message-failure-reference-link" href={referenceHref}>
Open mailbox message
{t("chat.openMailboxMessage", "Open mailbox message")}
</a>
) : (
<details className="chat-message-failure-reference-details">
<summary className="btn btn-sm chat-message-failure-reference-link">View failure details</summary>
<summary className="btn btn-sm chat-message-failure-reference-link">{t("chat.viewFailureDetails", "View failure details")}</summary>
<dl className="chat-message-failure-reference-meta" id={referenceDetailsId}>
<div>
<dt>Kind</dt>
<dt>{t("chat.failureReferenceKind", "Kind")}</dt>
<dd>{reference.kind}</dd>
</div>
<div>
<dt>ID</dt>
<dt>{t("chat.failureReferenceId", "ID")}</dt>
<dd>{reference.id}</dd>
</div>
{reference.label && (
<div>
<dt>Label</dt>
<dt>{t("chat.failureReferenceMetaLabel", "Label")}</dt>
<dd>{reference.label}</dd>
</div>
)}
@@ -251,7 +253,7 @@ function renderFailureReference(reference: FailureInfo["reference"]): ReactNode
);
}
function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
function renderToolCalls(toolCalls: ToolCallInfo[] | undefined, t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string): ReactNode {
if (!toolCalls || toolCalls.length === 0) return null;
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
@@ -262,11 +264,11 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
const summaryPreview = isRunning
? argsSummary
: resultSummary
? `result: ${resultSummary}`
? `${t("chat.toolCallResultPrefix", "result")}: ${resultSummary}`
: argsSummary
? `args: ${argsSummary}`
? `${t("chat.toolCallArgsPrefix", "args")}: ${argsSummary}`
: null;
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
const statusLabel = isRunning ? t("chat.toolCallStatusRunning", "running") : isError ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusCompleted", "completed");
return (
<details
@@ -287,13 +289,13 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
<div className="chat-tool-call-content">
{argsSummary && (
<div className="chat-tool-call-row">
<span className="chat-tool-call-label">args</span>
<span className="chat-tool-call-label">{t("chat.toolCallArgsPrefix", "args")}</span>
<span className="chat-tool-call-value">{argsSummary}</span>
</div>
)}
{resultSummary && (
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
<span className="chat-tool-call-label">result</span>
<span className="chat-tool-call-label">{t("chat.toolCallResultPrefix", "result")}</span>
<span className="chat-tool-call-value">{resultSummary}</span>
</div>
)}
@@ -308,7 +310,7 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
<div className={className} data-testid="chat-tool-calls">
<div className="chat-tool-calls-header">
<Wrench size={12} aria-hidden="true" />
<span>Tool calls</span>
<span>{t("chat.toolCallsHeader", "Tool calls")}</span>
</div>
{renderToolCallItem(toolCalls[0], 0)}
</div>
@@ -325,9 +327,9 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
? `${visibleNames.join(", ")}, +${overflowCount} more`
: visibleNames.join(", ");
const statusSummary = hasRunning
? `(${runningCount} running)`
? `(${runningCount} ${t("chat.toolCallStatusRunning", "running")})`
: errorCount > 0
? `(${errorCount} ${errorCount === 1 ? "error" : "errors"})`
? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "errors")})`
: null;
return (
@@ -335,7 +337,7 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
<details className="chat-tool-calls-group" data-testid="chat-tool-calls-group" open={hasRunning}>
<summary className="chat-tool-calls-group-summary">
<Wrench size={12} aria-hidden="true" />
<span className="chat-tool-calls-count">{toolCalls.length} tool calls</span>
<span className="chat-tool-calls-count">{t("chat.toolCallsCount", "{{count}} tool calls", { count: toolCalls.length })}</span>
<span className="chat-tool-calls-names" title={namesSummary}>{namesSummary}</span>
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
</summary>
@@ -525,6 +527,7 @@ interface NewChatDialogProps {
}
function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDialogProps) {
const { t } = useTranslation("app");
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
const { agents, loading: agentsLoading } = useAgentsMapCache(projectId);
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
@@ -611,7 +614,7 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi
return (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={onClose} role="dialog" aria-modal="true">
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
<h3>New Chat</h3>
<h3>{t("chat.newChatTitle", "New Chat")}</h3>
<div className="chat-new-dialog-mode-toggle" data-testid="chat-new-dialog-mode-toggle">
<button
type="button"
@@ -621,7 +624,7 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi
setChatMode("agent");
}}
>
Agent
{t("chat.newChatModeAgent", "Agent")}
</button>
<button
type="button"
@@ -633,17 +636,17 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi
setSelectedModel((current) => current || defaultModelValue);
}}
>
Model
{t("chat.newChatModeModel", "Model")}
</button>
</div>
<form onSubmit={handleSubmit}>
{chatMode === "agent" && (
<label className="chat-new-dialog-model-label">
Agent
{t("chat.newChatModeAgent", "Agent")}
{agentsLoading ? (
<div className="chat-new-dialog-loading">Loading agents...</div>
<div className="chat-new-dialog-loading">{t("chat.loadingAgents", "Loading agents...")}</div>
) : agents.length === 0 ? (
<div className="chat-new-dialog-empty">No agents available</div>
<div className="chat-new-dialog-empty">{t("chat.noAgentsAvailable", "No agents available")}</div>
) : (
<div className="chat-new-dialog-agent-list">
{agents.map((agent) => (
@@ -666,14 +669,14 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi
{chatMode === "model" && (
<div className="chat-new-dialog-model-dropdown" data-testid="chat-new-dialog-model-section">
{modelsLoading ? (
<div className="chat-new-dialog-loading">Loading models...</div>
<div className="chat-new-dialog-loading">{t("chat.loadingModels", "Loading models...")}</div>
) : (
<CustomModelDropdown
models={models}
value={selectedModel}
onChange={setSelectedModel}
label="Model"
placeholder="Select a model"
label={t("chat.newChatModeModel", "Model")}
placeholder={t("chat.selectModel", "Select a model")}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
@@ -684,14 +687,14 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi
)}
<div className="chat-new-dialog-actions">
<button type="button" className="btn btn-sm" onClick={onClose}>
Cancel
{t("chat.cancel", "Cancel")}
</button>
<button
type="submit"
className="btn btn-sm btn-primary"
disabled={isSubmitDisabled}
>
Create
{t("chat.create", "Create")}
</button>
</div>
</form>
@@ -753,6 +756,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
copyAction,
onScrollToTop,
}: ChatMessageItemProps) {
const { t } = useTranslation("app");
const isAssistantMessage = message.role === "assistant";
const failureInfo = isAssistantMessage ? message.failureInfo : undefined;
const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo));
@@ -772,7 +776,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
const mentionedAgent = mentionAgentsByName.get(normalizedName);
if (mentionedAgent) {
const isNonMember = Boolean(roomContext && !roomContext.memberIds.has(mentionedAgent.id));
const nonMemberLabel = isNonMember ? `Not a member of ${roomContext?.roomName}` : undefined;
const nonMemberLabel = isNonMember ? t("chat.mentionNonMember", "Not a member of {{roomName}}", { roomName: roomContext?.roomName }) : undefined;
parts.push(
<span
key={`${mentionedAgent.id}-${start}`}
@@ -848,7 +852,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
<div className="chat-message-content chat-message-content--failure">
<div className="chat-message-failure-summary-row">
<span className="status-dot status-dot--error" aria-hidden="true" />
<span className="chat-message-failure-label">Response failed</span>
<span className="chat-message-failure-label">{t("chat.responseFailed", "Response failed")}</span>
</div>
<div className="chat-message-failure-summary">{failureInfo.summary}</div>
{(failureInfo.errorClass || failureInfo.code) && (
@@ -861,10 +865,10 @@ const ChatMessageItem = memo(function ChatMessageItem({
<details className="chat-message-failure-details">
<summary>
<TriangleAlert size={14} aria-hidden="true" />
<span>Failure details</span>
<span>{t("chat.failureDetails", "Failure details")}</span>
</summary>
{failureInfo.detail && <pre className="chat-message-failure-detail">{linkifyFilePaths(failureInfo.detail)}</pre>}
{renderFailureReference(failureInfo.reference)}
{renderFailureReference(failureInfo.reference, t)}
</details>
)}
</div>
@@ -905,7 +909,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
<button
type="button"
className="btn-icon chat-message-scroll-to-top-action"
aria-label="Scroll message to top"
aria-label={t("chat.scrollMessageToTop", "Scroll message to top")}
data-testid={`chat-message-scroll-to-top-${message.id}`}
onClick={() => onScrollToTop(message.id)}
>
@@ -914,20 +918,21 @@ const ChatMessageItem = memo(function ChatMessageItem({
)}
</div>
)}
{renderToolCalls(message.toolCalls)}
{renderToolCalls(message.toolCalls, t)}
{message.thinkingOutput && (
<details className="chat-message-thinking">
<summary>Thinking</summary>
<summary>{t("chat.thinking", "Thinking")}</summary>
<pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre>
</details>
)}
{renderedAttachments}
<div className="chat-message-time">{formatRelativeTime(message.createdAt)}</div>
<div className="chat-message-time">{formatRelativeTime(message.createdAt, t)}</div>
</div>
);
});
export function ChatView({ projectId, addToast, experimentalFeatures }: ChatViewProps) {
const { t } = useTranslation("app");
useEffect(() => {
recordResumeEvent({
view: "ChatView",
@@ -1241,9 +1246,18 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
return byName;
}, [mentionAgents]);
// Key the reset on skill ids, not array identity: useDiscoveredSkillsCache
// (SWR) re-delivers content-identical lists with fresh identities (cache
// reads re-parse; revalidation notifies a new array). Resetting on identity
// alone wipes the user's keyboard highlight mid-navigation when a
// revalidation lands — only a *semantic* list change should reset it.
const filteredSkillsKey = useMemo(
() => filteredSkills.map((skill) => skill.id).join(""),
[filteredSkills],
);
useEffect(() => {
setHighlightedSkillIndex(0);
}, [filteredSkills]);
}, [filteredSkillsKey]);
useEffect(() => {
setMentionHighlightIndex(0);
@@ -1805,7 +1819,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
// On mobile, hide sidebar after selecting
if (isMobile) setSidebarVisible(false);
} catch {
addToast("Failed to create chat session", "error");
addToast(t("chat.failedToCreateSession", "Failed to create chat session"), "error");
}
},
[createSession, addToast, isMobile],
@@ -1874,7 +1888,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
modelProvider: activeSession.modelProvider ?? undefined,
modelId: activeSession.modelId ?? undefined,
}).catch(() => {
addToast("Failed to clear conversation", "error");
addToast(t("chat.failedToClearConversation", "Failed to clear conversation"), "error");
});
return;
}
@@ -1910,7 +1924,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
try {
await rooms.clearRoom(rooms.activeRoom.id);
} catch {
addToast("Failed to clear room conversation", "error");
addToast(t("chat.failedToClearRoomConversation", "Failed to clear room conversation"), "error");
}
return;
}
@@ -1929,15 +1943,15 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
if (error instanceof RoomMessageDeliveredButReplyFailedError) {
const message = error.message.trim()
? error.message
: "Message sent, but assistant reply failed";
addToast(`Message sent, but assistant reply failed: ${message}`, "error");
: t("chat.messageSentButReplyFailed", "Message sent, but assistant reply failed");
addToast(t("chat.messageSentButReplyFailedDetail", "Message sent, but assistant reply failed: {{detail}}", { detail: message }), "error");
return;
}
setMessageInput(previousInput);
const message = error instanceof Error && error.message.trim()
? error.message
: "Failed to send room message";
: t("chat.failedToSendRoomMessage", "Failed to send room message");
addToast(message, "error");
} finally {
roomSendInFlightRef.current = false;
@@ -2283,9 +2297,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
setContextMenu(null);
try {
await archiveSession(id);
addToast("Conversation archived", "success");
addToast(t("chat.conversationArchived", "Conversation archived"), "success");
} catch {
addToast("Failed to archive conversation", "error");
addToast(t("chat.failedToArchiveConversation", "Failed to archive conversation"), "error");
}
},
[archiveSession, addToast],
@@ -2298,9 +2312,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
setContextMenu(null);
try {
await deleteSession(id);
addToast("Conversation deleted", "success");
addToast(t("chat.conversationDeleted", "Conversation deleted"), "success");
} catch {
addToast("Failed to delete conversation", "error");
addToast(t("chat.failedToDeleteConversation", "Failed to delete conversation"), "error");
}
},
[deleteSession, addToast],
@@ -2404,10 +2418,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
return (
<div className="chat-empty-state">
<MessageSquare size={48} strokeWidth={1.5} />
<h2>Start a new conversation</h2>
<h2>{t("chat.startNewConversation", "Start a new conversation")}</h2>
<button className="btn btn-primary" onClick={() => setShowNewDialog(true)}>
<Plus size={16} />
New Chat
{t("chat.newChat", "New Chat")}
</button>
</div>
);
@@ -2588,7 +2602,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
type="button"
className={`btn-icon chat-message-copy-action${copyFeedbackByMessageId[messageId] === "success" ? " chat-message-copy-action--success" : ""}${copyFeedbackByMessageId[messageId] === "error" ? " chat-message-copy-action--error" : ""}`}
data-testid={testId ?? `chat-copy-response-${messageId}`}
aria-label={copyFeedbackByMessageId[messageId] === "success" ? "Response copied" : copyFeedbackByMessageId[messageId] === "error" ? "Copy failed" : "Copy response"}
aria-label={copyFeedbackByMessageId[messageId] === "success" ? t("chat.responseCopied", "Response copied") : copyFeedbackByMessageId[messageId] === "error" ? t("chat.copyFailed", "Copy failed") : t("chat.copyResponse", "Copy response")}
onClick={() => {
void handleCopyResponse(messageId, content);
}}
@@ -2626,7 +2640,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
data-testid="chat-sidebar-scope-direct"
onClick={() => setChatScope("direct")}
>
Direct
{t("chat.scopeDirect", "Direct")}
</button>
<button
type="button"
@@ -2636,7 +2650,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
data-testid="chat-sidebar-scope-rooms"
onClick={() => setChatScope("rooms")}
>
Rooms
{t("chat.scopeRooms", "Rooms")}
</button>
</div>
)}
@@ -2649,7 +2663,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
<input
type="text"
className="chat-sidebar-search"
placeholder="Search conversations..."
placeholder={t("chat.searchConversations", "Search conversations...")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
data-testid="chat-search-input"
@@ -2659,9 +2673,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
{/* Session list section */}
<div className="chat-session-list chat-sidebar-list">
{sessionsLoading ? (
<div className="chat-empty-state chat-empty-state--padded">Loading...</div>
<div className="chat-empty-state chat-empty-state--padded">{t("chat.loadingConversations", "Loading...")}</div>
) : filteredSessions.length === 0 ? (
<div className="chat-empty-state chat-empty-state--padded">No conversations yet</div>
<div className="chat-empty-state chat-empty-state--padded">{t("chat.noConversationsYet", "No conversations yet")}</div>
) : (
filteredSessions.map((session) => {
const isActive = activeSession?.id === session.id;
@@ -2691,22 +2705,22 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
setConfirmDelete(session.id);
}}
data-testid="chat-session-delete-btn"
aria-label="Delete conversation"
aria-label={t("chat.deleteConversation", "Delete conversation")}
>
<Trash2 size={14} />
</button>
<div className="chat-session-title">
{session.title || "Untitled"}
{session.title || t("chat.untitledSession", "Untitled")}
{showUnreadDot ? (
<span
className="chat-unread-dot"
data-testid={`chat-unread-dot-${session.id}`}
aria-label="Unread messages"
aria-label={t("chat.unreadMessages", "Unread messages")}
/>
) : null}
</div>
<div className="chat-session-preview">
{session.lastMessagePreview || "No messages"}
{session.lastMessagePreview || t("chat.noMessages", "No messages")}
</div>
<div className="chat-session-meta">
<span className="chat-session-meta-model">
@@ -2716,7 +2730,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
(session.agentId === FN_AGENT_ID ? sessionModelTag : session.agentId.slice(0, 30))}
</span>
</span>
<span>{session.updatedAt ? formatRelativeTime(session.updatedAt) : ""}</span>
<span>{session.updatedAt ? formatRelativeTime(session.updatedAt, t) : ""}</span>
</div>
</div>
);
@@ -2735,13 +2749,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
onClick={() => setCreateRoomOpen(true)}
>
<Plus size={14} />
Create room
{t("chat.createRoom", "Create room")}
</button>
</div>
)}
{rooms.rooms.length === 0 ? (
<div className="chat-sidebar-rooms-empty" data-testid="chat-sidebar-rooms-empty">
No rooms yet.
{t("chat.noRoomsYet", "No rooms yet.")}
</div>
) : (
<div className="chat-session-list chat-sidebar-list">
@@ -2780,13 +2794,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
<span
className="chat-unread-dot"
data-testid={`chat-unread-dot-${room.id}`}
aria-label="Unread messages"
aria-label={t("chat.unreadMessages", "Unread messages")}
/>
) : null}
</span>
{isActive ? (
<span className="chat-room-item-meta">
{rooms.activeRoomMembers.length} {rooms.activeRoomMembers.length === 1 ? "member" : "members"}
{t("chat.roomMemberCount", "{{count}} member", { count: rooms.activeRoomMembers.length, defaultValue_one: "{{count}} member", defaultValue_other: "{{count}} members" })}
</span>
) : null}
</span>
@@ -2794,7 +2808,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
type="button"
className="btn-icon chat-room-item-delete"
data-testid={`chat-room-delete-${room.slug}`}
aria-label={`Delete room ${room.name}`}
aria-label={t("chat.deleteRoom", "Delete room {{name}}", { name: room.name })}
onClick={(event) => {
event.stopPropagation();
setConfirmDeleteRoomId(room.id);
@@ -2819,7 +2833,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
onClick={() => setCreateRoomOpen(true)}
>
<Plus size={14} />
Create room
{t("chat.createRoom", "Create room")}
</button>
</div>
) : null
@@ -2831,7 +2845,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
data-testid="chat-new-btn"
>
<Plus size={14} />
New Chat
{t("chat.newChat", "New Chat")}
</button>
</div>
)}
@@ -2845,7 +2859,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
aria-valuemin={CHAT_SIDEBAR_MIN_WIDTH}
aria-valuemax={CHAT_SIDEBAR_MAX_WIDTH}
aria-valuenow={sidebarWidth}
aria-label="Resize chat sidebar"
aria-label={t("chat.resizeSidebar", "Resize chat sidebar")}
tabIndex={0}
onPointerDown={handleResizeStart}
onKeyDown={handleResizeKeyDown}
@@ -2864,7 +2878,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
data-testid="chat-context-archive"
>
<Archive size={14} />
Archive
{t("chat.archive", "Archive")}
</button>
<button
onClick={() => {
@@ -2874,7 +2888,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
data-testid="chat-context-delete"
>
<Trash2 size={14} />
Delete
{t("chat.delete", "Delete")}
</button>
</div>
)}
@@ -2883,19 +2897,19 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
{confirmDelete && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDelete(null)}>
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
<h3>Delete Conversation?</h3>
<h3>{t("chat.deleteConversationTitle", "Delete Conversation?")}</h3>
<p className="chat-view-delete-dialog-copy">
This action cannot be undone. All messages in this conversation will be permanently deleted.
{t("chat.deleteConversationBody", "This action cannot be undone. All messages in this conversation will be permanently deleted.")}
</p>
<div className="chat-new-dialog-actions">
<button className="btn btn-sm" onClick={() => setConfirmDelete(null)}>
Cancel
{t("chat.cancel", "Cancel")}
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => void handleDelete(confirmDelete)}
>
Delete
{t("chat.delete", "Delete")}
</button>
</div>
</div>
@@ -2905,13 +2919,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
{chatRoomsEnabled && confirmDeleteRoomId && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDeleteRoomId(null)}>
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
<h3>Delete Room?</h3>
<h3>{t("chat.deleteRoomTitle", "Delete Room?")}</h3>
<p className="chat-view-delete-dialog-copy">
This action cannot be undone. This room and all its messages will be permanently deleted.
{t("chat.deleteRoomBody", "This action cannot be undone. This room and all its messages will be permanently deleted.")}
</p>
<div className="chat-new-dialog-actions">
<button className="btn btn-sm" onClick={() => setConfirmDeleteRoomId(null)}>
Cancel
{t("chat.cancel", "Cancel")}
</button>
<button
className="btn btn-sm btn-danger"
@@ -2921,12 +2935,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
await rooms.deleteRoom(confirmDeleteRoomId);
setConfirmDeleteRoomId(null);
} catch {
addToast("Failed to delete room", "error");
addToast(t("chat.failedToDeleteRoom", "Failed to delete room"), "error");
}
})();
}}
>
Delete
{t("chat.delete", "Delete")}
</button>
</div>
</div>
@@ -2996,14 +3010,14 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
</div>
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
{rooms.messagesLoading ? (
<div className="chat-empty-state">Loading messages...</div>
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
) : rooms.messages.filter((message) => message.content.trim() !== ROOM_SKIP_SENTINEL).length === 0 ? (
<div className="chat-empty-state">No messages yet. Start the conversation!</div>
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
rooms.messages
.filter((message) => message.content.trim() !== ROOM_SKIP_SENTINEL)
.map((message) => {
const senderName = message.senderAgentId ? (agentsMap.get(message.senderAgentId)?.name ?? message.senderAgentId.slice(0, 30)) : "You";
const senderName = message.senderAgentId ? (agentsMap.get(message.senderAgentId)?.name ?? message.senderAgentId.slice(0, 30)) : t("chat.you", "You");
const roomMessage: ChatMessageInfo = {
id: message.id,
sessionId: message.roomId,
@@ -3043,12 +3057,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
onClick={() => scrollToBottom("fab-click")}
>
<ChevronDown size={14} />
Latest
{t("chat.latest", "Latest")}
</button>
)}
</>
) : (
<div className="chat-room-empty-pane" data-testid="chat-rooms-empty-pane">Select a room or create one</div>
<div className="chat-room-empty-pane" data-testid="chat-rooms-empty-pane">{t("chat.selectRoomOrCreate", "Select a room or create one")}</div>
)}
{rooms.activeRoom && (
@@ -3058,7 +3072,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
<textarea
ref={handleComposerRef}
className="chat-input-textarea"
placeholder="Type a message..."
placeholder={t("chat.typeMessage", "Type a message...")}
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
@@ -3163,7 +3177,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
data-testid={`chat-mobile-session-option-${session.id}`}
onClick={() => handleSessionClick(session.id)}
>
<span className="chat-mobile-session-option-title">{session.title || "Untitled"}</span>
<span className="chat-mobile-session-option-title">{session.title || t("chat.untitledSession", "Untitled")}</span>
</button>
))}
</div>
@@ -3182,7 +3196,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
type="button"
className={`chat-thread-header-render-toggle${showAllAsPlain ? " chat-thread-header-render-toggle--plain" : ""}`}
data-testid="chat-thread-render-toggle"
aria-label={showAllAsPlain ? "Show all messages as rendered Markdown" : "Show all messages as plain text"}
aria-label={showAllAsPlain ? t("chat.showRenderedMarkdown", "Show all messages as rendered Markdown") : t("chat.showPlainText", "Show all messages as plain text")}
onClick={toggleAllAsPlain}
>
{showAllAsPlain ? <EyeOff size={14} /> : <Eye size={14} />}
@@ -3195,7 +3209,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
data-testid="chat-thread-new-chat-btn"
>
<Plus size={14} />
New Chat
{t("chat.newChat", "New Chat")}
</button>
)}
@@ -3206,7 +3220,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
<div ref={loadMoreSentinelRef} className="chat-load-more-sentinel">
{hasMoreMessages && messagesLoading && (
<div className="chat-loading-older">Loading older messages…</div>
<div className="chat-loading-older">{t("chat.loadingOlderMessages", "Loading older messages…")}</div>
)}
</div>
{isStreaming ? (
@@ -3240,14 +3254,14 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
renderAssistantContent(streamingText, showAllAsPlain)
) : (
<div className="chat-message-content chat-message-content--waiting">
{streamingThinking ? "Thinking…" : "Connecting…"}
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
</div>
)}
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
{renderToolCalls(streamingToolCalls)}
{renderToolCalls(streamingToolCalls, t)}
{streamingThinking && (
<details className="chat-message-thinking">
<summary>Thinking</summary>
<summary>{t("chat.thinking", "Thinking")}</summary>
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
</details>
)}
@@ -3259,11 +3273,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
</div>
</>
) : messagesLoading ? (
<div className="chat-empty-state">Loading messages...</div>
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
) : messages.length === 0 && !activeSession ? (
renderEmptyState()
) : messages.length === 0 && activeSession ? (
<div className="chat-empty-state">No messages yet. Start the conversation!</div>
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
<>
{messages.map((message) => (
@@ -3295,7 +3309,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
onClick={() => scrollToBottom("fab-click")}
>
<ChevronDown size={14} />
Latest
{t("chat.latest", "Latest")}
</button>
)}
@@ -3314,12 +3328,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
}}
/>
{showSkillMenu && (
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label="Skill suggestions">
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
{skillsLoading ? (
<div className="chat-skill-menu-empty">Loading skills…</div>
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
) : filteredSkills.length === 0 ? (
<div className="chat-skill-menu-empty">
{skillFilter ? "No skills found" : "No skills available"}
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
</div>
) : (
filteredSkills.map((skill, index) => (
@@ -3373,7 +3387,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
type="button"
className="btn-icon chat-attach-btn"
data-testid="chat-attach-btn"
aria-label="Attach files"
aria-label={t("chat.attachFiles", "Attach files")}
onClick={() => fileInputRef.current?.click()}
>
<Paperclip size={16} />
@@ -3394,7 +3408,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
<textarea
ref={handleComposerRef}
className="chat-input-textarea"
placeholder="Type a message..."
placeholder={t("chat.typeMessage", "Type a message...")}
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
@@ -3443,11 +3457,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
/>
{pendingMessage && (
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{`Queued: ${pendingPreview}`}</span>
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label="Dismiss queued message"
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
data-testid="chat-pending-dismiss"
onClick={clearPendingMessage}
>
@@ -3460,7 +3474,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
<button
className="chat-input-stop"
onClick={stopStreaming}
aria-label="Stop generation"
aria-label={t("chat.stopGeneration", "Stop generation")}
data-testid="chat-stop-btn"
>
<Square size={14} />

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import {
fetchClaudeCliStatus,
@@ -45,6 +46,7 @@ export function ClaudeCliProviderCard({
onToggled,
compact = false,
}: ClaudeCliProviderCardProps) {
const { t } = useTranslation("app");
const [status, setStatus] = useState<ClaudeCliStatus | null>(null);
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(
null,
@@ -125,8 +127,7 @@ export function ClaudeCliProviderCard({
const description = (
<span className="onboarding-provider-card__description">
Route AI calls through your locally-installed <code>claude</code> CLI.
Uses your existing Claude subscription / quota instead of an API key.
{t("setup.claudeCli.description", "Route AI calls through your locally-installed claude CLI. Uses your existing Claude subscription / quota instead of an API key.")}
</span>
);
@@ -141,10 +142,10 @@ export function ClaudeCliProviderCard({
{busy === "testing" ? (
<>
<Loader2 size={12} className="animate-spin" />
Testing…
{t("setup.claudeCli.testing", "Testing…")}
</>
) : (
"Test"
t("setup.claudeCli.test", "Test")
)}
</button>
{currentlyEnabled ? (
@@ -154,7 +155,7 @@ export function ClaudeCliProviderCard({
onClick={() => void handleToggle(false)}
disabled={busy !== null}
>
{busy === "disabling" ? "Disabling…" : "Disable"}
{busy === "disabling" ? t("setup.claudeCli.disabling", "Disabling…") : t("setup.claudeCli.disable", "Disable")}
</button>
) : (
<button
@@ -164,11 +165,11 @@ export function ClaudeCliProviderCard({
disabled={busy !== null || !binaryAvailable}
title={
!binaryAvailable
? "`claude` binary not detected on PATH — install Claude CLI first."
? t("setup.claudeCli.binaryNotFound", "`claude` binary not detected on PATH — install Claude CLI first.")
: undefined
}
>
{busy === "enabling" ? "Enabling…" : "Enable"}
{busy === "enabling" ? t("setup.claudeCli.enabling", "Enabling…") : t("setup.claudeCli.enable", "Enable")}
</button>
)}
</>
@@ -185,13 +186,13 @@ export function ClaudeCliProviderCard({
<div className="auth-provider-header">
<div className="auth-provider-info">
<ProviderIcon provider="claude-cli" size="sm" />
<strong>Anthropic — via Claude CLI</strong>
<strong>{t("setup.claudeCli.name", "Anthropic — via Claude CLI")}</strong>
<ClaudeCliBadge status={status} authenticated={authenticated} />
</div>
<div className="auth-provider-cli-actions">{actions}</div>
</div>
<details className="auth-provider-cli-details">
<summary>Details</summary>
<summary>{t("setup.claudeCli.details", "Details")}</summary>
<div className="auth-provider-cli-details-body">
{description}
<ClaudeCliStatusLine status={status} authenticated={authenticated} />
@@ -212,7 +213,7 @@ export function ClaudeCliProviderCard({
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">
Anthropic — via Claude CLI
{t("setup.claudeCli.name", "Anthropic — via Claude CLI")}
</strong>
{description}
<ClaudeCliStatusLine status={status} authenticated={authenticated} />
@@ -230,15 +231,16 @@ function ClaudeCliBadge({
status: ClaudeCliStatus | null;
authenticated: boolean;
}) {
const { t } = useTranslation("app");
const enabled = status?.enabled ?? authenticated;
const available = status?.binary.available ?? false;
if (enabled) {
return <span className="auth-status-badge authenticated">✓ Active</span>;
return <span className="auth-status-badge authenticated">{t("setup.claudeCli.active", "✓ Active")}</span>;
}
if (!available && status) {
return <span className="auth-status-badge not-authenticated">✗ Not installed</span>;
return <span className="auth-status-badge not-authenticated">{t("setup.claudeCli.notInstalled", "✗ Not installed")}</span>;
}
return <span className="auth-status-badge not-authenticated">✗ Not connected</span>;
return <span className="auth-status-badge not-authenticated">{t("setup.claudeCli.notConnected", "✗ Not connected")}</span>;
}
/**
@@ -253,10 +255,11 @@ function ClaudeCliStatusLine({
status: ClaudeCliStatus | null;
authenticated: boolean;
}) {
const { t } = useTranslation("app");
if (!status) {
return (
<small className="settings-muted">
<Loader2 size={10} className="animate-spin" /> Probing local CLI…
<Loader2 size={10} className="animate-spin" /> {t("setup.claudeCli.probing", "Probing local CLI…")}
</small>
);
}
@@ -264,37 +267,35 @@ function ClaudeCliStatusLine({
if (!binary.available) {
return (
<small className="onboarding-provider-card__status onboarding-provider-card__status--error">
✗ {binary.reason ?? "`claude` not found on PATH"}
✗ {binary.reason ?? t("setup.claudeCli.binaryNotFoundPath", "`claude` not found on PATH")}
</small>
);
}
if (!enabled) {
return (
<small className="settings-muted">
<code>claude</code> {binary.version ? `(${binary.version})` : ""} detected
{binary.binaryPath ? ` at ${binary.binaryPath}` : ""}. Click Enable to
route AI calls through it.
<code>claude</code> {binary.version ? `(${binary.version})` : ""} {t("setup.claudeCli.detectedPrompt", "detected{{path}}. Click Enable to route AI calls through it.", { path: binary.binaryPath ? ` at ${binary.binaryPath}` : "" })}
</small>
);
}
if (extension && extension.status !== "ok") {
return (
<small className="onboarding-provider-card__status onboarding-provider-card__status--warning">
⚠ Extension load failed: {extension.reason ?? extension.status}
⚠ {t("setup.claudeCli.extensionFailed", "Extension load failed: {{reason}}", { reason: extension.reason ?? extension.status })}
</small>
);
}
if (ready || authenticated) {
return (
<small className="onboarding-provider-card__status onboarding-provider-card__status--connected">
✓ Connected{binary.version ? ` — ${binary.version}` : ""}
{t("setup.claudeCli.connected", "✓ Connected{{version}}", { version: binary.version ? ` — ${binary.version}` : "" })}
</small>
);
}
// Enabled but `ready` is false and we have no specific reason — usually a
// transient state after flipping the toggle before the first probe
// completes.
return <small className="settings-muted">Enabled. Validating…</small>;
return <small className="settings-muted">{t("setup.claudeCli.validating", "Enabled. Validating…")}</small>;
}
function ClaudeCliActionToast({
@@ -305,6 +306,7 @@ function ClaudeCliActionToast({
| { kind: "disabled"; restartRequired: boolean }
| { kind: "error"; message: string };
}) {
const { t } = useTranslation("app");
if (action.kind === "error") {
return (
<p className="onboarding-helper-text onboarding-helper-text--error">
@@ -312,13 +314,13 @@ function ClaudeCliActionToast({
</p>
);
}
const verb = action.kind === "enabled" ? "Enabled" : "Disabled";
const verb = action.kind === "enabled" ? t("setup.claudeCli.enabledVerb", "Enabled") : t("setup.claudeCli.disabledVerb", "Disabled");
return (
<p className="onboarding-helper-text">
{verb}.{" "}
{action.kind === "enabled"
? "Claude-CLI-routed models are now visible in the model picker."
: "Claude-CLI-routed models are hidden from the model picker."}
? t("setup.claudeCli.enabledMessage", "Claude-CLI-routed models are now visible in the model picker.")
: t("setup.claudeCli.disabledMessage", "Claude-CLI-routed models are hidden from the model picker.")}
</p>
);
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import {
fetchFnBinaryStatus,
@@ -45,6 +46,7 @@ function persistDismissal(): void {
* Binary panel always lets the user reinstall later.
*/
export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
const { t } = useTranslation("app");
const [status, setStatus] = useState<FnBinaryStatus | null>(null);
const [dismissed, setDismissed] = useState<boolean>(() => isDismissed());
const [installing, setInstalling] = useState(false);
@@ -106,21 +108,20 @@ export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
const isMismatch = status.state === "version-mismatch";
const installedVersion = status.binary.version;
const targetVersion = status.expectedVersion;
const title = isMismatch ? "Update the Fusion CLI" : "Install the Fusion CLI";
const title = isMismatch ? t("cli.updateTitle", "Update the Fusion CLI") : t("cli.installTitle", "Install the Fusion CLI");
const body = isMismatch ? (
<>
Your installed <code>fn</code>/<code>fusion</code> CLI is{" "}
<strong>v{installedVersion ?? "unknown"}</strong> but this dashboard expects{" "}
<strong>v{targetVersion}</strong>. Update to stay in sync.
{t("cli.versionMismatchPrefix", "Your installed")} <code>fn</code>/<code>fusion</code> CLI is{" "}
<strong>v{installedVersion ?? "unknown"}</strong> {t("cli.versionMismatchInfix", "but this dashboard expects")} {" "}
<strong>v{targetVersion}</strong>. {t("cli.versionMismatchSuffix", "Update to stay in sync.")}
</>
) : (
<>
Get the <code>fn</code> and <code>fusion</code> commands on your terminal so you
can drive Fusion from anywhere. One click below or copy the command into your shell.
{t("cli.installBody", "Get the {{fn}} and {{fusion}} commands on your terminal so you can drive Fusion from anywhere. One click below or copy the command into your shell.", { fn: "fn", fusion: "fusion" })}
</>
);
const idleLabel = isMismatch ? "Update with npm" : "Install with npm";
const busyLabel = isMismatch ? "Updating…" : "Installing…";
const idleLabel = isMismatch ? t("cli.updateButton", "Update with npm") : t("cli.installButton", "Install with npm");
const busyLabel = isMismatch ? t("cli.updating", "Updating…") : t("cli.installing", "Installing…");
return (
<div className="cli-binary-banner" role="status">
@@ -141,7 +142,7 @@ export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
className="cli-binary-banner__secondary"
onClick={onOpenSettings}
>
Open Settings
{t("cli.openSettings", "Open Settings")}
</button>
</div>
{installError && (
@@ -151,7 +152,7 @@ export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
<button
type="button"
className="cli-binary-banner__dismiss"
aria-label="Dismiss"
aria-label={t("actions.dismiss", "Dismiss")}
onClick={handleDismiss}
>
<X size={16} />

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
fetchFnBinaryStatus,
installFnBinary,
@@ -16,12 +17,8 @@ interface Props {
defer?: boolean;
}
const STATE_LABELS: Record<FnBinaryStatus["state"], { text: string; tone: "ok" | "warn" | "err" }> = {
installed: { text: "Installed", tone: "ok" },
missing: { text: "Not installed", tone: "err" },
"version-mismatch": { text: "Version mismatch", tone: "warn" },
skipped: { text: "Check disabled", tone: "warn" },
};
// Note: These labels are fetched dynamically in the component via useTranslation
// to support i18n. See below for the actual label rendering.
/**
* Settings panel for the `fn` / `fusion` global CLI binary.
@@ -31,6 +28,7 @@ const STATE_LABELS: Record<FnBinaryStatus["state"], { text: string; tone: "ok" |
* commands so users with non-default npm setups can install themselves.
*/
export function CliBinaryPanel({ defer = false }: Props) {
const { t } = useTranslation("app");
const [status, setStatus] = useState<FnBinaryStatus | null>(null);
const [loading, setLoading] = useState(false);
const [installing, setInstalling] = useState(false);
@@ -38,6 +36,19 @@ export function CliBinaryPanel({ defer = false }: Props) {
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const getStateLabel = (state: FnBinaryStatus["state"]): { text: string; tone: "ok" | "warn" | "err" } => {
switch (state) {
case "installed":
return { text: t("cliBinary.stateInstalled", "Installed"), tone: "ok" };
case "missing":
return { text: t("cliBinary.stateMissing", "Not installed"), tone: "err" };
case "version-mismatch":
return { text: t("cliBinary.stateVersionMismatch", "Version mismatch"), tone: "warn" };
case "skipped":
return { text: t("cliBinary.stateCheckDisabled", "Check disabled"), tone: "warn" };
}
};
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
@@ -86,12 +97,12 @@ export function CliBinaryPanel({ defer = false }: Props) {
}
}, []);
const stateMeta = status ? STATE_LABELS[status.state] : null;
const stateMeta = status ? getStateLabel(status.state) : null;
return (
<div className="cli-binary-panel">
<div className="cli-binary-header">
<h4 className="settings-section-heading">CLI Binary</h4>
<h4 className="settings-section-heading">{t("cliBinary.heading", "CLI Binary")}</h4>
{stateMeta && (
<span className={`cli-binary-pill cli-binary-pill--${stateMeta.tone}`}>
{stateMeta.text}
@@ -99,12 +110,10 @@ export function CliBinaryPanel({ defer = false }: Props) {
)}
</div>
<small className="cli-binary-help">
Installing the global CLI lets you run <code>fn</code> and <code>fusion</code> from any
terminal. Automations and scripts work without it via <code>npx</code>, but a global
install is faster and more convenient.
{t("cliBinary.help", "Installing the global CLI lets you run fn and fusion from any terminal. Automations and scripts work without it via npx, but a global install is faster and more convenient.")}
</small>
{loading && !status && <p className="cli-binary-status-line">Checking…</p>}
{loading && !status && <p className="cli-binary-status-line">{t("cliBinary.checking", "Checking…")}</p>}
{status && (
<div className="cli-binary-detail">
@@ -130,7 +139,7 @@ export function CliBinaryPanel({ defer = false }: Props) {
</ul>
) : (
<p className="cli-binary-status-line">
Neither <code>fn</code> nor <code>fusion</code> was found on PATH.
{t("cliBinary.notOnPath", "Neither fn nor fusion was found on PATH.")}
</p>
)}
@@ -142,10 +151,10 @@ export function CliBinaryPanel({ defer = false }: Props) {
disabled={installing}
>
{installing
? "Installing…"
? t("cliBinary.installing", "Installing…")
: status.binary.installed
? "Reinstall"
: "Install with npm"}
? t("cliBinary.reinstall", "Reinstall")
: t("cliBinary.installWithNpm", "Install with npm")}
</button>
<button
type="button"
@@ -153,12 +162,12 @@ export function CliBinaryPanel({ defer = false }: Props) {
onClick={() => void refresh()}
disabled={loading || installing}
>
Refresh
{t("cliBinary.refresh", "Refresh")}
</button>
</div>
<div className="cli-binary-commands">
<label>Or copy and run yourself:</label>
<label>{t("cliBinary.orCopyLabel", "Or copy and run yourself:")}</label>
{[
{ label: "npm", command: status.install.npm },
{ label: "curl", command: status.install.curl },
@@ -170,7 +179,7 @@ export function CliBinaryPanel({ defer = false }: Props) {
onClick={() => void copy(label, command)}
className="cli-binary-copy-btn"
>
{copied === label ? "Copied" : "Copy"}
{copied === label ? t("cliBinary.copied", "Copied") : t("cliBinary.copy", "Copy")}
</button>
</div>
))}
@@ -182,8 +191,8 @@ export function CliBinaryPanel({ defer = false }: Props) {
<details className="cli-binary-install-log" open={!installResult.success}>
<summary>
{installResult.success
? `Install succeeded in ${(installResult.durationMs / 1000).toFixed(1)}s`
: `Install failed (exit ${installResult.exitCode ?? "n/a"})`}
? t("cliBinary.succeededDuration", "Install succeeded in {{duration}}s", { duration: (installResult.durationMs / 1000).toFixed(1) })
: t("cliBinary.failedExit", "Install failed (exit {{code}})", { code: installResult.exitCode ?? "n/a" })}
</summary>
{installResult.permissionsHint && (
<p className="cli-binary-permissions-hint">{installResult.permissionsHint}</p>

View File

@@ -1,4 +1,5 @@
import { memo, useMemo, useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
import { useConfirm } from "../hooks/useConfirm";
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core";
@@ -79,6 +80,7 @@ interface ColumnProps {
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap, prAuthAvailable }: ColumnProps) {
const { t } = useTranslation("app");
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -159,20 +161,20 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
if (shouldPrompt) {
const keepProgress = await confirm({
title: "Preserve Progress?",
message: "This task has completed steps. Keep progress before moving?",
confirmLabel: "Keep Progress",
cancelLabel: "Reset Progress",
title: t("column.preserveProgressTitle", "Preserve Progress?"),
message: t("column.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"),
confirmLabel: t("column.keepProgress", "Keep Progress"),
cancelLabel: t("column.resetProgress", "Reset Progress"),
});
if (keepProgress) {
moveOptions = { preserveProgress: true };
} else {
const resetProgress = await confirm({
title: "Reset Progress?",
message: "Reset all step progress before moving this task?",
confirmLabel: "Reset Progress",
cancelLabel: "Cancel Move",
title: t("column.resetProgressTitle", "Reset Progress?"),
message: t("column.resetProgressMessage", "Reset all step progress before moving this task?"),
confirmLabel: t("column.resetProgressConfirm", "Reset Progress"),
cancelLabel: t("column.cancelMove", "Cancel Move"),
danger: true,
});
if (!resetProgress) {
@@ -208,8 +210,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
if (tasks.length === 0) return;
const confirmed = await confirm({
title: "Replan All Tasks",
message: `Move all ${tasks.length} todo task${tasks.length === 1 ? "" : "s"} back to planning to be replanned?`,
title: t("column.replanAllTitle", "Replan All Tasks"),
message: t("column.replanAllMessage", "Move all {{count}} todo task{{plural}} back to planning to be replanned?", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" }),
});
if (!confirmed) return;
@@ -222,9 +224,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const failed = results.filter((r) => r.status === "rejected").length;
const moved = results.length - failed;
if (failed === 0) {
addToast(`Moved ${moved} task${moved === 1 ? "" : "s"} to planning for replanning`, "success");
addToast(t("column.movedToPlanning", "Moved {{count}} task{{plural}} to planning for replanning", { count: moved, plural: moved === 1 ? "" : "s" }), "success");
} else {
addToast(`Moved ${moved} of ${results.length} tasks; ${failed} failed`, "error");
addToast(t("column.movePartialFailure", "Moved {{moved}} of {{total}} tasks; {{failed}} failed", { moved, total: results.length, failed }), "error");
}
} finally {
setIsReplanning(false);
@@ -246,8 +248,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
if (pauseEligibleCount === 0) return;
const confirmed = await confirm({
title: "Stop All Tasks",
message: `Stop all ${pauseEligibleCount} ${COLUMN_LABELS[column].toLowerCase()} task${pauseEligibleCount === 1 ? "" : "s"}?`,
title: t("column.stopAllTitle", "Stop All Tasks"),
message: t("column.stopAllMessage", "Stop all {{count}} {{columnLabel}} task{{plural}}?", { count: pauseEligibleCount, columnLabel: COLUMN_LABELS[column].toLowerCase(), plural: pauseEligibleCount === 1 ? "" : "s" }),
danger: true,
});
if (!confirmed) return;
@@ -260,9 +262,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const failed = results.filter((r) => r.status === "rejected").length;
const paused = results.length - failed;
if (failed === 0) {
addToast(`Stopped ${paused} task${paused === 1 ? "" : "s"}`, "success");
addToast(t("column.stoppedTasks", "Stopped {{count}} task{{plural}}", { count: paused, plural: paused === 1 ? "" : "s" }), "success");
} else {
addToast(`Stopped ${paused} of ${results.length} tasks; ${failed} failed`, "error");
addToast(t("column.stopPartialFailure", "Stopped {{paused}} of {{total}} tasks; {{failed}} failed", { paused, total: results.length, failed }), "error");
}
} finally {
setIsPausingAll(false);
@@ -274,8 +276,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
if (tasks.length === 0) return;
const confirmed = await confirm({
title: "Move All to Todo",
message: `Move all ${tasks.length} ${COLUMN_LABELS[column].toLowerCase()} task${tasks.length === 1 ? "" : "s"} to Todo?`,
title: t("column.moveAllToTodoTitle", "Move All to Todo"),
message: t("column.moveAllToTodoMessage", "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", { count: tasks.length, columnLabel: COLUMN_LABELS[column].toLowerCase(), plural: tasks.length === 1 ? "" : "s" }),
});
if (!confirmed) return;
@@ -283,20 +285,20 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
let preserveProgress = false;
if (hasAnyProgress) {
const keepProgress = await confirm({
title: "Preserve Progress?",
message: "Some tasks have completed steps. Keep progress before moving to Todo?",
confirmLabel: "Keep Progress",
cancelLabel: "Reset Progress",
title: t("column.preserveProgressTitle", "Preserve Progress?"),
message: t("column.preserveProgressMoveTodoMessage", "Some tasks have completed steps. Keep progress before moving to Todo?"),
confirmLabel: t("column.keepProgress", "Keep Progress"),
cancelLabel: t("column.resetProgress", "Reset Progress"),
});
if (keepProgress) {
preserveProgress = true;
} else {
const resetProgress = await confirm({
title: "Reset Progress?",
message: "Reset step progress for tasks before moving to Todo?",
confirmLabel: "Reset Progress",
cancelLabel: "Cancel Move",
title: t("column.resetProgressTitle", "Reset Progress?"),
message: t("column.resetProgressMoveTodoMessage", "Reset step progress for tasks before moving to Todo?"),
confirmLabel: t("column.resetProgressConfirm", "Reset Progress"),
cancelLabel: t("column.cancelMove", "Cancel Move"),
danger: true,
});
if (!resetProgress) {
@@ -313,9 +315,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const failed = results.filter((r) => r.status === "rejected").length;
const moved = results.length - failed;
if (failed === 0) {
addToast(`Moved ${moved} task${moved === 1 ? "" : "s"} to Todo`, "success");
addToast(t("column.movedToTodo", "Moved {{count}} task{{plural}} to Todo", { count: moved, plural: moved === 1 ? "" : "s" }), "success");
} else {
addToast(`Moved ${moved} of ${results.length} tasks to Todo; ${failed} failed`, "error");
addToast(t("column.moveToTodoPartialFailure", "Moved {{moved}} of {{total}} tasks to Todo; {{failed}} failed", { moved, total: results.length, failed }), "error");
}
} finally {
setIsMovingAllToTodo(false);
@@ -327,19 +329,19 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
if (tasks.length === 0) return;
const confirmed = await confirm({
title: "Archive All Done",
message: `Archive all ${tasks.length} done tasks?`,
title: t("column.archiveAllTitle", "Archive All Done"),
message: t("column.archiveAllMessage", "Archive all {{count}} done tasks?", { count: tasks.length }),
danger: true,
});
if (!confirmed) return;
try {
const archived = await onArchiveAllDone();
addToast(`Archived ${archived.length} tasks`, "success");
addToast(t("column.archivedTasks", "Archived {{count}} tasks", { count: archived.length }), "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to archive tasks", "error");
addToast(getErrorMessage(err) || t("column.failedToArchive", "Failed to archive tasks"), "error");
}
}, [onArchiveAllDone, tasks.length, addToast, confirm]);
}, [onArchiveAllDone, tasks.length, addToast, confirm, t]);
return (
<div
@@ -354,19 +356,19 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
<h2>{COLUMN_LABELS[column]}</h2>
<span className={`column-count${countFlashing ? " count-flash" : ""}`}>{tasks.length}</span>
{column === "in-review" && onToggleAutoMerge && (
<label className="auto-merge-toggle" title={autoMerge ? "Auto-merge enabled" : "Auto-merge disabled"}>
<label className="auto-merge-toggle" title={autoMerge ? t("column.autoMergeEnabled", "Auto-merge enabled") : t("column.autoMergeDisabled", "Auto-merge disabled")}>
<input
type="checkbox"
checked={!!autoMerge}
onChange={onToggleAutoMerge}
/>
<span className="toggle-slider" />
<span className="toggle-label">Auto-merge</span>
<span className="toggle-label">{t("column.autoMerge", "Auto-merge")}</span>
</label>
)}
{onNewTask && (
<button className="btn btn-task-create btn-sm" onClick={onNewTask}>
+ New Task
+ {t("column.newTask", "New Task")}
</button>
)}
{column === "done" && onArchiveAllDone && (
@@ -374,8 +376,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
className="btn btn-icon btn-sm"
onClick={handleArchiveAll}
disabled={tasks.length === 0}
title="Archive all done tasks"
aria-label="Archive all done tasks"
title={t("column.archiveAllDoneTitle", "Archive all done tasks")}
aria-label={t("column.archiveAllDoneAriaLabel", "Archive all done tasks")}
>
<Archive />
</button>
@@ -384,8 +386,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
<button
className="btn btn-icon btn-sm"
onClick={onToggleCollapse}
title={collapsed ? "Expand archived tasks" : "Collapse archived tasks"}
aria-label={collapsed ? "Expand archived tasks" : "Collapse archived tasks"}
title={collapsed ? t("column.expandArchivedTitle", "Expand archived tasks") : t("column.collapseArchivedTitle", "Collapse archived tasks")}
aria-label={collapsed ? t("column.expandArchivedLabel", "Expand archived tasks") : t("column.collapseArchivedLabel", "Collapse archived tasks")}
>
{/* Directional chevrons stay explicit for clearer collapsed-state affordance in compact headers. */}
{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
@@ -399,8 +401,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onClick={() => setIsMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
aria-label={`${COLUMN_LABELS[column]} column actions`}
title="Column actions"
aria-label={t("column.actionsAriaLabel", "{{columnLabel}} column actions", { columnLabel: COLUMN_LABELS[column] })}
title={t("column.actionsTitle", "Column actions")}
disabled={isMenuBusy}
>
<MoreVertical />
@@ -415,9 +417,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onClick={() => void handleReplanAll()}
disabled={tasks.length === 0 || isReplanning}
>
Replan All
{t("column.replanAll", "Replan All")}
<span className="column-menu-item-hint">
Move {tasks.length} task{tasks.length === 1 ? "" : "s"} to Planning
{t("column.replanAllHint", "Move {{count}} task{{plural}} to Planning", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" })}
</span>
</button>
)}
@@ -430,13 +432,13 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onClick={() => void handlePauseAll()}
disabled={pauseEligibleCount === 0 || isPausingAll || !onPauseTask}
>
Stop All
{t("column.stopAll", "Stop All")}
<span className="column-menu-item-hint">
{tasks.length === 0
? "No tasks in this column"
? t("column.noTasksInColumn", "No tasks in this column")
: pauseEligibleCount === 0
? "No manually pausable tasks"
: `Pause ${pauseEligibleCount} active unassigned task${pauseEligibleCount === 1 ? "" : "s"}`}
? t("column.noManuallyPausableTasks", "No manually pausable tasks")
: t("column.pauseHint", "Pause {{count}} active unassigned task{{plural}}", { count: pauseEligibleCount, plural: pauseEligibleCount === 1 ? "" : "s" })}
</span>
</button>
<button
@@ -446,9 +448,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onClick={() => void handleMoveAllToTodo()}
disabled={tasks.length === 0 || isMovingAllToTodo}
>
Move All to Todo
{t("column.moveAllToTodo", "Move All to Todo")}
<span className="column-menu-item-hint">
Move {tasks.length} task{tasks.length === 1 ? "" : "s"} to Todo
{t("column.moveToTodoHint", "Move {{count}} task{{plural}} to Todo", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" })}
</span>
</button>
</>
@@ -489,7 +491,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
)}
{column === "in-progress" ? (
worktreeGroups.length === 0 ? (
<div className="empty-column">No tasks</div>
<div className="empty-column">{t("column.noTasks", "No tasks")}</div>
) : (
worktreeGroups.map((group) => (
<WorktreeGroup
@@ -515,7 +517,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
))
)
) : tasks.length === 0 ? (
<div className="empty-column">No tasks</div>
<div className="empty-column">{t("column.noTasks", "No tasks")}</div>
) : (
<>
{visibleTasks.map((task) => (
@@ -549,7 +551,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
className="btn btn-secondary btn-sm"
onClick={handleLoadMore}
>
Load {Math.min(VISIBLE_TASKS_INCREMENT, hiddenTaskCount)} more ({hiddenTaskCount} remaining)
{t("column.loadMore", "Load {{count}} more ({{remaining}} remaining)", { count: Math.min(VISIBLE_TASKS_INCREMENT, hiddenTaskCount), remaining: hiddenTaskCount })}
</button>
)}
</>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FileCode, ChevronDown, ChevronRight, AlertCircle, GitCommit } from "lucide-react";
import type { MergeDetails } from "@fusion/core";
import { fetchCommitDiff } from "../api";
@@ -73,6 +74,7 @@ export function parsePatch(rawPatch: string): ParsedFile[] {
* the in-progress `TaskChangesTab` but sourced from git history.
*/
export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
const { t } = useTranslation("app");
const [files, setFiles] = useState<ParsedFile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -95,7 +97,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
setExpandedFiles(new Set([parsed[0].path]));
}
} catch (err) {
setError(getErrorMessage(err) || "Failed to load commit diff");
setError(getErrorMessage(err) || t("commitDiff.loadError", "Failed to load commit diff"));
} finally {
setLoading(false);
}
@@ -122,9 +124,9 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
<div className="detail-section">
<div className="task-changes-state task-changes-state--empty">
<GitCommit size={24} />
<p>No commit SHA available.</p>
<p>{t("commitDiff.noSha", "No commit SHA available.")}</p>
<span className="task-changes-state-hint">
Commit diff is only available for tasks that were merged.
{t("commitDiff.mergedOnly", "Commit diff is only available for tasks that were merged.")}
</span>
</div>
</div>
@@ -136,7 +138,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
<div className="detail-section">
<div className="task-changes-state task-changes-state--loading">
<div className="loading-spinner" />
<span>Loading commit diff...</span>
<span>{t("commitDiff.loading", "Loading commit diff...")}</span>
</div>
</div>
);
@@ -147,7 +149,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
<div className="detail-section">
<div className="task-changes-state task-changes-state--error">
<AlertCircle size={16} />
<span>Error loading commit diff: {error}</span>
<span>{t("commitDiff.error", "Error loading commit diff: {{error}}", { error })}</span>
</div>
</div>
);
@@ -158,7 +160,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
<div className="detail-section">
<div className="task-changes-state task-changes-state--empty">
<FileCode size={24} />
<p>No files changed in this commit.</p>
<p>{t("commitDiff.noFiles", "No files changed in this commit.")}</p>
</div>
</div>
);
@@ -186,14 +188,14 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
<div className="changes-header">
<h4>
<FileCode size={16} />
Files Changed ({totalFiles})
{t("commit.filesChanged", "Files Changed ({{count}})", { count: totalFiles })}
<span className="changes-stat-summary">
<span className="diff-add">+{totalAdditions}</span>{" "}
<span className="diff-del">-{totalDeletions}</span>
</span>
</h4>
<button className="btn btn-sm" onClick={loadDiff} disabled={loading}>
Refresh
{t("actions.refresh", "Refresh")}
</button>
</div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { ConfirmOptions } from "../hooks/useConfirm";
import "./ConfirmDialog.css";
@@ -25,6 +26,7 @@ export function ConfirmDialog({
checkboxChecked = false,
onCheckboxChange,
}: ConfirmDialogProps) {
const { t } = useTranslation("app");
const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
@@ -60,7 +62,7 @@ export function ConfirmDialog({
>
<div className="modal-header">
<h3>{options.title}</h3>
<button className="modal-close" onClick={onCancel} aria-label="Close confirmation dialog">
<button className="modal-close" onClick={onCancel} aria-label={t("confirm.closeDialog", "Close confirmation dialog")}>
&times;
</button>
</div>
@@ -81,7 +83,7 @@ export function ConfirmDialog({
<div className="modal-actions confirm-dialog__actions">
<button ref={cancelButtonRef} className="btn" onClick={onCancel}>
{options.cancelLabel ?? "Cancel"}
{options.cancelLabel ?? t("confirm.cancel", "Cancel")}
</button>
{options.tertiaryLabel && onTertiary ? (
<button className={`btn ${options.tertiaryDanger ? "btn-danger" : ""}`.trim()} onClick={onTertiary}>
@@ -89,7 +91,7 @@ export function ConfirmDialog({
</button>
) : null}
<button className={`btn ${options.danger ? "btn-danger" : "btn-primary"}`} onClick={onConfirm}>
{options.confirmLabel ?? "Confirm"}
{options.confirmLabel ?? t("confirm.confirm", "Confirm")}
</button>
</div>
</div>

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import type { NodeCreateInput, NodeInfo } from "../api";
export interface ConnectNodeInput {
@@ -28,24 +30,24 @@ const DEFAULT_PORT = 3001;
const MAX_CONCURRENT_MIN = 1;
const MAX_CONCURRENT_MAX = 10;
function validateInput(input: { name: string; host: string; port: string; maxConcurrent: number }): FormErrors {
function validateInput(input: { name: string; host: string; port: string; maxConcurrent: number }, t: TFunction<"app">): FormErrors {
const errors: FormErrors = {};
if (!input.name.trim()) {
errors.name = "Node name is required";
errors.name = t("nodes.validation.nameRequired", "Node name is required");
}
if (!input.host.trim()) {
errors.host = "Host / IP address is required";
errors.host = t("nodes.validation.hostRequired", "Host / IP address is required");
}
const portNum = Number(input.port);
if (input.port && (isNaN(portNum) || portNum < 1 || portNum > 65535)) {
errors.port = "Port must be between 1 and 65535";
errors.port = t("nodes.validation.portRange", "Port must be between 1 and 65535");
}
if (!Number.isFinite(input.maxConcurrent) || input.maxConcurrent < MAX_CONCURRENT_MIN || input.maxConcurrent > MAX_CONCURRENT_MAX) {
errors.maxConcurrent = `Concurrency must be between ${MAX_CONCURRENT_MIN} and ${MAX_CONCURRENT_MAX}`;
errors.maxConcurrent = t("nodes.validation.concurrencyRange", `Concurrency must be between {{min}} and {{max}}`, { min: MAX_CONCURRENT_MIN, max: MAX_CONCURRENT_MAX });
}
return errors;
@@ -58,6 +60,7 @@ function buildUrl(host: string, port: string): string {
}
export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmit }: ConnectNodeModalProps) {
const { t } = useTranslation("app");
const [name, setName] = useState("");
const [host, setHost] = useState("");
const [port, setPort] = useState(String(DEFAULT_PORT));
@@ -106,7 +109,7 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
const handleSubmit = useCallback(async () => {
if (isSubmitting) return;
const validationErrors = validateInput({ name, host, port, maxConcurrent });
const validationErrors = validateInput({ name, host, port, maxConcurrent }, t);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
@@ -142,23 +145,23 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Failed to connect" }));
const error = await response.json().catch(() => ({ error: t("nodes.errors.connectFailed", "Failed to connect") }));
throw new Error(error.error || `HTTP ${response.status}`);
}
node = await response.json() as NodeInfo;
}
addToast(`Connected to "${node.name}"`, "success");
addToast(t("nodes.success.connected", `Connected to "{{name}}"`, { name: node.name }), "success");
onConnected(node);
onClose();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to connect to node";
const message = error instanceof Error ? error.message : t("nodes.errors.connectToNode", "Failed to connect to node");
addToast(message, "error");
} finally {
setIsSubmitting(false);
}
}, [addToast, apiKey, constructedUrl, host, isSubmitting, maxConcurrent, name, onClose, onConnected, onSubmit, port]);
}, [addToast, apiKey, constructedUrl, host, isSubmitting, maxConcurrent, name, onClose, onConnected, onSubmit, port, t]);
if (!open) return null;
@@ -169,25 +172,25 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Connect to Node"
aria-label={t("nodes.modal.title", "Connect to Node")}
>
<div className="modal-header">
<h3>Connect to Node</h3>
<button className="modal-close" onClick={onClose} disabled={isSubmitting} aria-label="Close connect node modal">
<h3>{t("nodes.modal.title", "Connect to Node")}</h3>
<button className="modal-close" onClick={onClose} disabled={isSubmitting} aria-label={t("nodes.modal.closeButton", "Close connect node modal")}>
&times;
</button>
</div>
<div className="modal-body connect-node-form">
<div className="form-group connect-node-field">
<label htmlFor="connect-node-name">Node Name</label>
<label htmlFor="connect-node-name">{t("nodes.fields.name", "Node Name")}</label>
<input
id="connect-node-name"
className="input connect-node-field__input"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Build Server"
placeholder={t("nodes.placeholders.name", "Build Server")}
disabled={isSubmitting}
aria-invalid={Boolean(errors.name)}
/>
@@ -195,14 +198,14 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
</div>
<div className="form-group connect-node-field">
<label htmlFor="connect-node-host">Host / IP Address</label>
<label htmlFor="connect-node-host">{t("nodes.fields.host", "Host / IP Address")}</label>
<input
id="connect-node-host"
className="input connect-node-field__input"
type="text"
value={host}
onChange={(event) => setHost(event.target.value)}
placeholder="192.0.2.10 or my-server.local"
placeholder={t("nodes.placeholders.host", "192.0.2.10 or my-server.local")}
disabled={isSubmitting}
aria-invalid={Boolean(errors.host)}
/>
@@ -210,7 +213,7 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
</div>
<div className="form-group connect-node-field">
<label htmlFor="connect-node-port">Port</label>
<label htmlFor="connect-node-port">{t("nodes.fields.port", "Port")}</label>
<input
id="connect-node-port"
className="input connect-node-field__input"
@@ -227,26 +230,26 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
{constructedUrl && (
<div className="connect-node-url-preview">
<span className="connect-node-url-preview-label">URL:</span>
<span className="connect-node-url-preview-label">{t("nodes.fields.url", "URL")}:</span>
<code>{constructedUrl}</code>
</div>
)}
<div className="form-group connect-node-field">
<label htmlFor="connect-node-auth-key">Auth Key</label>
<label htmlFor="connect-node-auth-key">{t("nodes.fields.authKey", "Auth Key")}</label>
<input
id="connect-node-auth-key"
className="input connect-node-field__input"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Optional"
placeholder={t("nodes.placeholders.optional", "Optional")}
disabled={isSubmitting}
/>
</div>
<div className="form-group connect-node-field">
<label htmlFor="connect-node-max-concurrent">Max Concurrent</label>
<label htmlFor="connect-node-max-concurrent">{t("nodes.fields.maxConcurrent", "Max Concurrent")}</label>
<input
id="connect-node-max-concurrent"
className="input connect-node-field__input"
@@ -264,10 +267,10 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
<div className="modal-actions connect-node-actions">
<button className="btn btn-sm" onClick={onClose} disabled={isSubmitting}>
Cancel
{t("actions.cancel", "Cancel")}
</button>
<button className="btn btn-primary btn-sm" onClick={handleSubmit} disabled={isSubmitting || !host.trim()}>
{isSubmitting ? "Connecting..." : "Connect"}
{isSubmitting ? t("nodes.actions.connecting", "Connecting...") : t("nodes.actions.connect", "Connect")}
</button>
</div>
</div>

View File

@@ -1,4 +1,6 @@
import "./ConversationHistory.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { PlanningQuestion } from "@fusion/core";
import { useState } from "react";
import type { ConversationHistoryEntry } from "../api";
@@ -26,7 +28,7 @@ function getResponseValue(entry: ConversationHistoryEntry): unknown {
return response;
}
function formatResponse(question: PlanningQuestion, responseValue: unknown): string {
function formatResponse(question: PlanningQuestion, responseValue: unknown, t: TFunction<"app">): string {
switch (question.type) {
case "text": {
if (typeof responseValue === "string") return responseValue;
@@ -54,8 +56,8 @@ function formatResponse(question: PlanningQuestion, responseValue: unknown): str
return responseValue == null ? "" : String(responseValue);
}
case "confirm": {
if (responseValue === true) return "Yes";
if (responseValue === false) return "No";
if (responseValue === true) return t("conversation.confirm.yes", "Yes");
if (responseValue === false) return t("conversation.confirm.no", "No");
return responseValue == null ? "" : String(responseValue);
}
default:
@@ -83,6 +85,7 @@ function normalizeEntries(entries: ConversationHistoryEntry[]): NumberedEntry[]
}
export function ConversationHistory({ entries, defaultShowThinking = false }: ConversationHistoryProps) {
const { t } = useTranslation("app");
const [expandedThinking, setExpandedThinking] = useState<Record<number, boolean>>({});
const normalizedEntries = normalizeEntries(entries);
@@ -100,7 +103,7 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co
const responseValue = hasQuestion ? getResponseValue(entry) : undefined;
const formattedResponse =
entry.question && responseValue !== undefined
? formatResponse(entry.question, responseValue)
? formatResponse(entry.question, responseValue, t)
: "";
const responseRecord =
entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)
@@ -118,13 +121,13 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co
</div>
) : (
<div className="conversation-entry-question">
<span className="conversation-entry-question-label">AI Reasoning</span>
<span className="conversation-entry-question-label">{t("conversation.aiReasoning", "AI Reasoning")}</span>
</div>
)}
{hasQuestion && (
<div className="conversation-entry-response">
<strong>Your response</strong>
<strong>{t("conversation.yourResponse", "Your response")}</strong>
<p>{formattedResponse || "—"}</p>
{comment && <p className="conversation-comment">💬 {comment}</p>}
</div>
@@ -145,8 +148,8 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co
>
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
{isExpanded
? `Hide ${hasQuestion ? "AI thinking" : "AI reasoning"}`
: `Show ${hasQuestion ? "AI thinking" : "AI reasoning"}`}
? t("conversation.hide", `Hide {{type}}`, { type: hasQuestion ? t("conversation.aiThinking", "AI thinking") : t("conversation.aiReasoning", "AI reasoning") })
: t("conversation.show", `Show {{type}}`, { type: hasQuestion ? t("conversation.aiThinking", "AI thinking") : t("conversation.aiReasoning", "AI reasoning") })}
</button>
{isExpanded && <pre>{entry.thinkingOutput}</pre>}
</div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { fetchAgents } from "../api";
import type { Agent } from "@fusion/core";
@@ -14,16 +15,17 @@ export interface RoomDraft {
memberAgentIds: string[];
}
export function validateRoomName(input: string, existingRoomNames: string[] = []): { ok: true; name: string } | { ok: false; error: string } {
export function validateRoomName(input: string, existingRoomNames: string[] = [], t?: (key: string, defaultValue: string) => string): { ok: true; name: string } | { ok: false; error: string } {
const raw = input.trim().replace(/^#/, "");
if (!raw) return { ok: false, error: "Room name is required." };
if (/[A-Z]/.test(raw)) return { ok: false, error: "Use lowercase letters only." };
const getError = (key: string, defaultValue: string) => t ? t(key, defaultValue) : defaultValue;
if (!raw) return { ok: false, error: getError("createRoom.nameRequired", "Room name is required.") };
if (/[A-Z]/.test(raw)) return { ok: false, error: getError("createRoom.lowercase", "Use lowercase letters only.") };
const stripped = raw.toLowerCase();
if (stripped.length > 80) return { ok: false, error: "Room names can be at most 80 characters." };
if (!/^[a-z0-9_-]+$/.test(stripped)) return { ok: false, error: "Use lowercase letters, numbers, hyphens, or underscores only." };
if (/^[-_]|[-_]$/.test(stripped)) return { ok: false, error: "Room names cannot start or end with a hyphen or underscore." };
if (stripped.length > 80) return { ok: false, error: getError("createRoom.maxLength", "Room names can be at most 80 characters.") };
if (!/^[a-z0-9_-]+$/.test(stripped)) return { ok: false, error: getError("createRoom.validChars", "Use lowercase letters, numbers, hyphens, or underscores only.") };
if (/^[-_]|[-_]$/.test(stripped)) return { ok: false, error: getError("createRoom.noEdgeChars", "Room names cannot start or end with a hyphen or underscore.") };
if (existingRoomNames.some((name) => name.toLowerCase() === stripped)) {
return { ok: false, error: "A room with this name already exists." };
return { ok: false, error: getError("createRoom.duplicate", "A room with this name already exists.") };
}
return { ok: true, name: stripped };
}
@@ -37,6 +39,7 @@ interface CreateRoomModalProps {
}
export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existingRoomNames = [] }: CreateRoomModalProps) {
const { t } = useTranslation("app");
const [rawName, setRawName] = useState("");
const [agents, setAgents] = useState<Agent[]>([]);
const [search, setSearch] = useState("");
@@ -56,7 +59,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
.then((result) => setAgents(result))
.catch(() => {
setAgents([]);
setSubmitError("Failed to load agents.");
setSubmitError(t("createRoom.failedLoadAgents", "Failed to load agents."));
})
.finally(() => setLoadingAgents(false));
}, [isOpen, projectId]);
@@ -88,7 +91,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
previousFocusRef.current?.focus();
}, [isOpen]);
const validation = useMemo(() => validateRoomName(rawName, existingRoomNames), [rawName, existingRoomNames]);
const validation = useMemo(() => validateRoomName(rawName, existingRoomNames, t), [rawName, existingRoomNames, t]);
const filteredAgents = useMemo(() => {
const normalized = search.trim().toLowerCase();
@@ -116,7 +119,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
return;
}
if (selectedAgentIds.length === 0) {
setSubmitError("Select at least one member.");
setSubmitError(t("createRoom.selectMember", "Select at least one member."));
return;
}
setSubmitError(null);
@@ -129,7 +132,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
});
onClose();
} catch (error) {
setSubmitError(error instanceof Error ? error.message : "Failed to create room.");
setSubmitError(error instanceof Error ? error.message : t("createRoom.failedCreate", "Failed to create room."));
} finally {
setIsSubmitting(false);
}
@@ -137,14 +140,14 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
return createPortal(
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()}>
<div className="modal modal-lg create-room-modal" role="dialog" aria-modal="true" aria-label="Create room" onClick={(event) => event.stopPropagation()}>
<div className="modal modal-lg create-room-modal" role="dialog" aria-modal="true" aria-label={t("createRoom.title", "Create room")} onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>Create room</h3>
<button type="button" className="modal-close" aria-label="Close" onClick={onClose}>×</button>
<h3>{t("createRoom.title", "Create room")}</h3>
<button type="button" className="modal-close" aria-label={t("actions.close", "Close")} onClick={onClose}>×</button>
</div>
<div className="form-group create-room-modal-name-group">
<label htmlFor="create-room-name">Room name</label>
<label htmlFor="create-room-name">{t("createRoom.nameLabel", "Room name")}</label>
<div className="create-room-modal-name-field">
<span aria-hidden="true" className="create-room-modal-name-hash">#</span>
<input
@@ -163,11 +166,11 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
</div>
<div className="form-group">
<label htmlFor="create-room-member-search">Members</label>
<label htmlFor="create-room-member-search">{t("createRoom.members", "Members")}</label>
<input
id="create-room-member-search"
className="input"
placeholder="Search agents"
placeholder={t("createRoom.searchAgents", "Search agents")}
value={search}
disabled={isSubmitting}
onChange={(event) => setSearch(event.target.value)}
@@ -192,10 +195,10 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
<div className="create-room-modal-member-list" data-testid="create-room-member-list">
{loadingAgents ? (
<div className="create-room-modal-empty">Loading agents...</div>
<div className="create-room-modal-empty">{t("createRoom.loadingAgents", "Loading agents...")}</div>
) : filteredAgents.length === 0 ? (
<div className="create-room-modal-empty">
{agents.length === 0 ? "No agents in this project yet." : "No agents match your search."}
{agents.length === 0 ? t("createRoom.noAgents", "No agents in this project yet.") : t("createRoom.noMatch", "No agents match your search.")}
</div>
) : (
filteredAgents.map((agent) => {
@@ -220,9 +223,9 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
{submitError && <div className="form-group"><div className="form-error">{submitError}</div></div>}
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose} disabled={isSubmitting}>Cancel</button>
<button type="button" className="btn" onClick={onClose} disabled={isSubmitting}>{t("actions.cancel", "Cancel")}</button>
<button type="button" className="btn btn-primary" onClick={() => void handleSubmit()} disabled={!canSubmit}>
{isSubmitting ? "Creating..." : "Create room"}
{isSubmitting ? t("createRoom.creating", "Creating...") : t("createRoom.create", "Create room")}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { fetchCursorCliStatus, setCursorCliEnabled, type CursorCliStatus } from "../api";
import { ProviderIcon } from "./ProviderIcon";
@@ -11,6 +12,7 @@ interface CursorCliProviderCardProps {
}
export function CursorCliProviderCard({ authenticated, compact = false, onToggled }: CursorCliProviderCardProps) {
const { t } = useTranslation("app");
const [status, setStatus] = useState<CursorCliStatus | null>(null);
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(null);
const mountedRef = useRef(true);
@@ -61,27 +63,27 @@ export function CursorCliProviderCard({ authenticated, compact = false, onToggle
if (mountedRef.current) setBusy(null);
});
}} disabled={busy !== null}>
{busy === "testing" ? <><Loader2 size={12} className="animate-spin" /> Testing…</> : "Test"}
{busy === "testing" ? <><Loader2 size={12} className="animate-spin" /> {t("setup.cursorCli.testing", "Testing…")}</> : t("setup.cursorCli.test", "Test")}
</button>
{currentlyEnabled ? (
<button type="button" className="btn btn-sm" onClick={() => void handleToggle(false)} disabled={busy !== null}>
{busy === "disabling" ? "Disabling…" : "Disable"}
{busy === "disabling" ? t("setup.cursorCli.disabling", "Disabling…") : t("setup.cursorCli.disable", "Disable")}
</button>
) : (
<button type="button" className="btn btn-primary btn-sm" onClick={() => void handleToggle(true)} disabled={busy !== null || !binaryAvailable}>
{busy === "enabling" ? "Enabling…" : "Enable"}
{busy === "enabling" ? t("setup.cursorCli.enabling", "Enabling…") : t("setup.cursorCli.enable", "Enable")}
</button>
)}
</>
);
const statusText = !status
? "Probing local CLI…"
? t("setup.cursorCli.probing", "Probing local CLI…")
: !status.binary.available
? status.binary.reason ?? "`cursor-agent` not found on PATH"
? status.binary.reason ?? t("setup.cursorCli.binaryNotFound", "`cursor-agent` not found on PATH")
: currentlyEnabled
? `Connected${status.binary.version ? ` — ${status.binary.version}` : ""}`
: "Detected. Click Enable to route calls through Cursor CLI.";
? t("setup.cursorCli.connected", "Connected{{version}}", { version: status.binary.version ? ` — ${status.binary.version}` : "" })
: t("setup.cursorCli.detectedPrompt", "Detected. Click Enable to route calls through Cursor CLI.");
if (compact) {
return (
@@ -90,7 +92,7 @@ export function CursorCliProviderCard({ authenticated, compact = false, onToggle
<div className="auth-provider-info">
<ProviderIcon provider="cursor-cli" size="sm" />
<strong>Cursor — via Cursor CLI</strong>
<span className={`auth-status-badge ${currentlyEnabled ? "authenticated" : "not-authenticated"}`}>{currentlyEnabled ? "✓ Active" : "✗ Not connected"}</span>
<span className={`auth-status-badge ${currentlyEnabled ? "authenticated" : "not-authenticated"}`}>{currentlyEnabled ? t("setup.cursorCli.active", "✓ Active") : t("setup.cursorCli.notConnected", "✗ Not connected")}</span>
</div>
<div className="auth-provider-cli-actions">{actions}</div>
</div>
@@ -106,7 +108,7 @@ export function CursorCliProviderCard({ authenticated, compact = false, onToggle
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">Cursor — via Cursor CLI</strong>
<span className="onboarding-provider-card__description">Route AI calls through your local Cursor agent runtime.</span>
<span className="onboarding-provider-card__description">{t("setup.cursorCli.description", "Route AI calls through your local Cursor agent runtime.")}</span>
<small className="settings-muted">{statusText}</small>
</div>
<div className="onboarding-provider-card__actions">{actions}</div>

View File

@@ -1,5 +1,6 @@
import "./CustomModelDropdown.css";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import type { ModelInfo } from "../api";
import { filterModels } from "../utils/modelFilter";
@@ -51,7 +52,7 @@ export function CustomModelDropdown({
models,
value,
onChange,
placeholder = "Select a model…",
placeholder: placeholderProp,
disabled = false,
id,
label,
@@ -60,8 +61,11 @@ export function CustomModelDropdown({
favoriteModels = [],
onToggleModelFavorite,
noChangeValue,
noChangeLabel = "No change",
noChangeLabel: noChangeLabelProp,
}: CustomModelDropdownProps) {
const { t } = useTranslation("app");
const placeholder = placeholderProp ?? t("model.selectPlaceholder", "Select a model…");
const noChangeLabel = noChangeLabelProp ?? t("model.noChange", "No change");
const [isOpen, setIsOpen] = useState(false);
const [localFilter, setLocalFilter] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
@@ -139,7 +143,7 @@ export function CustomModelDropdown({
if (hasNoChangeOption) {
options.push({ type: "no-change", value: noChangeValue, label: noChangeLabel });
}
options.push({ type: "default", value: "", label: "Use default" });
options.push({ type: "default", value: "", label: t("models.useDefault", "Use default") });
return options;
}, [hasNoChangeOption, noChangeLabel, noChangeValue]);
@@ -179,7 +183,7 @@ export function CustomModelDropdown({
if (hasNoChangeOption && value === noChangeValue) {
return noChangeLabel;
}
if (!value) return "Use default";
if (!value) return t("models.useDefault", "Use default");
const slashIdx = value.indexOf("/");
if (slashIdx === -1) return value;
const provider = value.slice(0, slashIdx);
@@ -488,7 +492,7 @@ export function CustomModelDropdown({
ref={searchInputRef}
type="text"
className="model-combobox-search"
placeholder="Filter models…"
placeholder={t("models.filterPlaceholder", "Filter models…")}
value={localFilter}
onChange={(e) => setLocalFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
@@ -498,7 +502,7 @@ export function CustomModelDropdown({
type="button"
className="model-combobox-clear"
onClick={handleClearFilter}
aria-label="Clear filter"
aria-label={t("models.clearFilter", "Clear filter")}
>
×
</button>
@@ -506,7 +510,7 @@ export function CustomModelDropdown({
</div>
<div className="model-combobox-results-count">
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
{t("models.count", { count: filteredModels.length, defaultValue_one: "{{count}} model", defaultValue_other: "{{count}} models" })}
</div>
<div ref={listRef} className="model-combobox-list">
@@ -557,8 +561,8 @@ export function CustomModelDropdown({
e.stopPropagation();
onToggleModelFavorite(fullId);
}}
title="Remove from favorites"
aria-label={`Remove ${model.name} from favorites`}
title={t("models.removeFromFavorites", "Remove from favorites")}
aria-label={t("models.removeFromFavoritesAriaLabel", "Remove {{name}} from favorites", { name: model.name })}
>
★
</button>
@@ -596,8 +600,8 @@ export function CustomModelDropdown({
e.stopPropagation();
onToggleFavorite(provider);
}}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorite ? `Remove ${provider} from favorites` : `Add ${provider} to favorites`}
title={isFavorite ? t("models.removeFromFavorites", "Remove from favorites") : t("models.addToFavorites", "Add to favorites")}
aria-label={isFavorite ? t("models.removeProviderFromFavoritesAriaLabel", "Remove {{provider}} from favorites", { provider }) : t("models.addProviderToFavoritesAriaLabel", "Add {{provider}} to favorites", { provider })}
>
★
</button>
@@ -630,8 +634,8 @@ export function CustomModelDropdown({
e.stopPropagation();
onToggleModelFavorite(optionValue);
}}
title={isFavorited ? "Remove from favorites" : "Add to favorites"}
aria-label={isFavorited ? `Remove ${m.name} from favorites` : `Add ${m.name} to favorites`}
title={isFavorited ? t("models.removeFromFavorites", "Remove from favorites") : t("models.addToFavorites", "Add to favorites")}
aria-label={isFavorited ? t("models.removeFromFavoritesAriaLabel", "Remove {{name}} from favorites", { name: m.name }) : t("models.addToFavoritesAriaLabel", "Add {{name}} to favorites", { name: m.name })}
>
{isFavorited ? "★" : "☆"}
</button>
@@ -644,7 +648,7 @@ export function CustomModelDropdown({
})}
{filteredModels.length === 0 && hasFilter && (
<div className="model-combobox-no-results">No models match &apos;{localFilter}&apos;</div>
<div className="model-combobox-no-results">{t("models.noResults", "No models match '{{filter}}'", { filter: localFilter })}</div>
)}
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, Search } from "lucide-react";
import type { CustomProviderConfig, CustomProviderModelInput } from "../api";
import { probeProviderModels } from "../api";
@@ -36,6 +37,7 @@ function emptyModel(): CustomProviderModelInput {
}
export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = false, error }: Props) {
const { t } = useTranslation("app");
const editing = Boolean(initialConfig);
const [id, setId] = useState(initialConfig?.id ?? "");
const [name, setName] = useState(initialConfig?.name ?? "");
@@ -72,7 +74,7 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
const handleDetectModels = useCallback(async () => {
const trimmedBaseUrl = baseUrl.trim();
if (!trimmedBaseUrl) {
setDetectError("Base URL is required to detect models.");
setDetectError(t("providers.detectError.urlRequired", "Base URL is required to detect models."));
return;
}
@@ -87,7 +89,7 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
});
if (result.models.length === 0) {
setDetectError("No models found. The provider may require an API key.");
setDetectError(t("providers.detectError.noModels", "No models found. The provider may require an API key."));
return;
}
@@ -110,35 +112,35 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
return [...nonEmpty, ...newModels];
});
} else {
setDetectError("All discovered models are already in the list.");
setDetectError(t("providers.detectError.allDuplicate", "All discovered models are already in the list."));
}
} catch (err) {
setDetectError(
err instanceof Error ? err.message : "Failed to detect models",
err instanceof Error ? err.message : t("providers.detectError.failed", "Failed to detect models"),
);
} finally {
setDetecting(false);
}
}, [baseUrl, apiKey, probeApiType, models]);
}, [baseUrl, apiKey, probeApiType, models, t]);
function validate(): string | null {
if (!id.trim()) return "Provider ID is required.";
if (!PROVIDER_ID_PATTERN.test(id.trim())) return "Provider ID must be kebab-case.";
if (!editing && BUILT_IN_PROVIDER_IDS.has(id.trim())) return "Provider ID conflicts with a built-in provider.";
if (!id.trim()) return t("providers.validation.idRequired", "Provider ID is required.");
if (!PROVIDER_ID_PATTERN.test(id.trim())) return t("providers.validation.idKebabCase", "Provider ID must be kebab-case.");
if (!editing && BUILT_IN_PROVIDER_IDS.has(id.trim())) return t("providers.validation.idConflict", "Provider ID conflicts with a built-in provider.");
if (!baseUrl.trim()) return "Base URL is required.";
if (!baseUrl.trim()) return t("providers.validation.urlRequired", "Base URL is required.");
try {
const parsed = new URL(baseUrl.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return "Base URL must use http or https.";
return t("providers.validation.urlProtocol", "Base URL must use http or https.");
}
} catch {
return "Base URL must be a valid URL.";
return t("providers.validation.urlValid", "Base URL must be a valid URL.");
}
if (!API_TYPES.includes(api)) return "API type is required.";
if (models.length === 0) return "At least one model is required.";
if (models.some((model) => !model.id?.trim())) return "Each model must have a model ID.";
if (!API_TYPES.includes(api)) return t("providers.validation.apiTypeRequired", "API type is required.");
if (models.length === 0) return t("providers.validation.modelRequired", "At least one model is required.");
if (models.some((model) => !model.id?.trim())) return t("providers.validation.modelId", "Each model must have a model ID.");
return null;
}
@@ -167,49 +169,49 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
return (
<form onSubmit={onSubmit} className="custom-provider-form" aria-label="custom-provider-form">
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-id">Provider ID</label>
<label htmlFor="custom-provider-id">{t("providers.fields.id", "Provider ID")}</label>
<input id="custom-provider-id" className="input" value={id} onChange={(e) => setId(e.target.value)} disabled={editing || saving} />
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-name">Display Name</label>
<label htmlFor="custom-provider-name">{t("providers.fields.name", "Display Name")}</label>
<input id="custom-provider-name" className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={saving} />
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-base-url">Base URL</label>
<label htmlFor="custom-provider-base-url">{t("providers.fields.baseUrl", "Base URL")}</label>
<input id="custom-provider-base-url" className="input" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} disabled={saving} />
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-api">API Type</label>
<label htmlFor="custom-provider-api">{t("providers.fields.apiType", "API Type")}</label>
<select id="custom-provider-api" className="select" value={api} onChange={(e) => setApi(e.target.value as CustomProviderConfig["api"])} disabled={saving}>
{API_TYPES.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-api-key">API Key</label>
<input id="custom-provider-api-key" className="input" placeholder="sk-..., MY_API_KEY, or !command" value={apiKey} onChange={(e) => setApiKey(e.target.value)} disabled={saving} />
<label htmlFor="custom-provider-api-key">{t("providers.fields.apiKey", "API Key")}</label>
<input id="custom-provider-api-key" className="input" placeholder={t("providers.placeholders.apiKey", "sk-..., MY_API_KEY, or !command")} value={apiKey} onChange={(e) => setApiKey(e.target.value)} disabled={saving} />
</div>
<div className="form-group custom-provider-form__group">
<label>Models</label>
<label>{t("providers.fields.models", "Models")}</label>
<div className="custom-provider-form__models">
{models.map((model, index) => (
<div key={`${index}-model`} className="custom-provider-form__model-row">
<input
className="input"
aria-label={`Model ID ${index + 1}`}
placeholder="Model ID"
aria-label={`${t("providers.fields.modelId", "Model ID")} ${index + 1}`}
placeholder={t("providers.fields.modelId", "Model ID")}
value={model.id}
onChange={(e) => updateModel(index, { id: e.target.value })}
disabled={saving}
/>
<input
className="input"
aria-label={`Model name ${index + 1}`}
placeholder="Display name"
aria-label={`${t("providers.fields.modelNameLabel", "Model name")} ${index + 1}`}
placeholder={t("providers.fields.modelName", "Display name")}
value={model.name ?? ""}
onChange={(e) => updateModel(index, { name: e.target.value })}
disabled={saving}
@@ -221,12 +223,12 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
onChange={(e) => updateModel(index, { reasoning: e.target.checked })}
disabled={saving}
/>
Reasoning
{t("providers.fields.reasoning", "Reasoning")}
</label>
<input
className="input"
aria-label={`Context window ${index + 1}`}
placeholder="Context window"
aria-label={`${t("providers.fields.contextWindow", "Context window")} ${index + 1}`}
placeholder={t("providers.fields.contextWindow", "Context window")}
type="number"
value={model.contextWindow ?? ""}
onChange={(e) => updateModel(index, { contextWindow: e.target.value ? Number(e.target.value) : undefined })}
@@ -234,8 +236,8 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
/>
<input
className="input"
aria-label={`Max tokens ${index + 1}`}
placeholder="Max tokens"
aria-label={`${t("providers.fields.maxTokens", "Max tokens")} ${index + 1}`}
placeholder={t("providers.fields.maxTokens", "Max tokens")}
type="number"
value={model.maxTokens ?? ""}
onChange={(e) => updateModel(index, { maxTokens: e.target.value ? Number(e.target.value) : undefined })}
@@ -246,7 +248,7 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
className="btn btn-icon btn-sm"
onClick={() => removeModel(index)}
disabled={saving || !canRemoveModel}
aria-label={`Remove model ${index + 1}`}
aria-label={t("providers.actions.removeModel", "Remove model", { count: index + 1 })}
>
×
</button>
@@ -255,22 +257,22 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
</div>
<div className="custom-provider-form__model-actions" style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<button type="button" className="btn btn-sm" onClick={() => setModels((prev) => [...prev, emptyModel()])} disabled={saving}>
+ Add model
{t("providers.actions.addModel", "+ Add model")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => void handleDetectModels()}
disabled={saving || detecting || !baseUrl.trim()}
title="Call the provider's /models endpoint to discover available models"
title={t("providers.actions.detectModelsTitle", "Call the provider's /models endpoint to discover available models")}
>
{detecting ? (
<>
<Loader2 className="spin" size={14} /> Detecting…
<Loader2 className="spin" size={14} /> {t("providers.actions.detecting", "Detecting…")}
</>
) : (
<>
<Search size={14} /> Detect Models
<Search size={14} /> {t("providers.actions.detectModels", "Detect Models")}
</>
)}
</button>
@@ -281,8 +283,8 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
{mergedError ? <div className="form-error">{mergedError}</div> : null}
<div className="custom-provider-form__actions">
{onCancel ? <button type="button" className="btn" onClick={onCancel} disabled={saving}>Cancel</button> : null}
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? "Saving..." : "Save Provider"}</button>
{onCancel ? <button type="button" className="btn" onClick={onCancel} disabled={saving}>{t("actions.cancel", "Cancel")}</button> : null}
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? t("providers.actions.saving", "Saving...") : t("providers.actions.save", "Save Provider")}</button>
</div>
</form>
);

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
addCustomProvider,
deleteCustomProvider,
@@ -65,6 +66,7 @@ interface CustomProvidersSectionProps {
}
export function CustomProvidersSection({ embedded = false, onProviderChange }: CustomProvidersSectionProps) {
const { t } = useTranslation("app");
const [providers, setProviders] = useState<CustomProvider[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
@@ -89,11 +91,11 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
setProviders(normalizeProviders(response));
setLoaded(true);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load custom providers.");
setError(loadError instanceof Error ? loadError.message : t("providers.failedLoad", "Failed to load custom providers."));
} finally {
setLoading(false);
}
}, []);
}, [t]);
const handleDisclosureToggle = useCallback(
(isOpen: boolean) => {
@@ -151,11 +153,11 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
const validateForm = useCallback((): string | null => {
if (!name.trim()) {
return "Provider name is required.";
return t("providers.nameRequired", "Provider name is required.");
}
if (!baseUrl.trim()) {
return "Base URL is required.";
return t("providers.urlRequired", "Base URL is required.");
}
let validProtocol = false;
@@ -167,21 +169,21 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
}
if (!validProtocol) {
return "Base URL must be a valid http/https URL.";
return t("providers.urlInvalid", "Base URL must be a valid http/https URL.");
}
if (!API_TYPES.includes(apiType)) {
return "API type is invalid.";
return t("providers.apiTypeInvalid", "API type is invalid.");
}
return null;
}, [apiType, baseUrl, name]);
}, [apiType, baseUrl, name, t]);
// Detect Models is available for all API types that expose a /models endpoint
const handleDetectModels = useCallback(async () => {
const trimmedBaseUrl = baseUrl.trim();
if (!trimmedBaseUrl) {
setDetectError("Base URL is required to detect models.");
setDetectError(t("providers.urlRequiredForDetect", "Base URL is required to detect models."));
return;
}
@@ -208,16 +210,16 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
return newIds.join(", ") + (existing ? ", " + existing : "");
});
} else {
setDetectError("No models found. The provider may require an API key.");
setDetectError(t("providers.noModelsFound", "No models found. The provider may require an API key."));
}
} catch (err) {
setDetectError(
err instanceof Error ? err.message : "Failed to detect models",
err instanceof Error ? err.message : t("providers.failedDetect", "Failed to detect models"),
);
} finally {
setDetecting(false);
}
}, [baseUrl, apiKey, apiType]);
}, [baseUrl, apiKey, apiType, t]);
const handleSave = useCallback(async () => {
const validationError = validateForm();
@@ -246,15 +248,15 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
onProviderChange?.();
resetForm();
} catch (saveError) {
setFormError(saveError instanceof Error ? saveError.message : "Failed to save provider.");
setFormError(saveError instanceof Error ? saveError.message : t("providers.failedSave", "Failed to save provider."));
} finally {
setSaving(false);
}
}, [apiKey, apiType, baseUrl, editingProvider, loadProviders, models, name, resetForm, validateForm]);
}, [apiKey, apiType, baseUrl, editingProvider, loadProviders, models, name, resetForm, validateForm, t]);
const handleDelete = useCallback(
async (provider: CustomProvider) => {
if (!window.confirm(`Delete custom provider "${provider.name}"?`)) return;
if (!window.confirm(t("providers.deleteConfirm", `Delete custom provider "{{name}}"?`, { name: provider.name }))) return;
setError(null);
try {
@@ -262,17 +264,17 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
await loadProviders();
onProviderChange?.();
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : "Failed to delete provider.");
setError(deleteError instanceof Error ? deleteError.message : t("providers.failedDelete", "Failed to delete provider."));
}
},
[loadProviders, onProviderChange],
[loadProviders, onProviderChange, t],
);
const sectionContent = (
<>
{embedded ? null : loading ? (
<div className="custom-provider-empty" role="status">
<Loader2 aria-hidden="true" className="spin" /> Loading custom providers…
<Loader2 aria-hidden="true" className="spin" /> {t("providers.loading", "Loading custom providers…")}
</div>
) : null}
@@ -303,7 +305,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
type="button"
className="btn btn-icon btn-sm"
onClick={() => openEditForm(provider)}
aria-label={`Edit ${provider.name}`}
aria-label={t("providers.editLabel", "Edit {{name}}", { name: provider.name })}
>
<Pencil aria-hidden="true" />
</button>
@@ -311,7 +313,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
type="button"
className="btn btn-icon btn-sm"
onClick={() => void handleDelete(provider)}
aria-label={`Delete ${provider.name}`}
aria-label={t("providers.deleteLabel", "Delete {{name}}", { name: provider.name })}
>
<Trash2 aria-hidden="true" />
</button>
@@ -321,7 +323,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
{isEditingThisProvider ? (
<div className="custom-provider-form custom-provider-item-edit-form">
<div className="form-group custom-provider-form-row">
<label htmlFor="custom-provider-name">Provider name</label>
<label htmlFor="custom-provider-name">{t("providers.nameLabel", "Provider name")}</label>
<input
id="custom-provider-name"
className="input"
@@ -332,7 +334,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
</div>
<div className="form-group custom-provider-form-row">
<label htmlFor="custom-provider-api-type">API type</label>
<label htmlFor="custom-provider-api-type">{t("providers.apiTypeLabel", "API type")}</label>
<select
id="custom-provider-api-type"
className="select"
@@ -340,14 +342,14 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
onChange={(event) => setApiType(event.target.value as ProviderApiType)}
disabled={saving}
>
<option value="openai-compatible">OpenAI-compatible</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic-compatible">Anthropic-compatible</option>
<option value="openai-compatible">{t("providers.apiTypeOpenAi", "OpenAI-compatible")}</option>
<option value="openai-responses">{t("providers.apiTypeOpenAiResp", "OpenAI Responses")}</option>
<option value="anthropic-compatible">{t("providers.apiTypeAnthropic", "Anthropic-compatible")}</option>
</select>
</div>
<div className="form-group custom-provider-form-row">
<label htmlFor="custom-provider-base-url">Base URL</label>
<label htmlFor="custom-provider-base-url">{t("providers.baseUrlLabel", "Base URL")}</label>
<input
id="custom-provider-base-url"
className="input"
@@ -359,7 +361,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
</div>
<div className="form-group custom-provider-form-row">
<label htmlFor="custom-provider-api-key">API key</label>
<label htmlFor="custom-provider-api-key">{t("providers.apiKeyLabel", "API key")}</label>
<input
id="custom-provider-api-key"
type="password"
@@ -371,7 +373,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
</div>
<div className="form-group custom-provider-form-row">
<label htmlFor="custom-provider-models">Available models</label>
<label htmlFor="custom-provider-models">{t("providers.modelsLabel", "Available models")}</label>
<input
id="custom-provider-models"
className="input"
@@ -388,15 +390,15 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
className="btn btn-sm"
onClick={() => void handleDetectModels()}
disabled={saving || detecting || !baseUrl.trim()}
title="Auto-detect models from the provider's /models endpoint"
title={t("providers.detectTitle", "Auto-detect models from the provider's /models endpoint")}
>
{detecting ? (
<>
<Loader2 className="spin" size={14} /> Detecting…
<Loader2 className="spin" size={14} /> {t("providers.detecting", "Detecting…")}
</>
) : (
<>
<Search size={14} /> Detect Models
<Search size={14} /> {t("providers.detectModels", "Detect Models")}
</>
)}
</button>
@@ -407,7 +409,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
<div className="custom-provider-form-actions">
<button type="button" className="btn btn-sm" onClick={resetForm} disabled={saving}>
Cancel
{t("actions.cancel", "Cancel")}
</button>
<button
type="button"
@@ -415,7 +417,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
onClick={() => void handleSave()}
disabled={saving}
>
{saving ? "Saving…" : "Save Changes"}
{saving ? t("providers.saving", "Saving…") : t("providers.saveChanges", "Save Changes")}
</button>
</div>
</div>
@@ -427,17 +429,17 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
) : null}
{!loading && providers.length === 0 && !error ? (
<div className="custom-provider-empty">No custom providers configured.</div>
<div className="custom-provider-empty">{t("providers.noneConfigured", "No custom providers configured.")}</div>
) : null}
<button type="button" className="btn btn-sm custom-provider-add-btn" onClick={openAddForm}>
<Plus aria-hidden="true" /> Add Custom Provider
<Plus aria-hidden="true" /> {t("providers.addCustom", "Add Custom Provider")}
</button>
{isFormOpen && !editingProvider ? (
<div className="custom-provider-form">
<div className="form-group custom-provider-form-row">
<label htmlFor="custom-provider-name">Provider name</label>
<label htmlFor="custom-provider-name">{t("providers.nameLabel", "Provider name")}</label>
<input
id="custom-provider-name"
className="input"
@@ -448,7 +450,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
</div>
<div className="form-group custom-provider-form-row">
<label htmlFor="custom-provider-api-type">API type</label>
<label htmlFor="custom-provider-api-type">{t("providers.apiTypeLabel", "API type")}</label>
<select
id="custom-provider-api-type"
className="select"
@@ -456,14 +458,14 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
onChange={(event) => setApiType(event.target.value as ProviderApiType)}
disabled={saving}
>
<option value="openai-compatible">OpenAI-compatible</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic-compatible">Anthropic-compatible</option>
<option value="openai-compatible">{t("providers.apiTypeOpenAi", "OpenAI-compatible")}</option>
<option value="openai-responses">{t("providers.apiTypeOpenAiResp", "OpenAI Responses")}</option>
<option value="anthropic-compatible">{t("providers.apiTypeAnthropic", "Anthropic-compatible")}</option>
</select>
</div>
<div className="form-group custom-provider-form-row">
<label htmlFor="custom-provider-base-url">Base URL</label>
<label htmlFor="custom-provider-base-url">{t("providers.baseUrlLabel", "Base URL")}</label>
<input
id="custom-provider-base-url"
className="input"
@@ -475,7 +477,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
</div>
<div className="form-group custom-provider-form-row">
<label htmlFor="custom-provider-api-key">API key</label>
<label htmlFor="custom-provider-api-key">{t("providers.apiKeyLabel", "API key")}</label>
<input
id="custom-provider-api-key"
type="password"
@@ -487,7 +489,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
</div>
<div className="form-group custom-provider-form-row">
<label htmlFor="custom-provider-models">Available models</label>
<label htmlFor="custom-provider-models">{t("providers.modelsLabel", "Available models")}</label>
<input
id="custom-provider-models"
className="input"
@@ -504,15 +506,15 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
className="btn btn-sm"
onClick={() => void handleDetectModels()}
disabled={saving || detecting || !baseUrl.trim()}
title="Auto-detect models from the provider's /models endpoint"
title={t("providers.detectTitle", "Auto-detect models from the provider's /models endpoint")}
>
{detecting ? (
<>
<Loader2 className="spin" size={14} /> Detecting…
<Loader2 className="spin" size={14} /> {t("providers.detecting", "Detecting…")}
</>
) : (
<>
<Search size={14} /> Detect Models
<Search size={14} /> {t("providers.detectModels", "Detect Models")}
</>
)}
</button>
@@ -523,7 +525,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
<div className="custom-provider-form-actions">
<button type="button" className="btn btn-sm" onClick={resetForm} disabled={saving}>
Cancel
{t("actions.cancel", "Cancel")}
</button>
<button
type="button"
@@ -531,7 +533,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
onClick={() => void handleSave()}
disabled={saving}
>
{saving ? "Saving…" : "Save Provider"}
{saving ? t("providers.saving", "Saving…") : t("providers.saveProvider", "Save Provider")}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { consumeVersionUpdateFlag } from "../versionCheck";
import { SWR_CACHE_KEYS, clearCache } from "../utils/swrCache";
import "./DashboardLoader.css";
@@ -16,9 +17,9 @@ interface LoaderStep {
}
const LOADER_STEPS: LoaderStep[] = [
{ id: "projects", label: "Loading projects" },
{ id: "project", label: "Selecting project" },
{ id: "tasks", label: "Fetching tasks" },
{ id: "projects", label: "dashboard.loaderSteps.projects" },
{ id: "project", label: "dashboard.loaderSteps.project" },
{ id: "tasks", label: "dashboard.loaderSteps.tasks" },
];
function getStepState(stepId: LoaderStep["id"], stage: DashboardLoaderStage): "done" | "active" | "pending" {
@@ -41,6 +42,7 @@ function getStepState(stepId: LoaderStep["id"], stage: DashboardLoaderStage): "d
}
export function DashboardLoader({ stage }: DashboardLoaderProps) {
const { t } = useTranslation("app");
const [isVersionUpdate] = useState(() => {
const versionUpdated = consumeVersionUpdateFlag();
if (versionUpdated) {
@@ -78,7 +80,7 @@ export function DashboardLoader({ stage }: DashboardLoaderProps) {
className="dashboard-loader"
role="status"
aria-live="polite"
aria-label={isVersionUpdate ? "Updating Fusion dashboard" : "Loading Fusion dashboard"}
aria-label={isVersionUpdate ? t("dashboard.updatingMessage", "Updating Fusion dashboard") : t("dashboard.loadingMessage", "Loading Fusion dashboard")}
data-stage={stage}
data-version-update={isVersionUpdate ? "true" : undefined}
>
@@ -86,13 +88,13 @@ export function DashboardLoader({ stage }: DashboardLoaderProps) {
<h1 className="dashboard-loader__logo">Fusion</h1>
{isVersionUpdate ? (
<p className="dashboard-loader__message dashboard-loader__message--update">
Updating to a new frontend version...
{t("dashboard.updatingVersion", "Updating to a new frontend version...")}
</p>
) : (
<p className="dashboard-loader__message">Initializing dashboard...</p>
<p className="dashboard-loader__message">{t("dashboard.initializingDashboard", "Initializing dashboard...")}</p>
)}
<ol className="dashboard-loader__steps" aria-label="Dashboard loading progress">
<ol className="dashboard-loader__steps" aria-label={t("dashboard.loadingProgress", "Dashboard loading progress")}>
{LOADER_STEPS.map((step) => {
const stepState = getStepState(step.id, stage);
@@ -111,7 +113,7 @@ export function DashboardLoader({ stage }: DashboardLoaderProps) {
"•"
)}
</span>
<span className="dashboard-loader__step-label">{step.label}</span>
<span className="dashboard-loader__step-label">{t(step.label, step.label === "dashboard.loaderSteps.projects" ? "Loading projects" : step.label === "dashboard.loaderSteps.project" ? "Selecting project" : "Fetching tasks")}</span>
</li>
);
})}

View File

@@ -1,5 +1,6 @@
import { AlertCircle } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { getErrorMessage } from "@fusion/core";
import { AgentEmptyState } from "./AgentEmptyState";
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
@@ -16,12 +17,13 @@ export interface DataBoundaryProps {
}
function DefaultErrorFallback({ error }: { error: unknown }) {
const { t } = useTranslation("app");
return (
<div className="agent-empty" data-testid="data-boundary-error">
<AlertCircle className="agent-empty-state__icon" size={48} opacity={0.3} />
<p className="agent-empty-state__title">Unable to load data</p>
<p className="agent-empty-state__title">{t("common.unableToLoadData", "Unable to load data")}</p>
<p className="agent-empty-state__description text-secondary">
{getErrorMessage(error) || "Something went wrong while loading this view."}
{getErrorMessage(error) || t("common.somethingWentWrong", "Something went wrong while loading this view.")}
</p>
</div>
);
@@ -37,6 +39,7 @@ export function DataBoundary({
errorFallback,
children,
}: DataBoundaryProps) {
const { t } = useTranslation("app");
if (error) {
return <>{errorFallback ?? <DefaultErrorFallback error={error} />}</>;
}
@@ -51,8 +54,8 @@ export function DataBoundary({
<>
{emptyFallback ?? (
<AgentEmptyState
title="No data available"
description="There is nothing to show yet."
title={t("common.noDataAvailable", "No data available")}
description={t("common.nothingToShow", "There is nothing to show yet.")}
ctaLabel=""
/>
)}

View File

@@ -1,4 +1,5 @@
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import "./DbCorruptionBanner.css";
@@ -17,6 +18,7 @@ export function DbCorruptionBanner({
refreshing,
refreshError,
}: DbCorruptionBannerProps) {
const { t } = useTranslation("app");
if (errors.length === 0) {
return null;
}
@@ -31,9 +33,9 @@ export function DbCorruptionBanner({
<span className="status-dot status-dot--error" aria-hidden="true" />
<AlertTriangle aria-hidden="true" />
<div className="db-corruption-banner__headline-copy">
<h2 className="db-corruption-banner__headline">Database corruption detected</h2>
<h2 className="db-corruption-banner__headline">{t("dbBanner.title", "Database corruption detected")}</h2>
{checkedAtLabel ? (
<p className="db-corruption-banner__meta">Last checked: {checkedAtLabel}</p>
<p className="db-corruption-banner__meta">{t("dbBanner.lastChecked", "Last checked: {{checkedAtLabel}}", { checkedAtLabel })}</p>
) : null}
</div>
</div>
@@ -46,12 +48,12 @@ export function DbCorruptionBanner({
disabled={refreshing}
>
<RefreshCw className={refreshing ? "db-corruption-banner__refresh-icon db-corruption-banner__refresh-icon--spinning" : "db-corruption-banner__refresh-icon"} aria-hidden="true" />
{refreshing ? "Refreshing…" : "Refresh health"}
{refreshing ? t("dbBanner.refreshing", "Refreshing…") : t("dbBanner.refreshHealth", "Refresh health")}
</button>
</div>
<p className="db-corruption-banner__body">
Fusion&apos;s background SQLite integrity check reported corruption. Review the failing objects below before continuing critical operations.
{t("dbBanner.body", "Fusion's background SQLite integrity check reported corruption. Review the failing objects below before continuing critical operations.")}
</p>
<ul className="db-corruption-banner__list">
@@ -63,10 +65,15 @@ export function DbCorruptionBanner({
</ul>
<p className="db-corruption-banner__footer">
<strong className="db-corruption-banner__footer-label">What to do:</strong>{" "}
Back up the project, try <code className="db-corruption-banner__inline-code">fn db --vacuum</code> if the database still opens cleanly, and restore from a known-good backup if corruption persists. See{" "}
<a href="docs/storage.md" target="_blank" rel="noreferrer" className="db-corruption-banner__link">docs/storage.md</a>
{" "}for the storage layout and recovery guidance.
<strong className="db-corruption-banner__footer-label">{t("dbBanner.whatToDo", "What to do:")}</strong>{" "}
<Trans
i18nKey="app:dbBanner.instructions"
defaults="Back up the project, try <cmd>fn db --vacuum</cmd> if the database still opens cleanly, and restore from a known-good backup if corruption persists. See <docsLink>docs/storage.md</docsLink> for the storage layout and recovery guidance."
components={{
cmd: <code />,
docsLink: <a href="docs/storage.md" />,
}}
/>
</p>
{refreshError ? <p className="db-corruption-banner__error">{refreshError}</p> : null}
</section>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, type PropsWithChildren } from "react";
import { useTranslation } from "react-i18next";
import type { ShellConnectionState } from "../types/native-shell";
import "./DesktopLaunchGate.css";
@@ -61,6 +62,7 @@ function applyServerBaseUrl(baseUrl: string): void {
}
export function DesktopLaunchGate({ children }: PropsWithChildren) {
const { t } = useTranslation("app");
const [phase, setPhase] = useState<Phase>({ kind: "loading" });
useEffect(() => {
@@ -95,7 +97,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
setPhase({ kind: "ready", serverBaseUrl: params.get("serverBaseUrl") ?? undefined });
return;
}
setPhase({ kind: "starting-local", message: "Starting local Fusion runtime…" });
setPhase({ kind: "starting-local", message: t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") });
const { baseUrl } = await waitForLocalRuntime(shell);
if (cancelled) return;
applyServerBaseUrl(baseUrl);
@@ -139,7 +141,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
}, []);
if (phase.kind === "loading" || phase.kind === "starting-local") {
const message = phase.kind === "loading" ? "Loading Fusion…" : phase.message;
const message = phase.kind === "loading" ? t("desktop.loading", "Loading Fusion…") : phase.message;
return (
<div className="desktop-launch-gate" role="status" aria-live="polite">
<div className="desktop-launch-gate__panel">
@@ -153,7 +155,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
return (
<div className="desktop-launch-gate" role="alert">
<div className="desktop-launch-gate__panel">
<h2>Couldn&apos;t start local Fusion</h2>
<h2>{t("desktop.couldNotStart", "Couldn't start local Fusion")}</h2>
<p>{phase.message}</p>
<button
type="button"
@@ -162,7 +164,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
window.location.reload();
}}
>
Retry
{t("desktop.retry", "Retry")}
</button>
</div>
</div>
@@ -177,7 +179,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
if (!shell) return;
setPhase({
kind: "starting-local",
message: mode === "local" ? "Starting local Fusion runtime…" : "Setting up remote connection…",
message: mode === "local" ? t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") : t("desktop.settingUpRemote", "Setting up remote connection…"),
});
try {
await shell.setDesktopMode(mode);
@@ -205,14 +207,14 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
}
function DesktopModeChooser({ onPick }: { onPick: (mode: "local" | "remote") => void }) {
const { t } = useTranslation("app");
const [pending, setPending] = useState<"local" | "remote" | null>(null);
return (
<div className="desktop-launch-gate" role="dialog" aria-labelledby="desktop-launch-gate-title">
<div className="desktop-launch-gate__panel">
<h1 id="desktop-launch-gate-title">How do you want to run Fusion?</h1>
<h1 id="desktop-launch-gate-title">{t("desktop.chooseMode", "How do you want to run Fusion?")}</h1>
<p>
Run Fusion locally in this app, or connect to a Fusion server you&apos;re already running
somewhere else.
{t("desktop.chooseModeDescription", "Run Fusion locally in this app, or connect to a Fusion server you're already running somewhere else.")}
</p>
<div className="desktop-launch-gate__actions">
<button
@@ -224,7 +226,7 @@ function DesktopModeChooser({ onPick }: { onPick: (mode: "local" | "remote") =>
onPick("local");
}}
>
{pending === "local" ? "Starting…" : "Run Fusion Locally"}
{pending === "local" ? t("desktop.starting", "Starting…") : t("desktop.runLocalButton", "Run Fusion Locally")}
</button>
<button
type="button"
@@ -235,7 +237,7 @@ function DesktopModeChooser({ onPick }: { onPick: (mode: "local" | "remote") =>
onPick("remote");
}}
>
{pending === "remote" ? "Opening…" : "Connect to Remote Fusion"}
{pending === "remote" ? t("desktop.opening", "Opening…") : t("desktop.connectRemoteButton", "Connect to Remote Fusion")}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Loader2, Maximize2, Minimize2, Search } from "lucide-react";
import "./DevServerLogViewer.css";
import type { DevServerLogEntry } from "../hooks/useDevServerLogs";
@@ -93,6 +94,7 @@ export function DevServerLogViewer({
onLoadMore,
isRunning,
}: DevServerLogViewerProps) {
const { t } = useTranslation("app");
const containerRef = useRef<HTMLDivElement | null>(null);
const prevEntryCountRef = useRef(entries.length);
const prevRunningRef = useRef(isRunning);
@@ -170,7 +172,7 @@ export function DevServerLogViewer({
<section className="devserver-log-viewer" data-testid="devserver-log-viewer">
<div className="devserver-log-viewer__loading" data-testid="devserver-log-loading">
<Loader2 size={16} className="devserver-log-viewer__spinner" />
<span>Loading logs…</span>
<span>{t("devserver.loadingLogs", "Loading logs…")}</span>
</div>
</section>
);
@@ -183,32 +185,32 @@ export function DevServerLogViewer({
>
<header className="devserver-log-viewer__toolbar">
<div className="devserver-log-viewer__toolbar-meta">
<span className="devserver-log-viewer__title">Logs</span>
<span className="devserver-log-viewer__title">{t("devserver.logs", "Logs")}</span>
<span className="devserver-log-viewer__count" data-testid="devserver-log-count">
{total !== null ? `${entries.length}/${total}` : `${entries.length}`} lines
{total !== null ? `${entries.length}/${total}` : `${entries.length}`} {t("devserver.lines", "lines")}
</span>
</div>
<div className="devserver-log-viewer__toolbar-actions">
<label className="devserver-log-viewer__severity" htmlFor="devserver-log-severity-filter">
<span className="visually-hidden">Filter logs by severity</span>
<span className="visually-hidden">{t("devserver.filterBySeverity", "Filter logs by severity")}</span>
<select
id="devserver-log-severity-filter"
className="select devserver-log-viewer__severity-select"
value={severityFilter}
onChange={(event) => setSeverityFilter(event.target.value as LogSeverityFilter)}
data-testid="devserver-log-severity-filter"
aria-label="Filter logs by severity"
aria-label={t("devserver.filterBySeverity", "Filter logs by severity")}
>
<option value="all">All severities</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
<option value="all">{t("devserver.allSeverities", "All severities")}</option>
<option value="info">{t("devserver.info", "Info")}</option>
<option value="warn">{t("devserver.warn", "Warn")}</option>
<option value="error">{t("devserver.error", "Error")}</option>
</select>
</label>
<label className="devserver-log-viewer__search" htmlFor="devserver-log-search">
<span className="visually-hidden">Search logs</span>
<span className="visually-hidden">{t("devserver.searchLogs", "Search logs")}</span>
<Search size={14} />
<input
id="devserver-log-search"
@@ -216,15 +218,15 @@ export function DevServerLogViewer({
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search logs"
placeholder={t("devserver.searchLogs", "Search logs")}
data-testid="devserver-log-search-input"
aria-label="Search logs"
aria-label={t("devserver.searchLogs", "Search logs")}
/>
</label>
{searchQuery.trim().length > 0 && (
<span className="devserver-log-viewer__matches" data-testid="devserver-log-match-count">
{matchCount} match{matchCount === 1 ? "" : "es"}
{t("devserver.matchCount", { count: matchCount, defaultValue_one: "{{count}} match", defaultValue_other: "{{count}} matches" })}
</span>
)}
@@ -233,7 +235,7 @@ export function DevServerLogViewer({
className="btn btn-sm btn-icon"
onClick={() => setIsFullscreen((prev) => !prev)}
data-testid="devserver-log-fullscreen-toggle"
aria-label={isFullscreen ? "Exit fullscreen logs" : "Enter fullscreen logs"}
aria-label={isFullscreen ? t("devserver.exitFullscreen", "Exit fullscreen logs") : t("devserver.enterFullscreen", "Enter fullscreen logs")}
>
{isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
@@ -253,10 +255,10 @@ export function DevServerLogViewer({
{loadingMore ? (
<>
<Loader2 size={14} className="devserver-log-viewer__spinner" />
Loading older logs…
{t("devserver.loadingOlderLogs", "Loading older logs…")}
</>
) : (
"Load older logs"
t("devserver.loadOlderLogs", "Load older logs")
)}
</button>
</div>
@@ -271,10 +273,10 @@ export function DevServerLogViewer({
{!loading && filteredEntries.length === 0 && (
<p className="devserver-log-viewer__empty" data-testid="devserver-log-empty">
{entries.length === 0
? "No logs yet. Start the dev server to see output."
? t("devserver.noLogsYet", "No logs yet. Start the dev server to see output.")
: (filteredBySeverity.length === 0
? "No log lines match the selected severity."
: "No log lines match your search.")}
? t("devserver.noMatchesSeverity", "No log lines match the selected severity.")
: t("devserver.noMatchesSearch", "No log lines match your search."))}
</p>
)}
@@ -288,7 +290,7 @@ export function DevServerLogViewer({
<span className="devserver-log-timestamp" data-testid="devserver-log-timestamp">{timestamp}</span>
)}
{entry.stream === "stderr" && (
<span className="devserver-log-stream-badge" data-testid="devserver-log-stderr-badge">ERR</span>
<span className="devserver-log-stream-badge" data-testid="devserver-log-stderr-badge">{t("devserver.errBadge", "ERR")}</span>
)}
<span className="devserver-log-text">{linkifyReactChildren(highlightText(plainText, searchQuery.trim()))}</span>
</div>
@@ -304,7 +306,7 @@ export function DevServerLogViewer({
data-testid="devserver-log-jump-button"
>
<ChevronDown size={14} />
New logs
{t("devserver.newLogs", "New logs")}
</button>
)}
</div>

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
import "./DevServerView.css";
import type { DetectedDevServerCommand } from "../api";
@@ -22,13 +24,15 @@ interface StatusBadgeConfig {
label: string;
}
const STATUS_BADGE_CONFIG: Record<"stopped" | "starting" | "running" | "failed" | "stopping", StatusBadgeConfig> = {
stopped: { className: "dev-server-status-badge--stopped", label: "Stopped" },
starting: { className: "dev-server-status-badge--starting", label: "Starting..." },
running: { className: "dev-server-status-badge--running", label: "Running" },
stopping: { className: "dev-server-status-badge--starting", label: "Stopping..." },
failed: { className: "dev-server-status-badge--failed", label: "Failed" },
};
function getStatusBadgeConfig(t: TFunction<"app">): Record<"stopped" | "starting" | "running" | "failed" | "stopping", StatusBadgeConfig> {
return {
stopped: { className: "dev-server-status-badge--stopped", label: t("devserver.status.stopped", "Stopped") },
starting: { className: "dev-server-status-badge--starting", label: t("devserver.status.starting", "Starting...") },
running: { className: "dev-server-status-badge--running", label: t("devserver.status.running", "Running") },
stopping: { className: "dev-server-status-badge--starting", label: t("devserver.status.stopping", "Stopping...") },
failed: { className: "dev-server-status-badge--failed", label: t("devserver.status.failed", "Failed") },
};
}
let devServerViewWasPreviouslyInactive = false;
@@ -82,6 +86,8 @@ function truncateCommand(command: string): string {
}
export function DevServerView({ addToast, projectId }: DevServerViewProps) {
const { t } = useTranslation("app");
useEffect(() => {
recordResumeEvent({
view: "DevServerView",
@@ -118,7 +124,8 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
const status = session?.status ?? "stopped";
const isRunning = status === "running" || status === "starting";
const statusBadge = STATUS_BADGE_CONFIG[status] ?? STATUS_BADGE_CONFIG.stopped;
const statusBadgeConfig = getStatusBadgeConfig(t);
const statusBadge = statusBadgeConfig[status] ?? statusBadgeConfig.stopped;
const {
entries: logEntries,
@@ -299,19 +306,19 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
setSelectedScript(candidate.scriptName);
setShowCandidates(false);
setCommandInput(candidate.command);
addToast(`Selected ${candidate.scriptName} script.`, "success");
addToast(t("devserver.toast.selectedScript", "Selected {{name}} script.", { name: candidate.scriptName }), "success");
}, [addToast]);
const handleClearSelection = useCallback(() => {
setSelectedScript(null);
setShowCandidates(true);
addToast("Cleared selected dev server script.", "success");
addToast(t("devserver.toast.clearedScript", "Cleared selected dev server script."), "success");
}, [addToast]);
const handleStart = () => {
const trimmedCommand = commandInput.trim();
if (trimmedCommand.length === 0) {
addToast("Enter a command before starting the dev server.", "warning");
addToast(t("devserver.toast.enterCommand", "Enter a command before starting the dev server."), "warning");
return;
}
@@ -321,16 +328,16 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
void runAction(
"start",
() => startServer(trimmedCommand, cwd),
"Dev server started.",
t("devserver.toast.started", "Dev server started."),
);
};
const handleStop = () => {
void runAction("stop", stopServer, "Dev server stopped.");
void runAction("stop", stopServer, t("devserver.toast.stopped", "Dev server stopped."));
};
const handleRestart = () => {
void runAction("restart", restartServer, "Dev server restarted.");
void runAction("restart", restartServer, t("devserver.toast.restarted", "Dev server restarted."));
};
const handleSetPreview = () => {
@@ -340,7 +347,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
void runAction(
"preview",
() => setPreviewUrl(nextUrl),
nextUrl ? "Preview URL updated." : "Preview URL override cleared.",
nextUrl ? t("devserver.toast.previewUpdated", "Preview URL updated.") : t("devserver.toast.previewCleared", "Preview URL override cleared."),
);
};
@@ -361,7 +368,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
<section className="dev-server-header" aria-label="Dev server controls header">
<div className="dev-server-header-title">
<Monitor size={16} />
<h2>Dev Server</h2>
<h2>{t("devserver.title", "Dev Server")}</h2>
<span
className={`dev-server-status-badge ${statusBadge.className}`}
data-testid="dev-server-status-badge"
@@ -378,7 +385,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
data-testid="dev-server-start-button"
>
<Play size={14} />
<span>{actionInFlight === "start" ? "Starting..." : "Start"}</span>
<span>{actionInFlight === "start" ? t("devserver.starting", "Starting...") : t("devserver.start", "Start")}</span>
</button>
<button
type="button"
@@ -388,7 +395,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
data-testid="dev-server-stop-button"
>
<Square size={14} />
<span>{actionInFlight === "stop" ? "Stopping..." : "Stop"}</span>
<span>{actionInFlight === "stop" ? t("devserver.stopping", "Stopping...") : t("devserver.stop", "Stop")}</span>
</button>
<button
type="button"
@@ -398,33 +405,33 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
data-testid="dev-server-restart-button"
>
<RotateCw size={14} />
<span>{actionInFlight === "restart" ? "Restarting..." : "Restart"}</span>
<span>{actionInFlight === "restart" ? t("devserver.restarting", "Restarting...") : t("devserver.restart", "Restart")}</span>
</button>
</div>
</section>
<section className="dev-server-panel dev-server-config" aria-label="Dev server configuration">
<div className="dev-server-section-header">
<h3>Configuration</h3>
{isLoading && <span className="dev-server-muted">Loading...</span>}
<h3>{t("devserver.configuration", "Configuration")}</h3>
{isLoading && <span className="dev-server-muted">{t("devserver.loading", "Loading...")}</span>}
</div>
{isLoading && !session && detectedCommands.length === 0 && (
<div className="dev-server-loading-state" data-testid="dev-server-loading-state">
<Loader2 size={16} className="dev-server-spin" />
<span>Loading dev server configuration...</span>
<span>{t("devserver.loadingConfig", "Loading dev server configuration...")}</span>
</div>
)}
{error && (
<div className="dev-server-error-box" role="alert" data-testid="dev-server-error-box">
<p>{error}</p>
<button type="button" className="btn btn-sm" onClick={handleRetry}>Retry</button>
<button type="button" className="btn btn-sm" onClick={handleRetry}>{t("devserver.retry", "Retry")}</button>
</div>
)}
<div className="dev-server-section">
<h3>Script Selection</h3>
<h3>{t("devserver.scriptSelection", "Script Selection")}</h3>
{selectedScript && (
<div className="dev-server-selected" data-testid="dev-server-selected-summary">
@@ -436,7 +443,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
onClick={() => setShowCandidates(true)}
data-testid="dev-server-change-selection"
>
Change
{t("devserver.change", "Change")}
</button>
<button
type="button"
@@ -444,14 +451,14 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
onClick={handleClearSelection}
data-testid="dev-server-clear-selection"
>
Clear
{t("devserver.clear", "Clear")}
</button>
</div>
)}
{showCandidates && detectedCommands.length === 0 && (
<p className="dev-server-empty-state" data-testid="dev-server-empty-candidates">
No dev server scripts detected. Check that your project has a <code>package.json</code> with a <code>dev</code>, <code>start</code>, or similar script.
{t("devserver.noScriptsDetected", "No dev server scripts detected. Check that your project has a package.json with a dev, start, or similar script.")}
</p>
)}
@@ -478,7 +485,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
</div>
<div className="dev-server-field-group">
<label htmlFor="dev-server-command" className="dev-server-label">Command</label>
<label htmlFor="dev-server-command" className="dev-server-label">{t("devserver.command", "Command")}</label>
<input
id="dev-server-command"
className="input"
@@ -492,13 +499,13 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
{(status === "running" || status === "starting") && session && (
<div className="dev-server-current-command" data-testid="dev-server-current-command">
<span className="dev-server-label">Running command</span>
<span className="dev-server-label">{t("devserver.runningCommand", "Running command")}</span>
<code>{session.config?.command ?? commandInput}</code>
</div>
)}
<div className="dev-server-preview-override">
<label htmlFor="dev-server-preview-input" className="dev-server-label">Preview URL Override</label>
<label htmlFor="dev-server-preview-input" className="dev-server-label">{t("devserver.previewUrlOverride", "Preview URL Override")}</label>
<input
id="dev-server-preview-input"
className="input"
@@ -515,20 +522,20 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
disabled={actionInFlight === "preview"}
data-testid="dev-server-set-preview"
>
Save
{t("devserver.save", "Save")}
</button>
</div>
{effectivePreviewUrl && (
<p className="dev-server-preview-hint">Auto-detected: {effectivePreviewUrl}</p>
<p className="dev-server-preview-hint">{t("devserver.autoDetected", "Auto-detected: {{url}}", { url: effectivePreviewUrl })}</p>
)}
</section>
<div className="dev-server-content">
<section className="dev-server-panel dev-server-logs-panel" data-testid="dev-server-logs-panel" aria-label="Dev server logs">
<div className="dev-server-section-header">
<h3>Logs</h3>
<span className="dev-server-muted">{logsTotal ?? logEntries.length} lines</span>
<h3>{t("devserver.logs", "Logs")}</h3>
<span className="dev-server-muted">{t("devserver.lines", "{{count}} lines", { count: logsTotal ?? logEntries.length })}</span>
</div>
<div className="dev-server-logs-viewer" data-testid="dev-server-log-viewer">
<DevServerLogViewer
@@ -548,15 +555,15 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
<div className="devserver-preview-header">
<div className="devserver-preview-title">
<Eye size={14} />
<span>Preview</span>
<span>{t("devserver.preview", "Preview")}</span>
</div>
<span
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
title={effectivePreviewUrl ?? "No preview URL"}
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
data-testid="devserver-preview-url-badge"
>
{isManualPreviewOverride ? "Manual" : "Auto"}
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : " · Not available"}
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")}
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")}
</span>
<div className="devserver-preview-actions">
<button
@@ -565,12 +572,12 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))}
data-testid="devserver-preview-mode-toggle"
>
{previewMode === "embedded" ? "External only" : "Embedded"}
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
</button>
<button
type="button"
className="btn btn-sm btn-icon"
title="Open in new tab"
title={t("devserver.openInNewTab", "Open in new tab")}
onClick={handleOpenInNewTab}
disabled={!effectivePreviewUrl}
data-testid="devserver-preview-open-tab"
@@ -580,7 +587,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
<button
type="button"
className="btn btn-sm btn-icon"
title="Refresh preview"
title={t("devserver.refreshPreview", "Refresh preview")}
onClick={handleRefreshPreview}
disabled={!effectivePreviewUrl}
data-testid="devserver-preview-refresh"
@@ -592,23 +599,23 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}>
{!effectivePreviewUrl && !isRunning && (
<p className="devserver-preview-empty">Start a dev server to see a live preview here.</p>
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p>
)}
{!effectivePreviewUrl && isRunning && (
<p className="devserver-preview-empty">No preview URL detected. Start the dev server or set a manual URL to preview your app.</p>
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p>
)}
{effectivePreviewUrl && previewMode === "external" && (
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only">
<p>Embedded preview is disabled. Open your app in a separate browser tab.</p>
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p>
<button
type="button"
className="btn btn-primary btn-sm touch-target"
onClick={handleOpenInNewTab}
data-testid="devserver-preview-external-open-tab"
>
Open in new tab
{t("devserver.openInNewTab", "Open in new tab")}
</button>
</div>
)}
@@ -624,12 +631,12 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
<div>
<p className="devserver-preview-blocked-title">
{embedStatus === "error" ? "Preview failed" : "Preview blocked"}
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
</p>
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
</div>
<p className="devserver-preview-blocked-description">
Open the preview in a new tab, or retry embedded mode after checking your server settings.
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
</p>
<div className="devserver-preview-blocked-actions">
<button
@@ -638,7 +645,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
onClick={handleOpenInNewTab}
data-testid="devserver-preview-fallback-open-tab"
>
Open preview in new tab
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
</button>
<button
type="button"
@@ -646,7 +653,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
onClick={handleRetryEmbeddedPreview}
data-testid="devserver-preview-fallback-retry"
>
Retry embedded preview
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle } from "lucide-react";
import { browseDirectory, type BrowseDirectoryResult } from "../api";
import { getPathBreadcrumbs } from "../utils/pathDisplay";
@@ -27,6 +28,7 @@ interface BrowserState {
}
export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, nodeId, localNodeId }: DirectoryPickerProps) {
const { t } = useTranslation("app");
const [browser, setBrowser] = useState<BrowserState>({
isOpen: false,
loading: false,
@@ -111,21 +113,21 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onInputKeyDown}
placeholder={placeholder || "/path/to/your/project"}
placeholder={placeholder || t("dirPicker.defaultPlaceholder", "/path/to/your/project")}
/>
<button
type="button"
className="btn btn-secondary btn-sm directory-picker-browse-btn"
onClick={handleToggleBrowser}
aria-label={browser.isOpen ? "Close directory browser" : "Browse directories"}
aria-label={browser.isOpen ? t("dirPicker.closeBrowser", "Close directory browser") : t("dirPicker.openBrowser", "Browse directories")}
>
{browser.isOpen ? <FolderOpen size={16} /> : <Folder size={16} />}
<span>Browse</span>
<span>{t("dirPicker.browse", "Browse")}</span>
</button>
</div>
{browser.isOpen && (
<div className="directory-picker-browser" role="tree" aria-label="Directory browser">
<div className="directory-picker-browser" role="tree" aria-label={t("dirPicker.ariaLabel", "Directory browser")}>
{/* Breadcrumbs */}
<div className="directory-picker-breadcrumbs">
{breadcrumbs.map((breadcrumb, index) => {
@@ -152,22 +154,22 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
type="button"
className="btn btn-sm btn-secondary directory-picker-up-btn"
onClick={() => handleNavigate(browser.parentPath!)}
aria-label="Go to parent directory"
title="Parent directory"
aria-label={t("dirPicker.parentDir", "Go to parent directory")}
title={t("dirPicker.parentDirTitle", "Parent directory")}
>
<ChevronUp size={14} />
<span>Up</span>
<span>{t("dirPicker.up", "Up")}</span>
</button>
)}
<button
type="button"
className="btn btn-sm btn-secondary directory-picker-hidden-toggle"
onClick={handleToggleHidden}
aria-label={browser.showHidden ? "Hide hidden directories" : "Show hidden directories"}
title={browser.showHidden ? "Hide hidden" : "Show hidden"}
aria-label={browser.showHidden ? t("dirPicker.hideHiddenAria", "Hide hidden directories") : t("dirPicker.showHiddenAria", "Show hidden directories")}
title={browser.showHidden ? t("dirPicker.hideHiddenTitle", "Hide hidden") : t("dirPicker.showHiddenTitle", "Show hidden")}
>
{browser.showHidden ? <EyeOff size={14} /> : <Eye size={14} />}
<span>{browser.showHidden ? "Hide hidden" : "Show hidden"}</span>
<span>{browser.showHidden ? t("dirPicker.hideHidden", "Hide hidden") : t("dirPicker.showHidden", "Show hidden")}</span>
</button>
</div>
@@ -175,7 +177,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
{browser.loading ? (
<div className="directory-picker-loading">
<Loader2 size={20} className="animate-spin" />
<span>Loading…</span>
<span>{t("dirPicker.loading", "Loading…")}</span>
</div>
) : browser.error ? (
<div className="directory-picker-error">
@@ -185,7 +187,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
) : (
<div className="directory-picker-entries">
{browser.entries.length === 0 ? (
<div className="directory-picker-empty">No subdirectories</div>
<div className="directory-picker-empty">{t("dirPicker.noSubdirs", "No subdirectories")}</div>
) : (
browser.entries.map((entry) => (
<button
@@ -217,7 +219,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
className="btn btn-primary directory-picker-select-btn"
onClick={handleSelect}
>
Select
{t("dirPicker.select", "Select")}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { ChevronDown, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { DockerHostConfig, ManagedDockerNodeInput } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { DockerTargetSelector } from "./DockerTargetSelector";
@@ -33,6 +34,7 @@ interface MountRow {
const DEFAULT_URL = "http://localhost:4040";
export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast: _addToast }: DockerNodeOnboardingModalProps) {
const { t } = useTranslation("app");
const [name, setName] = useState("");
const [hostConfig, setHostConfig] = useState<DockerHostConfig>({});
const [reachableUrl, setReachableUrl] = useState(DEFAULT_URL);
@@ -174,16 +176,16 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
const nextErrors: FormErrors = {};
if (!input.name || input.name.length > 64) {
nextErrors.name = "Name is required and must be 64 characters or fewer";
nextErrors.name = t("docker.errors.nameRequired", "Name is required and must be 64 characters or fewer");
}
if (!input.reachableUrl) {
nextErrors.reachableUrl = "URL is required";
nextErrors.reachableUrl = t("docker.errors.urlRequired", "URL is required");
}
if (memoryMB < 512) {
nextErrors.memoryMB = "Memory must be at least 512 MB";
nextErrors.memoryMB = t("docker.errors.memoryMinimum", "Memory must be at least 512 MB");
}
if (cpus < 0.5) {
nextErrors.cpus = "CPUs must be at least 0.5";
nextErrors.cpus = t("docker.errors.cpusMinimum", "CPUs must be at least 0.5");
}
setErrors(nextErrors);
@@ -200,7 +202,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
} finally {
setSubmitting(false);
}
}, [closeModal, cpus, input, memoryMB, onSubmit, submitting]);
}, [closeModal, cpus, input, memoryMB, onSubmit, submitting, t]);
if (!isOpen) return null;
@@ -210,28 +212,28 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
className="modal docker-onboarding"
role="dialog"
aria-modal="true"
aria-label="Docker node onboarding"
aria-label={t("docker.ariaLabels.modal", "Docker node onboarding")}
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<h3>Provision Docker Node</h3>
<button className="modal-close" onClick={closeModal} disabled={submitting} aria-label="Close onboarding modal">
<h3>{t("docker.titles.provisionNode", "Provision Docker Node")}</h3>
<button className="modal-close" onClick={closeModal} disabled={submitting} aria-label={t("docker.ariaLabels.closeModal", "Close onboarding modal")}>
&times;
</button>
</div>
<div className="modal-body docker-onboarding__body">
<section className="docker-onboarding__section">
<h4 className="docker-onboarding__section-title">Required Settings</h4>
<h4 className="docker-onboarding__section-title">{t("docker.sections.requiredSettings", "Required Settings")}</h4>
<label className="docker-onboarding__field">
<span>Node Name</span>
<span>{t("docker.labels.nodeName", "Node Name")}</span>
<input
className="input"
value={name}
onChange={(event) => setName(event.target.value)}
disabled={submitting}
placeholder="my-docker-node"
placeholder={t("docker.placeholders.nodeName", "my-docker-node")}
autoFocus
/>
</label>
@@ -240,7 +242,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
<DockerTargetSelector value={hostConfig} onChange={setHostConfig} />
<label className="docker-onboarding__field">
<span>Reachable URL</span>
<span>{t("docker.labels.reachableUrl", "Reachable URL")}</span>
<input
className="input"
value={reachableUrl}
@@ -260,7 +262,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
onChange={() => setApiKeyMode("auto")}
disabled={submitting}
/>
Auto-generate
{t("docker.options.autoGenerate", "Auto-generate")}
</label>
<label className="checkbox-label">
<input
@@ -269,20 +271,20 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
onChange={() => setApiKeyMode("manual")}
disabled={submitting}
/>
Provide manually
{t("docker.options.provideManually", "Provide manually")}
</label>
</div>
{apiKeyMode === "manual" && (
<label className="docker-onboarding__field">
<span>API Key</span>
<span>{t("docker.labels.apiKey", "API Key")}</span>
<input
className="input"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
disabled={submitting}
placeholder="Enter API key"
placeholder={t("docker.placeholders.apiKey", "Enter API key")}
/>
</label>
)}
@@ -295,7 +297,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
onChange={(event) => setIncludeClaudeCli(event.target.checked)}
disabled={submitting}
/>
Claude CLI
{t("docker.options.claudeCli", "Claude CLI")}
</label>
<label className="checkbox-label">
<input
@@ -304,7 +306,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
onChange={(event) => setIncludeDroidCli(event.target.checked)}
disabled={submitting}
/>
Droid CLI
{t("docker.options.droidCli", "Droid CLI")}
</label>
<label className="checkbox-label">
<input
@@ -313,13 +315,13 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
onChange={(event) => setPersistentStorage(event.target.checked)}
disabled={submitting}
/>
Keep data across container recreations
{t("docker.options.persistentStorage", "Keep data across container recreations")}
</label>
</div>
<div className="docker-onboarding__inline-fields">
<label className="docker-onboarding__field">
<span>Memory (MB)</span>
<span>{t("docker.labels.memory", "Memory (MB)")}</span>
<input
className="input"
type="number"
@@ -330,7 +332,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
/>
</label>
<label className="docker-onboarding__field">
<span>CPUs</span>
<span>{t("docker.labels.cpus", "CPUs")}</span>
<input
className="input"
type="number"
@@ -353,7 +355,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
onClick={() => setShowAdvanced((value) => !value)}
disabled={submitting}
>
<span>Advanced</span>
<span>{t("docker.sections.advanced", "Advanced")}</span>
<ChevronDown />
</button>
@@ -361,35 +363,35 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
<div>
<div className="docker-onboarding__inline-fields">
<label className="docker-onboarding__field">
<span>Image</span>
<span>{t("docker.labels.image", "Image")}</span>
<input
className="input"
value={imageName}
onChange={(event) => setImageName(event.target.value)}
disabled={submitting}
placeholder="runfusion/fusion"
placeholder={t("docker.placeholders.image", "runfusion/fusion")}
/>
</label>
<label className="docker-onboarding__field">
<span>Tag</span>
<span>{t("docker.labels.tag", "Tag")}</span>
<input
className="input"
value={imageTag}
onChange={(event) => setImageTag(event.target.value)}
disabled={submitting}
placeholder="latest"
placeholder={t("docker.placeholders.tag", "latest")}
/>
</label>
</div>
<div className="docker-onboarding__kv-list">
<h5>Environment Variables</h5>
<h5>{t("docker.sections.environmentVariables", "Environment Variables")}</h5>
{envRows.map((row, index) => (
<div key={`env-${index}`} className="docker-onboarding__kv-row docker-onboarding__kv-row--env">
<input
className="input"
placeholder="KEY"
placeholder={t("docker.placeholders.envVarKey", "KEY")}
value={row.key}
disabled={submitting}
onChange={(event) =>
@@ -401,7 +403,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
/>
<input
className="input"
placeholder="Value"
placeholder={t("docker.placeholders.envVarValue", "Value")}
value={row.value}
disabled={submitting}
onChange={(event) =>
@@ -414,7 +416,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
<button
type="button"
className="btn btn-icon"
aria-label="Remove environment variable"
aria-label={t("docker.ariaLabels.removeVariable", "Remove environment variable")}
onClick={() => removeEnvRow(index)}
disabled={submitting}
>
@@ -429,17 +431,17 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
disabled={submitting}
>
<Plus size={14} />
Add variable
{t("docker.actions.addVariable", "Add variable")}
</button>
</div>
<div className="docker-onboarding__kv-list">
<h5>Volume Mounts</h5>
<h5>{t("docker.sections.volumeMounts", "Volume Mounts")}</h5>
{mountRows.map((row, index) => (
<div key={`mount-${index}`} className="docker-onboarding__kv-row docker-onboarding__kv-row--mount">
<input
className="input"
placeholder="Host path"
placeholder={t("docker.placeholders.hostPath", "Host path")}
value={row.hostPath}
disabled={submitting}
onChange={(event) =>
@@ -452,7 +454,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
/>
<input
className="input"
placeholder="Container path"
placeholder={t("docker.placeholders.containerPath", "Container path")}
value={row.containerPath}
disabled={submitting}
onChange={(event) =>
@@ -481,7 +483,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
<button
type="button"
className="btn btn-icon"
aria-label="Remove volume mount"
aria-label={t("docker.ariaLabels.removeMount", "Remove volume mount")}
onClick={() => removeMountRow(index)}
disabled={submitting}
>
@@ -496,7 +498,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
disabled={submitting}
>
<Plus size={14} />
Add mount
{t("docker.actions.addMount", "Add mount")}
</button>
</div>
</div>
@@ -506,10 +508,10 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
<div className="modal-actions">
<button className="btn" onClick={closeModal} disabled={submitting}>
Cancel
{t("docker.actions.cancel", "Cancel")}
</button>
<button className="btn btn-primary" onClick={() => void handleSubmit()} disabled={submitting}>
{submitting ? "Creating..." : "Create Docker Node"}
{submitting ? t("docker.states.creating", "Creating...") : t("docker.actions.createNode", "Create Docker Node")}
</button>
</div>
</div>

View File

@@ -1,13 +1,14 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle, AlertCircle, ExternalLink, RefreshCw, Terminal } from "lucide-react";
import type { DockerProvisionResult } from "@fusion/core";
import "./DockerProvisioningStatus.css";
const STAGES = [
"Pulling image...",
"Creating container...",
"Starting container...",
"Registering node...",
const STAGE_KEYS = [
"docker.stage.pullingImage",
"docker.stage.creatingContainer",
"docker.stage.startingContainer",
"docker.stage.registeringNode",
];
const STAGE_INTERVAL_MS = 2000;
@@ -27,7 +28,14 @@ export function DockerProvisioningStatus({
onRetry,
onViewNode,
}: DockerProvisioningStatusProps) {
const { t } = useTranslation("app");
const [stageIndex, setStageIndex] = useState(0);
const STAGES = [
t("docker.stage.pullingImage", "Pulling image..."),
t("docker.stage.creatingContainer", "Creating container..."),
t("docker.stage.startingContainer", "Starting container..."),
t("docker.stage.registeringNode", "Registering node..."),
];
// Animate stages during provisioning
useEffect(() => {
@@ -37,7 +45,7 @@ export function DockerProvisioningStatus({
setStageIndex(0);
const timer = setInterval(() => {
setStageIndex((prev) => (prev < STAGES.length - 1 ? prev + 1 : prev));
setStageIndex((prev) => (prev < STAGE_KEYS.length - 1 ? prev + 1 : prev));
}, STAGE_INTERVAL_MS);
return () => clearInterval(timer);
@@ -52,7 +60,7 @@ export function DockerProvisioningStatus({
<span className="provisioning-status__dot" />
<span className="provisioning-status__dot" />
</div>
<div className="provisioning-status__text">Creating Docker node...</div>
<div className="provisioning-status__text">{t("docker.creatingNode", "Creating Docker node...")}</div>
<div className="provisioning-status__stage">{STAGES[stageIndex]}</div>
</div>
);
@@ -67,15 +75,15 @@ export function DockerProvisioningStatus({
<div className="provisioning-status__icon provisioning-status__icon--success">
<CheckCircle size={24} />
</div>
<div className="provisioning-status__text">Node created successfully!</div>
<div className="provisioning-status__text">{t("docker.successMessage", "Node created successfully!")}</div>
{result.containerId && (
<div className="provisioning-status__detail">
Container: <code>{result.containerId.slice(0, 12)}</code>
{t("docker.container", "Container:")} <code>{result.containerId.slice(0, 12)}</code>
</div>
)}
{durationSec && (
<div className="provisioning-status__detail">
Provisioned in {durationSec}s
{t("docker.provisionedIn", "Provisioned in {{durationSec}}s", { durationSec })}
</div>
)}
{result.nodeId && onViewNode && (
@@ -84,7 +92,7 @@ export function DockerProvisioningStatus({
onClick={() => onViewNode(result.nodeId!)}
>
<ExternalLink size={14} />
View Node
{t("docker.viewNode", "View Node")}
</button>
)}
</div>
@@ -101,11 +109,11 @@ export function DockerProvisioningStatus({
<AlertCircle size={24} />
</div>
<div className="provisioning-status__text">
{displayError ?? "Provisioning failed"}
{displayError ?? t("docker.failedMessage", "Provisioning failed")}
</div>
{failedStage && (
<div className="provisioning-status__detail">
Failed at: {failedStage}
{t("docker.failedAt", "Failed at: {{stage}}", { stage: failedStage })}
</div>
)}
{result?.containerName && (
@@ -117,7 +125,7 @@ export function DockerProvisioningStatus({
{onRetry && (
<button className="btn btn-sm" onClick={onRetry}>
<RefreshCw size={14} />
Retry
{t("docker.retry", "Retry")}
</button>
)}
</div>

View File

@@ -1,5 +1,6 @@
import { RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { DockerHostConfig } from "@fusion/core";
import { useDockerTargets } from "../hooks/useDockerTargets";
import { DockerTlsConfig } from "./DockerTlsConfig";
@@ -14,6 +15,7 @@ interface DockerTargetSelectorProps {
}
export function DockerTargetSelector({ value, onChange, onError }: DockerTargetSelectorProps) {
const { t } = useTranslation("app");
const initialMode: TargetMode = value?.context ? "context" : value?.host ? "host" : "local";
const [mode, setMode] = useState<TargetMode>(initialMode);
const [selectedContext, setSelectedContext] = useState(value?.context ?? "");
@@ -56,19 +58,27 @@ export function DockerTargetSelector({ value, onChange, onError }: DockerTargetS
return (
<div className="docker-target-selector">
<div className="docker-target-selector__modes" role="group" aria-label="Docker target mode">
<div className="docker-target-selector__modes" role="group" aria-label={t("docker.targetMode", "Docker target mode")}>
<button type="button" className={`btn btn-sm ${mode === "local" ? "docker-target-selector__mode-active" : ""}`} onClick={() => {
setMode("local");
void checkLocalDocker()
.then((result) => setLocalStatus(result.available ? `Docker is available${result.version ? ` (${result.version})` : ""}` : `Docker not found${result.error ? `: ${result.error}` : ""}`))
.then((result) => {
if (result.available) {
const version = result.version ? ` (${result.version})` : "";
setLocalStatus(t("docker.available", "Docker is available{{version}}", { version }));
} else {
const error = result.error ? `: ${result.error}` : "";
setLocalStatus(t("docker.notFound", "Docker not found{{error}}", { error }));
}
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setLocalStatus(`Docker not found: ${message}`);
setLocalStatus(t("docker.notFoundError", "Docker not found: {{message}}", { message }));
onError?.(message);
});
}}>Local Docker</button>
<button type="button" className={`btn btn-sm ${mode === "context" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("context")}>Docker Context</button>
<button type="button" className={`btn btn-sm ${mode === "host" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("host")}>Remote Host</button>
}}>{t("docker.local", "Local Docker")}</button>
<button type="button" className={`btn btn-sm ${mode === "context" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("context")}>{t("docker.context", "Docker Context")}</button>
<button type="button" className={`btn btn-sm ${mode === "host" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("host")}>{t("docker.remote", "Remote Host")}</button>
</div>
{mode === "local" && localStatus && <div className="docker-target-selector__status">{localStatus}</div>}
@@ -81,12 +91,12 @@ export function DockerTargetSelector({ value, onChange, onError }: DockerTargetS
setSelectedContext(next);
onChange(next ? { context: next } : {});
}}>
<option value="">Select context</option>
<option value="">{t("docker.selectContext", "Select context")}</option>
{contexts.map((context) => (
<option key={context.name} value={context.name}>{context.name}{context.isCurrentContext ? " (current)" : ""}{context.dockerHost ? ` — ${context.dockerHost}` : ""}</option>
))}
</select>
<button type="button" className="btn btn-sm btn-icon" onClick={() => void loadContexts()} disabled={isLoadingContexts} aria-label="Refresh contexts"><RefreshCw size={14} /></button>
<button type="button" className="btn btn-sm btn-icon" onClick={() => void loadContexts()} disabled={isLoadingContexts} aria-label={t("docker.refreshContexts", "Refresh contexts")}><RefreshCw size={14} /></button>
</div>
{contextsError && <div className="docker-target-selector__error">{contextsError}</div>}
</div>
@@ -95,11 +105,11 @@ export function DockerTargetSelector({ value, onChange, onError }: DockerTargetS
{mode === "host" && (
<div className="docker-target-selector__panel">
<div className="docker-target-selector__field">
<label htmlFor="docker-target-selector-host">Docker Host</label>
<label htmlFor="docker-target-selector-host">{t("docker.host", "Docker Host")}</label>
<input
id="docker-target-selector-host"
className="input"
placeholder="tcp://host:2376"
placeholder={t("docker.hostPlaceholder", "tcp://host:2376")}
value={host}
onChange={(event) => {
const next = event.target.value;
@@ -113,14 +123,14 @@ export function DockerTargetSelector({ value, onChange, onError }: DockerTargetS
)}
<button type="button" className="btn btn-sm" onClick={() => void testConnection(mode === "local" ? undefined : mode === "context" ? { context: selectedContext } : { host, ...tlsValue })} disabled={isTestingConnection || isCheckingLocal}>
{isTestingConnection ? "Testing..." : "Test Connection"}
{isTestingConnection ? t("docker.testing", "Testing...") : t("docker.testConnection", "Test Connection")}
</button>
{lastTestResult && (
<div className={lastTestResult.success ? "docker-target-selector__success" : "docker-target-selector__error"}>
{lastTestResult.success
? `Connected${lastTestResult.dockerVersion ? ` (Docker ${lastTestResult.dockerVersion})` : ""}`
: lastTestResult.error ?? "Connection failed"}
? t("docker.connected", "Connected{{version}}", { version: lastTestResult.dockerVersion ? ` (Docker ${lastTestResult.dockerVersion})` : "" })
: lastTestResult.error ?? t("docker.connectionFailed", "Connection failed")}
</div>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { DockerHostConfig } from "@fusion/core";
import "./DockerTlsConfig.css";
@@ -10,6 +11,7 @@ interface DockerTlsConfigProps {
}
export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
const { t } = useTranslation("app");
const [enabled, setEnabled] = useState(Boolean(value?.tlsCaPath || value?.tlsCertPath || value?.tlsKeyPath || value?.tlsVerify));
useEffect(() => {
@@ -24,12 +26,12 @@ export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
<div className="docker-tls-config">
<label className="checkbox-label">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
Use TLS
{t("docker.useTls", "Use TLS")}
</label>
{enabled && (
<div className="docker-tls-config__fields">
<div className="docker-tls-config__field">
<label htmlFor="docker-tls-ca-path">CA Certificate Path</label>
<label htmlFor="docker-tls-ca-path">{t("docker.caPath", "CA Certificate Path")}</label>
<input
id="docker-tls-ca-path"
className="input"
@@ -39,7 +41,7 @@ export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
/>
</div>
<div className="docker-tls-config__field">
<label htmlFor="docker-tls-cert-path">Client Certificate Path</label>
<label htmlFor="docker-tls-cert-path">{t("docker.certPath", "Client Certificate Path")}</label>
<input
id="docker-tls-cert-path"
className="input"
@@ -49,7 +51,7 @@ export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
/>
</div>
<div className="docker-tls-config__field">
<label htmlFor="docker-tls-key-path">Client Key Path</label>
<label htmlFor="docker-tls-key-path">{t("docker.keyPath", "Client Key Path")}</label>
<input
id="docker-tls-key-path"
className="input"
@@ -60,7 +62,7 @@ export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
</div>
<label className="checkbox-label">
<input type="checkbox" checked={tls.tlsVerify} onChange={(event) => onChange({ ...tls, tlsVerify: event.target.checked })} />
Verify TLS Certificate
{t("docker.verifyTls", "Verify TLS Certificate")}
</label>
</div>
)}

View File

@@ -1,5 +1,6 @@
import "./DocumentsView.css";
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -57,6 +58,7 @@ function getContentPreview(content: string, maxLength: number = 200): string {
}
function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCardProps) {
const { t } = useTranslation("app");
const [expanded, setExpanded] = useState(false);
const preview = getContentPreview(document.content);
@@ -74,8 +76,8 @@ function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCa
<button
className="btn btn-sm document-card-expand-btn"
onClick={() => setExpanded((current) => !current)}
title={expanded ? "Collapse" : "Expand"}
aria-label={expanded ? "Collapse content" : "Expand content"}
title={expanded ? t("documents.collapse", "Collapse") : t("documents.expand", "Expand")}
aria-label={expanded ? t("documents.collapseContent", "Collapse content") : t("documents.expandContent", "Expand content")}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
@@ -95,11 +97,11 @@ function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCa
<button
className="btn btn-sm document-mode-toggle"
onClick={onToggleMarkdown}
aria-label={renderMarkdown ? "Switch to plain text" : "Switch to markdown"}
aria-label={renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
aria-pressed={renderMarkdown}
title={renderMarkdown ? "Switch to plain text" : "Switch to markdown"}
title={renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
>
{renderMarkdown ? "Markdown" : "Plain"}
{renderMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
</button>
</div>
{renderMarkdown ? (
@@ -124,6 +126,7 @@ function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCa
}
function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownStates, onToggleMarkdown }: TaskGroupProps) {
const { t } = useTranslation("app");
const [expanded, setExpanded] = useState(false);
return (
@@ -133,23 +136,23 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
className="documents-group-toggle-btn"
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} documents for task ${taskId}`}
aria-label={`${expanded ? t("documents.collapse", "Collapse") : t("documents.expand", "Expand")} documents for task ${taskId}`}
>
<span className="documents-group-toggle" aria-hidden="true">
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
<span className="documents-group-task-id">{taskId}</span>
<span className="documents-group-task-title">{taskTitle || "Untitled"}</span>
<span className="documents-group-task-title">{taskTitle || t("documents.untitled", "Untitled")}</span>
</button>
<span className="documents-group-count">{documents.length} doc{documents.length !== 1 ? "s" : ""}</span>
<span className="documents-group-count">{t("documents.docCount", "{{count}} doc{{plural}}", { count: documents.length, plural: documents.length !== 1 ? "s" : "" })}</span>
<button
className="documents-group-task-link"
onClick={() => onOpenTask(taskId)}
aria-label={`Open task ${taskId}: ${taskTitle || "Untitled"}`}
aria-label={`Open task ${taskId}: ${taskTitle || t("documents.untitled", "Untitled")}`}
>
Open task
{t("documents.openTask", "Open task")}
</button>
</div>
@@ -170,6 +173,7 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
}
export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsViewProps) {
const { t } = useTranslation("app");
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
const [searchQuery, setSearchQuery] = useState("");
const [selectedFile, setSelectedFile] = useState<MarkdownFileEntry | null>(null);
@@ -372,8 +376,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
const activeCount = activeTab === "project" ? filteredProjectFiles.length : documents.length;
const searchPlaceholder = activeTab === "project"
? "Search project markdown files…"
: "Search task documents…";
? t("documents.searchProjectFiles", "Search project markdown files…")
: t("documents.searchTaskDocuments", "Search task documents…");
return (
<div className="documents-view">
@@ -381,10 +385,10 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
<div className="documents-view-title-row">
<h2 className="documents-view-title">
<FileText size={20} />
Documents
{t("documents.title", "Documents")}
</h2>
<span className="documents-view-count">
{activeCount} result{activeCount !== 1 ? "s" : ""}
{t("documents.resultCount", "{{count}} result{{plural}}", { count: activeCount, plural: activeCount !== 1 ? "s" : "" })}
</span>
</div>
@@ -394,20 +398,20 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
className={`btn documents-tab${activeTab === "project" ? " active" : ""}`}
role="tab"
aria-selected={activeTab === "project"}
aria-label="Show project markdown files"
aria-label={t("documents.showProjectFiles", "Show project markdown files")}
onClick={() => handleTabChange("project")}
>
Project Files
{t("documents.projectFilesTab", "Project Files")}
<span className="documents-tab-count">{projectFiles.length}</span>
</button>
<button
className={`btn documents-tab${activeTab === "tasks" ? " active" : ""}`}
role="tab"
aria-selected={activeTab === "tasks"}
aria-label="Show task documents"
aria-label={t("documents.showTaskDocuments", "Show task documents")}
onClick={() => handleTabChange("tasks")}
>
Task Documents
{t("documents.taskDocumentsTab", "Task Documents")}
<span className="documents-tab-count">{groupedDocuments.length}</span>
</button>
</div>
@@ -417,11 +421,11 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
className="btn btn-sm documents-hidden-toggle"
onClick={() => setShowHiddenProjectFiles((prev) => !prev)}
aria-pressed={showHiddenProjectFiles}
aria-label={showHiddenProjectFiles ? "Hide hidden project files" : "Show hidden project files"}
title={showHiddenProjectFiles ? "Hide hidden files" : "Show hidden files"}
aria-label={showHiddenProjectFiles ? t("documents.hideHidden", "Hide hidden project files") : t("documents.showHidden", "Show hidden project files")}
title={showHiddenProjectFiles ? t("documents.hideHiddenFiles", "Hide hidden files") : t("documents.showHiddenFiles", "Show hidden files")}
>
{showHiddenProjectFiles ? <EyeOff size={14} /> : <Eye size={14} />}
{showHiddenProjectFiles ? "Hide Hidden" : "Show Hidden"}
{showHiddenProjectFiles ? t("documents.hideHiddenLabel", "Hide Hidden") : t("documents.showHiddenLabel", "Show Hidden")}
</button>
)}
@@ -439,7 +443,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
<button
className="documents-search-clear"
onClick={clearSearch}
aria-label="Clear search"
aria-label={t("documents.clearSearch", "Clear search")}
>
<X size={16} />
</button>
@@ -451,25 +455,25 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
<div className="documents-view-content">
{activeError ? (
<div className="documents-view-error">
<p>Failed to load {activeTab === "project" ? "project files" : "task documents"}: {activeError}</p>
<button className="btn btn-primary" onClick={() => void handleRetry()} aria-label="Retry loading documents">
<p>{t("documents.failedToLoad", "Failed to load {{type}}: {{error}}", { type: activeTab === "project" ? t("documents.projectFiles", "project files") : t("documents.taskDocuments", "task documents"), error: activeError })}</p>
<button className="btn btn-primary" onClick={() => void handleRetry()} aria-label={t("documents.retryLoading", "Retry loading documents")}>
<RefreshCw size={16} />
Retry
{t("documents.retry", "Retry")}
</button>
</div>
) : activeTab === "project" ? (
projectFilesLoading && projectFiles.length === 0 ? (
<div className="documents-view-loading">
<p>Loading project markdown files…</p>
<p>{t("documents.loadingProjectFiles", "Loading project markdown files…")}</p>
</div>
) : filteredProjectFiles.length === 0 ? (
<div className="documents-view-empty">
{searchQuery.trim() ? (
<p>No project markdown files match "{searchQuery.trim()}".</p>
<p>{t("documents.noMatchProject", "No project markdown files match \"{{query}}\".", { query: searchQuery.trim() })}</p>
) : (
<>
<FileText size={48} className="documents-view-empty-icon" />
<p>No Markdown files found in this project.</p>
<p>{t("documents.noMarkdownFiles", "No Markdown files found in this project.")}</p>
</>
)}
</div>
@@ -507,16 +511,16 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
<button
className="btn btn-sm documents-mobile-back"
onClick={handleBackToFileList}
aria-label="Back to project files list"
aria-label={t("documents.backToFilesList", "Back to project files list")}
>
<ArrowLeft size={14} />
Back to files
{t("documents.backToFiles", "Back to files")}
</button>
)}
{!selectedFile ? (
<div className="documents-view-empty">
<p>Select a Markdown file to view its content.</p>
<p>{t("documents.selectFile", "Select a Markdown file to view its content.")}</p>
</div>
) : (
<div className="documents-content-viewer">
@@ -525,15 +529,15 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
<button
className="btn btn-sm document-mode-toggle"
onClick={() => setRenderProjectMarkdown((prev) => !prev)}
aria-label={renderProjectMarkdown ? "Switch to plain text" : "Switch to markdown"}
aria-label={renderProjectMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
aria-pressed={renderProjectMarkdown}
title={renderProjectMarkdown ? "Switch to plain text" : "Switch to markdown"}
title={renderProjectMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
>
{renderProjectMarkdown ? "Markdown" : "Plain"}
{renderProjectMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
</button>
</div>
{fileLoading ? (
<p className="documents-content-state">Loading file content…</p>
<p className="documents-content-state">{t("documents.loadingFileContent", "Loading file content…")}</p>
) : fileError ? (
<p className="documents-content-state documents-content-state--error">{fileError}</p>
) : renderProjectMarkdown ? (
@@ -553,18 +557,18 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
)
) : documentsLoading && documents.length === 0 ? (
<div className="documents-view-loading">
<p>Loading task documents…</p>
<p>{t("documents.loadingTaskDocuments", "Loading task documents…")}</p>
</div>
) : groupedDocuments.length === 0 ? (
<div className="documents-view-empty">
{searchQuery.trim() ? (
<p>No task documents match "{searchQuery.trim()}".</p>
<p>{t("documents.noMatchTask", "No task documents match \"{{query}}\".", { query: searchQuery.trim() })}</p>
) : (
<>
<FileText size={48} className="documents-view-empty-icon" />
<p>No task documents yet.</p>
<p>{t("documents.noTaskDocuments", "No task documents yet.")}</p>
<p className="documents-view-empty-hint">
Documents are created in task detail tabs.
{t("documents.documentsCreatedIn", "Documents are created in task detail tabs.")}
</p>
</>
)}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import {
fetchDroidCliStatus,
@@ -19,6 +20,7 @@ export function DroidCliProviderCard({
onToggled,
compact = false,
}: DroidCliProviderCardProps) {
const { t } = useTranslation("app");
const [status, setStatus] = useState<DroidCliStatus | null>(null);
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(
null,
@@ -98,8 +100,7 @@ export function DroidCliProviderCard({
const description = (
<span className="onboarding-provider-card__description">
Route AI calls through your locally-installed <code>droid</code> CLI.
Uses your existing Factory AI subscription / quota instead of an API key.
{t("droidCli.description", "Route AI calls through your locally-installed droid CLI. Uses your existing Factory AI subscription / quota instead of an API key.")}
</span>
);
@@ -114,10 +115,10 @@ export function DroidCliProviderCard({
{busy === "testing" ? (
<>
<Loader2 size={12} className="animate-spin" />
Testing…
{t("droidCli.testing", "Testing…")}
</>
) : (
"Test"
t("droidCli.test", "Test")
)}
</button>
{currentlyEnabled ? (
@@ -127,7 +128,7 @@ export function DroidCliProviderCard({
onClick={() => void handleToggle(false)}
disabled={busy !== null}
>
{busy === "disabling" ? "Disabling…" : "Disable"}
{busy === "disabling" ? t("droidCli.disabling", "Disabling…") : t("droidCli.disable", "Disable")}
</button>
) : (
<button
@@ -137,11 +138,11 @@ export function DroidCliProviderCard({
disabled={busy !== null || !binaryAvailable}
title={
!binaryAvailable
? "`droid` binary not detected on PATH — install Droid CLI first."
? t("droidCli.notOnPath", "droid binary not detected on PATH — install Droid CLI first.")
: undefined
}
>
{busy === "enabling" ? "Enabling…" : "Enable"}
{busy === "enabling" ? t("droidCli.enabling", "Enabling…") : t("droidCli.enable", "Enable")}
</button>
)}
</>
@@ -156,13 +157,13 @@ export function DroidCliProviderCard({
<div className="auth-provider-header">
<div className="auth-provider-info">
<ProviderIcon provider="droid-cli" size="sm" />
<strong>Factory AI — via Droid CLI</strong>
<strong>{t("droidCli.cardTitle", "Factory AI — via Droid CLI")}</strong>
<DroidCliBadge status={status} authenticated={authenticated} />
</div>
<div className="auth-provider-cli-actions">{actions}</div>
</div>
<details className="auth-provider-cli-details">
<summary>Details</summary>
<summary>{t("droidCli.details", "Details")}</summary>
<div className="auth-provider-cli-details-body">
{description}
<DroidCliStatusLine status={status} authenticated={authenticated} />
@@ -182,7 +183,7 @@ export function DroidCliProviderCard({
<ProviderIcon provider="droid-cli" size="md" />
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">Factory AI — via Droid CLI</strong>
<strong className="onboarding-provider-card__name">{t("droidCli.cardTitle", "Factory AI — via Droid CLI")}</strong>
{description}
<DroidCliStatusLine status={status} authenticated={authenticated} />
</div>
@@ -199,13 +200,14 @@ function DroidCliBadge({
status: DroidCliStatus | null;
authenticated: boolean;
}) {
const { t } = useTranslation("app");
const enabled = status?.enabled ?? authenticated;
const available = status?.binary.available ?? false;
if (enabled) return <span className="auth-status-badge authenticated">✓ Active</span>;
if (enabled) return <span className="auth-status-badge authenticated">✓ {t("droidCli.active", "Active")}</span>;
if (!available && status) {
return <span className="auth-status-badge not-authenticated">✗ Not installed</span>;
return <span className="auth-status-badge not-authenticated">✗ {t("droidCli.notInstalled", "Not installed")}</span>;
}
return <span className="auth-status-badge not-authenticated">✗ Not connected</span>;
return <span className="auth-status-badge not-authenticated">✗ {t("droidCli.notConnected", "Not connected")}</span>;
}
function DroidCliStatusLine({
@@ -215,10 +217,11 @@ function DroidCliStatusLine({
status: DroidCliStatus | null;
authenticated: boolean;
}) {
const { t } = useTranslation("app");
if (!status) {
return (
<small className="settings-muted">
<Loader2 size={10} className="animate-spin" /> Probing local CLI…
<Loader2 size={10} className="animate-spin" /> {t("droidCli.probingCli", "Probing local CLI…")}
</small>
);
}
@@ -227,7 +230,7 @@ function DroidCliStatusLine({
if (!binary.available) {
return (
<small className="onboarding-provider-card__status onboarding-provider-card__status--error">
✗ {binary.reason ?? "`droid` not found on PATH"}
✗ {binary.reason ?? t("droidCli.droidNotFound", "droid not found on PATH")}
</small>
);
}
@@ -235,9 +238,10 @@ function DroidCliStatusLine({
if (!enabled) {
return (
<small className="settings-muted">
<code>droid</code> {binary.version ? `(${binary.version})` : ""} detected
{binary.binaryPath ? ` at ${binary.binaryPath}` : ""}. Click Enable to
route AI calls through it.
{t("droidCli.detected", "droid {{version}} detected{{path}}. Click Enable to route AI calls through it.", {
version: binary.version ? `(${binary.version})` : "",
path: binary.binaryPath ? ` at ${binary.binaryPath}` : ""
})}
</small>
);
}
@@ -245,7 +249,7 @@ function DroidCliStatusLine({
if (extension && extension.status !== "ok") {
return (
<small className="onboarding-provider-card__status onboarding-provider-card__status--warning">
⚠ Extension load failed: {extension.reason ?? extension.status}
⚠ {t("droidCli.extensionFailed", "Extension load failed: {{reason}}", { reason: extension.reason ?? extension.status })}
</small>
);
}
@@ -253,12 +257,12 @@ function DroidCliStatusLine({
if (ready || authenticated) {
return (
<small className="onboarding-provider-card__status onboarding-provider-card__status--connected">
✓ Connected{binary.version ? ` — ${binary.version}` : ""}
✓ {t("droidCli.connectedVersion", "Connected{{version}}", { version: binary.version ? ` — ${binary.version}` : "" })}
</small>
);
}
return <small className="settings-muted">Enabled. Validating…</small>;
return <small className="settings-muted">{t("droidCli.enabledValidating", "Enabled. Validating…")}</small>;
}
function DroidCliActionToast({
@@ -269,6 +273,7 @@ function DroidCliActionToast({
| { kind: "disabled"; restartRequired: boolean }
| { kind: "error"; message: string };
}) {
const { t } = useTranslation("app");
if (action.kind === "error") {
return (
<p className="onboarding-helper-text onboarding-helper-text--error">
@@ -276,15 +281,15 @@ function DroidCliActionToast({
</p>
);
}
const verb = action.kind === "enabled" ? "Enabled" : "Disabled";
const verb = action.kind === "enabled" ? t("droidCli.toastEnabled", "Enabled") : t("droidCli.toastDisabled", "Disabled");
return (
<p className="onboarding-helper-text">
{verb}.{" "}
{action.kind === "enabled"
? "Factory AI (via Droid CLI) models are now visible in the model picker."
: "Factory AI (via Droid CLI) models are hidden from the model picker."}
? t("droidCli.modelsNowVisible", "Factory AI (via Droid CLI) models are now visible in the model picker.")
: t("droidCli.modelsHidden", "Factory AI (via Droid CLI) models are hidden from the model picker.")}
{action.restartRequired
? " Restart required: restart your active CLI/chat session for routing changes to take effect."
? " " + t("droidCli.restartRequired", "Restart required: restart your active CLI/chat session for routing changes to take effect.")
: ""}
</p>
);

View File

@@ -1,5 +1,6 @@
import "./DuplicateWarningModal.css";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { DuplicateMatch } from "../api";
interface DuplicateWarningModalProps {
@@ -14,6 +15,7 @@ function toStatusClass(column: string): string {
}
export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }: DuplicateWarningModalProps) {
const { t } = useTranslation("app");
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
@@ -35,10 +37,10 @@ export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }:
<div className="modal-overlay open" role="presentation">
<div className="modal duplicate-warning-modal" role="dialog" aria-modal="true" aria-labelledby="duplicate-warning-modal-title">
<div className="modal-header">
<h3 id="duplicate-warning-modal-title">Possible duplicates</h3>
<h3 id="duplicate-warning-modal-title">{t("duplicateWarning.title", "Possible duplicates")}</h3>
</div>
<div className="duplicate-warning-modal-body">
<p className="duplicate-warning-modal-copy">We found similar active tasks. Open an existing task or create this one anyway.</p>
<p className="duplicate-warning-modal-copy">{t("duplicateWarning.message", "We found similar active tasks. Open an existing task or create this one anyway.")}</p>
<div className="duplicate-warning-modal-list">
{matches.map((match) => (
<article className="card duplicate-warning-modal-item" key={match.id}>
@@ -47,9 +49,9 @@ export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }:
<span className={`card-status-badge ${toStatusClass(match.column)}`}>{match.column}</span>
<span className="duplicate-warning-modal-score">{Math.round(match.score * 100)}%</span>
</div>
<div className="card-title duplicate-warning-modal-title">{match.title || "Untitled task"}</div>
<div className="card-title duplicate-warning-modal-title">{match.title || t("duplicateWarning.untitledTask", "Untitled task")}</div>
<div className="duplicate-warning-modal-actions">
<button className="btn btn-sm" type="button" onClick={() => onOpen(match.id)}>Open</button>
<button className="btn btn-sm" type="button" onClick={() => onOpen(match.id)}>{t("duplicateWarning.open", "Open")}</button>
</div>
</article>
))}
@@ -57,10 +59,10 @@ export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }:
</div>
<div className="modal-actions">
<div className="modal-actions-left">
<button className="btn" type="button" ref={cancelButtonRef} onClick={onCancel}>Cancel</button>
<button className="btn" type="button" ref={cancelButtonRef} onClick={onCancel}>{t("duplicateWarning.cancel", "Cancel")}</button>
</div>
<div className="modal-actions-right">
<button className="btn btn-primary" type="button" onClick={onProceed}>Create anyway</button>
<button className="btn btn-primary" type="button" onClick={onProceed}>{t("duplicateWarning.createAnyway", "Create anyway")}</button>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, RefreshCw, Settings } from "lucide-react";
import { fetchSettings } from "../api";
import { useEvals } from "../hooks/useEvals";
@@ -12,6 +13,7 @@ interface EvalsViewProps {
}
export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: EvalsViewProps) {
const { t } = useTranslation("app");
const { loading, error, results, runs, filters, setFilters, selectedEvalId, setSelectedEvalId, selectedEval, refresh } = useEvals({ projectId });
const [scheduledEnabled, setScheduledEnabled] = useState(true);
@@ -37,11 +39,11 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
if (!scheduledEnabled) {
return (
<section className="evals-view card" data-testid="evals-disabled">
<h2 className="evals-title">Scheduled evals are disabled</h2>
<p className="evals-empty-copy">Enable Scheduled Evals to review scored tasks, evidence, and follow-up recommendations.</p>
<button className="btn btn-primary" type="button" onClick={() => onOpenSettings?.("scheduled-evals")}>
<h2 className="evals-title">{t("evals.disabledTitle", "Scheduled evals are disabled")}</h2>
<p className="evals-empty-copy">{t("evals.enablePrompt", "Enable Scheduled Evals to review scored tasks, evidence, and follow-up recommendations.")}</p>
<button className="btn btn-primary" type="button" onClick={() => onOpenSettings?.("scheduled-evals")}>
<Settings size={16} />
Open Scheduled Evals Settings
{t("evals.openSettings", "Open Scheduled Evals Settings")}
</button>
</section>
);
@@ -53,7 +55,7 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
<div className="evals-toolbar">
<input
className="input"
placeholder="Search task or rationale"
placeholder={t("evals.searchPlaceholder", "Search task or rationale")}
value={filters.q}
onChange={(event) => setFilters((prev) => ({ ...prev, q: event.target.value }))}
/>
@@ -62,20 +64,20 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
value={filters.runId}
onChange={(event) => setFilters((prev) => ({ ...prev, runId: event.target.value }))}
>
<option value="">All runs</option>
<option value="">{t("evals.allRuns", "All runs")}</option>
{runs.map((run) => (
<option key={run.id} value={run.id}>{run.id}</option>
))}
</select>
<input className="input" placeholder="Min score" value={filters.scoreMin} onChange={(event) => setFilters((prev) => ({ ...prev, scoreMin: event.target.value }))} />
<input className="input" placeholder="Max score" value={filters.scoreMax} onChange={(event) => setFilters((prev) => ({ ...prev, scoreMax: event.target.value }))} />
<button className="btn btn-icon" type="button" onClick={() => void refresh()} aria-label="Refresh evals"><RefreshCw size={16} /></button>
<input className="input" placeholder={t("evals.minScorePlaceholder", "Min score")} value={filters.scoreMin} onChange={(event) => setFilters((prev) => ({ ...prev, scoreMin: event.target.value }))} />
<input className="input" placeholder={t("evals.maxScorePlaceholder", "Max score")} value={filters.scoreMax} onChange={(event) => setFilters((prev) => ({ ...prev, scoreMax: event.target.value }))} />
<button className="btn btn-icon" type="button" onClick={() => void refresh()} aria-label={t("evals.refreshAria", "Refresh evals")}><RefreshCw size={16} /></button>
</div>
{loading && <p className="evals-state" data-testid="evals-loading">Loading evals…</p>}
{loading && <p className="evals-state" data-testid="evals-loading">{t("evals.loading", "Loading evals…")}</p>}
{error && <p className="evals-state evals-state--error">{error}</p>}
{!loading && !error && !hasResults && (
<p className="evals-state">No evals yet. Scheduled evals review tasks completed since the last run.</p>
<p className="evals-state">{t("evals.empty", "No evals yet. Scheduled evals review tasks completed since the last run.")}</p>
)}
<ul className="evals-results" data-testid="evals-results">
@@ -83,7 +85,7 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
<li key={result.id}>
<button className={`evals-result ${result.id === selectedEvalId ? "evals-result--active" : ""}`} type="button" onClick={() => setSelectedEvalId(result.id)}>
<span className="evals-result-title">{result.taskTitle}</span>
<span className="evals-result-meta">{result.taskId} · {result.runId} · {result.overallScore ?? "n/a"}</span>
<span className="evals-result-meta">{result.taskId} · {result.runId} · {result.overallScore ?? t("evals.naPlaceholder", "n/a")}</span>
</button>
</li>
))}
@@ -91,21 +93,21 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
</div>
<div className="evals-detail card" data-testid="evals-detail">
{!selectedEval && <p className="evals-state">Select an evaluation to inspect scores, rationale, and evidence.</p>}
{!selectedEval && <p className="evals-state">{t("evals.selectPrompt", "Select an evaluation to inspect scores, rationale, and evidence.")}</p>}
{selectedEval && (
<>
<h3 className="evals-detail-title">{selectedSummary?.taskTitle ?? selectedEval.taskTitle}</h3>
<p className="evals-result-meta">{selectedEval.taskId} · {selectedEval.runId}</p>
<p className="evals-score">Overall score: {selectedEval.overallScore ?? "n/a"}</p>
<p className="evals-score">{t("evals.overallScore", "Overall score: {{score}}", { score: selectedEval.overallScore ?? t("evals.naPlaceholder", "n/a") })}</p>
<ul className="evals-categories">
{selectedEval.categoryScores.map((score) => (
<li key={score.category}>{score.category}: {score.finalScore}</li>
))}
</ul>
<p className="evals-rationale">{selectedEval.rationale || "No rationale recorded."}</p>
<p className="evals-rationale">{selectedEval.rationale || t("evals.noRationale", "No rationale recorded.")}</p>
<div>
<h4>Evidence</h4>
<h4>{t("evals.evidenceHeading", "Evidence")}</h4>
<ul className="evals-links">
{selectedEval.evidence.map((item, index) => {
const taskId = typeof item.metadata?.taskId === "string" ? item.metadata.taskId : undefined;
@@ -126,9 +128,9 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
</div>
<div>
<h4>Suggested follow-up tasks</h4>
<h4>{t("evals.suggestedFollowupsHeading", "Suggested follow-up tasks")}</h4>
<ul className="evals-follow-ups">
{selectedEval.followUps.length === 0 && <li>None</li>}
{selectedEval.followUps.length === 0 && <li>{t("evals.noFollowups", "None")}</li>}
{selectedEval.followUps.map((followUp) => (
<li key={followUp.suggestionId}><strong>{followUp.title}</strong><p>{followUp.rationale}</p></li>
))}

View File

@@ -1,5 +1,7 @@
import "./ExecutorStatusBar.css";
import { useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import {
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS,
@@ -43,8 +45,8 @@ interface ExecutorStatusBarProps {
/**
* Format a relative time string (e.g., "2m ago", "1h ago")
*/
function formatRelativeTime(timestamp: string | undefined): string {
if (!timestamp) return "no activity";
function formatRelativeTime(timestamp: string | undefined, t: TFunction<"app">): string {
if (!timestamp) return t("executor.noActivity", "no activity");
const now = Date.now();
const then = new Date(timestamp).getTime();
@@ -55,25 +57,25 @@ function formatRelativeTime(timestamp: string | undefined): string {
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
if (seconds > 10) return `${seconds}s ago`;
return "just now";
if (days > 0) return t("executor.daysAgo", "{{count}}d ago", { count: days });
if (hours > 0) return t("executor.hoursAgo", "{{count}}h ago", { count: hours });
if (minutes > 0) return t("executor.minutesAgo", "{{count}}m ago", { count: minutes });
if (seconds > 10) return t("executor.secondsAgo", "{{count}}s ago", { count: seconds });
return t("executor.justNow", "just now");
}
/**
* Get display configuration for an executor state
*/
function getStateDisplay(state: ExecutorState): { label: string; color: string; icon: typeof Play } {
function getStateDisplay(state: ExecutorState, t: TFunction<"app">): { label: string; color: string; icon: typeof Play } {
switch (state) {
case "running":
return { label: "Running", color: "var(--color-success)", icon: Play };
return { label: t("executor.stateRunning", "Running"), color: "var(--color-success)", icon: Play };
case "paused":
return { label: "Paused", color: "var(--triage)", icon: Pause };
return { label: t("executor.statePaused", "Paused"), color: "var(--triage)", icon: Pause };
case "idle":
default:
return { label: "Idle", color: "var(--text-muted)", icon: Zap };
return { label: t("executor.stateIdle", "Idle"), color: "var(--text-muted)", icon: Zap };
}
}
@@ -88,13 +90,13 @@ function getStateDisplay(state: ExecutorState): { label: string; color: string;
* - Last activity timestamp
*/
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen }: ExecutorStatusBarProps) {
if (hideWhenKeyboardOpen) return null;
const { t } = useTranslation("app");
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
const stateDisplay = useMemo(() => getStateDisplay(stats.executorState), [stats.executorState]);
const stateDisplay = useMemo(() => getStateDisplay(stats.executorState, t), [stats.executorState, t]);
const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt), [stats.lastActivityAt]);
const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt, t), [stats.lastActivityAt, t]);
const highestOverlapBlocker = useMemo(() => {
const fanoutMap = computeBlockerFanoutMap(tasks, {
@@ -117,9 +119,13 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
const StateIcon = stateDisplay.icon;
// Keyboard-open guard runs after all hooks: toggling it must not change the
// hook count between renders (Rules of Hooks).
if (hideWhenKeyboardOpen) return null;
if (error) {
return (
<div className="executor-status-bar executor-status-bar--error" role="status" aria-label="Executor status">
<div className="executor-status-bar executor-status-bar--error" role="status" aria-label={t("executor.status", "Executor status")}>
<span className="executor-status-bar__error">
<AlertTriangle size={14} />
{error}
@@ -130,8 +136,8 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
if (loading && stats.runningTaskCount === 0) {
return (
<div className="executor-status-bar executor-status-bar--loading" role="status" aria-label="Executor status">
<span className="executor-status-bar__loading-text">Loading...</span>
<div className="executor-status-bar executor-status-bar--loading" role="status" aria-label={t("executor.status", "Executor status")}>
<span className="executor-status-bar__loading-text">{t("executor.loading", "Loading...")}</span>
</div>
);
}
@@ -140,7 +146,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
<div
className={`executor-status-bar ${stats.executorState === "running" ? "executor-status-bar--running" : ""}${keyboardOpen ? " executor-status-bar--keyboard-open" : ""}`}
role="status"
aria-label="Executor status"
aria-label={t("executor.status", "Executor status")}
>
{/* Background AI tasks indicator */}
{backgroundSessions && backgroundSessions.length > 0 && onOpenBackgroundSession && onDismissBackgroundSession && (
@@ -159,7 +165,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
{/* Queued tasks */}
<div className="executor-status-bar__segment">
<span className="executor-status-bar__indicator executor-status-bar__indicator--queued" aria-hidden="true" />
<span className="executor-status-bar__label">Queued</span>
<span className="executor-status-bar__label">{t("executor.queued", "Queued")}</span>
<span className="executor-status-bar__count">{stats.queuedTaskCount}</span>
</div>
@@ -172,7 +178,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
className={`executor-status-bar__indicator executor-status-bar__indicator--running ${stats.runningTaskCount > 0 ? "executor-status-bar__indicator--active" : ""}`}
aria-hidden="true"
/>
<span className="executor-status-bar__label">Running</span>
<span className="executor-status-bar__label">{t("executor.running", "Running")}</span>
<span className="executor-status-bar__count">{stats.runningTaskCount}</span>
<span className="executor-status-bar__separator" aria-hidden="true">/</span>
<span className="executor-status-bar__max">{stats.maxConcurrent}</span>
@@ -186,7 +192,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
<>
<div className="executor-status-bar__segment executor-status-bar__segment--stuck">
<span className="executor-status-bar__indicator executor-status-bar__indicator--stuck executor-status-bar__indicator--active" aria-hidden="true" />
<span className="executor-status-bar__label">Stuck</span>
<span className="executor-status-bar__label">{t("executor.stuck", "Stuck")}</span>
<span className="executor-status-bar__count executor-status-bar__count--error">{stats.stuckTaskCount}</span>
</div>
<span className="executor-status-bar__divider" aria-hidden="true" />
@@ -199,7 +205,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
className={`executor-status-bar__indicator executor-status-bar__indicator--blocked ${stats.blockedTaskCount > 0 ? "executor-status-bar__indicator--active" : ""}`}
aria-hidden="true"
/>
<span className="executor-status-bar__label">Blocked</span>
<span className="executor-status-bar__label">{t("executor.blocked", "Blocked")}</span>
<span className={`executor-status-bar__count ${stats.blockedTaskCount > 0 ? "executor-status-bar__count--warning" : ""}`}>
{stats.blockedTaskCount}
</span>
@@ -211,7 +217,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
{/* In review count */}
<div className="executor-status-bar__segment">
<span className="executor-status-bar__indicator executor-status-bar__indicator--review" aria-hidden="true" />
<span className="executor-status-bar__label">In Review</span>
<span className="executor-status-bar__label">{t("executor.inReview", "In Review")}</span>
<span className="executor-status-bar__count">{stats.inReviewCount}</span>
</div>
@@ -220,12 +226,17 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
<span className="executor-status-bar__divider" aria-hidden="true" />
<div className="executor-status-bar__segment executor-status-bar__segment--fanout">
<span className="executor-status-bar__indicator executor-status-bar__indicator--fanout executor-status-bar__indicator--active" aria-hidden="true" />
<span className="executor-status-bar__label">Overlap queue</span>
<span className="executor-status-bar__label">{t("executor.overlapQueue", "Overlap queue")}</span>
<span
className="executor-status-bar__fanout-summary"
title={`${highestOverlapBlocker.entry.escalation ? "Escalated" : "Temporary"} overlap bottleneck ${highestOverlapBlocker.blockerId}: ${highestOverlapBlocker.entry.overlapBlockedTodoCount} todo blocked via blockedBy (threshold ${HIGH_FANOUT_BLOCKER_TODO_THRESHOLD})`}
title={t("executor.overlapBottleneck", "{{status}} overlap bottleneck {{blockerId}}: {{count}} todo blocked via blockedBy (threshold {{threshold}})", {
status: highestOverlapBlocker.entry.escalation ? t("executor.escalated", "Escalated") : t("executor.temporary", "Temporary"),
blockerId: highestOverlapBlocker.blockerId,
count: highestOverlapBlocker.entry.overlapBlockedTodoCount,
threshold: HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
})}
>
{highestOverlapBlocker.blockerId} · {highestOverlapBlocker.entry.overlapBlockedTodoCount} todo{highestOverlapBlocker.entry.escalation ? " (escalated)" : ""}
{highestOverlapBlocker.blockerId} · {highestOverlapBlocker.entry.overlapBlockedTodoCount} todo{highestOverlapBlocker.entry.escalation ? t("executor.escalatedSuffix", " (escalated)") : ""}
</span>
</div>
</>
@@ -238,10 +249,10 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
<button
className={`executor-status-bar__folder-toggle${isProjectPathVisible ? " executor-status-bar__folder-toggle--active" : ""}`}
onClick={() => setIsProjectPathVisible((prev) => !prev)}
aria-label={isProjectPathVisible ? "Hide project directory" : "Show project directory"}
aria-label={isProjectPathVisible ? t("executor.hideProjectDir", "Hide project directory") : t("executor.showProjectDir", "Show project directory")}
aria-expanded={isProjectPathVisible}
data-testid="executor-project-path-toggle"
title={isProjectPathVisible ? "Hide project directory" : "Show project directory"}
title={isProjectPathVisible ? t("executor.hideProjectDir", "Hide project directory") : t("executor.showProjectDir", "Show project directory")}
>
<Folder size={12} aria-hidden="true" />
</button>

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Agent, AgentOnboardingSummary, ConversationHistoryEntry, ExistingAgentOnboardingConfig, OnboardingMode } from "../api";
import {
cancelAgentOnboarding,
@@ -31,6 +32,7 @@ export function ExperimentalAgentOnboardingModal({
mode = "create",
existingAgentConfig,
}: ExperimentalAgentOnboardingModalProps) {
const { t } = useTranslation("app");
const [viewState, setViewState] = useState<ViewState>("initial");
const [intent, setIntent] = useState("");
const [sessionId, setSessionId] = useState<string | null>(null);
@@ -113,7 +115,7 @@ export function ExperimentalAgentOnboardingModal({
const renderSummaryValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === "") {
return <em className="experimental-agent-onboarding-modal__summary-empty">Not set</em>;
return <em className="experimental-agent-onboarding-modal__summary-empty">{t("agents.onboarding.notSet", "Not set")}</em>;
}
return <span>{value}</span>;
};
@@ -168,91 +170,91 @@ export function ExperimentalAgentOnboardingModal({
return (
<div className="modal-overlay open" role="presentation">
<div className="modal modal-lg experimental-agent-onboarding-modal" role="dialog" aria-modal="true" aria-label="AI Interview">
<div className="modal modal-lg experimental-agent-onboarding-modal" role="dialog" aria-modal="true" aria-label={t("agents.onboarding.dialogLabel", "AI Interview")}>
<div className="modal-header">
<h3>AI Interview</h3>
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">×</button>
<h3>{t("agents.onboarding.title", "AI Interview")}</h3>
<button className="modal-close" onClick={() => void handleClose()} aria-label={t("common.closeAriaLabel", "Close")}>×</button>
</div>
{history.length > 0 && <ConversationHistory entries={history} />}
{viewState === "initial" && (
<div className="form-group">
<label htmlFor="agent-onboarding-intent">{isEditMode ? "What should this agent change or improve?" : "What should this new agent own?"}</label>
<label htmlFor="agent-onboarding-intent">{isEditMode ? t("agents.onboarding.intentLabelEdit", "What should this agent change or improve?") : t("agents.onboarding.intentLabelCreate", "What should this new agent own?")}</label>
<textarea id="agent-onboarding-intent" className="input experimental-agent-onboarding-modal__textarea" value={intent} onChange={(e) => setIntent(e.target.value)} />
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>{isEditMode ? "Start interview" : "Start onboarding"}</button>
<button className="btn" onClick={() => void handleClose()}>{t("common.cancel", "Cancel")}</button>
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>{isEditMode ? t("agents.onboarding.startInterview", "Start interview") : t("agents.onboarding.startOnboarding", "Start onboarding")}</button>
</div>
</div>
)}
{(viewState === "loading" || viewState === "question") && (
<div className="form-group">
<label htmlFor="agent-onboarding-answer">{currentQuestion || "Thinking..."}</label>
<label htmlFor="agent-onboarding-answer">{currentQuestion || t("agents.onboarding.thinking", "Thinking...")}</label>
<textarea id="agent-onboarding-answer" className="input experimental-agent-onboarding-modal__textarea" value={answer} onChange={(e) => setAnswer(e.target.value)} />
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
<button className="btn btn-primary" disabled={viewState === "loading" || !answer.trim()} onClick={() => void submitAnswer()}>Continue</button>
<button className="btn" onClick={() => void handleClose()}>{t("common.cancel", "Cancel")}</button>
<button className="btn btn-primary" disabled={viewState === "loading" || !answer.trim()} onClick={() => void submitAnswer()}>{t("agents.onboarding.continue", "Continue")}</button>
</div>
</div>
)}
{viewState === "summary" && summary && (
<div className="form-group">
<label>{isEditMode ? "Updated draft ready for review" : "Draft ready for review"}</label>
<label>{isEditMode ? t("agents.onboarding.updatedDraftReady", "Updated draft ready for review") : t("agents.onboarding.draftReady", "Draft ready for review")}</label>
<p className="experimental-agent-onboarding-modal__summary-intro">
Review this generated draft. Nothing is applied until you confirm.
{t("agents.onboarding.draftIntro", "Review this generated draft. Nothing is applied until you confirm.")}
</p>
<div className="experimental-agent-onboarding-modal__summary card">
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Identity</h4>
<h4>{t("agents.onboarding.sectionIdentity", "Identity")}</h4>
<dl className="experimental-agent-onboarding-modal__summary-list">
<div><dt>Name</dt><dd>{renderSummaryValue(summary.name)}</dd></div>
<div><dt>Role</dt><dd>{renderSummaryValue(summary.role)}</dd></div>
<div><dt>Title</dt><dd>{renderSummaryValue(summary.title)}</dd></div>
<div><dt>Icon</dt><dd>{renderSummaryValue(summary.icon)}</dd></div>
<div><dt>Reports To</dt><dd>{renderSummaryValue(summary.reportsTo)}</dd></div>
<div><dt>{t("agents.onboarding.fieldName", "Name")}</dt><dd>{renderSummaryValue(summary.name)}</dd></div>
<div><dt>{t("agents.onboarding.fieldRole", "Role")}</dt><dd>{renderSummaryValue(summary.role)}</dd></div>
<div><dt>{t("agents.onboarding.fieldTitle", "Title")}</dt><dd>{renderSummaryValue(summary.title)}</dd></div>
<div><dt>{t("agents.onboarding.fieldIcon", "Icon")}</dt><dd>{renderSummaryValue(summary.icon)}</dd></div>
<div><dt>{t("agents.onboarding.fieldReportsTo", "Reports To")}</dt><dd>{renderSummaryValue(summary.reportsTo)}</dd></div>
</dl>
</div>
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Configuration</h4>
<h4>{t("agents.onboarding.sectionConfiguration", "Configuration")}</h4>
<dl className="experimental-agent-onboarding-modal__summary-list">
<div><dt>Inline Instructions</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.instructionsText)}</dd></div>
<div><dt>Soul</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.soul)}</dd></div>
<div><dt>Agent Memory</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.memory)}</dd></div>
<div><dt>Skills</dt><dd>{renderSummaryValue(summary.skills?.join(", "))}</dd></div>
<div><dt>Thinking Level</dt><dd>{renderSummaryValue(summary.thinkingLevel)}</dd></div>
<div><dt>Max Turns</dt><dd>{renderSummaryValue(summary.maxTurns)}</dd></div>
<div><dt>Template</dt><dd>{renderSummaryValue(summary.templateId)}</dd></div>
<div><dt>Pattern Agent</dt><dd>{renderSummaryValue(summary.patternAgentId)}</dd></div>
<div><dt>{t("agents.onboarding.fieldInlineInstructions", "Inline Instructions")}</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.instructionsText)}</dd></div>
<div><dt>{t("agents.onboarding.fieldSoul", "Soul")}</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.soul)}</dd></div>
<div><dt>{t("agents.onboarding.fieldAgentMemory", "Agent Memory")}</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.memory)}</dd></div>
<div><dt>{t("agents.onboarding.fieldSkills", "Skills")}</dt><dd>{renderSummaryValue(summary.skills?.join(", "))}</dd></div>
<div><dt>{t("agents.onboarding.fieldThinkingLevel", "Thinking Level")}</dt><dd>{renderSummaryValue(summary.thinkingLevel)}</dd></div>
<div><dt>{t("agents.onboarding.fieldMaxTurns", "Max Turns")}</dt><dd>{renderSummaryValue(summary.maxTurns)}</dd></div>
<div><dt>{t("agents.onboarding.fieldTemplate", "Template")}</dt><dd>{renderSummaryValue(summary.templateId)}</dd></div>
<div><dt>{t("agents.onboarding.fieldPatternAgent", "Pattern Agent")}</dt><dd>{renderSummaryValue(summary.patternAgentId)}</dd></div>
</dl>
</div>
{(summary.heartbeatProcedurePath || summary.heartbeatIntervalMs || summary.heartbeatEnabled !== undefined || summary.modelHint || summary.runtimeHint) && (
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Runtime Hints</h4>
<h4>{t("agents.onboarding.sectionRuntimeHints", "Runtime Hints")}</h4>
<dl className="experimental-agent-onboarding-modal__summary-list">
<div><dt>Heartbeat Procedure Path</dt><dd>{renderSummaryValue(summary.heartbeatProcedurePath)}</dd></div>
<div><dt>Heartbeat Interval</dt><dd>{renderSummaryValue(summary.heartbeatIntervalMs ? `${summary.heartbeatIntervalMs}ms` : undefined)}</dd></div>
<div><dt>Heartbeat Enabled</dt><dd>{renderSummaryValue(summary.heartbeatEnabled === undefined ? undefined : summary.heartbeatEnabled ? "yes" : "no")}</dd></div>
<div><dt>Model Hint</dt><dd>{renderSummaryValue(summary.modelHint)}</dd></div>
<div><dt>Runtime Hint</dt><dd>{renderSummaryValue(summary.runtimeHint)}</dd></div>
<div><dt>{t("agents.onboarding.fieldHeartbeatPath", "Heartbeat Procedure Path")}</dt><dd>{renderSummaryValue(summary.heartbeatProcedurePath)}</dd></div>
<div><dt>{t("agents.onboarding.fieldHeartbeatInterval", "Heartbeat Interval")}</dt><dd>{renderSummaryValue(summary.heartbeatIntervalMs ? `${summary.heartbeatIntervalMs}ms` : undefined)}</dd></div>
<div><dt>{t("agents.onboarding.fieldHeartbeatEnabled", "Heartbeat Enabled")}</dt><dd>{renderSummaryValue(summary.heartbeatEnabled === undefined ? undefined : summary.heartbeatEnabled ? t("agents.onboarding.yes", "yes") : t("agents.onboarding.no", "no"))}</dd></div>
<div><dt>{t("agents.onboarding.fieldModelHint", "Model Hint")}</dt><dd>{renderSummaryValue(summary.modelHint)}</dd></div>
<div><dt>{t("agents.onboarding.fieldRuntimeHint", "Runtime Hint")}</dt><dd>{renderSummaryValue(summary.runtimeHint)}</dd></div>
</dl>
</div>
)}
{summary.rationale && (
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Rationale</h4>
<h4>{t("agents.onboarding.sectionRationale", "Rationale")}</h4>
<p className="experimental-agent-onboarding-modal__summary-block">{summary.rationale}</p>
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
<button className="btn btn-primary" onClick={() => void handleConfirmDraft()}>{isEditMode ? "Apply draft to settings form" : "Apply draft to agent form"}</button>
<button className="btn" onClick={() => void handleClose()}>{t("common.cancel", "Cancel")}</button>
<button className="btn btn-primary" onClick={() => void handleConfirmDraft()}>{isEditMode ? t("agents.onboarding.applyDraftSettings", "Apply draft to settings form") : t("agents.onboarding.applyDraftAgent", "Apply draft to agent form")}</button>
</div>
</div>
)}
@@ -261,7 +263,7 @@ export function ExperimentalAgentOnboardingModal({
<div className="form-group">
<div className="form-error">{error}</div>
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Close</button>
<button className="btn" onClick={() => void handleClose()}>{t("common.close", "Close")}</button>
</div>
</div>
)}

View File

@@ -1,5 +1,6 @@
import "./FileBrowser.css";
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Folder, File, ChevronRight, Loader2, Copy, Move, Trash2, Pencil, Download, Archive } from "lucide-react";
import type { FileNode } from "../api";
import { copyFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api";
@@ -98,6 +99,7 @@ interface FileContextMenuProps {
}
function FileContextMenu({ x, y, entry, onAction, onClose }: FileContextMenuProps) {
const { t } = useTranslation("app");
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPos, setAdjustedPos] = useState({ x, y });
@@ -142,15 +144,15 @@ function FileContextMenu({ x, y, entry, onAction, onClose }: FileContextMenuProp
const isDir = entry.type === "directory";
const items: ContextMenuItem[] = [
{ id: "copy", label: "Copy", icon: Copy, disabled: false },
{ id: "move", label: "Move", icon: Move, disabled: false },
{ id: "rename", label: "Rename", icon: Pencil, disabled: false },
{ id: "copy", label: t("fileBrowser.contextCopy", "Copy"), icon: Copy, disabled: false },
{ id: "move", label: t("fileBrowser.contextMove", "Move"), icon: Move, disabled: false },
{ id: "rename", label: t("fileBrowser.contextRename", "Rename"), icon: Pencil, disabled: false },
...(isDir
? [{ id: "download-zip" as string, label: "Download as ZIP", icon: Archive, disabled: false }]
: [{ id: "download" as string, label: "Download", icon: Download, disabled: false }]
? [{ id: "download-zip" as string, label: t("fileBrowser.contextDownloadZip", "Download as ZIP"), icon: Archive, disabled: false }]
: [{ id: "download" as string, label: t("fileBrowser.contextDownload", "Download"), icon: Download, disabled: false }]
),
{ id: "divider", label: "", icon: Copy, disabled: true },
{ id: "delete", label: "Delete", icon: Trash2, disabled: false },
{ id: "delete", label: t("fileBrowser.contextDelete", "Delete"), icon: Trash2, disabled: false },
];
return (
@@ -159,7 +161,7 @@ function FileContextMenu({ x, y, entry, onAction, onClose }: FileContextMenuProp
ref={menuRef}
className="file-browser-context-menu"
role="menu"
aria-label="File operations"
aria-label={t("fileBrowser.contextMenuLabel", "File operations")}
style={{ left: adjustedPos.x, top: adjustedPos.y }}
onClick={(e) => e.stopPropagation()}
>
@@ -199,6 +201,7 @@ interface OperationDialogProps {
}
function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, loading, error }: OperationDialogProps) {
const { t } = useTranslation("app");
const inputRef = useRef<HTMLInputElement>(null);
const defaultValue = type === "rename" ? entry.name : "";
const [value, setValue] = useState(defaultValue);
@@ -233,22 +236,22 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load
return (
<div className="context-menu-overlay" onClick={onCancel}>
<div className="file-browser-dialog" onClick={(e) => e.stopPropagation()}>
<div className="file-browser-dialog-title">Delete {entry.type === "directory" ? "Folder" : "File"}</div>
<div className="file-browser-dialog-title">{t("fileBrowser.deleteTitle", "Delete {{type}}", { type: entry.type === "directory" ? t("fileBrowser.typeFolder", "Folder") : t("fileBrowser.typeFile", "File") })}</div>
<div className="file-browser-dialog-message">
Are you sure you want to delete <strong>{entry.name}</strong>?
{entry.type === "directory" && " This will delete all contents recursively."}
{t("fileBrowser.deleteConfirm", "Are you sure you want to delete {{name}}?", { name: entry.name })}
{entry.type === "directory" && ` ${t("fileBrowser.deleteRecursive", "This will delete all contents recursively.")}`}
</div>
{error && <div className="file-browser-dialog-error">{error}</div>}
<div className="file-browser-dialog-actions">
<button className="btn btn-sm" onClick={onCancel} disabled={loading}>
Cancel
{t("common.cancel", "Cancel")}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => onConfirm("")}
disabled={loading}
>
{loading ? "Deleting..." : "Delete"}
{loading ? t("fileBrowser.deleting", "Deleting...") : t("fileBrowser.delete", "Delete")}
</button>
</div>
</div>
@@ -257,9 +260,9 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load
}
const labels: Record<string, { title: string; placeholder: string; confirm: string }> = {
copy: { title: "Copy", placeholder: "Destination path", confirm: "Copy" },
move: { title: "Move", placeholder: "Destination path", confirm: "Move" },
rename: { title: "Rename", placeholder: "New name", confirm: "Rename" },
copy: { title: t("fileBrowser.copyTitle", "Copy"), placeholder: t("fileBrowser.copyPlaceholder", "Destination path"), confirm: t("fileBrowser.copy", "Copy") },
move: { title: t("fileBrowser.moveTitle", "Move"), placeholder: t("fileBrowser.movePlaceholder", "Destination path"), confirm: t("fileBrowser.move", "Move") },
rename: { title: t("fileBrowser.renameTitle", "Rename"), placeholder: t("fileBrowser.renamePlaceholder", "New name"), confirm: t("fileBrowser.rename", "Rename") },
};
const config = labels[type!];
@@ -284,14 +287,14 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load
{error && <div className="file-browser-dialog-error">{error}</div>}
<div className="file-browser-dialog-actions">
<button className="btn btn-sm" onClick={onCancel} disabled={loading}>
Cancel
{t("common.cancel", "Cancel")}
</button>
<button
className="btn btn-primary btn-sm"
onClick={() => onConfirm(value.trim())}
disabled={loading || !value.trim()}
>
{loading ? `${config.confirm}ing...` : config.confirm}
{loading ? `${config.confirm}${t("fileBrowser.operationSuffix", "ing...")}` : config.confirm}
</button>
</div>
</div>
@@ -313,6 +316,7 @@ export function FileBrowser({
onRefresh,
projectId,
}: FileBrowserProps) {
const { t } = useTranslation("app");
const [contextMenu, setContextMenu] = useState<ContextMenuState>(INITIAL_CONTEXT_MENU);
const [dialog, setDialog] = useState<DialogState>(INITIAL_DIALOG);
const [operationLoading, setOperationLoading] = useState(false);
@@ -496,7 +500,7 @@ export function FileBrowser({
setDialog(INITIAL_DIALOG);
onRefresh?.();
} catch (err) {
setOperationError(getErrorMessage(err) || "Operation failed");
setOperationError(getErrorMessage(err) || t("fileBrowser.operationFailed", "Operation failed"));
} finally {
setOperationLoading(false);
}
@@ -526,7 +530,7 @@ export function FileBrowser({
return (
<div className="file-browser-loading">
<Loader2 className="spin" size={24} />
<span>Loading files...</span>
<span>{t("fileBrowser.loadingFiles", "Loading files...")}</span>
</div>
);
}
@@ -534,10 +538,10 @@ export function FileBrowser({
if (error) {
return (
<div className="file-browser-error">
<p>Error: {error}</p>
<p>{t("fileBrowser.error", "Error: {{message}}", { message: error })}</p>
{onRetry && (
<button className="btn btn-sm" onClick={onRetry}>
Retry
{t("common.retry", "Retry")}
</button>
)}
</div>
@@ -555,15 +559,15 @@ export function FileBrowser({
}}
>
<ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} />
Up one level
{t("fileBrowser.upOneLevel", "Up one level")}
</button>
)}
<span className="file-browser-path">{currentPath === "." ? "Root" : normalizeDisplayPath(currentPath)}</span>
<span className="file-browser-path">{currentPath === "." ? t("fileBrowser.root", "Root") : normalizeDisplayPath(currentPath)}</span>
</div>
<div className="file-browser-list">
{entries.length === 0 ? (
<div className="file-browser-empty">(empty directory)</div>
<div className="file-browser-empty">{t("fileBrowser.emptyDirectory", "(empty directory)")}</div>
) : (
entries.map((entry) => {
const fullPath = entryPath(currentPath, entry.name);

View File

@@ -1,5 +1,6 @@
import "./FileBrowser.css";
import { useState, useCallback, useEffect, useMemo, useRef, useId } from "react";
import { useTranslation } from "react-i18next";
import { X, Save, RotateCcw, Folder, FileType, ArrowLeft, ChevronDown, ChevronUp } from "lucide-react";
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
@@ -75,6 +76,7 @@ export function FileBrowserModal({
onWorkspaceChange,
projectId,
}: FileBrowserModalProps) {
const { t } = useTranslation("app");
const { projectName, workspaces } = useWorkspaces(projectId);
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, true, "fusion:files-modal-size");
@@ -278,13 +280,13 @@ export function FileBrowserModal({
const workspaceLabel = useMemo(() => {
if (currentWorkspace === "project") {
return "Project";
return t("fileBrowser.workspaceProject", "Project");
}
return workspaces.find((workspace) => workspace.id === currentWorkspace)?.id ?? currentWorkspace;
}, [currentWorkspace, workspaces]);
}, [currentWorkspace, workspaces, t]);
const modalTitle = `Files — ${workspaceLabel}`;
const modalTitle = t("fileBrowser.modalTitle", "Files — {{workspace}}", { workspace: workspaceLabel });
// Compute image source URL when an image file is selected
const imageSrc = useMemo(() => {
@@ -318,7 +320,7 @@ export function FileBrowserModal({
workspaces={workspaces}
onSelect={handleWorkspaceSelect}
/>
<button className="modal-close" onClick={onClose} aria-label="Close">
<button className="modal-close" onClick={onClose} aria-label={t("actions.close", "Close")}>
<X size={20} />
</button>
</div>
@@ -351,7 +353,7 @@ export function FileBrowserModal({
aria-valuemin={SIDEBAR_MIN_WIDTH}
aria-valuemax={SIDEBAR_MAX_WIDTH}
aria-valuenow={sidebarWidth}
aria-label="Resize sidebar"
aria-label={t("fileBrowser.resizeSidebar", "Resize sidebar")}
tabIndex={0}
onPointerDown={handleResizeStart}
onKeyDown={handleResizeKeyDown}
@@ -367,18 +369,18 @@ export function FileBrowserModal({
<button
className="file-browser-back-button"
onClick={handleBackToList}
aria-label="Back to file list"
aria-label={t("fileBrowser.back", "Back to file list")}
>
<ArrowLeft size={16} />
<span>Back</span>
<span>{t("actions.back", "Back")}</span>
</button>
)}
{!isBinaryFile(selectedFile) && (
<button
className="btn btn-sm btn-icon file-editor-toolbar-button"
onClick={() => setToolbarActionsExpanded((prev) => !prev)}
aria-label="Toggle editor options"
title="Toggle editor options"
aria-label={t("fileBrowser.toggleEditorOptions", "Toggle editor options")}
title={t("fileBrowser.toggleEditorOptions", "Toggle editor options")}
aria-expanded={toolbarActionsExpanded}
aria-controls={toolbarActionsId}
>
@@ -389,16 +391,16 @@ export function FileBrowserModal({
{isBinaryFile(selectedFile) && (
<span className="file-browser-binary-indicator">
<FileType size={12} />
Binary file — read only
{t("fileBrowser.binaryReadOnly", "Binary file — read only")}
</span>
)}
{mtime && (
<span className="file-browser-mtime">
Modified: {new Date(mtime).toLocaleString()}
{t("fileBrowser.modified", "Modified: {{date}}", { date: new Date(mtime).toLocaleString() })}
</span>
)}
{editorLoading && (
<span className="file-browser-loading">Loading...</span>
<span className="file-browser-loading">{t("fileBrowser.loading", "Loading…")}</span>
)}
</div>
<div className="file-browser-actions">
@@ -410,7 +412,7 @@ export function FileBrowserModal({
disabled={saving}
>
<RotateCcw size={14} />
Discard
{t("actions.discard", "Discard")}
</button>
<button
className="btn btn-primary btn-sm"
@@ -418,7 +420,7 @@ export function FileBrowserModal({
disabled={saving}
>
<Save size={14} />
{saving ? "Saving..." : "Save"}
{saving ? t("fileBrowser.saving", "Saving…") : t("actions.save", "Save")}
</button>
</>
)}
@@ -456,14 +458,14 @@ export function FileBrowserModal({
{!imageSrc && (
<div className="file-browser-footer">
<span>{formatFileSize(content)}</span>
{hasChanges && <span className="file-browser-unsaved">Unsaved changes</span>}
{hasChanges && <span className="file-browser-unsaved">{t("fileBrowser.unsavedChanges", "Unsaved changes")}</span>}
</div>
)}
</>
) : (
<div className="file-browser-placeholder">
<Folder size={48} opacity={0.3} />
<p>Select a file to edit</p>
<p>{t("fileBrowser.selectFileToEdit", "Select a file to edit")}</p>
</div>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useMemo, useRef, useId, useEffect } from "react";
import { useTranslation } from "react-i18next";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { FileEdit, Eye, ListOrdered, WrapText, ChevronDown, ChevronUp } from "lucide-react";
@@ -45,6 +46,7 @@ export function FileEditor({
toolbarExpanded,
toolbarActionsId: externalToolbarActionsId,
}: FileEditorProps) {
const { t } = useTranslation("app");
const [showPreview, setShowPreview] = useState(false);
const [wordWrap, setWordWrap] = useState(true);
const [internalExpanded, setInternalExpanded] = useState(false);
@@ -186,7 +188,7 @@ export function FileEditor({
{hasToolbarActions && (expanded || !isControlled) ? (
<div className={`file-editor-toolbar ${expanded ? "file-editor-toolbar--expanded" : ""}`}>
{!isControlled && (
<button className="btn btn-sm btn-icon file-editor-toolbar-button" onClick={handleToolbarActionsToggle} aria-label="Toggle editor options" title="Toggle editor options" aria-expanded={expanded} aria-controls={toolbarActionsId}>
<button className="btn btn-sm btn-icon file-editor-toolbar-button" onClick={handleToolbarActionsToggle} aria-label={t("fileEditor.toggleOptions", "Toggle editor options")} title={t("fileEditor.toggleOptions", "Toggle editor options")} aria-expanded={expanded} aria-controls={toolbarActionsId}>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
)}
@@ -194,27 +196,27 @@ export function FileEditor({
{isMarkdown ? (
<>
{!readOnly && (
<button className={`btn btn-sm file-editor-toolbar-button ${!effectiveShowPreview ? "btn-primary" : ""}`} onClick={handleEditClick} disabled={!effectiveShowPreview} aria-label="Edit mode">
<button className={`btn btn-sm file-editor-toolbar-button ${!effectiveShowPreview ? "btn-primary" : ""}`} onClick={handleEditClick} disabled={!effectiveShowPreview} aria-label={t("fileEditor.editMode", "Edit mode")}>
<FileEdit size={14} />
Edit
{t("fileEditor.edit", "Edit")}
</button>
)}
<button className={`btn btn-sm file-editor-toolbar-button ${effectiveShowPreview ? "btn-primary" : ""}`} onClick={handlePreviewClick} disabled={effectiveShowPreview} aria-label="Preview mode">
<button className={`btn btn-sm file-editor-toolbar-button ${effectiveShowPreview ? "btn-primary" : ""}`} onClick={handlePreviewClick} disabled={effectiveShowPreview} aria-label={t("fileEditor.previewMode", "Preview mode")}>
<Eye size={14} />
Preview
{t("fileEditor.preview", "Preview")}
</button>
</>
) : null}
{shouldShowLineNumbersToggle && (
<button className={`btn btn-sm file-editor-toolbar-button ${showLineNumbers ? "btn-primary" : ""}`} onClick={onToggleLineNumbers} aria-label="Toggle line numbers" aria-pressed={showLineNumbers} title="Toggle line numbers">
<button className={`btn btn-sm file-editor-toolbar-button ${showLineNumbers ? "btn-primary" : ""}`} onClick={onToggleLineNumbers} aria-label={t("fileEditor.toggleLineNumbers", "Toggle line numbers")} aria-pressed={showLineNumbers} title={t("fileEditor.toggleLineNumbers", "Toggle line numbers")}>
<ListOrdered size={14} />
<span>Line #</span>
<span>{t("fileEditor.lineNumber", "Line #")}</span>
</button>
)}
{!readOnly && (
<button className={`btn btn-sm file-editor-toolbar-button ${wordWrap ? "btn-primary" : ""}`} onClick={handleWordWrapToggle} aria-label="Toggle word wrap" title="Toggle word wrap">
<button className={`btn btn-sm file-editor-toolbar-button ${wordWrap ? "btn-primary" : ""}`} onClick={handleWordWrapToggle} aria-label={t("fileEditor.toggleWordWrap", "Toggle word wrap")} title={t("fileEditor.toggleWordWrap", "Toggle word wrap")}>
<WrapText size={14} />
<span>Wrap</span>
<span>{t("fileEditor.wrap", "Wrap")}</span>
</button>
)}
</div>
@@ -226,7 +228,7 @@ export function FileEditor({
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : (
<div className="file-editor-codemirror" ref={editorHostRef} aria-label={filePath ? `Editor for ${filePath}` : "File editor"} />
<div className="file-editor-codemirror" ref={editorHostRef} aria-label={filePath ? t("fileEditor.editorFor", `Editor for ${filePath}`) : t("fileEditor.fileEditor", "File editor")} />
)}
</div>
);

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