diff --git a/src/App.tsx b/src/App.tsx index e2aba681..b839b8d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,8 +20,10 @@ import { FilePicker } from "./chrome/FilePicker"; import { UsageFooter } from "./chrome/UsageFooter"; import { useProjectBranches } from "./hooks/useProjectBranches"; import { + loadAllProjectsView, loadProjectRailOpen, loadSidebarTabOrder, + saveAllProjectsView, saveProjectRailOpen, type SidebarTabId, } from "./lib/appearance"; @@ -209,6 +211,7 @@ import { import { removeProjectData } from "./lib/projectData"; import { archiveProject, + collectRailProjects, forgetProject, lastProjectPath, loadRecents, @@ -366,6 +369,7 @@ import { mergeHistorySummary, mergeProjectHistorySummary, replaceProjectHistory, + historyAcrossProjects, historyWithLiveSessions, summaryFromSession, } from "./lib/sessionHistory"; @@ -627,7 +631,8 @@ export default function App({ [], ); const [projectRailOpen, setProjectRailOpen] = useState(loadProjectRailOpen); - const tabCloseScope = "project" as const; + const [allProjectsView, setAllProjectsView] = useState(loadAllProjectsView); + const tabCloseScope = allProjectsView ? "workspace" : "project"; const currentProjectDock = findProjectTerminal(projectTerminals, projectCwd); const dockVisible = !!currentProjectDock?.open; const [sidebarTab, setSidebarTab] = useState( @@ -942,6 +947,10 @@ export default function App({ projectCwd; const sidebarCwdRef = useRef(sidebarCwd); sidebarCwdRef.current = sidebarCwd; + /** A project not listed yet stays out, or its one row would pass for a full list. */ + const isCachedHistoryCwd = (cwd: string) => + cwd === sidebarCwdRef.current || + loadedProjectsRef.current.has(normalizeProjectPath(cwd)); const sidebarCwdKey = sidebarCwd && sidebarCwd !== "~" ? normalizeProjectPath(sidebarCwd) : null; const historyFailed = @@ -1169,6 +1178,30 @@ export default function App({ void refreshHistory(sidebarCwd); }, [sidebarCwd, refreshHistory]); + const railProjectPaths = useMemo( + () => + [...collectRailProjects(recents, sidebarCwd).values()].map((p) => p.path), + [recents, sidebarCwd], + ); + + // The all-projects list needs every rail project's rows, not only the ones + // visited so far this run. + useEffect(() => { + if (!allProjectsView) return; + for (const path of railProjectPaths) { + const key = normalizeProjectPath(path); + if (loadedProjectsRef.current.has(key)) continue; + void listSessionsByProject(path) + .then((rows) => { + setHistory((current) => replaceProjectHistory(current, path, rows)); + setLoadedProjects((prev) => + prev.has(key) ? prev : new Set(prev).add(key), + ); + }) + .catch(() => undefined); + } + }, [allProjectsView, railProjectPaths]); + useEffect(() => { if (!inboxViewOpen) return; let cancelled = false; @@ -1200,7 +1233,7 @@ export default function App({ .then((summary) => { if (!summary) return; lastPersisted.current.set(session.id, fingerprint); - if (summary.cwd === sidebarCwdRef.current) { + if (isCachedHistoryCwd(summary.cwd)) { setHistory((current) => mergeProjectHistorySummary(current, summary)); } }) @@ -1260,7 +1293,7 @@ export default function App({ const summary = await upsertSession(session).catch(() => null); if (!summary) return; lastPersisted.current.set(session.id, fingerprint); - if (summary.cwd === sidebarCwdRef.current) { + if (isCachedHistoryCwd(summary.cwd)) { setHistory((current) => mergeProjectHistorySummary(current, summary), ); @@ -2316,30 +2349,28 @@ export default function App({ [onClosePane, onCloseTab, tabCloseScope], ); - const deckProjectTabs = useMemo(() => { + const stripTabs = useMemo(() => { + if (allProjectsView) return tabs; // A projectless session belongs to no project, so it stands on its own // rather than trailing the last project's tabs. const active = tabs.find((tab) => tab.id === activeTabId); if (active && !workspaceTabCwd(active, sessions)) return [active]; return filterTabsForProject(tabs, sessions, projectCwd); - }, [activeTabId, tabs, sessions, projectCwd]); + }, [activeTabId, allProjectsView, tabs, sessions, projectCwd]); const onNext = useCallback(() => { - const index = deckProjectTabs.findIndex((t) => t.id === activeTabId); - if (index >= 0) - activateTab(deckProjectTabs[(index + 1) % deckProjectTabs.length].id); - }, [activateTab, activeTabId, deckProjectTabs]); + const index = stripTabs.findIndex((t) => t.id === activeTabId); + if (index >= 0) activateTab(stripTabs[(index + 1) % stripTabs.length].id); + }, [activateTab, activeTabId, stripTabs]); const onPrev = useCallback(() => { - const index = deckProjectTabs.findIndex((t) => t.id === activeTabId); + const index = stripTabs.findIndex((t) => t.id === activeTabId); if (index >= 0) { activateTab( - deckProjectTabs[ - (index - 1 + deckProjectTabs.length) % deckProjectTabs.length - ].id, + stripTabs[(index - 1 + stripTabs.length) % stripTabs.length].id, ); } - }, [activateTab, activeTabId, deckProjectTabs]); + }, [activateTab, activeTabId, stripTabs]); const onVisitBack = useCallback(() => { const openIds = new Set(tabsRef.current.map((tab) => tab.id)); @@ -2371,13 +2402,10 @@ export default function App({ const onActivate = useCallback( (slot: number) => { - const tab = - slot < 0 - ? deckProjectTabs[deckProjectTabs.length - 1] - : deckProjectTabs[slot]; + const tab = slot < 0 ? stripTabs[stripTabs.length - 1] : stripTabs[slot]; if (tab) activateTab(tab.id); }, - [activateTab, deckProjectTabs], + [activateTab, stripTabs], ); const onFocusPane = useCallback( @@ -2549,15 +2577,11 @@ export default function App({ leafIds(entry.layout).includes(sessionId), ); if (!tab) return false; - setActiveTabId(tab.id); - setTabs((prev) => - prev.map((entry) => - entry.id === tab.id ? { ...entry, focusedId: sessionId } : entry, - ), - ); - setComposerFocused(true); + // Goes through activateTab so the current project follows a chat picked + // from another project (all-projects list, working agents). + activateTab(tab.id, sessionId); return true; - }, []); + }, [activateTab]); const replaceBlankPaneWithSession = useCallback((session: Session) => { const tab = @@ -2746,6 +2770,14 @@ export default function App({ if (focusOpenSession(sessionId)) return; const session = await ensureOpenSession(sessionId); if (!session || session.inboxAsk) return; + // The all-projects list offers other projects' chats too. + if ( + looksLikeProject(session.cwd) && + !sameProjectPath(session.cwd, projectCwdRef.current) + ) { + setProjectCwd(normalizeProjectPath(session.cwd)); + setRecents(rememberProject(session.cwd)); + } if (replaceBlankPaneWithSession(session)) return; const tab = newTab(session.id); appendTab(tab, session.cwd); @@ -3343,6 +3375,16 @@ export default function App({ [activateTab, appendTab, onCwdChange, readProjectReturnMemory], ); + const onAllProjectsViewChange = useCallback((all: boolean) => { + if (all) { + setSearchViewOpen(false); + setInboxViewOpen(false); + setNotesViewOpen(false); + } + setAllProjectsView(all); + saveAllProjectsView(all); + }, []); + const pickProject = useCallback(async () => { const path = await pickFolder(); if (path) onSelectProject(path); @@ -4760,7 +4802,7 @@ export default function App({ [onOpenApprovalSession], ); - const nextTitleTabs: TitleTab[] = deckProjectTabs.map((tab) => + const nextTitleTabs: TitleTab[] = stripTabs.map((tab) => toTitleTab(tab, sessions, dirtyFiles), ); tabProjectsRef.current = new Map( @@ -4779,17 +4821,35 @@ export default function App({ [history, sidebarCwd], ); + const sidebarGitHint = useMemo( + () => ({ + ...(projectBranches?.current ? { branch: projectBranches.current } : {}), + ...(sidebarCwd && sidebarCwd !== "~" + ? { repo: projectName(sidebarCwd) } + : {}), + }), + [projectBranches, sidebarCwd], + ); const sidebarHistory = useMemo( () => - historyWithLiveSessions(history, sessions, sidebarCwd, { - ...(projectBranches?.current - ? { branch: projectBranches.current } - : {}), - ...(sidebarCwd && sidebarCwd !== "~" - ? { repo: projectName(sidebarCwd) } - : {}), - }), - [history, projectBranches, sessions, sidebarCwd], + allProjectsView + ? historyAcrossProjects(history, sessions, railProjectPaths, (cwd) => + sameProjectPath(cwd, sidebarCwd) ? sidebarGitHint : undefined, + ) + : historyWithLiveSessions( + history, + sessions, + sidebarCwd, + sidebarGitHint, + ), + [ + allProjectsView, + history, + railProjectPaths, + sessions, + sidebarCwd, + sidebarGitHint, + ], ); const inboxRelatedSessions = useMemo(() => { const byId = new Map(); @@ -4825,19 +4885,20 @@ export default function App({ sessions .filter( (session) => - !session.inboxAsk && sameProjectPath(session.cwd, sidebarCwd), + !session.inboxAsk && + (allProjectsView || sameProjectPath(session.cwd, sidebarCwd)), ) .map((session) => - summaryFromSession(session, { - ...(projectBranches?.current - ? { branch: projectBranches.current } - : {}), - ...(sidebarCwd && sidebarCwd !== "~" - ? { repo: projectName(sidebarCwd) } - : {}), - }), + summaryFromSession( + session, + sameProjectPath(session.cwd, sidebarCwd) + ? sidebarGitHint + : looksLikeProject(session.cwd) + ? { repo: projectName(session.cwd) } + : undefined, + ), ), - [projectBranches, sessions, sidebarCwd], + [allProjectsView, sessions, sidebarCwd, sidebarGitHint], ); const onToggleSidebar = useCallback(() => { @@ -5518,6 +5579,8 @@ export default function App({ onSelectProject={onSelectProject} onOpenProject={pickProject} onRemoveProject={onRemoveProject} + allProjectsView={allProjectsView} + onAllProjectsViewChange={onAllProjectsViewChange} onNew={onNew} openSessions={openProjectSessions} onNewTerminal={onNewTerminal} @@ -5614,6 +5677,7 @@ export default function App({ onGoToFile={onGoToFile} recents={recents} onSelectProject={onSelectProject} + showProject={allProjectsView} />
diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx index af318525..32ef526f 100644 --- a/src/chrome/Composer.tsx +++ b/src/chrome/Composer.tsx @@ -52,7 +52,7 @@ import { type MentionIndex, type MentionToken, } from "../lib/fileMentions"; -import type { ProjectFile } from "../lib/fs"; +import { basename, type ProjectFile } from "../lib/fs"; import { composeInboxMessage, type InboxComposerCard, @@ -90,6 +90,7 @@ import { ContextMeter } from "./ContextMeter"; import { AttachmentChip } from "./AttachmentChip"; import { BranchPicker } from "./BranchPicker"; import { CwdPicker } from "./CwdPicker"; +import { ProjectLogoIcon } from "./ProjectLogoIcon"; import { FileMentionPicker } from "./FileMentionPicker"; import { FileTypeIcon } from "./FileTypeIcon"; import { InboxMiniCard } from "./InboxMiniCard"; @@ -1277,17 +1278,6 @@ export function Composer({ ) : null} {hideTopBar ? null : (
- {hideProjectPicker ? null : ( - ref.current?.focus()} - /> - )} {hideBranchPicker ? null : (
+ {hideProjectPicker ? null : ( + ref.current?.focus()} + > + + + {looksLikeProject(cwd) ? basename(cwd) : "Choose project"} + + + )} { + 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(); +}); + +const recents = Array.from({ length: 30 }, (_, index) => ({ + path: `/Users/me/code/project-${index}`, + openedAt: 30 - index, +})); + +function menuItems(label: string): HTMLButtonElement[] { + const menu = document.querySelector(`[role="menu"][aria-label="${label}"]`); + return [ + ...(menu?.querySelectorAll('[role="menuitem"]') ?? []), + ]; +} + +describe("cwd picker", () => { + it("keeps a long project list to five rows and scrolls the rest", () => { + const onCwdChange = vi.fn(); + act(() => + root.render( + createElement(CwdPicker, { + cwd: recents[0].path, + recents, + pill: true, + onCwdChange, + }), + ), + ); + act(() => container.querySelector("button")!.click()); + + const rows = menuItems("Project picker"); + // The current project is left out, then five recents and "More Projects". + expect(rows.map((row) => row.textContent)).toEqual([ + expect.stringContaining("project-1"), + expect.stringContaining("project-2"), + expect.stringContaining("project-3"), + expect.stringContaining("project-4"), + expect.stringContaining("project-5"), + "More Projects", + ]); + + act(() => { + rows[5].dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + }); + const submenu = document.querySelector( + '[role="menu"][aria-label="More projects"]', + )!; + expect(submenu.className).toContain("overflow-y-auto"); + const more = menuItems("More projects"); + expect(more).toHaveLength(24); + + act(() => more[23].click()); + expect(onCwdChange).toHaveBeenCalledWith("/Users/me/code/project-29"); + }); +}); diff --git a/src/chrome/CwdPicker.tsx b/src/chrome/CwdPicker.tsx index e9b0f563..53f624f9 100644 --- a/src/chrome/CwdPicker.tsx +++ b/src/chrome/CwdPicker.tsx @@ -29,6 +29,8 @@ type Props = { buttonClassName?: string; /** Chevron on the trailing edge; flips when the menu is open. */ chevron?: boolean; + /** Filled chip matching the composer's model and access pickers. */ + pill?: boolean; children?: ReactNode; onCwdChange: (path: string) => void; onNewTerminal?: () => void; @@ -58,6 +60,7 @@ export function CwdPicker({ className, buttonClassName, chevron = false, + pill = false, children, onCwdChange, onNewTerminal, @@ -204,13 +207,15 @@ export function CwdPicker({ }} onKeyDown={onKeyDown} className={ - buttonClassName - ? `${buttonClassName} ${ - open ? "bg-content/10 text-content" : "hover:bg-content/5" - } disabled:opacity-40` - : `flex min-w-0 items-center gap-1.5 ${ - open ? "text-content" : "text-content/50 hover:text-content" - } disabled:opacity-40` + pill + ? "flex h-6.5 min-w-0 max-w-40 items-center gap-1 rounded-md bg-content/10 px-1.5 text-content hover:bg-content/15 disabled:opacity-40" + : buttonClassName + ? `${buttonClassName} ${ + open ? "bg-content/10 text-content" : "hover:bg-content/5" + } disabled:opacity-40` + : `flex min-w-0 items-center gap-1.5 ${ + open ? "text-content" : "text-content/50 hover:text-content" + } disabled:opacity-40` } > {children ?? ( diff --git a/src/chrome/ProjectRail.tsx b/src/chrome/ProjectRail.tsx index e690f6e4..e45e1a80 100644 --- a/src/chrome/ProjectRail.tsx +++ b/src/chrome/ProjectRail.tsx @@ -11,12 +11,20 @@ import { Pin, PinOff, File, + Folders, Plus, Search, Settings, Trash2, } from "./icons"; -import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type MouseEvent, + type ReactNode, +} from "react"; import { useDragResize } from "../hooks/useDragResize"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; import { useProjectDiffStats } from "../hooks/useProjectDiffStats"; @@ -125,6 +133,9 @@ type Props = { onSelectProject: (path: string) => void; onOpenProject: () => void; onRemoveProject?: (path: string, options: { purgeData: boolean }) => void; + /** Title bar shows every project's tabs instead of the selected one's. */ + allProjectsView?: boolean; + onAllProjectsViewChange?: (all: boolean) => void; liveAgents?: LiveAgent[]; activeSessionId?: string; onSelectAgent?: (sessionId: string) => void; @@ -158,6 +169,8 @@ export function ProjectRail({ onSelectProject, onOpenProject, onRemoveProject, + allProjectsView = false, + onAllProjectsViewChange, liveAgents = [], activeSessionId, onSelectAgent, @@ -356,16 +369,22 @@ export function ProjectRail({ setRemoving(null); }; + const selectProject = (path: string) => { + onAllProjectsViewChange?.(false); + onSelectProject(path); + }; + const pinnedIds = sections.pinned.map((item) => item.path); const projectIds = sections.projects.map((item) => item.path); const pinnedSortable = useSortable(pinnedIds, onReorderPinned, { axis: "y", - onActivate: onSelectProject, + onActivate: selectProject, }); const projectSortable = useSortable(projectIds, onReorderProjects, { axis: "y", - onActivate: onSelectProject, + onActivate: selectProject, }); + const overlayActive = searchActive || inboxActive || notesActive; return (
+ {leading} {items.length === 0 && emptyLabel ? (

{emptyLabel} @@ -847,7 +878,7 @@ function ProjectSection({ void; +}) { + const label = "All projects"; + return ( +

+ +
+ ); +} + function ProjectCard({ item, selected, diff --git a/src/chrome/Sidebar.tsx b/src/chrome/Sidebar.tsx index 92695e4c..929da08a 100644 --- a/src/chrome/Sidebar.tsx +++ b/src/chrome/Sidebar.tsx @@ -236,6 +236,8 @@ type Props = { onSelectProject?: (path: string) => void; onOpenProject?: () => void; onRemoveProject?: (path: string, options: { purgeData: boolean }) => void; + allProjectsView?: boolean; + onAllProjectsViewChange?: (all: boolean) => void; onNew?: () => string | void; onNewTerminal?: () => void; onSearch?: () => void; @@ -314,6 +316,8 @@ function SidebarComponent({ onSelectProject, onOpenProject, onRemoveProject, + allProjectsView, + onAllProjectsViewChange, onNew, onSearch, onOpenInbox, @@ -1668,6 +1672,8 @@ function SidebarComponent({ onSelectProject={onSelectProject} onOpenProject={onOpenProject} onRemoveProject={onRemoveProject} + allProjectsView={allProjectsView} + onAllProjectsViewChange={onAllProjectsViewChange} settingsOpen={settingsOpen} settingsSection={settingsSection} onOpenSettings={onOpenSettings} diff --git a/src/chrome/TitleBar.test.ts b/src/chrome/TitleBar.test.ts index 0093f2e1..2eb8a7e6 100644 --- a/src/chrome/TitleBar.test.ts +++ b/src/chrome/TitleBar.test.ts @@ -82,11 +82,28 @@ describe("tabCopy", () => { expect(copy.headline).toBe("New session"); expect(copy.meta).toBe(""); }); + + it("leads the meta line with the project when asked", () => { + const single = tabCopy(tab({ title: "Only chat" }), true); + expect(single.meta).toBe("agent-terminal"); + + const split = tabCopy( + tab({ multiPane: true, title: "Chat", files: ["a.ts"] }), + true, + ); + expect(split.meta).toBe("agent-terminal · a.ts"); + + const projectless = tabCopy(tab({ project: "~", title: "Chat" }), true); + expect(projectless.meta).toBe(""); + }); }); describe("tabStripOverflow", () => { it("hides both chevrons when the strip fits", () => { - expect(tabStripOverflow(0, 400, 400)).toEqual({ left: false, right: false }); + expect(tabStripOverflow(0, 400, 400)).toEqual({ + left: false, + right: false, + }); }); it("shows only the right chevron at the start", () => { @@ -94,11 +111,17 @@ describe("tabStripOverflow", () => { }); it("shows both chevrons in the middle", () => { - expect(tabStripOverflow(200, 400, 800)).toEqual({ left: true, right: true }); + expect(tabStripOverflow(200, 400, 800)).toEqual({ + left: true, + right: true, + }); }); it("shows only the left chevron at the end", () => { - expect(tabStripOverflow(400, 400, 800)).toEqual({ left: true, right: false }); + expect(tabStripOverflow(400, 400, 800)).toEqual({ + left: true, + right: false, + }); }); }); diff --git a/src/chrome/TitleBar.tsx b/src/chrome/TitleBar.tsx index 517fd095..e26c1621 100644 --- a/src/chrome/TitleBar.tsx +++ b/src/chrome/TitleBar.tsx @@ -82,6 +82,8 @@ type Props = { onGoToFile?: () => void; recents?: RecentProject[]; onSelectProject?: (path: string) => void; + /** Tabs from several projects share the strip, so each names its project. */ + showProject?: boolean; }; function sessionMeta(tab: Tab): string { @@ -90,7 +92,10 @@ function sessionMeta(tab: Tab): string { return ""; } -export function tabCopy(tab: Tab): { +export function tabCopy( + tab: Tab, + showProject = false, +): { headline: string; meta: string; tooltip: string; @@ -125,6 +130,7 @@ export function tabCopy(tab: Tab): { if (sessions) metaParts.push(sessions); } + if (showProject && project !== "~") metaParts.unshift(project); const meta = metaParts.join(" · "); const tooltipParts = [project]; @@ -222,6 +228,7 @@ function TitleTabItem({ active, closable, canDrag, + showProject, sortable, onSelect, onClose, @@ -232,6 +239,7 @@ function TitleTabItem({ index: number; active: boolean; closable: boolean; + showProject: boolean; canDrag: boolean; sortable: SortableApi; onSelect: (id: string) => void; @@ -240,7 +248,7 @@ function TitleTabItem({ itemRef?: (el: HTMLDivElement | null) => void; }) { const dragging = canDrag && sortable.draggingId === tab.id; - const { headline, meta, tooltip } = tabCopy(tab); + const { headline, meta, tooltip } = tabCopy(tab, showProject); const fileIcon = tab.files[0]; const showStart = canDrag && @@ -558,6 +566,7 @@ function TitleBarComponent({ onGoToFile, recents = [], onSelectProject, + showProject = false, }: Props) { const tabIds = tabs.map((tab) => tab.id); const sortable = useSortable(tabIds, onReorder); @@ -829,6 +838,7 @@ function TitleBarComponent({ active={tab.id === activeId} closable={titleTabClosable(tab, tabs.length)} canDrag={canDrag} + showProject={showProject} sortable={sortable} onSelect={onSelect} onClose={onClose} diff --git a/src/chrome/icons.tsx b/src/chrome/icons.tsx index 4d1b9a04..7f1620dd 100644 --- a/src/chrome/icons.tsx +++ b/src/chrome/icons.tsx @@ -42,6 +42,7 @@ import Folder01Icon from "@hugeicons/core-free-icons/Folder01Icon"; import FolderAddIcon from "@hugeicons/core-free-icons/FolderAddIcon"; import FolderOpenIcon from "@hugeicons/core-free-icons/FolderOpenIcon"; import FolderTreeIcon from "@hugeicons/core-free-icons/FolderTreeIcon"; +import FoldersIcon from "@hugeicons/core-free-icons/FoldersIcon"; import GaugeIcon from "@hugeicons/core-free-icons/GaugeIcon"; import GitBranchIcon from "@hugeicons/core-free-icons/GitBranchIcon"; import GitCompareIcon from "@hugeicons/core-free-icons/GitCompareIcon"; @@ -175,6 +176,7 @@ export const Folder = wrap(Folder01Icon, "Folder"); export const FolderOpen = wrap(FolderOpenIcon, "FolderOpen"); export const FolderPlus = wrap(FolderAddIcon, "FolderPlus"); export const FolderTree = wrap(FolderTreeIcon, "FolderTree"); +export const Folders = wrap(FoldersIcon, "Folders"); export const Gauge = wrap(GaugeIcon, "Gauge"); export const GitBranch = wrap(GitBranchIcon, "GitBranch"); export const GitCompare = wrap(GitCompareIcon, "GitCompare"); diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts index eb6dfaf9..a54f8377 100644 --- a/src/lib/appearance.ts +++ b/src/lib/appearance.ts @@ -7,6 +7,7 @@ const THEME_SATURATION_KEY = "monocode.themeSaturation"; const OPACITY_KEY = "monocode.sidebarOpacity"; const BLUR_KEY = "monocode.sidebarBlur"; const PROJECT_RAIL_OPEN_KEY = "monocode.projectRailOpen"; +const ALL_PROJECTS_VIEW_KEY = "monocode.allProjectsView"; const BODY_KEY = "monocode.bodyGlass"; const SCHEME_KEY = "monocode.colorScheme"; const SIDEBAR_TAB_ORDER_KEY = "monocode.sidebarTabOrder"; @@ -437,6 +438,14 @@ export function saveProjectRailOpen(value: boolean) { writeFlag(PROJECT_RAIL_OPEN_KEY, value); } +export function loadAllProjectsView(): boolean { + return readFlag(ALL_PROJECTS_VIEW_KEY) ?? false; +} + +export function saveAllProjectsView(value: boolean) { + writeFlag(ALL_PROJECTS_VIEW_KEY, value); +} + export function loadSidebarTabOrder(): SidebarTabId[] { try { const raw = localStorage.getItem(SIDEBAR_TAB_ORDER_KEY); diff --git a/src/lib/sessionHistory.test.ts b/src/lib/sessionHistory.test.ts index 7a8505e3..ec197d93 100644 --- a/src/lib/sessionHistory.test.ts +++ b/src/lib/sessionHistory.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + historyAcrossProjects, historyWithLiveSessions, filterSessionsByArchive, filterSessionsByQuery, @@ -126,6 +127,26 @@ describe("historyWithLiveSessions", () => { }); }); +describe("historyAcrossProjects", () => { + it("merges the listed projects newest first and skips the rest", () => { + const history = [ + summary("a1", "/tmp/project-a", 1), + summary("b1", "/tmp/project-b", 3), + summary("c1", "/tmp/project-c", 5), + ]; + const live = newSession("cursor", "/tmp/project-b"); + live.blocks = [{ id: "u1", role: "user", text: "hello" }]; + live.busy = true; + + const rows = historyAcrossProjects( + history, + [live], + ["/tmp/project-a", "/tmp/project-b"], + ); + expect(rows.map((row) => row.id)).toEqual([live.id, "b1", "a1"]); + }); +}); + describe("filterSessionsByArchive", () => { it("hides archived sessions by default", () => { const rows = [ diff --git a/src/lib/sessionHistory.ts b/src/lib/sessionHistory.ts index 59fffd7d..2782b3e5 100644 --- a/src/lib/sessionHistory.ts +++ b/src/lib/sessionHistory.ts @@ -1,5 +1,5 @@ import { fuzzyMatch } from "./fuzzy"; -import { projectName } from "./paths"; +import { pathKey, projectName } from "./paths"; import { sameProjectPath } from "./recents"; import { sessionDisplayTitle, sessionNeedsInput, type Session } from "./session"; import { shouldPersistSession, type SessionSummary } from "./sessionStore"; @@ -155,3 +155,31 @@ export function historyWithLiveSessions( } return [...rows].sort(compareSessionSummaries); } + +/** `historyWithLiveSessions` for several projects, merged into one list. */ +export function historyAcrossProjects( + history: SessionSummary[], + sessions: Session[], + cwds: Iterable, + gitFor?: (cwd: string) => SessionGitHint | undefined, +): SessionSummary[] { + // One pass over `history`: filtering it once per project made each render + // cost projects × rows, and this reruns on every streamed update. + const byProject = new Map(); + for (const cwd of cwds) byProject.set(pathKey(cwd), { cwd, rows: [] }); + for (const entry of history) { + byProject.get(pathKey(entry.cwd))?.rows.push(entry); + } + const rows: SessionSummary[] = []; + for (const project of byProject.values()) { + rows.push( + ...historyWithLiveSessions( + project.rows, + sessions, + project.cwd, + gitFor?.(project.cwd), + ), + ); + } + return rows.sort(compareSessionSummaries); +} diff --git a/src/surfaces/SessionPane.tsx b/src/surfaces/SessionPane.tsx index 5c798a4b..09c5e8e6 100644 --- a/src/surfaces/SessionPane.tsx +++ b/src/surfaces/SessionPane.tsx @@ -295,7 +295,11 @@ export const SessionPane = memo(function SessionPane({ }, [addSelectionToChat, addToChatTarget]); const workCwd = sessionWorkCwd(session); const isEmpty = session.blocks.length === 0; - const showDeckProjectPicker = isEmpty && !looksLikeProject(session.cwd); + // A fresh session can still move to another project; once it has a turn, + // switching opens a new tab instead (see onCwdChange). Split panes share + // their tab's project, so only a projectless one may pick. + const showProjectPicker = + isEmpty && (!inSplit || !looksLikeProject(session.cwd)); const dockComposer = !isEmpty || inSplit || !!session.inboxAsk; const draftRef = useRef(undefined); const composer = ( @@ -314,8 +318,7 @@ export const SessionPane = memo(function SessionPane({ compactSupported={canCompactHarnessContext(session.harness)} recents={recents} hideProjectPicker={ - !!session.inboxAsk || - (hideProjectPicker ? !showDeckProjectPicker : false) + !!session.inboxAsk || (hideProjectPicker ? !showProjectPicker : false) } hideBranchPicker={!!session.inboxAsk} hideTopBar={!!session.inboxAsk}