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:
@@ -37,6 +37,91 @@ test.describe("Agent stream UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the active Markdown root mounted across streamed text updates", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "stream-markdown-root-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for Markdown root stability test.",
|
||||
});
|
||||
try {
|
||||
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||
await expect(assistantMessage).toContainText("walking through", { timeout: 30_000 });
|
||||
|
||||
const activeBlock = assistantMessage.locator(":scope > *").last();
|
||||
const initialText = (await activeBlock.textContent()) ?? "";
|
||||
const activeBlockHandle = await activeBlock.elementHandle();
|
||||
if (!activeBlockHandle) {
|
||||
throw new Error("Expected the active assistant message to contain a block");
|
||||
}
|
||||
const markdownRoot = await activeBlock.locator(":scope > *").first().elementHandle();
|
||||
if (!markdownRoot) {
|
||||
throw new Error("Expected the active assistant block to contain a Markdown root");
|
||||
}
|
||||
|
||||
await page.evaluate((block) => {
|
||||
const evidence = {
|
||||
addedNodes: 0,
|
||||
characterDataMutations: 0,
|
||||
removedNodes: 0,
|
||||
};
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
evidence.addedNodes += record.addedNodes.length;
|
||||
evidence.removedNodes += record.removedNodes.length;
|
||||
if (record.type === "characterData") {
|
||||
evidence.characterDataMutations += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(block, { characterData: true, childList: true, subtree: true });
|
||||
Object.assign(window, {
|
||||
__markdownRootEvidence: evidence,
|
||||
__markdownRootObserver: observer,
|
||||
});
|
||||
}, activeBlockHandle);
|
||||
|
||||
await expect
|
||||
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
|
||||
.toBeGreaterThan(initialText.length + 80);
|
||||
|
||||
const evidence = await page.evaluate((root) => {
|
||||
const state = window as typeof window & {
|
||||
__markdownRootEvidence?: {
|
||||
addedNodes: number;
|
||||
characterDataMutations: number;
|
||||
removedNodes: number;
|
||||
};
|
||||
__markdownRootObserver?: MutationObserver;
|
||||
};
|
||||
state.__markdownRootObserver?.disconnect();
|
||||
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
|
||||
const message = messages.item(messages.length - 1);
|
||||
const block = message?.lastElementChild;
|
||||
return {
|
||||
...state.__markdownRootEvidence,
|
||||
connected: root.isConnected,
|
||||
sameRoot: block?.firstElementChild === root,
|
||||
};
|
||||
}, markdownRoot);
|
||||
|
||||
await testInfo.attach("markdown-root-stability", {
|
||||
body: JSON.stringify(evidence, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(evidence.connected).toBe(true);
|
||||
expect(evidence.sameRoot).toBe(true);
|
||||
expect(
|
||||
evidence.removedNodes,
|
||||
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
|
||||
).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1203,7 +1203,10 @@ export const useSessionStore = create<SessionStore>()(
|
||||
[serverId]: {
|
||||
...session,
|
||||
agents: nextAgents,
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(nextAgents),
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(
|
||||
nextAgents,
|
||||
session.workspaceAgentActivity,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user