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
This commit is contained in:
@@ -4,13 +4,7 @@ import { PortalProvider } from "@gorhom/portal";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import {
|
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
||||||
Stack,
|
|
||||||
useGlobalSearchParams,
|
|
||||||
useNavigationContainerRef,
|
|
||||||
usePathname,
|
|
||||||
useRouter,
|
|
||||||
} from "expo-router";
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -33,6 +27,7 @@ import { DownloadToast } from "@/components/download-toast";
|
|||||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
|
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
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 { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
import {
|
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||||
buildOpenProjectRoute,
|
|
||||||
parseHostAgentRouteFromPathname,
|
|
||||||
parseServerIdFromPathname,
|
|
||||||
parseWorkspaceOpenIntent,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
import {
|
import {
|
||||||
@@ -401,17 +391,12 @@ const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
|||||||
|
|
||||||
interface AppContainerProps {
|
interface AppContainerProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
selectedAgentId?: string;
|
|
||||||
chromeEnabled?: boolean;
|
chromeEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
||||||
|
|
||||||
function AppContainer({
|
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
||||||
children,
|
|
||||||
selectedAgentId,
|
|
||||||
chromeEnabled: chromeEnabledOverride,
|
|
||||||
}: AppContainerProps) {
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||||
@@ -468,9 +453,7 @@ function AppContainer({
|
|||||||
|
|
||||||
const workspaceChrome = (
|
const workspaceChrome = (
|
||||||
<View style={rowStyle}>
|
<View style={rowStyle}>
|
||||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && <LeftSidebar />}
|
||||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
|
||||||
)}
|
|
||||||
{isCompactLayout && chromeEnabled ? (
|
{isCompactLayout && chromeEnabled ? (
|
||||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||||
<View style={flexStyle}>{children}</View>
|
<View style={flexStyle}>{children}</View>
|
||||||
@@ -482,23 +465,25 @@ function AppContainer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View style={layoutStyles.surfaceFill}>
|
<SidebarModelProvider>
|
||||||
{workspaceChrome}
|
<View style={layoutStyles.surfaceFill}>
|
||||||
<FloatingPanelPortalHost />
|
{workspaceChrome}
|
||||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
<FloatingPanelPortalHost />
|
||||||
<DownloadToast />
|
{isCompactLayout && chromeEnabled && <LeftSidebar />}
|
||||||
<RosettaCalloutSource />
|
<DownloadToast />
|
||||||
<UpdateCalloutSource />
|
<RosettaCalloutSource />
|
||||||
<WorktreeSetupCalloutSource />
|
<UpdateCalloutSource />
|
||||||
<CommandCenter />
|
<WorktreeSetupCalloutSource />
|
||||||
<HostChooserModal />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<HostChooserModal />
|
||||||
<ProviderSettingsHost />
|
<ProjectPickerModal />
|
||||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
<ProviderSettingsHost />
|
||||||
<WorkspaceSetupDialog />
|
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||||
<KeyboardShortcutsDialog />
|
<WorkspaceSetupDialog />
|
||||||
<QuittingOverlay />
|
<KeyboardShortcutsDialog />
|
||||||
</View>
|
<QuittingOverlay />
|
||||||
|
</View>
|
||||||
|
</SidebarModelProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isCompactLayout) {
|
if (!isCompactLayout) {
|
||||||
@@ -751,7 +736,6 @@ function OpenProjectListener() {
|
|||||||
|
|
||||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
@@ -765,30 +749,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
pathname === "/schedules" ||
|
pathname === "/schedules" ||
|
||||||
routeHasKnownHost);
|
routeHasKnownHost);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
||||||
// 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 (
|
|
||||||
<AppContainer
|
|
||||||
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
|
|
||||||
chromeEnabled={shouldShowAppChrome}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AppContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FaviconStatusSync() {
|
function FaviconStatusSync() {
|
||||||
|
|||||||
@@ -34,16 +34,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
|
||||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||||
import { useHostFeature } from "@/runtime/host-features";
|
import { useHostFeature } from "@/runtime/host-features";
|
||||||
import {
|
import { type SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
type SidebarProjectEntry,
|
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||||
type SidebarStatusWorkspacePlacement,
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||||
useSidebarWorkspacesList,
|
import { type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
|
||||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
|
||||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
@@ -73,25 +69,20 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
|||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
|
||||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||||
|
|
||||||
interface LeftSidebarProps {
|
|
||||||
selectedAgentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarSharedProps {
|
interface SidebarSharedProps {
|
||||||
theme: SidebarTheme;
|
theme: SidebarTheme;
|
||||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
statusGroups: StatusGroup[];
|
||||||
projects: SidebarProjectEntry[];
|
projects: SidebarProjectEntry[];
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
isInitialLoad: boolean;
|
isInitialLoad: boolean;
|
||||||
isRevalidating: boolean;
|
isRevalidating: boolean;
|
||||||
isManualRefresh: boolean;
|
isManualRefresh: boolean;
|
||||||
groupMode: SidebarGroupMode;
|
groupMode: SidebarGroupMode;
|
||||||
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
toggleProjectCollapsed: (projectKey: string) => void;
|
||||||
handleRefresh: () => void;
|
handleRefresh: () => void;
|
||||||
handleOpenProject: () => void;
|
handleOpenProject: () => void;
|
||||||
handleHome: () => void;
|
handleHome: () => void;
|
||||||
@@ -129,11 +120,7 @@ interface DesktopSidebarProps extends SidebarSharedProps {
|
|||||||
handleViewSchedules: () => void;
|
handleViewSchedules: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeftSidebar = memo(function LeftSidebar({
|
export const LeftSidebar = memo(function LeftSidebar() {
|
||||||
selectedAgentId: _selectedAgentId,
|
|
||||||
}: LeftSidebarProps) {
|
|
||||||
void _selectedAgentId;
|
|
||||||
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -144,22 +131,18 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
workspacePlacements,
|
|
||||||
projects,
|
projects,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
} = useSidebarWorkspacesList({
|
statusGroups,
|
||||||
enabled: isCompactLayout || isOpen,
|
collapsedProjectKeys,
|
||||||
});
|
toggleProjectCollapsed,
|
||||||
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
groupMode,
|
||||||
placements: workspacePlacements,
|
shortcutModel,
|
||||||
});
|
} = useSidebarModel();
|
||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
const { shortcutIndexByWorkspaceKey } = shortcutModel;
|
||||||
useSidebarShortcutModel({ projects });
|
|
||||||
|
|
||||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
|
||||||
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
|
|
||||||
@@ -250,7 +233,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
|
|
||||||
const sharedProps = {
|
const sharedProps = {
|
||||||
theme,
|
theme,
|
||||||
statusWorkspacePlacements,
|
statusGroups,
|
||||||
projects,
|
projects,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
@@ -548,7 +531,7 @@ function SidebarFooter({
|
|||||||
|
|
||||||
function MobileSidebar({
|
function MobileSidebar({
|
||||||
theme,
|
theme,
|
||||||
statusWorkspacePlacements,
|
statusGroups,
|
||||||
projects,
|
projects,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
@@ -659,7 +642,7 @@ function MobileSidebar({
|
|||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
groupMode={groupMode}
|
groupMode={groupMode}
|
||||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
statusGroups={statusGroups}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
projectNamesByKey={projectNamesByKey}
|
projectNamesByKey={projectNamesByKey}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
@@ -686,7 +669,7 @@ function MobileSidebar({
|
|||||||
|
|
||||||
function DesktopSidebar({
|
function DesktopSidebar({
|
||||||
theme,
|
theme,
|
||||||
statusWorkspacePlacements,
|
statusGroups,
|
||||||
projects,
|
projects,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
@@ -811,7 +794,7 @@ function DesktopSidebar({
|
|||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
groupMode={groupMode}
|
groupMode={groupMode}
|
||||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
statusGroups={statusGroups}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
projectNamesByKey={projectNamesByKey}
|
projectNamesByKey={projectNamesByKey}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ import {
|
|||||||
shouldShowSidebarHostLabels,
|
shouldShowSidebarHostLabels,
|
||||||
useSidebarWorkspaceEntry,
|
useSidebarWorkspaceEntry,
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarStatusWorkspacePlacement,
|
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
type SidebarWorkspacePlacement,
|
type SidebarWorkspacePlacement,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
@@ -93,6 +92,7 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
|||||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
||||||
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||||
import {
|
import {
|
||||||
SidebarWorkspaceRowFrame,
|
SidebarWorkspaceRowFrame,
|
||||||
SidebarWorkspaceRowContent,
|
SidebarWorkspaceRowContent,
|
||||||
@@ -228,7 +228,7 @@ function selectionForSelectedWorkspace(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarWorkspaceListProps {
|
interface SidebarWorkspaceListProps {
|
||||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
statusGroups: StatusGroup[];
|
||||||
projects: SidebarProjectEntry[];
|
projects: SidebarProjectEntry[];
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
collapsedProjectKeys: ReadonlySet<string>;
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
@@ -2166,7 +2166,7 @@ function areProjectBlockSelectionsEqual(
|
|||||||
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
||||||
|
|
||||||
export function SidebarWorkspaceList({
|
export function SidebarWorkspaceList({
|
||||||
statusWorkspacePlacements,
|
statusGroups,
|
||||||
projects,
|
projects,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
@@ -2196,7 +2196,7 @@ export function SidebarWorkspaceList({
|
|||||||
const content =
|
const content =
|
||||||
groupMode === "status" ? (
|
groupMode === "status" ? (
|
||||||
<SidebarStatusModeWrapper
|
<SidebarStatusModeWrapper
|
||||||
statusWorkspacePlacements={statusWorkspacePlacements}
|
statusGroups={statusGroups}
|
||||||
projectNamesByKey={projectNamesByKey}
|
projectNamesByKey={projectNamesByKey}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
@@ -2224,14 +2224,14 @@ export function SidebarWorkspaceList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarStatusModeWrapper({
|
function SidebarStatusModeWrapper({
|
||||||
statusWorkspacePlacements,
|
statusGroups,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
}: {
|
}: {
|
||||||
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
statusGroups: StatusGroup[];
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
@@ -2242,7 +2242,7 @@ function SidebarStatusModeWrapper({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarStatusWorkspaceList
|
<SidebarStatusWorkspaceList
|
||||||
workspaces={statusWorkspacePlacements}
|
groups={statusGroups}
|
||||||
projectNamesByKey={projectNamesByKey}
|
projectNamesByKey={projectNamesByKey}
|
||||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||||
showShortcutBadges={showShortcutBadges}
|
showShortcutBadges={showShortcutBadges}
|
||||||
@@ -2268,7 +2268,7 @@ function ProjectModeList({
|
|||||||
supportsMultiplicityByServerId,
|
supportsMultiplicityByServerId,
|
||||||
}: Omit<
|
}: Omit<
|
||||||
SidebarWorkspaceListProps,
|
SidebarWorkspaceListProps,
|
||||||
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||||
> & {
|
> & {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||||
|
|||||||
@@ -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<string>;
|
||||||
|
toggleProjectCollapsed: (projectKey: string) => void;
|
||||||
|
shortcutModel: SidebarShortcutModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarModelContext = createContext<SidebarModel | null>(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 <SidebarModelContext.Provider value={value}>{children}</SidebarModelContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarModel(): SidebarModel {
|
||||||
|
const model = useContext(SidebarModelContext);
|
||||||
|
if (!model) throw new Error("SidebarModelProvider is required");
|
||||||
|
return model;
|
||||||
|
}
|
||||||
@@ -9,11 +9,7 @@ import {
|
|||||||
type SidebarStatusWorkspacePlacement,
|
type SidebarStatusWorkspacePlacement,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import {
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||||
buildStatusGroups,
|
|
||||||
buildStatusShortcutIndex,
|
|
||||||
type StatusGroup,
|
|
||||||
} from "@/hooks/sidebar-status-view-model";
|
|
||||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
@@ -90,7 +86,7 @@ const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedCol
|
|||||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
interface StatusWorkspaceListProps {
|
interface StatusWorkspaceListProps {
|
||||||
workspaces: SidebarStatusWorkspacePlacement[];
|
groups: StatusGroup[];
|
||||||
projectNamesByKey: Map<string, string>;
|
projectNamesByKey: Map<string, string>;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
showShortcutBadges: boolean;
|
showShortcutBadges: boolean;
|
||||||
@@ -100,31 +96,19 @@ interface StatusWorkspaceListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarStatusWorkspaceList({
|
export function SidebarStatusWorkspaceList({
|
||||||
workspaces,
|
groups,
|
||||||
projectNamesByKey,
|
projectNamesByKey,
|
||||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
hostLabelByServerId,
|
hostLabelByServerId,
|
||||||
showHostLabels,
|
showHostLabels,
|
||||||
}: StatusWorkspaceListProps) {
|
}: StatusWorkspaceListProps) {
|
||||||
const groups = useMemo(
|
|
||||||
() => buildStatusGroups(workspaces, projectNamesByKey),
|
|
||||||
[workspaces, projectNamesByKey],
|
|
||||||
);
|
|
||||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedStatusGroupKeys,
|
(state) => state.collapsedStatusGroupKeys,
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusShortcutIndex = useMemo(
|
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
||||||
() =>
|
|
||||||
showShortcutBadges
|
|
||||||
? buildStatusShortcutIndex(
|
|
||||||
groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)),
|
|
||||||
)
|
|
||||||
: new Map<string, number>(),
|
|
||||||
[collapsedStatusGroupKeys, groups, showShortcutBadges],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
|||||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
|
||||||
|
import { SidebarModelProvider } from "./sidebar/sidebar-model";
|
||||||
|
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
@@ -122,7 +123,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
|
|
||||||
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
|
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
root?.render(
|
||||||
|
<SidebarModelProvider>
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||||
|
</SidebarModelProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||||
@@ -186,7 +191,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
root?.render(
|
||||||
|
<SidebarModelProvider>
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||||
|
</SidebarModelProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||||
@@ -220,7 +229,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
root?.render(
|
||||||
|
<SidebarModelProvider>
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||||
|
</SidebarModelProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||||
@@ -238,11 +251,19 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
|||||||
|
|
||||||
it("clears targets when disabled", async () => {
|
it("clears targets when disabled", async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} />);
|
root?.render(
|
||||||
|
<SidebarModelProvider>
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={true} />
|
||||||
|
</SidebarModelProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} />);
|
root?.render(
|
||||||
|
<SidebarModelProvider>
|
||||||
|
<WorkspaceShortcutTargetsSubscriber enabled={false} />
|
||||||
|
</SidebarModelProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
|
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
|
||||||
|
|||||||
@@ -1,56 +1,13 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect } from "react";
|
||||||
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||||
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
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 }) {
|
export function WorkspaceShortcutTargetsSubscriber({ enabled }: { enabled: boolean }) {
|
||||||
const { workspacePlacements, projects, projectNamesByKey } = useSidebarWorkspacesList({
|
const { shortcutModel } = useSidebarModel();
|
||||||
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 setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
||||||
(state) => state.setSidebarShortcutWorkspaceTargets,
|
(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(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setSidebarShortcutWorkspaceTargets([]);
|
setSidebarShortcutWorkspaceTargets([]);
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ describe("shared sidebar workspace model", () => {
|
|||||||
sessions: [
|
sessions: [
|
||||||
{
|
{
|
||||||
serverId: "host-a",
|
serverId: "host-a",
|
||||||
|
workspaceAgentActivity: new Map(),
|
||||||
workspaces: new Map([
|
workspaces: new Map([
|
||||||
[
|
[
|
||||||
"main",
|
"main",
|
||||||
@@ -246,6 +247,7 @@ describe("shared sidebar workspace model", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: "host-b",
|
serverId: "host-b",
|
||||||
|
workspaceAgentActivity: new Map(),
|
||||||
workspaces: new Map([
|
workspaces: new Map([
|
||||||
[
|
[
|
||||||
"feature",
|
"feature",
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { PrHint } from "@/git/pr-hint";
|
|||||||
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
||||||
import { type HostProjectListItem } from "@/projects/host-project-model";
|
import { type HostProjectListItem } from "@/projects/host-project-model";
|
||||||
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
|
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 {
|
import type {
|
||||||
WorkspaceStructureHostPlacement,
|
WorkspaceStructureHostPlacement,
|
||||||
WorkspaceStructureProject,
|
WorkspaceStructureProject,
|
||||||
} from "@/projects/workspace-structure";
|
} from "@/projects/workspace-structure";
|
||||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
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";
|
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||||
|
|
||||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||||
@@ -66,7 +66,7 @@ export interface SidebarWorkspacePlacementModel {
|
|||||||
export interface SidebarStatusWorkspaceSession {
|
export interface SidebarStatusWorkspaceSession {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaces: Map<string, WorkspaceDescriptor>;
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
agents?: Map<string, Agent>;
|
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EffectiveWorkspaceStatus {
|
interface EffectiveWorkspaceStatus {
|
||||||
@@ -74,8 +74,6 @@ interface EffectiveWorkspaceStatus {
|
|||||||
enteredAt: Date | null;
|
enteredAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
|
|
||||||
|
|
||||||
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
|
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
|
||||||
return (
|
return (
|
||||||
workspace.projectCustomName ??
|
workspace.projectCustomName ??
|
||||||
@@ -96,7 +94,7 @@ export function createSidebarWorkspaceEntry(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspace: WorkspaceDescriptor;
|
workspace: WorkspaceDescriptor;
|
||||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||||
agents?: Map<string, Agent>;
|
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||||
}): SidebarWorkspaceEntry {
|
}): SidebarWorkspaceEntry {
|
||||||
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
|
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
|
||||||
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
||||||
@@ -129,7 +127,7 @@ function deriveEffectiveWorkspaceStatus(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspace: WorkspaceDescriptor;
|
workspace: WorkspaceDescriptor;
|
||||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||||
agents?: Map<string, Agent>;
|
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||||
}): EffectiveWorkspaceStatus {
|
}): EffectiveWorkspaceStatus {
|
||||||
if (input.workspace.status !== "done") {
|
if (input.workspace.status !== "done") {
|
||||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||||
@@ -144,10 +142,7 @@ function deriveEffectiveWorkspaceStatus(input: {
|
|||||||
return { status: "running", enteredAt: pendingStartedAt };
|
return { status: "running", enteredAt: pendingStartedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootAgentActivity = getRootAgentWorkspaceActivity({
|
const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id);
|
||||||
workspace: input.workspace,
|
|
||||||
agents: input.agents,
|
|
||||||
});
|
|
||||||
if (rootAgentActivity && rootAgentActivity.status !== "done") {
|
if (rootAgentActivity && rootAgentActivity.status !== "done") {
|
||||||
return rootAgentActivity;
|
return rootAgentActivity;
|
||||||
}
|
}
|
||||||
@@ -173,28 +168,6 @@ function getPendingInitialAgentCreateStartedAt(input: {
|
|||||||
return latestStartedAt;
|
return latestStartedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRootAgentWorkspaceActivity(input: {
|
|
||||||
workspace: WorkspaceDescriptor;
|
|
||||||
agents?: Map<string, Agent>;
|
|
||||||
}): 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: {
|
export function buildSidebarWorkspacePlacementModel(input: {
|
||||||
projects: readonly HostProjectListItem[];
|
projects: readonly HostProjectListItem[];
|
||||||
}): SidebarWorkspacePlacementModel {
|
}): SidebarWorkspacePlacementModel {
|
||||||
@@ -298,7 +271,7 @@ export function buildSidebarStatusWorkspacePlacements(input: {
|
|||||||
serverId: placement.serverId,
|
serverId: placement.serverId,
|
||||||
workspace,
|
workspace,
|
||||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||||
agents: session.agents,
|
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||||
});
|
});
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
|
|||||||
@@ -61,12 +61,14 @@ export function useSidebarWorkspaceEntry(
|
|||||||
(state) => {
|
(state) => {
|
||||||
const workspace = selectWorkspace(state, serverId, workspaceId);
|
const workspace = selectWorkspace(state, serverId, workspaceId);
|
||||||
if (!workspace) return null;
|
if (!workspace) return null;
|
||||||
const agents = serverId ? state.sessions[serverId]?.agents : undefined;
|
const workspaceAgentActivity = serverId
|
||||||
|
? state.sessions[serverId]?.workspaceAgentActivity
|
||||||
|
: undefined;
|
||||||
return createSidebarWorkspaceEntry({
|
return createSidebarWorkspaceEntry({
|
||||||
serverId: serverId ?? "",
|
serverId: serverId ?? "",
|
||||||
workspace,
|
workspace,
|
||||||
pendingCreateAttempts,
|
pendingCreateAttempts,
|
||||||
agents,
|
workspaceAgentActivity,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
equal,
|
equal,
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ import {
|
|||||||
selectStatusModeSessions,
|
selectStatusModeSessions,
|
||||||
type StatusModeSession,
|
type StatusModeSession,
|
||||||
} from "./use-status-mode-workspaces";
|
} 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<string, WorkspaceDescriptor> {
|
function workspaceMap(): Map<string, WorkspaceDescriptor> {
|
||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentMap(): Map<string, Agent> {
|
function activityMap(): Map<string, WorkspaceAgentActivity> {
|
||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
|
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
|
||||||
return {
|
return {
|
||||||
workspaces: input?.workspaces ?? workspaceMap(),
|
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"],
|
["host-b", "missing", "host-a"],
|
||||||
),
|
),
|
||||||
).toEqual([
|
).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", () => {
|
it("keeps selector output equal when only wrapper objects change", () => {
|
||||||
const workspaces = workspaceMap();
|
const workspaces = workspaceMap();
|
||||||
const agents = agentMap();
|
const workspaceAgentActivity = activityMap();
|
||||||
|
|
||||||
const previous = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
|
const previous = selectStatusModeSessions(
|
||||||
"host-a",
|
{ "host-a": statusSession({ workspaces, workspaceAgentActivity }) },
|
||||||
]);
|
["host-a"],
|
||||||
const next = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
|
);
|
||||||
"host-a",
|
const next = selectStatusModeSessions(
|
||||||
]);
|
{ "host-a": statusSession({ workspaces, workspaceAgentActivity }) },
|
||||||
|
["host-a"],
|
||||||
|
);
|
||||||
|
|
||||||
expect(previous).not.toBe(next);
|
expect(previous).not.toBe(next);
|
||||||
expect(areStatusModeSessionsEqual(previous, next)).toBe(true);
|
expect(areStatusModeSessionsEqual(previous, next)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects workspace or agent map changes for selected hosts", () => {
|
it("detects workspace or activity index changes for selected hosts", () => {
|
||||||
const agents = agentMap();
|
const workspaceAgentActivity = activityMap();
|
||||||
const previous = selectStatusModeSessions(
|
const previous = selectStatusModeSessions(
|
||||||
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
|
{ "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||||
["host-a"],
|
["host-a"],
|
||||||
);
|
);
|
||||||
const next = selectStatusModeSessions(
|
const next = selectStatusModeSessions(
|
||||||
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
|
{ "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
|
||||||
["host-a"],
|
["host-a"],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
|
|||||||
|
|
||||||
interface StatusModeSessionSource {
|
interface StatusModeSessionSource {
|
||||||
workspaces: SessionState["workspaces"];
|
workspaces: SessionState["workspaces"];
|
||||||
agents: SessionState["agents"];
|
workspaceAgentActivity: SessionState["workspaceAgentActivity"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusModeSession {
|
export interface StatusModeSession {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaces: SessionState["workspaces"];
|
workspaces: SessionState["workspaces"];
|
||||||
agents: SessionState["agents"];
|
workspaceAgentActivity: SessionState["workspaceAgentActivity"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectStatusModeSessions(
|
export function selectStatusModeSessions(
|
||||||
@@ -38,7 +38,7 @@ export function selectStatusModeSessions(
|
|||||||
statusSessions.push({
|
statusSessions.push({
|
||||||
serverId,
|
serverId,
|
||||||
workspaces: session.workspaces,
|
workspaces: session.workspaces,
|
||||||
agents: session.agents,
|
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return statusSessions;
|
return statusSessions;
|
||||||
@@ -59,7 +59,7 @@ export function areStatusModeSessionsEqual(
|
|||||||
!rightSession ||
|
!rightSession ||
|
||||||
leftSession.serverId !== rightSession.serverId ||
|
leftSession.serverId !== rightSession.serverId ||
|
||||||
leftSession.workspaces !== rightSession.workspaces ||
|
leftSession.workspaces !== rightSession.workspaces ||
|
||||||
leftSession.agents !== rightSession.agents
|
leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ import {
|
|||||||
createAgentLastActivityCoalescer,
|
createAgentLastActivityCoalescer,
|
||||||
type AgentLastActivityCommitter,
|
type AgentLastActivityCommitter,
|
||||||
} from "@/runtime/activity";
|
} from "@/runtime/activity";
|
||||||
|
import {
|
||||||
|
buildWorkspaceAgentActivityIndex,
|
||||||
|
type WorkspaceAgentActivity,
|
||||||
|
} from "@/utils/workspace-agent-activity";
|
||||||
|
|
||||||
// Re-export types that were in session-context
|
// Re-export types that were in session-context
|
||||||
export type MessageEntry =
|
export type MessageEntry =
|
||||||
@@ -353,6 +357,7 @@ export interface SessionState {
|
|||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
agents: Map<string, Agent>;
|
agents: Map<string, Agent>;
|
||||||
|
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||||
agentDetails: Map<string, Agent>;
|
agentDetails: Map<string, Agent>;
|
||||||
workspaces: Map<string, WorkspaceDescriptor>;
|
workspaces: Map<string, WorkspaceDescriptor>;
|
||||||
// Project parents with no active workspaces, keyed by projectId. The
|
// Project parents with no active workspaces, keyed by projectId. The
|
||||||
@@ -560,6 +565,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
|||||||
agentAuthoritativeHistoryApplied: new Map(),
|
agentAuthoritativeHistoryApplied: new Map(),
|
||||||
initializingAgents: new Map(),
|
initializingAgents: new Map(),
|
||||||
agents: new Map(),
|
agents: new Map(),
|
||||||
|
workspaceAgentActivity: new Map(),
|
||||||
agentDetails: new Map(),
|
agentDetails: new Map(),
|
||||||
workspaces: new Map(),
|
workspaces: new Map(),
|
||||||
emptyProjects: new Map(),
|
emptyProjects: new Map(),
|
||||||
@@ -1194,7 +1200,11 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
...prev,
|
...prev,
|
||||||
sessions: {
|
sessions: {
|
||||||
...prev.sessions,
|
...prev.sessions,
|
||||||
[serverId]: { ...session, agents: nextAgents },
|
[serverId]: {
|
||||||
|
...session,
|
||||||
|
agents: nextAgents,
|
||||||
|
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(nextAgents),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,39 @@ const SERVER_ID = "server-1";
|
|||||||
const AGENT_ID = "agent-1";
|
const AGENT_ID = "agent-1";
|
||||||
const WORKSPACE_ID = "workspace-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 {
|
function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
|
||||||
return (
|
return (
|
||||||
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
|
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
|
||||||
@@ -44,11 +77,7 @@ function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
|
|||||||
} as unknown as Parameters<typeof store.updateSessionServerInfo>[1]);
|
} as unknown as Parameters<typeof store.updateSessionServerInfo>[1]);
|
||||||
store.setAgents(SERVER_ID, (prev) => {
|
store.setAgents(SERVER_ID, (prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(AGENT_ID, {
|
next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z")));
|
||||||
id: AGENT_ID,
|
|
||||||
workspaceId: WORKSPACE_ID,
|
|
||||||
archivedAt: new Date(),
|
|
||||||
} as unknown as Agent);
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -95,11 +124,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
|||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.setAgents(SERVER_ID, (prev) => {
|
store.setAgents(SERVER_ID, (prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(AGENT_ID, {
|
next.set(AGENT_ID, agent(null));
|
||||||
id: AGENT_ID,
|
|
||||||
workspaceId: WORKSPACE_ID,
|
|
||||||
archivedAt: undefined,
|
|
||||||
} as unknown as Agent);
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
SidebarProjectEntry,
|
SidebarProjectEntry,
|
||||||
SidebarWorkspaceEntry,
|
SidebarWorkspaceEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildSidebarShortcutModel,
|
buildSidebarShortcutModel,
|
||||||
@@ -202,11 +203,13 @@ describe("buildStatusSidebarShortcutModel", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const model = buildStatusSidebarShortcutModel({
|
const model = buildStatusSidebarShortcutModel({
|
||||||
workspaces,
|
groups: buildStatusGroups(
|
||||||
projectNamesByKey: new Map([
|
workspaces,
|
||||||
["p1", "Project 1"],
|
new Map([
|
||||||
["p2", "Project 2"],
|
["p1", "Project 1"],
|
||||||
]),
|
["p2", "Project 2"],
|
||||||
|
]),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.shortcutTargets).toEqual([
|
expect(model.shortcutTargets).toEqual([
|
||||||
@@ -242,8 +245,7 @@ describe("buildStatusSidebarShortcutModel", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const model = buildStatusSidebarShortcutModel({
|
const model = buildStatusSidebarShortcutModel({
|
||||||
workspaces,
|
groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])),
|
||||||
projectNamesByKey: new Map([["p1", "Project 1"]]),
|
|
||||||
collapsedStatusGroupKeys: new Set(["needs_input"]),
|
collapsedStatusGroupKeys: new Set(["needs_input"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
SidebarProjectEntry,
|
SidebarProjectEntry,
|
||||||
SidebarStatusWorkspacePlacement,
|
|
||||||
SidebarWorkspacePlacement,
|
SidebarWorkspacePlacement,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} 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 {
|
export interface SidebarShortcutWorkspaceTarget {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -53,17 +52,15 @@ export function buildSidebarShortcutModel(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildStatusSidebarShortcutModel(input: {
|
export function buildStatusSidebarShortcutModel(input: {
|
||||||
workspaces: SidebarStatusWorkspacePlacement[];
|
groups: readonly StatusGroup[];
|
||||||
projectNamesByKey: Map<string, string>;
|
|
||||||
collapsedStatusGroupKeys?: ReadonlySet<string>;
|
collapsedStatusGroupKeys?: ReadonlySet<string>;
|
||||||
shortcutLimit?: number;
|
shortcutLimit?: number;
|
||||||
}): SidebarShortcutModel {
|
}): SidebarShortcutModel {
|
||||||
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
|
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
|
||||||
const groups = buildStatusGroups(input.workspaces, input.projectNamesByKey);
|
|
||||||
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [];
|
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [];
|
||||||
const shortcutIndexByWorkspaceKey = new Map<string, number>();
|
const shortcutIndexByWorkspaceKey = new Map<string, number>();
|
||||||
|
|
||||||
for (const group of groups) {
|
for (const group of input.groups) {
|
||||||
if (input.collapsedStatusGroupKeys?.has(group.bucket)) {
|
if (input.collapsedStatusGroupKeys?.has(group.bucket)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, Agent>,
|
||||||
|
): Map<string, WorkspaceAgentActivity> {
|
||||||
|
const activityByWorkspaceId = new Map<string, WorkspaceAgentActivity>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user