From a743a2249640e9885e64a5b97209e4d651d6a626 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Jul 2026 00:12:56 +0200 Subject: [PATCH] Keep large sidebars responsive (#1966) * perf(app): keep large sidebars responsive Sidebar rows rescanned every agent for each session update, multiplying work with both workspace and agent count. Build workspace activity once and share the derived sidebar model so updates scale with the underlying collections. * test(app): use valid agent fixture for workspace restore * refactor(app): keep sidebar model surface focused --- packages/app/src/app/_layout.tsx | 89 +++-------- packages/app/src/components/left-sidebar.tsx | 59 +++----- .../src/components/sidebar-workspace-list.tsx | 16 +- .../src/components/sidebar/sidebar-model.tsx | 76 ++++++++++ .../sidebar/sidebar-status-list.tsx | 26 +--- ...space-shortcut-targets-subscriber.test.tsx | 31 +++- .../workspace-shortcut-targets-subscriber.tsx | 49 +----- .../sidebar-workspaces-view-model.test.ts | 2 + .../hooks/sidebar-workspaces-view-model.ts | 41 +---- .../src/hooks/use-sidebar-workspaces-list.ts | 6 +- .../hooks/use-status-mode-workspaces.test.ts | 43 ++++-- .../src/hooks/use-status-mode-workspaces.ts | 8 +- packages/app/src/stores/session-store.ts | 12 +- .../restore-archived-workspace.test.ts | 45 ++++-- .../app/src/utils/sidebar-shortcuts.test.ts | 16 +- packages/app/src/utils/sidebar-shortcuts.ts | 9 +- .../utils/workspace-agent-activity.test.ts | 142 ++++++++++++++++++ .../app/src/utils/workspace-agent-activity.ts | 37 +++++ 18 files changed, 445 insertions(+), 262 deletions(-) create mode 100644 packages/app/src/components/sidebar/sidebar-model.tsx create mode 100644 packages/app/src/utils/workspace-agent-activity.test.ts create mode 100644 packages/app/src/utils/workspace-agent-activity.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index f7d07f4e0..af0568476 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -4,13 +4,7 @@ import { PortalProvider } from "@gorhom/portal"; import { QueryClientProvider } from "@tanstack/react-query"; import * as Linking from "expo-linking"; import * as Notifications from "expo-notifications"; -import { - Stack, - useGlobalSearchParams, - useNavigationContainerRef, - usePathname, - useRouter, -} from "expo-router"; +import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router"; import { createContext, type ReactNode, @@ -33,6 +27,7 @@ import { DownloadToast } from "@/components/download-toast"; import { QuittingOverlay } from "@/components/quitting-overlay"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { LeftSidebar } from "@/components/left-sidebar"; +import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host"; import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProviderSettingsHost } from "@/components/provider-settings-host"; @@ -93,12 +88,7 @@ import { usePanelStore } from "@/stores/panel-store"; import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; -import { - buildOpenProjectRoute, - parseHostAgentRouteFromPathname, - parseServerIdFromPathname, - parseWorkspaceOpenIntent, -} from "@/utils/host-routes"; +import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { @@ -401,17 +391,12 @@ const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y"; interface AppContainerProps { children: ReactNode; - selectedAgentId?: string; chromeEnabled?: boolean; } const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"]; -function AppContainer({ - children, - selectedAgentId, - chromeEnabled: chromeEnabledOverride, -}: AppContainerProps) { +function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) { const daemons = useHosts(); const { settings, updateSettings } = useAppSettings(); const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList); @@ -468,9 +453,7 @@ function AppContainer({ const workspaceChrome = ( - {!isCompactLayout && chromeEnabled && !isFocusModeEnabled && ( - - )} + {!isCompactLayout && chromeEnabled && !isFocusModeEnabled && } {isCompactLayout && chromeEnabled ? ( {children} @@ -482,23 +465,25 @@ function AppContainer({ ); const content = ( - - {workspaceChrome} - - {isCompactLayout && chromeEnabled && } - - - - - - - - - - - - - + + + {workspaceChrome} + + {isCompactLayout && chromeEnabled && } + + + + + + + + + + + + + + ); if (!isCompactLayout) { @@ -751,7 +736,6 @@ function OpenProjectListener() { function AppWithSidebar({ children }: { children: ReactNode }) { const pathname = usePathname(); - const params = useGlobalSearchParams<{ open?: string | string[] }>(); const hosts = useHosts(); const storeReady = useStoreReady(); const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); @@ -765,30 +749,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) { pathname === "/schedules" || routeHasKnownHost); - // Parse selectedAgentKey directly from pathname - // useLocalSearchParams doesn't update when navigating between same-pattern routes - const selectedAgentKey = useMemo(() => { - const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/); - const workspaceServerId = workspaceMatch?.[1]?.trim() ?? ""; - const openValue = Array.isArray(params.open) ? params.open[0] : params.open; - const openIntent = parseWorkspaceOpenIntent(openValue); - if (workspaceServerId && openIntent?.kind === "agent") { - const agentId = openIntent.agentId.trim(); - return agentId ? `${workspaceServerId}:${agentId}` : undefined; - } - - const match = parseHostAgentRouteFromPathname(pathname); - return match ? `${match.serverId}:${match.agentId}` : undefined; - }, [params.open, pathname]); - - return ( - - {children} - - ); + return {children}; } function FaviconStatusSync() { diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index b55f9c56e..fba5d8074 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -34,16 +34,12 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; -import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; import { useHostFeature } from "@/runtime/host-features"; -import { - type SidebarProjectEntry, - type SidebarStatusWorkspacePlacement, - useSidebarWorkspacesList, -} from "@/hooks/use-sidebar-workspaces-list"; -import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces"; -import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { type SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { useSidebarModel } from "@/components/sidebar/sidebar-model"; +import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; +import { type SidebarGroupMode } from "@/stores/sidebar-view-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHosts } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -73,25 +69,20 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list"; const MIN_CHAT_WIDTH = 400; -type SidebarShortcutModel = ReturnType; type SidebarTheme = ReturnType["theme"]; -interface LeftSidebarProps { - selectedAgentId?: string; -} - interface SidebarSharedProps { theme: SidebarTheme; - statusWorkspacePlacements: SidebarStatusWorkspacePlacement[]; + statusGroups: StatusGroup[]; projects: SidebarProjectEntry[]; projectNamesByKey: Map; isInitialLoad: boolean; isRevalidating: boolean; isManualRefresh: boolean; groupMode: SidebarGroupMode; - collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"]; - shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"]; - toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"]; + collapsedProjectKeys: ReadonlySet; + shortcutIndexByWorkspaceKey: Map; + toggleProjectCollapsed: (projectKey: string) => void; handleRefresh: () => void; handleOpenProject: () => void; handleHome: () => void; @@ -129,11 +120,7 @@ interface DesktopSidebarProps extends SidebarSharedProps { handleViewSchedules: () => void; } -export const LeftSidebar = memo(function LeftSidebar({ - selectedAgentId: _selectedAgentId, -}: LeftSidebarProps) { - void _selectedAgentId; - +export const LeftSidebar = memo(function LeftSidebar() { const { theme } = useUnistyles(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -144,22 +131,18 @@ export const LeftSidebar = memo(function LeftSidebar({ const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const { - workspacePlacements, projects, projectNamesByKey, isInitialLoad, isRevalidating, refreshAll, - } = useSidebarWorkspacesList({ - enabled: isCompactLayout || isOpen, - }); - const statusWorkspacePlacements = useStatusModeWorkspacePlacements({ - placements: workspacePlacements, - }); - const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } = - useSidebarShortcutModel({ projects }); - - const groupMode = useSidebarViewStore((state) => state.groupMode); + statusGroups, + collapsedProjectKeys, + toggleProjectCollapsed, + groupMode, + shortcutModel, + } = useSidebarModel(); + const { shortcutIndexByWorkspaceKey } = shortcutModel; const [isManualRefresh, setIsManualRefresh] = useState(false); @@ -250,7 +233,7 @@ export const LeftSidebar = memo(function LeftSidebar({ const sharedProps = { theme, - statusWorkspacePlacements, + statusGroups, projects, projectNamesByKey, isInitialLoad, @@ -548,7 +531,7 @@ function SidebarFooter({ function MobileSidebar({ theme, - statusWorkspacePlacements, + statusGroups, projects, projectNamesByKey, isInitialLoad, @@ -659,7 +642,7 @@ function MobileSidebar({ onToggleProjectCollapsed={toggleProjectCollapsed} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} groupMode={groupMode} - statusWorkspacePlacements={statusWorkspacePlacements} + statusGroups={statusGroups} projects={projects} projectNamesByKey={projectNamesByKey} isRefreshing={isManualRefresh && isRevalidating} @@ -686,7 +669,7 @@ function MobileSidebar({ function DesktopSidebar({ theme, - statusWorkspacePlacements, + statusGroups, projects, projectNamesByKey, isInitialLoad, @@ -811,7 +794,7 @@ function DesktopSidebar({ onToggleProjectCollapsed={toggleProjectCollapsed} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} groupMode={groupMode} - statusWorkspacePlacements={statusWorkspacePlacements} + statusGroups={statusGroups} projects={projects} projectNamesByKey={projectNamesByKey} isRefreshing={isManualRefresh && isRevalidating} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 6c1891d97..d7611099e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -68,7 +68,6 @@ import { shouldShowSidebarHostLabels, useSidebarWorkspaceEntry, type SidebarProjectEntry, - type SidebarStatusWorkspacePlacement, type SidebarWorkspaceEntry, type SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; @@ -93,6 +92,7 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list"; +import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, @@ -228,7 +228,7 @@ function selectionForSelectedWorkspace( } interface SidebarWorkspaceListProps { - statusWorkspacePlacements: SidebarStatusWorkspacePlacement[]; + statusGroups: StatusGroup[]; projects: SidebarProjectEntry[]; projectNamesByKey: Map; collapsedProjectKeys: ReadonlySet; @@ -2166,7 +2166,7 @@ function areProjectBlockSelectionsEqual( const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual); export function SidebarWorkspaceList({ - statusWorkspacePlacements, + statusGroups, projects, projectNamesByKey, collapsedProjectKeys, @@ -2196,7 +2196,7 @@ export function SidebarWorkspaceList({ const content = groupMode === "status" ? ( ; shortcutIndexByWorkspaceKey: Map; onWorkspacePress?: () => void; @@ -2242,7 +2242,7 @@ function SidebarStatusModeWrapper({ return ( & { pathname: string; hostLabelByServerId: ReadonlyMap; diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx new file mode 100644 index 000000000..dec481d5a --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -0,0 +1,76 @@ +import React, { createContext, useContext, useMemo, type ReactNode } from "react"; +import { + useSidebarWorkspacesList, + type SidebarWorkspacesListResult, +} from "@/hooks/use-sidebar-workspaces-list"; +import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces"; +import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model"; +import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; +import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { + buildSidebarShortcutModel, + buildStatusSidebarShortcutModel, + type SidebarShortcutModel, +} from "@/utils/sidebar-shortcuts"; + +interface SidebarModel extends SidebarWorkspacesListResult { + groupMode: SidebarGroupMode; + statusGroups: StatusGroup[]; + collapsedProjectKeys: ReadonlySet; + toggleProjectCollapsed: (projectKey: string) => void; + shortcutModel: SidebarShortcutModel; +} + +const SidebarModelContext = createContext(null); + +export function SidebarModelProvider({ children }: { children: ReactNode }) { + const list = useSidebarWorkspacesList(); + const groupMode = useSidebarViewStore((state) => state.groupMode); + const collapsedProjectKeys = useSidebarCollapsedSectionsStore( + (state) => state.collapsedProjectKeys, + ); + const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( + (state) => state.collapsedStatusGroupKeys, + ); + const toggleProjectCollapsed = useSidebarCollapsedSectionsStore( + (state) => state.toggleProjectCollapsed, + ); + const isStatusMode = groupMode === "status"; + const statusWorkspacePlacements = useStatusModeWorkspacePlacements({ + placements: list.workspacePlacements, + enabled: isStatusMode, + }); + const statusGroups = useMemo( + () => + isStatusMode ? buildStatusGroups(statusWorkspacePlacements, list.projectNamesByKey) : [], + [isStatusMode, list.projectNamesByKey, statusWorkspacePlacements], + ); + const shortcutModel = useMemo(() => { + if (isStatusMode) { + return buildStatusSidebarShortcutModel({ + groups: statusGroups, + collapsedStatusGroupKeys, + }); + } + return buildSidebarShortcutModel({ projects: list.projects, collapsedProjectKeys }); + }, [collapsedProjectKeys, collapsedStatusGroupKeys, isStatusMode, list.projects, statusGroups]); + const value = useMemo( + () => ({ + ...list, + groupMode, + statusGroups, + collapsedProjectKeys, + toggleProjectCollapsed, + shortcutModel, + }), + [collapsedProjectKeys, groupMode, list, shortcutModel, statusGroups, toggleProjectCollapsed], + ); + + return {children}; +} + +export function useSidebarModel(): SidebarModel { + const model = useContext(SidebarModelContext); + if (!model) throw new Error("SidebarModelProvider is required"); + return model; +} diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 3b506647c..693dc0ebe 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -9,11 +9,7 @@ import { type SidebarStatusWorkspacePlacement, type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; -import { - buildStatusGroups, - buildStatusShortcutIndex, - type StatusGroup, -} from "@/hooks/sidebar-status-view-model"; +import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { StyleSheet } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; @@ -90,7 +86,7 @@ const archiveLeadingIcon = ; interface StatusWorkspaceListProps { - workspaces: SidebarStatusWorkspacePlacement[]; + groups: StatusGroup[]; projectNamesByKey: Map; shortcutIndexByWorkspaceKey: Map; showShortcutBadges: boolean; @@ -100,31 +96,19 @@ interface StatusWorkspaceListProps { } export function SidebarStatusWorkspaceList({ - workspaces, + groups, projectNamesByKey, - shortcutIndexByWorkspaceKey: _projectShortcutIndex, + shortcutIndexByWorkspaceKey, showShortcutBadges, onWorkspacePress, hostLabelByServerId, showHostLabels, }: StatusWorkspaceListProps) { - const groups = useMemo( - () => buildStatusGroups(workspaces, projectNamesByKey), - [workspaces, projectNamesByKey], - ); const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedStatusGroupKeys, ); - const statusShortcutIndex = useMemo( - () => - showShortcutBadges - ? buildStatusShortcutIndex( - groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)), - ) - : new Map(), - [collapsedStatusGroupKeys, groups, showShortcutBadges], - ); + const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map(); return ( diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 3d251a86e..ba46840b2 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -14,6 +14,7 @@ import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import type { HostProfile } from "@/types/host-connection"; import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber"; +import { SidebarModelProvider } from "./sidebar/sidebar-model"; vi.hoisted(() => { (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; @@ -122,7 +123,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { it("publishes workspace shortcut targets without rendering the sidebar", async () => { await act(async () => { - root?.render(); + root?.render( + + + , + ); }); expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([ @@ -186,7 +191,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { }); await act(async () => { - root?.render(); + root?.render( + + + , + ); }); expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([ @@ -220,7 +229,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { }); await act(async () => { - root?.render(); + root?.render( + + + , + ); }); expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([ @@ -238,11 +251,19 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { it("clears targets when disabled", async () => { await act(async () => { - root?.render(); + root?.render( + + + , + ); }); await act(async () => { - root?.render(); + root?.render( + + + , + ); }); expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]); diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx index fba5c6833..710342cea 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx @@ -1,56 +1,13 @@ -import { useEffect, useMemo } from "react"; -import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list"; -import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces"; +import { useEffect } from "react"; +import { useSidebarModel } from "@/components/sidebar/sidebar-model"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; -import { useSidebarViewStore } from "@/stores/sidebar-view-store"; -import { - buildSidebarShortcutModel, - buildStatusSidebarShortcutModel, -} from "@/utils/sidebar-shortcuts"; export function WorkspaceShortcutTargetsSubscriber({ enabled }: { enabled: boolean }) { - const { workspacePlacements, projects, projectNamesByKey } = useSidebarWorkspacesList({ - enabled, - }); - const groupMode = useSidebarViewStore((state) => state.groupMode); - const isStatusMode = enabled && groupMode === "status"; - const statusWorkspacePlacements = useStatusModeWorkspacePlacements({ - placements: workspacePlacements, - enabled: isStatusMode, - }); - const collapsedProjectKeys = useSidebarCollapsedSectionsStore( - (state) => state.collapsedProjectKeys, - ); - const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( - (state) => state.collapsedStatusGroupKeys, - ); + const { shortcutModel } = useSidebarModel(); const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore( (state) => state.setSidebarShortcutWorkspaceTargets, ); - const shortcutModel = useMemo(() => { - if (groupMode === "status") { - return buildStatusSidebarShortcutModel({ - workspaces: statusWorkspacePlacements, - projectNamesByKey, - collapsedStatusGroupKeys, - }); - } - - return buildSidebarShortcutModel({ - projects, - collapsedProjectKeys, - }); - }, [ - collapsedProjectKeys, - collapsedStatusGroupKeys, - groupMode, - projectNamesByKey, - projects, - statusWorkspacePlacements, - ]); - useEffect(() => { if (!enabled) { setSidebarShortcutWorkspaceTargets([]); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 0c86599f7..d36c56203 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -231,6 +231,7 @@ describe("shared sidebar workspace model", () => { sessions: [ { serverId: "host-a", + workspaceAgentActivity: new Map(), workspaces: new Map([ [ "main", @@ -246,6 +247,7 @@ describe("shared sidebar workspace model", () => { }, { serverId: "host-b", + workspaceAgentActivity: new Map(), workspaces: new Map([ [ "feature", diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index dccf4e9e3..09dd29b77 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -2,13 +2,13 @@ import type { PrHint } from "@/git/pr-hint"; import { selectPrHintFromStatus } from "@/git/pr-hint"; import { type HostProjectListItem } from "@/projects/host-project-model"; import type { PendingCreateAttempt } from "@/stores/create-flow-store"; -import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceStructureHostPlacement, WorkspaceStructureProject, } from "@/projects/workspace-structure"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; -import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; const EMPTY_PROJECTS: SidebarProjectEntry[] = []; @@ -66,7 +66,7 @@ export interface SidebarWorkspacePlacementModel { export interface SidebarStatusWorkspaceSession { serverId: string; workspaces: Map; - agents?: Map; + workspaceAgentActivity: Map; } interface EffectiveWorkspaceStatus { @@ -74,8 +74,6 @@ interface EffectiveWorkspaceStatus { enteredAt: Date | null; } -interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {} - function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string { return ( workspace.projectCustomName ?? @@ -96,7 +94,7 @@ export function createSidebarWorkspaceEntry(input: { serverId: string; workspace: WorkspaceDescriptor; pendingCreateAttempts?: Record; - agents?: Map; + workspaceAgentActivity?: ReadonlyMap; }): SidebarWorkspaceEntry { const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId; const effectiveStatus = deriveEffectiveWorkspaceStatus(input); @@ -129,7 +127,7 @@ function deriveEffectiveWorkspaceStatus(input: { serverId: string; workspace: WorkspaceDescriptor; pendingCreateAttempts?: Record; - agents?: Map; + workspaceAgentActivity?: ReadonlyMap; }): EffectiveWorkspaceStatus { if (input.workspace.status !== "done") { return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; @@ -144,10 +142,7 @@ function deriveEffectiveWorkspaceStatus(input: { return { status: "running", enteredAt: pendingStartedAt }; } - const rootAgentActivity = getRootAgentWorkspaceActivity({ - workspace: input.workspace, - agents: input.agents, - }); + const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id); if (rootAgentActivity && rootAgentActivity.status !== "done") { return rootAgentActivity; } @@ -173,28 +168,6 @@ function getPendingInitialAgentCreateStartedAt(input: { return latestStartedAt; } -function getRootAgentWorkspaceActivity(input: { - workspace: WorkspaceDescriptor; - agents?: Map; -}): WorkspaceAgentActivity | null { - let latest: WorkspaceAgentActivity | null = null; - for (const agent of input.agents?.values() ?? []) { - if (agent.archivedAt || agent.parentAgentId) continue; - if (agent.workspaceId !== input.workspace.id) continue; - const status = deriveSidebarStateBucket({ - status: agent.status, - pendingPermissionCount: agent.pendingPermissions.length, - requiresAttention: agent.requiresAttention, - attentionReason: agent.attentionReason, - }); - const enteredAt = agent.attentionTimestamp ?? agent.updatedAt; - if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) { - latest = { status, enteredAt }; - } - } - return latest; -} - export function buildSidebarWorkspacePlacementModel(input: { projects: readonly HostProjectListItem[]; }): SidebarWorkspacePlacementModel { @@ -298,7 +271,7 @@ export function buildSidebarStatusWorkspacePlacements(input: { serverId: placement.serverId, workspace, pendingCreateAttempts: input.pendingCreateAttempts, - agents: session.agents, + workspaceAgentActivity: session.workspaceAgentActivity, }); rows.push({ diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index b8248c2d1..2f680725c 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -61,12 +61,14 @@ export function useSidebarWorkspaceEntry( (state) => { const workspace = selectWorkspace(state, serverId, workspaceId); if (!workspace) return null; - const agents = serverId ? state.sessions[serverId]?.agents : undefined; + const workspaceAgentActivity = serverId + ? state.sessions[serverId]?.workspaceAgentActivity + : undefined; return createSidebarWorkspaceEntry({ serverId: serverId ?? "", workspace, pendingCreateAttempts, - agents, + workspaceAgentActivity, }); }, equal, diff --git a/packages/app/src/hooks/use-status-mode-workspaces.test.ts b/packages/app/src/hooks/use-status-mode-workspaces.test.ts index 38d050063..33677ece7 100644 --- a/packages/app/src/hooks/use-status-mode-workspaces.test.ts +++ b/packages/app/src/hooks/use-status-mode-workspaces.test.ts @@ -4,20 +4,21 @@ import { selectStatusModeSessions, type StatusModeSession, } from "./use-status-mode-workspaces"; -import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; function workspaceMap(): Map { return new Map(); } -function agentMap(): Map { +function activityMap(): Map { return new Map(); } function statusSession(input?: Partial>) { return { workspaces: input?.workspaces ?? workspaceMap(), - agents: input?.agents ?? agentMap(), + workspaceAgentActivity: input?.workspaceAgentActivity ?? activityMap(), }; } @@ -37,34 +38,44 @@ describe("status mode session selection", () => { ["host-b", "missing", "host-a"], ), ).toEqual([ - { serverId: "host-b", workspaces: hostB.workspaces, agents: hostB.agents }, - { serverId: "host-a", workspaces: hostA.workspaces, agents: hostA.agents }, + { + serverId: "host-b", + workspaces: hostB.workspaces, + workspaceAgentActivity: hostB.workspaceAgentActivity, + }, + { + serverId: "host-a", + workspaces: hostA.workspaces, + workspaceAgentActivity: hostA.workspaceAgentActivity, + }, ]); }); it("keeps selector output equal when only wrapper objects change", () => { const workspaces = workspaceMap(); - const agents = agentMap(); + const workspaceAgentActivity = activityMap(); - const previous = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [ - "host-a", - ]); - const next = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [ - "host-a", - ]); + const previous = selectStatusModeSessions( + { "host-a": statusSession({ workspaces, workspaceAgentActivity }) }, + ["host-a"], + ); + const next = selectStatusModeSessions( + { "host-a": statusSession({ workspaces, workspaceAgentActivity }) }, + ["host-a"], + ); expect(previous).not.toBe(next); expect(areStatusModeSessionsEqual(previous, next)).toBe(true); }); - it("detects workspace or agent map changes for selected hosts", () => { - const agents = agentMap(); + it("detects workspace or activity index changes for selected hosts", () => { + const workspaceAgentActivity = activityMap(); const previous = selectStatusModeSessions( - { "host-a": statusSession({ agents, workspaces: workspaceMap() }) }, + { "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) }, ["host-a"], ); const next = selectStatusModeSessions( - { "host-a": statusSession({ agents, workspaces: workspaceMap() }) }, + { "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) }, ["host-a"], ); diff --git a/packages/app/src/hooks/use-status-mode-workspaces.ts b/packages/app/src/hooks/use-status-mode-workspaces.ts index 055899ec5..a66dfb935 100644 --- a/packages/app/src/hooks/use-status-mode-workspaces.ts +++ b/packages/app/src/hooks/use-status-mode-workspaces.ts @@ -16,13 +16,13 @@ const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType< interface StatusModeSessionSource { workspaces: SessionState["workspaces"]; - agents: SessionState["agents"]; + workspaceAgentActivity: SessionState["workspaceAgentActivity"]; } export interface StatusModeSession { serverId: string; workspaces: SessionState["workspaces"]; - agents: SessionState["agents"]; + workspaceAgentActivity: SessionState["workspaceAgentActivity"]; } export function selectStatusModeSessions( @@ -38,7 +38,7 @@ export function selectStatusModeSessions( statusSessions.push({ serverId, workspaces: session.workspaces, - agents: session.agents, + workspaceAgentActivity: session.workspaceAgentActivity, }); } return statusSessions; @@ -59,7 +59,7 @@ export function areStatusModeSessionsEqual( !rightSession || leftSession.serverId !== rightSession.serverId || leftSession.workspaces !== rightSession.workspaces || - leftSession.agents !== rightSession.agents + leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity ) { return false; } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 734f700b1..0b371ffcc 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -37,6 +37,10 @@ import { createAgentLastActivityCoalescer, type AgentLastActivityCommitter, } from "@/runtime/activity"; +import { + buildWorkspaceAgentActivityIndex, + type WorkspaceAgentActivity, +} from "@/utils/workspace-agent-activity"; // Re-export types that were in session-context export type MessageEntry = @@ -353,6 +357,7 @@ export interface SessionState { // Agents agents: Map; + workspaceAgentActivity: Map; agentDetails: Map; workspaces: Map; // Project parents with no active workspaces, keyed by projectId. The @@ -560,6 +565,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess agentAuthoritativeHistoryApplied: new Map(), initializingAgents: new Map(), agents: new Map(), + workspaceAgentActivity: new Map(), agentDetails: new Map(), workspaces: new Map(), emptyProjects: new Map(), @@ -1194,7 +1200,11 @@ export const useSessionStore = create()( ...prev, sessions: { ...prev.sessions, - [serverId]: { ...session, agents: nextAgents }, + [serverId]: { + ...session, + agents: nextAgents, + workspaceAgentActivity: buildWorkspaceAgentActivityIndex(nextAgents), + }, }, }; }); diff --git a/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts index 1f5a5e911..ab0c01b31 100644 --- a/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts +++ b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts @@ -27,6 +27,39 @@ const SERVER_ID = "server-1"; const AGENT_ID = "agent-1"; const WORKSPACE_ID = "workspace-1"; +function agent(archivedAt: Date | null): Agent { + const createdAt = new Date("2026-01-01T00:00:00.000Z"); + return { + serverId: SERVER_ID, + id: AGENT_ID, + provider: "codex", + status: "idle", + createdAt, + updatedAt: createdAt, + lastUserMessageAt: null, + lastActivityAt: createdAt, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + cwd: "/repo", + workspaceId: WORKSPACE_ID, + model: null, + archivedAt, + parentAgentId: null, + labels: {}, + }; +} + function status(): "restoring" | "failed" | "needs-host-upgrade" | null { return ( useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null @@ -44,11 +77,7 @@ function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void { } as unknown as Parameters[1]); store.setAgents(SERVER_ID, (prev) => { const next = new Map(prev); - next.set(AGENT_ID, { - id: AGENT_ID, - workspaceId: WORKSPACE_ID, - archivedAt: new Date(), - } as unknown as Agent); + next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z"))); return next; }); } @@ -95,11 +124,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => { const store = useSessionStore.getState(); store.setAgents(SERVER_ID, (prev) => { const next = new Map(prev); - next.set(AGENT_ID, { - id: AGENT_ID, - workspaceId: WORKSPACE_ID, - archivedAt: undefined, - } as unknown as Agent); + next.set(AGENT_ID, agent(null)); return next; }); refreshAgent.mockImplementation(() => new Promise(() => {})); diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 0d262aa4f..4b853de30 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -3,6 +3,7 @@ import type { SidebarProjectEntry, SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; +import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; import { buildSidebarShortcutModel, @@ -202,11 +203,13 @@ describe("buildStatusSidebarShortcutModel", () => { ]; const model = buildStatusSidebarShortcutModel({ - workspaces, - projectNamesByKey: new Map([ - ["p1", "Project 1"], - ["p2", "Project 2"], - ]), + groups: buildStatusGroups( + workspaces, + new Map([ + ["p1", "Project 1"], + ["p2", "Project 2"], + ]), + ), }); expect(model.shortcutTargets).toEqual([ @@ -242,8 +245,7 @@ describe("buildStatusSidebarShortcutModel", () => { ]; const model = buildStatusSidebarShortcutModel({ - workspaces, - projectNamesByKey: new Map([["p1", "Project 1"]]), + groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])), collapsedStatusGroupKeys: new Set(["needs_input"]), }); diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index e3dff267f..a75f24d54 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -1,9 +1,8 @@ import type { SidebarProjectEntry, - SidebarStatusWorkspacePlacement, SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; -import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; +import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; export interface SidebarShortcutWorkspaceTarget { serverId: string; @@ -53,17 +52,15 @@ export function buildSidebarShortcutModel(input: { } export function buildStatusSidebarShortcutModel(input: { - workspaces: SidebarStatusWorkspacePlacement[]; - projectNamesByKey: Map; + groups: readonly StatusGroup[]; collapsedStatusGroupKeys?: ReadonlySet; shortcutLimit?: number; }): SidebarShortcutModel { const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9)); - const groups = buildStatusGroups(input.workspaces, input.projectNamesByKey); const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []; const shortcutIndexByWorkspaceKey = new Map(); - for (const group of groups) { + for (const group of input.groups) { if (input.collapsedStatusGroupKeys?.has(group.bucket)) { continue; } diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts new file mode 100644 index 000000000..d1e6e711b --- /dev/null +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import type { Agent } from "@/stores/session-store"; +import { buildWorkspaceAgentActivityIndex } from "./workspace-agent-activity"; + +function agent(input: { + id: string; + workspaceId?: string; + status?: Agent["status"]; + updatedAt: string; + attentionTimestamp?: string | null; + requiresAttention?: boolean; + attentionReason?: Agent["attentionReason"]; + pendingPermissionCount?: number; + archivedAt?: string | null; + parentAgentId?: string | null; +}): Agent { + return { + serverId: "host-a", + id: input.id, + provider: "codex", + status: input.status ?? "idle", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date(input.updatedAt), + lastUserMessageAt: null, + lastActivityAt: new Date(input.updatedAt), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: Array.from({ length: input.pendingPermissionCount ?? 0 }, (_, index) => ({ + id: `permission-${index}`, + provider: "codex", + name: "shell", + kind: "tool", + input: {}, + })), + persistence: null, + title: null, + cwd: "/repo", + workspaceId: input.workspaceId, + model: null, + requiresAttention: input.requiresAttention, + attentionReason: input.attentionReason, + attentionTimestamp: input.attentionTimestamp ? new Date(input.attentionTimestamp) : null, + archivedAt: input.archivedAt ? new Date(input.archivedAt) : null, + parentAgentId: input.parentAgentId ?? null, + labels: {}, + }; +} + +describe("workspace agent activity index", () => { + it("keeps the latest active root agent for each workspace", () => { + const index = buildWorkspaceAgentActivityIndex( + new Map([ + [ + "older", + agent({ + id: "older", + workspaceId: "workspace-a", + status: "running", + updatedAt: "2026-06-01T10:00:00.000Z", + }), + ], + [ + "permission", + agent({ + id: "permission", + workspaceId: "workspace-a", + updatedAt: "2026-06-01T10:01:00.000Z", + pendingPermissionCount: 1, + }), + ], + [ + "attention", + agent({ + id: "attention", + workspaceId: "workspace-b", + updatedAt: "2026-06-01T10:00:00.000Z", + attentionTimestamp: "2026-06-01T10:02:00.000Z", + requiresAttention: true, + attentionReason: "finished", + }), + ], + ]), + ); + + expect(index).toEqual( + new Map([ + ["workspace-a", { status: "needs_input", enteredAt: new Date("2026-06-01T10:01:00.000Z") }], + ["workspace-b", { status: "attention", enteredAt: new Date("2026-06-01T10:02:00.000Z") }], + ]), + ); + }); + + it("does not let archived or child agents change root workspace activity", () => { + const index = buildWorkspaceAgentActivityIndex( + new Map([ + [ + "root", + agent({ + id: "root", + workspaceId: "workspace-a", + status: "running", + updatedAt: "2026-06-01T10:00:00.000Z", + }), + ], + [ + "child", + agent({ + id: "child", + workspaceId: "workspace-a", + updatedAt: "2026-06-01T10:03:00.000Z", + pendingPermissionCount: 1, + parentAgentId: "root", + }), + ], + [ + "archived", + agent({ + id: "archived", + workspaceId: "workspace-a", + updatedAt: "2026-06-01T10:04:00.000Z", + requiresAttention: true, + attentionReason: "error", + archivedAt: "2026-06-01T10:04:00.000Z", + }), + ], + ]), + ); + + expect(index.get("workspace-a")).toEqual({ + status: "running", + enteredAt: new Date("2026-06-01T10:00:00.000Z"), + }); + }); +}); diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts new file mode 100644 index 000000000..7c33392f5 --- /dev/null +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -0,0 +1,37 @@ +import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +import { deriveSidebarStateBucket } from "./sidebar-agent-state"; + +export interface WorkspaceAgentActivity { + status: WorkspaceDescriptor["status"]; + enteredAt: Date | null; +} + +export function buildWorkspaceAgentActivityIndex( + agents: ReadonlyMap, +): Map { + const activityByWorkspaceId = new Map(); + + for (const agent of agents.values()) { + if (agent.archivedAt || agent.parentAgentId || !agent.workspaceId) { + continue; + } + + const enteredAt = agent.attentionTimestamp ?? agent.updatedAt; + const current = activityByWorkspaceId.get(agent.workspaceId); + if (current && enteredAt <= (current.enteredAt ?? new Date(0))) { + continue; + } + + activityByWorkspaceId.set(agent.workspaceId, { + status: deriveSidebarStateBucket({ + status: agent.status, + pendingPermissionCount: agent.pendingPermissions.length, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + }), + enteredAt, + }); + } + + return activityByWorkspaceId; +}