diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index 92d2f8430d..5ce7238da7 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -1434,7 +1434,7 @@ export async function handleCommand( const result = await backend.updateSession({ sessionId, metadata: merged }); if (!result.updated) throw new Error(`Session ${sessionId} not found`); // Annotating a session is not session activity. updateSession stamps - // updated_at, which clients sort and label rows by, so a favorite would + // updated_at, which clients sort and label rows by, so a pin would // otherwise make an old session look like it just ran. if (existing?.updatedAt) { store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [ diff --git a/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx b/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx index fcbb2801ee..42b44d61f3 100644 --- a/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx +++ b/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx @@ -47,13 +47,14 @@ function makeSessionHistory( loadOlderSessions?: ReturnType; mayHaveMoreSessions?: boolean; hasLoadedHistory?: boolean; + isLoadingMore?: boolean; } = {}, ): UseSessionHistoryResult { return { deleteThread: vi.fn(), forkThread: vi.fn(), hasLoadedHistory: options.hasLoadedHistory ?? true, - isLoadingMore: false, + isLoadingMore: options.isLoadingMore ?? false, loadAllSessions: vi.fn(async () => true), loadOlderSessions: options.loadOlderSessions ?? vi.fn(), loadMoreSessions, @@ -178,7 +179,7 @@ afterEach(async () => { }); describe("AgentSidebar session organization", () => { - it("filters scheduled sessions without changing their titles", async () => { + it("groups scheduled sessions into a collapsible Scheduled section", async () => { const scheduled = { ...makeThread("scheduled", 1), source: "core", @@ -203,23 +204,186 @@ describe("AgentSidebar session organization", () => { }); expect(sessionIsVisible("scheduled session 1")).toBe(true); - expect(container.textContent).not.toContain("(schedule)"); + expect(sessionIsVisible("regular session 1")).toBe(true); + const scheduledHeader = buttonWithText("Scheduled"); + const tasksHeader = buttonWithText("Tasks"); + expect(scheduledHeader.getAttribute("aria-expanded")).toBe("true"); + expect(tasksHeader.getAttribute("aria-expanded")).toBe("true"); + // The scheduled and pinned categories replaced the old filter options. await click( container.querySelector('[aria-label="Filter sessions"]') as Element, ); - expect(document.body.textContent).not.toContain("Recent"); - const schedulesOption = await vi.waitFor(() => { - const option = [ - ...document.querySelectorAll('[role="menuitemradio"]'), - ].find((candidate) => candidate.textContent?.includes("Schedules")); - expect(option).toBeDefined(); - return option as HTMLElement; + await vi.waitFor(() => { + expect( + document.querySelectorAll('[role="menuitemradio"]').length, + ).toBeGreaterThan(0); }); - await click(schedulesOption); + const filterOptionLabels = [ + ...document.querySelectorAll('[role="menuitemradio"]'), + ].map((option) => option.textContent); + expect(filterOptionLabels).not.toContain("Schedules"); + expect(filterOptionLabels).not.toContain("Favorites"); - expect(sessionIsVisible("scheduled session 1")).toBe(true); - expect(sessionIsVisible("regular session 1")).toBe(false); + await click(scheduledHeader); + expect(sessionIsVisible("scheduled session 1")).toBe(false); + expect(sessionIsVisible("regular session 1")).toBe(true); + }); + + it("shows pinned sessions in a Pinned section ahead of Tasks", async () => { + const pinned = { ...makeThread("pinned", 1), pinned: true }; + const regular = makeThread("regular", 1); + + await act(async () => { + root.render( + + + , + ); + }); + + expect(sessionIsVisible("pinned session 1")).toBe(true); + expect(sessionIsVisible("regular session 1")).toBe(true); + expect(container.querySelector('[aria-label="Pinned"]')).not.toBeNull(); + const pinnedHeader = buttonWithText("Pinned"); + const tasksHeader = buttonWithText("Tasks"); + expect( + pinnedHeader.compareDocumentPosition(tasksHeader) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + + await click(pinnedHeader); + expect(sessionIsVisible("pinned session 1")).toBe(false); + expect(sessionIsVisible("regular session 1")).toBe(true); + }); + + it("keeps growing history when a fetch adds no tasks and more remain", async () => { + const scheduled = Array.from({ length: 8 }, (_, index) => ({ + ...makeThread("cron", index + 1), + isScheduled: true, + })); + const tasks = Array.from({ length: 5 }, (_, index) => + makeThread("plain", index + 1), + ); + const loadOlderSessions = vi.fn(async () => true); + const renderSidebar = async (isLoadingMore: boolean) => { + await act(async () => { + root.render( + + + , + ); + }); + }; + + await renderSidebar(false); + expect(loadOlderSessions).not.toHaveBeenCalled(); + + // Five loaded tasks cannot fill the requested page of 20, so the + // page-fill effect grows the history window. + await click(buttonWithText("Show more")); + expect(loadOlderSessions).toHaveBeenCalledOnce(); + + // Simulate that fetch settling without adding any tasks (the batch was + // all scheduled sessions): with more history remaining, the effect + // asks again instead of leaving the click without visible progress. + await renderSidebar(true); + await renderSidebar(false); + expect(loadOlderSessions).toHaveBeenCalledTimes(2); + }); + + it("halts page-fill retries after a failed fetch until the next click", async () => { + const tasks = Array.from({ length: 5 }, (_, index) => + makeThread("plain", index + 1), + ); + const loadOlderSessions = vi.fn(async () => false); + const renderSidebar = async (isLoadingMore: boolean) => { + await act(async () => { + root.render( + + + , + ); + }); + }; + + await renderSidebar(false); + await click(buttonWithText("Show more")); + expect(loadOlderSessions).toHaveBeenCalledOnce(); + + // The failed fetch settles with nothing changed; retrying automatically + // would loop the same failing request and re-toast the error forever. + await renderSidebar(true); + await renderSidebar(false); + expect(loadOlderSessions).toHaveBeenCalledOnce(); + + // An explicit click clears the halt and retries. + await click(buttonWithText("Show more")); + expect(loadOlderSessions).toHaveBeenCalledTimes(2); + }); + + it("keeps a flat session list when nothing is pinned or scheduled", async () => { + await act(async () => { + root.render( + + + , + ); + }); + + expect(sessionIsVisible("plain session 1")).toBe(true); + expect(container.textContent).not.toContain("Pinned"); + expect(container.textContent).not.toContain("Scheduled"); + expect(container.textContent).not.toContain("Tasks"); }); it("defaults to all sources and filters by the selected client source", async () => { @@ -382,9 +546,18 @@ describe("AgentSidebar session organization", () => { expect(sessionIsVisible("alpha session 11")).toBe(false); expect(sessionIsVisible("beta session 1")).toBe(false); + // The first page grows purely from already-loaded sessions (24 loaded, + // 20 requested), so no history fetch is needed. await click(buttonWithText("Show more")); expect(sessionIsVisible("alpha session 11")).toBe(true); - expect(loadMoreSessions).toHaveBeenCalledWith(20); + expect(loadMoreSessions).not.toHaveBeenCalled(); + expect(loadOlderSessions).not.toHaveBeenCalled(); + + // The next page (30) exceeds the 24 loaded sessions, so the whole + // history window grows. + await click(buttonWithText("Show more")); + expect(sessionIsVisible("beta session 12")).toBe(true); + expect(loadOlderSessions).toHaveBeenCalledOnce(); await click( container.querySelector('[aria-label="Sort sessions: Time"]') as Element, @@ -413,7 +586,7 @@ describe("AgentSidebar session organization", () => { expect(sessionIsVisible("beta session 11")).toBe(false); await click(buttonWithText("Load older projects")); - expect(loadOlderSessions).toHaveBeenCalledOnce(); + expect(loadOlderSessions).toHaveBeenCalledTimes(2); }); it("shows the signed-in account and active organization in the footer", async () => { diff --git a/apps/examples/desktop-app/webview/components/agent-sidebar.tsx b/apps/examples/desktop-app/webview/components/agent-sidebar.tsx index c1b872e50c..bdc6aaf013 100644 --- a/apps/examples/desktop-app/webview/components/agent-sidebar.tsx +++ b/apps/examples/desktop-app/webview/components/agent-sidebar.tsx @@ -17,13 +17,13 @@ import { Loader2, PanelLeftOpen, Pencil, + Pin, Plug, Plus, Radio, Search, Settings, SlidersHorizontal, - Star, Store, Trash2, Wrench, @@ -114,9 +114,10 @@ import { cn } from "@/lib/utils"; type Thread = SessionThread; type AppView = "chat" | "sessions" | "settings"; -const filterOptions = ["All", "Running", "Schedules", "Favorites"] as const; +const filterOptions = ["All", "Running"] as const; type FilterOption = (typeof filterOptions)[number]; type SidebarSortMode = "time" | "project"; +type SessionCategory = "pinned" | "scheduled" | "tasks"; type DesktopProcessContext = { appVersion?: unknown; hub?: { @@ -257,7 +258,6 @@ export function AgentSidebar({ isLoadingMore, loadAllSessions, loadOlderSessions, - loadMoreSessions, mayHaveMoreSessions, openThread: openHistoryThread, pendingAction, @@ -274,6 +274,12 @@ export function AgentSidebar({ const [showMoreCount, setShowMoreCount] = useState( INITIAL_VISIBLE_THREAD_COUNT, ); + const [scheduledVisibleCount, setScheduledVisibleCount] = useState( + INITIAL_VISIBLE_THREAD_COUNT, + ); + const [collapsedSections, setCollapsedSections] = useState< + Set + >(() => new Set()); const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(""); const [deleteConfirmThread, setDeleteConfirmThread] = useState( @@ -329,16 +335,10 @@ export function AgentSidebar({ const sourceOptions = useMemo(() => getSessionSources(threads), [threads]); const filteredThreads = useMemo(() => { const filtered = filterSessionsBySource(threads, sourceFilter); - switch (filter) { - case "Running": - return filtered.filter((t) => t.status === "running"); - case "Schedules": - return filtered.filter((t) => t.isScheduled); - case "Favorites": - return filtered.filter((t) => t.pinned); - default: - return filtered; + if (filter === "Running") { + return filtered.filter((t) => t.status === "running"); } + return filtered; }, [filter, sourceFilter, threads]); const closeMobileSidebar = useCallback(() => { if (isMobile) setOpenMobile(false); @@ -418,7 +418,7 @@ export function AgentSidebar({ [forkHistoryThread], ); - const toggleFavorite = useCallback( + const togglePinned = useCallback( async (thread: Thread) => { await setThreadPinned(thread.id, !thread.pinned); }, @@ -441,25 +441,75 @@ export function AgentSidebar({ () => filteredThreads.filter((t) => t.pinned), [filteredThreads], ); - const sessionThreads = useMemo( - () => filteredThreads.filter((t) => !t.pinned), + const scheduledThreads = useMemo( + () => filteredThreads.filter((t) => !t.pinned && t.isScheduled), [filteredThreads], ); - const displayedThreads = useMemo( - () => - filter === "All" - ? [...pinnedThreads, ...sessionThreads.slice(0, showMoreCount)] - : [...pinnedThreads, ...sessionThreads].slice(0, showMoreCount), - [filter, pinnedThreads, sessionThreads, showMoreCount], + const taskThreads = useMemo( + () => filteredThreads.filter((t) => !t.pinned && !t.isScheduled), + [filteredThreads], ); + // Category headers only appear once there is something to categorize; + // a lone "Tasks" header over the whole list would be noise. + const showCategorySections = + pinnedThreads.length > 0 || scheduledThreads.length > 0; const showTimeShowMore = - sessionThreads.length > showMoreCount || + taskThreads.length > showMoreCount || (filter === "All" && mayHaveMoreSessions); + // A failed fetch leaves the task count and has-more state unchanged, which + // are exactly the conditions the page-fill effect fires on; without this + // halt it would retry a failing request (and re-toast the error) forever. + // The next explicit "Show more" click clears the halt to retry. + const pageFillFailedRef = useRef(false); + // A "Show more" click can outpace the loaded history: showMoreCount counts + // only Tasks rows while the backend limit counts all sessions, and a + // fetched batch can consist entirely of pinned or scheduled sessions. Keep + // growing the history window until the requested Tasks page fills or + // history runs out, so every click makes visible progress. The + // isLoadingMore dependency retriggers the check after each fetch settles. + useEffect(() => { + if ( + sortMode !== "time" || + filter !== "All" || + isLoadingMore || + !mayHaveMoreSessions || + showMoreCount <= INITIAL_VISIBLE_THREAD_COUNT || + taskThreads.length >= showMoreCount || + pageFillFailedRef.current + ) { + return; + } + void loadOlderSessions().then((loaded) => { + if (!loaded) { + pageFillFailedRef.current = true; + } + }); + }, [ + filter, + isLoadingMore, + loadOlderSessions, + mayHaveMoreSessions, + showMoreCount, + sortMode, + taskThreads.length, + ]); const projectGroups = useMemo( - () => groupThreadsByProject([...pinnedThreads, ...sessionThreads]), - [pinnedThreads, sessionThreads], + () => + groupThreadsByProject([ + ...pinnedThreads, + ...filteredThreads.filter((t) => !t.pinned), + ]), + [filteredThreads, pinnedThreads], ); + const toggleSection = useCallback((section: SessionCategory) => { + setCollapsedSections((current) => { + const next = new Set(current); + if (next.has(section)) next.delete(section); + else next.add(section); + return next; + }); + }, []); const toggleProject = useCallback((project: string) => { setCollapsedProjects((current) => { const next = new Set(current); @@ -490,11 +540,12 @@ export function AgentSidebar({ - Session type + Status { setFilter(value as FilterOption); setShowMoreCount(INITIAL_VISIBLE_THREAD_COUNT); + setScheduledVisibleCount(INITIAL_VISIBLE_THREAD_COUNT); setProjectVisibleCounts({}); }} value={filter} @@ -513,6 +564,7 @@ export function AgentSidebar({ onValueChange={(value) => { setSourceFilter(value); setShowMoreCount(INITIAL_VISIBLE_THREAD_COUNT); + setScheduledVisibleCount(INITIAL_VISIBLE_THREAD_COUNT); setProjectVisibleCounts({}); }} value={sourceFilter} @@ -578,7 +630,7 @@ export function AgentSidebar({ onEditTitleChange={setEditingTitle} onFork={() => void forkThread(thread)} onRename={() => startRenameThread(thread)} - onToggleFavorite={() => void toggleFavorite(thread)} + onTogglePin={() => void togglePinned(thread)} pendingAction={ pendingAction?.sessionId === thread.id ? pendingAction.action : null } @@ -586,6 +638,37 @@ export function AgentSidebar({ unread={unreadSessionIds.has(thread.id)} /> ); + const timeShowMoreButton = ( + + ); return ( <> @@ -831,41 +914,101 @@ export function AgentSidebar({ ) : ( <> - {sortMode === "time" - ? displayedThreads.map(threadItem) - : projectGroups.map((project) => { - const visibleCount = - projectVisibleCounts[project.id] ?? - INITIAL_VISIBLE_THREAD_COUNT; - return ( - toggleProject(project.id)} + {sortMode === "time" ? ( + showCategorySections ? ( + <> + {pinnedThreads.length > 0 ? ( + toggleSection("pinned")} > - {project.threads - .slice(0, visibleCount) + {pinnedThreads.map(threadItem)} + + ) : null} + {scheduledThreads.length > 0 ? ( + toggleSection("scheduled")} + > + {scheduledThreads + .slice(0, scheduledVisibleCount) .map(threadItem)} - {project.threads.length > visibleCount ? ( + {scheduledThreads.length > + scheduledVisibleCount ? ( ) : null} - - ); - })} + + ) : null} + {taskThreads.length > 0 || showTimeShowMore ? ( + toggleSection("tasks")} + > + {taskThreads + .slice(0, showMoreCount) + .map(threadItem)} + {showTimeShowMore ? timeShowMoreButton : null} + + ) : null} + + ) : ( + taskThreads.slice(0, showMoreCount).map(threadItem) + ) + ) : ( + projectGroups.map((project) => { + const visibleCount = + projectVisibleCounts[project.id] ?? + INITIAL_VISIBLE_THREAD_COUNT; + return ( + toggleProject(project.id)} + > + {project.threads + .slice(0, visibleCount) + .map(threadItem)} + {project.threads.length > visibleCount ? ( + + ) : null} + + ); + }) + )} {(sortMode === "time" - ? displayedThreads.length === 0 + ? filteredThreads.length === 0 : projectGroups.length === 0) && (
No sessions found in history. @@ -873,36 +1016,10 @@ export function AgentSidebar({ )} )} - {sortMode === "time" && showTimeShowMore && ( - - )} + {sortMode === "time" && + !showCategorySections && + showTimeShowMore && + timeShowMoreButton} {sortMode === "project" && filter === "All" && mayHaveMoreSessions && ( @@ -1083,6 +1200,44 @@ export function AgentSidebar({ ); } +function CategorySection({ + label, + count, + collapsed, + onToggle, + children, +}: { + label: string; + count: number; + collapsed: boolean; + onToggle: () => void; + children: ReactNode; +}) { + return ( +
+ + {!collapsed ? ( +
{children}
+ ) : null} +
+ ); +} + function ProjectSection({ label, collapsed, @@ -1126,7 +1281,7 @@ function ThreadItem({ onCommitRename, onEditTitleChange, onRename, - onToggleFavorite, + onTogglePin, onFork, onDelete, pendingAction, @@ -1141,7 +1296,7 @@ function ThreadItem({ onCommitRename: () => void; onEditTitleChange: (title: string) => void; onRename: () => void; - onToggleFavorite: () => void; + onTogglePin: () => void; onFork: () => void; onDelete: () => void; pendingAction: "rename" | "fork" | "delete" | null; @@ -1204,10 +1359,7 @@ function ThreadItem({ {thread.pinned ? ( - + ) : statusDotClass ? (