From c9fad4dd73be350aa03a28378f15fea13d5eb615 Mon Sep 17 00:00:00 2001 From: shohihul Date: Mon, 5 Oct 2026 23:40:19 +0700 Subject: [PATCH 1/5] Add projectless chats --- CHANGELOG.md | 4 + src-tauri/src/lib.rs | 14 ++ src-tauri/src/menu.rs | 21 ++- src-tauri/src/session_store.rs | 34 ++++- src/app/App.tsx | 138 ++++++++++++++++-- src/app/shell/MenuBar.tsx | 12 ++ src/app/shell/ProjectRail.tsx | 14 ++ src/app/shell/Sidebar.tsx | 103 ++++++++++--- src/app/shell/SidebarChats.test.ts | 128 ++++++++++++++++ src/app/shell/TitleBar.tsx | 8 +- .../sessions/data/sessionStore.test.ts | 7 + src/features/sessions/data/sessionStore.ts | 9 +- src/features/sessions/model/chatMove.test.ts | 49 +++++++ src/features/sessions/model/chatMove.ts | 28 ++++ .../sessions/model/chatSession.test.ts | 39 +++++ src/features/sessions/model/chatSession.ts | 27 ++++ src/features/sessions/model/inFlight.test.ts | 7 + src/features/sessions/model/inFlight.ts | 1 - src/features/sessions/ui/SessionPane.tsx | 5 +- .../settings/model/appShortcuts.test.ts | 9 ++ src/features/settings/model/appShortcuts.ts | 1 + src/features/settings/model/settings.ts | 1 + .../harness/core/registry.test.ts | 40 +++++ src/integrations/harness/core/registry.ts | 19 ++- 24 files changed, 664 insertions(+), 54 deletions(-) create mode 100644 src/app/shell/SidebarChats.test.ts create mode 100644 src/features/sessions/model/chatMove.test.ts create mode 100644 src/features/sessions/model/chatMove.ts create mode 100644 src/features/sessions/model/chatSession.test.ts create mode 100644 src/features/sessions/model/chatSession.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc4cae062..cb67636050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Chats: start a session without a project from **Chats** in the project rail or **File → New Chat** (`⌘N` / `Ctrl+N`). Chats are saved and listed in their own sidebar section, where they can be renamed, pinned, archived, searched and deleted like project sessions, and they run in a folder MonoCode keeps for them instead of the home directory. A chat can be moved into a project later from the project picker in its composer: the conversation is carried over as a brief on the next turn, and files the agent wrote in the chats folder stay there. In #757. + ## [0.7.1] - 2026-10-05 ### Added diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7dfe38038e..bb142a1708 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -69,6 +69,19 @@ fn home_dir() -> String { .unwrap_or_else(|| "~".into()) } +/// Folder projectless chats run in, so an agent with no project is not turned +/// loose on the whole home directory. +#[tauri::command] +fn chat_workspace_dir(app: tauri::AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("chats"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(fs::path_to_js(&dir)) +} + pub(crate) struct PasswdIdentity { pub home: String, pub user: String, @@ -280,6 +293,7 @@ pub fn run() { control::app_cli_path, default_cwd, home_dir, + chat_workspace_dir, notifications::notification_permission, notifications::request_notification_permission, notifications::show_notification, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index a0aaf3547f..811610d851 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -145,12 +145,12 @@ pub fn dispatch(app: &AppHandle, id: &str) { let _ = app.emit("toggle_autosave", enabled); } "quit" => crate::window::request_quit(app), - "new_tab" | "close_tab" | "close_other_tabs" | "next_tab" | "prev_tab" | "back_tab" - | "forward_tab" | "split_right" | "split_down" | "focus_left" | "focus_right" - | "focus_up" | "focus_down" | "sidebar_opacity" | "open_project" | "go_to_file" - | "open_search" | "open_inbox" | "open_notes" | "find_in_project" | "find" - | "new_terminal" | "new_terminal_tab" | "toggle_terminal" | "open_model_picker" - | "open_settings" | "check_for_updates" => { + "new_tab" | "new_chat" | "close_tab" | "close_other_tabs" | "next_tab" | "prev_tab" + | "back_tab" | "forward_tab" | "split_right" | "split_down" | "focus_left" + | "focus_right" | "focus_up" | "focus_down" | "sidebar_opacity" | "open_project" + | "go_to_file" | "open_search" | "open_inbox" | "open_notes" | "find_in_project" + | "find" | "new_terminal" | "new_terminal_tab" | "toggle_terminal" + | "open_model_picker" | "open_settings" | "check_for_updates" => { let _ = app.emit(id, ()); } // Sidebar, Zoom, Reload, Command Palette, and Close All Tabs target one window: a broadcast would @@ -254,6 +254,14 @@ fn build( "Tab: New", overrides, )?; + let new_chat = menu_item( + app, + "new_chat", + "New Chat", + "CmdOrCtrl+N", + "Chat: New", + overrides, + )?; let new_terminal = menu_item( app, "new_terminal", @@ -461,6 +469,7 @@ fn build( .item(&autosave) .separator() .item(&new_tab) + .item(&new_chat) .item(&new_terminal) .item(&new_terminal_tab) .item(&split_right) diff --git a/src-tauri/src/session_store.rs b/src-tauri/src/session_store.rs index 5e78881279..650a285843 100644 --- a/src-tauri/src/session_store.rs +++ b/src-tauri/src/session_store.rs @@ -1102,13 +1102,13 @@ fn upsert_session(conn: &Connection, session: &SessionUpsert) -> rusqlite::Resul .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()); - let git = crate::fs::git_info_for(&crate::fs::expand_home( + let git = git_info_for_cwd( session .worktree_cwd .as_deref() .filter(|cwd| !cwd.is_empty()) .unwrap_or(&session.cwd), - )); + ); let branch = if session.worktree_removed { None } else { @@ -1561,8 +1561,17 @@ fn ceil_char_boundary(text: &str, mut index: usize) -> usize { index } +/// Projectless chats are stored under `~`. They have no repository, and asking +/// git about the home directory would only walk it for nothing. +fn git_info_for_cwd(cwd: &str) -> crate::fs::GitInfo { + if cwd == "~" { + return crate::fs::GitInfo::default(); + } + crate::fs::git_info_for(&crate::fs::expand_home(cwd)) +} + fn list_by_project(conn: &Connection, cwd: &str) -> rusqlite::Result> { - let git = crate::fs::git_info_for(&crate::fs::expand_home(cwd)); + let git = git_info_for_cwd(cwd); let mut statement = conn.prepare( "SELECT id, cwd, harness, model, runtime_mode, title, provider_session_id, created_at, updated_at, branch, archived, pinned, @@ -2500,6 +2509,25 @@ mod tests { assert_eq!(listed[1].id, "s1"); } + #[test] + fn list_by_project_lists_projectless_chats() { + let store = SessionStore::open_in_memory().unwrap(); + let conn = store.conn.lock().unwrap(); + upsert_session(&conn, &sample("chat", "~", "Chat")).unwrap(); + upsert_session(&conn, &sample("s1", "/tmp/a", "A1")).unwrap(); + let chats = list_by_project(&conn, "~").unwrap(); + assert_eq!(chats.len(), 1); + assert_eq!(chats[0].id, "chat"); + assert_eq!(chats[0].cwd, "~"); + assert!(chats[0].branch.is_none()); + assert!(chats[0].repo.is_none()); + + // Moving the chat into a project takes it out of the chats list. + upsert_session(&conn, &sample("chat", "/tmp/a", "Chat")).unwrap(); + assert!(list_by_project(&conn, "~").unwrap().is_empty()); + assert_eq!(list_by_project(&conn, "/tmp/a").unwrap().len(), 2); + } + #[test] fn delete_removes_session() { let store = SessionStore::open_in_memory().unwrap(); diff --git a/src/app/App.tsx b/src/app/App.tsx index f3819080a5..303e88c5e5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -290,6 +290,7 @@ import { wrapHandoffPrompt, } from "../features/sessions/model/handoff"; import { requestOutgoingHandoff } from "../features/sessions/model/handoffTurn"; +import { moveChatToProject } from "../features/sessions/model/chatMove"; import { applyBtwHarnessEvent, btwTurnHarness, @@ -644,6 +645,11 @@ import { reconcileProjectReturn, type ProjectReturnMemory, } from "../features/projects/model/projectReturn"; +import { + CHATS_CWD, + isChatSession, + setChatWorkspaceDir, +} from "../features/sessions/model/chatSession"; import { planProjectOpenRun, type ProjectOpenStep, @@ -1622,8 +1628,8 @@ function Workspace({ tab: tab === "inbox" ? "sessions" : tab, }); }, []); - const sidebarCwdKey = - sidebarCwd && sidebarCwd !== "~" ? normalizeProjectPath(sidebarCwd) : null; + // Projectless chats are listed under `~`, like one more project. + const sidebarCwdKey = sidebarCwd ? normalizeProjectPath(sidebarCwd) : null; const historyFailed = sidebarCwdKey != null && historyErrorCwd === sidebarCwdKey; // True from the very first frame that shows a project we have never listed, @@ -1926,7 +1932,7 @@ function Workspace({ }, [flushHarnessEvents, keepWorkspaceTab, readProjectReturnMemory]); const refreshHistory = useCallback(async (cwd: string) => { - if (!cwd || cwd === "~") return; + if (!cwd) return; // `history` holds every visited project's rows and the sidebar filters it // by cwd, so a project loaded once paints from cache on the way back and // revalidates quietly underneath the cards already on screen. Whether the @@ -2130,13 +2136,23 @@ function Workspace({ if (!looksLikeProject(cwd)) return; setProjectCwd(cwd); setRecents((prev) => (prev.length > 0 ? prev : rememberProject(cwd))); + // Only the untouched boot tab adopts the folder. A restored chat with + // a conversation stays projectless. setSessions((prev) => - prev.map((s) => (s.cwd === "~" ? { ...s, cwd } : s)), + prev.map((s) => + isChatSession(s) && isBlankSession(s) ? { ...s, cwd } : s, + ), ); }) .catch(() => {}); }, []); + useEffect(() => { + void invoke("chat_workspace_dir") + .then(setChatWorkspaceDir) + .catch(() => {}); + }, []); + useEffect(() => { setTabs((prev) => { let changed = false; @@ -2358,6 +2374,71 @@ function Workspace({ projectCwd, ]); + /** Land on a projectless chat: an untouched one if any is open, else new. */ + const openChat = useCallback( + (preferOpen: boolean) => { + setSearchViewOpen(false); + setInboxViewOpen(false); + setNotesViewOpen(false); + setAutomationsViewOpen(false); + workspaceNavigation.cancel(); + setProjectCwd(CHATS_CWD); + const sessions = sessionsRef.current; + const chatIn = (tab: WorkspaceTab, blankOnly: boolean) => + leafIds(tab.layout).find((id) => { + const session = sessions.find((entry) => entry.id === id); + return ( + !!session && + isChatSession(session) && + !session.inboxAsk && + (!blankOnly || isBlankSession(session)) + ); + }); + const tabs = tabsRef.current; + const current = tabs.find((tab) => tab.id === activeTabIdRef.current); + // The rail returns to the chat already on screen; New Chat wants a + // fresh composer. + const candidates: [WorkspaceTab, boolean][] = [ + ...(preferOpen && current + ? ([[current, false]] as [WorkspaceTab, boolean][]) + : []), + ...tabs.map((tab): [WorkspaceTab, boolean] => [tab, true]), + ...(preferOpen + ? tabs.map((tab): [WorkspaceTab, boolean] => [tab, false]) + : []), + ]; + for (const [tab, blankOnly] of candidates) { + const paneId = chatIn(tab, blankOnly); + if (!paneId) continue; + activateTab(tab.id, paneId); + setComposerFocused(true); + return paneId; + } + const session = newDefaultSession( + CHATS_CWD, + sessionDefaults?.runtimeMode, + ); + const tab = newTab(session.id); + setSessions((prev) => [...prev, session]); + appendTab(tab, CHATS_CWD); + setActiveTabId(tab.id); + setComposerFocused(true); + return session.id; + }, + [ + activateTab, + appendTab, + sessionDefaults?.runtimeMode, + workspaceNavigation.cancel, + ], + ); + const onOpenChats = useCallback(() => { + openChat(true); + }, [openChat]); + const onNewChat = useCallback(() => { + openChat(false); + }, [openChat]); + const onSelectRemoteSession = useCallback( (project: string, remoteSessionId: string) => { setSearchViewOpen(false); @@ -3538,10 +3619,11 @@ function Workspace({ return stats; }, [tabs, sessions, sidebarCwd, tabWorkspace, workspaceNavigation.revision]); const deckProjectTabs = useMemo(() => { - // A projectless session belongs to no project, so it stands on its own - // rather than trailing the last project's tabs. + // A projectless chat belongs to no project, so chats share the strip with + // each other rather than trailing the last project's tabs. const active = tabs.find((tab) => tab.id === activeTabId); - if (active && !workspaceTabCwd(active, sessions)) return [active]; + if (active && !workspaceTabCwd(active, sessions)) + return tabs.filter((tab) => !workspaceTabCwd(tab, sessions)); // Each worktree keeps its own tabs; the others stay open, just hidden. const worktree = projectWorktree?.path ?? projectCwd; return filterTabsForProject(tabs, sessions, projectCwd).filter((tab) => { @@ -5103,11 +5185,40 @@ function Workspace({ ) { void keepSessionChanges(sessionId, previous).catch(() => undefined); } + // A chat with a conversation is promoted into the project. Its provider + // thread belongs to the chats folder, so it cannot move mid-turn, and + // the child is dropped so the next turn starts in the project. + const promoted = + current && + isChatSession(current) && + !isBlankSession(current) && + looksLikeProject(normalized); + let moved: Session | undefined; + if (promoted) { + if (current.busy) return; + for (const id of sessionChildHarnesses(current)) { + void forgetHarnessSession(id, sessionId); + } + const next = moveChatToProject(current, normalized); + moved = next; + sessionsRef.current = sessionsRef.current.map((s) => + s.id === sessionId ? next : s, + ); + persistSession(next); + setHistory((history) => + history.map((entry) => + entry.id === sessionId + ? { ...entry, cwd: normalized, providerSessionId: undefined } + : entry, + ), + ); + } setProjectCwd(normalized); setRecents(rememberProject(normalized)); setSessions((prev) => prev.map((s) => { if (s.id !== sessionId) return s; + if (moved) return moved; // A blank session moving into a project adopts its provider defaults; // a conversation keeps its own provider. const base = isBlankSession(s) @@ -5144,7 +5255,7 @@ function Workspace({ }); notifyReviewChanged(sessionId); }, - [appendTab, projectOfTab], + [appendTab, persistSession, projectOfTab], ); const onBranchChange = useCallback( @@ -10222,6 +10333,7 @@ function Workspace({ const actions = useRef({ onNew, + onNewChat, onArchiveFocusedSession, onCloseOtherTabs, onCloseAllTabs, @@ -10253,6 +10365,7 @@ function Workspace({ }); actions.current = { onNew, + onNewChat, onArchiveFocusedSession, onCloseOtherTabs, onCloseAllTabs, @@ -10458,8 +10571,9 @@ function Workspace({ } const shortcut = resolveAppShortcut(e); if (shortcut) { + // Ctrl+K and Ctrl+N are readline keys; a terminal keeps them. if ( - shortcut === "App: Search" && + (shortcut === "App: Search" || shortcut === "Chat: New") && e.target instanceof Element && e.target.closest(".monocode-terminal") && e.ctrlKey && @@ -10470,7 +10584,8 @@ function Workspace({ e.preventDefault(); e.stopPropagation(); const a = actions.current; - if (shortcut === "App: New Window") + if (shortcut === "Chat: New") run("new_chat", a.onNewChat); + else if (shortcut === "App: New Window") run("new_window", () => void invoke("open_new_window")); else if (shortcut === "App: Open Project") run("open_project", () => void a.pickProject()); @@ -10498,6 +10613,7 @@ function Workspace({ useEffect(() => { const unlisten: Array void>> = [ listen("new_tab", () => run("new", actions.current.onNew)), + listen("new_chat", () => run("new_chat", actions.current.onNewChat)), listen("close_other_tabs", () => run("close-others", actions.current.onCloseOtherTabs), ), @@ -10858,6 +10974,7 @@ function Workspace({ onOpenInboxItem={onOpenLinkedWorkItem} onOpenNotes={notesEnabled ? onOpenNotes : undefined} onOpenAutomations={onOpenAutomations} + onOpenChats={onOpenChats} onGoToFile={onGoToFile} searchActive={searchViewOpen} inboxActive={inboxViewOpen} @@ -10912,6 +11029,7 @@ function Workspace({ {!IS_MAC ? ( void; + onNewChat?: () => void; onNewTerminal?: () => void; onToggleTerminal?: () => void; onGoToFile?: () => void; @@ -40,6 +41,7 @@ type Props = { export function MenuBar({ onNew, + onNewChat, onNewTerminal, onToggleTerminal, onGoToFile, @@ -141,6 +143,9 @@ export function MenuBar({ case "new_tab": onNew(); break; + case "new_chat": + onNewChat?.(); + break; case "new_terminal": onNewTerminal?.(); break; @@ -217,6 +222,7 @@ export function MenuBar({ onFindInProject, onGoToFile, onNew, + onNewChat, onNewTerminal, onToggleTerminal, onPickProject, @@ -242,6 +248,12 @@ export function MenuBar({ label: "New Tab", shortcut: shortcut("Tab: New", `${MOD}T`), }, + { + kind: "item", + id: "new_chat", + label: "New Chat", + shortcut: shortcut("Chat: New", `${MOD}N`), + }, { kind: "item", id: "new_terminal", diff --git a/src/app/shell/ProjectRail.tsx b/src/app/shell/ProjectRail.tsx index 118e6d68ab..6f6162274c 100644 --- a/src/app/shell/ProjectRail.tsx +++ b/src/app/shell/ProjectRail.tsx @@ -5,6 +5,7 @@ import { FolderPlus, Internet, Inbox, + MessageSquare, MoreHorizontal, Pin, PinOff, @@ -109,6 +110,8 @@ type Props = { notesActive?: boolean; onOpenAutomations?: () => void; automationsActive?: boolean; + onOpenChats?: () => void; + chatsActive?: boolean; onTogglePanel?: () => void; onSelectProject: (path: string) => void; onOpenProject: () => void; @@ -146,6 +149,8 @@ export function ProjectRail({ notesActive = false, onOpenAutomations, automationsActive = false, + onOpenChats, + chatsActive = false, onTogglePanel, onSelectProject, onOpenProject, @@ -401,6 +406,15 @@ export function ProjectRail({ active={automationsActive} ariaLabel="Automations" /> + {onOpenChats ? ( + + ) : null}
void; onOpenNotes?: () => void; onOpenAutomations?: () => void; + /** Show projectless chats: the open one if any, else a new one. */ + onOpenChats?: () => void; onGoToFile?: () => void; searchActive?: boolean; inboxActive?: boolean; @@ -395,6 +399,7 @@ function SidebarComponent({ onOpenInboxItem, onOpenNotes, onOpenAutomations, + onOpenChats, onGoToFile, searchActive = false, inboxActive = false, @@ -419,7 +424,9 @@ function SidebarComponent({ onDismissUpdate, }: Props) { const remoteProject = isRemoteProjectPath(cwd); - const tab: SidebarTabId = requestedTab; + // Projectless chats have no folder, so the sidebar is just their list. + const chatsMode = isChatCwd(cwd); + const tab: SidebarTabId = chatsMode ? "sessions" : requestedTab; const remote = useRemoteProjectSessions(cwd, remoteProject); const hostProject = remoteProject ? remoteProjectFor(cwd) : undefined; const remoteChange = async ( @@ -757,15 +764,21 @@ function SidebarComponent({ compactProjectRail && showProjectRail && !railVisible; const inProject = looksLikeProject(cwd); const showSidebarFooter = !projectRailOpen; - // A blank session has no project to browse, so the shell stands alone until - // one is picked — whether or not the rail is open. + // The sidebar lists a project's sessions or, with no project, the chats. const sidebarAvailable = !searchActive && !inboxActive && !notesActive && !automationsActive && !settingsOpen && - inProject; + (inProject || chatsMode); + const chatsActive = + chatsMode && + !searchActive && + !inboxActive && + !notesActive && + !automationsActive && + !settingsOpen; const sidebarVisible = open && sidebarAvailable; // With the sidebar collapsed beside the compact rail, its tab shortcuts // open the sidebar temporarily until the user clicks away. @@ -1137,8 +1150,13 @@ function SidebarComponent({ disabled: !onSetReminders, submenu: sessionReminderPresets(), }, - { kind: "sep" as const }, - { kind: "item" as const, id: "folder-new", label: "New folder" }, + // Folders are kept per project, and a chat has none. + ...(chatsMode + ? [] + : [ + { kind: "sep" as const }, + { kind: "item" as const, id: "folder-new", label: "New folder" }, + ]), ...(sessionFolders.length > 0 ? [{ kind: "sep" as const }] : []), ...sessionFolders.map((folder) => ({ kind: "item" as const, @@ -1632,11 +1650,15 @@ function SidebarComponent({ /> ) : ( - Workspace + {chatsMode ? "Chats" : "Workspace"} )}
- + ); @@ -1648,13 +1670,15 @@ function SidebarComponent({ {railVisible ? ( <> {workspaceHeader} -
- {workspaceTabItems} -
+ {chatsMode ? null : ( +
+ {workspaceTabItems} +
+ )} ) : ( <> @@ -1691,6 +1715,8 @@ function SidebarComponent({ onOpenNotificationSettings={onOpenNotificationSettings} onOpenNotes={notesEnabled ? onOpenNotes : undefined} onOpenAutomations={onOpenAutomations} + onOpenChats={onOpenChats} + chatsActive={chatsActive} searchActive={searchActive} inboxActive={inboxActive} notesActive={notesActive} @@ -1698,7 +1724,7 @@ function SidebarComponent({ inboxUnseen={inboxUnseen} /> ) : null} - {!compactRailVisible ? ( + {!compactRailVisible && !chatsMode ? (
- ) : ( + ) : chatsMode ? null : (

No project folder

)} - {tab === "sessions" && cwd && cwd !== "~" ? ( + {tab === "sessions" && cwd ? (
@@ -1768,7 +1794,7 @@ function SidebarComponent({ tab === "sessions" ? "" : "hidden" }`} > - {!cwd || cwd === "~" ? ( + {!cwd ? (

No project folder

@@ -1801,7 +1827,13 @@ function SidebarComponent({ This project’s machine isn’t connected on this computer.

) : ( - + ) ) : (
    @@ -2193,7 +2225,7 @@ function SidebarComponent({ cwd={cwd} recents={recents} busy={projectPathBusy(busyProjectPaths, cwd)} - tabs={visibleTabs} + tabs={chatsMode ? [] : visibleTabs} activeTab={tab} tabShown={panelOpen} changesLabel={changesLabel} @@ -2212,6 +2244,8 @@ function SidebarComponent({ notesActive={notesActive} onOpenAutomations={onOpenAutomations} automationsActive={automationsActive} + onOpenChats={onOpenChats} + chatsActive={chatsActive} onOpenSettings={onOpenSettings} onTogglePanel={onToggleProjectRail} onLeaveActive={onGoBack} @@ -2241,6 +2275,8 @@ function SidebarComponent({ notesActive={notesActive} onOpenAutomations={onOpenAutomations} automationsActive={automationsActive} + onOpenChats={onOpenChats} + chatsActive={chatsActive} onTogglePanel={onToggleProjectRail} onSelectProject={onSelectProject} onOpenProject={onOpenProject} @@ -2317,6 +2353,8 @@ function SidebarProjectPicker({ onOpenNotificationSettings, onOpenNotes, onOpenAutomations, + onOpenChats, + chatsActive = false, searchActive = false, inboxActive = false, notesActive = false, @@ -2326,6 +2364,8 @@ function SidebarProjectPicker({ cwd: string; recents: RecentProject[]; busy: boolean; + onOpenChats?: () => void; + chatsActive?: boolean; onSelectProject: (path: string) => void; onOpenProject?: () => void; onRemoveProject?: Props["onRemoveProject"]; @@ -2413,6 +2453,11 @@ function SidebarProjectPicker({ ) : null} + {onOpenChats ? ( + + + + ) : null}
{inboxMenu ? ( void; + chatsActive?: boolean; cwd: string; recents: RecentProject[]; busy: boolean; @@ -2590,6 +2639,14 @@ function CompactProjectRail({ active={automationsActive} onClick={action(automationsActive, onOpenAutomations)} /> + {onOpenChats ? ( + + ) : null}
@@ -2690,9 +2747,11 @@ function CompactRailAction({ function WorkspaceTitleActions({ onSearch, onNew, + newLabel = "New session", }: { onSearch?: () => void; onNew?: () => void; + newLabel?: string; }) { if (!onSearch && !onNew) return null; return ( @@ -2706,7 +2765,7 @@ function WorkspaceTitleActions({ ) : null} {onNew ? ( - + ) : null} diff --git a/src/app/shell/SidebarChats.test.ts b/src/app/shell/SidebarChats.test.ts new file mode 100644 index 0000000000..a7d3fc481c --- /dev/null +++ b/src/app/shell/SidebarChats.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment happy-dom +import { act, createElement, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatSessionTitle } from "../../features/sessions/model/session"; +import { Sidebar } from "./Sidebar"; + +// Keep native services out of these layout tests. +vi.mock("../../features/source-control/hooks/useProjectDiffStats", () => ({ + useProjectDiffStats: vi.fn(() => null), +})); +vi.mock("../../features/source-control/hooks/useGitFileStatuses", () => ({ + useGitFileStatuses: () => ({ files: new Map(), dirs: new Map() }), +})); +vi.mock("./SidebarUpdate", () => ({ SidebarUpdateFooter: () => null })); +vi.mock("../../features/files/ui/FileTree", () => ({ FileTree: () => null })); + +let container: HTMLDivElement; +let root: Root; +let props: ComponentProps; + +function render(patch: Partial> = {}) { + props = { ...props, ...patch }; + act(() => root.render(createElement(Sidebar, props))); +} + +function button(label: string): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll("button")).find( + (item) => + item.getAttribute("aria-label") === label || + item.textContent?.trim() === label, + ); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const stored = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => stored.set(key, value), + removeItem: (key: string) => stored.delete(key), + clear: () => stored.clear(), + }); + props = { + cwd: "~", + open: true, + sessions: [ + { + id: "chat-1", + cwd: "~", + harness: "codex", + model: "", + runtimeMode: "supervised", + title: formatSessionTitle("codex", "Quick question"), + createdAt: Date.now(), + updatedAt: Date.now(), + }, + ], + busySessionIds: new Set(), + approvalSessionIds: new Set(), + activeSessionId: "chat-1", + status: "idle", + pending: false, + // The stored tab belongs to a project; chats only ever list sessions. + tab: "files", + filesSearchOpen: false, + recents: [], + onSelectSession: vi.fn(), + onSelectProject: vi.fn(), + onOpenProject: vi.fn(), + onOpenChats: vi.fn(), + onOpenFile: vi.fn(), + onTabChange: vi.fn(), + onFilesSearchOpenChange: vi.fn(), + projectRailOpen: true, + }; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("Sidebar chats", () => { + it("lists projectless chats without the project tabs", () => { + render(); + + expect(container.querySelector('[data-session-card="chat-1"]')).not.toBeNull(); + expect(container.textContent).toContain("Chats"); + expect(container.textContent).not.toContain("No project folder"); + expect(container.querySelector('[role="tablist"]')).toBeNull(); + }); + + it("marks Chats active in the rail and opens it on click", () => { + render(); + + const chats = button("Chats")!; + expect(chats).toBeDefined(); + act(() => chats.click()); + expect(props.onOpenChats).toHaveBeenCalledTimes(1); + }); + + it("says where new chats will appear when there are none", () => { + render({ sessions: [], activeSessionId: undefined }); + + expect(container.textContent).toContain( + "Chats you start will show up here", + ); + }); + + it("keeps the project tabs for a project", () => { + render({ + cwd: "/workspace/project", + sessions: [], + activeSessionId: undefined, + tab: "sessions", + }); + + expect(container.querySelector('[role="tablist"]')).not.toBeNull(); + expect(container.textContent).toContain( + "Sessions you start will show up here", + ); + }); +}); diff --git a/src/app/shell/TitleBar.tsx b/src/app/shell/TitleBar.tsx index 285ecd1809..d749185cb1 100644 --- a/src/app/shell/TitleBar.tsx +++ b/src/app/shell/TitleBar.tsx @@ -23,6 +23,7 @@ import { } from "react"; import { basename } from "../../platform/tauri/fs"; import { looksLikeProject } from "../../features/projects/model/recents"; +import { isChatCwd } from "../../features/sessions/model/chatSession"; import type { HarnessId } from "../../features/sessions/model/session"; import { CwdPicker } from "../../features/projects/ui/CwdPicker"; import { useLockOverscroll } from "../../shared/hooks/useLockOverscroll"; @@ -836,9 +837,10 @@ function TitleBarComponent({ const railClosed = !projectRailOpen; const showCurrentProject = looksLikeProject(cwd); - // Until a project is picked, the rail and the sidebar hide, so nothing - // project-scoped is actionable and the window controls need room. - const projectless = !showCurrentProject; + // With neither a project nor the chats list, the rail and the sidebar hide, + // so nothing project-scoped is actionable and the window controls need room. + // Projectless chats keep the sidebar for their list, like a project does. + const projectless = !showCurrentProject && !isChatCwd(cwd); // An open project is labeled in the sidebar, above Sessions / Explorer / // Changes. Without a project that sidebar is gone, so the picker stays here. const showProjectButton = diff --git a/src/features/sessions/data/sessionStore.test.ts b/src/features/sessions/data/sessionStore.test.ts index a68e30fd1b..93c7b29acc 100644 --- a/src/features/sessions/data/sessionStore.test.ts +++ b/src/features/sessions/data/sessionStore.test.ts @@ -23,6 +23,13 @@ it("keeps host-owned transcripts out of local session storage", () => { expect(shouldPersistSession(session)).toBe(false); }); +it("saves a projectless chat once it has a user turn", () => { + const chat = newSession("codex", "~"); + expect(shouldPersistSession(chat)).toBe(false); + chat.blocks = [{ id: "turn", role: "user", text: "Quick question" }]; + expect(shouldPersistSession(chat)).toBe(true); +}); + describe("Claude Shell row recovery", () => { it("restores only matching placeholder rows and preserves tool output", () => { const blocks: Block[] = [ diff --git a/src/features/sessions/data/sessionStore.ts b/src/features/sessions/data/sessionStore.ts index 46679b19ca..b338f469ad 100644 --- a/src/features/sessions/data/sessionStore.ts +++ b/src/features/sessions/data/sessionStore.ts @@ -112,12 +112,14 @@ type SessionUpsertPayload = { automationId?: string; }; -/** Only real chats belong in project history — blank tabs stay ephemeral. */ +/** + * Only real conversations belong in history — blank tabs stay ephemeral. A + * projectless chat counts once it has a user turn, and is filed under `~`. + */ export function shouldPersistSession(session: Session): boolean { return ( !session.inboxAsk && !isRemoteProjectPath(session.cwd) && - session.cwd !== "~" && session.blocks.some((block) => block.role === "user") ); } @@ -290,7 +292,8 @@ export function persistFingerprint(session: Session): string { export async function listSessionsByProject( cwd: string, ): Promise { - if (!cwd || cwd === "~") return []; + if (!cwd) return []; + // Projectless chats are listed under `~`, which normalizes to itself. const rows = await invoke("session_list_by_project", { cwd: normalizeProjectPath(cwd), }); diff --git a/src/features/sessions/model/chatMove.test.ts b/src/features/sessions/model/chatMove.test.ts new file mode 100644 index 0000000000..6537334912 --- /dev/null +++ b/src/features/sessions/model/chatMove.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { moveChatToProject } from "./chatMove"; +import { pendingHandoff } from "./handoff"; +import { newSession, type Session } from "./session"; + +function chat(): Session { + return { + ...newSession("claude", "~"), + providerSessionId: "thread-1", + providerAccountId: "work", + context: { used: 10, window: 100 }, + blocks: [ + { id: "u1", role: "user", text: "How do I parse a CSV in Rust?" }, + { id: "a1", role: "assistant", text: "Use the csv crate." }, + ], + }; +} + +describe("moveChatToProject", () => { + it("files the chat under the project and starts a fresh provider thread", () => { + const source = chat(); + const moved = moveChatToProject(source, "/Users/me/app"); + + expect(moved.id).toBe(source.id); + expect(moved.cwd).toBe("/Users/me/app"); + expect(moved.providerSessionId).toBeUndefined(); + expect(moved.context).toBeUndefined(); + expect(moved.providerAccountId).toBe("work"); + expect(moved.harness).toBe("claude"); + expect(moved.blocks.slice(0, 2)).toEqual(source.blocks); + }); + + it("carries the conversation to the next turn as a brief", () => { + const moved = moveChatToProject(chat(), "/Users/me/app"); + const handoff = pendingHandoff(moved); + + expect(handoff?.from).toBe("claude"); + expect(handoff?.to).toBe("claude"); + expect(handoff?.text).toContain("How do I parse a CSV in Rust?"); + }); + + it("leaves the source session untouched", () => { + const source = chat(); + moveChatToProject(source, "/Users/me/app"); + expect(source.cwd).toBe("~"); + expect(source.providerSessionId).toBe("thread-1"); + expect(source.blocks).toHaveLength(2); + }); +}); diff --git a/src/features/sessions/model/chatMove.ts b/src/features/sessions/model/chatMove.ts new file mode 100644 index 0000000000..e0c5eeb76c --- /dev/null +++ b/src/features/sessions/model/chatMove.ts @@ -0,0 +1,28 @@ +import { appendReadyHandoff, buildDeterministicHandoff } from "./handoff"; +import type { Session } from "./session"; + +/** + * Promote a projectless chat into a project, keeping its transcript. + * + * A provider conversation is bound to the folder it started in, so the thread + * cannot simply resume from the project. The chat starts a fresh provider + * thread there instead, and a brief of the conversation so far rides along + * with the next turn so the agent still knows what was discussed. + */ +export function moveChatToProject(session: Session, cwd: string): Session { + const brief = buildDeterministicHandoff(session); + const moved: Session = { + ...session, + cwd, + providerSessionId: undefined, + context: undefined, + branch: undefined, + worktreeCwd: undefined, + worktreeRemoved: undefined, + workspaceMode: undefined, + worktreeBase: undefined, + }; + return brief.trim() + ? appendReadyHandoff(moved, session.harness, session.harness, brief) + : moved; +} diff --git a/src/features/sessions/model/chatSession.test.ts b/src/features/sessions/model/chatSession.test.ts new file mode 100644 index 0000000000..404f935040 --- /dev/null +++ b/src/features/sessions/model/chatSession.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + CHATS_CWD, + chatWorkCwd, + isChatCwd, + isChatSession, + setChatWorkspaceDir, +} from "./chatSession"; + +afterEach(() => setChatWorkspaceDir(null)); + +describe("chat sessions", () => { + it("treats only the home placeholder as a chat", () => { + expect(isChatCwd(CHATS_CWD)).toBe(true); + expect(isChatCwd("/Users/me/app")).toBe(false); + expect(isChatCwd("")).toBe(false); + expect(isChatCwd(undefined)).toBe(false); + expect(isChatSession({ cwd: "~" })).toBe(true); + expect(isChatSession({ cwd: "/Users/me/app" })).toBe(false); + expect(isChatSession(undefined)).toBe(false); + }); + + it("keeps the home directory until the chats folder is known", () => { + expect(chatWorkCwd("~")).toBe("~"); + }); + + it("runs chats in the chats folder and leaves projects alone", () => { + setChatWorkspaceDir("/data/monocode/chats"); + expect(chatWorkCwd("~")).toBe("/data/monocode/chats"); + expect(chatWorkCwd("/Users/me/app")).toBe("/Users/me/app"); + }); + + it("ignores an unusable chats folder", () => { + setChatWorkspaceDir(" "); + expect(chatWorkCwd("~")).toBe("~"); + setChatWorkspaceDir("~"); + expect(chatWorkCwd("~")).toBe("~"); + }); +}); diff --git a/src/features/sessions/model/chatSession.ts b/src/features/sessions/model/chatSession.ts new file mode 100644 index 0000000000..e36320b9ce --- /dev/null +++ b/src/features/sessions/model/chatSession.ts @@ -0,0 +1,27 @@ +/** Working directory of a chat that belongs to no project. */ +export const CHATS_CWD = "~"; + +let chatWorkspaceDir: string | null = null; + +export function isChatCwd(cwd: string | undefined | null): boolean { + return cwd === CHATS_CWD; +} + +export function isChatSession(session: { cwd: string } | undefined): boolean { + return isChatCwd(session?.cwd); +} + +/** Folder the app keeps for projectless chats, once the backend has made it. */ +export function setChatWorkspaceDir(path: string | null): void { + const trimmed = path?.trim(); + chatWorkspaceDir = trimmed && trimmed !== CHATS_CWD ? trimmed : null; +} + +/** + * Where a harness should actually run for `cwd`. A projectless chat runs in + * the app's chats folder rather than the home directory; until that folder is + * known it keeps the home directory it has always had. + */ +export function chatWorkCwd(cwd: string): string { + return isChatCwd(cwd) && chatWorkspaceDir ? chatWorkspaceDir : cwd; +} diff --git a/src/features/sessions/model/inFlight.test.ts b/src/features/sessions/model/inFlight.test.ts index a3a31c86be..0340c759d4 100644 --- a/src/features/sessions/model/inFlight.test.ts +++ b/src/features/sessions/model/inFlight.test.ts @@ -87,6 +87,13 @@ describe("inFlightRefs", () => { expect(hasInFlightSessions([blank])).toBe(true); }); + it("resumes a projectless chat like any saved session", () => { + const projectless = chat("~", { busy: true }); + expect(inFlightRefs([projectless], [newTab(projectless.id)])).toEqual([ + { sessionId: projectless.id, cwd: "~" }, + ]); + }); + it("does not resume a session whose worktree was removed", () => { const removed = chat("/tmp/a", { busy: true, diff --git a/src/features/sessions/model/inFlight.ts b/src/features/sessions/model/inFlight.ts index c467904af8..afdcbcf8c9 100644 --- a/src/features/sessions/model/inFlight.ts +++ b/src/features/sessions/model/inFlight.ts @@ -150,7 +150,6 @@ export function shouldWriteInFlightSnapshot( function canResumeAfterQuit(session: Session): boolean { return ( !session.worktreeRemoved && - session.cwd !== "~" && session.blocks.some((block) => block.role === "user") ); } diff --git a/src/features/sessions/ui/SessionPane.tsx b/src/features/sessions/ui/SessionPane.tsx index 0acb729939..f32b4c2374 100644 --- a/src/features/sessions/ui/SessionPane.tsx +++ b/src/features/sessions/ui/SessionPane.tsx @@ -539,7 +539,10 @@ const LocalSessionPane = memo(function LocalSessionPane({ return () => window.removeEventListener(ADD_TO_CHAT_EVENT, onAdd); }, [addSelectionToChat, addToChatTarget]); const workCwd = sessionWorkCwd(session); - const showDeckProjectPicker = isEmpty && !looksLikeProject(session.cwd); + // A projectless chat keeps the picker after its first turn, so it can be + // moved into a project once it grows into real work. + const showDeckProjectPicker = + !looksLikeProject(session.cwd) && (isEmpty || !session.busy); const dockComposer = remoteSessionLoading || (!draftBlock && (!isEmpty || inSplit || !!session.inboxAsk)); diff --git a/src/features/settings/model/appShortcuts.test.ts b/src/features/settings/model/appShortcuts.test.ts index 7eb909ac6b..cba8ea97d1 100644 --- a/src/features/settings/model/appShortcuts.test.ts +++ b/src/features/settings/model/appShortcuts.test.ts @@ -43,6 +43,15 @@ describe("resolveAppShortcut", () => { ).toBe("App: Toggle Session Sidebar"); }); + it("tells New Chat from New Window by Shift", () => { + expect(resolveAppShortcut(key({ key: "n", metaKey: true }))).toBe( + "Chat: New", + ); + expect( + resolveAppShortcut(key({ key: "N", metaKey: true, shiftKey: true })), + ).toBe("App: New Window"); + }); + it("ignores a chord while an IME is composing", () => { // The maintainer's blocker: these used to fire mid-composition. for (const chord of [ diff --git a/src/features/settings/model/appShortcuts.ts b/src/features/settings/model/appShortcuts.ts index 4a4f67da4f..57a3719682 100644 --- a/src/features/settings/model/appShortcuts.ts +++ b/src/features/settings/model/appShortcuts.ts @@ -12,6 +12,7 @@ type AppShortcutEvent = ShortcutEvent & { /** command, key, and whether Shift must be held. */ const APP_SHORTCUTS: [string, string, boolean][] = [ ["App: New Window", "n", true], + ["Chat: New", "n", false], ["App: Open Project", "o", false], ["App: Toggle Sidebar", "b", false], ["App: Toggle Session Sidebar", "b", true], diff --git a/src/features/settings/model/settings.ts b/src/features/settings/model/settings.ts index a0a27bc265..1d47ee9f58 100644 --- a/src/features/settings/model/settings.ts +++ b/src/features/settings/model/settings.ts @@ -976,6 +976,7 @@ export const KEYBINDINGS: KeybindingRow[] = [ { command: "App: Find in Files", keys: `${MOD}${SHIFT}F`, when: "Always" }, { command: "App: Open Project", keys: `${MOD}O`, when: "Always" }, { command: "App: New Window", keys: `${MOD}${SHIFT}N`, when: "Always" }, + { command: "Chat: New", keys: `${MOD}N`, when: "Always" }, ...(IS_MAC ? [ { diff --git a/src/integrations/harness/core/registry.test.ts b/src/integrations/harness/core/registry.test.ts index 875b6dcb5a..80a5e7599e 100644 --- a/src/integrations/harness/core/registry.test.ts +++ b/src/integrations/harness/core/registry.test.ts @@ -21,6 +21,7 @@ import { type HarnessAdapter, } from "./registry"; import type { SendTurnInput, SteerTurnInput } from "./types"; +import { setChatWorkspaceDir } from "../../../features/sessions/model/chatSession"; import { registerBuiltinHarnesses } from "./register"; function stub( @@ -45,6 +46,7 @@ describe("harness registry", () => { afterEach(() => { resetHarnessModelOverlays(); resetHarnessIdlePark(); + setChatWorkspaceDir(null); vi.useRealTimers(); }); @@ -355,6 +357,44 @@ describe("harness registry", () => { ]); }); + it("runs projectless chats in the chats folder", async () => { + setChatWorkspaceDir("/data/chats"); + const sendTurn = vi.fn(async (_input: SendTurnInput) => undefined); + const bindSession = vi.fn(); + registerHarness(stub("claude", { sendTurn, bindSession })); + + await sendHarnessTurn({ + harness: "claude", + sessionId: "chat", + cwd: "~", + model: "claude:sonnet", + text: "hi", + runtimeMode: "supervised", + onEvent: () => undefined, + }); + await sendHarnessTurn({ + harness: "claude", + sessionId: "project", + cwd: "/repo", + model: "claude:sonnet", + text: "hi", + runtimeMode: "supervised", + onEvent: () => undefined, + }); + bindHarnessSession("claude", "chat", "sess_1", "~"); + + expect(sendTurn.mock.calls.map(([input]) => input.cwd)).toEqual([ + "/data/chats", + "/repo", + ]); + expect(bindSession).toHaveBeenCalledWith( + "chat", + "sess_1", + "/data/chats", + undefined, + ); + }); + it("binds a restored session and forwards its task panels", () => { const bindSession = vi.fn(); const restoreTaskLists = vi.fn(); diff --git a/src/integrations/harness/core/registry.ts b/src/integrations/harness/core/registry.ts index 98c275e20d..68dd5b772a 100644 --- a/src/integrations/harness/core/registry.ts +++ b/src/integrations/harness/core/registry.ts @@ -8,6 +8,7 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import type { GeneratedSessionTitle } from "../../../features/sessions/model/sessionTitle"; import type { PrContent } from "../../../features/source-control/model/gitText"; import { hasLiveCatalog } from "../../../features/sessions/model/models"; +import { chatWorkCwd } from "../../../features/sessions/model/chatSession"; import type { UserQuestionReply } from "../../../features/sessions/model/userQuestion"; import type { NativeCommandProvider } from "./nativeCommands"; import type { @@ -210,7 +211,10 @@ export function listHarnesses(): HarnessAdapter[] { return [...adapters.values()]; } -export function sendHarnessTurn(input: SendTurnInput & { harness: HarnessId }) { +export function sendHarnessTurn( + turn: SendTurnInput & { harness: HarnessId }, +) { + const input = { ...turn, cwd: chatWorkCwd(turn.cwd) }; return queueSessionOperation(input.sessionId, async () => { const adapter = requireHarness(input.harness); if (!adapter.live) { @@ -254,7 +258,7 @@ export function compactHarnessContext( } cancelIdlePark(input.sessionId); try { - await adapter.compactContext(input); + await adapter.compactContext({ ...input, cwd: chatWorkCwd(input.cwd) }); } finally { scheduleIdlePark(input.harness, input.sessionId); } @@ -300,7 +304,7 @@ export function steerHarnessTurn( throw new Error(`${input.harness} is not connected yet`); } cancelIdlePark(input.sessionId); - await adapter.steerTurn(input); + await adapter.steerTurn({ ...input, cwd: chatWorkCwd(input.cwd) }); }); } @@ -371,7 +375,12 @@ export function bindHarnessSession( blocks?: Block[], ): void { const adapter = getHarness(harness); - adapter?.bindSession(threadId, providerSessionId, cwd, providerAccountId); + adapter?.bindSession( + threadId, + providerSessionId, + chatWorkCwd(cwd), + providerAccountId, + ); if (!blocks || !adapter?.restoreTaskLists) return; const lists = blocks.flatMap((block) => block.role === "tasks" && block.taskList ? [block.taskList] : [], @@ -410,7 +419,7 @@ export async function generateHarnessTitle( ): Promise { const adapter = getHarness(harness); if (!adapter?.generateTitle) return null; - return adapter.generateTitle(input); + return adapter.generateTitle({ ...input, cwd: chatWorkCwd(input.cwd) }); } export async function generateHarnessCommitMessage( From 21764621b43eee6def4d09800495793cf2ac1b16 Mon Sep 17 00:00:00 2001 From: shohihul Date: Tue, 6 Oct 2026 07:19:55 +0700 Subject: [PATCH 2/5] Send New Chat to the focused window and drop branch controls from chats --- src-tauri/src/menu.rs | 19 ++++++++++--------- src/features/sessions/ui/SessionPane.tsx | 6 +++++- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 811610d851..e29a93ca2e 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -145,16 +145,16 @@ pub fn dispatch(app: &AppHandle, id: &str) { let _ = app.emit("toggle_autosave", enabled); } "quit" => crate::window::request_quit(app), - "new_tab" | "new_chat" | "close_tab" | "close_other_tabs" | "next_tab" | "prev_tab" - | "back_tab" | "forward_tab" | "split_right" | "split_down" | "focus_left" - | "focus_right" | "focus_up" | "focus_down" | "sidebar_opacity" | "open_project" - | "go_to_file" | "open_search" | "open_inbox" | "open_notes" | "find_in_project" - | "find" | "new_terminal" | "new_terminal_tab" | "toggle_terminal" - | "open_model_picker" | "open_settings" | "check_for_updates" => { + "new_tab" | "close_tab" | "close_other_tabs" | "next_tab" | "prev_tab" | "back_tab" + | "forward_tab" | "split_right" | "split_down" | "focus_left" | "focus_right" + | "focus_up" | "focus_down" | "sidebar_opacity" | "open_project" | "go_to_file" + | "open_search" | "open_inbox" | "open_notes" | "find_in_project" | "find" + | "new_terminal" | "new_terminal_tab" | "toggle_terminal" | "open_model_picker" + | "open_settings" | "check_for_updates" => { let _ = app.emit(id, ()); } - // Sidebar, Zoom, Reload, Command Palette, and Close All Tabs target one window: a broadcast would - // make every window act on a single menu click. + // Sidebar, Zoom, Reload, Command Palette, Close All Tabs, and New Chat target one window: a + // broadcast would make every window act on a single menu click. "toggle_sidebar" | "toggle_session_sidebar" | "zoom_in" @@ -162,7 +162,8 @@ pub fn dispatch(app: &AppHandle, id: &str) { | "zoom_reset" | "reload" | "open_command_palette" - | "close_all_tabs" => emit_to_focused(app, id), + | "close_all_tabs" + | "new_chat" => emit_to_focused(app, id), _ => {} } } diff --git a/src/features/sessions/ui/SessionPane.tsx b/src/features/sessions/ui/SessionPane.tsx index f32b4c2374..93738383dc 100644 --- a/src/features/sessions/ui/SessionPane.tsx +++ b/src/features/sessions/ui/SessionPane.tsx @@ -29,6 +29,7 @@ import { looksLikeProject, type RecentProject, } from "../../projects/model/recents"; +import { isChatSession } from "../model/chatSession"; import { sessionDisplayTitle, sessionDraftBlock, @@ -573,7 +574,10 @@ const LocalSessionPane = memo(function LocalSessionPane({ !!session.inboxAsk || (hideProjectPicker ? !showDeckProjectPicker : false) } - hideBranchPicker={!!session.inboxAsk || managed} + // A projectless chat has no checkout or branch to pick. + hideBranchPicker={ + !!session.inboxAsk || managed || isChatSession(session) + } hideTopBar={!!session.inboxAsk} context={session.context} quoteRequest={quoteRequest} From abafbf8b88a2f69147ac850e807657e951d07ec5 Mon Sep 17 00:00:00 2001 From: shohihul Date: Tue, 6 Oct 2026 09:37:51 +0700 Subject: [PATCH 3/5] Keep the collapsed rail intact in chats --- src/app/shell/Sidebar.tsx | 41 +++++++++++-------- src/app/shell/SidebarChats.test.ts | 12 ++++++ .../projects/ui/SearchableProjectPicker.tsx | 15 ++++++- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/app/shell/Sidebar.tsx b/src/app/shell/Sidebar.tsx index 214ff312e8..a560d38a8a 100644 --- a/src/app/shell/Sidebar.tsx +++ b/src/app/shell/Sidebar.tsx @@ -2587,24 +2587,29 @@ function CompactProjectRail({ onOpenNotificationSettings={onOpenNotificationSettings} /> ) : null} -
- {tabs.map((itemId) => ( - openWorkspaceTab(itemId)} - /> - ))} -
+ {/* Chats have no workspace tabs; an empty list would leave a gap. */} + {tabs.length > 0 ? ( +
+ {tabs.map((itemId) => ( + openWorkspaceTab(itemId)} + /> + ))} +
+ ) : null} { expect(props.onOpenChats).toHaveBeenCalledTimes(1); }); + it("keeps the collapsed rail whole while in chats", () => { + render({ projectRailOpen: false, compactProjectRail: true }); + + const rail = container.querySelector('[aria-label="Project shortcuts"]')!; + expect(rail).not.toBeNull(); + // No empty workspace tab list, and the project picker still shows an icon. + expect(rail.querySelector('[role="tablist"]')).toBeNull(); + const picker = rail.querySelector('button[aria-haspopup="dialog"]')!; + expect(picker.querySelector("svg")).not.toBeNull(); + expect(button("Chats")).toBeDefined(); + }); + it("says where new chats will appear when there are none", () => { render({ sessions: [], activeSessionId: undefined }); diff --git a/src/features/projects/ui/SearchableProjectPicker.tsx b/src/features/projects/ui/SearchableProjectPicker.tsx index 0e2b7251a1..d1115ca86f 100644 --- a/src/features/projects/ui/SearchableProjectPicker.tsx +++ b/src/features/projects/ui/SearchableProjectPicker.tsx @@ -24,7 +24,13 @@ import { resolveTabGroupLogo, resolveTabGroupMascot, } from "../../workspace/model/tabGroups"; -import { Check, ChevronDown, Plus, Search } from "../../../shared/ui/icons"; +import { + Check, + ChevronDown, + Folder, + Plus, + Search, +} from "../../../shared/ui/icons"; import { Popover } from "../../../shared/ui/Popover"; import { ProjectLogoIcon } from "./ProjectLogoIcon"; import { ProjectMascot } from "./ProjectMascot"; @@ -226,7 +232,12 @@ export function SearchableProjectPicker({ : "text-content/50 hover:bg-content/5 hover:text-content" }${buttonClassName ? ` ${buttonClassName}` : ""}`} > - {!inProject ? null : logoPath ? ( + {!inProject ? ( + // With no project the compact trigger is icon-only, so it needs one. + compact ? ( + + ) : null + ) : logoPath ? ( Date: Tue, 6 Oct 2026 10:01:58 +0700 Subject: [PATCH 4/5] Label the chat project picker Move to project --- src/features/projects/ui/CwdPicker.test.ts | 60 ++++++++++++++++++++++ src/features/projects/ui/CwdPicker.tsx | 12 ++++- src/features/sessions/ui/Composer.tsx | 9 ++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 src/features/projects/ui/CwdPicker.test.ts diff --git a/src/features/projects/ui/CwdPicker.test.ts b/src/features/projects/ui/CwdPicker.test.ts new file mode 100644 index 0000000000..edda70ebf6 --- /dev/null +++ b/src/features/projects/ui/CwdPicker.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +import { act, createElement, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CwdPicker } from "./CwdPicker"; + +let container: HTMLDivElement; +let root: Root; + +function render(props: Partial>) { + act(() => + root.render( + createElement(CwdPicker, { + cwd: "~", + recents: [], + onCwdChange: vi.fn(), + ...props, + }), + ), + ); +} + +function trigger(): HTMLButtonElement { + return container.querySelector("button")!; +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("CwdPicker trigger", () => { + it("names what it does while there is no project", () => { + render({ emptyLabel: "Move to project" }); + + expect(trigger().textContent).toBe("Move to project"); + expect(trigger().getAttribute("aria-label")).toBe("Move to project"); + }); + + it("shows the path without an empty label", () => { + render({}); + + expect(trigger().textContent).toBe("~"); + }); + + it("shows the project once there is one", () => { + render({ cwd: "/Users/me/app", emptyLabel: "Move to project" }); + + expect(trigger().textContent).not.toContain("Move to project"); + expect(trigger().textContent).toContain("app"); + }); +}); diff --git a/src/features/projects/ui/CwdPicker.tsx b/src/features/projects/ui/CwdPicker.tsx index 491431b685..8318fed57c 100644 --- a/src/features/projects/ui/CwdPicker.tsx +++ b/src/features/projects/ui/CwdPicker.tsx @@ -36,6 +36,8 @@ type Props = { buttonClassName?: string; /** Chevron on the trailing edge; flips when the menu is open. */ chevron?: boolean; + /** Text for the trigger while there is no project, instead of the path. */ + emptyLabel?: string; children?: ReactNode; onCwdChange: (path: string) => void; onNewTerminal?: () => void; @@ -68,6 +70,7 @@ export function CwdPicker({ className, buttonClassName, chevron = false, + emptyLabel, children, onCwdChange, onNewTerminal, @@ -86,6 +89,7 @@ export function CwdPicker({ const inProject = looksLikeProject(cwd); const label = prettyCwd(cwd); + const triggerLabel = !inProject && emptyLabel ? emptyLabel : null; // Read the saved rail order on opening, including changes made while Notes is open. const projects = mode === "move" ? projectRailItems(recents, activeCwd ?? "") : recents; @@ -205,7 +209,7 @@ export function CwdPicker({