Prevent Android chats from freezing or going blank (#1989)

* fix(app): stabilize streamed chat rendering

Use deterministic Markdown AST keys and preserve unique row identities when an assistant message resumes after a tool. Keep native gesture and plan-card children correctly keyed to avoid unsupported Fabric reconciliation paths.

* fix(app): stabilize retained native panels

Keep retained workspace roots in a stable native order and make active
panels present in the selection commit. Hidden panels now stop expensive
subscriptions and animations without losing mounted state.

* fix(app): harden retained panel activity
This commit is contained in:
Mohamed Boudra
2026-07-11 15:39:14 +08:00
committed by GitHub
parent 860fcb2e35
commit 28a27b02d3
40 changed files with 1244 additions and 543 deletions
@@ -35,18 +35,23 @@ export type AssistantTurnForkHandler = (input: {
export const TurnFooter = memo(function TurnFooter({
isRunning,
isActive,
inFlightTurnStartedAt,
host,
strategy,
onForkAssistantTurn,
}: {
isRunning: boolean;
isActive: boolean;
inFlightTurnStartedAt: Date | null;
host: TurnFooterHost | null;
strategy: TurnContentStrategy;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
if (isRunning) {
if (!isActive) {
return null;
}
return (
<TurnFooterRow>
<RunningTurnFooter inFlightTurnStartedAt={inFlightTurnStartedAt} />
+4 -3
View File
@@ -2,7 +2,6 @@ import React, {
forwardRef,
memo,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
@@ -88,7 +87,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { MountedTabActiveContext } from "@/components/split-container";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { generateDraftId } from "@/stores/draft-keys";
import {
buildDraftWorkspaceAttachmentScopeKey,
@@ -509,7 +508,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
// cell-window renders on every 48ms flush from background agents.
// When isActive flips back to true, the context change triggers a re-render and
// the component reads the current (fresh) streamItems/streamHead from props.
const isActive = useContext(MountedTabActiveContext);
const isActive = useRetainedPanelActive();
const frozenStreamItemsRef = useRef(streamItems);
const frozenStreamHeadRef = useRef(streamHead);
if (isActive) {
@@ -773,6 +772,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
showRunningTurnFooter || bottomTurnFooterHost ? (
<TurnFooter
isRunning={showRunningTurnFooter}
isActive={isActive}
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
@@ -781,6 +781,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
) : null,
[
handleForkAssistantTurn,
isActive,
showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
+33 -6
View File
@@ -84,7 +84,7 @@ import {
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
import { usePanelStore } from "@/stores/panel-store";
import { selectIsAgentListOpen, 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";
@@ -451,9 +451,16 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
useActiveWorktreeNewAction();
useGlobalNewWorkspaceAction();
const sidebarChrome = (
<SidebarChrome
showSidebar={chromeEnabled && (isCompactLayout || !isFocusModeEnabled)}
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
/>
);
const workspaceChrome = (
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && <LeftSidebar />}
{!isCompactLayout ? sidebarChrome : null}
{isCompactLayout && chromeEnabled ? (
<CompactExplorerSidebarHost enabled={chromeEnabled}>
<View style={flexStyle}>{children}</View>
@@ -468,7 +475,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar />}
{isCompactLayout ? sidebarChrome : null}
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
@@ -477,7 +484,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<HostChooserModal />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<QuittingOverlay />
@@ -490,7 +496,26 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
return <SidebarModelProvider>{content}</SidebarModelProvider>;
return content;
}
function SidebarChrome({
showSidebar,
keyboardShortcutsEnabled,
}: {
showSidebar: boolean;
keyboardShortcutsEnabled: boolean;
}) {
const isCompactLayout = useIsCompactFormFactor();
const isOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
return (
<SidebarModelProvider active={showSidebar && isOpen}>
{showSidebar ? <LeftSidebar /> : null}
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
</SidebarModelProvider>
);
}
function MobileGestureWrapper({
@@ -504,7 +529,9 @@ function MobileGestureWrapper({
return (
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
{children}
<View collapsable={false} style={layoutStyles.surfaceFill}>
{children}
</View>
</GestureDetector>
);
}
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native";
import { StyleSheet, View } from "react-native";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { RetainedPanel } from "@/components/retained-panel";
import {
type ActiveWorkspaceSelection,
useActiveWorkspaceSelection,
@@ -15,6 +16,7 @@ import {
areWorkspaceSelectionListsEqual,
areWorkspaceSelectionsEqual,
getWorkspaceSelectionKey,
orderWorkspaceSelectionsForStableRender,
pruneMountedWorkspaceSelections,
shouldKeepWorkspaceDeckEntryMounted,
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
@@ -185,22 +187,25 @@ function WorkspaceDeck() {
);
}, []);
useEffect(() => {
if (!activeSelection) {
return;
}
setMountedSelections((current) => {
const next = pruneMountedWorkspaceSelections({
currentSelections: current,
const nextMountedSelections = useMemo(
() =>
pruneMountedWorkspaceSelections({
currentSelections: mountedSelections,
activeSelection,
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
});
if (areWorkspaceSelectionListsEqual(current, next)) {
return current;
}
return next;
});
}, [activeSelection]);
}),
[activeSelection, mountedSelections],
);
const renderedSelections = useMemo(
() => orderWorkspaceSelectionsForStableRender(nextMountedSelections),
[nextMountedSelections],
);
useLayoutEffect(() => {
if (!areWorkspaceSelectionListsEqual(mountedSelections, nextMountedSelections)) {
setMountedSelections(nextMountedSelections);
}
}, [mountedSelections, nextMountedSelections]);
if (!activeSelection) {
return null;
@@ -208,7 +213,7 @@ function WorkspaceDeck() {
return (
<View style={styles.deck}>
{mountedSelections.map((selection) => {
{renderedSelections.map((selection) => {
return (
<WorkspaceDeckEntry
key={getWorkspaceSelectionKey(selection)}
@@ -251,8 +256,8 @@ function WorkspaceDeckEntry({
}
return (
<View
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
<RetainedPanel
active={isActive}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
@@ -260,7 +265,7 @@ function WorkspaceDeckEntry({
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
/>
</View>
</RetainedPanel>
);
}
@@ -268,11 +273,4 @@ const styles = StyleSheet.create({
deck: {
flex: 1,
},
activeDeckEntry: {
flex: 1,
},
inactiveDeckEntry: {
display: "none",
flex: 1,
},
});
@@ -38,6 +38,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { isWeb } from "@/constants/platform";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
@@ -121,24 +122,26 @@ export function CompactExplorerSidebar({
);
return (
<MobilePanelOverlay
panel="file-explorer"
closeGesture={closeGesture}
panelStyle={mobileSidebarStyle}
>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</MobilePanelOverlay>
<RetainedPanelActivity active={isOpen}>
<MobilePanelOverlay
panel="file-explorer"
closeGesture={closeGesture}
panelStyle={mobileSidebarStyle}
>
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</MobilePanelOverlay>
</RetainedPanelActivity>
);
}
+3 -3
View File
@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useMemo, useRef } from "react";
import React, { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import {
@@ -29,7 +29,7 @@ import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/ut
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { MountedTabActiveContext } from "@/components/split-container";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
@@ -414,7 +414,7 @@ export function FilePane({
// Re-read the file when this pane becomes visible again (#445). `isActive`
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
// gate itself lives in isFileQueryEnabled.
const isActive = useContext(MountedTabActiveContext);
const isActive = useRetainedPanelActive();
const isAppVisible = useAppVisible();
const query = useQuery({
+41 -26
View File
@@ -36,8 +36,12 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import { type SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import { RetainedPanelActivity } from "@/components/retained-panel";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -75,6 +79,7 @@ interface SidebarSharedProps {
theme: SidebarTheme;
statusGroups: StatusGroup[];
projects: SidebarProjectEntry[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
isInitialLoad: boolean;
isRevalidating: boolean;
@@ -132,6 +137,7 @@ export const LeftSidebar = memo(function LeftSidebar() {
const {
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
@@ -235,6 +241,7 @@ export const LeftSidebar = memo(function LeftSidebar() {
theme,
statusGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
@@ -250,35 +257,39 @@ export const LeftSidebar = memo(function LeftSidebar() {
if (isCompactLayout) {
return (
<MobileSidebar
{...sharedProps}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
/>
<RetainedPanelActivity active={isOpen}>
<MobileSidebar
{...sharedProps}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
/>
</RetainedPanelActivity>
);
}
return (
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
handleViewSchedules={handleViewSchedulesNavigate}
/>
<RetainedPanelActivity active={isOpen}>
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
handleViewSchedules={handleViewSchedulesNavigate}
/>
</RetainedPanelActivity>
);
});
@@ -533,6 +544,7 @@ function MobileSidebar({
theme,
statusGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
@@ -644,6 +656,7 @@ function MobileSidebar({
groupMode={groupMode}
statusGroups={statusGroups}
projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
@@ -671,6 +684,7 @@ function DesktopSidebar({
theme,
statusGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
@@ -796,6 +810,7 @@ function DesktopSidebar({
groupMode={groupMode}
statusGroups={statusGroups}
projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
+2 -2
View File
@@ -697,7 +697,7 @@ interface LiveElapsedProps {
}
/**
* Ticks every 100ms to render an elapsed duration. Isolated from parents so
* Ticks every second to render an elapsed duration. Isolated from parents so
* only this component re-renders on each tick.
*/
export const LiveElapsed = memo(function LiveElapsed({
@@ -712,7 +712,7 @@ export const LiveElapsed = memo(function LiveElapsed({
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
const handle = setInterval(() => {
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
}, 100);
}, 1000);
return () => clearInterval(handle);
}, [startedAtMs]);
+8 -28
View File
@@ -9,22 +9,16 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
function MarkdownInlineText({
textKey,
inheritedStyle,
ruleStyle,
children,
}: {
textKey: string;
inheritedStyle: StyleProp<TextStyle>;
ruleStyle: StyleProp<TextStyle>;
children: ReactNode;
}) {
const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]);
return (
<Text key={textKey} style={style}>
{children}
</Text>
);
return <Text style={style}>{children}</Text>;
}
function MarkdownListItemContent({
@@ -39,12 +33,10 @@ function MarkdownListItemContent({
}
function MarkdownParagraph({
textKey,
paragraphStyle,
isLastChild,
children,
}: {
textKey: string;
paragraphStyle: StyleProp<ViewStyle>;
isLastChild: boolean;
children: ReactNode;
@@ -53,11 +45,7 @@ function MarkdownParagraph({
() => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null],
[paragraphStyle, isLastChild],
);
return (
<View key={textKey} style={style}>
{children}
</View>
);
return <View style={style}>{children}</View>;
}
function createPlanMarkdownRules() {
@@ -69,11 +57,7 @@ function createPlanMarkdownRules() {
styles: MarkdownRuleStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInlineText
textKey={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.text}
>
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.text}>
{node.content}
</MarkdownInlineText>
),
@@ -85,7 +69,7 @@ function createPlanMarkdownRules() {
inheritedStyles: TextStyle = {},
) => (
<MarkdownInlineText
textKey={node.key}
key={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.textgroup}
>
@@ -100,7 +84,7 @@ function createPlanMarkdownRules() {
inheritedStyles: TextStyle = {},
) => (
<MarkdownInlineText
textKey={node.key}
key={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.code_block}
>
@@ -114,11 +98,7 @@ function createPlanMarkdownRules() {
styles: MarkdownRuleStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInlineText
textKey={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.fence}
>
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.fence}>
{node.content}
</MarkdownInlineText>
),
@@ -130,7 +110,7 @@ function createPlanMarkdownRules() {
inheritedStyles: TextStyle = {},
) => (
<MarkdownInlineText
textKey={node.key}
key={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.code_inline}
>
@@ -183,7 +163,7 @@ function createPlanMarkdownRules() {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<MarkdownParagraph
textKey={node.key}
key={node.key}
paragraphStyle={styles.paragraph}
isLastChild={isLastChild}
>
@@ -0,0 +1,69 @@
import { createContext, memo, type ReactNode, useContext } from "react";
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
const RetainedPanelActiveContext = createContext(true);
export function useRetainedPanelActive(): boolean {
return useContext(RetainedPanelActiveContext);
}
interface RetainedPanelProps {
active: boolean;
children: ReactNode;
style?: StyleProp<ViewStyle>;
testID?: string;
}
interface RetainedPanelActivityProps {
active: boolean;
children: ReactNode;
}
export function RetainedPanelActivity({ active, children }: RetainedPanelActivityProps) {
const parentActive = useRetainedPanelActive();
return (
<RetainedPanelActiveContext value={parentActive && active}>
{children}
</RetainedPanelActiveContext>
);
}
/**
* Keeps expensive panel state mounted without letting an inactive panel render
* on screen. The stable, non-collapsible native root is intentional: retained
* panels must not detach or reparent their native descendants when visibility
* changes.
*/
export const RetainedPanel = memo(function RetainedPanel({
active,
children,
style,
testID,
}: RetainedPanelProps) {
const visibleStyle = StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(styles.root, style);
const panelStyle = active
? visibleStyle
: StyleSheet.compose<ViewStyle, ViewStyle, ViewStyle>(visibleStyle, styles.hidden);
return (
<RetainedPanelActivity active={active}>
<View
collapsable={false}
pointerEvents={active ? "auto" : "none"}
style={panelStyle}
testID={testID}
>
{children}
</View>
</RetainedPanelActivity>
);
});
const styles = StyleSheet.create({
root: {
flex: 1,
},
hidden: {
display: "none",
},
});
@@ -62,7 +62,6 @@ import {
} from "@/utils/host-routes";
import {
shouldShowSidebarHostLabels,
useSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
type SidebarWorkspacePlacement,
@@ -223,6 +222,7 @@ function selectionForSelectedWorkspace(
interface SidebarWorkspaceListProps {
statusGroups: StatusGroup[];
projects: SidebarProjectEntry[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
collapsedProjectKeys: ReadonlySet<string>;
onToggleProjectCollapsed: (projectKey: string) => void;
@@ -1613,6 +1613,7 @@ function WorkspaceRowWithMenu({
interface WorkspaceRowItemProps {
workspace: SidebarWorkspacePlacement;
workspaceEntry: SidebarWorkspaceEntry | null;
subtitle?: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
@@ -1628,6 +1629,7 @@ interface WorkspaceRowItemProps {
function WorkspaceRowItem({
workspace,
workspaceEntry,
subtitle,
shortcutNumber,
showShortcutBadge,
@@ -1650,7 +1652,7 @@ function WorkspaceRowItem({
return (
<WorkspaceRow
workspace={workspace}
workspaceEntry={workspaceEntry}
subtitle={subtitle}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
@@ -1688,6 +1690,7 @@ function areWorkspaceRowItemPropsEqual(
});
return (
previous.workspace === next.workspace &&
previous.workspaceEntry === next.workspaceEntry &&
previous.subtitle === next.subtitle &&
previous.shortcutNumber === next.shortcutNumber &&
previous.showShortcutBadge === next.showShortcutBadge &&
@@ -1704,7 +1707,7 @@ function areWorkspaceRowItemPropsEqual(
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
function WorkspaceRow({
workspace,
workspaceEntry,
subtitle,
shortcutNumber,
showShortcutBadge,
@@ -1716,7 +1719,7 @@ function WorkspaceRow({
isCreating = false,
selected,
}: {
workspace: SidebarWorkspacePlacement;
workspaceEntry: SidebarWorkspaceEntry | null;
subtitle?: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
@@ -1728,15 +1731,13 @@ function WorkspaceRow({
isCreating?: boolean;
selected: boolean;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
if (!hydratedWorkspace) {
if (!workspaceEntry) {
return null;
}
return (
<WorkspaceRowWithMenu
workspace={hydratedWorkspace}
workspace={workspaceEntry}
subtitle={subtitle}
selected={selected}
shortcutNumber={shortcutNumber}
@@ -1753,6 +1754,7 @@ function WorkspaceRow({
function ProjectBlock({
project,
workspaceEntriesByKey,
collapsed,
displayName,
iconDataUri,
@@ -1775,6 +1777,7 @@ function ProjectBlock({
supportsMultiplicityByServerId,
}: {
project: SidebarProjectEntry;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
collapsed: boolean;
displayName: string;
iconDataUri: string | null;
@@ -1824,6 +1827,7 @@ function ProjectBlock({
return (
<MemoWorkspaceRowItem
workspace={item}
workspaceEntry={workspaceEntriesByKey.get(item.workspaceKey) ?? null}
subtitle={
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
}
@@ -1850,6 +1854,7 @@ function ProjectBlock({
selectionEnabled,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
workspaceEntriesByKey,
],
);
@@ -2004,6 +2009,7 @@ type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
return (
previous.project === next.project &&
previous.workspaceEntriesByKey === next.workspaceEntriesByKey &&
previous.collapsed === next.collapsed &&
previous.displayName === next.displayName &&
previous.iconDataUri === next.iconDataUri &&
@@ -2058,6 +2064,7 @@ const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
export function SidebarWorkspaceList({
statusGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
collapsedProjectKeys,
onToggleProjectCollapsed,
@@ -2096,6 +2103,7 @@ export function SidebarWorkspaceList({
) : (
<ProjectModeList
projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={onToggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
@@ -2145,6 +2153,7 @@ function SidebarStatusModeWrapper({
function ProjectModeList({
projects,
workspaceEntriesByKey,
collapsedProjectKeys,
onToggleProjectCollapsed,
shortcutIndexByWorkspaceKey,
@@ -2333,6 +2342,7 @@ function ProjectModeList({
return (
<MemoProjectBlock
project={item}
workspaceEntriesByKey={workspaceEntriesByKey}
collapsed={collapsedProjectKeys.has(item.projectKey)}
displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
@@ -2371,6 +2381,7 @@ function ProjectModeList({
selectionEnabled,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
workspaceEntriesByKey,
creatingWorkspaceIds,
],
);
@@ -1,9 +1,10 @@
import React, { createContext, useContext, useMemo, type ReactNode } from "react";
import {
useSidebarWorkspacesList,
type SidebarWorkspaceEntry,
type SidebarWorkspacesListResult,
} from "@/hooks/use-sidebar-workspaces-list";
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries";
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";
@@ -14,6 +15,7 @@ import {
} from "@/utils/sidebar-shortcuts";
interface SidebarModel extends SidebarWorkspacesListResult {
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
groupMode: SidebarGroupMode;
statusGroups: StatusGroup[];
collapsedProjectKeys: ReadonlySet<string>;
@@ -23,7 +25,13 @@ interface SidebarModel extends SidebarWorkspacesListResult {
const SidebarModelContext = createContext<SidebarModel | null>(null);
export function SidebarModelProvider({ children }: { children: ReactNode }) {
export function SidebarModelProvider({
active,
children,
}: {
active?: boolean;
children: ReactNode;
}) {
const list = useSidebarWorkspacesList();
const groupMode = useSidebarViewStore((state) => state.groupMode);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
@@ -36,14 +44,16 @@ export function SidebarModelProvider({ children }: { children: ReactNode }) {
(state) => state.toggleProjectCollapsed,
);
const isStatusMode = groupMode === "status";
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
placements: list.workspacePlacements,
enabled: isStatusMode,
});
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
list.workspacePlacements,
active !== false || isStatusMode,
);
const statusGroups = useMemo(
() =>
isStatusMode ? buildStatusGroups(statusWorkspacePlacements, list.projectNamesByKey) : [],
[isStatusMode, list.projectNamesByKey, statusWorkspacePlacements],
isStatusMode
? buildStatusGroups(Array.from(workspaceEntriesByKey.values()), list.projectNamesByKey)
: [],
[isStatusMode, list.projectNamesByKey, workspaceEntriesByKey],
);
const shortcutModel = useMemo(() => {
if (isStatusMode) {
@@ -57,13 +67,22 @@ export function SidebarModelProvider({ children }: { children: ReactNode }) {
const value = useMemo(
() => ({
...list,
workspaceEntriesByKey,
groupMode,
statusGroups,
collapsedProjectKeys,
toggleProjectCollapsed,
shortcutModel,
}),
[collapsedProjectKeys, groupMode, list, shortcutModel, statusGroups, toggleProjectCollapsed],
[
collapsedProjectKeys,
groupMode,
list,
shortcutModel,
statusGroups,
toggleProjectCollapsed,
workspaceEntriesByKey,
],
);
return <SidebarModelContext.Provider value={value}>{children}</SidebarModelContext.Provider>;
@@ -4,11 +4,7 @@ import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } fr
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import {
useSidebarWorkspaceEntry,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
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";
@@ -286,13 +282,12 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
showShortcutBadge,
onWorkspacePress,
}: {
workspace: SidebarStatusWorkspacePlacement;
workspace: SidebarWorkspaceEntry;
subtitle: string;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onWorkspacePress?: () => void;
}) {
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
@@ -304,11 +299,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!workspaceEntry) return null;
return (
<StatusWorkspaceRowWithMenu
workspace={workspaceEntry}
workspace={workspace}
subtitle={subtitle}
selected={selected}
shortcutNumber={shortcutNumber}
@@ -142,7 +142,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
</View>
<View style={styles.workspaceRowRight}>{children}</View>
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
{subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
@@ -502,7 +502,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
shortcutBadgeOverlay: {
position: "absolute",
top: 1,
@@ -1,6 +1,5 @@
import {
Fragment,
createContext,
memo,
useCallback,
useEffect,
@@ -32,6 +31,7 @@ import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { RetainedPanel } from "@/components/retained-panel";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
@@ -74,10 +74,6 @@ import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
// true = this tab slot is the active (visible) tab; false = mounted but hidden.
// Defaults to true so consumers outside a slot (e.g. web preview) are unaffected.
export const MountedTabActiveContext = createContext<boolean>(true);
interface SplitContainerProps {
layout: WorkspaceLayout;
workspaceKey: string;
@@ -214,26 +210,20 @@ const MountedTabSlot = memo(function MountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
const wrapperStyle = useMemo(() => {
const display: "flex" | "none" = isVisible ? "flex" : "none";
return { display, flex: 1 };
}, [isVisible]);
const handleFocusPane = useCallback(() => {
onFocusPane(paneId);
}, [onFocusPane, paneId]);
return (
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<MountedTabActiveContext value={isVisible}>
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
</MountedTabActiveContext>
<RetainedPanel active={isVisible}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</RetainedPanel>
</RenderProfile>
);
});
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSharedStepClock } from "@/components/synced-loader-clock";
afterEach(() => {
vi.useRealTimers();
});
describe("createSharedStepClock", () => {
it("advances a shared cadence only while at least one consumer is subscribed", () => {
vi.useFakeTimers();
const clock = createSharedStepClock(6, 960);
const firstListener = vi.fn();
const secondListener = vi.fn();
const unsubscribeFirst = clock.subscribe(firstListener);
const unsubscribeSecond = clock.subscribe(secondListener);
vi.advanceTimersByTime(320);
expect(clock.getSnapshot()).toBe(2);
expect(firstListener).toHaveBeenCalledTimes(2);
expect(secondListener).toHaveBeenCalledTimes(2);
unsubscribeFirst();
vi.advanceTimersByTime(160);
expect(firstListener).toHaveBeenCalledTimes(2);
expect(secondListener).toHaveBeenCalledTimes(3);
unsubscribeSecond();
vi.advanceTimersByTime(640);
expect(clock.getSnapshot()).toBe(3);
expect(secondListener).toHaveBeenCalledTimes(3);
});
});
@@ -0,0 +1,36 @@
export interface SharedStepClock {
getSnapshot: () => number;
subscribe: (listener: () => void) => () => void;
}
export function createSharedStepClock(stepCount: number, cycleDurationMs: number): SharedStepClock {
const normalizedStepCount = Math.max(1, Math.floor(stepCount));
const stepDurationMs = Math.max(1, Math.round(cycleDurationMs / normalizedStepCount));
const listeners = new Set<() => void>();
let step = 0;
let timer: ReturnType<typeof setInterval> | null = null;
function advance(): void {
step = (step + 1) % normalizedStepCount;
for (const listener of listeners) {
listener();
}
}
return {
getSnapshot: () => step,
subscribe: (listener) => {
listeners.add(listener);
if (timer === null) {
timer = setInterval(advance, stepDurationMs);
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && timer !== null) {
clearInterval(timer);
timer = null;
}
};
},
};
}
+42 -91
View File
@@ -1,65 +1,26 @@
import { useMemo, useSyncExternalStore } from "react";
import { View } from "react-native";
import Animated, {
Easing,
makeMutable,
type SharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useEffect, useMemo } from "react";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { createSharedStepClock } from "@/components/synced-loader-clock";
const SYNCED_LOADER_DURATION_MS = 950;
const SYNCED_LOADER_EPOCH_MS = 0;
const SYNCED_LOADER_DURATION_MS = 6_000;
const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const;
const DOT_COUNT = DOT_SEQUENCE.length;
const GRID_COLUMNS = 2;
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
const DOT_KEYS = Array.from({ length: DOT_COUNT }, (_, i) => `dot-${i}`);
const sharedStepProgress = makeMutable(0);
let sharedLoopStarted = false;
function ensureSharedStepLoopStarted(): void {
if (sharedLoopStarted) {
return;
}
sharedLoopStarted = true;
const elapsedMs = (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
sharedStepProgress.value = withTiming(
DOT_COUNT,
{
duration: Math.max(1, Math.round(SYNCED_LOADER_DURATION_MS - elapsedMs)),
easing: Easing.linear,
},
(finished) => {
if (!finished) {
sharedLoopStarted = false;
return;
}
sharedStepProgress.value = 0;
sharedStepProgress.value = withRepeat(
withTiming(DOT_COUNT, {
duration: SYNCED_LOADER_DURATION_MS,
easing: Easing.linear,
}),
-1,
false,
);
},
);
}
const DOT_INDEXES = Array.from({ length: DOT_COUNT }, (_, dotIndex) => dotIndex);
const DOT_KEYS = DOT_INDEXES.map((dotIndex) => `dot-${dotIndex}`);
const sharedStepClock = createSharedStepClock(DOT_COUNT, SYNCED_LOADER_DURATION_MS);
const subscribePaused = () => () => {};
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
useEffect(() => {
ensureSharedStepLoopStarted();
}, []);
const animatedStyle = useAnimatedStyle(() => ({
opacity: 1,
}));
const active = useRetainedPanelActive();
const step = useSyncExternalStore(
active ? sharedStepClock.subscribe : subscribePaused,
sharedStepClock.getSnapshot,
sharedStepClock.getSnapshot,
);
const gap = Math.max(1, Math.round(size * 0.12));
const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
@@ -67,10 +28,9 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
const gridHeight = dotSize * 3 + gap * 2;
const gridStyle = useMemo(
() => [animatedStyle, { width: gridWidth, height: gridHeight }],
[animatedStyle, gridWidth, gridHeight],
() => ({ width: gridWidth, height: gridHeight }),
[gridHeight, gridWidth],
);
const containerStyle = useMemo(
() =>
({
@@ -84,8 +44,8 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
return (
<View style={containerStyle}>
<Animated.View style={gridStyle}>
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
<View style={gridStyle}>
{DOT_INDEXES.map((dotIndex) => {
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
const columnIndex = dotIndex % GRID_COLUMNS;
const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
@@ -96,13 +56,13 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
color={color}
dotSize={dotSize}
sequenceIndex={sequenceIndex}
progress={sharedStepProgress}
step={step}
left={columnIndex * (dotSize + gap)}
top={rowIndex * (dotSize + gap)}
/>
);
})}
</Animated.View>
</View>
</View>
);
}
@@ -111,50 +71,41 @@ function SpinnerDot({
color,
dotSize,
sequenceIndex,
progress,
step,
left,
top,
}: {
color: string;
dotSize: number;
sequenceIndex: number;
progress: SharedValue<number>;
step: number;
left: number;
top: number;
}) {
const animatedStyle = useAnimatedStyle(() => {
const headIndex = Math.floor(progress.value) % DOT_COUNT;
let opacity = 0;
let opacity = 0;
for (let segmentIndex = 0; segmentIndex < SNAKE_SEGMENT_OFFSETS.length; segmentIndex += 1) {
const activeSequenceIndex =
(headIndex + SNAKE_SEGMENT_OFFSETS[segmentIndex] + DOT_COUNT) % DOT_COUNT;
if (sequenceIndex === activeSequenceIndex) {
opacity = SNAKE_OPACITIES[segmentIndex] ?? 0;
break;
}
for (let segmentIndex = 0; segmentIndex < SNAKE_SEGMENT_OFFSETS.length; segmentIndex += 1) {
const activeSequenceIndex =
(step + SNAKE_SEGMENT_OFFSETS[segmentIndex] + DOT_COUNT) % DOT_COUNT;
if (sequenceIndex === activeSequenceIndex) {
opacity = SNAKE_OPACITIES[segmentIndex] ?? 0;
break;
}
return {
opacity,
};
});
}
const dotStyle = useMemo(
() => [
animatedStyle,
{
width: dotSize,
height: dotSize,
borderRadius: dotSize / 2,
backgroundColor: color,
position: "absolute" as const,
left,
top,
},
],
[animatedStyle, dotSize, color, left, top],
() => ({
width: dotSize,
height: dotSize,
borderRadius: dotSize / 2,
backgroundColor: color,
position: "absolute" as const,
left,
top,
opacity,
}),
[color, dotSize, left, opacity, top],
);
return <Animated.View style={dotStyle} />;
return <View style={dotStyle} />;
}
@@ -1,6 +1,6 @@
import type { SidebarStatusWorkspacePlacement } from "@/hooks/sidebar-workspaces-view-model";
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
export type StatusBucket = SidebarStatusWorkspacePlacement["statusBucket"];
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
"needs_input",
@@ -21,14 +21,14 @@ export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
export interface StatusGroup {
bucket: StatusBucket;
label: string;
rows: SidebarStatusWorkspacePlacement[];
rows: SidebarWorkspaceEntry[];
}
export function buildStatusGroups(
workspaces: SidebarStatusWorkspacePlacement[],
workspaces: SidebarWorkspaceEntry[],
projectNamesByKey: Map<string, string>,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarStatusWorkspacePlacement[]>();
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
for (const ws of workspaces) {
const bucket: StatusBucket = ws.statusBucket;
@@ -54,8 +54,8 @@ export function buildStatusGroups(
}
function compareStatusRows(
a: SidebarStatusWorkspacePlacement,
b: SidebarStatusWorkspacePlacement,
a: SidebarWorkspaceEntry,
b: SidebarWorkspaceEntry,
projectNamesByKey: Map<string, string>,
): number {
const aTime = a.statusEnteredAt?.getTime() ?? null;
@@ -4,7 +4,7 @@ import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarStatusWorkspacePlacements,
buildSidebarWorkspaceEntries,
buildSidebarWorkspacePlacementModel,
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
@@ -226,7 +226,7 @@ describe("shared sidebar workspace model", () => {
}),
],
});
const statusRows = buildSidebarStatusWorkspacePlacements({
const workspaceEntries = buildSidebarWorkspaceEntries({
placements: model.workspaces,
sessions: [
{
@@ -290,14 +290,66 @@ describe("shared sidebar workspace model", () => {
],
}),
]);
expect(statusRows.map((entry) => [entry.workspaceKey, entry.statusBucket, entry.name])).toEqual(
[
["host-a:main", "done", "main"],
["host-b:feature", "running", "feature/status-flow"],
],
);
expect(
Array.from(workspaceEntries.values()).map((entry) => [
entry.workspaceKey,
entry.statusBucket,
entry.name,
]),
).toEqual([
["host-a:main", "done", "main"],
["host-b:feature", "running", "feature/status-flow"],
]);
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
});
it("preserves unchanged row identities when another workspace updates", () => {
const model = buildSidebarWorkspacePlacementModel({
projects: [project({ projectKey: "project", workspaceKeys: ["srv:one", "srv:two"] })],
});
const one = workspace({
id: "one",
name: "one",
projectId: "project",
projectDisplayName: "project",
});
const two = workspace({
id: "two",
name: "two",
projectId: "project",
projectDisplayName: "project",
});
const previousEntries = buildSidebarWorkspaceEntries({
placements: model.workspaces,
sessions: [
{
serverId: "srv",
workspaceAgentActivity: new Map(),
workspaces: new Map([
["one", one],
["two", two],
]),
},
],
});
const nextEntries = buildSidebarWorkspaceEntries({
placements: model.workspaces,
sessions: [
{
serverId: "srv",
workspaceAgentActivity: new Map(),
workspaces: new Map([
["one", one],
["two", { ...two, status: "running" }],
]),
},
],
previousEntries,
});
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
});
});
describe("shouldShowSidebarHostLabels", () => {
@@ -63,12 +63,59 @@ export interface SidebarWorkspacePlacementModel {
projectNamesByKey: Map<string, string>;
}
export interface SidebarStatusWorkspaceSession {
export interface SidebarWorkspaceSession {
serverId: string;
workspaces: Map<string, WorkspaceDescriptor>;
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
}
interface SidebarWorkspaceSessionSource {
workspaces: Map<string, WorkspaceDescriptor>;
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
}
export function selectSidebarWorkspaceSessions(
sessions: Record<string, SidebarWorkspaceSessionSource | undefined>,
serverIds: readonly string[],
): SidebarWorkspaceSession[] {
const selected: SidebarWorkspaceSession[] = [];
for (const serverId of serverIds) {
const session = sessions[serverId];
if (!session) {
continue;
}
selected.push({
serverId,
workspaces: session.workspaces,
workspaceAgentActivity: session.workspaceAgentActivity,
});
}
return selected;
}
export function areSidebarWorkspaceSessionsEqual(
left: readonly SidebarWorkspaceSession[],
right: readonly SidebarWorkspaceSession[],
): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
const leftSession = left[index];
const rightSession = right[index];
if (
!leftSession ||
!rightSession ||
leftSession.serverId !== rightSession.serverId ||
leftSession.workspaces !== rightSession.workspaces ||
leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity
) {
return false;
}
}
return true;
}
interface EffectiveWorkspaceStatus {
status: WorkspaceDescriptor["status"];
enteredAt: Date | null;
@@ -245,17 +292,18 @@ function resolveStructuralWorkspaceIdentity(input: {
};
}
export function buildSidebarStatusWorkspacePlacements(input: {
export function buildSidebarWorkspaceEntries(input: {
placements: readonly SidebarWorkspacePlacement[];
sessions: SidebarStatusWorkspaceSession[];
sessions: SidebarWorkspaceSession[];
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
}): SidebarStatusWorkspacePlacement[] {
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
}): Map<string, SidebarWorkspaceEntry> {
if (input.placements.length === 0 || input.sessions.length === 0) {
return [];
return new Map();
}
const sessionByServerId = new Map(input.sessions.map((session) => [session.serverId, session]));
const rows: SidebarStatusWorkspacePlacement[] = [];
const entries = new Map<string, SidebarWorkspaceEntry>();
for (const placement of input.placements) {
const session = sessionByServerId.get(placement.serverId);
@@ -267,24 +315,46 @@ export function buildSidebarStatusWorkspacePlacements(input: {
const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : null;
if (!workspace) continue;
const effectiveStatus = deriveEffectiveWorkspaceStatus({
const entry = createSidebarWorkspaceEntry({
serverId: placement.serverId,
workspace,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceAgentActivity: session.workspaceAgentActivity,
});
rows.push({
...placement,
name: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
statusBucket: effectiveStatus.status,
statusEnteredAt: effectiveStatus.enteredAt,
});
const previousEntry = input.previousEntries?.get(placement.workspaceKey);
entries.set(
placement.workspaceKey,
previousEntry && areSidebarWorkspaceEntriesEqual(previousEntry, entry)
? previousEntry
: entry,
);
}
return rows;
return entries;
}
function areSidebarWorkspaceEntriesEqual(
left: SidebarWorkspaceEntry,
right: SidebarWorkspaceEntry,
): boolean {
const keys = Object.keys(left) as Array<keyof SidebarWorkspaceEntry>;
if (keys.length !== Object.keys(right).length) return false;
return keys.every((key) => {
if (key !== "prHint") return Object.is(left[key], right[key]);
const leftHint = left.prHint;
const rightHint = right.prHint;
return (
leftHint === rightHint ||
(leftHint !== null &&
rightHint !== null &&
leftHint.url === rightHint.url &&
leftHint.number === rightHint.number &&
leftHint.state === rightHint.state &&
leftHint.checks === rightHint.checks &&
leftHint.checksStatus === rightHint.checksStatus &&
leftHint.reviewDecision === rightHint.reviewDecision)
);
});
}
export function buildSidebarProjectsFromStructure(input: {
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
import {
areSidebarWorkspaceSessionsEqual,
selectSidebarWorkspaceSessions,
type SidebarWorkspaceSession,
} from "./sidebar-workspaces-view-model";
function workspaceMap(): Map<string, WorkspaceDescriptor> {
return new Map();
}
function activityMap(): Map<string, WorkspaceAgentActivity> {
return new Map();
}
function sidebarSession(input?: Partial<Omit<SidebarWorkspaceSession, "serverId">>) {
return {
workspaces: input?.workspaces ?? workspaceMap(),
workspaceAgentActivity: input?.workspaceAgentActivity ?? activityMap(),
};
}
describe("sidebar workspace session selection", () => {
it("selects only sessions needed by sidebar placements", () => {
const hostA = sidebarSession();
const hostB = sidebarSession();
const unusedHost = sidebarSession();
expect(
selectSidebarWorkspaceSessions(
{
"host-a": hostA,
"host-b": hostB,
unused: unusedHost,
},
["host-b", "missing", "host-a"],
),
).toEqual([
{
serverId: "host-b",
workspaces: hostB.workspaces,
workspaceAgentActivity: hostB.workspaceAgentActivity,
},
{
serverId: "host-a",
workspaces: hostA.workspaces,
workspaceAgentActivity: hostA.workspaceAgentActivity,
},
]);
});
it("ignores high-frequency session changes outside the sidebar indexes", () => {
const workspaces = workspaceMap();
const workspaceAgentActivity = activityMap();
const previous = selectSidebarWorkspaceSessions(
{ "host-a": sidebarSession({ workspaces, workspaceAgentActivity }) },
["host-a"],
);
const next = selectSidebarWorkspaceSessions(
{ "host-a": sidebarSession({ workspaces, workspaceAgentActivity }) },
["host-a"],
);
expect(previous).not.toBe(next);
expect(areSidebarWorkspaceSessionsEqual(previous, next)).toBe(true);
});
it("detects changes to a selected workspace or activity index", () => {
const workspaceAgentActivity = activityMap();
const previous = selectSidebarWorkspaceSessions(
{ "host-a": sidebarSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
["host-a"],
);
const next = selectSidebarWorkspaceSessions(
{ "host-a": sidebarSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
["host-a"],
);
expect(areSidebarWorkspaceSessionsEqual(previous, next)).toBe(false);
});
});
@@ -0,0 +1,54 @@
import { useMemo, useRef } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
areSidebarWorkspaceSessionsEqual,
buildSidebarWorkspaceEntries,
selectSidebarWorkspaceSessions,
type SidebarWorkspaceEntry,
type SidebarWorkspacePlacement,
type SidebarWorkspaceSession,
} from "./sidebar-workspaces-view-model";
const EMPTY_ENTRIES = new Map<string, SidebarWorkspaceEntry>();
const EMPTY_SESSIONS: SidebarWorkspaceSession[] = [];
const EMPTY_PENDING_CREATE_ATTEMPTS: Record<string, never> = {};
export function useSidebarWorkspaceEntries(
placements: readonly SidebarWorkspacePlacement[],
enabled = true,
): ReadonlyMap<string, SidebarWorkspaceEntry> {
const serverIds = useMemo(
() => Array.from(new Set(placements.map((placement) => placement.serverId))),
[placements],
);
const sessions = useStoreWithEqualityFn(
useSessionStore,
(state) =>
enabled ? selectSidebarWorkspaceSessions(state.sessions, serverIds) : EMPTY_SESSIONS,
areSidebarWorkspaceSessionsEqual,
);
const pendingCreateAttempts = useCreateFlowStore((state) =>
enabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
);
const previousEntriesRef = useRef<ReadonlyMap<string, SidebarWorkspaceEntry>>(EMPTY_ENTRIES);
// Collection ownership is intentional: retained sidebars have one cheap
// subscription to structurally shared indexes, never one session-store
// subscription per mounted row.
return useMemo(() => {
if (!enabled || placements.length === 0 || sessions.length === 0) {
previousEntriesRef.current = EMPTY_ENTRIES;
return EMPTY_ENTRIES;
}
const entries = buildSidebarWorkspaceEntries({
placements,
sessions,
pendingCreateAttempts,
previousEntries: previousEntriesRef.current,
});
previousEntriesRef.current = entries;
return entries;
}, [enabled, pendingCreateAttempts, placements, sessions]);
}
@@ -1,10 +1,7 @@
import { useCallback, useEffect, useMemo } from "react";
import equal from "fast-deep-equal";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
@@ -14,7 +11,6 @@ import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-works
import {
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
createSidebarWorkspaceEntry,
deriveSidebarLoadingState,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
@@ -26,10 +22,9 @@ export {
applyStoredOrdering,
buildSidebarProjectsFromHostProjects,
buildSidebarProjectsFromStructure,
buildSidebarStatusWorkspacePlacements,
createSidebarWorkspaceEntry,
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
createSidebarWorkspaceEntry,
deriveSidebarLoadingState,
shouldShowSidebarHostLabels,
type SidebarLoadingState,
@@ -42,39 +37,6 @@ export {
type SidebarWorkspaceEntry,
} from "./sidebar-workspaces-view-model";
export function useSidebarWorkspaceEntry(
serverId: string | null,
workspaceId: string | null,
): SidebarWorkspaceEntry | null {
// Deep-compare so that adding/removing unrelated pending creates doesn't re-render this row.
const pendingCreateAttempts = useStoreWithEqualityFn(
useCreateFlowStore,
(state) => state.pendingByDraftId,
workspaceEqualityFns.deep,
);
// Single subscription: reads workspace + agents together, computes the full entry, and
// deep-compares the output. Agents-Map identity churn (setAgents replaces the Map on every
// status transition) never causes a React re-render unless the derived entry actually changes.
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
const workspace = selectWorkspace(state, serverId, workspaceId);
if (!workspace) return null;
const workspaceAgentActivity = serverId
? state.sessions[serverId]?.workspaceAgentActivity
: undefined;
return createSidebarWorkspaceEntry({
serverId: serverId ?? "",
workspace,
pendingCreateAttempts,
workspaceAgentActivity,
});
},
equal,
);
}
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
@@ -1,84 +0,0 @@
import { describe, expect, it } from "vitest";
import {
areStatusModeSessionsEqual,
selectStatusModeSessions,
type StatusModeSession,
} from "./use-status-mode-workspaces";
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
import type { WorkspaceDescriptor } from "@/stores/session-store";
function workspaceMap(): Map<string, WorkspaceDescriptor> {
return new Map();
}
function activityMap(): Map<string, WorkspaceAgentActivity> {
return new Map();
}
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
return {
workspaces: input?.workspaces ?? workspaceMap(),
workspaceAgentActivity: input?.workspaceAgentActivity ?? activityMap(),
};
}
describe("status mode session selection", () => {
it("selects only sessions needed by visible placements", () => {
const hostA = statusSession();
const hostB = statusSession();
const unusedHost = statusSession();
expect(
selectStatusModeSessions(
{
"host-a": hostA,
"host-b": hostB,
unused: unusedHost,
},
["host-b", "missing", "host-a"],
),
).toEqual([
{
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 workspaceAgentActivity = activityMap();
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 activity index changes for selected hosts", () => {
const workspaceAgentActivity = activityMap();
const previous = selectStatusModeSessions(
{ "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
["host-a"],
);
const next = selectStatusModeSessions(
{ "host-a": statusSession({ workspaceAgentActivity, workspaces: workspaceMap() }) },
["host-a"],
);
expect(areStatusModeSessionsEqual(previous, next)).toBe(false);
});
});
@@ -1,100 +0,0 @@
import { useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import {
buildSidebarStatusWorkspacePlacements,
type SidebarStatusWorkspacePlacement,
type SidebarWorkspacePlacement,
} from "./use-sidebar-workspaces-list";
const EMPTY_WORKSPACES: SidebarStatusWorkspacePlacement[] = [];
const EMPTY_STATUS_SESSIONS: StatusModeSession[] = [];
const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
typeof useCreateFlowStore.getState
>["pendingByDraftId"] = {};
interface StatusModeSessionSource {
workspaces: SessionState["workspaces"];
workspaceAgentActivity: SessionState["workspaceAgentActivity"];
}
export interface StatusModeSession {
serverId: string;
workspaces: SessionState["workspaces"];
workspaceAgentActivity: SessionState["workspaceAgentActivity"];
}
export function selectStatusModeSessions(
sessions: Record<string, StatusModeSessionSource | undefined>,
serverIds: readonly string[],
): StatusModeSession[] {
const statusSessions: StatusModeSession[] = [];
for (const serverId of serverIds) {
const session = sessions[serverId];
if (!session) {
continue;
}
statusSessions.push({
serverId,
workspaces: session.workspaces,
workspaceAgentActivity: session.workspaceAgentActivity,
});
}
return statusSessions;
}
export function areStatusModeSessionsEqual(
left: readonly StatusModeSession[],
right: readonly StatusModeSession[],
): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
const leftSession = left[index];
const rightSession = right[index];
if (
!leftSession ||
!rightSession ||
leftSession.serverId !== rightSession.serverId ||
leftSession.workspaces !== rightSession.workspaces ||
leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity
) {
return false;
}
}
return true;
}
export function useStatusModeWorkspacePlacements(input: {
placements: SidebarWorkspacePlacement[];
enabled?: boolean;
}): SidebarStatusWorkspacePlacement[] {
const isEnabled = input.enabled !== false && input.placements.length > 0;
const serverIds = useMemo(
() => Array.from(new Set(input.placements.map((placement) => placement.serverId))),
[input.placements],
);
const statusSessions = useStoreWithEqualityFn(
useSessionStore,
(state) =>
isEnabled ? selectStatusModeSessions(state.sessions, serverIds) : EMPTY_STATUS_SESSIONS,
areStatusModeSessionsEqual,
);
const pendingCreateAttempts = useCreateFlowStore((state) =>
isEnabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
);
return useMemo(() => {
if (!isEnabled) {
return EMPTY_WORKSPACES;
}
return buildSidebarStatusWorkspacePlacements({
placements: input.placements,
sessions: statusSessions,
pendingCreateAttempts,
});
}, [input.placements, isEnabled, pendingCreateAttempts, statusSessions]);
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import {
orderWorkspaceSelectionsForStableRender,
pruneMountedWorkspaceSelections,
shouldKeepWorkspaceDeckEntryMounted,
} from "@/screens/workspace/workspace-deck-retention";
@@ -14,6 +15,17 @@ function mountedWorkspaceIds(selections: ActiveWorkspaceSelection[]): string[] {
}
describe("pruneMountedWorkspaceSelections", () => {
it("retains the deck while an app-wide route temporarily clears the active workspace", () => {
const mountedSelections = [workspace("A"), workspace("B")];
expect(
pruneMountedWorkspaceSelections({
currentSelections: mountedSelections,
activeSelection: null,
}),
).toBe(mountedSelections);
});
it("keeps the active workspace and the two most recent inactive workspaces", () => {
const mountedAfterA = pruneMountedWorkspaceSelections({
currentSelections: [],
@@ -64,6 +76,22 @@ describe("pruneMountedWorkspaceSelections", () => {
});
});
describe("orderWorkspaceSelectionsForStableRender", () => {
it("does not move retained native roots when the active LRU order changes", () => {
const activeA = [workspace("A"), workspace("B")];
const activeB = [workspace("B"), workspace("A")];
expect(mountedWorkspaceIds(orderWorkspaceSelectionsForStableRender(activeA))).toEqual([
"A",
"B",
]);
expect(mountedWorkspaceIds(orderWorkspaceSelectionsForStableRender(activeB))).toEqual([
"A",
"B",
]);
});
});
describe("shouldKeepWorkspaceDeckEntryMounted", () => {
it("keeps the active workspace mounted even when it is missing from hydrated workspaces", () => {
expect(
@@ -43,7 +43,7 @@ export function pruneMountedWorkspaceSelections({
maxMountedWorkspaces = WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
}: PruneMountedWorkspaceSelectionsInput): ActiveWorkspaceSelection[] {
if (!activeSelection) {
return [];
return currentSelections;
}
const maxSelections = Math.max(1, maxMountedWorkspaces);
@@ -74,6 +74,14 @@ export function pruneMountedWorkspaceSelections({
return nextSelections;
}
export function orderWorkspaceSelectionsForStableRender(
selections: ActiveWorkspaceSelection[],
): ActiveWorkspaceSelection[] {
return [...selections].sort((left, right) =>
getWorkspaceSelectionKey(left).localeCompare(getWorkspaceSelectionKey(right)),
);
}
export function shouldKeepWorkspaceDeckEntryMounted({
isActive,
hasHydratedWorkspaces,
@@ -59,7 +59,8 @@ import {
FloatingPanelPortalHostNameProvider,
} from "@/components/ui/floating-panel-portal";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { MountedTabActiveContext, SplitContainer } from "@/components/split-container";
import { SplitContainer } from "@/components/split-container";
import { RetainedPanel } from "@/components/retained-panel";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
@@ -847,21 +848,15 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
const slotStyle = isVisible
? styles.mobileMountedTabSlotVisible
: styles.mobileMountedTabSlotHidden;
return (
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<MountedTabActiveContext value={isVisible}>
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
</MountedTabActiveContext>
<RetainedPanel active={isVisible} style={styles.mobileMountedTabSlot}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</RetainedPanel>
</RenderProfile>
);
});
@@ -3965,13 +3960,8 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
position: "relative",
},
mobileMountedTabSlotVisible: {
mobileMountedTabSlot: {
...StyleSheet.absoluteFillObject,
opacity: 1,
},
mobileMountedTabSlotHidden: {
...StyleSheet.absoluteFillObject,
opacity: 0,
},
contentPlaceholder: {
flex: 1,
+4 -1
View File
@@ -1203,7 +1203,10 @@ export const useSessionStore = create<SessionStore>()(
[serverId]: {
...session,
agents: nextAgents,
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(nextAgents),
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(
nextAgents,
session.workspaceAgentActivity,
),
},
},
};
+136
View File
@@ -376,6 +376,142 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(first.messageId, "msg-same");
});
it("keeps row identities unique when an assistant message resumes after a tool", () => {
const messageId = "msg-resumed";
const state = hydrateStreamState([
{
event: assistantTimeline("Before the tool.", "codex", messageId),
timestamp: new Date("2025-01-01T10:02:00Z"),
},
{
event: canonicalToolTimeline({
provider: "codex",
callId: "tool-between-assistant-segments",
name: "shell",
status: "completed",
}),
timestamp: new Date("2025-01-01T10:02:01Z"),
},
{
event: assistantTimeline("After the tool.", "codex", messageId),
timestamp: new Date("2025-01-01T10:02:02Z"),
},
]);
const messages = state.filter(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
item.kind === "assistant_message",
);
expect(messages.map((message) => message.text)).toEqual([
"Before the tool.",
"After the tool.",
]);
expect(messages.map((message) => message.messageId)).toEqual([messageId, messageId]);
expect(new Set(messages.map((message) => message.id)).size).toBe(2);
});
it("keeps resumed live assistant rows when the turn completes", () => {
const messageId = "msg-live-resumed";
let tail: StreamItem[] = [];
let head: StreamItem[] = [];
for (const update of [
{
event: assistantTimeline("Before the tool.", "codex", messageId),
timestamp: new Date("2025-01-01T10:02:00Z"),
},
{
event: canonicalToolTimeline({
provider: "codex",
callId: "live-tool-between-assistant-segments",
name: "shell",
status: "completed",
}),
timestamp: new Date("2025-01-01T10:02:01Z"),
},
{
event: assistantTimeline("After the tool.", "codex", messageId),
timestamp: new Date("2025-01-01T10:02:02Z"),
},
{
event: { type: "turn_completed" as const, provider: "codex" as const },
timestamp: new Date("2025-01-01T10:02:03Z"),
},
]) {
const result = applyStreamEvent({
tail,
head,
event: update.event,
timestamp: update.timestamp,
});
tail = result.tail;
head = result.head;
}
const messages = tail.filter(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
item.kind === "assistant_message",
);
expect(head).toEqual([]);
expect(messages.map((message) => message.text)).toEqual([
"Before the tool.",
"After the tool.",
]);
expect(messages.map((message) => message.messageId)).toEqual([messageId, messageId]);
expect(new Set(messages.map((message) => message.id)).size).toBe(2);
});
it("keeps every promoted block when an assistant message resumes after a tool", () => {
const messageId = "msg-promoted-resume";
let tail: StreamItem[] = [];
let head: StreamItem[] = [];
for (const update of [
{
event: assistantTimeline("Before one.\n\nBefore two.", "codex", messageId),
timestamp: new Date("2025-01-01T10:02:00Z"),
},
{
event: canonicalToolTimeline({
provider: "codex" as const,
callId: "tool-between-promoted-segments",
name: "shell",
status: "completed" as const,
}),
timestamp: new Date("2025-01-01T10:02:01Z"),
},
{
event: assistantTimeline("After one.\n\nAfter two.", "codex", messageId),
timestamp: new Date("2025-01-01T10:02:02Z"),
},
{
event: { type: "turn_completed" as const, provider: "codex" as const },
timestamp: new Date("2025-01-01T10:02:03Z"),
},
]) {
const result = applyStreamEvent({
tail,
head,
event: update.event,
timestamp: update.timestamp,
});
tail = result.tail;
head = result.head;
}
const messages = tail.filter(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
item.kind === "assistant_message",
);
expect(messages.map((message) => message.text)).toEqual([
"Before one.",
"Before two.",
"After one.",
"After two.",
]);
expect(new Set(messages.map((message) => message.id)).size).toBe(messages.length);
});
it("preserves old assistant merge behavior when message ids are absent", () => {
const state = hydrateStreamState([
{
+58 -5
View File
@@ -43,6 +43,35 @@ function createUniqueTimelineId(
return `${base}_${suffixSeed.toString(36)}`;
}
function createAssistantItemId(
state: StreamItem[],
messageId: string | undefined,
text: string,
timestamp: Date,
reservedItemIds?: ReadonlySet<string>,
): string {
if (!messageId) {
return createUniqueTimelineId(state, "assistant", text, timestamp);
}
const isOccupied = (id: string) =>
reservedItemIds?.has(id) === true || state.some((item) => item.id === id);
if (!isOccupied(messageId)) {
return messageId;
}
const segmentId = `${messageId}:segment:${timestamp.getTime().toString(36)}`;
if (!isOccupied(segmentId)) {
return segmentId;
}
let suffix = 1;
while (isOccupied(`${segmentId}:${suffix.toString(36)}`)) {
suffix += 1;
}
return `${segmentId}:${suffix.toString(36)}`;
}
export type StreamItem =
| UserMessageItem
| AssistantMessageItem
@@ -170,6 +199,11 @@ export interface TodoListItem {
export type StreamUpdateSource = "live" | "canonical";
interface StreamUpdateOptions {
source?: StreamUpdateSource;
reservedItemIds?: ReadonlySet<string>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -320,6 +354,7 @@ function appendAssistantMessage(
timestamp: Date,
source: StreamUpdateSource,
messageId?: string,
reservedItemIds?: ReadonlySet<string>,
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!chunk) {
@@ -362,7 +397,7 @@ function appendAssistantMessage(
}
const idSeed = chunk.trim() || chunk;
const entryId = messageId ?? createUniqueTimelineId(state, "assistant", idSeed, timestamp);
const entryId = createAssistantItemId(state, messageId, idSeed, timestamp, reservedItemIds);
const item: AssistantMessageItem = {
kind: "assistant_message",
id: entryId,
@@ -751,6 +786,7 @@ function reduceTimelineEvent(
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
timestamp: Date,
source: StreamUpdateSource,
reservedItemIds?: ReadonlySet<string>,
): StreamItem[] {
const item = event.item;
switch (item.type) {
@@ -758,7 +794,14 @@ function reduceTimelineEvent(
return finalizeActiveThoughts(appendUserMessage(state, item.text, timestamp, item.messageId));
case "assistant_message":
return finalizeActiveThoughts(
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),
appendAssistantMessage(
state,
item.text,
timestamp,
source,
item.messageId,
reservedItemIds,
),
);
case "reasoning":
return appendThought(state, item.text, timestamp);
@@ -798,12 +841,12 @@ export function reduceStreamUpdate(
state: StreamItem[],
event: AgentStreamEventPayload,
timestamp: Date,
options?: { source?: StreamUpdateSource },
options?: StreamUpdateOptions,
): StreamItem[] {
const source = options?.source ?? "live";
switch (event.type) {
case "timeline":
return reduceTimelineEvent(state, event, timestamp, source);
return reduceTimelineEvent(state, event, timestamp, source, options?.reservedItemIds);
case "thread_started":
case "turn_started":
case "turn_completed":
@@ -1173,7 +1216,17 @@ export function applyStreamEvent(params: {
// For streamable kinds, apply to head
if (incomingKind !== null && isStreamableKind(incomingKind)) {
const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source });
const reservedItemIds =
incomingKind === "assistant_message" && getActiveAssistantHeadIndex(nextHead) < 0
? new Set(
nextTail.flatMap((item) =>
item.kind === "assistant_message" && item.blockGroupId
? [item.id, item.blockGroupId]
: [item.id],
),
)
: undefined;
const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, reservedItemIds });
if (reduced !== nextHead) {
nextHead = reduced;
changedHead = true;
@@ -92,8 +92,22 @@ describe("workspace agent activity index", () => {
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") }],
[
"workspace-a",
{
agentId: "permission",
status: "needs_input",
enteredAt: new Date("2026-06-01T10:01:00.000Z"),
},
],
[
"workspace-b",
{
agentId: "attention",
status: "attention",
enteredAt: new Date("2026-06-01T10:02:00.000Z"),
},
],
]),
);
});
@@ -135,8 +149,82 @@ describe("workspace agent activity index", () => {
);
expect(index.get("workspace-a")).toEqual({
agentId: "root",
status: "running",
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
});
});
it("preserves the activity index while the same agent remains in the same status", () => {
const previous = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({
id: "root",
workspaceId: "workspace-a",
status: "running",
updatedAt: "2026-06-01T10:00:00.000Z",
}),
],
]),
);
const next = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({
id: "root",
workspaceId: "workspace-a",
status: "running",
updatedAt: "2026-06-01T10:05:00.000Z",
}),
],
]),
previous,
);
expect(next).toBe(previous);
expect(next.get("workspace-a")?.enteredAt).toEqual(new Date("2026-06-01T10:00:00.000Z"));
});
it("records a new entry time when an agent changes status", () => {
const previous = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({
id: "root",
workspaceId: "workspace-a",
status: "running",
updatedAt: "2026-06-01T10:00:00.000Z",
}),
],
]),
);
const next = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({
id: "root",
workspaceId: "workspace-a",
status: "idle",
updatedAt: "2026-06-01T10:05:00.000Z",
pendingPermissionCount: 1,
}),
],
]),
previous,
);
expect(next).not.toBe(previous);
expect(next.get("workspace-a")).toEqual({
agentId: "root",
status: "needs_input",
enteredAt: new Date("2026-06-01T10:05:00.000Z"),
});
});
});
@@ -2,14 +2,17 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { deriveSidebarStateBucket } from "./sidebar-agent-state";
export interface WorkspaceAgentActivity {
agentId: string;
status: WorkspaceDescriptor["status"];
enteredAt: Date | null;
}
export function buildWorkspaceAgentActivityIndex(
agents: ReadonlyMap<string, Agent>,
previous?: ReadonlyMap<string, WorkspaceAgentActivity>,
): Map<string, WorkspaceAgentActivity> {
const activityByWorkspaceId = new Map<string, WorkspaceAgentActivity>();
const latestActivityAtByWorkspaceId = new Map<string, Date>();
for (const agent of agents.values()) {
if (agent.archivedAt || agent.parentAgentId || !agent.workspaceId) {
@@ -17,21 +20,52 @@ export function buildWorkspaceAgentActivityIndex(
}
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
const current = activityByWorkspaceId.get(agent.workspaceId);
if (current && enteredAt <= (current.enteredAt ?? new Date(0))) {
const latestActivityAt = latestActivityAtByWorkspaceId.get(agent.workspaceId);
if (latestActivityAt && enteredAt <= latestActivityAt) {
continue;
}
latestActivityAtByWorkspaceId.set(agent.workspaceId, enteredAt);
const status = deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
});
activityByWorkspaceId.set(agent.workspaceId, {
status: deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
}),
agentId: agent.id,
status,
enteredAt,
});
}
for (const [workspaceId, activity] of activityByWorkspaceId) {
const previousActivity = previous?.get(workspaceId);
if (
previousActivity?.agentId === activity.agentId &&
previousActivity.status === activity.status
) {
activityByWorkspaceId.set(workspaceId, previousActivity);
}
}
if (previous && areWorkspaceAgentActivityIndexesIdentical(previous, activityByWorkspaceId)) {
return previous instanceof Map ? previous : new Map(previous);
}
return activityByWorkspaceId;
}
function areWorkspaceAgentActivityIndexesIdentical(
previous: ReadonlyMap<string, WorkspaceAgentActivity>,
next: ReadonlyMap<string, WorkspaceAgentActivity>,
): boolean {
if (previous.size !== next.size) {
return false;
}
for (const [workspaceId, activity] of next) {
if (previous.get(workspaceId) !== activity) {
return false;
}
}
return true;
}