diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b06f89f3..653742daa 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.8.0] - 2026-10-06 ### Added diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 075e824f9..c0b0a587d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -71,6 +71,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, @@ -282,6 +295,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 a0aaf3547..e29a93ca2 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -153,8 +153,8 @@ pub fn dispatch(app: &AppHandle, id: &str) { | "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), _ => {} } } @@ -254,6 +255,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 +470,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 a25a8b7f1..036a891a6 100644 --- a/src-tauri/src/session_store.rs +++ b/src-tauri/src/session_store.rs @@ -1145,13 +1145,13 @@ pub(crate) fn upsert_session( .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 { @@ -1627,8 +1627,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, @@ -2688,6 +2697,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 85b46f232..f5937077b 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -319,6 +319,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, @@ -733,6 +734,10 @@ import { reconcileProjectReturn, type ProjectReturnMemory, } from "../features/projects/model/projectReturn"; +import { + CHATS_CWD, + isChatSession, +} from "../features/sessions/model/chatSession"; import { planProjectOpenRun, type ProjectOpenStep, @@ -1756,8 +1761,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, @@ -2101,7 +2106,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 @@ -2310,10 +2315,16 @@ function Workspace({ void invoke("default_cwd") .then((cwd) => { if (!looksLikeProject(cwd)) return; + // Only the untouched boot tab adopts the folder. A chat the user + // opened, or restored with a conversation, stays projectless, and so + // does a project they already navigated to. + const boot = sessionsRef.current.find((s) => s.id === seed.session.id); + if (!boot || !isChatSession(boot) || !isBlankSession(boot)) return; + if (projectCwdRef.current !== CHATS_CWD) return; setProjectCwd(cwd); setRecents((prev) => (prev.length > 0 ? prev : rememberProject(cwd))); setSessions((prev) => - prev.map((s) => (s.cwd === "~" ? { ...s, cwd } : s)), + prev.map((s) => (s.id === seed.session.id ? { ...s, cwd } : s)), ); }) .catch(() => {}); @@ -2541,6 +2552,74 @@ 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); + setSettingsOpen(false); + closeMonoView(); + 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, + closeMonoView, + 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); @@ -3745,10 +3824,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) => { @@ -5517,11 +5597,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) @@ -5558,7 +5667,7 @@ function Workspace({ }); notifyReviewChanged(sessionId); }, - [appendTab, projectOfTab], + [appendTab, persistSession, projectOfTab], ); const onBranchChange = useCallback( @@ -11500,6 +11609,7 @@ function Workspace({ const actions = useRef({ onNew, + onNewChat, onArchiveFocusedSession, onCloseOtherTabs, onCloseAllTabs, @@ -11532,6 +11642,7 @@ function Workspace({ }); actions.current = { onNew, + onNewChat, onArchiveFocusedSession, onCloseOtherTabs, onCloseAllTabs, @@ -11738,8 +11849,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 && @@ -11750,7 +11862,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()); @@ -11780,6 +11893,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), ), @@ -12282,6 +12396,7 @@ function Workspace({ onOpenInboxItem={onOpenLinkedWorkItem} onOpenNotes={notesEnabled ? onOpenNotes : undefined} onOpenAutomations={onOpenAutomations} + onOpenChats={onOpenChats} onGoToFile={onGoToFile} searchActive={searchViewOpen} inboxActive={inboxViewOpen} @@ -12336,6 +12451,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 83e0079ee..107932152 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, @@ -110,6 +111,8 @@ type Props = { notesActive?: boolean; onOpenAutomations?: () => void; automationsActive?: boolean; + onOpenChats?: () => void; + chatsActive?: boolean; onTogglePanel?: () => void; onSelectProject: (path: string) => void; onOpenProject: () => void; @@ -149,6 +152,8 @@ export function ProjectRail({ notesActive = false, onOpenAutomations, automationsActive = false, + onOpenChats, + chatsActive = false, onTogglePanel, onSelectProject, onOpenProject, @@ -412,6 +417,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; @@ -409,6 +413,7 @@ function SidebarComponent({ onOpenInboxItem, onOpenNotes, onOpenAutomations, + onOpenChats, onGoToFile, searchActive = false, inboxActive = false, @@ -435,7 +440,9 @@ function SidebarComponent({ monoViewActive = false, }: 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 ( @@ -789,10 +796,10 @@ function SidebarComponent({ const railMonos = monos ? { ...monos, activeId: otherViewActive ? undefined : monos.activeId } : undefined; - // 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 = - !otherViewActive && !monoViewActive && inProject; + !otherViewActive && !monoViewActive && (inProject || chatsMode); + const chatsActive = chatsMode && !otherViewActive && !monoViewActive; const sidebarVisible = open && sidebarAvailable; // With the sidebar collapsed beside the compact rail, its tab shortcuts // open the sidebar temporarily until the user clicks away. @@ -1170,8 +1177,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, @@ -1665,11 +1677,15 @@ function SidebarComponent({ /> ) : ( - Workspace + {chatsMode ? "Chats" : "Workspace"} )}
- + ); @@ -1681,13 +1697,15 @@ function SidebarComponent({ {railVisible ? ( <> {workspaceHeader} -
- {workspaceTabItems} -
+ {chatsMode ? null : ( +
+ {workspaceTabItems} +
+ )} ) : ( <> @@ -1724,6 +1742,8 @@ function SidebarComponent({ onOpenNotificationSettings={onOpenNotificationSettings} onOpenNotes={notesEnabled ? onOpenNotes : undefined} onOpenAutomations={onOpenAutomations} + onOpenChats={onOpenChats} + chatsActive={chatsActive} searchActive={searchActive} inboxActive={inboxActive} notesActive={notesActive} @@ -1731,7 +1751,7 @@ function SidebarComponent({ inboxUnseen={inboxUnseen} /> ) : null} - {!compactRailVisible ? ( + {!compactRailVisible && !chatsMode ? (
) : null}
- ) : ( + ) : chatsMode ? null : (

No project folder

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

No project folder

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

) : ( - + ) ) : (
    @@ -2228,7 +2254,7 @@ function SidebarComponent({ cwd={cwd} recents={recents} busy={projectPathBusy(busyProjectPaths, cwd)} - tabs={visibleTabs} + tabs={chatsMode ? [] : visibleTabs} activeTab={tab} tabShown={panelOpen} changesLabel={changesLabel} @@ -2247,6 +2273,8 @@ function SidebarComponent({ notesActive={notesActive} onOpenAutomations={onOpenAutomations} automationsActive={automationsActive} + onOpenChats={onOpenChats} + chatsActive={chatsActive} onOpenSettings={onOpenSettings} onTogglePanel={onToggleProjectRail} onLeaveActive={onGoBack} @@ -2278,6 +2306,8 @@ function SidebarComponent({ notesActive={notesActive} onOpenAutomations={onOpenAutomations} automationsActive={automationsActive} + onOpenChats={onOpenChats} + chatsActive={chatsActive} onTogglePanel={onToggleProjectRail} onSelectProject={onSelectProject} onOpenProject={onOpenProject} @@ -2355,6 +2385,8 @@ function SidebarProjectPicker({ onOpenNotificationSettings, onOpenNotes, onOpenAutomations, + onOpenChats, + chatsActive = false, searchActive = false, inboxActive = false, notesActive = false, @@ -2364,6 +2396,8 @@ function SidebarProjectPicker({ cwd: string; recents: RecentProject[]; busy: boolean; + onOpenChats?: () => void; + chatsActive?: boolean; onSelectProject: (path: string) => void; onOpenProject?: () => void; onRemoveProject?: Props["onRemoveProject"]; @@ -2451,6 +2485,11 @@ function SidebarProjectPicker({ ) : null} + {onOpenChats ? ( + + + + ) : null}
{inboxMenu ? ( void; + chatsActive?: boolean; cwd: string; recents: RecentProject[]; busy: boolean; @@ -2602,24 +2645,29 @@ function CompactProjectRail({ monos={pickerMonos} /> ) : 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} + {onOpenChats ? ( + + ) : null}
@@ -2754,9 +2810,11 @@ function CompactRailAction({ function WorkspaceTitleActions({ onSearch, onNew, + newLabel = "New session", }: { onSearch?: () => void; onNew?: () => void; + newLabel?: string; }) { if (!onSearch && !onNew) return null; return ( @@ -2770,7 +2828,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 000000000..fade9f885 --- /dev/null +++ b/src/app/shell/SidebarChats.test.ts @@ -0,0 +1,140 @@ +// @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("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 }); + + 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 77a783a80..1be3746e4 100644 --- a/src/app/shell/TitleBar.tsx +++ b/src/app/shell/TitleBar.tsx @@ -24,6 +24,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"; @@ -857,9 +858,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/projects/ui/CwdPicker.test.ts b/src/features/projects/ui/CwdPicker.test.ts new file mode 100644 index 000000000..edda70ebf --- /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 491431b68..8318fed57 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({