From ee943caf2708353858d453f03b631f16c05a12d5 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 11 Jun 2026 23:12:05 +0800 Subject: [PATCH 1/8] feat(conversations): add folderless chat mode Add a quick "chat mode" for conversations that need no project folder. Each chat conversation is backed by a hidden is_chat folder pointing at its own dated scratch dir (chat-sessions///), created lazily on first send so merely selecting the mode writes no DB rows. The scratch dir is prepared eagerly on selection so the agent connects before the first prompt, and the first send runs the same inline create-bind-send path as a normal conversation. - Backend: is_chat folder column (+migration), create_chat_conversation and create_chat_dir endpoints for both runtimes, folder lists exclude is_chat folders, and deleting the last conversation retires the hidden folder. - Sidebar: an always-present "Chat" section with an empty-state hint and a hover-revealed New-chat action. - Composer: a "no-folder mode" entry; branch pickers and the aux panel are hidden while a chat conversation is active. - Sends gate on a cwd-matched live connection, single-flight the first create, and fully restore the composer state if the create fails. - Localized across all 10 locales. --- src-tauri/src/commands/conversations.rs | 399 ++++++++++++++++++ src-tauri/src/db/entities/folder.rs | 4 + .../m20260611_000001_folder_is_chat.rs | 40 ++ src-tauri/src/db/migration/mod.rs | 2 + src-tauri/src/db/service/folder_service.rs | 49 +++ src-tauri/src/lib.rs | 2 + src-tauri/src/models/folder.rs | 4 + src-tauri/src/web/handlers/conversations.rs | 48 +++ src-tauri/src/web/router.rs | 8 + src/app/workspace/layout.tsx | 19 + src/components/chat/chat-input.tsx | 15 +- .../chat/conversation-context-bar.tsx | 91 +++- .../conversation-detail-panel-layout.test.ts | 98 +++++ .../conversation-detail-panel.tsx | 265 ++++++++++-- .../sidebar-conversation-grouping.test.ts | 165 +++++++- .../sidebar-conversation-grouping.ts | 84 +++- .../sidebar-conversation-list.tsx | 102 ++++- .../conversations/sidebar-section-header.tsx | 57 ++- src/components/layout/branch-dropdown.tsx | 6 +- src/components/layout/folder-title-bar.tsx | 58 ++- src/contexts/aux-panel-context.tsx | 6 + src/contexts/tab-context.test.tsx | 105 +++++ src/contexts/tab-context.tsx | 237 ++++++++++- src/hooks/use-connection.ts | 7 + src/hooks/use-is-active-chat-mode.ts | 20 + src/i18n/messages/ar.json | 9 +- src/i18n/messages/de.json | 9 +- src/i18n/messages/en.json | 9 +- src/i18n/messages/es.json | 9 +- src/i18n/messages/fr.json | 9 +- src/i18n/messages/ja.json | 9 +- src/i18n/messages/ko.json | 9 +- src/i18n/messages/pt.json | 9 +- src/i18n/messages/zh-CN.json | 9 +- src/i18n/messages/zh-TW.json | 9 +- src/lib/api.ts | 31 ++ src/lib/branch-switch.test.ts | 1 + src/lib/folder-display.test.ts | 20 + src/lib/folder-display.ts | 13 + src/lib/queue-flush.test.ts | 46 ++ src/lib/queue-flush.ts | 41 ++ src/lib/sidebar-view-mode-storage.ts | 2 + src/lib/types.ts | 28 ++ 43 files changed, 2040 insertions(+), 123 deletions(-) create mode 100644 src-tauri/src/db/migration/m20260611_000001_folder_is_chat.rs create mode 100644 src/hooks/use-is-active-chat-mode.ts diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 51e2d529d..e199d05e8 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,46 @@ 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}") + } + } +} + #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn delete_conversation( @@ -1063,10 +1244,19 @@ pub async fn delete_conversation( db: tauri::State<'_, AppDatabase>, 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(&db.conn, conversation_id) + .await + .ok() + .map(|c| c.folder_id); 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; + if let Some(folder_id) = folder_id { + cleanup_chat_folder_for_deleted_conversation(&db.conn, folder_id).await; + } Ok(()) } @@ -1683,6 +1873,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..b4528521a 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,6 +314,13 @@ pub async fn delete_conversation( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + // Capture the backing folder before the soft-delete so a hidden chat folder + // can be cleaned up afterward. + let folder_id = + crate::db::service::conversation_service::get_by_id(&state.db.conn, params.conversation_id) + .await + .ok() + .map(|c| c.folder_id); 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( @@ -285,5 +329,9 @@ pub async fn delete_conversation( params.conversation_id, ) .await; + if let Some(folder_id) = folder_id { + conv_commands::cleanup_chat_folder_for_deleted_conversation(&state.db.conn, folder_id) + .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/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/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/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/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..1f9d5efbd 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -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} { ({ 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..21a020bc7 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -87,6 +87,7 @@ import { pointerYToTargetIndex, reuseSelected, reuseSet, + selectChatConversationsWithReuse, selectPinnedWithReuse, type SidebarRow, } from "./sidebar-conversation-grouping" @@ -578,6 +579,7 @@ export function SidebarConversationList({ closeConversationTab, closeTabsByFolder, openNewConversationTab, + openChatModeTab, activeTabId, tabs, } = useTabContext() @@ -645,6 +647,7 @@ export function SidebarConversationList({ useState({}) const pinnedExpanded = !sectionCollapsed.pinned const foldersExpanded = !sectionCollapsed.folders + const chatsExpanded = !sectionCollapsed.chats const [removeConfirm, setRemoveConfirm] = useState<{ folderId: number folderName: string @@ -698,13 +701,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 +788,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 +880,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 +892,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 +909,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 +934,8 @@ export function SidebarConversationList({ folderExpanded, folderTotalCounts, foldersExpanded, + chatConversations, + chatsExpanded, }), [ pinned, @@ -909,6 +945,8 @@ export function SidebarConversationList({ folderExpanded, folderTotalCounts, foldersExpanded, + chatConversations, + chatsExpanded, ] ) @@ -978,6 +1016,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 +1080,8 @@ export function SidebarConversationList({ childToParent, pinnedExpanded, foldersExpanded, + chatFolderIds, + chatsExpanded, ]) const toggleFolder = useCallback((folderId: number) => { @@ -1646,6 +1698,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 +1737,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 +1778,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}` } 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/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. */}