diff --git a/README.md b/README.md index 7ba43ce0f..666d9eb06 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ العربية

-Codeg (Code Generation) is a multi-agent coding workspace. It brings multiple agents (Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes, etc.) into one workspace, supporting conversation aggregation and multi-agent collaboration, with desktop installation plus server/Docker deployment. +Codeg (Code Generation) is a multi-agent coding workspace. It brings multiple agents (Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes Agent, etc.) into one workspace, supporting conversation aggregation and multi-agent collaboration, with desktop installation plus server/Docker deployment. ![gallery](./docs/images/gallery.svg) @@ -70,15 +70,15 @@ Codeg (Code Generation) is a multi-agent coding workspace. It brings multiple ag ## Supported Agents -| Agent | Environment Variable Path | macOS / Linux Default | Windows Default | -| ----------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | -| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | -| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | -| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | -| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | -| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | -| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | -| Hermes | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | +| Agent | Environment Variable Path | macOS / Linux Default | Windows Default | +| ------------ | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | +| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | +| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | +| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | +| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | +| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | +| Hermes Agent | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | > Note: environment variables take precedence over fallback paths. diff --git a/docs/images/weixin-dark.jpg b/docs/images/weixin-dark.jpg index 59b3c9dbd..54555b0ea 100644 Binary files a/docs/images/weixin-dark.jpg and b/docs/images/weixin-dark.jpg differ diff --git a/docs/images/weixin-light.jpg b/docs/images/weixin-light.jpg index 61075275f..980e1d31c 100644 Binary files a/docs/images/weixin-light.jpg and b/docs/images/weixin-light.jpg differ diff --git a/docs/readme/README.ar.md b/docs/readme/README.ar.md index 7505f9683..a747aec7e 100644 --- a/docs/readme/README.ar.md +++ b/docs/readme/README.ar.md @@ -19,7 +19,7 @@ العربية

-Codeg (Code Generation) هو مساحة عمل للبرمجة متعددة الوكلاء. يجمع عدة وكلاء (Claude Code، Codex CLI، OpenCode، Gemini CLI، OpenClaw، Cline، Hermes، وغيرها) في مساحة عمل واحدة، ويدعم تجميع المحادثات والتعاون بين عدة وكلاء، مع دعم التثبيت على سطح المكتب والنشر على الخادم/Docker. +Codeg (Code Generation) هو مساحة عمل للبرمجة متعددة الوكلاء. يجمع عدة وكلاء (Claude Code، Codex CLI، OpenCode، Gemini CLI، OpenClaw، Cline، Hermes Agent، وغيرها) في مساحة عمل واحدة، ويدعم تجميع المحادثات والتعاون بين عدة وكلاء، مع دعم التثبيت على سطح المكتب والنشر على الخادم/Docker. ![gallery](../images/gallery.svg) @@ -70,15 +70,15 @@ Codeg (Code Generation) هو مساحة عمل للبرمجة متعددة ال ## الوكلاء المدعومون -| الوكيل | مسار متغير البيئة | الافتراضي في macOS / Linux | الافتراضي في Windows | -| ----------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | -| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | -| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | -| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | -| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | -| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | -| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | -| Hermes | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | +| الوكيل | مسار متغير البيئة | الافتراضي في macOS / Linux | الافتراضي في Windows | +| ------------ | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | +| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | +| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | +| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | +| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | +| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | +| Hermes Agent | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | > ملاحظة: متغيرات البيئة لها الأولوية على المسارات الافتراضية. diff --git a/docs/readme/README.de.md b/docs/readme/README.de.md index 7f2013463..d15f3092e 100644 --- a/docs/readme/README.de.md +++ b/docs/readme/README.de.md @@ -19,7 +19,7 @@ العربية

-Codeg (Code Generation) ist ein Multi-Agent-Coding-Workspace. Es vereint mehrere Agenten (Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes usw.) in einem Arbeitsbereich, unterstützt Konversationsaggregation und Multi-Agent-Zusammenarbeit sowie Desktop-Installation und Server-/Docker-Bereitstellung. +Codeg (Code Generation) ist ein Multi-Agent-Coding-Workspace. Es vereint mehrere Agenten (Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes Agent usw.) in einem Arbeitsbereich, unterstützt Konversationsaggregation und Multi-Agent-Zusammenarbeit sowie Desktop-Installation und Server-/Docker-Bereitstellung. ![gallery](../images/gallery.svg) @@ -70,15 +70,15 @@ Codeg (Code Generation) ist ein Multi-Agent-Coding-Workspace. Es vereint mehrere ## Unterstützte Agenten -| Agent | Umgebungsvariablen-Pfad | macOS / Linux Standard | Windows Standard | -| ----------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | -| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | -| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | -| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | -| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | -| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | -| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | -| Hermes | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | +| Agent | Umgebungsvariablen-Pfad | macOS / Linux Standard | Windows Standard | +| ------------ | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | +| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | +| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | +| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | +| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | +| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | +| Hermes Agent | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | > Hinweis: Umgebungsvariablen haben Vorrang vor Fallback-Pfaden. diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index c0b2bb6e1..a3bf1c68d 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -19,7 +19,7 @@ العربية

-Codeg (Code Generation) es un espacio de trabajo de codificación multiagente. Unifica varios agentes (Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes, etc.) en un único espacio de trabajo, admite agregación de conversaciones y colaboración multiagente, y permite instalación de escritorio y despliegue en servidor/Docker. +Codeg (Code Generation) es un espacio de trabajo de codificación multiagente. Unifica varios agentes (Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes Agent, etc.) en un único espacio de trabajo, admite agregación de conversaciones y colaboración multiagente, y permite instalación de escritorio y despliegue en servidor/Docker. ![gallery](../images/gallery.svg) @@ -70,15 +70,15 @@ Codeg (Code Generation) es un espacio de trabajo de codificación multiagente. U ## Agentes compatibles -| Agente | Ruta de variable de entorno | Ruta por defecto en macOS / Linux | Ruta por defecto en Windows | -| ----------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | -| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | -| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | -| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | -| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | -| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | -| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | -| Hermes | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | +| Agente | Ruta de variable de entorno | Ruta por defecto en macOS / Linux | Ruta por defecto en Windows | +| ------------ | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | +| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | +| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | +| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | +| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | +| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | +| Hermes Agent | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | > Nota: las variables de entorno tienen prioridad sobre las rutas de respaldo. diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index ea60e9816..9dccd19e2 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -19,7 +19,7 @@ العربية

-Codeg (Code Generation) est un espace de travail de codage multi-agent. Il réunit plusieurs agents (Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes, etc.) dans un seul espace de travail, prend en charge l'agrégation des conversations et la collaboration multi-agent, ainsi que l'installation desktop et le déploiement serveur/Docker. +Codeg (Code Generation) est un espace de travail de codage multi-agent. Il réunit plusieurs agents (Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes Agent, etc.) dans un seul espace de travail, prend en charge l'agrégation des conversations et la collaboration multi-agent, ainsi que l'installation desktop et le déploiement serveur/Docker. ![gallery](../images/gallery.svg) @@ -70,15 +70,15 @@ Codeg (Code Generation) est un espace de travail de codage multi-agent. Il réun ## Agents supportés -| Agent | Chemin via variable d'environnement | Défaut macOS / Linux | Défaut Windows | -| ----------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | -| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | -| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | -| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | -| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | -| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | -| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | -| Hermes | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | +| Agent | Chemin via variable d'environnement | Défaut macOS / Linux | Défaut Windows | +| ------------ | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | +| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | +| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | +| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | +| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | +| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | +| Hermes Agent | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | > Remarque : les variables d'environnement ont priorité sur les chemins par défaut. diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index ea52f495b..91ca49a83 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -19,7 +19,7 @@ العربية

-Codeg(Code Generation)は、マルチエージェント・コーディングワークスペースです。Claude Code、Codex CLI、OpenCode、Gemini CLI、OpenClaw、Cline、Hermes などの複数のエージェントを 1 つのワークスペースに統合し、会話の集約とマルチエージェント協働に対応します。デスクトップへのインストールに加え、サーバー/Docker デプロイにも対応しています。 +Codeg(Code Generation)は、マルチエージェント・コーディングワークスペースです。Claude Code、Codex CLI、OpenCode、Gemini CLI、OpenClaw、Cline、Hermes Agent などの複数のエージェントを 1 つのワークスペースに統合し、会話の集約とマルチエージェント協働に対応します。デスクトップへのインストールに加え、サーバー/Docker デプロイにも対応しています。 ![gallery](../images/gallery.svg) @@ -70,15 +70,15 @@ Codeg(Code Generation)は、マルチエージェント・コーディング ## 対応エージェント -| Agent | 環境変数パス | macOS / Linux デフォルト | Windows デフォルト | -| ----------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | -| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | -| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | -| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | -| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | -| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | -| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | -| Hermes | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | +| Agent | 環境変数パス | macOS / Linux デフォルト | Windows デフォルト | +| ------------ | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | +| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | +| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | +| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | +| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | +| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | +| Hermes Agent | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | > 注: 環境変数はフォールバックパスより優先されます。 diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 5c9b15e01..fbdc415ab 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -19,7 +19,7 @@ العربية

-Codeg(Code Generation)는 멀티 에이전트 코딩 워크스페이스입니다. Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes 등의 여러 에이전트를 하나의 워크스페이스로 통합하며, 대화 집계와 멀티 에이전트 협업을 지원하고 데스크톱 설치와 서버/Docker 배포를 지원합니다. +Codeg(Code Generation)는 멀티 에이전트 코딩 워크스페이스입니다. Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes Agent 등의 여러 에이전트를 하나의 워크스페이스로 통합하며, 대화 집계와 멀티 에이전트 협업을 지원하고 데스크톱 설치와 서버/Docker 배포를 지원합니다. ![gallery](../images/gallery.svg) @@ -70,15 +70,15 @@ Codeg(Code Generation)는 멀티 에이전트 코딩 워크스페이스입니다 ## 지원 에이전트 -| Agent | 환경 변수 경로 | macOS / Linux 기본값 | Windows 기본값 | -| ----------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | -| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | -| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | -| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | -| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | -| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | -| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | -| Hermes | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | +| Agent | 환경 변수 경로 | macOS / Linux 기본값 | Windows 기본값 | +| ------------ | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | +| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | +| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | +| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | +| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | +| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | +| Hermes Agent | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | > 참고: 환경 변수가 기본 경로보다 우선합니다. diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 5cc671da4..37da5de2c 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -19,7 +19,7 @@ العربية

-Codeg (Code Generation) é um workspace de codificação multiagente. Ele reúne vários agentes (Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes, etc.) em um único workspace, com suporte à agregação de conversas e à colaboração multiagente, além de instalação desktop e implantação em servidor/Docker. +Codeg (Code Generation) é um workspace de codificação multiagente. Ele reúne vários agentes (Claude Code, Codex CLI, OpenCode, Gemini CLI, OpenClaw, Cline, Hermes Agent, etc.) em um único workspace, com suporte à agregação de conversas e à colaboração multiagente, além de instalação desktop e implantação em servidor/Docker. ![gallery](../images/gallery.svg) @@ -70,15 +70,15 @@ Codeg (Code Generation) é um workspace de codificação multiagente. Ele reúne ## Agentes suportados -| Agente | Caminho por variável de ambiente | Padrão macOS / Linux | Padrão Windows | -| ----------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | -| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | -| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | -| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | -| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | -| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | -| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | -| Hermes | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | +| Agente | Caminho por variável de ambiente | Padrão macOS / Linux | Padrão Windows | +| ------------ | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | +| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | +| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | +| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | +| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | +| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | +| Hermes Agent | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | > Nota: as variáveis de ambiente têm prioridade sobre os caminhos padrão. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index f1034abd6..397379274 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -19,7 +19,7 @@ العربية

-Codeg(Code Generation)是一个多智能体编码工作台,它将多个智能体(Claude Code、Codex CLI、OpenCode、Gemini CLI、OpenClaw、Cline、Hermes 等)统一到一个工作区中,支持会话聚合和多智能体协作,支持桌面安装,服务器/Docker 部署。 +Codeg(Code Generation)是一个多智能体编码工作台,它将多个智能体(Claude Code、Codex CLI、OpenCode、Gemini CLI、OpenClaw、Cline、Hermes Agent 等)统一到一个工作区中,支持会话聚合和多智能体协作,支持桌面安装,服务器/Docker 部署。 ![gallery](../images/gallery.svg) @@ -70,15 +70,15 @@ Codeg(Code Generation)是一个多智能体编码工作台,它将多个智 ## 支持的Agent -| Agent | 环境变量优先路径 | macOS / Linux 默认路径 | Windows 默认路径 | -| ----------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | -| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | -| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | -| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | -| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | -| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | -| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | -| Hermes | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | +| Agent | 环境变量优先路径 | macOS / Linux 默认路径 | Windows 默认路径 | +| ------------ | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | +| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | +| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | +| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | +| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | +| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | +| Hermes Agent | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | > 注意:环境变量的优先级高于默认路径。 diff --git a/docs/readme/README.zh-TW.md b/docs/readme/README.zh-TW.md index 489a2b00c..c72d2223d 100644 --- a/docs/readme/README.zh-TW.md +++ b/docs/readme/README.zh-TW.md @@ -19,7 +19,7 @@ العربية

-Codeg(Code Generation)是一個多智慧體編碼工作台,它將多個智慧體(Claude Code、Codex CLI、OpenCode、Gemini CLI、OpenClaw、Cline、Hermes 等)統一到一個工作區中,支援會話彙整和多智慧體協作,支援桌面安裝、伺服器/Docker 部署。 +Codeg(Code Generation)是一個多智慧體編碼工作台,它將多個智慧體(Claude Code、Codex CLI、OpenCode、Gemini CLI、OpenClaw、Cline、Hermes Agent 等)統一到一個工作區中,支援會話彙整和多智慧體協作,支援桌面安裝、伺服器/Docker 部署。 ![gallery](../images/gallery.svg) @@ -70,15 +70,15 @@ Codeg(Code Generation)是一個多智慧體編碼工作台,它將多個智 ## 支援的 Agent -| Agent | 環境變數優先路徑 | macOS / Linux 預設路徑 | Windows 預設路徑 | -| ----------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | -| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | -| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | -| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | -| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | -| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | -| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | -| Hermes | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | +| Agent | 環境變數優先路徑 | macOS / Linux 預設路徑 | Windows 預設路徑 | +| ------------ | ------------------------------------- | ------------------------------------- | ----------------------------------------------------- | +| Claude Code | `$CLAUDE_CONFIG_DIR/projects` | `~/.claude/projects` | `%USERPROFILE%\\.claude\\projects` | +| Codex CLI | `$CODEX_HOME/sessions` | `~/.codex/sessions` | `%USERPROFILE%\\.codex\\sessions` | +| OpenCode | `$XDG_DATA_HOME/opencode/opencode.db` | `~/.local/share/opencode/opencode.db` | `%USERPROFILE%\\.local\\share\\opencode\\opencode.db` | +| Gemini CLI | `$GEMINI_CLI_HOME/.gemini` | `~/.gemini` | `%USERPROFILE%\\.gemini` | +| OpenClaw | — | `~/.openclaw/agents` | `%USERPROFILE%\\.openclaw\\agents` | +| Cline | `$CLINE_DIR` | `~/.cline/data/tasks` | `%USERPROFILE%\\.cline\\data\\tasks` | +| Hermes Agent | `$HERMES_HOME/state.db` | `~/.hermes/state.db` | `%USERPROFILE%\\.hermes\\state.db` | > 注意:環境變數的優先順序高於預設路徑。 diff --git a/package.json b/package.json index 562c374ca..142fd0a07 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeg", "private": true, - "version": "0.15.7", + "version": "0.15.8", "scripts": { "dev": "next dev --turbopack", "build": "next build", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a410a9cce..e640314ef 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -982,7 +982,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "codeg" -version = "0.15.7" +version = "0.15.8" dependencies = [ "aes-gcm", "agent-client-protocol-schema", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6ce40439d..7997e30a2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeg" -version = "0.15.7" +version = "0.15.8" description = "Agent Code Generation App" authors = ["feitao"] edition = "2021" diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 51e2d529d..32452968c 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -955,6 +955,147 @@ pub async fn create_conversation( Ok(id) } +/// Result of [`create_chat_conversation_core`]: the new conversation id plus the +/// hidden chat folder backing it, so the frontend can drop the folder straight +/// into `allFolders` (resolving cwd / active-folder) without a refetch. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateChatConversationResult { + pub conversation_id: i32, + pub folder_id: i32, + pub folder: FolderDetail, +} + +/// Result of [`create_chat_dir`]: the freshly created scratch directory path. +/// Handed to the frontend so a chat draft can point its ACP connection at a real +/// cwd *before* any conversation row exists. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateChatDirResult { + pub path: String, +} + +/// Create a fresh dated scratch directory for a chat-mode conversation and +/// return its absolute path. Mirrors Codex's date-grouped session dirs: +/// `/chat-sessions///`. +/// +/// This is a pure filesystem operation — it writes NO database rows — so it can +/// run eagerly the moment the user picks "no-folder mode" (giving the ACP +/// connection a cwd to spawn in) without breaching the lazy-conversation +/// invariant. The row-creating [`create_chat_conversation_core`] later reuses +/// this directory via its `existing_dir` parameter, so the connection's cwd +/// never moves across the first send. +pub fn create_chat_dir_core(data_dir: &std::path::Path) -> Result { + let date = chrono::Local::now().format("%Y-%m-%d").to_string(); + let unique = uuid::Uuid::new_v4().simple().to_string(); + let dir = data_dir.join("chat-sessions").join(date).join(unique); + std::fs::create_dir_all(&dir).map_err(AppCommandError::io)?; + Ok(dir.to_string_lossy().to_string()) +} + +/// Core logic for creating a folderless "chat mode" conversation. Mirrors +/// Codex's date-grouped session dirs: each chat conversation gets its own +/// scratch directory under `/chat-sessions///` plus a +/// dedicated hidden `is_chat` folder pointing at it, so the NOT-NULL `folder_id` +/// FK stays satisfied. Called lazily on first prompt send — never before — so +/// merely selecting "no-folder mode" writes nothing to the DB. Shared by the +/// Tauri command and the web handler. +/// +/// `existing_dir`: when the frontend already eagerly created a scratch dir (to +/// connect ACP before sending), pass it here so this reuses it instead of +/// minting a second one — keeping the connection's cwd put across the lazy +/// create. `None` mints a fresh dir (the send-before-dir-ready fallback). +/// `create_dir_all` is idempotent, so re-ensuring an existing dir is harmless. +pub async fn create_chat_conversation_core( + conn: &sea_orm::DatabaseConnection, + data_dir: &std::path::Path, + agent_type: AgentType, + title: Option, + existing_dir: Option<&str>, +) -> Result { + let path = match existing_dir { + Some(dir) => { + std::fs::create_dir_all(dir).map_err(AppCommandError::io)?; + dir.to_string() + } + None => create_chat_dir_core(data_dir)?, + }; + + let folder = folder_service::add_chat_folder(conn, &path) + .await + .map_err(AppCommandError::from)?; + + // A fresh empty scratch dir has no git repo, so skip branch detection — this + // also keeps the composer/top-bar branch pickers hidden in chat mode. No + // transaction spans the folder + conversation inserts (the service calls take + // a plain connection), so if the conversation insert fails, compensate by + // soft-deleting the just-created hidden folder — otherwise it would linger as + // an orphan (active, conversation-less, never reached by the delete path) and + // pollute the active-folder scope. + let model = match conversation_service::create(conn, folder.id, agent_type, title, None).await { + Ok(model) => model, + Err(create_err) => { + if let Err(cleanup_err) = folder_service::remove_folder(conn, &folder.path).await { + eprintln!( + "[conversations] failed to clean up orphan chat folder {} after conversation create error: {cleanup_err}", + folder.id + ); + } + return Err(AppCommandError::from(create_err)); + } + }; + + Ok(CreateChatConversationResult { + conversation_id: model.id, + folder_id: folder.id, + folder, + }) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn create_chat_conversation( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + agent_type: AgentType, + title: Option, + existing_dir: Option, +) -> Result { + use tauri::Manager; + let data_dir = app + .path() + .app_data_dir() + .map(|p| crate::paths::resolve_effective_data_dir(&p)) + .unwrap_or_else(|_| std::path::PathBuf::from(".")); + let result = create_chat_conversation_core( + &db.conn, + &data_dir, + agent_type, + title, + existing_dir.as_deref(), + ) + .await?; + emit_conversation_upsert(&EventEmitter::Tauri(app), &db.conn, result.conversation_id).await; + Ok(result) +} + +/// Eagerly create a chat-mode scratch directory (no DB rows) and return its +/// path, so the frontend can connect ACP at a real cwd the instant the user +/// selects "no-folder mode" — before any first prompt. The hidden folder + +/// conversation are still created lazily on first send (reusing this dir). +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn create_chat_dir(app: tauri::AppHandle) -> Result { + use tauri::Manager; + let data_dir = app + .path() + .app_data_dir() + .map(|p| crate::paths::resolve_effective_data_dir(&p)) + .unwrap_or_else(|_| std::path::PathBuf::from(".")); + let path = create_chat_dir_core(&data_dir)?; + Ok(CreateChatDirResult { path }) +} + async fn detect_git_branch(path: &str) -> Option { let output = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) @@ -1056,6 +1197,71 @@ pub async fn delete_conversation_core( .map_err(AppCommandError::from) } +/// When the deleted conversation was backed by a dedicated hidden chat folder, +/// soft-delete that folder too so it stops counting toward `list_all`'s active +/// folder scope. The per-conversation scratch dir on disk is intentionally left +/// in place (symmetric with conversation soft-delete keeping session files; a +/// future GC can prune dirs whose folder is soft-deleted). Best effort — +/// failures are logged, never propagated. `folder_id` must be captured BEFORE +/// the conversation soft-delete. +pub async fn cleanup_chat_folder_for_deleted_conversation( + conn: &sea_orm::DatabaseConnection, + folder_id: i32, +) { + match folder_service::get_folder_by_id(conn, folder_id).await { + Ok(Some(folder)) if folder.is_chat => { + // Only retire the hidden folder once it backs no remaining + // (non-deleted) conversations, so deleting one chat conversation can + // never hide another that happens to share the folder. (Normally a + // chat folder backs exactly one conversation, but this keeps the + // delete path safe regardless.) + match conversation_service::list_by_folder(conn, folder_id, None, None, None, None).await + { + Ok(remaining) if remaining.is_empty() => { + if let Err(e) = folder_service::remove_folder(conn, &folder.path).await { + eprintln!( + "[conversations] chat folder cleanup failed (folder {folder_id}): {e}" + ); + } + } + Ok(_) => {} + Err(e) => eprintln!( + "[conversations] chat folder conversation check failed (folder {folder_id}): {e}" + ), + } + } + Ok(_) => {} + Err(e) => { + eprintln!("[conversations] chat folder lookup failed (folder {folder_id}): {e}") + } + } +} + +/// Full conversation-delete orchestration shared by the Tauri command and the web +/// handler: capture the backing folder BEFORE the soft-delete (so a hidden chat +/// folder can be retired afterward), soft-delete, broadcast the deletion, then run +/// the tab + chat-folder cleanups. The thin `delete_conversation_core` primitive +/// stays event-free for internal/test callers, so the orchestration lives here. +pub async fn delete_conversation_with_cleanup_core( + emitter: &EventEmitter, + conn: &sea_orm::DatabaseConnection, + conversation_id: i32, +) -> Result<(), AppCommandError> { + // Capture the backing folder before the soft-delete so a hidden chat folder + // can be cleaned up afterward. + let folder_id = conversation_service::get_by_id(conn, conversation_id) + .await + .ok() + .map(|c| c.folder_id); + delete_conversation_core(conn, conversation_id).await?; + emit_conversation_deleted(emitter, conversation_id); + cleanup_tabs_for_deleted_conversation(emitter, conn, conversation_id).await; + if let Some(folder_id) = folder_id { + cleanup_chat_folder_for_deleted_conversation(conn, folder_id).await; + } + Ok(()) +} + #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn delete_conversation( @@ -1063,11 +1269,8 @@ pub async fn delete_conversation( db: tauri::State<'_, AppDatabase>, conversation_id: i32, ) -> Result<(), AppCommandError> { - delete_conversation_core(&db.conn, conversation_id).await?; let emitter = EventEmitter::Tauri(app); - emit_conversation_deleted(&emitter, conversation_id); - cleanup_tabs_for_deleted_conversation(&emitter, &db.conn, conversation_id).await; - Ok(()) + delete_conversation_with_cleanup_core(&emitter, &db.conn, conversation_id).await } fn compute_stats(all_conversations: &[ConversationSummary]) -> AgentStats { @@ -1683,6 +1886,215 @@ mod tests { } } + #[tokio::test] + async fn create_chat_conversation_core_creates_dir_folder_and_conversation() { + let db = fresh_in_memory_db().await; + let data_dir = tempfile::tempdir().expect("tempdir"); + let result = create_chat_conversation_core( + &db.conn, + data_dir.path(), + AgentType::ClaudeCode, + Some("hello chat".into()), + None, + ) + .await + .expect("create chat conversation"); + + // The backing folder is a hidden, top-level chat folder. + assert!(result.folder.is_chat, "folder must be is_chat"); + assert_eq!(result.folder.parent_id, None); + assert_eq!(result.folder_id, result.folder.id); + assert!( + result + .folder + .path + .starts_with(&*data_dir.path().to_string_lossy()), + "scratch path under data dir: {}", + result.folder.path + ); + // The dated scratch dir exists on disk. + assert!( + std::path::Path::new(&result.folder.path).is_dir(), + "scratch dir created" + ); + + // The conversation points at the hidden folder, with no git branch. + let summary = conversation_service::get_by_id(&db.conn, result.conversation_id) + .await + .expect("read back"); + assert_eq!(summary.folder_id, result.folder_id); + assert_eq!(summary.agent_type, AgentType::ClaudeCode); + assert!(summary.git_branch.is_none()); + + // It surfaces in the default sidebar query (active-folder scope). + let rows = + list_all_conversations_core(&db.conn, None, None, None, None, None, false) + .await + .expect("list"); + assert!(rows.iter().any(|c| c.id == result.conversation_id)); + } + + #[tokio::test] + async fn create_chat_dir_core_creates_dated_dir_without_db_rows() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let path = create_chat_dir_core(data_dir.path()).expect("create chat dir"); + + assert!(std::path::Path::new(&path).is_dir(), "scratch dir exists"); + assert!( + path.starts_with(&*data_dir.path().to_string_lossy()), + "under data dir: {path}" + ); + assert!( + path.contains("chat-sessions"), + "date-grouped under chat-sessions: {path}" + ); + // Two calls mint distinct directories (uuid segment). + let other = create_chat_dir_core(data_dir.path()).expect("second chat dir"); + assert_ne!(path, other, "each prepare gets its own dir"); + } + + #[tokio::test] + async fn create_chat_conversation_core_reuses_existing_dir() { + let db = fresh_in_memory_db().await; + let data_dir = tempfile::tempdir().expect("tempdir"); + // Eager step: mint the scratch dir first (as the frontend does on select). + let prepared = create_chat_dir_core(data_dir.path()).expect("prepare dir"); + + let result = create_chat_conversation_core( + &db.conn, + data_dir.path(), + AgentType::ClaudeCode, + None, + Some(prepared.as_str()), + ) + .await + .expect("create chat conversation reusing dir"); + + // The conversation's hidden folder points at the SAME pre-created dir — + // no second directory was minted, so the ACP cwd never moved. + assert_eq!( + result.folder.path, prepared, + "reuses the eagerly-created scratch dir" + ); + + // Exactly one uuid dir exists under that date bucket. + let date_dir = std::path::Path::new(&prepared) + .parent() + .expect("date dir") + .to_path_buf(); + let count = std::fs::read_dir(&date_dir) + .expect("read date dir") + .filter_map(Result::ok) + .filter(|e| e.path().is_dir()) + .count(); + assert_eq!(count, 1, "no duplicate scratch dir created"); + } + + #[tokio::test] + async fn cleanup_chat_folder_soft_deletes_hidden_folder() { + let db = fresh_in_memory_db().await; + let data_dir = tempfile::tempdir().expect("tempdir"); + let res = + create_chat_conversation_core(&db.conn, data_dir.path(), AgentType::Codex, None, None) + .await + .expect("create"); + + // Before cleanup the hidden folder is active. + assert!(folder_service::get_folder_by_id(&db.conn, res.folder_id) + .await + .unwrap() + .is_some()); + + delete_conversation_core(&db.conn, res.conversation_id) + .await + .expect("delete conversation"); + cleanup_chat_folder_for_deleted_conversation(&db.conn, res.folder_id).await; + + // After cleanup the hidden folder is soft-deleted (no longer returned), + // so it stops counting toward the active-folder scope. The on-disk dir is + // intentionally left in place. + assert!(folder_service::get_folder_by_id(&db.conn, res.folder_id) + .await + .unwrap() + .is_none()); + assert!( + std::path::Path::new(&res.folder.path).is_dir(), + "scratch dir is intentionally retained on delete" + ); + } + + #[tokio::test] + async fn cleanup_chat_folder_keeps_folder_with_remaining_conversations() { + let db = fresh_in_memory_db().await; + let data_dir = tempfile::tempdir().expect("tempdir"); + let res = + create_chat_conversation_core(&db.conn, data_dir.path(), AgentType::Codex, None, None) + .await + .expect("create"); + // Simulate a second conversation that happens to share the hidden folder. + let second = + conversation_service::create(&db.conn, res.folder_id, AgentType::Codex, None, None) + .await + .expect("second conversation"); + + // Deleting the first must NOT retire the folder — the second remains. + delete_conversation_core(&db.conn, res.conversation_id) + .await + .expect("delete first"); + cleanup_chat_folder_for_deleted_conversation(&db.conn, res.folder_id).await; + assert!( + folder_service::get_folder_by_id(&db.conn, res.folder_id) + .await + .unwrap() + .is_some(), + "folder retained while a sibling conversation remains" + ); + + // Deleting the last one retires the now-empty folder. + delete_conversation_core(&db.conn, second.id) + .await + .expect("delete second"); + cleanup_chat_folder_for_deleted_conversation(&db.conn, res.folder_id).await; + assert!( + folder_service::get_folder_by_id(&db.conn, res.folder_id) + .await + .unwrap() + .is_none(), + "folder retired once empty" + ); + } + + #[tokio::test] + async fn chat_folders_excluded_from_user_facing_lists_but_in_all_details() { + let db = fresh_in_memory_db().await; + let data_dir = tempfile::tempdir().expect("tempdir"); + let normal_id = seed_folder(&db, "/tmp/codeg-chat-list-test").await; + let chat_id = + create_chat_conversation_core(&db.conn, data_dir.path(), AgentType::Codex, None, None) + .await + .expect("chat") + .folder_id; + + // Folder history excludes the hidden chat folder, keeps the normal one. + let history = folder_service::list_folders(&db.conn).await.unwrap(); + assert!(history.iter().any(|f| f.id == normal_id)); + assert!(!history.iter().any(|f| f.id == chat_id)); + + // Open-folder surfaces exclude it too. + let open_details = folder_service::list_open_folder_details(&db.conn) + .await + .unwrap(); + assert!(!open_details.iter().any(|f| f.id == chat_id)); + let open_entries = folder_service::list_open_folders(&db.conn).await.unwrap(); + assert!(!open_entries.iter().any(|f| f.id == chat_id)); + + // But the full set keeps it (internal cwd / active-folder resolution). + let all = folder_service::list_all_folder_details(&db.conn) + .await + .unwrap(); + assert!(all.iter().any(|f| f.id == chat_id && f.is_chat)); + } + #[tokio::test] async fn get_folder_conversation_core_missing_id_errors() { let db = fresh_in_memory_db().await; diff --git a/src-tauri/src/db/entities/folder.rs b/src-tauri/src/db/entities/folder.rs index 55b015cb3..c84614bda 100644 --- a/src-tauri/src/db/entities/folder.rs +++ b/src-tauri/src/db/entities/folder.rs @@ -21,6 +21,10 @@ pub struct Model { /// top-level folders. Flattened: a worktree of a worktree still points at the /// original root, never an intermediate worktree. pub parent_id: Option, + /// True for the dedicated hidden folder backing a single chat-mode + /// (folderless) conversation. Excluded from user-facing folder lists; its + /// conversations route to the sidebar "Chat" group. + pub is_chat: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src-tauri/src/db/migration/m20260611_000001_folder_is_chat.rs b/src-tauri/src/db/migration/m20260611_000001_folder_is_chat.rs new file mode 100644 index 000000000..e4e48e0d0 --- /dev/null +++ b/src-tauri/src/db/migration/m20260611_000001_folder_is_chat.rs @@ -0,0 +1,40 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Folder::Table) + .add_column( + ColumnDef::new(Folder::IsChat) + .boolean() + .not_null() + .default(false), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Folder::Table) + .drop_column(Folder::IsChat) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Folder { + Table, + IsChat, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 3b2b44884..002188455 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -20,6 +20,7 @@ mod m20260522_000001_delegation_columns; mod m20260607_000001_folder_parent_id; mod m20260608_000001_conversation_title_locked; mod m20260610_000001_conversation_pinned_at; +mod m20260611_000001_folder_is_chat; pub struct Migrator; #[async_trait::async_trait] @@ -46,6 +47,7 @@ impl MigratorTrait for Migrator { Box::new(m20260607_000001_folder_parent_id::Migration), Box::new(m20260608_000001_conversation_title_locked::Migration), Box::new(m20260610_000001_conversation_pinned_at::Migration), + Box::new(m20260611_000001_folder_is_chat::Migration), ] } } diff --git a/src-tauri/src/db/service/folder_service.rs b/src-tauri/src/db/service/folder_service.rs index 06f298acb..2a68e182c 100644 --- a/src-tauri/src/db/service/folder_service.rs +++ b/src-tauri/src/db/service/folder_service.rs @@ -40,6 +40,7 @@ fn to_detail(m: folder::Model) -> FolderDetail { sort_order: m.sort_order, color: m.color, parent_id: m.parent_id, + is_chat: m.is_chat, } } @@ -141,6 +142,7 @@ async fn add_folder_inner( ParentWrite::Preserve => None, ParentWrite::Set(parent_id) => parent_id, }), + is_chat: Set(false), }; active.insert(conn).await? }; @@ -148,6 +150,45 @@ async fn add_folder_inner( Ok(to_entry(model)) } +/// Create a dedicated hidden folder backing a single chat-mode conversation. +/// +/// Unlike [`add_folder`], the display name is a fixed sentinel ("Chat") rather +/// than derived from the path, and `is_chat` is set so the frontend routes this +/// folder's conversations to the sidebar "Chat" group and hides folder-bound +/// chrome. `path` is a freshly generated per-conversation scratch dir, so it +/// never collides on the `UNIQUE(path)` constraint. Returns the full +/// [`FolderDetail`] so the caller can hand it straight to the frontend. +pub async fn add_chat_folder( + conn: &DatabaseConnection, + path: &str, +) -> Result { + let now = Utc::now(); + let max_order = folder::Entity::find() + .order_by_desc(folder::Column::SortOrder) + .one(conn) + .await? + .map(|m| m.sort_order) + .unwrap_or(0); + let active = folder::ActiveModel { + id: NotSet, + name: Set("Chat".to_string()), + path: Set(path.to_string()), + git_branch: Set(None), + default_agent_type: Set(None), + last_opened_at: Set(now), + created_at: Set(now), + updated_at: Set(now), + deleted_at: Set(None), + is_open: Set(true), + sort_order: Set(max_order + 1), + color: Set(DEFAULT_FOLDER_COLOR.to_string()), + parent_id: Set(None), + is_chat: Set(true), + }; + let model = active.insert(conn).await?; + Ok(to_detail(model)) +} + pub async fn update_folder_color( conn: &DatabaseConnection, folder_id: i32, @@ -199,6 +240,9 @@ pub async fn update_folder_default_agent( pub async fn list_folders(conn: &DatabaseConnection) -> Result, DbError> { let rows = folder::Entity::find() .filter(folder::Column::DeletedAt.is_null()) + // Hidden chat folders are an implementation detail, never user-facing in + // folder history / open-folder pickers. + .filter(folder::Column::IsChat.eq(false)) .order_by_desc(folder::Column::LastOpenedAt) .all(conn) .await?; @@ -245,6 +289,7 @@ pub async fn list_open_folders( let rows = folder::Entity::find() .filter(folder::Column::DeletedAt.is_null()) .filter(folder::Column::IsOpen.eq(true)) + .filter(folder::Column::IsChat.eq(false)) .order_by_desc(folder::Column::LastOpenedAt) .all(conn) .await?; @@ -255,9 +300,13 @@ pub async fn list_open_folders( pub async fn list_open_folder_details( conn: &DatabaseConnection, ) -> Result, DbError> { + // Excludes hidden chat folders from the workspace "open folders" surface. + // `list_all_folder_details` (below) intentionally keeps them so the frontend + // can still resolve an active chat conversation's cwd / active folder by id. let rows = folder::Entity::find() .filter(folder::Column::DeletedAt.is_null()) .filter(folder::Column::IsOpen.eq(true)) + .filter(folder::Column::IsChat.eq(false)) .order_by_asc(folder::Column::SortOrder) .order_by_desc(folder::Column::LastOpenedAt) .all(conn) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d1df5d430..8d4209b8f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -740,6 +740,8 @@ mod tauri_app { conversations::get_stats, conversations::get_sidebar_data, conversations::create_conversation, + conversations::create_chat_conversation, + conversations::create_chat_dir, conversations::update_conversation_status, conversations::update_conversation_title, conversations::update_conversation_pinned, diff --git a/src-tauri/src/models/folder.rs b/src-tauri/src/models/folder.rs index 323ffe60e..d0c8eeedc 100644 --- a/src-tauri/src/models/folder.rs +++ b/src-tauri/src/models/folder.rs @@ -24,6 +24,10 @@ pub struct FolderDetail { /// Root folder this one was created under (worktree folders only); NULL for /// top-level folders. Drives sidebar merge + worktree-branch detection. pub parent_id: Option, + /// True for a hidden chat-mode folder. The frontend keeps it in `allFolders` + /// (so cwd / active-folder resolve) but hides it from folder lists and routes + /// its conversations to the sidebar "Chat" group. + pub is_chat: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index c6ac760ef..f20866310 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -201,6 +201,43 @@ pub async fn create_conversation( Ok(Json(result)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateChatConversationParams { + pub agent_type: AgentType, + pub title: Option, + /// Reuse an eagerly-created scratch dir (from `create_chat_dir`) instead of + /// minting a new one, so the ACP cwd stays put across the first send. + pub existing_dir: Option, +} + +pub async fn create_chat_conversation( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = conv_commands::create_chat_conversation_core( + &state.db.conn, + &state.data_dir, + params.agent_type, + params.title, + params.existing_dir.as_deref(), + ) + .await?; + conv_commands::emit_conversation_upsert(&state.emitter, &state.db.conn, result.conversation_id) + .await; + Ok(Json(result)) +} + +/// Eagerly create a chat-mode scratch directory (no DB rows) and return its +/// path. Web twin of the `create_chat_dir` Tauri command — lets the browser +/// client connect ACP at a real cwd the instant "no-folder mode" is selected. +pub async fn create_chat_dir( + Extension(state): Extension>, +) -> Result, AppCommandError> { + let path = conv_commands::create_chat_dir_core(&state.data_dir)?; + Ok(Json(conv_commands::CreateChatDirResult { path })) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateConversationStatusParams { @@ -277,13 +314,11 @@ pub async fn delete_conversation( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - conv_commands::delete_conversation_core(&state.db.conn, params.conversation_id).await?; - conv_commands::emit_conversation_deleted(&state.emitter, params.conversation_id); - conv_commands::cleanup_tabs_for_deleted_conversation( + conv_commands::delete_conversation_with_cleanup_core( &state.emitter, &state.db.conn, params.conversation_id, ) - .await; + .await?; Ok(Json(())) } diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 73e8d4361..c47432ee6 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -110,6 +110,14 @@ pub fn build_router( "/create_conversation", post(handlers::conversations::create_conversation), ) + .route( + "/create_chat_conversation", + post(handlers::conversations::create_chat_conversation), + ) + .route( + "/create_chat_dir", + post(handlers::conversations::create_chat_dir), + ) .route( "/update_conversation_status", post(handlers::conversations::update_conversation_status), diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 36db6685a..82e33b95e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "codeg", - "version": "0.15.7", + "version": "0.15.8", "identifier": "app.codeg", "build": { "beforeDevCommand": "pnpm tauri:before-dev", diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index ea2bdd468..f12ac893e 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -10,6 +10,7 @@ import { } from "react" import type { ImperativePanelGroupHandle } from "react-resizable-panels" import { FolderTitleBar } from "@/components/layout/folder-title-bar" +import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" import { Sidebar } from "@/components/layout/sidebar" import { StatusBar } from "@/components/layout/status-bar" import { @@ -103,6 +104,23 @@ function TabKeysSync() { return null } +/** + * Auto-hides the right (aux) panel whenever a folderless chat conversation + * becomes active. Effect fires only on the `isChatMode` rising edge (it does NOT + * depend on `isOpen`), so it won't fight a user who reopens the panel — though in + * practice the toggle button and shortcut are also hidden in chat mode, making + * the hide effectively sticky for the chat session. Leaving chat mode does not + * auto-restore (kept simple); the user reopens it on a normal folder. + */ +function ChatModeAuxAutoHide() { + const isChatMode = useIsActiveChatMode() + const { setOpen } = useAuxPanelContext() + useEffect(() => { + if (isChatMode) setOpen(false) + }, [isChatMode, setOpen]) + return null +} + function isSameLayout(a: number[], b: number[]): boolean { if (a.length !== b.length) return false return a.every((value, index) => Math.abs(value - b[index]) <= LAYOUT_EPSILON) @@ -777,6 +795,7 @@ function FolderLayoutShell({ children }: { children: React.ReactNode }) { return (
+ {isMobile ? ( {children} diff --git a/src/components/chat/agent-plan-overlay.tsx b/src/components/chat/agent-plan-overlay.tsx index 303fc7949..6a778dab0 100644 --- a/src/components/chat/agent-plan-overlay.tsx +++ b/src/components/chat/agent-plan-overlay.tsx @@ -135,12 +135,12 @@ export const AgentPlanOverlay = memo(function AgentPlanOverlay({ } if (!isExpanded) { - // Positioning (absolute right-8 top-4 z-20) is owned by the shared + // Positioning (absolute start-0 top-4 z-20) is owned by the shared // overlay-stack container in MessageListView so this panel stacks with the // sub-agent overlay; the chip only declares layout + pointer behavior. return ( } + icon={} summary={t("collapsedSummary", { completed: completedCount, total: resolvedEntries.length, diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index db7283253..a90697ad6 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -49,6 +49,14 @@ interface ChatInputProps { onForkSend?: (draft: PromptDraft, modeId?: string | null) => void onAddFeedback?: () => void feedbackAddDisabled?: boolean + /** + * Keep the composer usable even while disconnected. Set for a folderless chat + * draft: it has no working dir yet (so it never auto-connects), and the FIRST + * send is precisely what lazily creates its conversation + scratch dir and + * triggers the connection. Without this the composer would be permanently + * disabled and the chat could never be started. + */ + allowOfflineCompose?: boolean } export const ChatInput = memo(function ChatInput({ @@ -85,6 +93,7 @@ export const ChatInput = memo(function ChatInput({ onForkSend, onAddFeedback, feedbackAddDisabled, + allowOfflineCompose = false, }: ChatInputProps) { const t = useTranslations("Folder.chat.chatInput") const isConnected = status === "connected" @@ -114,7 +123,11 @@ export const ChatInput = memo(function ChatInput({ promptCapabilities={promptCapabilities} onFocus={onFocus} defaultPath={defaultPath} - disabled={(!isConnected && !isPrompting) || selectorsLoading} + disabled={ + allowOfflineCompose + ? false + : (!isConnected && !isPrompting) || selectorsLoading + } isPrompting={isPrompting} onCancel={onCancel} modes={modes} diff --git a/src/components/chat/collapsed-overlay-chip.tsx b/src/components/chat/collapsed-overlay-chip.tsx index 540d0c797..c1fd8622a 100644 --- a/src/components/chat/collapsed-overlay-chip.tsx +++ b/src/components/chat/collapsed-overlay-chip.tsx @@ -1,30 +1,34 @@ "use client" /** - * Collapsed chip for the top-right conversation overlays (the plan panel and - * the sub-agent panel). + * Collapsed chip for the inline-start conversation overlays (the message + * navigator, the plan panel, and the sub-agent panel). * - * Rests as a circular icon button — minimal, so it doesn't crowd the message - * area. The summary is `display:none` at rest, so the button is exactly the - * 32px icon cap (a true circle, with no way for text to leak); on hover or - * keyboard focus it switches to `flex` and reveals the full pill (summary + - * chevron). Clicking it expands the owning overlay into its card. Shared so - * both chips stay pixel identical. + * Rests as a dimmed bullet — a flat start-side edge with a rounded end-side (a + * "bullet" pointing toward where it expands: right in LTR, mirrored in RTL), + * flush to the inline-start edge; minimal and unobtrusive so it doesn't crowd + * the message area, and brightens to full opacity on hover/focus. Built from + * logical properties (`rounded-s/e`, `pe`, an `rtl:`-flipped chevron) so it + * mirrors cleanly under `dir="rtl"`. The summary is `display:none` at rest, so + * the resting button is the 20px icon cap (flat start-side, round end-side, no + * way for text to leak); on hover or keyboard focus it switches to `flex` and + * reveals the full pill (summary + chevron). Clicking it expands the owning + * overlay into its card. Shared so all three chips stay pixel identical. * * `summary` is the visible text AND the button's accessible name (`aria-label`), * so what a screen reader / voice-control user gets matches what a sighted user * reads on hover (WCAG 2.5.3); `aria-expanded` conveys the collapsed disclosure - * state. The button carries no layout border (focus shows as a `ring`), so its - * resting width stays exactly equal to its height. + * state. A 1px border traces the top, bottom, and rounded end — the flush + * start edge stays open; keyboard focus adds an inset `ring`. */ import type { ReactNode } from "react" -import { ChevronUpIcon } from "lucide-react" +import { ChevronRightIcon } from "lucide-react" import { cn } from "@/lib/utils" interface CollapsedOverlayChipProps { - /** Leading icon, shown alone when resting. Pass it sized (e.g. `size-4`). */ + /** Leading icon, shown alone when resting. Pass it sized (e.g. `size-3.5`). */ icon: ReactNode /** Summary text revealed on hover/focus, e.g. "子智能体 3" / "计划 2/5". Also * the button's accessible name. */ @@ -46,17 +50,17 @@ export function CollapsedOverlayChip({ aria-expanded={false} onClick={onClick} className={cn( - "group/chip pointer-events-auto flex h-8 items-center rounded-full", - "bg-secondary/70 text-secondary-foreground shadow-md transition-colors hover:bg-secondary", - "cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" + "group/chip pointer-events-auto flex items-center rounded-s-none rounded-e-full border-y border-e", + "bg-secondary/70 text-secondary-foreground opacity-60 shadow-md transition-[background-color,opacity] duration-150 hover:bg-secondary hover:opacity-100 focus-visible:opacity-100", + "cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50" )} > - {/* Fixed square icon cap — the whole resting chip is just this circle. */} - {icon} + {/* Fixed icon cap — the whole resting chip is just this bullet cap. */} + {icon} {/* Summary: hidden at rest (no width), revealed on hover/focus. */} - + {summary} - +
diff --git a/src/components/chat/conversation-context-bar.tsx b/src/components/chat/conversation-context-bar.tsx index 08dbf97e6..1de852d45 100644 --- a/src/components/chat/conversation-context-bar.tsx +++ b/src/components/chat/conversation-context-bar.tsx @@ -3,7 +3,14 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { Check, ChevronDown, Folder, GitBranch, Loader2 } from "lucide-react" +import { + Check, + ChevronDown, + Folder, + GitBranch, + Loader2, + MessageSquare, +} from "lucide-react" import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react" import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTabContext } from "@/contexts/tab-context" @@ -23,11 +30,13 @@ import { CommandInput, CommandItem, CommandList, + CommandSeparator, } from "@/components/ui/command" import { ScrollArea } from "@/components/ui/scroll-area" import { cn } from "@/lib/utils" import { toErrorMessage } from "@/lib/app-error" import { + excludeChatFolders, filterTopLevelFolders, resolveFolderDisplayName, resolvePickerSelectedFolderId, @@ -114,7 +123,8 @@ export const ConversationFolderBranchPicker = memo( }: ConversationFolderBranchPickerProps) { const t = useTranslations("Folder.conversationContextBar") const tBd = useTranslations("Folder.branchDropdown") - const { tabs, activeTabId, openNewConversationTab } = useTabContext() + const { tabs, activeTabId, openNewConversationTab, openChatModeTab } = + useTabContext() const { folders, allFolders, branches, setBranch, refreshFolder } = useAppWorkspace() const { addTask, updateTask } = useTaskContext() @@ -135,26 +145,40 @@ export const ConversationFolderBranchPicker = memo( // The folder picker lists only top-level repos — worktree folders // (`parent_id != null`) are reached through the branch picker, not here, so - // they're hidden to keep this picker a clean repo switcher. + // they're hidden to keep this picker a clean repo switcher. Hidden chat + // folders are excluded too (they're a per-conversation implementation + // detail, not a switchable repo). const topLevelFolders = useMemo( - () => filterTopLevelFolders(folders), + () => excludeChatFolders(filterTopLevelFolders(folders)), [folders] ) - if (!ownTab || !ownFolder) return null + if (!ownTab) return null + // Chat mode: either a draft flagged `isChat` (no folder yet) or a bound + // conversation whose folder is a hidden `is_chat` folder. Show the folder + // chip (so the user can switch back to a real folder while drafting) but + // suppress the branch picker — a folderless chat has no git branch. + const isChatMode = ownTab.isChat === true || ownFolder?.is_chat === true + if (!ownFolder && !isChatMode) return null const isNewConversation = ownTab.conversationId == null const currentBranch = - branches.get(ownFolder.id) ?? ownFolder.git_branch ?? null + isChatMode || !ownFolder + ? null + : (branches.get(ownFolder.id) ?? ownFolder.git_branch ?? null) const showBranchPicker = currentBranch != null // Worktree folders surface their parent (root repo) name here; the picker's // own list below keeps real folder names/paths for selection, and every // git/path operation still uses `ownFolder` (the worktree) unchanged. - const displayFolderName = resolveFolderDisplayName(ownFolder, allFolders) + const displayFolderName = isChatMode + ? t("chatModeLabel") + : resolveFolderDisplayName(ownFolder!, allFolders) // When the conversation lives in a worktree, the picker highlights its // parent repo (the worktree itself isn't listed). Display-only — the tab's - // real folder/working dir is untouched. - const pickerSelectedId = resolvePickerSelectedFolderId(ownFolder) + // real folder/working dir is untouched. Chat mode has no real folder, so + // `-1` (no row) is highlighted. + const pickerSelectedId = + isChatMode || !ownFolder ? -1 : resolvePickerSelectedFolderId(ownFolder) return ( <> @@ -189,9 +213,23 @@ export const ConversationFolderBranchPicker = memo( }} labelEmpty={t("noFolders")} labelSearch={t("searchFolder")} + labelChatMode={t("chatModeLabel")} + isChatMode={isChatMode} + onSelectChatMode={() => { + try { + openChatModeTab() + toast.success(t("toasts.switchedToChatMode")) + } catch (err) { + console.error( + "[ConversationFolderBranchPicker] switch to chat mode failed:", + err + ) + toast.error(t("toasts.openFolderFailed")) + } + }} /> - {showBranchPicker && ( + {showBranchPicker && ownFolder && ( f.id === ownTab.folderId) ?? null) : null - return Boolean(ownTab && ownFolder) + // Chat-mode drafts have no resolvable folder yet, but the picker row must + // still show so the folder chip (and the "no-folder mode" item) are reachable. + return Boolean(ownTab && (ownFolder || ownTab.isChat)) } // ============================================================================ @@ -268,6 +308,12 @@ interface FolderPickerProps { onSelect: (folderId: number) => void | Promise labelEmpty: string labelSearch: string + /** Label for the pinned "no-folder (chat) mode" item at the bottom. */ + labelChatMode: string + /** Whether the draft is currently in chat mode (shows the check mark). */ + isChatMode: boolean + /** Select folderless chat mode. */ + onSelectChatMode: () => void } const FolderPicker = memo(function FolderPicker({ @@ -279,6 +325,9 @@ const FolderPicker = memo(function FolderPicker({ onSelect, labelEmpty, labelSearch, + labelChatMode, + isChatMode, + onSelectChatMode, }: FolderPickerProps) { const [open, setOpen] = useState(false) @@ -336,6 +385,26 @@ const FolderPicker = memo(function FolderPicker({ ))} + + {/* Pinned to the bottom: folderless "chat mode". A stable, plain + `value` (no folder name/path) keeps it visible under any search + filter so the entry point is always reachable. */} + + { + setOpen(false) + onSelectChatMode() + }} + > + + + {labelChatMode} + + {isChatMode && } + + diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 2f478b6c9..1981dced1 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -2452,7 +2452,7 @@ export function MessageInput({ disabled={disabled} variant="ghost" size="icon-xs" - className="shrink-0" + className="shrink-0 text-muted-foreground" title={t("addActions")} aria-label={t("addActions")} > diff --git a/src/components/chat/sub-agent-overlay.tsx b/src/components/chat/sub-agent-overlay.tsx index 42dea26c2..7afe3e434 100644 --- a/src/components/chat/sub-agent-overlay.tsx +++ b/src/components/chat/sub-agent-overlay.tsx @@ -1,11 +1,11 @@ "use client" /** - * Top-right overlay listing the sub-agents delegated in the LAST agent reply. + * Inline-start overlay listing the sub-agents delegated in the LAST agent reply. * - * Mirrors `AgentPlanOverlay` (the "计划任务" panel): collapses to a pill, + * Mirrors `AgentPlanOverlay` (the "计划任务" panel): collapses to a bullet chip, * expands to a card, remembers collapse state per `overlayKey`, and renders - * nothing when there's nothing to show. Positioning (absolute right/top) is + * nothing when there's nothing to show. Positioning (absolute inline-start/top) is * owned by the shared overlay-stack container in `MessageListView`, which * places this panel BELOW the plan panel when both are present. * @@ -65,7 +65,7 @@ export const SubAgentOverlay = memo(function SubAgentOverlay({ if (!isExpanded) { return ( } + icon={} summary={t("collapsedSummary", { count })} onClick={() => setCollapsedByKey((prev) => ({ ...prev, [stateKey]: false })) diff --git a/src/components/conversations/conversation-detail-panel-layout.test.ts b/src/components/conversations/conversation-detail-panel-layout.test.ts index 9303c38a0..609d4e5e8 100644 --- a/src/components/conversations/conversation-detail-panel-layout.test.ts +++ b/src/components/conversations/conversation-detail-panel-layout.test.ts @@ -106,3 +106,101 @@ describe("ConversationDetailPanel new conversation layout", () => { expect(source).toContain("mx-auto flex w-full max-w-2xl") }) }) + +describe("ConversationDetailPanel chat-mode send path", () => { + // Regression guard for the "first chat message gets stuck in the queue and is + // never sent" bug: the chat first-send must NOT enqueue-and-return, it must + // take the same inline create+bind+lifecycleSend path as a normal new + // conversation. The old failure mode relied on the flush-on-connect engine, + // which went dormant once the eager connection was already `connected`. + it("does not special-case the chat first send into an enqueue-and-return branch", () => { + // The old chat-draft early branch and its single-flight guard are gone. + expect(source).not.toContain( + "sendOwnTab?.isChat === true && dbConvIdRef.current == null" + ) + expect(source).not.toContain("createChatPendingRef") + }) + + it("creates the chat row inline in the shared new-tab path and sends via lifecycleSend", () => { + // Chat send is selected synchronously, then the SAME async block that + // handles normal new conversations creates the row and delivers inline. + expect(source).toContain("const chatSend = sendOwnTab?.isChat === true") + expect(source).toContain("createChatConversation(") + + const sendStart = source.indexOf("const chatSend = sendOwnTab?.isChat") + const sendEnd = source.indexOf( + "createConversationPendingRef.current = false" + ) + expect(sendStart).toBeGreaterThan(-1) + expect(sendEnd).toBeGreaterThan(sendStart) + const block = source.slice(sendStart, sendEnd) + // Inline delivery (the fix) — not an mqEnqueue that defers to the queue. + expect(block).toContain("lifecycleSend(draft, selectedModeIdArg, {") + expect(block).not.toContain("mqEnqueue") + }) + + it("gates the chat-draft composer on a live connection (no offline compose)", () => { + // allowOfflineCompose let the user send before connecting, which is what + // parked the first prompt in the never-flushed queue. The composer now + // waits for `connected` like a normal conversation. + expect(source).not.toContain("allowOfflineCompose") + }) + + it("surfaces a non-silent error when the eager scratch-dir prepare fails", () => { + // Without offline compose, a failed mkdir would silently disable the + // composer forever; the eager effect must surface it instead. + expect(source).toContain( + 'setAgentConnectError(tWelcome("prepareSessionFailed"))' + ) + }) +}) + +describe("ConversationDetailPanel send-path hardening", () => { + // Guards for the production-readiness fixes from the Codex review of the + // chat-mode work. The behavioral cores (readiness predicate, duplicate-create + // rejection) are unit-tested in src/lib/queue-flush.test.ts; these assert they + // are actually wired into the send path here. + it("gates the direct send on a cwd-matched connection, not bare connected", () => { + // A chat draft mid-reconnect can read a stale "connected" for the previous + // cwd; sending then would hit the wrong workspace. handleSend must gate on + // the readiness predicate (connected AND cwd matches), like the flush effect. + expect(source).toContain("isConnectionReady(") + expect(source).toContain("if (!connectionReady) return") + }) + + it("disables the welcome composer while connected-but-not-ready", () => { + // The composer reads a downgraded status so its send affordance is disabled + // during the transient mismatch window instead of inviting a rejected send. + expect(source).toContain("composerConnStatus") + expect(source).toContain("status={composerConnStatus}") + }) + + it("single-flights the unbound create before any optimistic mutation", () => { + // A double-submit during the create window must be rejected BEFORE the + // optimistic turn is appended, or it orphans a turn it can never deliver. + expect(source).toContain("shouldRejectDuplicateCreate(") + const guardIdx = source.indexOf("shouldRejectDuplicateCreate(") + // The CALL site (assignment), not the function definition earlier in the file. + const optimisticIdx = source.indexOf( + "const optimisticTurn = buildOptimisticUserTurnFromDraft(" + ) + expect(guardIdx).toBeGreaterThan(-1) + expect(optimisticIdx).toBeGreaterThan(guardIdx) + }) + + it("fully restores pre-send state when the create fails", () => { + // A failed create must not strand the user behind a blank panel: drop the + // optimistic turn, return to welcome mode, re-seed the draft, surface error. + const catchIdx = source.indexOf( + '"[ConversationTabView] create conversation:"' + ) + expect(catchIdx).toBeGreaterThan(-1) + const catchBlock = source.slice(catchIdx, catchIdx + 1500) + expect(catchBlock).toContain("removeOptimisticTurn(") + expect(catchBlock).toContain("setHasSentMessage(false)") + expect(catchBlock).toContain("saveMessageInputDraft(") + expect(catchBlock).toContain( + 'setAgentConnectError(tWelcome("createConversationFailed"))' + ) + }) +}) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 5311e7983..74e6a82dd 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -16,8 +16,8 @@ import { FileImage, FileText, Focus, - Plus, RefreshCw, + SquarePen, X, } from "lucide-react" import { useTranslations } from "next-intl" @@ -42,11 +42,19 @@ import { AgentSelector } from "@/components/chat/agent-selector" import { ChatInput } from "@/components/chat/chat-input" import { WelcomeHero, WelcomeTip } from "@/components/chat/welcome-hero" import { ScrollArea } from "@/components/ui/scroll-area" -import { acpFork, createConversation, openSettingsWindow } from "@/lib/api" +import { + acpFork, + createChatConversation, + createChatDir, + createConversation, + openSettingsWindow, +} from "@/lib/api" import { flushRetryDelayMs, forkSendBlockedByQueue, + isConnectionReady, shouldQueueDirectSend, + shouldRejectDuplicateCreate, } from "@/lib/queue-flush" import { TurnBusyError } from "@/lib/turn-busy" import { useConversationRuntime } from "@/contexts/conversation-runtime-context" @@ -74,6 +82,7 @@ import { buildConversationDraftStorageKey, buildNewConversationDraftStorageKey, clearMessageInputDraft, + saveMessageInputDraft, } from "@/lib/message-input-draft" import { ContextMenu, @@ -180,11 +189,12 @@ const ConversationTabView = memo(function ConversationTabView({ const tWelcome = useTranslations("Folder.chat.welcomeInputPanel") const sharedT = useTranslations("Folder.chat.shared") const { activeFolder: folder, activeFolderId } = useActiveFolder() - const { refreshConversations } = useAppWorkspace() + const { refreshConversations, upsertFolder } = useAppWorkspace() const folderId = activeFolderId ?? 0 const { tabs, bindConversationTab, + setChatDraftWorkingDir, setTabRuntimeConversationId, pinTab, openNewConversationTab, @@ -245,6 +255,16 @@ const ConversationTabView = memo(function ConversationTabView({ const hasPersistedConversation = dbConversationId != null + // A folderless chat draft before its first send (chat tab, not yet persisted). + // Used to trigger the eager scratch-dir prepare below, which gives the draft a + // real workingDir so the ACP connection can spawn BEFORE the first send — the + // composer is gated on `connected` like any normal conversation (no offline + // compose). Once bound it has a persisted row + workingDir and this is false. + const isChatDraft = useMemo(() => { + const ownTab = tabs.find((tab) => tab.id === tabId) + return ownTab?.isChat === true && !hasPersistedConversation + }, [tabs, tabId, hasPersistedConversation]) + // Expose the runtime session key to the tab so the aux panel (Diff sidebar) // can look up live turns even before the DB conversation is created. useEffect(() => { @@ -272,6 +292,8 @@ const ConversationTabView = memo(function ConversationTabView({ const mountedRef = useRef(true) const selectedAgentRef = useRef(selectedAgent) const createConversationPendingRef = useRef(false) + // Single-flight guard for the eager scratch-dir prepare (on chat-mode select). + const prepareChatDirPendingRef = useRef(false) const sessionIdRef = useRef(null) const syncCancelRef = useRef<(() => void) | null>(null) @@ -283,6 +305,47 @@ const ConversationTabView = memo(function ConversationTabView({ selectedAgentRef.current = selectedAgent }, [selectedAgent]) + // Eagerly create the chat-mode scratch dir the moment this becomes an unbound + // chat draft, so the ACP connection can spawn at a real cwd BEFORE the first + // send — picking "no-folder mode" no longer leaves the agent unconnected. + // Filesystem-only (writes no DB rows), so the lazy-conversation invariant + // holds; the first send reuses this dir via createChatConversation(existingDir), + // keeping the connection's cwd put across the bind. Single-flight and + // self-disarming: once workingDir lands the guard flips false. openChatModeTab + // clears workingDir on re-entry, so a fresh dir is prepared each time. + useEffect(() => { + if (!isActive || !isChatDraft || workingDir) return + if (prepareChatDirPendingRef.current) return + prepareChatDirPendingRef.current = true + void (async () => { + try { + const res = await createChatDir() + if (mountedRef.current) { + setChatDraftWorkingDir(tabId, res.path) + } + } catch (e) { + // The composer is gated on a live connection (no offline compose), and + // the connection needs this scratch dir. If the mkdir fails the draft + // would otherwise sit with a permanently disabled composer and no + // explanation — surface it on the welcome screen's error banner so the + // user can re-enter chat mode to retry. + console.error("[ConversationTabView] prepare chat dir:", e) + if (mountedRef.current) { + setAgentConnectError(tWelcome("prepareSessionFailed")) + } + } finally { + prepareChatDirPendingRef.current = false + } + })() + }, [ + isActive, + isChatDraft, + workingDir, + tabId, + setChatDraftWorkingDir, + tWelcome, + ]) + // Sync the agentType prop into draftAgentType for draft tabs. The prop // changes when openNewConversationTab re-points an existing draft at a // different folder's default agent (or when any other external mutation @@ -400,6 +463,22 @@ const ConversationTabView = memo(function ConversationTabView({ isViewerRef.current = conn.isViewer }, [conn.isViewer]) const isConnecting = connStatus === "connecting" + // The live connection is ready for THIS tab only when it's connected AND its + // cwd matches the tab's intended working dir. A just-retargeted chat draft (or + // any mid-reconnect) can briefly read a stale "connected" for the PREVIOUS cwd; + // sending then would deliver the prompt to the wrong agent/workspace. Every + // direct send gates on this (handleSend), mirroring the flush effect's guard. + // No-op for normal conversations, whose connected cwd always equals intended. + const connectionReady = isConnectionReady( + connStatus, + conn.connectedWorkingDir, + workingDirForConnection + ) + // Present "connecting" to the composer while connected-but-not-ready, so it + // disables its send affordance instead of inviting a submit handleSend rejects. + // Only ever differs from connStatus during that transient mismatch window. + const composerConnStatus = + connStatus === "connected" && !connectionReady ? "connecting" : connStatus const connectionModes = useMemo( () => conn.modes?.available_modes ?? [], [conn.modes?.available_modes] @@ -507,6 +586,18 @@ const ConversationTabView = memo(function ConversationTabView({ const runtimeSyncState = runtimeSession?.syncState ?? "idle" useEffect(() => { if (connStatus !== "connected") return + // Don't flush onto a connection whose cwd doesn't match the tab's intended + // working dir. This matters for a just-bound chat conversation: bind switches + // the tab's workingDir from the draft's previous folder to the scratch dir, + // and for one render `connStatus` can still read the stale "connected" of the + // old-folder session before the reconnect lands. Flushing then would deliver + // the queued prompt to the wrong folder's agent. (No-op for normal + // conversations, whose connection cwd always equals the intended one.) + if ( + (conn.connectedWorkingDir ?? null) !== (workingDirForConnection ?? null) + ) { + return + } if (runtimeSyncState === "awaiting_persist") return if (msgQueue.length === 0) return // setTimeout (not microtask) so a COMPLETE_TURN commit settles first AND so @@ -522,7 +613,13 @@ const ConversationTabView = memo(function ConversationTabView({ } }, wait) return () => clearTimeout(timer) - }, [connStatus, runtimeSyncState, msgQueue.length]) + }, [ + connStatus, + runtimeSyncState, + msgQueue.length, + conn.connectedWorkingDir, + workingDirForConnection, + ]) useEffect(() => { // Only sync non-null liveMessage updates to state. When conn.liveMessage @@ -662,11 +759,24 @@ const ConversationTabView = memo(function ConversationTabView({ // re-queues at the TAIL. opts?: { fromQueueFlush?: boolean } ) => { + // Capture the tab's chat-draft state + eager scratch dir synchronously, + // before any await. A folderless chat draft is NOT special-cased here: + // its first send takes the exact same gated, inline path as a normal new + // conversation (the new-tab branch below just creates the row via + // createChatConversation, reusing this eager dir). The composer is gated + // on `connected` for chat drafts too, so by the time we get here the agent + // is live and the prompt is delivered inline — never parked in the queue. + const sendOwnTab = tabs.find((tab) => tab.id === tabId) + if (!hasPersistedConversation && !canAutoConnect) { setAgentConnectError(tWelcome("enableAgentFirstPlaceholder")) return } - if (connStatus !== "connected") return + // Connected AND the connection's cwd matches this tab's working dir. Bare + // `connStatus === "connected"` is not enough: a chat draft mid-reconnect can + // read a stale "connected" for the old cwd, and an inline send then would + // deliver to the wrong workspace. Same predicate the flush effect uses. + if (!connectionReady) return const fromQueueFlush = opts?.fromQueueFlush ?? false // Preserve FIFO: a direct send issued while the queue is non-empty joins @@ -677,6 +787,23 @@ const ConversationTabView = memo(function ConversationTabView({ return } + // Single-flight the unbound new-tab create. A second direct submit fired + // before the first create resolves (a double Enter / double click) would + // otherwise append an optimistic turn it can never deliver: the + // createConversationPendingRef guard further down returns AFTER the + // optimistic append. Reject the duplicate here, before any optimistic + // mutation. Only the unbound path (no persisted id yet) is single-flighted, + // so persisted sends keep their concurrent queued-send behavior. Applies + // equally to chat and normal new conversations. + if ( + shouldRejectDuplicateCreate( + dbConvIdRef.current != null, + createConversationPendingRef.current + ) + ) { + return + } + const optimisticTurn = buildOptimisticUserTurnFromDraft( draft, sharedT("attachedResources") @@ -733,44 +860,86 @@ const ConversationTabView = memo(function ConversationTabView({ // New-tab path: create the DB row first, then send with the new id // pinned. This prevents the backend's send_prompt_linked from racing - // us to create its own conversation row. + // us to create its own conversation row. A folderless chat draft creates + // via createChatConversation (reusing the eager scratch dir) and binds to + // its hidden is_chat folder; every other step — the optimistic turn + // appended above, the inline lifecycleSend, the rollback — is identical to + // a normal new conversation. This is the whole point of the fix: after the + // scratch dir exists, chat mode shares the normal send path and never + // depends on the flush-on-connect queue to deliver its first prompt. if (createConversationPendingRef.current) return createConversationPendingRef.current = true const title = getPromptDraftDisplayText( draft, sharedT("attachedResources") ).slice(0, 80) + const chatSend = sendOwnTab?.isChat === true + const chatExistingDir = sendOwnTab?.workingDir void (async () => { try { - const newConversationId = await createConversation( - folderId, - selectedAgent, - title - ) - dbConvIdRef.current = newConversationId - // Set external ID on the stable virtual session (no migration needed — - // effectiveConversationId never changes, so the session stays in place). - // DB persistence of external_id is now backend-driven from - // send_prompt_linked once the row is linked, so no explicit DB write here. - setExternalId(effectiveConversationId, sessionIdRef.current ?? null) - - if (!mountedRef.current) { - // Component unmounted while creating — mark for deferred cleanup - // so the background turn_complete handler can clean up later. - setPendingCleanup(effectiveConversationId, true) - refreshConversations() - return + let newConversationId: number + // The send's folderId defaults to the active folder; a chat send + // overrides it with the backend-created hidden is_chat folder. + let sendFolderId = folderId + if (chatSend) { + const res = await createChatConversation( + selectedAgent, + title, + chatExistingDir + ) + newConversationId = res.conversationId + sendFolderId = res.folderId + dbConvIdRef.current = newConversationId + setExternalId(effectiveConversationId, sessionIdRef.current ?? null) + if (!mountedRef.current) { + setPendingCleanup(effectiveConversationId, true) + refreshConversations() + return + } + // Seed allFolders with the hidden chat folder so the tab's new + // folderId resolves (cwd / active-folder) on the next render. bind + // reuses the eager scratch dir as workingDir, so the connection's + // cwd does not move and no reconnect is triggered. + upsertFolder(res.folder) + setCreatedConversationId(newConversationId) + bindConversationTab( + tabId, + newConversationId, + selectedAgent, + title, + effectiveConversationId, + res.folderId, + res.folder.path + ) + } else { + newConversationId = await createConversation( + folderId, + selectedAgent, + title + ) + dbConvIdRef.current = newConversationId + // Set external ID on the stable virtual session (no migration needed — + // effectiveConversationId never changes, so the session stays in place). + // DB persistence of external_id is now backend-driven from + // send_prompt_linked once the row is linked, so no explicit DB write here. + setExternalId(effectiveConversationId, sessionIdRef.current ?? null) + if (!mountedRef.current) { + // Component unmounted while creating — mark for deferred cleanup + // so the background turn_complete handler can clean up later. + setPendingCleanup(effectiveConversationId, true) + refreshConversations() + return + } + setCreatedConversationId(newConversationId) + bindConversationTab( + tabId, + newConversationId, + selectedAgent, + title, + effectiveConversationId + ) } - - setCreatedConversationId(newConversationId) - bindConversationTab( - tabId, - newConversationId, - selectedAgent, - title, - effectiveConversationId - ) clearMessageInputDraft(buildNewConversationDraftStorageKey()) refreshConversations() @@ -778,13 +947,35 @@ const ConversationTabView = memo(function ConversationTabView({ // conversation_id pinned so the backend adopts our row instead of // creating a duplicate one. lifecycleSend(draft, selectedModeIdArg, { - folderId, + folderId: sendFolderId, conversationId: newConversationId, clientMessageId: optimisticTurn.id, onTurnInProgress, }) } catch (e) { console.error("[ConversationTabView] create conversation:", e) + // A failed create (chat OR normal) must fully restore the pre-send + // state, not strand the user behind a blank panel: + // 1. drop the optimistic turn (no ghost stuck in awaiting_persist), + // 2. return syncState to idle, + // 3. setHasSentMessage(false) → re-enters welcome mode (otherwise the + // welcome screen never returns and the list is empty), + // 4. re-seed the draft text — message-input clears it synchronously on + // send, so without this the user's prompt is lost on failure, + // 5. surface the error on the welcome banner so it isn't silent. + removeOptimisticTurn(effectiveConversationId, optimisticTurn.id) + setSyncState(effectiveConversationId, "idle") + setHasSentMessage(false) + const draftText = draft.displayText.trim() + if (draftText) { + saveMessageInputDraft( + buildNewConversationDraftStorageKey(), + draftText + ) + } + if (mountedRef.current) { + setAgentConnectError(tWelcome("createConversationFailed")) + } } finally { createConversationPendingRef.current = false } @@ -798,7 +989,7 @@ const ConversationTabView = memo(function ConversationTabView({ mqGetQueueLength, bindConversationTab, canAutoConnect, - connStatus, + connectionReady, effectiveConversationId, folderId, hasPersistedConversation, @@ -813,6 +1004,7 @@ const ConversationTabView = memo(function ConversationTabView({ tabs, tWelcome, tabId, + upsertFolder, ] ) @@ -1209,7 +1401,10 @@ const ConversationTabView = memo(function ConversationTabView({ ) : null} - + {t("newConversation")} diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index b55300d59..4f6f9900f 100644 --- a/src/components/conversations/sidebar-conversation-card.tsx +++ b/src/components/conversations/sidebar-conversation-card.tsx @@ -5,7 +5,7 @@ import { Pencil, Trash2, Circle, - Plus, + SquarePen, Loader2, XCircle, Pin, @@ -321,7 +321,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ onNewConversation(conversation.folder_id)} > - + {t("newConversation")} diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index 669b7bb43..bc7f62f69 100644 --- a/src/components/conversations/sidebar-conversation-grouping.test.ts +++ b/src/components/conversations/sidebar-conversation-grouping.test.ts @@ -14,6 +14,7 @@ import { pointerYToTargetIndex, reuseSelected, reuseSet, + selectChatConversationsWithReuse, selectPinnedWithReuse, type SidebarRow, } from "./sidebar-conversation-grouping" @@ -226,7 +227,10 @@ describe("buildRows", () => { ({ kind: "section", section: "folders", expanded: true, count }) as const // Folder-only convenience wrapper (no pinned section), matching the original - // positional tests but through the new options-object signature. + // positional tests but through the new options-object signature. The Chat + // section is always present now (a permanent entry point), but it is exercised + // by its own tests below — so this wrapper trims it off to keep the focused + // folder assertions exact. function folderRows( orderedFolderIds: number[], byFolder: Map, @@ -234,7 +238,7 @@ describe("buildRows", () => { folderTotalCounts: Map, foldersExpanded = true ): SidebarRow[] { - return buildRows({ + const rows = buildRows({ pinned: [], pinnedExpanded: true, orderedFolderIds, @@ -242,7 +246,13 @@ describe("buildRows", () => { folderExpanded, folderTotalCounts, foldersExpanded, + chatConversations: [], + chatsExpanded: true, }) + const chatsIdx = rows.findIndex( + (r) => r.kind === "section" && r.section === "chats" + ) + return chatsIdx === -1 ? rows : rows.slice(0, chatsIdx) } it("emits a Folders section header above the folder rows", () => { @@ -351,6 +361,8 @@ describe("buildRows", () => { folderExpanded: { 10: true }, folderTotalCounts: new Map([[10, 1]]), foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, }) expect(rows[0]).toEqual({ kind: "section", @@ -377,9 +389,15 @@ describe("buildRows", () => { folderExpanded: {}, folderTotalCounts: new Map(), foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, }) + // Pinned section collapsed → header only; the always-present Chat section + // trails (empty → header + hint). expect(rows).toEqual([ { kind: "section", section: "pinned", expanded: false, count: 1 }, + { kind: "section", section: "chats", expanded: true, count: 0 }, + { kind: "chats-empty" }, ]) }) @@ -390,6 +408,149 @@ describe("buildRows", () => { rows.some((r) => r.kind === "section" && r.section === "pinned") ).toBe(false) }) + + it("emits a flat Chat section below the folders section", () => { + const c1 = conv(1, 99) + const c2 = conv(2, 99) + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map([[10, [conv(3, 10)]]]), + folderExpanded: { 10: true }, + folderTotalCounts: new Map([[10, 1]]), + foldersExpanded: true, + chatConversations: [c1, c2], + chatsExpanded: true, + }) + const foldersIdx = rows.findIndex( + (r) => r.kind === "section" && r.section === "folders" + ) + const chatsIdx = rows.findIndex( + (r) => r.kind === "section" && r.section === "chats" + ) + expect(foldersIdx).toBeGreaterThanOrEqual(0) + expect(chatsIdx).toBeGreaterThan(foldersIdx) + expect(rows[chatsIdx]).toEqual({ + kind: "section", + section: "chats", + expanded: true, + count: 2, + }) + expect(rows[chatsIdx + 1]).toEqual({ + kind: "conversation", + conversation: c1, + }) + expect(rows[chatsIdx + 2]).toEqual({ + kind: "conversation", + conversation: c2, + }) + // Flat — no folder headers inside the chat section. + expect(rows.slice(chatsIdx + 1).some((r) => r.kind === "folder")).toBe( + false + ) + }) + + it("always emits the Chat section, with an empty hint when there are no chat conversations", () => { + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map([[10, [conv(1, 10)]]]), + folderExpanded: { 10: true }, + folderTotalCounts: new Map([[10, 1]]), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + }) + const chatsIdx = rows.findIndex( + (r) => r.kind === "section" && r.section === "chats" + ) + // The header is present (count 0) even with no chat conversations — it is a + // permanent entry point — and an expanded empty section shows a single hint. + expect(rows[chatsIdx]).toEqual({ + kind: "section", + section: "chats", + expanded: true, + count: 0, + }) + expect(rows[chatsIdx + 1]).toEqual({ kind: "chats-empty" }) + }) + + it("shows only the Chat header (no empty hint) when the empty section is collapsed", () => { + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [], + byFolder: new Map(), + folderExpanded: {}, + folderTotalCounts: new Map(), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: false, + }) + expect(rows).toEqual([ + { kind: "section", section: "chats", expanded: false, count: 0 }, + ]) + }) + + it("hides chat conversations when the Chat section is collapsed", () => { + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [], + byFolder: new Map(), + folderExpanded: {}, + folderTotalCounts: new Map(), + foldersExpanded: true, + chatConversations: [conv(1, 99)], + chatsExpanded: false, + }) + expect(rows).toEqual([ + { kind: "section", section: "chats", expanded: false, count: 1 }, + ]) + }) +}) + +describe("selectChatConversationsWithReuse", () => { + it("selects only chat-folder conversations, newest-updated first, excluding pinned", () => { + const chatIds = new Set([99]) + const a = conv(1, 99) + const b = conv(2, 99) // higher id → later updated_at + const pinnedChat = conv(3, 99, { pinned_at: new Date(5000).toISOString() }) + const folderConv = conv(4, 10) + const out = selectChatConversationsWithReuse( + [a, b, pinnedChat, folderConv], + chatIds, + true, + [] + ) + expect(out.map((c) => c.id)).toEqual([2, 1]) + }) + + it("excludes completed conversations unless showCompleted", () => { + const chatIds = new Set([99]) + const done = conv(1, 99, { status: "completed" }) + const active = conv(2, 99) + expect( + selectChatConversationsWithReuse([done, active], chatIds, false, []).map( + (c) => c.id + ) + ).toEqual([2]) + expect( + selectChatConversationsWithReuse([done, active], chatIds, true, []) + .map((c) => c.id) + .sort() + ).toEqual([1, 2]) + }) + + it("returns the prev array when membership is referentially unchanged", () => { + const chatIds = new Set([99]) + const a = conv(1, 99) + const first = selectChatConversationsWithReuse([a], chatIds, true, []) + const second = selectChatConversationsWithReuse([a], chatIds, true, first) + expect(second).toBe(first) + }) }) describe("selectPinnedWithReuse", () => { diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index 1ffb428ea..8e9164cb7 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -206,6 +206,37 @@ export function selectPinnedWithReuse( return arraysShallowEqual(prev, next) ? prev : next } +/** + * Select the folderless "chat mode" conversations — those whose `folder_id` is a + * hidden `is_chat` folder — for the flat "Chat" sidebar section. Sorted + * most-recently-updated first, with reference reuse (same motivation as + * {@link selectPinnedWithReuse}). + * + * Excludes pinned conversations (they surface in the Pinned section, an explicit + * override) and — unless `showCompleted` — completed ones, matching how + * `folderConversations` is filtered for the folders section. + * + * `chatFolderIds` is the set of hidden chat folder ids (from + * `allFolders.filter(f => f.is_chat)`). `prev` is the array returned last call + * (threaded via a ref by the caller). + */ +export function selectChatConversationsWithReuse( + conversations: readonly DbConversationSummary[], + chatFolderIds: ReadonlySet, + showCompleted: boolean, + prev: DbConversationSummary[] +): DbConversationSummary[] { + const next: DbConversationSummary[] = [] + for (const conv of conversations) { + if (conv.pinned_at != null) continue + if (!chatFolderIds.has(conv.folder_id)) continue + if (!showCompleted && conv.status === "completed") continue + next.push(conv) + } + next.sort(compareByUpdatedAtDesc) + return arraysShallowEqual(prev, next) ? prev : next +} + // ── Flat row model (Phase 2 virtualization) ───────────────────────────────── // The sidebar tree (folders → their conversation rows) is flattened into a // single linear array so it can be windowed by `virtua`. Each visible folder @@ -240,16 +271,28 @@ export interface EmptyHintRow { } /** - * A collapsible section heading. Two exist: "pinned" (above the folders, shown - * only when there are pinned conversations) and "folders" (wraps the whole - * folder list). Both live in the same flat row array so the single Virtualizer - * windows them like any other row — there is no separate, un-virtualized list. + * The single empty-state hint shown under an expanded but empty "Chat" section + * ("No chats yet"). Unlike {@link EmptyHintRow} it is folderless — chat + * conversations are a flat list — so it carries no folder id and renders with a + * flat (non-rail) indent. + */ +export interface ChatsEmptyRow { + kind: "chats-empty" +} + +/** + * A collapsible section heading. Three exist: "pinned" (above the folders, shown + * only when there are pinned conversations), "folders" (wraps the whole folder + * list), and "chats" (below the folders, a flat list of folderless chat-mode + * conversations, shown only when there are any). All live in the same flat row + * array so the single Virtualizer windows them like any other row — there is no + * separate, un-virtualized list. */ export interface SectionHeaderRow { kind: "section" - section: "pinned" | "folders" + section: "pinned" | "folders" | "chats" expanded: boolean - /** Pinned conversation count, or folder count — shown beside the title. */ + /** Pinned count, folder count, or chat-conversation count — shown beside the title. */ count: number } @@ -258,6 +301,7 @@ export type SidebarRow = | FolderHeaderRow | ConversationRow | EmptyHintRow + | ChatsEmptyRow /** * Flatten the (optional) pinned section and the folders section into a single @@ -279,6 +323,12 @@ export type SidebarRow = * non-empty folder contributes header + its (already sorted) bucket. `byFolder` * / `folderTotalCounts` exclude pinned conversations (they live in the Pinned * section), so a folder whose only conversations are pinned reads as empty. + * - The "Chat" section header ALWAYS appears (even with zero chat + * conversations), so the section is a permanent entry point — its New-chat + * affordance and an empty hint stay reachable. When expanded and empty it + * contributes a single `chats-empty` hint row; otherwise its (flat, folderless) + * conversation rows. Pinned chat conversations live in the Pinned section, so + * they are excluded from `chatConversations`. */ export function buildRows(args: { pinned: readonly DbConversationSummary[] @@ -288,6 +338,8 @@ export function buildRows(args: { folderExpanded: Record folderTotalCounts: Map foldersExpanded: boolean + chatConversations: readonly DbConversationSummary[] + chatsExpanded: boolean }): SidebarRow[] { const { pinned, @@ -297,6 +349,8 @@ export function buildRows(args: { folderExpanded, folderTotalCounts, foldersExpanded, + chatConversations, + chatsExpanded, } = args const rows: SidebarRow[] = [] @@ -342,6 +396,24 @@ export function buildRows(args: { } } + // The Chat section header is always present (a permanent entry point), unlike + // the conditional Pinned/Folders headers above. + rows.push({ + kind: "section", + section: "chats", + expanded: chatsExpanded, + count: chatConversations.length, + }) + if (chatsExpanded) { + if (chatConversations.length === 0) { + rows.push({ kind: "chats-empty" }) + } else { + for (const conv of chatConversations) { + rows.push({ kind: "conversation", conversation: conv }) + } + } + } + return rows } diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 17dc1e1e0..09b419e8c 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -28,8 +28,8 @@ import { Loader2, MoreHorizontal, Palette, - Plus, Rocket, + SquarePen, XCircle, } from "lucide-react" import { useActiveFolder } from "@/contexts/active-folder-context" @@ -87,6 +87,7 @@ import { pointerYToTargetIndex, reuseSelected, reuseSet, + selectChatConversationsWithReuse, selectPinnedWithReuse, type SidebarRow, } from "./sidebar-conversation-grouping" @@ -363,7 +364,7 @@ const FolderHeader = memo(function FolderHeader({ aria-label={t("moreOptions")} aria-haspopup="menu" className={cn( - "mr-[0.125rem] flex h-7 w-7 shrink-0 items-center justify-center", + "flex h-7 w-7 shrink-0 items-center justify-center", // Shares the card action-icon palette: default /90 is the lightest // muted shade clearing 3:1 non-text contrast (incl. on touch, where // this stays visible); hover deepens to full foreground. @@ -374,12 +375,32 @@ const FolderHeader = memo(function FolderHeader({ > + onNewConversation(folderId)}> - + {t("newConversation")} ({}) const pinnedExpanded = !sectionCollapsed.pinned const foldersExpanded = !sectionCollapsed.folders + const chatsExpanded = !sectionCollapsed.chats const [removeConfirm, setRemoveConfirm] = useState<{ folderId: number folderName: string @@ -698,13 +721,16 @@ export function SidebarConversationList({ setSectionCollapsed(loadSectionCollapsed()) }, []) - const toggleSection = useCallback((section: "pinned" | "folders") => { - setSectionCollapsed((prev) => { - const next = { ...prev, [section]: !prev[section] } - saveSectionCollapsed(next) - return next - }) - }, []) + const toggleSection = useCallback( + (section: "pinned" | "folders" | "chats") => { + setSectionCollapsed((prev) => { + const next = { ...prev, [section]: !prev[section] } + saveSectionCollapsed(next) + return next + }) + }, + [] + ) const handleChangeFolderColor = useCallback( async (folderId: number, color: FolderThemeColor) => { @@ -782,14 +808,40 @@ export function SidebarConversationList({ return () => clearInterval(interval) }, []) + // Hidden chat-mode folders (one per folderless conversation). Their + // conversations are routed to the flat "Chat" section and excluded from the + // folders grouping; the folders themselves are excluded from the folder list + // (orderedFolderIds) below. + const chatFolderIds = useMemo( + () => new Set(allFolders.filter((f) => f.is_chat).map((f) => f.id)), + [allFolders] + ) + // Folder grouping source: pinned conversations are surfaced in the dedicated - // Pinned section, never in their folder group, so exclude them here; then - // apply the completed filter as before. + // Pinned section, and folderless chat conversations in the dedicated Chat + // section, so exclude both here; then apply the completed filter as before. const folderConversations = useMemo(() => { - const base = conversations.filter((c) => c.pinned_at == null) + const base = conversations.filter( + (c) => c.pinned_at == null && !chatFolderIds.has(c.folder_id) + ) if (showCompleted) return base return base.filter((c) => c.status !== "completed") - }, [conversations, showCompleted]) + }, [conversations, showCompleted, chatFolderIds]) + + // Flat "Chat" bucket: folderless chat-mode conversations, most-recently-updated + // first, with reference reuse (so an unrelated status event doesn't rebuild it + // and defeat the section's memo). Pinned chats live in the Pinned section. + const chatConvsRef = useRef([]) + const chatConversations = useMemo(() => { + const next = selectChatConversationsWithReuse( + conversations, + chatFolderIds, + showCompleted, + chatConvsRef.current + ) + chatConvsRef.current = next + return next + }, [conversations, chatFolderIds, showCompleted]) // Pinned bucket: the FULL conversation list (ignores "Show completed" — a // pinned conversation stays visible regardless), sorted most-recently-pinned @@ -848,9 +900,11 @@ export function SidebarConversationList({ const orderedFolderIds = useMemo(() => { const folderIdSet = new Set(folders.map((f) => f.id)) - // Worktree child folders are merged into their parent group, so they never - // get their own header row. - const isMergedChild = (id: number) => childToParent.has(id) + // Worktree child folders are merged into their parent group, and hidden + // chat folders belong to the flat "Chat" section — neither gets its own + // header row in the folders list. + const isHidden = (id: number) => + childToParent.has(id) || chatFolderIds.has(id) // During drag we honour the optimistic order so sibling folders shift live // as the user hovers over slots. We still filter/append against the source // of truth so newly-added or -removed folders don't disappear mid-drag. @@ -858,13 +912,13 @@ export function SidebarConversationList({ const seen = new Set() const ids: number[] = [] for (const id of dragOrder) { - if (folderIdSet.has(id) && !seen.has(id) && !isMergedChild(id)) { + if (folderIdSet.has(id) && !seen.has(id) && !isHidden(id)) { seen.add(id) ids.push(id) } } for (const f of folders) { - if (!seen.has(f.id) && !isMergedChild(f.id)) { + if (!seen.has(f.id) && !isHidden(f.id)) { seen.add(f.id) ids.push(f.id) } @@ -875,13 +929,13 @@ export function SidebarConversationList({ const seen = new Set() const ids: number[] = [] for (const f of folders) { - if (!seen.has(f.id) && !isMergedChild(f.id)) { + if (!seen.has(f.id) && !isHidden(f.id)) { seen.add(f.id) ids.push(f.id) } } return ids - }, [folders, dragOrder, childToParent]) + }, [folders, dragOrder, childToParent, chatFolderIds]) const darkMode = resolvedTheme === "dark" @@ -900,6 +954,8 @@ export function SidebarConversationList({ folderExpanded, folderTotalCounts, foldersExpanded, + chatConversations, + chatsExpanded, }), [ pinned, @@ -909,6 +965,8 @@ export function SidebarConversationList({ folderExpanded, folderTotalCounts, foldersExpanded, + chatConversations, + chatsExpanded, ] ) @@ -978,6 +1036,18 @@ export function SidebarConversationList({ pendingScrollRef.current = true return } + } else if (chatFolderIds.has(conv.folder_id)) { + // Chat conversations live in the flat Chat section — gated only by that + // section's collapse, never by any folder. + if (!chatsExpanded) { + setSectionCollapsed((prev) => { + const next = { ...prev, chats: false } + saveSectionCollapsed(next) + return next + }) + pendingScrollRef.current = true + return + } } else { // A folder conversation appears only when the Folders section AND its // (display) folder are expanded. @@ -1030,6 +1100,8 @@ export function SidebarConversationList({ childToParent, pinnedExpanded, foldersExpanded, + chatFolderIds, + chatsExpanded, ]) const toggleFolder = useCallback((folderId: number) => { @@ -1646,6 +1718,10 @@ export function SidebarConversationList({ section={row.section} expanded={row.expanded} onToggle={toggleSection} + // The chats section gets an always-visible New-chat button (its primary + // entry point, reachable even when empty). `openChatModeTab` is a stable + // context callback, so the memo holds. + onNewChat={row.section === "chats" ? openChatModeTab : undefined} // Every section header carries a top gap: it separates "Folders" from // the "Pinned" section above it, and — now that a fixed New chat / // Search region sits above the scrolled list — gives the first section @@ -1681,6 +1757,15 @@ export function SidebarConversationList({ ) } + if (row.kind === "chats-empty") { + // Folderless flat hint — no themeWrap, no conversation rail; align with the + // section header's text inset (px-[0.5rem]) rather than the folder rail. + return ( +
+ {t("noChats")} +
+ ) + } const conv = row.conversation // Worktree child folders render under their parent group, so theme the row // by the display group (parent) for a unified look. @@ -1713,6 +1798,7 @@ export function SidebarConversationList({ if (row.kind === "section") return `section-${row.section}` if (row.kind === "folder") return `folder-${row.folderId}` if (row.kind === "empty") return `empty-${row.folderId}` + if (row.kind === "chats-empty") return "chats-empty" return `conv-${row.conversation.agent_type}-${row.conversation.id}` } @@ -1862,7 +1948,7 @@ export function SidebarConversationList({ onSelect={handleNewConversation} disabled={!activeFolder} > - + {t("newConversation")}
diff --git a/src/components/conversations/sidebar-section-header.tsx b/src/components/conversations/sidebar-section-header.tsx index dd29005f1..e70daf80c 100644 --- a/src/components/conversations/sidebar-section-header.tsx +++ b/src/components/conversations/sidebar-section-header.tsx @@ -1,7 +1,7 @@ "use client" import { memo } from "react" -import { ChevronRight } from "lucide-react" +import { ChevronRight, SquarePen } from "lucide-react" import { useTranslations } from "next-intl" import { cn } from "@/lib/utils" @@ -23,11 +23,20 @@ export const SidebarSectionHeader = memo(function SidebarSectionHeader({ section, expanded, onToggle, + onNewChat, topGap = false, }: { - section: "pinned" | "folders" + section: "pinned" | "folders" | "chats" expanded: boolean - onToggle: (section: "pinned" | "folders") => void + onToggle: (section: "pinned" | "folders" | "chats") => void + /** + * When provided on the "chats" section, renders a New-chat action button at + * the row's right edge, revealed only while the row is hovered/focused (and + * always on touch, which has no hover). A sibling of — not nested in — the + * toggle button (nesting buttons is invalid HTML), so clicking it never + * toggles the section. Must be referentially stable to preserve the memo. + */ + onNewChat?: () => void /** * Adds breathing room above the header so the "Folders" section reads as * visually separated from the "Pinned" section above it. Implemented as @@ -38,10 +47,16 @@ export const SidebarSectionHeader = memo(function SidebarSectionHeader({ topGap?: boolean }) { const t = useTranslations("Folder.sidebar") - const label = section === "pinned" ? t("sectionPinned") : t("sectionFolders") + const label = + section === "pinned" + ? t("sectionPinned") + : section === "chats" + ? t("sectionChats") + : t("sectionFolders") + const showNewChat = section === "chats" && onNewChat != null return (
-
+
+ {showNewChat && ( + + )}
) diff --git a/src/components/files/file-workspace-tab-bar.tsx b/src/components/files/file-workspace-tab-bar.tsx index 93baac376..7bb3b223a 100644 --- a/src/components/files/file-workspace-tab-bar.tsx +++ b/src/components/files/file-workspace-tab-bar.tsx @@ -23,7 +23,7 @@ import { useIsMobile } from "@/hooks/use-mobile" import { useLongPressDrag } from "@/hooks/use-long-press-drag" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" -import { cn } from "@/lib/utils" +import { cn, handleMiddleClickClose } from "@/lib/utils" import { ContextMenu, ContextMenuContent, @@ -316,6 +316,9 @@ const FileWorkspaceTabItem = memo(function FileWorkspaceTabItem({ role="tab" aria-selected={active} onClick={handleSwitch} + onMouseDown={(event) => + handleMiddleClickClose(event, () => onClose(tab.id)) + } className={cn( "group/filetab relative flex items-center h-full gap-1.5 px-3 text-xs rounded-full", "cursor-pointer select-none shrink-0 hover:bg-primary/8 transition-colors", diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index 0be7b666a..b23fa829f 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -87,6 +87,7 @@ import { resolveFolderDisplayName } from "@/lib/folder-display" import { useSwitchToBranch } from "@/hooks/use-switch-to-branch" import type { GitBranchList, GitConflictInfo } from "@/lib/types" import { useActiveFolder } from "@/contexts/active-folder-context" +import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useTabContext } from "@/contexts/tab-context" import { useTaskContext } from "@/contexts/task-context" @@ -122,6 +123,7 @@ export function BranchDropdown() { const t = useTranslations("Folder.branchDropdown") const tCommon = useTranslations("Folder.common") const { activeFolder } = useActiveFolder() + const isChatMode = useIsActiveChatMode() const { allFolders, branches, refreshFolder, openWorktreeFolder } = useAppWorkspace() const { openNewConversationTab } = useTabContext() @@ -586,7 +588,9 @@ export function BranchDropdown() { ) } - if (!activeFolder) return null + // Folderless chat conversations have no git branch — hide the top-bar + // selector entirely (covers both the mobile and desktop title-bar instances). + if (!activeFolder || isChatMode) return null // Worktree folders display their parent (root repo) name; paths/ids/git ops // below still use `activeFolder` (the worktree) unchanged. diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index 15d3d215b..bbc600dc9 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -15,6 +15,7 @@ import { openSettingsWindow } from "@/lib/api" import { getPetSettings, openPetWindow } from "@/lib/pet/api" import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useActiveFolder } from "@/contexts/active-folder-context" +import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" import { isDesktop, openFileDialog } from "@/lib/platform" import { getActiveRemoteConnectionId } from "@/lib/transport" import { Button } from "@/components/ui/button" @@ -49,6 +50,7 @@ export function FolderTitleBar() { const tPet = useTranslations("Pet") const { openFolder } = useAppWorkspace() const { activeFolder } = useActiveFolder() + const isChatMode = useIsActiveChatMode() const { isOpen, toggle } = useSidebarContext() const { isOpen: auxPanelOpen, toggle: toggleAuxPanel } = useAuxPanelContext() const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext() @@ -127,6 +129,9 @@ export function FolderTitleBar() { return } if (matchShortcutEvent(e, shortcuts.toggle_aux_panel)) { + // Chat mode hides the aux panel + its toggle; the shortcut must not + // re-open it either. + if (isChatMode) return e.preventDefault() toggleAuxPanel() return @@ -159,6 +164,7 @@ export function FolderTitleBar() { toggle, toggleAuxPanel, toggleTerminal, + isChatMode, ]) const isMobile = useIsMobile() @@ -229,13 +235,16 @@ export function FolderTitleBar() { - - - {tTitleBar("toggleAuxPanel")} - + {/* Folderless chat conversations hide the aux panel entirely. */} + {!isChatMode && ( + + + {tTitleBar("toggleAuxPanel")} + + )} toggleTerminal()} disabled={!activeFolder} @@ -272,22 +281,25 @@ export function FolderTitleBar() { > - + {/* Folderless chat conversations hide the aux panel entirely. */} + {!isChatMode && ( + + )} {/* Desktop search moved into the sidebar's fixed top region; the dialog + ⌘K shortcut still live here. */} +
+
+
+
+ + {t("title")} + + {count} + +
+ +
- -
+
{entries.map((entry) => { - const active = activeThreadIndex === entry.threadIndex + const isOpen = openGroups[entry.turnId] ?? false + const uniqueFileCount = new Set( + entry.files.map((file) => normalizeSlashPath(file.path)) + ).size + return ( - - ) - })} -
- - - {expanded && ( -
-
- {t("title")} - -
- - -
- {entries.map((entry) => { - const isOpen = openGroups[entry.turnId] ?? false - const active = activeThreadIndex === entry.threadIndex - const uniqueFileCount = new Set( - entry.files.map((file) => normalizeSlashPath(file.path)) - ).size - - return ( -
+ - - {entry.hasChanges && ( - )} -
+ + - {entry.hasChanges && isOpen && ( -
    - {entry.files.map((file, fileIndex) => { - const displayPath = toFolderRelativePath( - file.path, - folder?.path - ) - const isRemoved = isRemovedFileDiff(file.diff) + {entry.hasChanges && ( + + )} +
- return ( -
  • - -
  • - ) - })} - - )} -
    - ) - })} -
    -
    + + )} + + + ) + })} + + )} +
    + ) + })}
    - )} +
    ) -} +}) diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 8c77d2bd0..124985244 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -58,11 +58,6 @@ import { type MessageNavEntry, } from "@/components/message/conversation-message-nav" import type { MessageScrollContextValue } from "@/components/message/message-scroll-context" -import { - pickActiveThreadIndex, - reconcileActive, - type ActiveClickGuard, -} from "@/lib/message-nav-active" import { extractSessionFilesGrouped } from "@/lib/session-files" import { useStickToBottomContext } from "use-stick-to-bottom" @@ -139,11 +134,6 @@ const EMPTY_DELEGATIONS: DelegationCardSource[] = [] // when a conversation has no user messages. const EMPTY_NAV_ENTRIES: MessageNavEntry[] = [] -// How long a marker click keeps its tick active while the smooth scroll -// settles. Released early once the scroll arrives (see reconcileActive); this -// is only the safety net for bottom-clamped targets that never reach the top. -const ACTIVE_CLICK_GUARD_MS = 1000 - // Collect the `delegate_to_agent` tool calls within a turn's adapted parts, // recursing through tool-groups and goal-runs (a delegate call is normally a // standalone part — `isAgentLikeToolName` keeps it out of tool-groups — but we @@ -743,22 +733,31 @@ export function MessageListView({ ? `subagents-${lastAssistantGroup.id}` : `subagents-history-${conversationId}` - // --- Message navigator rail ------------------------------------------------- - // Lifted scroll handle so the rail (a sibling outside the MessageScrollProvider - // subtree) can drive scrollToIndex. + // --- Message navigator panel ------------------------------------------------ + // Lifted scroll handle so the panel (which lives in the overlay stack, outside + // the MessageScrollProvider subtree) can drive scrollToIndex. const scrollApiRef = useRef(null) - const [activeThreadIndex, setActiveThreadIndex] = useState( - null - ) - // A marker click optimistically activates its tick; this guard stops the - // ensuing smooth-scroll readings from regressing it before the scroll lands. - const activeClickGuardRef = useRef(null) + // Collapse state is owned here (not in the panel) so the expensive per-file + // `navEntries` is computed only while the panel is open. + const [navExpanded, setNavExpanded] = useState(false) + + // Cheap user-message tally for the collapsed chip — counts user turns without + // parsing any file diffs. + const userMessageCount = useMemo(() => { + if (!showMessageNav) return 0 + let count = 0 + for (const item of threadItems) { + if (item.kind === "turn" && item.group.role === "user") count += 1 + } + return count + }, [showMessageNav, threadItems]) // One entry per user message — including ones with no edits (placeholders). - // `extractSessionFilesGrouped(..., {includeEmpty})` yields a group per user - // turn in order; we join the rendered threadItems index for scrolling. + // Computed lazily: only while the panel is expanded, since + // `extractSessionFilesGrouped` parses every turn's diffs. Collapsed (the + // default) it stays EMPTY, keeping the streaming hot path free of diff parsing. const navEntries = useMemo(() => { - if (!showMessageNav) return EMPTY_NAV_ENTRIES + if (!showMessageNav || !navExpanded) return EMPTY_NAV_ENTRIES const turns = timelineTurns.map((item) => item.turn) const groups = extractSessionFilesGrouped(turns, { includeEmpty: true }) if (groups.length === 0) return EMPTY_NAV_ENTRIES @@ -793,39 +792,7 @@ export function MessageListView({ }) } return entries.length > 0 ? entries : EMPTY_NAV_ENTRIES - }, [showMessageNav, timelineTurns, threadItems]) - - // Optimistically activate the clicked tick and arm a guard so the smooth - // scroll that follows can't regress the highlight to the previous tick - // before it lands (and so bottom-clamped targets, which never reach the top, - // still light up — see reconcileActive). - const handleMarkerActivate = useCallback((threadIndex: number) => { - activeClickGuardRef.current = { - target: threadIndex, - releaseAfter: performance.now() + ACTIVE_CLICK_GUARD_MS, - } - setActiveThreadIndex(threadIndex) - }, []) - - // navEntries is ascending by threadIndex; pick the last one at or above the - // viewport top, reconcile it with any pending click guard, and only setState - // when it changes (avoids a storm on every scroll frame). Depending on - // navEntries keeps this referentially stable while turns are unchanged, so - // the VirtualizedMessageThread memo still bails out on cross-tab broadcast - // re-renders. - const handleVisibleStartIndexChange = useCallback( - (startIndex: number) => { - const computed = pickActiveThreadIndex(navEntries, startIndex) - const { active, guard } = reconcileActive( - computed, - activeClickGuardRef.current, - performance.now() - ) - activeClickGuardRef.current = guard - setActiveThreadIndex((prev) => (prev === active ? prev : active)) - }, - [navEntries] - ) + }, [showMessageNav, navExpanded, timelineTurns, threadItems]) const hasRenderableContent = threadItems.length > 0 || Boolean(liveMessage) @@ -898,60 +865,61 @@ export function MessageListView({ } return ( -
    -
    - - - + + + + + + {liveMessage && connStatus === "prompting" && ( + + )} + {/* Shared overlay stack pinned to the inline-start edge (top-left in LTR, + top-right in RTL). A flex column keeps the order stable regardless of + each panel's expand/collapse height: the message navigator first, then + the plan panel, then the sub-agent panel. Empty panels render null and + collapse out. Positioning lives here (not in the child overlays); the + chips are "bullets" — flat on the start side (flush to the pinned + edge), rounded on the end side — that expand toward the inline-end on + hover. Logical `start-0` + `items-start` keep the anchor and the bullet + on the same side, so the whole stack mirrors cleanly in RTL. */} +
    + {showMessageNav && userMessageCount > 0 && ( + - - - {liveMessage && connStatus === "prompting" && ( - )} - {/* Shared overlay stack: the plan panel on top, the sub-agent panel - below it. A flex column keeps the order stable regardless of each - panel's expand/collapse height; empty panels render null and collapse - out. Positioning lives here (not in the child overlays). */} -
    - - -
    -
    - {showMessageNav && navEntries.length > 0 && ( - - )} + +
    ) } diff --git a/src/components/message/virtualized-message-thread.tsx b/src/components/message/virtualized-message-thread.tsx index 7a1813f02..e1987203f 100644 --- a/src/components/message/virtualized-message-thread.tsx +++ b/src/components/message/virtualized-message-thread.tsx @@ -48,26 +48,11 @@ interface VirtualizedMessageThreadProps { /** * Publishes the virtualizer scroll handle to an ancestor so siblings that * live outside the `MessageScrollProvider` subtree (e.g. the conversation - * message navigator rail) can drive `scrollToIndex`. + * message navigator) can drive `scrollToIndex`. */ scrollApiRef?: RefObject - /** - * Fires with the index of the item nearest the top of the viewport whenever - * the thread scrolls. Used to highlight the active entry in the navigator. - */ - onVisibleStartIndexChange?: (index: number) => void } -/** - * Small top tolerance (px) when mapping scroll offset → "active" item index. - * A click runs `scrollToIndex(N, {align: "start"})`, pinning message N to the - * top, but the browser floors `scrollTop` a sub-pixel below `offsetOf(N)`, so - * `findItemIndex` (largest i with `offsetOf(i) <= offset`) returns N-1. Because - * user-message nav ticks are sparse, N-1 lands on the *previous* tick. Nudging - * the query past that boundary maps the pinned message back to itself. - */ -const ACTIVE_TOP_EPSILON_PX = 2 - function VirtualizedMessageThreadImpl({ items, getItemKey, @@ -81,7 +66,6 @@ function VirtualizedMessageThreadImpl({ contentClassName, contentProps, scrollApiRef, - onVisibleStartIndexChange, }: VirtualizedMessageThreadProps) { const { scrollRef } = useStickToBottomContext() const virtualizerHandleRef = useRef(null) @@ -139,17 +123,6 @@ function VirtualizedMessageThreadImpl({ return () => el.removeEventListener("pointerdown", onPointerDown) }, [scrollRef]) - const handleScroll = useCallback( - (offset: number) => { - if (!onVisibleStartIndexChange) return - const index = virtualizerHandleRef.current?.findItemIndex( - offset + ACTIVE_TOP_EPSILON_PX - ) - if (typeof index === "number") onVisibleStartIndexChange(index) - }, - [onVisibleStartIndexChange] - ) - // Pre-compute the three possible padding styles so every render reuses // the same object references (avoids allocating per-item on each frame). const styles = useMemo(() => { @@ -184,7 +157,6 @@ function VirtualizedMessageThreadImpl({ scrollRef={scrollRef as unknown as RefObject} itemSize={itemSize} bufferSize={bufferSize} - onScroll={onVisibleStartIndexChange ? handleScroll : undefined} > {items.map((item, index) => (
    -
    +
    +
    {t("preferences")}
    - - + + + +
    ) return ( @@ -240,7 +243,9 @@ export function SettingsShell({ children }: SettingsShellProps) {
    {/* Desktop sidebar */} {!isMobile && ( - + )} {/* Mobile navigation Sheet */} diff --git a/src/components/tabs/tab-item.tsx b/src/components/tabs/tab-item.tsx index ffb871f3e..a30ec301c 100644 --- a/src/components/tabs/tab-item.tsx +++ b/src/components/tabs/tab-item.tsx @@ -4,7 +4,7 @@ import { memo, useCallback, useMemo, useRef } from "react" import { Reorder } from "motion/react" import { X } from "lucide-react" import { useTranslations } from "next-intl" -import { cn } from "@/lib/utils" +import { cn, handleMiddleClickClose } from "@/lib/utils" import type { ConversationStatus } from "@/lib/types" import { ConversationStatusDot } from "@/components/conversations/conversation-status-dot" import { @@ -126,6 +126,7 @@ export const TabItem = memo(function TabItem({ aria-selected={isActive} onClick={handleClick} onDoubleClick={handleDoubleClick} + onMouseDown={(event) => handleMiddleClickClose(event, handleClose)} className={cn( "group/tab relative flex items-center h-full gap-1.5 px-3 text-xs rounded-full", "cursor-pointer select-none shrink-0", diff --git a/src/components/terminal/terminal-tab-bar.tsx b/src/components/terminal/terminal-tab-bar.tsx index d3ccc2c4f..d2a07d879 100644 --- a/src/components/terminal/terminal-tab-bar.tsx +++ b/src/components/terminal/terminal-tab-bar.tsx @@ -9,6 +9,7 @@ import { useTerminalContext } from "@/contexts/terminal-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { useIsMac } from "@/hooks/use-is-mac" import { formatShortcutLabel } from "@/lib/keyboard-shortcuts" +import { handleMiddleClickClose } from "@/lib/utils" import { Button } from "@/components/ui/button" import { ContextMenu, @@ -79,6 +80,10 @@ export function TerminalTabBar() { : "text-muted-foreground hover:text-foreground hover:bg-muted" }`} onClick={() => switchTerminal(tab.id)} + onMouseDown={(event) => { + if (editingId === tab.id) return + handleMiddleClickClose(event, () => closeTerminal(tab.id)) + }} title={`${folderIndex.get(tab.folderId) ?? String(tab.folderId)} — ${tab.title}`} > {editingId === tab.id ? ( diff --git a/src/contexts/aux-panel-context.tsx b/src/contexts/aux-panel-context.tsx index 6c2991398..9cd965190 100644 --- a/src/contexts/aux-panel-context.tsx +++ b/src/contexts/aux-panel-context.tsx @@ -32,6 +32,8 @@ interface AuxPanelContextValue { maxWidth: number activeTab: AuxPanelTab toggle: () => void + /** Imperatively set the panel open/closed (used by the chat-mode auto-hide). */ + setOpen: (open: boolean) => void setWidth: (w: number) => void setActiveTab: (tab: AuxPanelTab) => void openTab: (tab: AuxPanelTab) => void @@ -71,6 +73,8 @@ export function AuxPanelProvider({ children }: AuxPanelProviderProps) { const toggle = useCallback(() => setIsOpen((prev) => !prev), []) + const setOpen = useCallback((open: boolean) => setIsOpen(open), []) + const setWidth = useCallback((w: number) => { setWidthState(clampWidth(w)) }, []) @@ -123,6 +127,7 @@ export function AuxPanelProvider({ children }: AuxPanelProviderProps) { maxWidth: MAX_WIDTH, activeTab, toggle, + setOpen, setWidth, setActiveTab, openTab, @@ -136,6 +141,7 @@ export function AuxPanelProvider({ children }: AuxPanelProviderProps) { width, activeTab, toggle, + setOpen, setWidth, openTab, pendingRevealPath, diff --git a/src/contexts/tab-context.test.tsx b/src/contexts/tab-context.test.tsx index fe14c9819..e95f7148b 100644 --- a/src/contexts/tab-context.test.tsx +++ b/src/contexts/tab-context.test.tsx @@ -45,6 +45,7 @@ vi.mock("@/contexts/app-workspace-context", () => ({ useAppWorkspace: () => ({ conversations: conversationsMock, folders: foldersMock, + allFolders: allFoldersMock, foldersHydrated: true, setActiveFolderId: setActiveFolderIdMock, }), @@ -80,6 +81,7 @@ const defaultFoldersMock: FolderDetail[] = [ sort_order: 0, color: "blue", parent_id: null, + is_chat: false, }, { id: 2, @@ -91,10 +93,14 @@ const defaultFoldersMock: FolderDetail[] = [ sort_order: 1, color: "green", parent_id: null, + is_chat: false, }, ] let foldersMock: FolderDetail[] = defaultFoldersMock +// `allFolders` includes hidden chat folders that the user-facing `folders` list +// excludes; defaults to the same set (no chat folders) for most tests. +let allFoldersMock: FolderDetail[] = defaultFoldersMock const conversationsMock: DbConversationSummary[] = [ { @@ -190,6 +196,7 @@ describe("TabProvider tab state transitions", () => { beforeEach(() => { vi.clearAllMocks() foldersMock = defaultFoldersMock + allFoldersMock = defaultFoldersMock listOpenedTabsMock.mockReturnValue(new Promise(() => {})) saveOpenedTabsMock.mockResolvedValue({ accepted: true, @@ -343,6 +350,72 @@ describe("TabProvider tab state transitions", () => { expect(screen.getByTestId("active")).toHaveTextContent(tabsText) }) + it("redirects a new-conversation action targeting a hidden chat folder to chat mode", () => { + // The open-folder list (`folders`) excludes chat folders after refetch, but + // `allFolders` keeps them — chat detection must read `allFolders`, else a + // "new conversation" from an active chat conversation would pile a normal + // draft onto the hidden per-conversation chat folder. + const chatFolder: FolderDetail = { + id: 42, + name: "Chat", + path: "/data/chat-sessions/x", + git_branch: null, + default_agent_type: null, + last_opened_at: "2026-06-11T00:00:00Z", + sort_order: 99, + color: "inherit", + parent_id: null, + is_chat: true, + } + foldersMock = defaultFoldersMock + allFoldersMock = [...defaultFoldersMock, chatFolder] + renderTabs() + expect(latestContext).not.toBeNull() + + act(() => { + latestContext?.openNewConversationTab(42, "/data/chat-sessions/x") + }) + + const activeId = latestContext?.activeTabId ?? "" + const draft = latestContext?.tabs.find((t) => t.id === activeId) + expect(activeId).toMatch(/^new-/) + expect(draft?.isChat).toBe(true) + expect(draft?.folderId).toBe(0) + }) + + it("seeds a non-chat replacement draft when closing a bound chat tab whose folder is filtered from the open list", () => { + const chatFolder: FolderDetail = { + id: 42, + name: "Chat", + path: "/data/chat-sessions/x", + git_branch: null, + default_agent_type: null, + last_opened_at: "2026-06-11T00:00:00Z", + sort_order: 99, + color: "inherit", + parent_id: null, + is_chat: true, + } + foldersMock = defaultFoldersMock // open list excludes the chat folder + allFoldersMock = [...defaultFoldersMock, chatFolder] + renderTabs() + expect(latestContext).not.toBeNull() + + act(() => { + latestContext?.openTab(42, 5, "codex", true, "chat conversation") + }) + act(() => { + latestContext?.closeTab("conv-42-codex-5") + }) + + const replId = latestContext?.activeTabId ?? "" + const repl = latestContext?.tabs.find((t) => t.id === replId) + expect(replId).toMatch(/^new-/) + expect(repl?.conversationId).toBeNull() + expect(repl?.folderId).not.toBe(42) + expect(repl?.isChat ?? false).toBe(false) + }) + it("retargets the replacement draft when reopening a closed draft for another folder in the same batch", async () => { renderTabs() @@ -482,6 +555,7 @@ describe("TabProvider cross-client sync", () => { beforeEach(() => { vi.clearAllMocks() foldersMock = defaultFoldersMock + allFoldersMock = defaultFoldersMock listOpenedTabsMock.mockResolvedValue({ items: [], version: 0 }) saveOpenedTabsMock.mockResolvedValue({ accepted: true, @@ -520,6 +594,37 @@ describe("TabProvider cross-client sync", () => { expect(screen.getByTestId("tabs")).toHaveTextContent("conv-1-codex-1") }) + it("preserves an active chat-mode draft across an inbound remote snapshot", async () => { + await renderHydrated() + expect(tabsChangedHandler).not.toBeNull() + + // Enter folderless chat mode → a device-local chat draft (folderId 0). + act(() => { + latestContext?.openChatModeTab() + }) + const chatDraftId = latestContext?.activeTabId ?? "" + expect(chatDraftId).toMatch(/^new-/) + expect(latestContext?.tabs.find((t) => t.id === chatDraftId)?.isChat).toBe( + true + ) + + // A remote snapshot arrives. The chat draft's folderId 0 is in no folder + // list, so it must be preserved by its `isChat` flag — never silently + // dropped — keeping the user on their unsent folderless draft. + act(() => { + tabsChangedHandler?.({ + version: 1, + origin: "other-device", + tabs: [tabItem(1, 1)], + }) + }) + + const draft = latestContext?.tabs.find((t) => t.id === chatDraftId) + expect(draft).toBeDefined() + expect(draft?.isChat).toBe(true) + expect(draft?.conversationId).toBeNull() + }) + it("does not save when applying a remote snapshot (no echo back)", async () => { await renderHydrated() saveOpenedTabsMock.mockClear() diff --git a/src/contexts/tab-context.tsx b/src/contexts/tab-context.tsx index 6b5f83f97..401bbba85 100644 --- a/src/contexts/tab-context.tsx +++ b/src/contexts/tab-context.tsx @@ -55,6 +55,16 @@ interface TabItemInternal { * runs (e.g. `acpListAgents()` keeps failing). */ agentTypeProvisional?: boolean + /** + * Marks a draft tab as "chat mode" (folderless). Set by `openChatModeTab`, + * cleared implicitly once the draft binds to a real conversation (whose hidden + * `is_chat` folder then drives chat-mode chrome via `useIsActiveChatMode`). + * **Internal-only and never persisted** — drafts (`conversationId == null`) are + * not written to opened_tabs, so this flag only ever lives in memory for the + * pre-send draft. While set, the draft has no resolvable folder, so the + * composer hides the branch picker and shows the "no-folder" chip. + */ + isChat?: boolean } export type TabItem = TabItemInternal @@ -111,6 +121,25 @@ interface TabContextValue { folderDefaultAgent?: AgentType | null } ) => void + /** + * Re-target the singleton draft tab into folderless "chat mode" — no DB write + * and no working dir yet (the backend creates the dated scratch dir + hidden + * `is_chat` folder lazily on first send, in `createChatConversation`). Sets + * the draft's `isChat` flag, drops its `workingDir`, and disconnects any live + * ACP session bound to the draft (its cwd is about to change). Wired from the + * composer folder picker's "no-folder mode" item. + */ + openChatModeTab: () => void + /** + * Attach an eagerly-created scratch dir to a chat-mode draft so its ACP + * connection can spawn at a real cwd *before* the first send. Patches the + * draft's `workingDir` only while it is still an unbound chat draft + * (`isChat && conversationId == null`); `folderId` stays 0 (no DB row yet, so + * `activeFolder` resolves null until the lazy create binds the hidden folder). + * A stale call (the draft already bound, retargeted, or left chat mode) is a + * no-op. Wired from conversation-detail-panel's eager-prepare effect. + */ + setChatDraftWorkingDir: (tabId: string, workingDir: string) => void /** * Mark a draft tab's agent as user-confirmed. Patches `agentType` on * the tab and clears the `agentTypeProvisional` flag so the correction @@ -134,7 +163,16 @@ interface TabContextValue { conversationId: number, agentType: AgentType, title: string, - runtimeConversationId?: number + runtimeConversationId?: number, + /** + * When a chat-mode draft binds, the backend has just created its hidden + * `is_chat` folder; pass the new `folderId`/`workingDir` so the tab points at + * the real per-conversation scratch dir (cwd) and `activeFolderId` syncs to + * the hidden folder (which drives chat-mode chrome). Omit for normal binds — + * the tab keeps its existing folder. + */ + folderId?: number, + workingDir?: string ) => void setTabRuntimeConversationId: ( tabId: string, @@ -237,8 +275,13 @@ function buildPersistItems( export function TabProvider({ children }: TabProviderProps) { const t = useTranslations("Folder.tabContext") const { activateConversationPane } = useWorkspaceContext() - const { conversations, folders, foldersHydrated, setActiveFolderId } = - useAppWorkspace() + const { + conversations, + folders, + allFolders, + foldersHydrated, + setActiveFolderId, + } = useAppWorkspace() const { disconnect: acpDisconnect } = useAcpActions() const [tabState, setTabState] = useState({ @@ -318,6 +361,19 @@ export function TabProvider({ children }: TabProviderProps) { foldersRef.current = folders }, [folders]) + // `allFolders` includes hidden `is_chat` folders (the user-facing `folders` + // list filters them out, and drops them on refetch), so chat-folder detection + // must read this ref — never `foldersRef`. + const allFoldersRef = useRef(allFolders) + useEffect(() => { + allFoldersRef.current = allFolders + }, [allFolders]) + + // Forward reference to `openChatModeTab` (defined after `openNewConversationTab` + // but called by it for the chat-folder redirect). Assigned at render time once + // the callback is created, mirroring the existing callback-ref idiom. + const openChatModeTabRef = useRef<() => void>(() => {}) + // ACP agent list driven by the shared hook. `sortedTypes` reflects the // user-defined drag-sort order (filtered to enabled+available) and is // seeded from localStorage for synchronous cold-start use. `fresh` @@ -449,6 +505,10 @@ export function TabProvider({ children }: TabProviderProps) { workingDir: request.workingDir, agentType: request.agentType, agentTypeProvisional: request.provisional, + // Retargets only ever move a draft to a REAL folder (the + // chat-folder case is redirected to openChatModeTab), so this + // clears chat mode if the draft was previously a chat draft. + isChat: false, } : tab ), @@ -732,11 +792,26 @@ export function TabProvider({ children }: TabProviderProps) { const makeReplacementDraftTab = useCallback( (preferred?: TabItemInternal): TabItemInternal => { - const folderId = preferred?.folderId ?? foldersRef.current[0]?.id ?? 0 - const workingDir = - preferred?.workingDir ?? - foldersRef.current.find((f) => f.id === folderId)?.path ?? - "" + // A closing chat-mode tab (its hidden `is_chat` folder, or the in-memory + // draft flag) must not seed the replacement draft — that folder is hidden + // from folder lists and has no real project cwd. Fall back to a real + // folder. Detection reads `allFoldersRef` (the in-memory draft flag is + // dropped on reload, and `foldersRef` excludes chat folders after refetch), + // while the fallback pool reads the user-facing `foldersRef`. + const preferredIsChat = + preferred?.isChat === true || + allFoldersRef.current.find((f) => f.id === preferred?.folderId) + ?.is_chat === true + const nonChatFallbackId = + foldersRef.current.find((f) => !f.is_chat)?.id ?? 0 + const folderId = preferredIsChat + ? nonChatFallbackId + : (preferred?.folderId ?? nonChatFallbackId) + const workingDir = preferredIsChat + ? (foldersRef.current.find((f) => f.id === folderId)?.path ?? "") + : (preferred?.workingDir ?? + foldersRef.current.find((f) => f.id === folderId)?.path ?? + "") // If we have a preferred (closing) tab, inherit BOTH its agent and // its provisional flag — we should not silently launder a system // best-guess into a confirmed value just because the source tab was @@ -839,12 +914,15 @@ export function TabProvider({ children }: TabProviderProps) { } }) - // Keep the device-local draft if its folder still exists. + // Keep the device-local draft if it's a folderless chat draft (its + // `folderId` 0 is in no folder list, so check the flag) or its real + // folder still exists. Never yank the user off an in-progress draft. const localDraft = prev.rawTabs.find((tb) => tb.conversationId == null) const nextTabs = [...remoteTabs] if ( localDraft && - foldersRef.current.some((f) => f.id === localDraft.folderId) + (localDraft.isChat === true || + foldersRef.current.some((f) => f.id === localDraft.folderId)) ) { nextTabs.push(localDraft) } @@ -1173,6 +1251,15 @@ export function TabProvider({ children }: TabProviderProps) { folderDefaultAgent?: AgentType | null } ) => { + // "New conversation" while a chat conversation is active resolves the + // active (hidden) chat folder. Never pile a second conversation into a + // per-conversation chat folder — its delete cleanup retires the folder and + // it has no real project cwd — so start a fresh folderless chat draft + // instead. Single choke point for every "new conversation" entry point. + if (allFoldersRef.current.find((f) => f.id === folderId)?.is_chat) { + openChatModeTabRef.current() + return + } // Pick the agent for the new conversation via the shared resolver. // Only inherit from the active tab when the caller opted in. The // active tab counts as a valid inherit source if it's either: @@ -1283,6 +1370,122 @@ export function TabProvider({ children }: TabProviderProps) { [activateConversationPane, resolveAgentForFolder, t] ) + const openChatModeTab = useCallback(() => { + // Inherit the agent like openNewConversationTab's inherit path: keep the + // active tab's agent when it's a real conversation or a confirmed draft, + // else fall back to the global default (chat mode has no folder default). + const activeTab = rawTabsRef.current.find( + (x) => x.id === activeTabIdRef.current + ) + const inherit = + activeTab && + (activeTab.conversationId != null || !activeTab.agentTypeProvisional) + ? activeTab.agentType + : null + const { agentType: targetAgent, provisional } = resolveAgentForFolder( + 0, + inherit, + null + ) + + // Capture the existing singleton draft (if any) up front so its stale ACP + // session can be torn down after we flip it to chat mode. + const existingDraft = rawTabsRef.current.find( + (t) => t.conversationId == null + ) + const needsDisconnect = + existingDraft != null && + !(existingDraft.isChat && existingDraft.folderId === 0) + + const tabId = makeNewConversationTabId() + setTabState((prevState) => { + const existingTab = prevState.rawTabs.find( + (t) => t.conversationId == null + ) + + if (!existingTab) { + const newTab: TabItemInternal = { + id: tabId, + kind: "conversation", + folderId: 0, + conversationId: null, + agentType: targetAgent, + title: t("newConversation"), + isPinned: true, + workingDir: undefined, + agentTypeProvisional: provisional, + isChat: true, + } + return { + ...prevState, + rawTabs: [...prevState.rawTabs, newTab], + activeTabId: tabId, + } + } + + // Already a chat-mode draft — just focus it. + if (existingTab.isChat && existingTab.folderId === 0) { + if (prevState.activeTabId === existingTab.id) return prevState + return { ...prevState, activeTabId: existingTab.id } + } + + // Existing draft on a real folder: flip it to chat mode SYNCHRONOUSLY in + // this same state update (folderId + isChat together), so a send issued + // before any async teardown can never still create/send in the old folder. + // Its now-stale ACP session is disconnected fire-and-forget below. The + // agent is re-resolved for chat mode (no folder default), so a draft still + // carrying its old folder's provisional default doesn't leak into chat. + return { + ...prevState, + activeTabId: existingTab.id, + rawTabs: prevState.rawTabs.map((tab) => + tab.id === existingTab.id + ? { + ...tab, + folderId: 0, + workingDir: undefined, + isChat: true, + agentType: targetAgent, + agentTypeProvisional: provisional, + } + : tab + ), + } + }) + if (needsDisconnect && existingDraft) { + void acpDisconnect(existingDraft.id).catch((err) => { + console.error("[TabProvider] disconnect chat-mode draft:", err) + }) + } + activateConversationPane() + }, [acpDisconnect, activateConversationPane, resolveAgentForFolder, t]) + // Forward reference for `openNewConversationTab`'s chat-folder redirect (the + // callbacks are siblings; this mirrors the codebase's callback-ref idiom). + openChatModeTabRef.current = openChatModeTab + + const setChatDraftWorkingDir = useCallback( + (tabId: string, workingDir: string) => { + setTabs((prev) => + prev.map((tab) => { + if (tab.id !== tabId) return tab + // Guard against a stale eager-prepare result landing after the draft + // already bound, retargeted to a real folder, or left chat mode — any + // of which would make this workingDir wrong. Only patch a still-unbound + // chat draft, and skip a redundant write to keep the reference stable. + if ( + tab.conversationId != null || + tab.isChat !== true || + tab.workingDir === workingDir + ) { + return tab + } + return { ...tab, workingDir } + }) + ) + }, + [setTabs] + ) + const confirmDraftAgent = useCallback( (tabId: string, agentType: AgentType) => { setTabs((prev) => @@ -1320,7 +1523,9 @@ export function TabProvider({ children }: TabProviderProps) { conversationId: number, agentType: AgentType, title: string, - runtimeConversationId?: number + runtimeConversationId?: number, + folderId?: number, + workingDir?: string ) => { setTabState((prevState) => { const nextTabs = prevState.rawTabs.flatMap((tab) => { @@ -1334,6 +1539,12 @@ export function TabProvider({ children }: TabProviderProps) { // Bound to a real conversation now — drop the provisional // hint so the correction effect never revisits it. agentTypeProvisional: false, + // Chat-mode bind: point at the backend-created hidden `is_chat` + // folder and its scratch cwd. `isChat` stays set so chrome stays + // hidden through the brief window before the folder lands in + // `allFolders` (after which `activeFolder.is_chat` takes over). + ...(folderId != null ? { folderId } : {}), + ...(workingDir != null ? { workingDir } : {}), } return [nextTab] } @@ -1526,6 +1737,8 @@ export function TabProvider({ children }: TabProviderProps) { pinTab, toggleTileMode, openNewConversationTab, + openChatModeTab, + setChatDraftWorkingDir, confirmDraftAgent, setDraftAgentFromFallback, bindConversationTab, @@ -1548,6 +1761,8 @@ export function TabProvider({ children }: TabProviderProps) { pinTab, toggleTileMode, openNewConversationTab, + openChatModeTab, + setChatDraftWorkingDir, confirmDraftAgent, setDraftAgentFromFallback, bindConversationTab, diff --git a/src/hooks/use-connection.ts b/src/hooks/use-connection.ts index 45b12e443..4dc546bb0 100644 --- a/src/hooks/use-connection.ts +++ b/src/hooks/use-connection.ts @@ -46,6 +46,10 @@ export interface UseConnectionReturn { selectorsReady: boolean hasCachedSelectors: boolean sessionId: string | null + /** The working directory the live connection was established with (null when + * not connected). Lets callers detect a connection that is mid-reconnect to a + * different cwd and avoid acting on the stale one. */ + connectedWorkingDir: string | null modes: SessionModeStateInfo | null configOptions: SessionConfigOptionInfo[] | null availableCommands: AvailableCommandInfo[] | null @@ -126,6 +130,7 @@ export function useConnection(contextKey: string): UseConnectionReturn { ? getCachedSelectors(connection.agentType) : null const hasCachedSelectors = cached !== null + const connectedWorkingDir = connection?.workingDir ?? null const modes = connection?.modes ?? cached?.modes ?? null const configOptions = connection?.configOptions ?? cached?.configOptions ?? null @@ -225,6 +230,7 @@ export function useConnection(contextKey: string): UseConnectionReturn { selectorsReady, hasCachedSelectors, sessionId, + connectedWorkingDir, modes, configOptions, availableCommands, @@ -260,6 +266,7 @@ export function useConnection(contextKey: string): UseConnectionReturn { selectorsReady, hasCachedSelectors, sessionId, + connectedWorkingDir, modes, configOptions, availableCommands, diff --git a/src/hooks/use-is-active-chat-mode.ts b/src/hooks/use-is-active-chat-mode.ts new file mode 100644 index 000000000..dc6793137 --- /dev/null +++ b/src/hooks/use-is-active-chat-mode.ts @@ -0,0 +1,20 @@ +"use client" + +import { useActiveFolder } from "@/contexts/active-folder-context" +import { useTabContext } from "@/contexts/tab-context" + +/** + * True when the active conversation is folderless "chat mode" — either a bound + * conversation whose backing folder is a hidden `is_chat` folder, or a + * not-yet-sent chat draft (the in-memory `isChat` tab flag). Drives hiding of + * folder-bound chrome — the top-bar branch selector, the aux-panel toggle, the + * right sidebar, and the composer branch picker — consistently from the moment + * "no-folder mode" is selected through first send and beyond. + */ +export function useIsActiveChatMode(): boolean { + const { activeFolder } = useActiveFolder() + const { tabs, activeTabId } = useTabContext() + if (activeFolder?.is_chat === true) return true + const activeTab = tabs.find((t) => t.id === activeTabId) + return activeTab?.isChat === true +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 97d4806b6..e265647e5 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1001,7 +1001,10 @@ "toastOpFailed": "فشلت العملية: {message}" }, "sectionPinned": "مثبّتة", - "sectionFolders": "المجلدات" + "sectionFolders": "المجلدات", + "sectionChats": "محادثة", + "noChats": "لا توجد محادثات", + "newChatAction": "محادثة جديدة" }, "conversation": { "reloadFailed": "فشل إعادة تحميل المحادثة: {message}", @@ -1822,6 +1825,8 @@ "autoConnectFallback": "انقر لفتح {path} وإدارة التثبيت.", "autoConnectAppend": "{message}. انقر لفتح {path} وإدارة التثبيت.", "enableAgentFirstPlaceholder": "فعّل وكيلًا واحدًا على الأقل قبل بدء جلسة...", + "prepareSessionFailed": "تعذّر تحضير جلسة الدردشة. يرجى المحاولة مرة أخرى.", + "createConversationFailed": "تعذّر إنشاء المحادثة. يرجى المحاولة مرة أخرى.", "askAnythingPlaceholder": "اسأل أي شيء..." }, "welcomePanel": { @@ -2128,9 +2133,8 @@ }, "messageNav": { "title": "تنقل الرسائل", - "expand": "توسيع تنقل الرسائل", "collapse": "طي تنقل الرسائل", - "jumpToMessage": "الانتقال إلى: {label}", + "collapsedSummary": "الرسائل {count}", "fileCount": "{count, plural, one {# ملف} other {# ملفات}}", "remove": "إزالة", "noDiffDataAvailable": "لا توجد بيانات diff متاحة لـ {filePath}" @@ -2192,12 +2196,14 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", + "chatModeLabel": "وضع بدون مجلد", "commit": "Commit", "push": "Push", "merge": "Merge", "toasts": { "folderChanged": "Switched to {name}", "openFolderFailed": "Failed to open folder", + "switchedToChatMode": "تم التبديل إلى وضع المحادثة", "openStashFailed": "Failed to open stash window", "openMergeFailed": "Failed to open merge window" } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index bfadd2669..9afff4bef 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1001,7 +1001,10 @@ "toastOpFailed": "Aktion fehlgeschlagen: {message}" }, "sectionPinned": "Angeheftet", - "sectionFolders": "Ordner" + "sectionFolders": "Ordner", + "sectionChats": "Chat", + "noChats": "Keine Chats", + "newChatAction": "Neuer Chat" }, "conversation": { "reloadFailed": "Konversation konnte nicht neu geladen werden: {message}", @@ -1822,6 +1825,8 @@ "autoConnectFallback": "Klicken Sie, um {path} zu öffnen und die Installation zu verwalten.", "autoConnectAppend": "{message}. Klicken Sie, um {path} zu öffnen und die Installation zu verwalten.", "enableAgentFirstPlaceholder": "Aktivieren Sie mindestens einen Agenten, bevor Sie eine Sitzung starten...", + "prepareSessionFailed": "Die Chat-Sitzung konnte nicht vorbereitet werden. Bitte versuche es erneut.", + "createConversationFailed": "Die Konversation konnte nicht erstellt werden. Bitte versuche es erneut.", "askAnythingPlaceholder": "Fragen Sie alles..." }, "welcomePanel": { @@ -2128,9 +2133,8 @@ }, "messageNav": { "title": "Nachrichtennavigation", - "expand": "Nachrichtennavigation einblenden", "collapse": "Nachrichtennavigation ausblenden", - "jumpToMessage": "Springen zu: {label}", + "collapsedSummary": "Nachrichten {count}", "fileCount": "{count, plural, one {# Datei} other {# Dateien}}", "remove": "Entfernen", "noDiffDataAvailable": "Keine Diff-Daten verfügbar für {filePath}" @@ -2192,12 +2196,14 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", + "chatModeLabel": "Ohne-Ordner-Modus", "commit": "Commit", "push": "Push", "merge": "Merge", "toasts": { "folderChanged": "Switched to {name}", "openFolderFailed": "Failed to open folder", + "switchedToChatMode": "In den Chat-Modus gewechselt", "openStashFailed": "Failed to open stash window", "openMergeFailed": "Failed to open merge window" } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 80bf1a1e9..3c7e6e43d 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1001,7 +1001,10 @@ "toastOpFailed": "Operation failed: {message}" }, "sectionPinned": "Pinned", - "sectionFolders": "Folders" + "sectionFolders": "Folders", + "sectionChats": "Chat", + "noChats": "No chats", + "newChatAction": "New chat" }, "conversation": { "reloadFailed": "Failed to reload conversation: {message}", @@ -1822,6 +1825,8 @@ "autoConnectFallback": "Click to open {path} and manage installation.", "autoConnectAppend": "{message}. Click to open {path} and manage installation.", "enableAgentFirstPlaceholder": "Enable at least one agent before starting a session...", + "prepareSessionFailed": "Couldn't prepare the chat session. Please try again.", + "createConversationFailed": "Couldn't create the conversation. Please try again.", "askAnythingPlaceholder": "Ask anything..." }, "welcomePanel": { @@ -2128,9 +2133,8 @@ }, "messageNav": { "title": "Message navigation", - "expand": "Expand message navigation", "collapse": "Collapse message navigation", - "jumpToMessage": "Jump to: {label}", + "collapsedSummary": "Messages {count}", "fileCount": "{count, plural, one {# file} other {# files}}", "remove": "Remove", "noDiffDataAvailable": "No diff data available for {filePath}" @@ -2192,12 +2196,14 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", + "chatModeLabel": "No-folder mode", "commit": "Commit", "push": "Push", "merge": "Merge", "toasts": { "folderChanged": "Switched to {name}", "openFolderFailed": "Failed to open folder", + "switchedToChatMode": "Switched to chat mode", "openStashFailed": "Failed to open stash window", "openMergeFailed": "Failed to open merge window" } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 2b40fde85..ee5c2ffc2 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1001,7 +1001,10 @@ "toastOpFailed": "Error en la operación: {message}" }, "sectionPinned": "Fijadas", - "sectionFolders": "Carpetas" + "sectionFolders": "Carpetas", + "sectionChats": "Chat", + "noChats": "Sin chats", + "newChatAction": "Nuevo chat" }, "conversation": { "reloadFailed": "No se pudo recargar la conversación: {message}", @@ -1822,6 +1825,8 @@ "autoConnectFallback": "Haz clic para abrir {path} y gestionar la instalación.", "autoConnectAppend": "{message}. Haz clic para abrir {path} y gestionar la instalación.", "enableAgentFirstPlaceholder": "Habilita al menos un agente antes de iniciar una sesión...", + "prepareSessionFailed": "No se pudo preparar la sesión de chat. Inténtalo de nuevo.", + "createConversationFailed": "No se pudo crear la conversación. Inténtalo de nuevo.", "askAnythingPlaceholder": "Pregunta lo que sea..." }, "welcomePanel": { @@ -2128,9 +2133,8 @@ }, "messageNav": { "title": "Navegación de mensajes", - "expand": "Expandir navegación de mensajes", "collapse": "Contraer navegación de mensajes", - "jumpToMessage": "Ir a: {label}", + "collapsedSummary": "Mensajes {count}", "fileCount": "{count, plural, one {# archivo} other {# archivos}}", "remove": "Quitar", "noDiffDataAvailable": "No hay datos de diff disponibles para {filePath}" @@ -2192,12 +2196,14 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", + "chatModeLabel": "Modo sin carpeta", "commit": "Commit", "push": "Push", "merge": "Merge", "toasts": { "folderChanged": "Switched to {name}", "openFolderFailed": "Failed to open folder", + "switchedToChatMode": "Cambiado al modo de chat", "openStashFailed": "Failed to open stash window", "openMergeFailed": "Failed to open merge window" } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8dfd0d4d4..144ff6fd9 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1001,7 +1001,10 @@ "toastOpFailed": "Échec de l'opération : {message}" }, "sectionPinned": "Épinglées", - "sectionFolders": "Dossiers" + "sectionFolders": "Dossiers", + "sectionChats": "Discussion", + "noChats": "Aucune discussion", + "newChatAction": "Nouvelle discussion" }, "conversation": { "reloadFailed": "Échec du rechargement de la conversation : {message}", @@ -1822,6 +1825,8 @@ "autoConnectFallback": "Cliquez pour ouvrir {path} et gérer l'installation.", "autoConnectAppend": "{message}. Cliquez pour ouvrir {path} et gérer l'installation.", "enableAgentFirstPlaceholder": "Activez au moins un agent avant de démarrer une session...", + "prepareSessionFailed": "Impossible de préparer la session de chat. Veuillez réessayer.", + "createConversationFailed": "Impossible de créer la conversation. Veuillez réessayer.", "askAnythingPlaceholder": "Posez n'importe quelle question..." }, "welcomePanel": { @@ -2128,9 +2133,8 @@ }, "messageNav": { "title": "Navigation des messages", - "expand": "Développer la navigation des messages", "collapse": "Réduire la navigation des messages", - "jumpToMessage": "Aller à : {label}", + "collapsedSummary": "Messages {count}", "fileCount": "{count, plural, one {# fichier} other {# fichiers}}", "remove": "Retirer", "noDiffDataAvailable": "Aucune donnée de diff disponible pour {filePath}" @@ -2192,12 +2196,14 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", + "chatModeLabel": "Mode sans dossier", "commit": "Commit", "push": "Push", "merge": "Merge", "toasts": { "folderChanged": "Switched to {name}", "openFolderFailed": "Failed to open folder", + "switchedToChatMode": "Basculé en mode discussion", "openStashFailed": "Failed to open stash window", "openMergeFailed": "Failed to open merge window" } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e31156ed4..e61294c3c 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1001,7 +1001,10 @@ "toastOpFailed": "操作に失敗しました: {message}" }, "sectionPinned": "ピン留め", - "sectionFolders": "フォルダ" + "sectionFolders": "フォルダ", + "sectionChats": "チャット", + "noChats": "チャットがありません", + "newChatAction": "新しいチャット" }, "conversation": { "reloadFailed": "会話の再読み込みに失敗しました: {message}", @@ -1822,6 +1825,8 @@ "autoConnectFallback": "{path} を開いてインストールを管理してください。", "autoConnectAppend": "{message}。{path} を開いてインストールを管理してください。", "enableAgentFirstPlaceholder": "セッション開始前に少なくとも1つのエージェントを有効化してください...", + "prepareSessionFailed": "チャットセッションを準備できませんでした。もう一度お試しください。", + "createConversationFailed": "会話を作成できませんでした。もう一度お試しください。", "askAnythingPlaceholder": "何でも質問してください..." }, "welcomePanel": { @@ -2128,9 +2133,8 @@ }, "messageNav": { "title": "メッセージナビ", - "expand": "メッセージナビを開く", "collapse": "メッセージナビを閉じる", - "jumpToMessage": "移動: {label}", + "collapsedSummary": "メッセージ {count}", "fileCount": "{count, plural, one {# 個のファイル} other {# 個のファイル}}", "remove": "削除", "noDiffDataAvailable": "{filePath} の差分データがありません" @@ -2192,12 +2196,14 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", + "chatModeLabel": "フォルダーなしモード", "commit": "Commit", "push": "Push", "merge": "Merge", "toasts": { "folderChanged": "Switched to {name}", "openFolderFailed": "Failed to open folder", + "switchedToChatMode": "チャットモードに切り替えました", "openStashFailed": "Failed to open stash window", "openMergeFailed": "Failed to open merge window" } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index f916eb077..38a659a97 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1001,7 +1001,10 @@ "toastOpFailed": "작업 실패: {message}" }, "sectionPinned": "고정됨", - "sectionFolders": "폴더" + "sectionFolders": "폴더", + "sectionChats": "채팅", + "noChats": "채팅 없음", + "newChatAction": "새 채팅" }, "conversation": { "reloadFailed": "대화 다시 불러오기 실패: {message}", @@ -1822,6 +1825,8 @@ "autoConnectFallback": "{path}을(를) 열어 설치를 관리하세요.", "autoConnectAppend": "{message}. {path}을(를) 열어 설치를 관리하세요.", "enableAgentFirstPlaceholder": "세션을 시작하기 전에 최소 한 개의 에이전트를 활성화하세요...", + "prepareSessionFailed": "채팅 세션을 준비하지 못했습니다. 다시 시도해 주세요.", + "createConversationFailed": "대화를 만들지 못했습니다. 다시 시도해 주세요.", "askAnythingPlaceholder": "무엇이든 물어보세요..." }, "welcomePanel": { @@ -2128,9 +2133,8 @@ }, "messageNav": { "title": "메시지 탐색", - "expand": "메시지 탐색 펼치기", "collapse": "메시지 탐색 접기", - "jumpToMessage": "이동: {label}", + "collapsedSummary": "메시지 {count}", "fileCount": "{count, plural, one {#개 파일} other {#개 파일}}", "remove": "제거", "noDiffDataAvailable": "{filePath}에 대한 diff 데이터가 없습니다" @@ -2192,12 +2196,14 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", + "chatModeLabel": "폴더 없음 모드", "commit": "Commit", "push": "Push", "merge": "Merge", "toasts": { "folderChanged": "Switched to {name}", "openFolderFailed": "Failed to open folder", + "switchedToChatMode": "채팅 모드로 전환했습니다", "openStashFailed": "Failed to open stash window", "openMergeFailed": "Failed to open merge window" } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c755fe003..c23adfc92 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1001,7 +1001,10 @@ "toastOpFailed": "Falha na operação: {message}" }, "sectionPinned": "Fixadas", - "sectionFolders": "Pastas" + "sectionFolders": "Pastas", + "sectionChats": "Chat", + "noChats": "Sem chats", + "newChatAction": "Novo chat" }, "conversation": { "reloadFailed": "Falha ao recarregar conversa: {message}", @@ -1822,6 +1825,8 @@ "autoConnectFallback": "Clique para abrir {path} e gerenciar a instalação.", "autoConnectAppend": "{message}. Clique para abrir {path} e gerenciar a instalação.", "enableAgentFirstPlaceholder": "Ative pelo menos um agente antes de iniciar uma sessão...", + "prepareSessionFailed": "Não foi possível preparar a sessão de chat. Tente novamente.", + "createConversationFailed": "Não foi possível criar a conversa. Tente novamente.", "askAnythingPlaceholder": "Pergunte qualquer coisa..." }, "welcomePanel": { @@ -2128,9 +2133,8 @@ }, "messageNav": { "title": "Navegação de mensagens", - "expand": "Expandir navegação de mensagens", "collapse": "Recolher navegação de mensagens", - "jumpToMessage": "Ir para: {label}", + "collapsedSummary": "Mensagens {count}", "fileCount": "{count, plural, one {# arquivo} other {# arquivos}}", "remove": "Remover", "noDiffDataAvailable": "Nenhum dado de diff disponível para {filePath}" @@ -2192,12 +2196,14 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", + "chatModeLabel": "Modo sem pasta", "commit": "Commit", "push": "Push", "merge": "Merge", "toasts": { "folderChanged": "Switched to {name}", "openFolderFailed": "Failed to open folder", + "switchedToChatMode": "Alternado para o modo de chat", "openStashFailed": "Failed to open stash window", "openMergeFailed": "Failed to open merge window" } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index bdf62c37f..2c891eea1 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1001,7 +1001,10 @@ "toastOpFailed": "操作失败:{message}" }, "sectionPinned": "已置顶", - "sectionFolders": "文件夹" + "sectionFolders": "文件夹", + "sectionChats": "聊天", + "noChats": "没有聊天", + "newChatAction": "新建聊天" }, "conversation": { "reloadFailed": "会话重新加载失败:{message}", @@ -1822,6 +1825,8 @@ "autoConnectFallback": "点击前往 {path} 管理安装。", "autoConnectAppend": "{message},点击前往 {path} 管理安装。", "enableAgentFirstPlaceholder": "请先启用至少一个 Agent 后开始会话...", + "prepareSessionFailed": "无法准备聊天会话,请重试。", + "createConversationFailed": "无法创建会话,请重试。", "askAnythingPlaceholder": "请开始输入..." }, "welcomePanel": { @@ -2128,9 +2133,8 @@ }, "messageNav": { "title": "消息导航", - "expand": "展开消息导航", "collapse": "收起消息导航", - "jumpToMessage": "跳转到:{label}", + "collapsedSummary": "消息 {count}", "fileCount": "{count} 个文件", "remove": "移除", "noDiffDataAvailable": "未找到 {filePath} 的差异数据" @@ -2192,12 +2196,14 @@ "noFolders": "暂无文件夹", "noBranches": "暂无分支", "noBranch": "(无分支)", + "chatModeLabel": "无文件夹模式", "commit": "提交", "push": "推送", "merge": "合并", "toasts": { "folderChanged": "已切换到 {name}", "openFolderFailed": "打开文件夹失败", + "switchedToChatMode": "已切换到聊天模式", "openStashFailed": "打开贮藏窗口失败", "openMergeFailed": "打开合并窗口失败" } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index fd00e31f4..92f6c1b17 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1001,7 +1001,10 @@ "toastOpFailed": "操作失敗:{message}" }, "sectionPinned": "已置頂", - "sectionFolders": "資料夾" + "sectionFolders": "資料夾", + "sectionChats": "聊天", + "noChats": "沒有聊天", + "newChatAction": "新增聊天" }, "conversation": { "reloadFailed": "會話重新載入失敗:{message}", @@ -1822,6 +1825,8 @@ "autoConnectFallback": "點擊前往 {path} 管理安裝。", "autoConnectAppend": "{message},點擊前往 {path} 管理安裝。", "enableAgentFirstPlaceholder": "請先啟用至少一個 Agent 後開始會話...", + "prepareSessionFailed": "無法準備聊天工作階段,請重試。", + "createConversationFailed": "無法建立會話,請重試。", "askAnythingPlaceholder": "請開始輸入..." }, "welcomePanel": { @@ -2128,9 +2133,8 @@ }, "messageNav": { "title": "訊息導覽", - "expand": "展開訊息導覽", "collapse": "收合訊息導覽", - "jumpToMessage": "跳至:{label}", + "collapsedSummary": "訊息 {count}", "fileCount": "{count} 個檔案", "remove": "移除", "noDiffDataAvailable": "找不到 {filePath} 的差異資料" @@ -2192,12 +2196,14 @@ "noFolders": "No folders", "noBranches": "No branches", "noBranch": "(no branch)", + "chatModeLabel": "無資料夾模式", "commit": "Commit", "push": "Push", "merge": "Merge", "toasts": { "folderChanged": "Switched to {name}", "openFolderFailed": "Failed to open folder", + "switchedToChatMode": "已切換到聊天模式", "openStashFailed": "Failed to open stash window", "openMergeFailed": "Failed to open merge window" } diff --git a/src/lib/api.ts b/src/lib/api.ts index da355f0d6..239a0bd37 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -37,6 +37,8 @@ import type { ExpertInstallStatus, FolderHistoryEntry, FolderDetail, + CreateChatConversationResult, + CreateChatDirResult, WorktreeResolution, DbConversationSummary, ImportResult, @@ -1511,6 +1513,35 @@ export async function createConversation( }) } +/** + * Create a folderless "chat mode" conversation. The backend lazily creates a + * dated per-conversation scratch dir and a dedicated hidden `is_chat` folder + * backing it, then the conversation. Returns the new conversation id plus that + * folder so the caller can seed `allFolders` (cwd / active-folder) immediately. + */ +export async function createChatConversation( + agentType: AgentType, + title?: string, + // Reuse a scratch dir already minted by `createChatDir` (eager connect) so the + // ACP cwd never moves across the first send; omit to let the backend mint one. + existingDir?: string +): Promise { + return getTransport().call("create_chat_conversation", { + agentType, + title: title ?? null, + existingDir: existingDir ?? null, + }) +} + +/** + * Eagerly create a chat-mode scratch directory (filesystem only — no DB rows) + * and return its path, so a chat draft can connect ACP at a real cwd the instant + * "no-folder mode" is selected, before any first prompt. + */ +export async function createChatDir(): Promise { + return getTransport().call("create_chat_dir", {}) +} + export async function updateConversationStatus( conversationId: number, status: string diff --git a/src/lib/branch-switch.test.ts b/src/lib/branch-switch.test.ts index f844f4c01..ac3ccf3f0 100644 --- a/src/lib/branch-switch.test.ts +++ b/src/lib/branch-switch.test.ts @@ -16,6 +16,7 @@ function mkFolder(p: Partial & { id: number }): FolderDetail { sort_order: p.id, color: "blue", parent_id: null, + is_chat: false, ...p, } } diff --git a/src/lib/folder-display.test.ts b/src/lib/folder-display.test.ts index 57d5d8415..368b87c8d 100644 --- a/src/lib/folder-display.test.ts +++ b/src/lib/folder-display.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" import { + excludeChatFolders, filterTopLevelFolders, resolveFolderDisplayName, resolvePickerSelectedFolderId, @@ -62,6 +63,25 @@ describe("filterTopLevelFolders", () => { }) }) +describe("excludeChatFolders", () => { + it("drops hidden chat folders, keeping real ones", () => { + const list = [ + { id: 1, is_chat: false }, + { id: 2, is_chat: true }, + { id: 3, is_chat: false }, + ] + expect(excludeChatFolders(list).map((f) => f.id)).toEqual([1, 3]) + }) + + it("returns all folders when none are chat folders", () => { + const list = [ + { id: 1, is_chat: false }, + { id: 2, is_chat: false }, + ] + expect(excludeChatFolders(list)).toHaveLength(2) + }) +}) + describe("resolvePickerSelectedFolderId", () => { it("returns the folder's own id for a top-level folder", () => { expect(resolvePickerSelectedFolderId({ id: 5, parent_id: null })).toBe(5) diff --git a/src/lib/folder-display.ts b/src/lib/folder-display.ts index a9baf86af..119368891 100644 --- a/src/lib/folder-display.ts +++ b/src/lib/folder-display.ts @@ -33,6 +33,19 @@ export function filterTopLevelFolders< return folders.filter((f) => f.parent_id == null) } +/** + * Drop hidden chat-mode folders (`is_chat`) from a folder list. These back + * folderless "chat mode" conversations and must never appear in user-facing + * folder surfaces (the sidebar "文件夹" group, the input-box folder picker). They + * stay in the full `allFolders` set so by-id lookups (cwd, active-folder, theme + * color) keep resolving — only list rendering excludes them. + */ +export function excludeChatFolders>( + folders: readonly T[] +): T[] { + return folders.filter((f) => !f.is_chat) +} + /** * The folder id the input-box picker highlights for a conversation's folder: * the parent repo for a worktree (since the worktree itself isn't listed), or diff --git a/src/lib/message-nav-active.test.ts b/src/lib/message-nav-active.test.ts deleted file mode 100644 index 987f21b67..000000000 --- a/src/lib/message-nav-active.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it, expect } from "vitest" -import { - pickActiveThreadIndex, - reconcileActive, - type ActiveClickGuard, -} from "./message-nav-active" - -// User-message ticks are sparse in the thread (assistant turns sit between -// them), so threadIndex jumps: 0, 3, 6. -const entries = [{ threadIndex: 0 }, { threadIndex: 3 }, { threadIndex: 6 }] - -describe("pickActiveThreadIndex", () => { - it("returns null when the top is above the first entry", () => { - expect(pickActiveThreadIndex(entries, -1)).toBeNull() - expect(pickActiveThreadIndex([], 5)).toBeNull() - }) - - it("picks the last entry at or above the viewport top", () => { - expect(pickActiveThreadIndex(entries, 0)).toBe(0) - expect(pickActiveThreadIndex(entries, 2)).toBe(0) - expect(pickActiveThreadIndex(entries, 3)).toBe(3) - expect(pickActiveThreadIndex(entries, 5)).toBe(3) - expect(pickActiveThreadIndex(entries, 6)).toBe(6) - expect(pickActiveThreadIndex(entries, 100)).toBe(6) - }) -}) - -describe("reconcileActive", () => { - it("passes the reading through when no guard is armed", () => { - expect(reconcileActive(3, null, 0)).toEqual({ active: 3, guard: null }) - expect(reconcileActive(null, null, 0)).toEqual({ - active: null, - guard: null, - }) - }) - - it("releases the guard as soon as the scroll arrives at the target", () => { - const guard: ActiveClickGuard = { target: 6, releaseAfter: 1000 } - expect(reconcileActive(6, guard, 200)).toEqual({ active: 6, guard: null }) - }) - - it("holds the clicked tick instead of regressing to the previous one", () => { - // The smooth scroll toward tick 6 momentarily reads tick 3 (the previous - // user message); within the guard window we keep showing 6, not 3. - const guard: ActiveClickGuard = { target: 6, releaseAfter: 1000 } - expect(reconcileActive(3, guard, 200)).toEqual({ active: 6, guard }) - }) - - it("safety-releases after the window so normal tracking resumes", () => { - const guard: ActiveClickGuard = { target: 6, releaseAfter: 1000 } - expect(reconcileActive(3, guard, 1500)).toEqual({ active: 3, guard: null }) - }) - - it("keeps a bottom-clamped click active, then resumes on later scroll", () => { - // Click the last message; align:"start" is clamped so the scroll-spy can - // never read 6 — it keeps reading the previous tick 3. - let guard: ActiveClickGuard | null = { target: 6, releaseAfter: 1000 } - - // During the clamped scroll: stays on the clicked tick, never regresses. - let step = reconcileActive(3, guard, 100) - expect(step.active).toBe(6) - guard = step.guard - step = reconcileActive(3, guard, 600) - expect(step.active).toBe(6) - guard = step.guard - - // After the safety window (a genuine later scroll): normal tracking resumes. - step = reconcileActive(3, guard, 1200) - expect(step.active).toBe(3) - expect(step.guard).toBeNull() - }) -}) diff --git a/src/lib/message-nav-active.ts b/src/lib/message-nav-active.ts deleted file mode 100644 index 3f5e491e9..000000000 --- a/src/lib/message-nav-active.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Active-tick resolution for the conversation message navigator. - * - * The navigator highlights the user-message tick nearest the top of the - * viewport (a scroll-spy). Two wrinkles are handled here as pure functions so - * they stay unit-testable away from the virtualizer: - * - * 1. A click optimistically activates the clicked tick. The smooth scroll that - * follows fires scroll-spy readings; we must not let them regress the - * highlight to the *previous* tick before the scroll arrives — and for a - * bottom-clamped target (a message near the end that can never reach the - * viewport top) the scroll never arrives at all. - */ - -/** Pending "I just clicked this tick" intent. */ -export interface ActiveClickGuard { - /** threadIndex the user clicked. */ - target: number - /** - * Monotonic time (ms, e.g. `performance.now()`) after which the guard is - * force-released. Safety net for bottom-clamped targets whose scroll never - * produces a reading equal to `target`. - */ - releaseAfter: number -} - -/** - * Last nav entry at or above the viewport top. `entries` must be ascending by - * `threadIndex`. Returns null when the top is above the first entry. - */ -export function pickActiveThreadIndex( - entries: readonly { threadIndex: number }[], - startIndex: number -): number | null { - let active: number | null = null - for (const entry of entries) { - if (entry.threadIndex <= startIndex) active = entry.threadIndex - else break - } - return active -} - -export interface ReconciledActive { - /** The active threadIndex to render. */ - active: number | null - /** Guard to carry into the next reading (null once released). */ - guard: ActiveClickGuard | null -} - -/** - * Reconcile a fresh scroll-spy reading (`computed`) with a pending click - * `guard`. While the guard holds we keep showing `guard.target` so the clicked - * tick never regresses to the previous one. The guard releases when the reading - * reaches the target (the scroll arrived) or once `now >= guard.releaseAfter` - * (the clamped-target safety net), after which normal scroll-spy resumes. - */ -export function reconcileActive( - computed: number | null, - guard: ActiveClickGuard | null, - now: number -): ReconciledActive { - if (!guard) return { active: computed, guard: null } - // Arrived at the clicked tick → resume normal tracking. - if (computed === guard.target) return { active: computed, guard: null } - // Still settling (incl. clamped targets) → hold the clicked tick. - if (now < guard.releaseAfter) return { active: guard.target, guard } - // Safety release → resume normal tracking. - return { active: computed, guard: null } -} diff --git a/src/lib/queue-flush.test.ts b/src/lib/queue-flush.test.ts index e7803ea7f..8624ba3fa 100644 --- a/src/lib/queue-flush.test.ts +++ b/src/lib/queue-flush.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect } from "vitest" import { flushRetryDelayMs, forkSendBlockedByQueue, + isConnectionReady, QUEUE_FLUSH_RETRY_BACKOFF_MS, shouldQueueDirectSend, + shouldRejectDuplicateCreate, } from "./queue-flush" describe("flushRetryDelayMs", () => { @@ -61,3 +63,47 @@ describe("forkSendBlockedByQueue", () => { expect(forkSendBlockedByQueue(0)).toBe(false) }) }) + +describe("isConnectionReady", () => { + const cwd = "/work/chat-sessions/2026-06-11/abc" + + it("is ready when connected AND the connection cwd matches the intended cwd", () => { + expect(isConnectionReady("connected", cwd, cwd)).toBe(true) + }) + + it("is NOT ready when connected but the connection cwd differs (stale reconnect window)", () => { + // The crux of the chat-draft fix: a stale "connected" for the PREVIOUS cwd + // must not be treated as ready, or a send would hit the wrong workspace. + expect(isConnectionReady("connected", "/old/folder", cwd)).toBe(false) + }) + + it("is NOT ready in any non-connected status, even if cwds match", () => { + expect(isConnectionReady("connecting", cwd, cwd)).toBe(false) + expect(isConnectionReady("disconnected", cwd, cwd)).toBe(false) + expect(isConnectionReady("prompting", cwd, cwd)).toBe(false) + expect(isConnectionReady(null, cwd, cwd)).toBe(false) + }) + + it("normalizes nullish cwds so null and undefined compare equal", () => { + expect(isConnectionReady("connected", null, undefined)).toBe(true) + expect(isConnectionReady("connected", undefined, null)).toBe(true) + // A real cwd vs. no cwd is still a mismatch. + expect(isConnectionReady("connected", cwd, null)).toBe(false) + expect(isConnectionReady("connected", null, cwd)).toBe(false) + }) +}) + +describe("shouldRejectDuplicateCreate", () => { + it("rejects a second submit while an unbound create is in flight", () => { + expect(shouldRejectDuplicateCreate(false, true)).toBe(true) + }) + + it("allows the first submit (no create pending yet)", () => { + expect(shouldRejectDuplicateCreate(false, false)).toBe(false) + }) + + it("never single-flights a persisted conversation (it allows concurrent queued sends)", () => { + expect(shouldRejectDuplicateCreate(true, true)).toBe(false) + expect(shouldRejectDuplicateCreate(true, false)).toBe(false) + }) +}) diff --git a/src/lib/queue-flush.ts b/src/lib/queue-flush.ts index 37ea50758..1b7978af5 100644 --- a/src/lib/queue-flush.ts +++ b/src/lib/queue-flush.ts @@ -58,3 +58,44 @@ export function shouldQueueDirectSend( export function forkSendBlockedByQueue(queueLength: number): boolean { return queueLength > 0 } + +/** + * Whether the live connection is ready to accept a send for THIS tab: connected + * AND its established cwd matches the tab's intended working dir. + * + * Bare `connStatus === "connected"` is insufficient. A chat draft that just + * retargeted into folderless mode (or any tab mid-reconnect) can read a stale + * "connected" belonging to the PREVIOUS cwd for a render or two before the + * reconnect lands. Sending then would deliver the prompt to the wrong + * agent/workspace. Both the direct send (handleSend) and the queue auto-flush + * gate on this. Nullish cwds are normalized so `null`/`undefined` compare equal + * (both mean "no cwd yet"). + */ +export function isConnectionReady( + connStatus: string | null | undefined, + connectedWorkingDir: string | null | undefined, + intendedWorkingDir: string | null | undefined +): boolean { + return ( + connStatus === "connected" && + (connectedWorkingDir ?? null) === (intendedWorkingDir ?? null) + ) +} + +/** + * Whether a direct submit must be rejected because an unbound new-tab create is + * already in flight (single-flight the create). + * + * A second submit fired before the first create resolves (double Enter / double + * click) would otherwise append an optimistic turn it can never deliver: the + * create-pending guard that actually stops the duplicate returns only AFTER the + * optimistic append. `hasPersistedId` is true once the tab is bound to a real + * conversation row, so only the unbound path is single-flighted — persisted + * conversations keep their legitimate concurrent queued-send behavior. + */ +export function shouldRejectDuplicateCreate( + hasPersistedId: boolean, + createPending: boolean +): boolean { + return !hasPersistedId && createPending +} diff --git a/src/lib/sidebar-view-mode-storage.ts b/src/lib/sidebar-view-mode-storage.ts index 4aa6efdaf..8055747d5 100644 --- a/src/lib/sidebar-view-mode-storage.ts +++ b/src/lib/sidebar-view-mode-storage.ts @@ -12,6 +12,7 @@ export type SidebarSortMode = "created" | "updated" export interface SidebarSectionCollapsed { pinned?: boolean folders?: boolean + chats?: boolean } export function loadFolderExpanded(): Record { @@ -94,6 +95,7 @@ export function loadSectionCollapsed(): SidebarSectionCollapsed { const result: SidebarSectionCollapsed = {} if (typeof obj.pinned === "boolean") result.pinned = obj.pinned if (typeof obj.folders === "boolean") result.folders = obj.folders + if (typeof obj.chats === "boolean") result.chats = obj.chats return result } catch { return {} diff --git a/src/lib/types.ts b/src/lib/types.ts index 7d859c25f..8ae8ad2ad 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -250,6 +250,34 @@ export interface FolderDetail { * original root. Drives the sidebar merge and worktree-branch detection. */ parent_id: number | null + /** + * True for the dedicated hidden folder backing a single chat-mode (folderless) + * conversation. Kept in `allFolders` so cwd / active-folder resolve, but hidden + * from user-facing folder lists; its conversation routes to the sidebar "Chat" + * group and folder-bound chrome is hidden while it is active. + */ + is_chat: boolean +} + +/** + * Result of `createChatConversation`: the new conversation id plus the hidden + * chat folder backing it, so the caller can drop the folder straight into + * `allFolders` (resolving cwd / active-folder) without a refetch. + */ +export interface CreateChatConversationResult { + conversationId: number + folderId: number + folder: FolderDetail +} + +/** + * Result of `createChatDir`: a freshly created chat-mode scratch directory + * (filesystem only — no DB rows). Used to connect ACP at a real cwd the instant + * "no-folder mode" is selected; the conversation is still created lazily on the + * first send, reusing this path. + */ +export interface CreateChatDirResult { + path: string } export interface OpenedTab { diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 000000000..22d41a1b8 --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,42 @@ +import type { MouseEvent } from "react" +import { describe, expect, it, vi } from "vitest" + +import { handleMiddleClickClose } from "./utils" + +function mouseEventWithButton(button: number) { + const preventDefault = vi.fn() + const event = { button, preventDefault } as unknown as MouseEvent + return { event, preventDefault } +} + +describe("handleMiddleClickClose", () => { + it("closes and prevents default on middle-click (button 1)", () => { + const onClose = vi.fn() + const { event, preventDefault } = mouseEventWithButton(1) + + handleMiddleClickClose(event, onClose) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(preventDefault).toHaveBeenCalledTimes(1) + }) + + it("ignores left-click (button 0)", () => { + const onClose = vi.fn() + const { event, preventDefault } = mouseEventWithButton(0) + + handleMiddleClickClose(event, onClose) + + expect(onClose).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it("ignores right-click (button 2) so the context menu still opens", () => { + const onClose = vi.fn() + const { event, preventDefault } = mouseEventWithButton(2) + + handleMiddleClickClose(event, onClose) + + expect(onClose).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2187866e3..4a42e2b03 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,4 @@ +import type { MouseEvent } from "react" import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" @@ -5,6 +6,23 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } +/** + * Close a tab when it is middle-clicked, matching the browser/editor + * convention. Wire this to a tab element's `onMouseDown`. Only the middle + * button (`event.button === 1`) acts; left-click select and right-click context + * menus fall through untouched. `preventDefault()` suppresses the default + * middle-button behaviour (autoscroll on Windows, primary-selection paste on + * Linux/X11). + */ +export function handleMiddleClickClose( + event: MouseEvent, + onClose: () => void +): void { + if (event.button !== 1) return + event.preventDefault() + onClose() +} + /** * Legacy clipboard copy: a hidden `