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; +}