diff --git a/package-lock.json b/package-lock.json index 13d704516..55218d381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37382,6 +37382,7 @@ "react-native-web": "~0.21.0", "react-native-webview": "^13.16.0", "react-native-worklets": "0.5.1", + "use-sync-external-store": "^1.6.0", "zod": "^3.23.8", "zustand": "^5.0.9" }, diff --git a/packages/app/package.json b/packages/app/package.json index f71052135..ca80f9d0b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -103,6 +103,7 @@ "react-native-web": "~0.21.0", "react-native-webview": "^13.16.0", "react-native-worklets": "0.5.1", + "use-sync-external-store": "^1.6.0", "zod": "^3.23.8", "zustand": "^5.0.9" }, diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index d8a90e78b..12f5e7f2a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -65,7 +65,9 @@ import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog" import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; +import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { queryClient } from "@/query/query-client"; +import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; import { WEB_NOTIFICATION_CLICK_EVENT, type WebNotificationClickDetail, @@ -392,10 +394,10 @@ function AppContainer({ const daemons = useHosts(); const { settings, updateSettings } = useAppSettings(); const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList); - const toggleMobileFileExplorer = usePanelStore((state) => state.toggleMobileFileExplorer); const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList); - const toggleDesktopFileExplorer = usePanelStore((state) => state.toggleDesktopFileExplorer); - const toggleDesktopSidebars = usePanelStore((state) => state.toggleDesktopSidebars); + const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList); + const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList); + const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen); @@ -411,7 +413,21 @@ function AppContainer({ const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const pathname = usePathname(); const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList; - const toggleFileExplorer = isCompactLayout ? toggleMobileFileExplorer : toggleDesktopFileExplorer; + const toggleDesktopSidebars = useCallback(() => { + const { desktop } = usePanelStore.getState(); + toggleDesktopSidebarsWithCheckoutIntent({ + isAgentListOpen: desktop.agentListOpen, + isFileExplorerOpen: desktop.fileExplorerOpen, + openAgentList: openDesktopAgentList, + closeAgentList: closeDesktopAgentList, + closeFileExplorer: closeDesktopFileExplorer, + toggleFocusedFileExplorer: () => + keyboardActionDispatcher.dispatch({ + id: "sidebar.toggle.right", + scope: "sidebar", + }), + }); + }, [closeDesktopAgentList, closeDesktopFileExplorer, openDesktopAgentList]); // TODO: stop matching pathname here as a branch. `chromeEnabled` should not // conflate workspace/project-specific chrome (sidebar, mobile gesture) with // global concerns like keyboard shortcuts. Split those out so settings (and @@ -451,7 +467,6 @@ function AppContainer({ enabled: keyboardShortcutsEnabled, isMobile: isCompactLayout, toggleAgentList, - toggleFileExplorer, toggleBothSidebars: toggleDesktopSidebars, toggleFocusMode, cycleTheme, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 19174b041..92b5504bf 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -125,8 +125,7 @@ const AgentStreamViewComponent = forwardRef>( new Set(), ); - const showMobileFileExplorer = usePanelStore((state) => state.showMobileFileExplorer); - const openDesktopFileExplorer = usePanelStore((state) => state.openDesktopFileExplorer); + const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); // Get serverId (fallback to agent's serverId if not provided) @@ -200,27 +199,25 @@ const AgentStreamViewComponent = forwardRef {}), encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images), openExternalUrlMock: vi.fn(async () => {}), + markScrollInvestigationRenderMock, mockSessionState, setAgentStreamTailMock, setAgentStreamHeadMock, @@ -313,7 +316,7 @@ vi.mock("@/contexts/toast-context", () => ({ })); vi.mock("@/utils/scroll-jank-investigation", () => ({ - markScrollInvestigationRender: vi.fn(), + markScrollInvestigationRender: markScrollInvestigationRenderMock, markScrollInvestigationEvent: vi.fn(), })); @@ -547,6 +550,7 @@ beforeEach(() => { deleteAttachmentsMock.mockClear(); encodeImagesMock.mockClear(); openExternalUrlMock.mockClear(); + markScrollInvestigationRenderMock.mockClear(); setAgentStreamTailMock.mockClear(); setAgentStreamHeadMock.mockClear(); setQueuedMessagesMock.mockClear(); @@ -663,6 +667,12 @@ function queryAllAttachmentMenuItems(): NodeListOf { return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'); } +function countMessageInputRenders(): number { + return markScrollInvestigationRenderMock.mock.calls.filter( + ([componentId]) => componentId === "MessageInput:server:agent", + ).length; +} + describe("Composer attachments", () => { it("opens a Plus menu with image and GitHub attachment actions", () => { renderComposer(); @@ -847,6 +857,27 @@ describe("Composer attachments", () => { expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]); }); + it("does not re-render MessageInput when opening the attachment lightbox", () => { + const image = imageAttachment("img-lightbox-render"); + renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] }); + const renderCountBeforeLightbox = countMessageInputRenders(); + + click(queryByTestId("composer-image-attachment-pill")!); + + expect(queryByTestId("attachment-lightbox-image")).not.toBeNull(); + expect(countMessageInputRenders()).toBe(renderCountBeforeLightbox); + }); + + it("still re-renders MessageInput when submit loading semantics change", () => { + renderComposer({ initialText: "pending submit", isSubmitLoading: false }); + const renderCountBeforeLoading = countMessageInputRenders(); + + renderComposer({ initialText: "pending submit", isSubmitLoading: true }); + + expect(countMessageInputRenders()).toBe(renderCountBeforeLoading + 1); + expect(document.querySelector('[aria-label="Send message"]')).toHaveProperty("disabled", true); + }); + it("locks the preserved draft while submit loading", () => { renderComposer({ initialText: "keep this prompt", diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index cdac24034..50d0ecb1b 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -1,5 +1,5 @@ import { View, Pressable, Text, ActivityIndicator, Image } from "react-native"; -import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo, memo } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useShallow } from "zustand/shallow"; @@ -134,6 +134,7 @@ interface ComposerProps { const EMPTY_ARRAY: readonly QueuedMessage[] = []; const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills"; const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands"; +const StableMessageInput = memo(MessageInput); export function Composer({ agentId, @@ -172,6 +173,8 @@ export function Composer({ const isConnected = useHostRuntimeIsConnected(serverId); const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); const toast = useToast(); + const toastErrorRef = useRef(toast.error); + toastErrorRef.current = toast.error; const voice = useVoiceOptional(); const voiceToggleKeys = useShortcutKeys("voice-toggle"); const dictationCancelKeys = useShortcutKeys("dictation-cancel"); @@ -238,6 +241,8 @@ export function Composer({ messageInputRef.current?.focus(); }, }); + const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress); + autocompleteOnKeyPressRef.current = autocomplete.onKeyPress; // Clear send error when user edits the input useEffect(() => { @@ -375,67 +380,82 @@ export function Composer({ [agentId, serverId, setQueuedMessages], ); - function queueMessage(message: string, attachments: ComposerAttachment[]) { - const trimmedMessage = message.trim(); - if (!trimmedMessage && attachments.length === 0) return; + const queueMessage = useCallback( + (message: string, attachments: ComposerAttachment[]) => { + const trimmedMessage = message.trim(); + if (!trimmedMessage && attachments.length === 0) return; - const newItem = { - id: generateMessageId(), - text: trimmedMessage, - attachments, - }; + const newItem = { + id: generateMessageId(), + text: trimmedMessage, + attachments, + }; - setQueuedMessages(serverId, (prev: Map) => { - const next = new Map(prev); - next.set(agentId, [...(prev.get(agentId) ?? []), newItem]); - return next; - }); + setQueuedMessages(serverId, (prev: Map) => { + const next = new Map(prev); + next.set(agentId, [...(prev.get(agentId) ?? []), newItem]); + return next; + }); - setUserInput(""); - setSelectedAttachments([]); - } + setUserInput(""); + setSelectedAttachments([]); + }, + [agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput], + ); - async function sendMessageWithContent( - message: string, - attachments: ComposerAttachment[], - forceSend?: boolean, - ) { - await submitAgentInput({ - message, - attachments, - hasExternalContent, + const sendMessageWithContent = useCallback( + async (message: string, attachments: ComposerAttachment[], forceSend?: boolean) => { + await submitAgentInput({ + message, + attachments, + hasExternalContent, + allowEmptySubmit, + forceSend, + submitBehavior, + isAgentRunning, + // Parent-managed submits are still valid submit paths even when the + // transport is disconnected, because the parent decides the failure mode. + canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current), + queueMessage: ({ message, attachments }) => { + queueMessage(message, attachments); + }, + submitMessage: async ({ message, attachments }) => { + await submitMessage(message, attachments); + }, + clearDraft, + setUserInput, + setAttachments: (nextAttachments) => { + setSelectedAttachments(nextAttachments); + }, + setSendError, + setIsProcessing, + onSubmitError: (error) => { + console.error("[AgentInput] Failed to send message:", error); + }, + }); + }, + [ allowEmptySubmit, - forceSend, - submitBehavior, - isAgentRunning: agentState.status === "running", - // Parent-managed submits are still valid submit paths even when the - // transport is disconnected, because the parent decides the failure mode. - canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current), - queueMessage: ({ message, attachments }) => { - queueMessage(message, attachments); - }, - submitMessage: async ({ message, attachments }) => { - await submitMessage(message, attachments); - }, clearDraft, + hasExternalContent, + isAgentRunning, + queueMessage, + setSelectedAttachments, setUserInput, - setAttachments: (nextAttachments) => { - setSelectedAttachments(nextAttachments); - }, - setSendError, - setIsProcessing, - onSubmitError: (error) => { - console.error("[AgentInput] Failed to send message:", error); - }, - }); - } + submitBehavior, + submitMessage, + ], + ); - function handleSubmit(payload: MessagePayload) { - if (blurOnSubmit) { - messageInputRef.current?.blur(); - } - void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend); - } + const handleSubmit = useCallback( + (payload: MessagePayload) => { + if (blurOnSubmit) { + messageInputRef.current?.blur(); + } + void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend); + }, + [blurOnSubmit, sendMessageWithContent], + ); const handlePickImage = useCallback(async () => { const result = await pickImages(); @@ -487,6 +507,18 @@ export function Composer({ } }, [isAgentRunning, isConnected]); + const handleCancelAgent = useCallback(() => { + if (!isAgentRunning || isCancellingAgent) { + return; + } + if (!isConnected || !client) { + return; + } + setIsCancellingAgent(true); + void client.cancelAgent(agentIdRef.current); + messageInputRef.current?.focus(); + }, [client, isAgentRunning, isCancellingAgent, isConnected]); + const handleKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => { if (!isPaneFocused) { @@ -553,18 +585,6 @@ export function Composer({ mode: "translate", }); - function handleCancelAgent() { - if (!isAgentRunning || isCancellingAgent) { - return; - } - if (!isConnected || !client) { - return; - } - setIsCancellingAgent(true); - void client.cancelAgent(agentIdRef.current); - messageInputRef.current?.focus(); - } - const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false; const handleToggleRealtimeVoice = useCallback(() => { @@ -582,10 +602,10 @@ export function Composer({ const message = error instanceof Error ? error.message : typeof error === "string" ? error : null; if (message && message.trim().length > 0) { - toast.error(message); + toastErrorRef.current(message); } }); - }, [agentId, hasAgent, isConnected, serverId, toast, voice]); + }, [agentId, hasAgent, isConnected, serverId, voice]); function handleEditQueuedMessage(id: string) { const item = queuedMessages.find((q) => q.id === id); @@ -612,9 +632,12 @@ export function Composer({ } } - const handleQueue = useCallback((payload: MessagePayload) => { - queueMessage(payload.text, payload.attachments); - }, []); + const handleQueue = useCallback( + (payload: MessagePayload) => { + queueMessage(payload.text, payload.attachments); + }, + [queueMessage], + ); const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; @@ -633,91 +656,109 @@ export function Composer({ return true; } - return autocomplete.onKeyPress(event); + return autocompleteOnKeyPressRef.current(event); }, + [hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, handleCancelAgent], + ); + + const cancelButton = useMemo( + () => + isAgentRunning && !hasSendableContent && !isProcessing ? ( + + + {isCancellingAgent ? ( + + ) : ( + + )} + + + + Interrupt + {dictationCancelKeys ? ( + + ) : null} + + + + ) : null, [ - autocomplete, + buttonIconSize, + dictationCancelKeys, + handleCancelAgent, hasSendableContent, isAgentRunning, isCancellingAgent, isConnected, - handleCancelAgent, + isProcessing, ], ); - const cancelButton = - isAgentRunning && !hasSendableContent && !isProcessing ? ( - - - {isCancellingAgent ? ( - - ) : ( - - )} - - - - Interrupt - {dictationCancelKeys ? ( - - ) : null} - - - - ) : null; - const showVoiceModeButton = !isVoiceModeForAgent && hasAgent; - const rightContent = - showVoiceModeButton || cancelButton ? ( - - {showVoiceModeButton ? ( - - [ - styles.realtimeVoiceButton as any, - (hovered ? styles.iconButtonHovered : undefined) as any, - (!isConnected || voice?.isVoiceSwitching - ? styles.buttonDisabled - : undefined) as any, - ]} - > - {({ hovered }) => - voice?.isVoiceSwitching ? ( - - ) : ( - - ) - } - - - - Voice mode - {voiceToggleKeys ? ( - - ) : null} - - - - ) : null} - {cancelButton} - - ) : null; + const rightContent = useMemo( + () => + showVoiceModeButton || cancelButton ? ( + + {showVoiceModeButton ? ( + + [ + styles.realtimeVoiceButton as any, + (hovered ? styles.iconButtonHovered : undefined) as any, + (!isConnected || voice?.isVoiceSwitching + ? styles.buttonDisabled + : undefined) as any, + ]} + > + {({ hovered }) => + voice?.isVoiceSwitching ? ( + + ) : ( + + ) + } + + + + Voice mode + {voiceToggleKeys ? ( + + ) : null} + + + + ) : null} + {cancelButton} + + ) : null, + [ + buttonIconSize, + cancelButton, + handleToggleRealtimeVoice, + isConnected, + showVoiceModeButton, + theme.colors.foreground, + theme.colors.foregroundMuted, + voice, + voiceToggleKeys, + ], + ); const hasContextWindowMeter = typeof agentState.contextWindowMaxTokens === "number" && @@ -725,15 +766,18 @@ export function Composer({ const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null; const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null; - const beforeVoiceContent = ( - - {contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? ( - - ) : null} - + const beforeVoiceContent = useMemo( + () => ( + + {contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? ( + + ) : null} + + ), + [contextWindowMaxTokens, contextWindowUsedTokens], ); const githubSearchQueryTrimmed = githubSearchQuery.trim(); @@ -808,12 +852,33 @@ export function Composer({ [setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen], ); - const leftContent = - resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( - - ) : ( - - ); + const leftContent = useMemo( + () => + resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( + + ) : ( + + ), + [agentId, focusInput, serverId, statusControls], + ); + + const handleAttachButtonRef = useCallback((node: View | null) => { + attachButtonRef.current = node; + }, []); + + const handleSelectionChange = useCallback((selection: { start: number; end: number }) => { + setCursorIndex(selection.start); + }, []); + + const handleFocusChange = useCallback( + (focused: boolean) => { + setIsMessageInputFocused(focused); + if (focused) { + onAttentionInputFocus?.(); + } + }, + [onAttentionInputFocus], + ); return ( { - attachButtonRef.current = node; - }} + onAttachButtonRef={handleAttachButtonRef} onAddImages={addImages} client={client} isReadyForDictation={isDictationReady} @@ -959,15 +1022,8 @@ export function Composer({ onQueue={handleQueue} onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined} onKeyPress={handleCommandKeyPress} - onSelectionChange={(selection) => { - setCursorIndex(selection.start); - }} - onFocusChange={(focused) => { - setIsMessageInputFocused(focused); - if (focused) { - onAttentionInputFocus?.(); - } - }} + onSelectionChange={handleSelectionChange} + onFocusChange={handleFocusChange} onHeightChange={onComposerHeightChange} inputWrapperStyle={inputWrapperStyle} /> diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 38cd32e45..052205b70 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -17,6 +17,7 @@ import { PrPane } from "./pr-pane"; import { usePrPaneData } from "@/hooks/use-pr-pane-data"; import { usePanelStore, + selectIsFileExplorerOpen, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH, type ExplorerTab, @@ -52,8 +53,7 @@ export function ExplorerSidebar({ const isScreenFocused = useIsFocused(); const insets = useSafeAreaInsets(); const isMobile = useIsCompactFormFactor(); - const mobileView = usePanelStore((state) => state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile })); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); const explorerTab = usePanelStore((state) => state.explorerTab); @@ -82,9 +82,6 @@ export function ExplorerSidebar({ } }, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]); - // Derive isOpen from the unified panel state - const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - const { translateX, backdropOpacity, @@ -105,8 +102,6 @@ export function ExplorerSidebar({ logExplorerSidebar("handleClose", { reason, isOpen, - mobileView, - desktopFileExplorerOpen, }); if (isMobile) { showMobileAgent(); @@ -114,14 +109,7 @@ export function ExplorerSidebar({ } closeDesktopFileExplorer(); }, - [ - closeDesktopFileExplorer, - desktopFileExplorerOpen, - isMobile, - isOpen, - mobileView, - showMobileAgent, - ], + [closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent], ); const handleCloseFromGesture = useCallback(() => { diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index e0cc79f8a..1449326de 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; import { ScreenTitle } from "./screen-title"; import { HeaderToggleButton } from "./header-toggle-button"; -import { usePanelStore } from "@/stores/panel-store"; +import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getShortcutOs } from "@/utils/shortcut-platform"; @@ -46,19 +46,16 @@ export function SidebarMenuToggle({ }: SidebarMenuToggleProps = {}) { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); - const mobileView = usePanelStore((state) => state.mobileView); - const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); - const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList); - const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList); + const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile })); + const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]; - const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const menuIconColor = !isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted; return ( toggleAgentListForLayout({ isCompact: isMobile })} tooltipLabel="Toggle sidebar" tooltipKeys={toggleShortcutKeys} tooltipSide={tooltipSide} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 6dad72d1d..e00737156 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -32,7 +32,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { router, usePathname } from "expo-router"; -import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store"; +import { + usePanelStore, + selectIsAgentListOpen, + MIN_SIDEBAR_WIDTH, + MAX_SIDEBAR_WIDTH, +} from "@/stores/panel-store"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; @@ -116,8 +121,9 @@ export const LeftSidebar = memo(function LeftSidebar({ const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isCompactLayout = useIsCompactFormFactor(); - const mobileView = usePanelStore((state) => state.mobileView); - const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + const isOpen = usePanelStore((state) => + selectIsAgentListOpen(state, { isCompact: isCompactLayout }), + ); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const pathname = usePathname(); const daemons = useHosts(); @@ -183,8 +189,6 @@ export const LeftSidebar = memo(function LeftSidebar({ const hostTriggerRef = useRef(null); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); - const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen; - const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ serverId: activeServerId, enabled: isOpen, diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index e54c881a7..145f3c65d 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -28,6 +28,12 @@ import type { HostProfile } from "@/types/host-connection"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; +import { + activateNavigationWorkspaceSelection, + syncNavigationActiveWorkspace, + useIsNavigationProjectActive, + useIsNavigationWorkspaceSelected, +} from "@/stores/navigation-active-workspace-store"; vi.mock("@react-native-async-storage/async-storage", () => ({ default: { @@ -43,6 +49,8 @@ type RenderCounts = { frame: number; headers: Record; rows: Record; + projectSelection: Record; + rowSelection: Record; }; const runningScript: WorkspaceScriptPayload = { @@ -166,6 +174,8 @@ function resetCounts(counts: RenderCounts): void { counts.frame = 0; counts.headers = {}; counts.rows = {}; + counts.projectSelection = {}; + counts.rowSelection = {}; } function incrementRecord(record: Record, key: string): void { @@ -201,6 +211,37 @@ function WorkspaceRowProbe({ return null; } +function ProjectActiveProbe({ + serverId, + project, + counts, +}: { + serverId: string; + project: SidebarProjectEntry; + counts: RenderCounts; +}): null { + useIsNavigationProjectActive({ + serverId, + workspaceIds: project.workspaces.map((workspace) => workspace.workspaceId), + }); + incrementRecord(counts.projectSelection, project.projectKey); + return null; +} + +function WorkspaceSelectionProbe({ + serverId, + workspaceId, + counts, +}: { + serverId: string; + workspaceId: string; + counts: RenderCounts; +}): null { + useIsNavigationWorkspaceSelected({ serverId, workspaceId }); + incrementRecord(counts.rowSelection, workspaceId); + return null; +} + function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement { counts.frame += 1; const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID }); @@ -210,13 +251,20 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement { {projects.map((project) => (
+ {project.workspaces.map((workspace) => ( - + + + + ))}
))} @@ -278,6 +326,7 @@ describe("sidebar workspace render isolation", () => { container?.remove(); container = null; act(() => { + syncNavigationActiveWorkspace({ current: null }); getHostRuntimeStore().syncHosts([]); useSessionStore.getState().clearSession(SERVER_ID); useSidebarOrderStore.setState({ @@ -288,7 +337,13 @@ describe("sidebar workspace render isolation", () => { }); it("re-renders only the changed workspace row for a status update", async () => { - const counts: RenderCounts = { frame: 0, headers: {}, rows: {} }; + const counts: RenderCounts = { + frame: 0, + headers: {}, + rows: {}, + projectSelection: {}, + rowSelection: {}, + }; ({ root, container } = await renderProbe(counts)); act(() => { @@ -306,7 +361,13 @@ describe("sidebar workspace render isolation", () => { }); it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => { - const counts: RenderCounts = { frame: 0, headers: {}, rows: {} }; + const counts: RenderCounts = { + frame: 0, + headers: {}, + rows: {}, + projectSelection: {}, + rowSelection: {}, + }; ({ root, container } = await renderProbe(counts)); act(() => { @@ -316,11 +377,23 @@ describe("sidebar workspace render isolation", () => { }); }); - expect(counts).toEqual({ frame: 0, headers: {}, rows: {} }); + expect(counts).toEqual({ + frame: 0, + headers: {}, + rows: {}, + projectSelection: {}, + rowSelection: {}, + }); }); it("does not re-render for a deep-equal scripts patch", async () => { - const counts: RenderCounts = { frame: 0, headers: {}, rows: {} }; + const counts: RenderCounts = { + frame: 0, + headers: {}, + rows: {}, + projectSelection: {}, + rowSelection: {}, + }; ({ root, container } = await renderProbe(counts)); act(() => { @@ -332,6 +405,49 @@ describe("sidebar workspace render isolation", () => { ); }); - expect(counts).toEqual({ frame: 0, headers: {}, rows: {} }); + expect(counts).toEqual({ + frame: 0, + headers: {}, + rows: {}, + projectSelection: {}, + rowSelection: {}, + }); + }); + + it("isolates active selection updates to affected row and project boolean probes", async () => { + const counts: RenderCounts = { + frame: 0, + headers: {}, + rows: {}, + projectSelection: {}, + rowSelection: {}, + }; + + act(() => { + activateNavigationWorkspaceSelection({ + serverId: SERVER_ID, + workspaceId: "a-one", + }); + }); + ({ root, container } = await renderProbe(counts)); + + act(() => { + activateNavigationWorkspaceSelection({ + serverId: SERVER_ID, + workspaceId: "b-two", + }); + }); + + expect(counts.frame).toBe(0); + expect(counts.headers).toEqual({}); + expect(counts.rows).toEqual({}); + expect(counts.projectSelection).toEqual({ + "project-a": 1, + "project-b": 1, + }); + expect(counts.rowSelection).toEqual({ + "a-one": 1, + "b-two": 1, + }); }); }); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index de1c6ce72..47711799e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -91,14 +91,17 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; -import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { + useIsNavigationProjectActive, + useIsNavigationWorkspaceSelected, +} from "@/stores/navigation-active-workspace-store"; import { normalizeWorkspaceDescriptor, useSessionStore, type WorkspaceDescriptor, } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; -import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; +import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { openExternalUrl } from "@/utils/open-external-url"; import { requireWorkspaceExecutionDirectory, @@ -1175,7 +1178,6 @@ function WorkspaceRowWithMenu({ isCreating?: boolean; }) { const toast = useToast(); - const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const workspaceDirectory = resolveWorkspaceExecutionDirectory({ @@ -1193,22 +1195,11 @@ function WorkspaceRowWithMenu({ const isWorktree = workspace.workspaceKind === "worktree"; const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace; const redirectAfterArchive = useCallback(() => { - if ( - activeWorkspaceSelection?.serverId !== workspace.serverId || - activeWorkspaceSelection.workspaceId !== workspace.workspaceId - ) { - return; - } - - router.replace( - buildWorkspaceArchiveRedirectRoute({ - serverId: workspace.serverId, - archivedWorkspaceId: workspace.workspaceId, - workspaces: - useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [], - }), - ); - }, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]); + redirectIfArchivingActiveWorkspace({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); + }, [workspace.serverId, workspace.workspaceId]); const handleArchiveWorktree = useCallback(() => { if (isArchiving) { @@ -1401,25 +1392,13 @@ function NonGitProjectRowWithMenuContent({ }) { const toast = useToast(); const contextMenu = useContextMenu(); - const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const redirectAfterArchive = useCallback(() => { - if ( - activeWorkspaceSelection?.serverId !== workspace.serverId || - activeWorkspaceSelection.workspaceId !== workspace.workspaceId - ) { - return; - } - - router.replace( - buildWorkspaceArchiveRedirectRoute({ - serverId: workspace.serverId, - archivedWorkspaceId: workspace.workspaceId, - workspaces: - useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [], - }) as any, - ); - }, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]); + redirectIfArchivingActiveWorkspace({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); + }, [workspace.serverId, workspace.workspaceId]); const handleArchiveWorkspace = useCallback(() => { if (isArchivingWorkspace) { @@ -1546,6 +1525,7 @@ function FlattenedProjectRow({ isProjectActive = false, onRemoveProject, removeProjectStatus, + selectionEnabled, }: { project: SidebarProjectEntry; displayName: string; @@ -1563,8 +1543,14 @@ function FlattenedProjectRow({ isProjectActive?: boolean; onRemoveProject?: () => void; removeProjectStatus?: "idle" | "pending"; + selectionEnabled: boolean; }) { const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId); + const selected = useIsNavigationWorkspaceSelected({ + serverId, + workspaceId: rowModel.workspace.workspaceId, + enabled: selectionEnabled, + }); if (!workspace) { return null; @@ -1577,7 +1563,7 @@ function FlattenedProjectRow({ displayName={displayName} iconDataUri={iconDataUri} workspace={workspace} - selected={rowModel.selected} + selected={selected} onPress={onPress} shortcutNumber={shortcutNumber} showShortcutBadge={showShortcutBadge} @@ -1594,7 +1580,7 @@ function FlattenedProjectRow({ displayName={displayName} iconDataUri={iconDataUri} workspace={workspace} - selected={rowModel.selected} + selected={selected} chevron={rowModel.chevron} onPress={onPress} serverId={serverId} @@ -1616,7 +1602,6 @@ function FlattenedProjectRow({ function WorkspaceRow({ workspace, - selected, shortcutNumber, showShortcutBadge, onPress, @@ -1625,9 +1610,9 @@ function WorkspaceRow({ dragHandleProps, canCopyBranchName, isCreating = false, + selectionEnabled, }: { workspace: SidebarWorkspaceEntry; - selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; @@ -1636,8 +1621,14 @@ function WorkspaceRow({ dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; isCreating?: boolean; + selectionEnabled: boolean; }) { const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId); + const selected = useIsNavigationWorkspaceSelected({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + enabled: selectionEnabled, + }); if (!hydratedWorkspace) { return null; @@ -1665,7 +1656,7 @@ function ProjectBlock({ displayName, iconDataUri, serverId, - activeWorkspaceSelection, + selectionEnabled, showShortcutBadges, shortcutIndexByWorkspaceKey, parentGestureRef, @@ -1673,6 +1664,7 @@ function ProjectBlock({ onWorkspacePress, onWorkspaceReorder, onWorktreeCreated, + currentPathname, drag, isDragging, dragHandleProps, @@ -1684,7 +1676,7 @@ function ProjectBlock({ displayName: string; iconDataUri: string | null; serverId: string | null; - activeWorkspaceSelection: { serverId: string; workspaceId: string } | null; + selectionEnabled: boolean; showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; parentGestureRef?: MutableRefObject; @@ -1692,6 +1684,7 @@ function ProjectBlock({ onWorkspacePress?: () => void; onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; onWorktreeCreated?: (workspaceId: string) => void; + currentPathname: string | null; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; @@ -1703,18 +1696,19 @@ function ProjectBlock({ buildSidebarProjectRowModel({ project, collapsed, - serverId, - activeWorkspaceSelection, }), - [activeWorkspaceSelection, collapsed, project, serverId], + [collapsed, project], ); - const isProjectActive = useMemo(() => { - if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) { - return false; - } - return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId); - }, [serverId, activeWorkspaceSelection, project.workspaces]); + const projectWorkspaceIds = useMemo( + () => project.workspaces.map((workspace) => workspace.workspaceId), + [project.workspaces], + ); + const isProjectActive = useIsNavigationProjectActive({ + serverId, + workspaceIds: projectWorkspaceIds, + enabled: selectionEnabled, + }); const renderWorkspaceRow = useCallback( ( @@ -1725,25 +1719,20 @@ function ProjectBlock({ dragHandleProps?: DraggableListDragHandleProps; }, ) => { - const isSelected = - Boolean(serverId) && - activeWorkspaceSelection?.serverId === serverId && - activeWorkspaceSelection.workspaceId === item.workspaceId; - return ( { if (!serverId) { return; } onWorkspacePress?.(); - navigateToWorkspace(serverId, item.workspaceId); + navigateToWorkspace(serverId, item.workspaceId, { currentPathname }); }} drag={input?.drag ?? (() => {})} isDragging={input?.isDragging ?? false} @@ -1752,11 +1741,12 @@ function ProjectBlock({ ); }, [ - activeWorkspaceSelection, project.projectKind, creatingWorkspaceIds, + currentPathname, onWorkspacePress, serverId, + selectionEnabled, shortcutIndexByWorkspaceKey, showShortcutBadges, ], @@ -1843,7 +1833,9 @@ function ProjectBlock({ return; } onWorkspacePress?.(); - navigateToWorkspace(serverId, rowModel.workspace.workspaceId); + navigateToWorkspace(serverId, rowModel.workspace.workspaceId, { + currentPathname, + }); }} serverId={serverId} onWorkspacePress={onWorkspacePress} @@ -1856,6 +1848,7 @@ function ProjectBlock({ isProjectActive={isProjectActive} onRemoveProject={handleRemoveProject} removeProjectStatus={isRemovingProject ? "pending" : "idle"} + selectionEnabled={selectionEnabled} /> ) : ( <> @@ -1914,9 +1907,7 @@ export function SidebarWorkspaceList({ listFooterComponent, parentGestureRef, }: SidebarWorkspaceListProps) { - const isMobile = useIsCompactFormFactor(); const pathname = usePathname(); - const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( new Map(), @@ -1932,7 +1923,7 @@ export function SidebarWorkspaceList({ () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), [pathname], ); - const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null; + const selectionEnabled = isWorkspaceRoute; const projectIconRequests = useMemo(() => { if (!serverId) { @@ -2137,7 +2128,7 @@ export function SidebarWorkspaceList({ displayName={item.projectName} iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null} serverId={serverId} - activeWorkspaceSelection={effectiveActiveWorkspaceSelection} + selectionEnabled={selectionEnabled} showShortcutBadges={showShortcutBadges} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} parentGestureRef={parentGestureRef} @@ -2145,6 +2136,7 @@ export function SidebarWorkspaceList({ onWorkspacePress={onWorkspacePress} onWorkspaceReorder={handleWorkspaceReorder} onWorktreeCreated={handleWorktreeCreated} + currentPathname={pathname} drag={drag} isDragging={isActive} dragHandleProps={dragHandleProps} @@ -2155,13 +2147,14 @@ export function SidebarWorkspaceList({ }, [ collapsedProjectKeys, - effectiveActiveWorkspaceSelection, handleWorktreeCreated, handleWorkspaceReorder, onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, + pathname, projectIconByProjectKey, + selectionEnabled, serverId, shortcutIndexByWorkspaceKey, showShortcutBadges, diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 5d504206b..0befd840d 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -76,6 +76,7 @@ interface SplitContainerProps { workspaceKey: string; normalizedServerId: string; normalizedWorkspaceId: string; + isWorkspaceFocused: boolean; uiTabs: WorkspaceTab[]; hoveredCloseTabKey: string | null; setHoveredTabKey: Dispatch>; @@ -93,7 +94,6 @@ interface SplitContainerProps { onCreateTerminalTab: (input: { paneId?: string }) => void; buildPaneContentModel: (input: { paneId: string; - isPaneFocused: boolean; tab: WorkspaceTabDescriptor; }) => WorkspacePaneContentModel; onFocusPane: (paneId: string) => void; @@ -158,11 +158,11 @@ interface SplitPaneViewProps interface MountedTabSlotProps { tabDescriptor: WorkspaceTabDescriptor; isVisible: boolean; + isWorkspaceFocused: boolean; isPaneFocused: boolean; paneId: string; buildPaneContentModel: (input: { paneId: string; - isPaneFocused: boolean; tab: WorkspaceTabDescriptor; }) => WorkspacePaneContentModel; } @@ -170,6 +170,7 @@ interface MountedTabSlotProps { const MountedTabSlot = memo(function MountedTabSlot({ tabDescriptor, isVisible, + isWorkspaceFocused, isPaneFocused, paneId, buildPaneContentModel, @@ -178,15 +179,18 @@ const MountedTabSlot = memo(function MountedTabSlot({ () => buildPaneContentModel({ paneId, - isPaneFocused, tab: tabDescriptor, }), - [buildPaneContentModel, isPaneFocused, paneId, tabDescriptor], + [buildPaneContentModel, paneId, tabDescriptor], ); return ( - + ); }); @@ -241,6 +245,7 @@ export function SplitContainer({ workspaceKey, normalizedServerId, normalizedWorkspaceId, + isWorkspaceFocused, uiTabs, hoveredCloseTabKey, setHoveredTabKey, @@ -511,6 +516,7 @@ export function SplitContainer({ focusedPaneId={layout.focusedPaneId} normalizedServerId={normalizedServerId} normalizedWorkspaceId={normalizedWorkspaceId} + isWorkspaceFocused={isWorkspaceFocused} hoveredCloseTabKey={hoveredCloseTabKey} setHoveredTabKey={setHoveredTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey} @@ -633,6 +639,7 @@ function SplitNodeView({ focusedPaneId, normalizedServerId, normalizedWorkspaceId, + isWorkspaceFocused, hoveredCloseTabKey, setHoveredTabKey, setHoveredCloseTabKey, @@ -668,6 +675,7 @@ function SplitNodeView({ isFocused={node.pane.id === focusedPaneId} normalizedServerId={normalizedServerId} normalizedWorkspaceId={normalizedWorkspaceId} + isWorkspaceFocused={isWorkspaceFocused} hoveredCloseTabKey={hoveredCloseTabKey} setHoveredTabKey={setHoveredTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey} @@ -718,6 +726,7 @@ function SplitNodeView({ focusedPaneId={focusedPaneId} normalizedServerId={normalizedServerId} normalizedWorkspaceId={normalizedWorkspaceId} + isWorkspaceFocused={isWorkspaceFocused} hoveredCloseTabKey={hoveredCloseTabKey} setHoveredTabKey={setHoveredTabKey} setHoveredCloseTabKey={setHoveredCloseTabKey} @@ -767,6 +776,7 @@ function SplitPaneView({ isFocused, normalizedServerId, normalizedWorkspaceId, + isWorkspaceFocused, hoveredCloseTabKey, setHoveredTabKey, setHoveredCloseTabKey, @@ -916,6 +926,7 @@ function SplitPaneView({ key={tabId} tabDescriptor={tabDescriptor} isVisible={tabId === activeTabDescriptor?.tabId} + isWorkspaceFocused={isWorkspaceFocused} isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId} paneId={pane.id} buildPaneContentModel={buildPaneContentModel} diff --git a/packages/app/src/components/stream-strategy-web.test.tsx b/packages/app/src/components/stream-strategy-web.test.tsx new file mode 100644 index 000000000..3ccd1a82a --- /dev/null +++ b/packages/app/src/components/stream-strategy-web.test.tsx @@ -0,0 +1,128 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { StreamItem } from "@/types/stream"; +import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy"; +import { createWebStreamStrategy } from "./stream-strategy-web"; + +function userMessage(index: number): StreamItem { + return { + kind: "user_message", + id: `message-${index}`, + text: `Message ${index}`, + timestamp: new Date(`2026-04-20T00:00:${String(index % 60).padStart(2, "0")}.000Z`), + }; +} + +function createRenderers(onRowRender: () => void): StreamSegmentRenderers { + return { + renderHistoryVirtualizedRow: (item) => { + onRowRender(); + return
{item.id}
; + }, + renderHistoryMountedRow: (item) =>
{item.id}
, + renderLiveHeadRow: (item) =>
{item.id}
, + renderLiveAuxiliary: () => null, + }; +} + +describe("createWebStreamStrategy", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + let originalScrollTo: HTMLElement["scrollTo"] | undefined; + let originalOffsetHeight: PropertyDescriptor | undefined; + + beforeEach(() => { + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, + }); + Object.defineProperty(globalThis, "ResizeObserver", { + value: class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + configurable: true, + }); + originalScrollTo = HTMLElement.prototype.scrollTo; + HTMLElement.prototype.scrollTo = vi.fn(); + originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get() { + return 24; + }, + }); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + if (originalScrollTo) { + HTMLElement.prototype.scrollTo = originalScrollTo; + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollTo"); + } + if (originalOffsetHeight) { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight"); + } + vi.restoreAllMocks(); + }); + + it("mounts virtualized history without recursive row measurement updates", () => { + const rowRenderCount = vi.fn(); + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + const viewportRef = React.createRef(); + const historyVirtualized = Array.from({ length: 16 }, (_, index) => userMessage(index)); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + <> + {strategy.render({ + agentId: "agent", + segments: { + historyVirtualized, + historyMounted: [], + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: true, + hasMountedHistory: false, + hasLiveHead: false, + historyToHeadGap: 0, + }, + renderers: createRenderers(rowRenderCount), + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + })} + , + ); + }); + + expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0); + expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); + }); +}); diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx index 2189a67b6..3c228833a 100644 --- a/packages/app/src/components/stream-strategy-web.tsx +++ b/packages/app/src/components/stream-strategy-web.tsx @@ -1,4 +1,4 @@ -import { +import React, { Fragment, type CSSProperties, useCallback, @@ -126,6 +126,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const lastTouchClientYRef = useRef(null); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(null); + const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const showDesktopWebScrollbar = !isMobileBreakpoint; const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, { enabled: showDesktopWebScrollbar, @@ -181,6 +182,38 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotalSize = rowVirtualizer.getTotalSize(); + const measureVirtualizedRowElement = useCallback( + (node: HTMLDivElement | null) => { + if (!node) { + rowVirtualizer.measureElement(null); + return; + } + const pendingFrames = pendingVirtualRowMeasureFramesRef.current; + const existingFrame = pendingFrames.get(node); + if (existingFrame !== undefined) { + window.cancelAnimationFrame(existingFrame); + } + const frame = window.requestAnimationFrame(() => { + pendingFrames.delete(node); + if (node.isConnected) { + rowVirtualizer.measureElement(node); + } + }); + pendingFrames.set(node, frame); + }, + [rowVirtualizer], + ); + + useEffect(() => { + const pendingFrames = pendingVirtualRowMeasureFramesRef.current; + return () => { + for (const frame of pendingFrames.values()) { + window.cancelAnimationFrame(frame); + } + pendingFrames.clear(); + }; + }, []); + const cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; if (pendingFrame !== null) { @@ -696,7 +729,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
{renderHistoryVirtualizedRow( diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index a69e16a68..61794a5fa 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -27,7 +26,9 @@ interface TerminalPaneProps { serverId: string; cwd: string; terminalId: string; + isWorkspaceFocused: boolean; isPaneFocused: boolean; + onOpenFileExplorer: () => void; } const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320]; @@ -82,15 +83,20 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string { return `${input.serverId}:${input.cwd}`; } -export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: TerminalPaneProps) { - const isScreenFocused = useIsFocused(); +export function TerminalPane({ + serverId, + cwd, + terminalId, + isWorkspaceFocused, + isPaneFocused, + onOpenFileExplorer, +}: TerminalPaneProps) { const isAppVisible = useAppVisible(); const { theme } = useUnistyles(); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]); const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); - const showMobileFileExplorer = usePanelStore((state) => state.showMobileFileExplorer); const swipeGesturesEnabled = isMobile && mobileView === "agent"; const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({ mode: "padding", @@ -131,7 +137,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi }, []); useEffect(() => { - if (isMobile || !isScreenFocused || !isPaneFocused || !terminalId) { + if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) { lastAutoFocusKeyRef.current = null; return; } @@ -143,14 +149,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi lastAutoFocusKeyRef.current = nextFocusKey; requestTerminalFocus(); - }, [isMobile, isPaneFocused, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]); + }, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]); useEffect(() => { - if (isPaneFocused && isScreenFocused && isAppVisible && terminalId) { + if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) { lastReportedSizeRef.current = null; requestTerminalReflow(); } - }, [isAppVisible, isPaneFocused, isScreenFocused, requestTerminalReflow, terminalId]); + }, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]); const clearKeyboardRefitTimeouts = useCallback(() => { if (keyboardRefitTimeoutsRef.current.length === 0) { @@ -187,29 +193,27 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi [pulseKeyboardRefits], ); - useFocusEffect( - useCallback(() => { - if (!terminalId) { - return; - } - // Navigation transitions can temporarily report stale dimensions. - // Pulse forced refits so xterm fills the pane when returning to an agent. - const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => - setTimeout(() => { - requestTerminalReflow(); - }, delayMs), - ); + useEffect(() => { + if (!isWorkspaceFocused || !terminalId) { + return; + } + // Focus transitions can temporarily report stale dimensions. + // Pulse forced refits so xterm fills the pane when returning to a terminal. + const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => + setTimeout(() => { + requestTerminalReflow(); + }, delayMs), + ); - return () => { - for (const handle of timeoutHandles) { - clearTimeout(handle); - } - }; - }, [requestTerminalReflow, terminalId]), - ); + return () => { + for (const handle of timeoutHandles) { + clearTimeout(handle); + } + }; + }, [isWorkspaceFocused, requestTerminalReflow, terminalId]); useEffect(() => { - if (!client || !isConnected || !isScreenFocused) { + if (!client || !isConnected || !isWorkspaceFocused) { return; } @@ -232,7 +236,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi }); setModifiers({ ...EMPTY_MODIFIERS }); }); - }, [client, isConnected, workspaceTerminalSession.snapshots]); + }, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]); useEffect(() => { lastReportedSizeRef.current = null; @@ -257,14 +261,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi client, getPreferredSize: () => lastReportedSizeRef.current, onOutput: ({ terminalId, text }) => { - if (!isScreenFocused || terminalIdRef.current !== terminalId) { + if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) { return; } emulatorRef.current?.writeOutput(text); }, onSnapshot: ({ terminalId, state }) => { workspaceTerminalSession.snapshots.set({ terminalId, state }); - if (!isScreenFocused || terminalIdRef.current !== terminalId) { + if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) { return; } emulatorRef.current?.renderSnapshot(state); @@ -274,7 +278,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi streamControllerRef.current = controller; controller.setTerminal({ - terminalId: isScreenFocused ? terminalIdRef.current : null, + terminalId: isWorkspaceFocused ? terminalIdRef.current : null, }); return () => { @@ -287,17 +291,17 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi client, handleStreamControllerStatus, isConnected, - isScreenFocused, + isWorkspaceFocused, workspaceTerminalSession.snapshots, ]); useEffect(() => { pendingTerminalInputRef.current = []; - const nextTerminalId = isScreenFocused ? terminalId : null; + const nextTerminalId = isWorkspaceFocused ? terminalId : null; streamControllerRef.current?.setTerminal({ terminalId: nextTerminalId, }); - }, [isScreenFocused, terminalId]); + }, [isWorkspaceFocused, terminalId]); const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => { const queue = pendingTerminalInputRef.current; @@ -477,7 +481,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi !client || !terminalId || !isPaneFocused || - !isScreenFocused || + !isWorkspaceFocused || !isAppVisible || rows <= 0 || cols <= 0 @@ -550,7 +554,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi return ( - {isScreenFocused ? ( + {isWorkspaceFocused ? ( )} - {isAttaching && isScreenFocused ? ( + {isAttaching && isWorkspaceFocused ? ( diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index 590091e70..7863a87b8 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -11,7 +11,7 @@ import { useWindowDimensions } from "react-native"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { usePanelStore } from "@/stores/panel-store"; +import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { getRightSidebarAnimationTargets, shouldSyncSidebarAnimation, @@ -38,11 +38,9 @@ const ExplorerSidebarAnimationContext = createContext state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - - // Derive isOpen from the unified panel state - const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen; + const isOpen = usePanelStore((state) => + selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }), + ); // Right sidebar: closed = +windowWidth (off-screen right), open = 0 const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth }); diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index cbc0e9f24..b1a1b5f83 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -11,7 +11,7 @@ import { useWindowDimensions } from "react-native"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { usePanelStore } from "@/stores/panel-store"; +import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { getLeftSidebarAnimationTargets, shouldSyncSidebarAnimation, @@ -37,11 +37,9 @@ const SidebarAnimationContext = createContext state.mobileView); - const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); - - // Derive isOpen from the unified panel state - const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen; + const isOpen = usePanelStore((state) => + selectIsAgentListOpen(state, { isCompact: isCompactLayout }), + ); // Initialize based on current state const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth }); diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index e5321a624..e2673f619 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -1,7 +1,7 @@ import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useId, useMemo } from "react"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { usePanelStore } from "@/stores/panel-store"; +import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; import { orderCheckoutDiffFiles } from "./checkout-diff-order"; @@ -59,10 +59,8 @@ export function useCheckoutDiffQuery({ const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const isMobile = useIsCompactFormFactor(); - const mobileView = usePanelStore((state) => state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const explorerTab = usePanelStore((state) => state.explorerTab); - const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; + const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile })); const hookInstanceId = useId(); const normalizedCompare = useMemo( () => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }), diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index ddb203ee8..998444cab 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -32,7 +32,6 @@ export function useKeyboardShortcuts({ enabled, isMobile, toggleAgentList, - toggleFileExplorer, toggleBothSidebars, toggleFocusMode, cycleTheme, @@ -40,7 +39,6 @@ export function useKeyboardShortcuts({ enabled: boolean; isMobile: boolean; toggleAgentList: () => void; - toggleFileExplorer?: () => void; toggleBothSidebars?: () => void; toggleFocusMode?: () => void; cycleTheme?: () => void; @@ -84,7 +82,7 @@ export function useKeyboardShortcuts({ return false; } - navigateToWorkspace(target.serverId, target.workspaceId); + navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname }); return true; }; const navigateRelativeWorkspace = (delta: 1 | -1): boolean => { @@ -100,7 +98,9 @@ export function useKeyboardShortcuts({ if (!fallback) { return false; } - navigateToWorkspace(fallback.serverId, fallback.workspaceId); + navigateToWorkspace(fallback.serverId, fallback.workspaceId, { + currentPathname: pathname, + }); return true; } @@ -115,7 +115,7 @@ export function useKeyboardShortcuts({ if (!target) { return false; } - navigateToWorkspace(target.serverId, target.workspaceId); + navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname }); return true; }; @@ -257,10 +257,10 @@ export function useKeyboardShortcuts({ } return true; case "sidebar.toggle.right": - if (toggleFileExplorer) { - toggleFileExplorer(); - } - return true; + return keyboardActionDispatcher.dispatch({ + id: "sidebar.toggle.right", + scope: "sidebar", + }); case "view.toggle.focus": if (toggleFocusMode) { toggleFocusMode(); @@ -425,7 +425,6 @@ export function useKeyboardShortcuts({ pathname, resetModifiers, toggleAgentList, - toggleFileExplorer, toggleFocusMode, ]); } diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index f49d09fd2..d40a92f39 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -1,4 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +/** + * @vitest-environment jsdom + */ +import { act } from "@testing-library/react"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { createRoot, type Root } from "react-dom/client"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; vi.hoisted(() => { (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; @@ -8,8 +15,12 @@ import { appendMissingOrderKeys, applyStoredOrdering, buildSidebarProjectsFromStructure, + useSidebarWorkspacesList, } from "./use-sidebar-workspaces-list"; import type { WorkspaceStructureProject } from "@/stores/session-store-hooks"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; interface OrderedItem { key: string; @@ -35,6 +46,45 @@ function project(input: { }; } +function workspaceDescriptor(id: string): WorkspaceDescriptor { + return { + id, + projectId: "project-1", + projectDisplayName: "Project 1", + projectRootPath: "/repo/main", + workspaceDirectory: `/repo/main/${id}`, + projectKind: "git", + workspaceKind: "worktree", + name: id, + status: "done", + diffStat: null, + scripts: [], + }; +} + +function DisabledHookProbe({ serverId }: { serverId: string }): null { + const result = useSidebarWorkspacesList({ serverId, enabled: false }); + + expect(result.projects).toEqual([]); + expect(result.isLoading).toBe(false); + expect(result.isInitialLoad).toBe(false); + expect(result.isRevalidating).toBe(false); + + return null; +} + +function DisabledRenderCountProbe({ + onRender, + serverId, +}: { + onRender: () => void; + serverId: string; +}): null { + useSidebarWorkspacesList({ serverId, enabled: false }); + onRender(); + return null; +} + describe("applyStoredOrdering", () => { it("keeps unknown items on the baseline while applying stored order", () => { const result = applyStoredOrdering({ @@ -139,3 +189,73 @@ describe("buildSidebarProjectsFromStructure", () => { ]); }); }); + +describe("useSidebarWorkspacesList", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + act(() => { + getHostRuntimeStore().syncHosts([]); + useSessionStore.getState().clearSession("srv-disabled"); + useSidebarOrderStore.setState({ + projectOrderByServerId: {}, + workspaceOrderByServerAndProject: {}, + }); + }); + }); + + it("honors enabled false without appending persisted order keys", async () => { + act(() => { + useSessionStore.getState().initializeSession("srv-disabled", null as unknown as DaemonClient); + useSessionStore + .getState() + .setWorkspaces("srv-disabled", new Map([["ws-main", workspaceDescriptor("ws-main")]])); + useSessionStore.getState().setHasHydratedWorkspaces("srv-disabled", true); + }); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(DisabledHookProbe, { serverId: "srv-disabled" })); + }); + + expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({}); + expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({}); + }); + + it("does not subscribe to order updates while disabled", async () => { + const onRender = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement(DisabledRenderCountProbe, { + serverId: "srv-disabled", + onRender, + }), + ); + }); + + expect(onRender).toHaveBeenCalledTimes(1); + + await act(async () => { + useSidebarOrderStore.getState().setProjectOrder("srv-disabled", ["project-a"]); + }); + + expect(onRender).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 356341062..effdc312e 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -191,11 +191,10 @@ export function useSidebarWorkspacesList(options?: { const value = options?.serverId; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; }, [options?.serverId]); + const isActive = Boolean(serverId) && options?.enabled !== false; const persistedProjectOrder = useSidebarOrderStore((state) => - serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER, + isActive && serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER, ); - - const isActive = Boolean(serverId); const hasHydratedWorkspaces = useSessionStore((state) => isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false, ); diff --git a/packages/app/src/hooks/use-workspace-navigation.test.ts b/packages/app/src/hooks/use-workspace-navigation.test.ts new file mode 100644 index 000000000..280c038ba --- /dev/null +++ b/packages/app/src/hooks/use-workspace-navigation.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { navigateMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), +})); + +vi.mock("expo-router", () => ({ + router: { + navigate: navigateMock, + }, +})); + +import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; +import { + activateNavigationWorkspaceSelection, + getNavigationActiveWorkspaceSelection, + syncNavigationActiveWorkspace, +} from "@/stores/navigation-active-workspace-store"; + +describe("navigateToWorkspace", () => { + beforeEach(() => { + navigateMock.mockReset(); + syncNavigationActiveWorkspace({ current: null }); + }); + + it("uses router navigation from a non-workspace route even when active selection is stale", () => { + activateNavigationWorkspaceSelection({ + serverId: "server-1", + workspaceId: "workspace-a", + }); + + navigateToWorkspace("server-1", "workspace-b", { + currentPathname: "/h/server-1/sessions", + }); + + expect(navigateMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b"); + expect(getNavigationActiveWorkspaceSelection()).toEqual({ + serverId: "server-1", + workspaceId: "workspace-a", + }); + }); + + it("keeps retained workspace switching on a workspace route", () => { + activateNavigationWorkspaceSelection({ + serverId: "server-1", + workspaceId: "workspace-a", + }); + + navigateToWorkspace("server-1", "workspace-b", { + currentPathname: "/h/server-1/workspace/workspace-a", + }); + + expect(navigateMock).not.toHaveBeenCalled(); + expect(getNavigationActiveWorkspaceSelection()).toEqual({ + serverId: "server-1", + workspaceId: "workspace-b", + }); + }); +}); diff --git a/packages/app/src/hooks/use-workspace-navigation.ts b/packages/app/src/hooks/use-workspace-navigation.ts index d69bc347c..f7483e52c 100644 --- a/packages/app/src/hooks/use-workspace-navigation.ts +++ b/packages/app/src/hooks/use-workspace-navigation.ts @@ -4,15 +4,43 @@ import { activateNavigationWorkspaceSelection, getNavigationActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; + +interface NavigateToWorkspaceOptions { + currentPathname?: string | null; +} + +function shouldUseRetainedWorkspaceSwitch(input: { + hasActiveWorkspace: boolean; + currentPathname?: string | null; +}): boolean { + if (!input.hasActiveWorkspace) { + return false; + } + + if (input.currentPathname == null) { + return true; + } + + return parseHostWorkspaceRouteFromPathname(input.currentPathname) !== null; +} /** * Open a workspace. Once the workspace shell is mounted, switching workspaces * is app-level state so native-stack does not rebuild every retained screen. */ -export function navigateToWorkspace(serverId: string, workspaceId: string) { +export function navigateToWorkspace( + serverId: string, + workspaceId: string, + options: NavigateToWorkspaceOptions = {}, +) { const activeWorkspace = getNavigationActiveWorkspaceSelection(); - if (activeWorkspace) { + if ( + shouldUseRetainedWorkspaceSwitch({ + hasActiveWorkspace: activeWorkspace !== null, + currentPathname: options.currentPathname, + }) + ) { activateNavigationWorkspaceSelection( { serverId, workspaceId }, { updateBrowserHistory: true, historyMode: "push" }, @@ -21,13 +49,16 @@ export function navigateToWorkspace(serverId: string, workspaceId: string) { } const href = buildHostWorkspaceRoute(serverId, workspaceId); - router.navigate(href as any); + router.navigate(href); } export function useWorkspaceNavigation() { return { - navigateToWorkspace: useCallback((serverId: string, workspaceId: string) => { - navigateToWorkspace(serverId, workspaceId); - }, []), + navigateToWorkspace: useCallback( + (serverId: string, workspaceId: string, options?: NavigateToWorkspaceOptions) => { + navigateToWorkspace(serverId, workspaceId, options); + }, + [], + ), }; } diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 90cbcf117..1a1989cd5 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -24,6 +24,7 @@ export type KeyboardActionId = | "workspace.pane.move-tab.down" | "workspace.pane.close" | "workspace.terminal.new" + | "sidebar.toggle.right" | "worktree.new" | "worktree.archive"; @@ -51,6 +52,7 @@ export type KeyboardActionDefinition = | { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope } | { id: "workspace.pane.close"; scope: KeyboardActionScope } | { id: "workspace.terminal.new"; scope: KeyboardActionScope } + | { id: "sidebar.toggle.right"; scope: KeyboardActionScope } | { id: "worktree.new"; scope: KeyboardActionScope } | { id: "worktree.archive"; scope: KeyboardActionScope }; diff --git a/packages/app/src/panels/agent-panel.test.tsx b/packages/app/src/panels/agent-panel.test.tsx new file mode 100644 index 000000000..b5fd07dbc --- /dev/null +++ b/packages/app/src/panels/agent-panel.test.tsx @@ -0,0 +1,438 @@ +/** + * @vitest-environment jsdom + */ +import React, { useImperativeHandle } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DaemonClient } from "@server/client/daemon-client"; +import { PaneFocusProvider, PaneProvider } from "@/panels/pane-context"; +import { agentPanelRegistration } from "@/panels/agent-panel"; +import { useDraftStore } from "@/stores/draft-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import type { PendingPermission } from "@/types/shared"; +import type { StreamItem } from "@/types/stream"; +import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types"; + +type PanelTestTheme = { + colors: { + foreground: string; + foregroundMuted: string; + surface0: string; + surface1: string; + surface2: string; + surface3: string; + border: string; + destructive: string; + }; + spacing: Record; + borderRadius: Record; + fontSize: Record; + fontWeight: Record; + iconSize: Record; +}; +type PanelTestStyles = Record; +type PanelTestStyleFactory = (input: PanelTestTheme) => PanelTestStyles; + +const { + composerRenderCount, + composerUnmountCount, + latestComposerCwd, + streamRenderCount, + latestStreamPermissionKeys, + latestStreamText, + theme, + runtimeClient, +} = vi.hoisted(() => { + Object.defineProperty(globalThis, "__DEV__", { + value: false, + configurable: true, + }); + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, + }); + Object.defineProperty(globalThis, "ResizeObserver", { + value: class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + configurable: true, + }); + + return { + composerRenderCount: vi.fn(), + composerUnmountCount: vi.fn(), + latestComposerCwd: { current: null as string | null }, + streamRenderCount: vi.fn(), + latestStreamPermissionKeys: { current: [] as string[] }, + latestStreamText: { current: null as string | null }, + theme: { + colors: { + foreground: "#ffffff", + foregroundMuted: "#999999", + surface0: "#000000", + surface1: "#111111", + surface2: "#222222", + surface3: "#333333", + border: "#444444", + destructive: "#ff0000", + }, + spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 }, + borderRadius: { md: 6, lg: 8, xl: 12 }, + fontSize: { sm: 13, base: 15, lg: 18 }, + fontWeight: { medium: "500" }, + iconSize: { lg: 22 }, + } satisfies PanelTestTheme, + runtimeClient: { + fetchAgent: vi.fn(), + fetchAgentTimeline: vi.fn(), + }, + }; +}); + +vi.mock("react-native-reanimated", () => ({ + default: { + View: ({ children }: { children: React.ReactNode }) =>
{children}
, + }, +})); + +vi.mock("react-native-unistyles", () => { + return { + StyleSheet: { + create: createPanelTestStyles, + }, + useUnistyles: () => ({ theme }), + }; +}); + +function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles): PanelTestStyles { + return typeof factory === "function" ? factory(theme) : factory; +} + +vi.mock("@/runtime/host-runtime", () => ({ + useHosts: () => [{ serverId: "server", label: "Test server" }], + useHostRuntimeClient: () => runtimeClient, + useHostRuntimeIsConnected: () => false, + useHostRuntimeConnectionStatus: () => "offline", + useHostRuntimeLastError: () => null, +})); + +vi.mock("@/attachments/service", () => ({ + garbageCollectAttachments: vi.fn(async () => {}), + persistAttachmentFromDataUrl: vi.fn(), + persistAttachmentFromFileUri: vi.fn(), +})); + +vi.mock("@/components/provider-icons", () => ({ + getProviderIcon: () => null, +})); + +vi.mock("@/components/file-drop-zone", () => ({ + FileDropZone: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/toast-host", () => ({ + ToastViewport: () => null, + useToastHost: () => ({ + toast: null, + dismiss: vi.fn(), + api: { + show: vi.fn(), + }, + }), +})); + +vi.mock("@/components/archived-agent-callout", () => ({ + ArchivedAgentCallout: ({ agentId }: { agentId: string }) => ( +
{agentId}
+ ), +})); + +vi.mock("@/hooks/use-keyboard-shift-style", () => ({ + useKeyboardShiftStyle: () => ({ style: null }), +})); + +vi.mock("@/hooks/use-archive-agent", () => ({ + useArchiveAgent: () => ({ + isArchivingAgent: () => false, + }), +})); + +vi.mock("@/components/composer", () => ({ + Composer: ({ cwd }: { cwd: string }) => { + React.useEffect( + () => () => { + composerUnmountCount(); + }, + [], + ); + composerRenderCount(); + latestComposerCwd.current = cwd; + return
{cwd}
; + }, +})); + +vi.mock("@/components/agent-stream-view", () => ({ + AgentStreamView: React.memo( + React.forwardRef< + { prepareForViewportChange: () => void; scrollToBottom: (reason: "message-sent") => void }, + { pendingPermissions: Map; streamItems: StreamItem[] } + >(function AgentStreamView({ pendingPermissions, streamItems }, ref) { + useImperativeHandle(ref, () => ({ + prepareForViewportChange: vi.fn(), + scrollToBottom: vi.fn(), + })); + streamRenderCount(); + latestStreamPermissionKeys.current = Array.from(pendingPermissions.keys()); + latestStreamText.current = + streamItems.find((item) => item.kind === "user_message")?.text ?? null; + return
{latestStreamText.current}
; + }), + ), +})); + +function makeClient(): DaemonClient { + return new DaemonClient({ + url: "ws://127.0.0.1:1", + clientId: "panel-render-isolation-test", + }); +} + +function makeAgent(overrides: Partial = {}): Agent { + const now = new Date("2026-04-20T00:00:00.000Z"); + return { + serverId: "server", + id: "agent", + provider: "codex", + status: "running", + createdAt: now, + updatedAt: now, + lastUserMessageAt: null, + lastActivityAt: now, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + lastError: null, + title: "Render isolation", + cwd: "/workspace/one", + model: null, + labels: {}, + ...overrides, + }; +} + +function seedReadyAgent(agent: Agent = makeAgent()) { + const store = useSessionStore.getState(); + store.initializeSession("server", makeClient()); + store.setAgents("server", new Map([["agent", agent]])); + store.setAgentAuthoritativeHistoryApplied("server", "agent", true); +} + +async function renderAgentPanel(root: Root) { + const AgentPanel = agentPanelRegistration.component; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + await act(async () => { + root.render( + + + + + + + , + ); + await Promise.resolve(); + }); +} + +function updateCurrentAgentStream(text: string) { + act(() => { + useSessionStore.getState().setAgentStreamTail("server", (previous) => { + const next = new Map(previous); + next.set("agent", [ + { + kind: "user_message", + id: `message-${text}`, + text, + timestamp: new Date("2026-04-20T00:00:01.000Z"), + }, + ]); + return next; + }); + }); +} + +function makePendingPermission(agentId: string, requestId: string): PendingPermission { + const request: AgentPermissionRequest = { + id: requestId, + provider: "codex", + name: `permission-${requestId}`, + kind: "tool", + }; + const key = `${agentId}:${requestId}`; + return { + key, + agentId, + request, + }; +} + +function updatePendingPermissions(permissions: PendingPermission[]) { + act(() => { + useSessionStore + .getState() + .setPendingPermissions( + "server", + new Map(permissions.map((permission) => [permission.key, permission])), + ); + }); +} + +async function updateCurrentAgentCwd(cwd: string) { + await act(async () => { + useSessionStore.getState().setAgents("server", (previous) => { + const current = previous.get("agent"); + if (!current) { + throw new Error("Expected seeded agent"); + } + const next = new Map(previous); + next.set("agent", { ...current, cwd }); + return next; + }); + await Promise.resolve(); + }); +} + +describe("AgentPanel render isolation", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); + useDraftStore.setState({ drafts: {}, createModalDraft: null }); + composerRenderCount.mockClear(); + composerUnmountCount.mockClear(); + latestComposerCwd.current = null; + streamRenderCount.mockClear(); + latestStreamPermissionKeys.current = []; + latestStreamText.current = null; + }); + + it("refreshes the stream view without invoking Composer for stream-only updates", async () => { + seedReadyAgent(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await renderAgentPanel(root); + const composerBaseline = composerRenderCount.mock.calls.length; + const streamBaseline = streamRenderCount.mock.calls.length; + expect(composerBaseline).toBeGreaterThan(0); + expect(streamBaseline).toBeGreaterThan(0); + + updateCurrentAgentStream("stream-only update"); + + expect(latestStreamText.current).toBe("stream-only update"); + expect(streamRenderCount).toHaveBeenCalledTimes(streamBaseline + 1); + expect(composerRenderCount).toHaveBeenCalledTimes(composerBaseline); + }); + + it("still invokes Composer for current-agent cwd changes and unmounts it for archives", async () => { + seedReadyAgent(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await renderAgentPanel(root); + expect(latestComposerCwd.current).toBe("/workspace/one"); + const composerBaseline = composerRenderCount.mock.calls.length; + expect(composerBaseline).toBeGreaterThan(0); + + await updateCurrentAgentCwd("/workspace/two"); + + expect(latestComposerCwd.current).toBe("/workspace/two"); + expect(composerRenderCount.mock.calls.length).toBeGreaterThan(composerBaseline); + + await act(async () => { + useSessionStore.getState().setAgents("server", (previous) => { + const current = previous.get("agent"); + if (!current) { + throw new Error("Expected seeded agent"); + } + const next = new Map(previous); + next.set("agent", { ...current, archivedAt: new Date("2026-04-20T00:00:02.000Z") }); + return next; + }); + await Promise.resolve(); + }); + + expect(composerUnmountCount).toHaveBeenCalledTimes(1); + }); + + it("keeps stream permissions stable for unrelated pending permission updates", async () => { + seedReadyAgent(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await renderAgentPanel(root); + const initialRenderCount = streamRenderCount.mock.calls.length; + expect(initialRenderCount).toBeGreaterThan(0); + expect(latestStreamPermissionKeys.current).toEqual([]); + + const unrelatedPermission = makePendingPermission("other-agent", "unrelated"); + updatePendingPermissions([unrelatedPermission]); + + expect(latestStreamPermissionKeys.current).toEqual([]); + expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount); + + const agentPermission = makePendingPermission("agent", "current-agent"); + updatePendingPermissions([unrelatedPermission, agentPermission]); + + expect(latestStreamPermissionKeys.current).toEqual(["agent:current-agent"]); + expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 1); + + updatePendingPermissions([unrelatedPermission]); + + expect(latestStreamPermissionKeys.current).toEqual([]); + expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2); + }); +}); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index b0dee6e34..70d8f630a 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useShallow } from "zustand/shallow"; +import { shallow, useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; import invariant from "tiny-invariant"; import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; @@ -23,7 +23,7 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useStableEvent } from "@/hooks/use-stable-event"; -import { usePaneContext } from "@/panels/pane-context"; +import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { useHostRuntimeClient, @@ -111,7 +111,8 @@ function useAgentPanelDescriptor( } function AgentPanel() { - const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext(); + const { serverId, target, openFileInWorkspace } = usePaneContext(); + const { isPaneFocused } = usePaneFocus(); invariant(target.kind === "agent", "AgentPanel requires agent target"); function openWorkspaceFile(input: { filePath: string }) { @@ -137,6 +138,12 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = { }; const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const EMPTY_PENDING_PERMISSIONS = new Map(); +const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = []; + +type RouteBottomAnchorRequest = ReturnType; +type PendingCreateByDraftId = ReturnType["pendingByDraftId"]; +type PendingCreateAttempt = PendingCreateByDraftId[string]; function logWebStickyBottom(_event: string, _details: Record): void {} @@ -151,6 +158,27 @@ function isNotFoundErrorMessage(message: string): boolean { return /agent not found|not found/i.test(message); } +function findPendingCreateForPanel(input: { + pendingByDraftId: PendingCreateByDraftId; + serverId: string; + agentId?: string; +}): PendingCreateAttempt | null { + if (!input.agentId) { + return null; + } + const values = Object.values(input.pendingByDraftId); + for (const entry of values) { + if ( + entry.lifecycle === "active" && + entry.serverId === input.serverId && + entry.agentId === input.agentId + ) { + return entry; + } + } + return null; +} + type AgentLookupState = | { tag: "idle" } | { tag: "loading" } @@ -455,26 +483,13 @@ function ChatAgentContent({ }; }), ); - const agentInputDraft = useAgentInputDraft({ - draftKey: buildDraftStoreKey({ - serverId, - agentId: agentId ?? "__pending__", - }), - initialCwd: agentState.cwd ?? "", - }); const projectPlacement = useStoreWithEqualityFn( useSessionStore, (state) => agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null, (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), ); - const streamItemsRaw = useSessionStore((state) => - agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, - ); - const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); - const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); - const clearPendingCreate = useCreateFlowStore((state) => state.clear); const isInitializingFromMap = useSessionStore((state) => agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false, ); @@ -489,11 +504,7 @@ function ChatAgentContent({ const agentHistorySyncGeneration = useSessionStore((state) => agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, ); - const allPendingPermissions = useSessionStore( - (state) => state.sessions[serverId]?.pendingPermissions, - ); const setAgents = useSessionStore((state) => state.setAgents); - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const { ensureAgentIsInitialized } = useAgentInitialization({ @@ -504,38 +515,13 @@ function ChatAgentContent({ kind: "idle", }); - const pendingCreate = useMemo(() => { - if (!agentId) { - return null; - } - const values = Object.values(pendingByDraftId); - for (const entry of values) { - if ( - entry.lifecycle === "active" && - entry.serverId === serverId && - entry.agentId === agentId - ) { - return entry; - } - } - return null; - }, [agentId, pendingByDraftId, serverId]); + const pendingCreate = useMemo( + () => findPendingCreateForPanel({ pendingByDraftId, serverId, agentId }), + [agentId, pendingByDraftId, serverId], + ); const isPendingCreateForPanel = Boolean(pendingCreate); const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; - const pendingPermissions = useMemo(() => { - if (!allPendingPermissions || !agentId) { - return new Map(); - } - const filtered = new Map(); - for (const [key, permission] of allPendingPermissions) { - if (permission.agentId === agentId) { - filtered.set(key, permission); - } - } - return filtered; - }, [agentId, allPendingPermissions]); - const attentionController = useAgentAttentionClear({ agentId, client, @@ -640,38 +626,7 @@ function ChatAgentContent({ return agentHistorySyncGeneration < historySyncGeneration; }, [agentHistorySyncGeneration, agentId, historySyncGeneration]); - const optimisticStreamItems = useMemo(() => { - if (!isPendingCreateForPanel || !pendingCreate) { - return EMPTY_STREAM_ITEMS; - } - return [ - { - kind: "user_message", - id: pendingCreate.clientMessageId, - text: pendingCreate.text, - timestamp: new Date(pendingCreate.timestamp), - ...(pendingCreate.images && pendingCreate.images.length > 0 - ? { images: pendingCreate.images } - : {}), - }, - ]; - }, [isPendingCreateForPanel, pendingCreate]); - - const mergedStreamItems = useMemo(() => { - if (optimisticStreamItems.length === 0) { - return streamItems; - } - const optimistic = optimisticStreamItems[0]; - if (!optimistic) { - return streamItems; - } - const alreadyHasOptimistic = streamItems.some( - (item) => item.kind === "user_message" && item.id === optimistic.id, - ); - return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems]; - }, [optimisticStreamItems, streamItems]); - - const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0; + const shouldUseOptimisticStream = isPendingCreateForPanel; const authoritativeStatus = agentState.status; const isAuthoritativeBootstrapping = authoritativeStatus === "initializing" || authoritativeStatus === "idle"; @@ -745,52 +700,29 @@ function ChatAgentContent({ [effectiveAgent?.id], ); - useEffect(() => { - if (!isPendingCreateForPanel || !pendingCreate) { + const handleComposerHeightChange = useCallback( + (height: number) => { + if (!agentId) { + return; + } + logWebStickyBottom("screen_composer_height_change", { + agentId, + height, + }); + streamViewRef.current?.prepareForViewportChange(); + }, + [agentId], + ); + + const handleMessageSent = useCallback(() => { + if (!agentId) { return; } - const hasUserMessage = streamItems.some( - (item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId, - ); - if (hasUserMessage && canFinalizePendingCreate) { - if (agentId && pendingCreate.images && pendingCreate.images.length > 0) { - setAgentStreamTail(serverId, (previous) => { - const current = previous.get(agentId); - if (!current) { - return previous; - } - - const merged = mergePendingCreateImages({ - streamItems: current, - clientMessageId: pendingCreate.clientMessageId, - images: pendingCreate.images, - }); - if (merged === current) { - return previous; - } - - const next = new Map(previous); - next.set(agentId, merged); - return next; - }); - } - markPendingCreateLifecycle({ - draftId: pendingCreate.draftId, - lifecycle: "sent", - }); - clearPendingCreate({ draftId: pendingCreate.draftId }); - } - }, [ - agentId, - canFinalizePendingCreate, - clearPendingCreate, - isPendingCreateForPanel, - markPendingCreateLifecycle, - pendingCreate, - serverId, - setAgentStreamTail, - streamItems, - ]); + logWebStickyBottom("screen_message_sent_scroll_to_bottom", { + agentId, + }); + streamViewRef.current?.scrollToBottom("message-sent"); + }, [agentId]); useEffect(() => { if (!agentId) { @@ -947,53 +879,35 @@ function ChatAgentContent({ - - {agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? ( - { - logWebStickyBottom("screen_composer_height_change", { - agentId, - height, - }); - streamViewRef.current?.prepareForViewportChange(); - }} - onMessageSent={() => { - logWebStickyBottom("screen_message_sent_scroll_to_bottom", { - agentId, - }); - streamViewRef.current?.scrollToBottom("message-sent"); - }} - /> - ) : agentId && agentState.archivedAt ? ( - - ) : null} + {viewState.tag === "ready" && viewState.sync.status === "catching_up" && @@ -1022,6 +936,265 @@ function ChatAgentContent({ ); } +function AgentStreamSection({ + streamViewRef, + serverId, + agentId, + agent, + pendingCreate, + shouldUseOptimisticStream, + canFinalizePendingCreate, + routeBottomAnchorRequest, + hasAppliedAuthoritativeHistory, + onOpenWorkspaceFile, +}: { + streamViewRef: React.RefObject; + serverId: string; + agentId?: string; + agent: AgentScreenAgent; + pendingCreate: PendingCreateAttempt | null; + shouldUseOptimisticStream: boolean; + canFinalizePendingCreate: boolean; + routeBottomAnchorRequest: RouteBottomAnchorRequest; + hasAppliedAuthoritativeHistory: boolean; + onOpenWorkspaceFile?: (input: { filePath: string }) => void; +}) { + const streamItemsRaw = useSessionStore((state) => + agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, + ); + const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; + const pendingPermissionList = useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!agentId) { + return EMPTY_PENDING_PERMISSION_LIST; + } + const allPendingPermissions = state.sessions[serverId]?.pendingPermissions; + if (!allPendingPermissions) { + return EMPTY_PENDING_PERMISSION_LIST; + } + const filtered: PendingPermission[] = []; + for (const permission of allPendingPermissions.values()) { + if (permission.agentId === agentId) { + filtered.push(permission); + } + } + return filtered.length > 0 ? filtered : EMPTY_PENDING_PERMISSION_LIST; + }, + shallow, + ); + const pendingPermissions = useMemo(() => { + if (pendingPermissionList.length === 0) { + return EMPTY_PENDING_PERMISSIONS; + } + return new Map(pendingPermissionList.map((permission) => [permission.key, permission])); + }, [pendingPermissionList]); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); + const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); + const clearPendingCreate = useCreateFlowStore((state) => state.clear); + + const optimisticStreamItems = useMemo(() => { + if (!shouldUseOptimisticStream || !pendingCreate) { + return EMPTY_STREAM_ITEMS; + } + return [ + { + kind: "user_message", + id: pendingCreate.clientMessageId, + text: pendingCreate.text, + timestamp: new Date(pendingCreate.timestamp), + ...(pendingCreate.images && pendingCreate.images.length > 0 + ? { images: pendingCreate.images } + : {}), + }, + ]; + }, [pendingCreate, shouldUseOptimisticStream]); + + const mergedStreamItems = useMemo(() => { + if (optimisticStreamItems.length === 0) { + return streamItems; + } + const optimistic = optimisticStreamItems[0]; + if (!optimistic) { + return streamItems; + } + const alreadyHasOptimistic = streamItems.some( + (item) => item.kind === "user_message" && item.id === optimistic.id, + ); + return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems]; + }, [optimisticStreamItems, streamItems]); + + useEffect(() => { + if (!shouldUseOptimisticStream || !pendingCreate) { + return; + } + const hasUserMessage = streamItems.some( + (item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId, + ); + if (!hasUserMessage || !canFinalizePendingCreate) { + return; + } + + const pendingImages = pendingCreate.images; + if (agentId && pendingImages && pendingImages.length > 0) { + setAgentStreamTail(serverId, (previous) => { + const current = previous.get(agentId); + if (!current) { + return previous; + } + + const merged = mergePendingCreateImages({ + streamItems: current, + clientMessageId: pendingCreate.clientMessageId, + images: pendingImages, + }); + if (merged === current) { + return previous; + } + + const next = new Map(previous); + next.set(agentId, merged); + return next; + }); + } + markPendingCreateLifecycle({ + draftId: pendingCreate.draftId, + lifecycle: "sent", + }); + clearPendingCreate({ draftId: pendingCreate.draftId }); + }, [ + agentId, + canFinalizePendingCreate, + clearPendingCreate, + markPendingCreateLifecycle, + pendingCreate, + serverId, + setAgentStreamTail, + shouldUseOptimisticStream, + streamItems, + ]); + + return ( + + ); +} + +function AgentComposerSection({ + agentId, + serverId, + isPaneFocused, + isArchivingCurrentAgent, + archivedAt, + initialCwd, + isSubmitLoading, + onAttentionInputFocus, + onAttentionPromptSend, + onAddImages, + onComposerHeightChange, + onMessageSent, +}: { + agentId?: string; + serverId: string; + isPaneFocused: boolean; + isArchivingCurrentAgent: boolean; + archivedAt: Date | null; + initialCwd: string; + isSubmitLoading: boolean; + onAttentionInputFocus: () => void; + onAttentionPromptSend: () => void; + onAddImages: (addImages: (images: ImageAttachment[]) => void) => void; + onComposerHeightChange: (height: number) => void; + onMessageSent: () => void; +}) { + if (!agentId) { + return null; + } + if (archivedAt) { + return ; + } + if (isArchivingCurrentAgent) { + return null; + } + + return ( + + ); +} + +function ActiveAgentComposer({ + agentId, + serverId, + isPaneFocused, + initialCwd, + isSubmitLoading, + onAttentionInputFocus, + onAttentionPromptSend, + onAddImages, + onComposerHeightChange, + onMessageSent, +}: { + agentId: string; + serverId: string; + isPaneFocused: boolean; + initialCwd: string; + isSubmitLoading: boolean; + onAttentionInputFocus: () => void; + onAttentionPromptSend: () => void; + onAddImages: (addImages: (images: ImageAttachment[]) => void) => void; + onComposerHeightChange: (height: number) => void; + onMessageSent: () => void; +}) { + const agentInputDraft = useAgentInputDraft({ + draftKey: buildDraftStoreKey({ + serverId, + agentId, + }), + initialCwd, + }); + + return ( + + ); +} + function AgentSessionUnavailableState({ serverLabel, connectionStatus, diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx index 5974097f6..4b70a8604 100644 --- a/packages/app/src/panels/draft-panel.tsx +++ b/packages/app/src/panels/draft-panel.tsx @@ -1,7 +1,7 @@ import { SquarePen } from "lucide-react-native"; import invariant from "tiny-invariant"; import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab"; -import { usePaneContext } from "@/panels/pane-context"; +import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelRegistration } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; @@ -17,15 +17,9 @@ function useDraftPanelDescriptor() { } function DraftPanel() { - const { - serverId, - workspaceId, - tabId, - target, - isPaneFocused, - openFileInWorkspace, - retargetCurrentTab, - } = usePaneContext(); + const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = + usePaneContext(); + const { isInteractive } = usePaneFocus(); invariant(target.kind === "draft", "DraftPanel requires draft target"); return ( @@ -34,7 +28,7 @@ function DraftPanel() { workspaceId={workspaceId} tabId={tabId} draftId={target.draftId} - isPaneFocused={isPaneFocused} + isPaneFocused={isInteractive} onOpenWorkspaceFile={({ filePath }) => { openFileInWorkspace(filePath); }} diff --git a/packages/app/src/panels/pane-context.test.ts b/packages/app/src/panels/pane-context.test.ts new file mode 100644 index 000000000..9d63c5784 --- /dev/null +++ b/packages/app/src/panels/pane-context.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { createPaneFocusContextValue } from "@/panels/pane-context"; + +describe("createPaneFocusContextValue", () => { + it("derives interactivity from both workspace and pane focus", () => { + expect( + createPaneFocusContextValue({ + isWorkspaceFocused: true, + isPaneFocused: true, + }), + ).toEqual({ + isWorkspaceFocused: true, + isPaneFocused: true, + isInteractive: true, + }); + expect( + createPaneFocusContextValue({ + isWorkspaceFocused: false, + isPaneFocused: true, + }), + ).toEqual({ + isWorkspaceFocused: false, + isPaneFocused: true, + isInteractive: false, + }); + }); +}); diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 2358d8dba..d4c3a27cd 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, type ReactNode } from "react"; +import React, { createContext, useContext, type ReactNode } from "react"; import invariant from "tiny-invariant"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; @@ -6,7 +6,6 @@ export interface PaneContextValue { serverId: string; workspaceId: string; tabId: string; - isPaneFocused: boolean; target: WorkspaceTabTarget; openTab(target: WorkspaceTabTarget): void; closeCurrentTab(): void; @@ -14,7 +13,25 @@ export interface PaneContextValue { openFileInWorkspace(filePath: string): void; } +export interface PaneFocusContextValue { + isWorkspaceFocused: boolean; + isPaneFocused: boolean; + isInteractive: boolean; +} + const PaneContext = createContext(null); +const PaneFocusContext = createContext(null); + +export function createPaneFocusContextValue(input: { + isWorkspaceFocused: boolean; + isPaneFocused: boolean; +}): PaneFocusContextValue { + return { + isWorkspaceFocused: input.isWorkspaceFocused, + isPaneFocused: input.isPaneFocused, + isInteractive: input.isWorkspaceFocused && input.isPaneFocused, + }; +} export function PaneProvider({ value, @@ -26,8 +43,24 @@ export function PaneProvider({ return {children}; } +export function PaneFocusProvider({ + value, + children, +}: { + value: PaneFocusContextValue; + children: ReactNode; +}) { + return {children}; +} + export function usePaneContext(): PaneContextValue { const value = useContext(PaneContext); invariant(value, "PaneContext is required"); return value; } + +export function usePaneFocus(): PaneFocusContextValue { + const value = useContext(PaneFocusContext); + invariant(value, "PaneFocusContext is required"); + return value; +} diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index b15c53b05..babf798c5 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -1,12 +1,13 @@ +import { useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { Terminal } from "lucide-react-native"; import { Text, View } from "react-native"; -import { useIsFocused } from "@react-navigation/native"; import invariant from "tiny-invariant"; import type { ListTerminalsResponse } from "@server/shared/messages"; import { TerminalPane } from "@/components/terminal-pane"; -import { usePaneContext } from "@/panels/pane-context"; +import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks"; @@ -57,15 +58,28 @@ function useTerminalPanelDescriptor( } function TerminalPanel() { - const isFocused = useIsFocused(); - const { serverId, workspaceId, target, isPaneFocused } = usePaneContext(); + const { serverId, workspaceId, target } = usePaneContext(); + const { isWorkspaceFocused, isPaneFocused } = usePaneFocus(); const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!; const workspaceDirectory = workspaceAuthority.ok ? workspaceAuthority.authority.workspaceDirectory : null; + const isGitCheckout = workspaceAuthority.ok + ? workspaceAuthority.authority.workspace.projectKind === "git" + : false; + const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); + const handleOpenFileExplorer = useCallback(() => { + if (!workspaceDirectory) { + return; + } + openFileExplorerForCheckout({ + isCompact: true, + checkout: { serverId, cwd: workspaceDirectory, isGit: isGitCheckout }, + }); + }, [isGitCheckout, openFileExplorerForCheckout, serverId, workspaceDirectory]); invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); - if (!isFocused) { + if (!isWorkspaceFocused) { return ; } @@ -86,7 +100,9 @@ function TerminalPanel() { serverId={serverId} cwd={workspaceDirectory} terminalId={target.terminalId} + isWorkspaceFocused={isWorkspaceFocused} isPaneFocused={isPaneFocused} + onOpenFileExplorer={handleOpenFileExplorer} /> ); } diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 39271189f..f78d64f23 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -36,11 +36,8 @@ import { useHostRuntimeIsConnected, } from "@/runtime/host-runtime"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; -import { usePanelStore } from "@/stores/panel-store"; -import { - setActiveExplorerCheckout, - type ExplorerCheckoutContext, -} from "@/stores/explorer-checkout-context"; +import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; +import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { WelcomeScreen } from "@/components/welcome-screen"; import type { Agent } from "@/contexts/session-context"; @@ -55,6 +52,8 @@ import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execut import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; @@ -239,18 +238,22 @@ function DraftAgentScreenContent({ statusControls, } = composerState; const isMobile = useIsCompactFormFactor(); - const mobileView = usePanelStore((state) => state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const showMobileFileExplorer = usePanelStore((state) => state.showMobileFileExplorer); - const toggleMobileFileExplorer = usePanelStore((state) => state.toggleMobileFileExplorer); + const isExplorerOpen = usePanelStore((state) => + selectIsFileExplorerOpen(state, { isCompact: isMobile }), + ); + const canOpenExplorerFromAgentView = usePanelStore( + (state) => + state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }), + ); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const openDesktopFileExplorer = usePanelStore((state) => state.openDesktopFileExplorer); - const toggleDesktopFileExplorer = usePanelStore((state) => state.toggleDesktopFileExplorer); const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); + const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); + const toggleFileExplorerForCheckout = usePanelStore( + (state) => state.toggleFileExplorerForCheckout, + ); const activateExplorerTabForCheckout = usePanelStore( (state) => state.activateExplorerTabForCheckout, ); - const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">( initialWorktreeMode, @@ -651,44 +654,42 @@ function DraftAgentScreenContent({ if (!draftExplorerCheckout) { return; } - activateExplorerTabForCheckout(draftExplorerCheckout); - if (isMobile) { - showMobileFileExplorer(); - return; - } - openDesktopFileExplorer(); - }, [ - activateExplorerTabForCheckout, - draftExplorerCheckout, - isMobile, - openDesktopFileExplorer, - showMobileFileExplorer, - ]); + openFileExplorerForCheckout({ + isCompact: isMobile, + checkout: draftExplorerCheckout, + }); + }, [draftExplorerCheckout, isMobile, openFileExplorerForCheckout]); const handleToggleExplorer = useCallback(() => { if (!canOpenExplorer) { return; } - if (isExplorerOpen) { - if (isMobile) { - toggleMobileFileExplorer(); - return; - } - toggleDesktopFileExplorer(); - return; - } - openExplorerForDraftCheckout(); - }, [ - canOpenExplorer, - isExplorerOpen, - isMobile, - openExplorerForDraftCheckout, - toggleDesktopFileExplorer, - toggleMobileFileExplorer, - ]); + toggleFileExplorerForCheckout({ + isCompact: isMobile, + checkout: draftExplorerCheckout, + }); + }, [canOpenExplorer, draftExplorerCheckout, isMobile, toggleFileExplorerForCheckout]); const explorerOpenGesture = useExplorerOpenGesture({ - enabled: isMobile && mobileView === "agent" && canOpenExplorer, + enabled: isMobile && canOpenExplorerFromAgentView && canOpenExplorer, onOpen: openExplorerForDraftCheckout, }); + const handleDraftSidebarAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (action.id !== "sidebar.toggle.right") { + return false; + } + handleToggleExplorer(); + return true; + }, + [handleToggleExplorer], + ); + useKeyboardActionHandler({ + handlerId: `draft-agent-sidebar-actions:${draftIdRef.current}`, + actions: ["sidebar.toggle.right"] as const, + enabled: Boolean(isFocused && canOpenExplorer), + priority: 100, + isActive: () => true, + handle: handleDraftSidebarAction, + }); const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0; const workingDirSearchError = directorySuggestionsQuery.error instanceof Error @@ -967,12 +968,6 @@ function DraftAgentScreenContent({ router.replace(route as any); }, }); - useEffect(() => { - if (!isFocused) { - return; - } - setActiveExplorerCheckout(draftExplorerCheckout); - }, [draftExplorerCheckout, isFocused, setActiveExplorerCheckout]); useEffect(() => { if (!isFocused || !draftExplorerCheckout) { return; @@ -996,12 +991,6 @@ function DraftAgentScreenContent({ isMobile, showMobileAgent, ]); - useEffect(() => { - return () => { - setActiveExplorerCheckout(null); - }; - }, [setActiveExplorerCheckout]); - if (daemons.length === 0) { return ( ({ + ensurePanelsRegistered: vi.fn(), +})); + +vi.mock("@/panels/panel-registry", () => ({ + getPanelRegistration: () => ({ + kind: "agent", + component: ProbePanel, + useDescriptor: vi.fn(), + }), +})); + +interface ProbeSnapshot { + paneContextValue: PaneContextValue; + focus: ReturnType; +} + +const snapshots: ProbeSnapshot[] = []; +const mountCount = vi.fn(); +const unmountCount = vi.fn(); + +function ProbePanel() { + const paneContextValue = usePaneContext(); + const focus = usePaneFocus(); + snapshots.push({ paneContextValue, focus }); + + useEffect(() => { + mountCount(); + return () => { + unmountCount(); + }; + }, []); + + return null; +} + +const tab: WorkspaceTabDescriptor = { + key: "agent_agent-a", + tabId: "agent_agent-a", + kind: "agent", + target: { kind: "agent", agentId: "agent-a" }, +}; + +function buildContent() { + return buildWorkspacePaneContentModel({ + tab, + normalizedServerId: "server-a", + normalizedWorkspaceId: "workspace-a", + onOpenTab: vi.fn(), + onCloseCurrentTab: vi.fn(), + onRetargetCurrentTab: vi.fn(), + onOpenWorkspaceFile: vi.fn(), + }); +} + +describe("WorkspacePaneContent", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + snapshots.length = 0; + mountCount.mockClear(); + unmountCount.mockClear(); + }); + + it("updates focus without remounting panel content or replacing pane identity", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const content = buildContent(); + + act(() => { + root?.render( + , + ); + }); + act(() => { + root?.render( + , + ); + }); + + expect(mountCount).toHaveBeenCalledTimes(1); + expect(unmountCount).not.toHaveBeenCalled(); + expect(snapshots).toHaveLength(2); + expect(snapshots[1]?.paneContextValue).toBe(snapshots[0]?.paneContextValue); + expect(snapshots[0]?.focus).toEqual({ + isWorkspaceFocused: true, + isPaneFocused: false, + isInteractive: false, + }); + expect(snapshots[1]?.focus).toEqual({ + isWorkspaceFocused: true, + isPaneFocused: true, + isInteractive: true, + }); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 230cfb061..91593054e 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -1,6 +1,11 @@ -import type { ComponentType } from "react"; +import React, { useMemo, type ComponentType } from "react"; import invariant from "tiny-invariant"; -import { PaneProvider, type PaneContextValue } from "@/panels/pane-context"; +import { + createPaneFocusContextValue, + PaneFocusProvider, + PaneProvider, + type PaneContextValue, +} from "@/panels/pane-context"; import { getPanelRegistration } from "@/panels/panel-registry"; import { ensurePanelsRegistered } from "@/panels/register-panels"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; @@ -15,7 +20,6 @@ export interface BuildWorkspacePaneContentModelInput { tab: WorkspaceTabDescriptor; normalizedServerId: string; normalizedWorkspaceId: string; - isPaneFocused: boolean; onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; onCloseCurrentTab: () => void; onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void; @@ -26,7 +30,6 @@ export function buildWorkspacePaneContentModel({ tab, normalizedServerId, normalizedWorkspaceId, - isPaneFocused, onOpenTab, onCloseCurrentTab, onRetargetCurrentTab, @@ -42,7 +45,6 @@ export function buildWorkspacePaneContentModel({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: tab.tabId, - isPaneFocused, target: tab.target, openTab: onOpenTab, closeCurrentTab: onCloseCurrentTab, @@ -54,14 +56,30 @@ export function buildWorkspacePaneContentModel({ export interface WorkspacePaneContentProps { content: WorkspacePaneContentModel; + isWorkspaceFocused: boolean; + isPaneFocused: boolean; } -export function WorkspacePaneContent({ content }: WorkspacePaneContentProps) { +export function WorkspacePaneContent({ + content, + isWorkspaceFocused, + isPaneFocused, +}: WorkspacePaneContentProps) { const { Component, key, paneContextValue } = content; + const paneFocusValue = useMemo( + () => + createPaneFocusContextValue({ + isWorkspaceFocused, + isPaneFocused, + }), + [isPaneFocused, isWorkspaceFocused], + ); return ( - + + + ); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 461709329..a5d62daa5 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -48,11 +48,8 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-bu import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { useToast } from "@/contexts/toast-context"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; -import { usePanelStore } from "@/stores/panel-store"; -import { - setActiveExplorerCheckout, - type ExplorerCheckoutContext, -} from "@/stores/explorer-checkout-context"; +import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; +import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { useSessionStore } from "@/stores/session-store"; import { buildWorkspaceTabPersistenceKey, @@ -513,11 +510,11 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ interface MobileMountedTabSlotProps { tabDescriptor: WorkspaceTabDescriptor; isVisible: boolean; + isWorkspaceFocused: boolean; isPaneFocused: boolean; paneId: string | null; buildPaneContentModel: (input: { paneId: string | null; - isPaneFocused: boolean; tab: WorkspaceTabDescriptor; }) => WorkspacePaneContentModel; } @@ -525,6 +522,7 @@ interface MobileMountedTabSlotProps { const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ tabDescriptor, isVisible, + isWorkspaceFocused, isPaneFocused, paneId, buildPaneContentModel, @@ -533,15 +531,18 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ () => buildPaneContentModel({ paneId, - isPaneFocused, tab: tabDescriptor, }), - [buildPaneContentModel, isPaneFocused, paneId, tabDescriptor], + [buildPaneContentModel, paneId, tabDescriptor], ); return ( - + ); }); @@ -847,19 +848,19 @@ function WorkspaceScreenContent({ ? trimNonEmpty(checkoutQuery.data.currentBranch) : null; - const mobileView = usePanelStore((state) => state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const showMobileFileExplorer = usePanelStore((state) => state.showMobileFileExplorer); - const toggleMobileFileExplorer = usePanelStore((state) => state.toggleMobileFileExplorer); - const openDesktopFileExplorer = usePanelStore((state) => state.openDesktopFileExplorer); - const toggleDesktopFileExplorer = usePanelStore((state) => state.toggleDesktopFileExplorer); - const activateExplorerTabForCheckout = usePanelStore( - (state) => state.activateExplorerTabForCheckout, + const isExplorerOpen = usePanelStore((state) => + selectIsFileExplorerOpen(state, { isCompact: isMobile }), + ); + const canOpenExplorerFromAgentView = usePanelStore( + (state) => + state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }), + ); + const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); + const toggleFileExplorerForCheckout = usePanelStore( + (state) => state.toggleFileExplorerForCheckout, ); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - const activeExplorerCheckout = useMemo(() => { if (!normalizedServerId || !workspaceDirectory) { return null; @@ -871,51 +872,28 @@ function WorkspaceScreenContent({ }; }, [isGitCheckout, normalizedServerId, workspaceDirectory]); - useEffect(() => { - if (!isRouteFocused) { - return; - } - setActiveExplorerCheckout(activeExplorerCheckout); - }, [activeExplorerCheckout, isRouteFocused, setActiveExplorerCheckout]); - const openExplorerForWorkspace = useCallback(() => { if (!activeExplorerCheckout) { return; } - activateExplorerTabForCheckout(activeExplorerCheckout); - if (isMobile) { - showMobileFileExplorer(); - return; - } - openDesktopFileExplorer(); - }, [ - activateExplorerTabForCheckout, - activeExplorerCheckout, - isMobile, - openDesktopFileExplorer, - showMobileFileExplorer, - ]); + openFileExplorerForCheckout({ + isCompact: isMobile, + checkout: activeExplorerCheckout, + }); + }, [activeExplorerCheckout, isMobile, openFileExplorerForCheckout]); const handleToggleExplorer = useCallback(() => { - if (isExplorerOpen) { - if (isMobile) { - toggleMobileFileExplorer(); - return; - } - toggleDesktopFileExplorer(); + if (!activeExplorerCheckout) { return; } - openExplorerForWorkspace(); - }, [ - isExplorerOpen, - isMobile, - openExplorerForWorkspace, - toggleDesktopFileExplorer, - toggleMobileFileExplorer, - ]); + toggleFileExplorerForCheckout({ + isCompact: isMobile, + checkout: activeExplorerCheckout, + }); + }, [activeExplorerCheckout, isMobile, toggleFileExplorerForCheckout]); const explorerOpenGesture = useExplorerOpenGesture({ - enabled: isMobile && mobileView === "agent", + enabled: isMobile && canOpenExplorerFromAgentView, onOpen: openExplorerForWorkspace, }); @@ -1717,6 +1695,17 @@ function WorkspaceScreenContent({ ], ); + const handleWorkspaceSidebarAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (action.id !== "sidebar.toggle.right") { + return false; + } + handleToggleExplorer(); + return true; + }, + [handleToggleExplorer], + ); + const handleWorkspacePaneAction = useCallback( (action: KeyboardActionDefinition): boolean => { if (!persistenceKey || !workspaceLayout) { @@ -1852,6 +1841,15 @@ function WorkspaceScreenContent({ handle: handleWorkspacePaneAction, }); + useKeyboardActionHandler({ + handlerId: `workspace-sidebar-actions:${normalizedServerId}:${normalizedWorkspaceId}`, + actions: ["sidebar.toggle.right"] as const, + enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId), + priority: 100, + isActive: () => true, + handle: handleWorkspaceSidebarAction, + }); + const activeTabDescriptor = activeTab?.descriptor ?? null; const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits; @@ -1865,14 +1863,12 @@ function WorkspaceScreenContent({ (input: { tab: WorkspaceTabDescriptor; paneId?: string | null; - isPaneFocused: boolean; focusPaneBeforeOpen?: boolean; }) => buildWorkspacePaneContentModel({ tab: input.tab, normalizedServerId, normalizedWorkspaceId, - isPaneFocused: isRouteFocused && input.isPaneFocused, onOpenTab: (target) => { if (!persistenceKey) { return; @@ -1908,7 +1904,6 @@ function WorkspaceScreenContent({ [ handleCloseTabById, handleOpenFileFromChat, - isRouteFocused, focusWorkspacePane, navigateToTabId, normalizedServerId, @@ -1935,12 +1930,10 @@ function WorkspaceScreenContent({ function buildMobilePaneContentModel(input: { paneId: string | null; tab: WorkspaceTabDescriptor; - isPaneFocused: boolean; }) { return buildPaneContentModel({ tab: input.tab, paneId: input.paneId, - isPaneFocused: input.isPaneFocused, focusPaneBeforeOpen: false, }); }, @@ -1983,7 +1976,8 @@ function WorkspaceScreenContent({ key={tabId} tabDescriptor={tabDescriptor} isVisible={isRouteFocused && tabId === activeTabDescriptor.tabId} - isPaneFocused={isRouteFocused && tabId === activeTabDescriptor.tabId} + isWorkspaceFocused={isRouteFocused} + isPaneFocused={tabId === activeTabDescriptor.tabId} paneId={focusedPaneId} buildPaneContentModel={buildMobilePaneContentModel} /> @@ -1992,15 +1986,10 @@ function WorkspaceScreenContent({ ); const buildDesktopPaneContentModel = useCallback( - function buildDesktopPaneContentModel(input: { - paneId: string; - tab: WorkspaceTabDescriptor; - isPaneFocused: boolean; - }) { + function buildDesktopPaneContentModel(input: { paneId: string; tab: WorkspaceTabDescriptor }) { return buildPaneContentModel({ tab: input.tab, paneId: input.paneId, - isPaneFocused: input.isPaneFocused, focusPaneBeforeOpen: true, }); }, @@ -2404,6 +2393,7 @@ function WorkspaceScreenContent({ workspaceKey={persistenceKey} normalizedServerId={normalizedServerId} normalizedWorkspaceId={normalizedWorkspaceId} + isWorkspaceFocused={isRouteFocused} uiTabs={uiTabs} hoveredCloseTabKey={hoveredCloseTabKey} setHoveredTabKey={setHoveredTabKey} diff --git a/packages/app/src/stores/explorer-checkout-context.ts b/packages/app/src/stores/explorer-checkout-context.ts index 392142398..926c02d4e 100644 --- a/packages/app/src/stores/explorer-checkout-context.ts +++ b/packages/app/src/stores/explorer-checkout-context.ts @@ -3,21 +3,3 @@ export interface ExplorerCheckoutContext { cwd: string; isGit: boolean; } - -let activeExplorerCheckout: ExplorerCheckoutContext | null = null; - -export function getActiveExplorerCheckout(): ExplorerCheckoutContext | null { - return activeExplorerCheckout; -} - -export function setActiveExplorerCheckout(checkout: ExplorerCheckoutContext | null) { - if ( - activeExplorerCheckout?.serverId === checkout?.serverId && - activeExplorerCheckout?.cwd === checkout?.cwd && - activeExplorerCheckout?.isGit === checkout?.isGit - ) { - return; - } - - activeExplorerCheckout = checkout; -} diff --git a/packages/app/src/stores/navigation-active-workspace-store.test.ts b/packages/app/src/stores/navigation-active-workspace-store.test.ts new file mode 100644 index 000000000..d007c7c09 --- /dev/null +++ b/packages/app/src/stores/navigation-active-workspace-store.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/constants/platform", () => ({ + isWeb: true, +})); + +function installWindowStub(pathname: string) { + const windowStub = { + location: { + href: "", + origin: "http://localhost", + pathname: "", + search: "", + hash: "", + }, + history: { + pushState: vi.fn((_state: unknown, _title: string, url: string) => { + updateLocation(url); + }), + replaceState: vi.fn((_state: unknown, _title: string, url: string) => { + updateLocation(url); + }), + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + + function updateLocation(url: string) { + const next = new URL(url, windowStub.location.origin); + windowStub.location = { + href: next.href, + origin: next.origin, + pathname: next.pathname, + search: next.search, + hash: next.hash, + }; + } + + updateLocation(pathname); + vi.stubGlobal("window", windowStub); + return windowStub; +} + +function createNavigationRef(serverId: string, workspaceId: string) { + return { + current: { + getCurrentRoute: () => ({ + params: { serverId, workspaceId }, + }), + }, + }; +} + +function createNavigationPathRef(path: string) { + return { + current: { + getCurrentRoute: () => ({ + path, + }), + }, + }; +} + +function createNavigationPathWithParamsRef(path: string, serverId: string, workspaceId: string) { + return { + current: { + getCurrentRoute: () => ({ + path, + params: { serverId, workspaceId }, + }), + }, + }; +} + +describe("navigation active workspace store", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps explicit web workspace activation ahead of a stale navigation route sync", async () => { + installWindowStub("/h/server-1/workspace/workspace-a"); + const store = await import("@/stores/navigation-active-workspace-store"); + + store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a")); + expect(store.getNavigationActiveWorkspaceSelection()).toEqual({ + serverId: "server-1", + workspaceId: "workspace-a", + }); + + store.activateNavigationWorkspaceSelection( + { serverId: "server-1", workspaceId: "workspace-b" }, + { updateBrowserHistory: true, historyMode: "push" }, + ); + store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a")); + + expect(store.getNavigationActiveWorkspaceSelection()).toEqual({ + serverId: "server-1", + workspaceId: "workspace-b", + }); + }); + + it("clears a stale browser workspace when navigation sync reports a non-workspace route", async () => { + installWindowStub("/h/server-1/workspace/workspace-a"); + const store = await import("@/stores/navigation-active-workspace-store"); + + store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a")); + expect(store.getNavigationActiveWorkspaceSelection()).toEqual({ + serverId: "server-1", + workspaceId: "workspace-a", + }); + + store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions")); + + expect(store.getNavigationActiveWorkspaceSelection()).toBeNull(); + }); + + it("clears stale workspace params when navigation sync reports a non-workspace path", async () => { + installWindowStub("/h/server-1/workspace/workspace-a"); + const store = await import("@/stores/navigation-active-workspace-store"); + + store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a")); + expect(store.getNavigationActiveWorkspaceSelection()).toEqual({ + serverId: "server-1", + workspaceId: "workspace-a", + }); + + store.syncNavigationActiveWorkspace( + createNavigationPathWithParamsRef("/h/server-1/sessions", "server-1", "workspace-a"), + ); + + expect(store.getNavigationActiveWorkspaceSelection()).toBeNull(); + }); +}); diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index daf07282d..0def2873f 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -1,4 +1,5 @@ -import { useSyncExternalStore } from "react"; +import { useMemo, useSyncExternalStore } from "react"; +import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"; import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment, @@ -16,15 +17,24 @@ interface ActivateWorkspaceSelectionOptions { historyMode?: "push" | "replace"; } -type NavigationRouteLike = { - name?: unknown; - params?: unknown; - path?: unknown; +type NavigationRouteParams = { + serverId?: string | string[]; + workspaceId?: string | string[]; }; +type NavigationRouteLike = { + params?: NavigationRouteParams | null; + path?: string | null; +}; + +type NavigationWorkspaceRouteState = + | { kind: "workspace"; selection: ActiveWorkspaceSelection } + | { kind: "nonWorkspace" } + | { kind: "unknown" }; + interface NavigationObserverRef { current: { - getCurrentRoute(): unknown; + getCurrentRoute(): NavigationRouteLike | null | undefined; } | null; } @@ -83,7 +93,7 @@ function writeBrowserWorkspaceUrl( } function extractActiveWorkspaceFromRoute( - route: NavigationRouteLike | undefined, + route: NavigationRouteLike | null | undefined, ): ActiveWorkspaceSelection | null { if (!route) { return null; @@ -96,17 +106,12 @@ function extractActiveWorkspaceFromRoute( } } - const params = - route.params && typeof route.params === "object" - ? (route.params as { - serverId?: string | string[]; - workspaceId?: string | string[]; - }) - : null; - const serverValue = Array.isArray(params?.serverId) ? params?.serverId[0] : params?.serverId; - const workspaceValue = Array.isArray(params?.workspaceId) - ? params?.workspaceId[0] - : params?.workspaceId; + const serverValue = Array.isArray(route.params?.serverId) + ? route.params.serverId[0] + : route.params?.serverId; + const workspaceValue = Array.isArray(route.params?.workspaceId) + ? route.params.workspaceId[0] + : route.params?.workspaceId; const serverId = typeof serverValue === "string" ? serverValue.trim() : ""; const workspaceId = typeof workspaceValue === "string" @@ -120,20 +125,55 @@ function extractActiveWorkspaceFromRoute( return { serverId, workspaceId }; } +function classifyNavigationWorkspaceRoute( + route: NavigationRouteLike | null | undefined, +): NavigationWorkspaceRouteState { + if (!route) { + return { kind: "unknown" }; + } + + if (typeof route.path === "string") { + const selection = parseHostWorkspaceRouteFromPathname(route.path); + if (selection) { + return { kind: "workspace", selection }; + } + return { kind: "nonWorkspace" }; + } + + const selection = extractActiveWorkspaceFromRoute(route); + if (selection) { + return { kind: "workspace", selection }; + } + + return { kind: "unknown" }; +} + +function getActiveWorkspaceForNavigationSync( + route: NavigationRouteLike | null | undefined, +): ActiveWorkspaceSelection | null { + const routeState = classifyNavigationWorkspaceRoute(route); + if (routeState.kind === "workspace") { + const browserWorkspace = getBrowserLocationWorkspace(); + return browserWorkspace ?? routeState.selection; + } + + if (routeState.kind === "nonWorkspace") { + return null; + } + + return getBrowserLocationWorkspace(); +} + export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverRef) { - emitIfChanged( - extractActiveWorkspaceFromRoute( - navigationRef.current?.getCurrentRoute() as NavigationRouteLike | undefined, - ), - ); + emitIfChanged(getActiveWorkspaceForNavigationSync(navigationRef.current?.getCurrentRoute())); } export function activateNavigationWorkspaceSelection( next: ActiveWorkspaceSelection, options: ActivateWorkspaceSelectionOptions = {}, ) { - emitIfChanged(next); writeBrowserWorkspaceUrl(next, options); + emitIfChanged(next); } export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { @@ -161,3 +201,45 @@ export function addBrowserActiveWorkspaceLocationListener(): () => void { export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } + +export function useIsNavigationWorkspaceSelected(input: { + serverId: string | null; + workspaceId: string | null; + enabled?: boolean; +}): boolean { + const enabled = input.enabled !== false; + return useSyncExternalStoreWithSelector( + subscribe, + getSnapshot, + getSnapshot, + (selection) => + enabled && + Boolean(input.serverId) && + Boolean(input.workspaceId) && + selection?.serverId === input.serverId && + selection.workspaceId === input.workspaceId, + Object.is, + ); +} + +export function useIsNavigationProjectActive(input: { + serverId: string | null; + workspaceIds: readonly string[]; + enabled?: boolean; +}): boolean { + const enabled = input.enabled !== false; + const workspaceIdsKey = input.workspaceIds.join("\0"); + const workspaceIdSet = useMemo(() => new Set(input.workspaceIds), [workspaceIdsKey]); + + return useSyncExternalStoreWithSelector( + subscribe, + getSnapshot, + getSnapshot, + (selection) => + enabled && + Boolean(input.serverId) && + selection?.serverId === input.serverId && + workspaceIdSet.has(selection.workspaceId), + Object.is, + ); +} diff --git a/packages/app/src/stores/panel-store.test.ts b/packages/app/src/stores/panel-store.test.ts index 8656f8abd..ae0010211 100644 --- a/packages/app/src/stores/panel-store.test.ts +++ b/packages/app/src/stores/panel-store.test.ts @@ -1,8 +1,64 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + import { buildExplorerCheckoutKey, resolveExplorerTabForCheckout, } from "@/stores/explorer-tab-memory"; +import { + selectIsAgentListOpen, + selectIsFileExplorerOpen, + selectPanelVisibility, + usePanelStore, + type PanelState, +} from "@/stores/panel-store"; + +function resetPanelStore() { + usePanelStore.setState({ + mobileView: "agent", + desktop: { + agentListOpen: false, + fileExplorerOpen: false, + focusModeEnabled: false, + }, + explorerTab: "changes", + explorerTabByCheckout: {}, + }); +} + +function createPanelState(input: { + mobileView: PanelState["mobileView"]; + agentListOpen: boolean; + fileExplorerOpen: boolean; +}): PanelState { + return { + ...usePanelStore.getState(), + mobileView: input.mobileView, + desktop: { + ...usePanelStore.getState().desktop, + agentListOpen: input.agentListOpen, + fileExplorerOpen: input.fileExplorerOpen, + }, + }; +} + +beforeEach(() => { + resetPanelStore(); +}); describe("panel-store explorer tab resolution", () => { const serverId = "server-1"; @@ -72,3 +128,120 @@ describe("panel-store explorer tab resolution", () => { ).toBe("files"); }); }); + +describe("panel-store visibility selectors", () => { + it("uses mobileView for compact layout visibility", () => { + const state = createPanelState({ + mobileView: "file-explorer", + agentListOpen: true, + fileExplorerOpen: false, + }); + + expect(selectPanelVisibility(state, { isCompact: true })).toEqual({ + isAgentListOpen: false, + isFileExplorerOpen: true, + }); + expect(selectIsAgentListOpen(state, { isCompact: true })).toBe(false); + expect(selectIsFileExplorerOpen(state, { isCompact: true })).toBe(true); + }); + + it("uses desktop flags for expanded layout visibility", () => { + const state = createPanelState({ + mobileView: "file-explorer", + agentListOpen: true, + fileExplorerOpen: false, + }); + + expect(selectPanelVisibility(state, { isCompact: false })).toEqual({ + isAgentListOpen: true, + isFileExplorerOpen: false, + }); + expect(selectIsAgentListOpen(state, { isCompact: false })).toBe(true); + expect(selectIsFileExplorerOpen(state, { isCompact: false })).toBe(false); + }); +}); + +describe("panel-store checkout-intent file explorer actions", () => { + it("opens the compact explorer and resolves the tab from the explicit checkout", () => { + const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; + const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; + usePanelStore.setState({ + explorerTab: "changes", + explorerTabByCheckout: { [key]: "files" }, + }); + + usePanelStore.getState().openFileExplorerForCheckout({ + isCompact: true, + checkout, + }); + + expect(usePanelStore.getState().mobileView).toBe("file-explorer"); + expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(false); + expect(usePanelStore.getState().explorerTab).toBe("files"); + }); + + it("opens the expanded explorer and resolves the tab from the explicit checkout", () => { + const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true }; + const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; + usePanelStore.setState({ + explorerTab: "changes", + explorerTabByCheckout: { [key]: "files" }, + }); + + usePanelStore.getState().openFileExplorerForCheckout({ + isCompact: false, + checkout, + }); + + expect(usePanelStore.getState().mobileView).toBe("agent"); + expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(true); + expect(usePanelStore.getState().explorerTab).toBe("files"); + }); + + it("toggles the explorer closed without changing the active tab", () => { + usePanelStore.setState({ + desktop: { + agentListOpen: false, + fileExplorerOpen: true, + focusModeEnabled: false, + }, + explorerTab: "files", + }); + + usePanelStore.getState().toggleFileExplorerForCheckout({ + isCompact: false, + checkout: { serverId: "server-1", cwd: "/tmp/repo", isGit: true }, + }); + + expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(false); + expect(usePanelStore.getState().explorerTab).toBe("files"); + }); + + it("coerces changes to files for a non-git checkout", () => { + const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: false }; + const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!; + usePanelStore.setState({ + explorerTab: "changes", + explorerTabByCheckout: { [key]: "changes" }, + }); + + usePanelStore.getState().openFileExplorerForCheckout({ + isCompact: false, + checkout, + }); + + expect(usePanelStore.getState().explorerTab).toBe("files"); + }); + + it("opens with the default files tab for an explicit non-git checkout with no stored tab", () => { + usePanelStore.setState({ explorerTab: "changes", explorerTabByCheckout: {} }); + + usePanelStore.getState().openFileExplorerForCheckout({ + isCompact: false, + checkout: { serverId: "server-1", cwd: "/tmp/non-git", isGit: false }, + }); + + expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(true); + expect(usePanelStore.getState().explorerTab).toBe("files"); + }); +}); diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index efc6a3253..34b605392 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -8,10 +8,7 @@ import { resolveExplorerTabForCheckout, type ExplorerTab, } from "./explorer-tab-memory"; -import { - getActiveExplorerCheckout, - type ExplorerCheckoutContext, -} from "./explorer-checkout-context"; +import { type ExplorerCheckoutContext } from "./explorer-checkout-context"; import { isWeb } from "@/constants/platform"; export type { ExplorerTab } from "./explorer-tab-memory"; export type { ExplorerCheckoutContext } from "./explorer-checkout-context"; @@ -27,7 +24,7 @@ export type { ExplorerCheckoutContext } from "./explorer-checkout-context"; * This makes impossible states unrepresentable - you cannot have both * sidebars open at the same time on mobile. */ -type MobilePanelView = "agent" | "agent-list" | "file-explorer"; +export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; /** * Desktop sidebar state. @@ -55,7 +52,20 @@ export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38; export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2; export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8; -interface PanelState { +export interface PanelVisibilityState { + isAgentListOpen: boolean; + isFileExplorerOpen: boolean; +} + +export interface PanelLayoutInput { + isCompact: boolean; +} + +export interface ExplorerPanelIntent extends PanelLayoutInput { + checkout: ExplorerCheckoutContext; +} + +export interface PanelState { // Mobile: which panel is currently shown mobileView: MobilePanelView; @@ -76,16 +86,16 @@ interface PanelState { toggleFocusMode: () => void; showMobileAgent: () => void; showMobileAgentList: () => void; - showMobileFileExplorer: () => void; toggleMobileAgentList: () => void; - toggleMobileFileExplorer: () => void; openDesktopAgentList: () => void; closeDesktopAgentList: () => void; toggleDesktopAgentList: () => void; - openDesktopFileExplorer: () => void; closeDesktopFileExplorer: () => void; - toggleDesktopFileExplorer: () => void; - toggleDesktopSidebars: () => void; + openAgentListForLayout: (input: PanelLayoutInput) => void; + closeAgentListForLayout: (input: PanelLayoutInput) => void; + toggleAgentListForLayout: (input: PanelLayoutInput) => void; + openFileExplorerForCheckout: (input: ExplorerPanelIntent) => void; + toggleFileExplorerForCheckout: (input: ExplorerPanelIntent) => void; // File explorer settings actions setExplorerTab: (tab: ExplorerTab) => void; @@ -118,13 +128,7 @@ function clampExplorerFilesSplitRatio(ratio: number): number { return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO); } -function resolveExplorerTabFromCheckout( - state: PanelState, - checkout: ExplorerCheckoutContext | null, -): ExplorerTab | null { - if (!checkout) { - return null; - } +function resolveExplorerTabFromCheckout(state: PanelState, checkout: ExplorerCheckoutContext) { return resolveExplorerTabForCheckout({ serverId: checkout.serverId, cwd: checkout.cwd, @@ -133,6 +137,47 @@ function resolveExplorerTabFromCheckout( }); } +function buildOpenFileExplorerPatch( + state: PanelState, + input: ExplorerPanelIntent, +): Partial { + const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout); + if (input.isCompact) { + return { + mobileView: "file-explorer", + explorerTab: resolvedTab, + }; + } + return { + desktop: { ...state.desktop, fileExplorerOpen: true }, + explorerTab: resolvedTab, + }; +} + +export function selectPanelVisibility( + state: PanelState, + input: PanelLayoutInput, +): PanelVisibilityState { + if (input.isCompact) { + return { + isAgentListOpen: state.mobileView === "agent-list", + isFileExplorerOpen: state.mobileView === "file-explorer", + }; + } + return { + isAgentListOpen: state.desktop.agentListOpen, + isFileExplorerOpen: state.desktop.fileExplorerOpen, + }; +} + +export function selectIsAgentListOpen(state: PanelState, input: PanelLayoutInput): boolean { + return selectPanelVisibility(state, input).isAgentListOpen; +} + +export function selectIsFileExplorerOpen(state: PanelState, input: PanelLayoutInput): boolean { + return selectPanelVisibility(state, input).isFileExplorerOpen; +} + const DEFAULT_DESKTOP_OPEN = isWeb; export const usePanelStore = create()( @@ -179,32 +224,11 @@ export const usePanelStore = create()( return { mobileView: "agent-list" as const }; }), - showMobileFileExplorer: () => - set((state) => { - const resolvedTab = resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout()); - return { - mobileView: "file-explorer" as const, - ...(resolvedTab ? { explorerTab: resolvedTab } : {}), - }; - }), - toggleMobileAgentList: () => set((state) => ({ mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list", })), - toggleMobileFileExplorer: () => - set((state) => { - if (state.mobileView === "file-explorer") { - return { mobileView: "agent" as const }; - } - const resolvedTab = resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout()); - return { - mobileView: "file-explorer" as const, - ...(resolvedTab ? { explorerTab: resolvedTab } : {}), - }; - }), - openDesktopAgentList: () => set((state) => { if (state.desktop.agentListOpen) { @@ -226,15 +250,6 @@ export const usePanelStore = create()( desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen }, })), - openDesktopFileExplorer: () => - set((state) => { - const resolvedTab = resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout()); - return { - desktop: { ...state.desktop, fileExplorerOpen: true }, - ...(resolvedTab ? { explorerTab: resolvedTab } : {}), - }; - }), - closeDesktopFileExplorer: () => set((state) => { if (!state.desktop.fileExplorerOpen) { @@ -243,31 +258,51 @@ export const usePanelStore = create()( return { desktop: { ...state.desktop, fileExplorerOpen: false } }; }), - toggleDesktopFileExplorer: () => + openAgentListForLayout: ({ isCompact }) => set((state) => { - const willOpen = !state.desktop.fileExplorerOpen; - const resolvedTab = willOpen - ? resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout()) - : null; + if (isCompact) { + return state.mobileView === "agent-list" + ? state + : { mobileView: "agent-list" as const }; + } + return state.desktop.agentListOpen + ? state + : { desktop: { ...state.desktop, agentListOpen: true } }; + }), + + closeAgentListForLayout: ({ isCompact }) => + set((state) => { + if (isCompact) { + return state.mobileView === "agent" ? state : { mobileView: "agent" as const }; + } + return state.desktop.agentListOpen + ? { desktop: { ...state.desktop, agentListOpen: false } } + : state; + }), + + toggleAgentListForLayout: ({ isCompact }) => + set((state) => { + if (isCompact) { + return { mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list" }; + } return { - desktop: { ...state.desktop, fileExplorerOpen: willOpen }, - ...(resolvedTab ? { explorerTab: resolvedTab } : {}), + desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen }, }; }), - toggleDesktopSidebars: () => + openFileExplorerForCheckout: (input) => + set((state) => buildOpenFileExplorerPatch(state, input)), + + toggleFileExplorerForCheckout: (input) => set((state) => { - const anyOpen = state.desktop.agentListOpen || state.desktop.fileExplorerOpen; - if (anyOpen) { - return { - desktop: { ...state.desktop, agentListOpen: false, fileExplorerOpen: false }, - }; + const isOpen = selectIsFileExplorerOpen(state, input); + if (!isOpen) { + return buildOpenFileExplorerPatch(state, input); } - const resolvedTab = resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout()); - return { - desktop: { ...state.desktop, agentListOpen: true, fileExplorerOpen: true }, - ...(resolvedTab ? { explorerTab: resolvedTab } : {}), - }; + if (input.isCompact) { + return { mobileView: "agent" as const }; + } + return { desktop: { ...state.desktop, fileExplorerOpen: false } }; }), setExplorerTab: (tab) => set({ explorerTab: tab }), @@ -434,54 +469,48 @@ export const usePanelStore = create()( * @param isMobile - Whether the current breakpoint is mobile */ export function usePanelState(isMobile: boolean) { - const store = usePanelStore(); + const isAgentListOpen = usePanelStore((state) => + selectIsAgentListOpen(state, { isCompact: isMobile }), + ); + const isFileExplorerOpen = usePanelStore((state) => + selectIsFileExplorerOpen(state, { isCompact: isMobile }), + ); + const showMobileAgent = usePanelStore((state) => state.showMobileAgent); + const openAgentListForLayout = usePanelStore((state) => state.openAgentListForLayout); + const closeAgentListForLayout = usePanelStore((state) => state.closeAgentListForLayout); + const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); + const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); + const explorerTab = usePanelStore((state) => state.explorerTab); + const explorerTabByCheckout = usePanelStore((state) => state.explorerTabByCheckout); + const explorerWidth = usePanelStore((state) => state.explorerWidth); + const explorerSortOption = usePanelStore((state) => state.explorerSortOption); + const explorerFilesSplitRatio = usePanelStore((state) => state.explorerFilesSplitRatio); + const setExplorerTab = usePanelStore((state) => state.setExplorerTab); + const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); + const activateExplorerTabForCheckout = usePanelStore( + (state) => state.activateExplorerTabForCheckout, + ); + const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); + const setExplorerSortOption = usePanelStore((state) => state.setExplorerSortOption); + const setExplorerFilesSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio); - if (isMobile) { - return { - isAgentListOpen: store.mobileView === "agent-list", - isFileExplorerOpen: store.mobileView === "file-explorer", - openAgentList: store.showMobileAgentList, - openFileExplorer: store.showMobileFileExplorer, - closeAgentList: store.showMobileAgent, - closeFileExplorer: store.showMobileAgent, - toggleAgentList: store.toggleMobileAgentList, - toggleFileExplorer: store.toggleMobileFileExplorer, - // Explorer settings - explorerTab: store.explorerTab, - explorerTabByCheckout: store.explorerTabByCheckout, - explorerWidth: store.explorerWidth, - explorerSortOption: store.explorerSortOption, - explorerFilesSplitRatio: store.explorerFilesSplitRatio, - setExplorerTab: store.setExplorerTab, - setExplorerTabForCheckout: store.setExplorerTabForCheckout, - activateExplorerTabForCheckout: store.activateExplorerTabForCheckout, - setExplorerWidth: store.setExplorerWidth, - setExplorerSortOption: store.setExplorerSortOption, - setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio, - }; - } - - // Desktop: independent toggles return { - isAgentListOpen: store.desktop.agentListOpen, - isFileExplorerOpen: store.desktop.fileExplorerOpen, - openAgentList: store.openDesktopAgentList, - openFileExplorer: store.openDesktopFileExplorer, - closeAgentList: store.closeDesktopAgentList, - closeFileExplorer: store.closeDesktopFileExplorer, - toggleAgentList: store.toggleDesktopAgentList, - toggleFileExplorer: store.toggleDesktopFileExplorer, - // Explorer settings - explorerTab: store.explorerTab, - explorerTabByCheckout: store.explorerTabByCheckout, - explorerWidth: store.explorerWidth, - explorerSortOption: store.explorerSortOption, - explorerFilesSplitRatio: store.explorerFilesSplitRatio, - setExplorerTab: store.setExplorerTab, - setExplorerTabForCheckout: store.setExplorerTabForCheckout, - activateExplorerTabForCheckout: store.activateExplorerTabForCheckout, - setExplorerWidth: store.setExplorerWidth, - setExplorerSortOption: store.setExplorerSortOption, - setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio, + isAgentListOpen, + isFileExplorerOpen, + openAgentList: () => openAgentListForLayout({ isCompact: isMobile }), + closeAgentList: () => closeAgentListForLayout({ isCompact: isMobile }), + closeFileExplorer: isMobile ? showMobileAgent : closeDesktopFileExplorer, + toggleAgentList: () => toggleAgentListForLayout({ isCompact: isMobile }), + explorerTab, + explorerTabByCheckout, + explorerWidth, + explorerSortOption, + explorerFilesSplitRatio, + setExplorerTab, + setExplorerTabForCheckout, + activateExplorerTabForCheckout, + setExplorerWidth, + setExplorerSortOption, + setExplorerFilesSplitRatio, }; } diff --git a/packages/app/src/types/use-sync-external-store-with-selector.d.ts b/packages/app/src/types/use-sync-external-store-with-selector.d.ts new file mode 100644 index 000000000..cdf961a36 --- /dev/null +++ b/packages/app/src/types/use-sync-external-store-with-selector.d.ts @@ -0,0 +1,9 @@ +declare module "use-sync-external-store/shim/with-selector" { + export function useSyncExternalStoreWithSelector( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => Snapshot, + getServerSnapshot: () => Snapshot, + selector: (snapshot: Snapshot) => Selection, + isEqual?: (left: Selection, right: Selection) => boolean, + ): Selection; +} diff --git a/packages/app/src/utils/desktop-sidebar-toggle.test.ts b/packages/app/src/utils/desktop-sidebar-toggle.test.ts new file mode 100644 index 000000000..994100582 --- /dev/null +++ b/packages/app/src/utils/desktop-sidebar-toggle.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +import { toggleDesktopSidebarsWithCheckoutIntent } from "./desktop-sidebar-toggle"; + +describe("toggleDesktopSidebarsWithCheckoutIntent", () => { + it("closes both sidebars when either desktop sidebar is open", () => { + const openAgentList = vi.fn(); + const closeAgentList = vi.fn(); + const closeFileExplorer = vi.fn(); + const toggleFocusedFileExplorer = vi.fn(() => true); + + const handled = toggleDesktopSidebarsWithCheckoutIntent({ + isAgentListOpen: true, + isFileExplorerOpen: false, + openAgentList, + closeAgentList, + closeFileExplorer, + toggleFocusedFileExplorer, + }); + + expect(handled).toBe(true); + expect(closeAgentList).toHaveBeenCalledTimes(1); + expect(closeFileExplorer).toHaveBeenCalledTimes(1); + expect(openAgentList).not.toHaveBeenCalled(); + expect(toggleFocusedFileExplorer).not.toHaveBeenCalled(); + }); + + it("opens the right sidebar only through the focused checkout-aware handler", () => { + const openAgentList = vi.fn(); + const closeAgentList = vi.fn(); + const closeFileExplorer = vi.fn(); + const toggleFocusedFileExplorer = vi.fn(() => false); + + const handled = toggleDesktopSidebarsWithCheckoutIntent({ + isAgentListOpen: false, + isFileExplorerOpen: false, + openAgentList, + closeAgentList, + closeFileExplorer, + toggleFocusedFileExplorer, + }); + + expect(handled).toBe(true); + expect(openAgentList).toHaveBeenCalledTimes(1); + expect(toggleFocusedFileExplorer).toHaveBeenCalledTimes(1); + expect(closeAgentList).not.toHaveBeenCalled(); + expect(closeFileExplorer).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/utils/desktop-sidebar-toggle.ts b/packages/app/src/utils/desktop-sidebar-toggle.ts new file mode 100644 index 000000000..fe186b887 --- /dev/null +++ b/packages/app/src/utils/desktop-sidebar-toggle.ts @@ -0,0 +1,20 @@ +interface DesktopSidebarToggleInput { + isAgentListOpen: boolean; + isFileExplorerOpen: boolean; + openAgentList: () => void; + closeAgentList: () => void; + closeFileExplorer: () => void; + toggleFocusedFileExplorer: () => boolean; +} + +export function toggleDesktopSidebarsWithCheckoutIntent(input: DesktopSidebarToggleInput): boolean { + if (input.isAgentListOpen || input.isFileExplorerOpen) { + input.closeAgentList(); + input.closeFileExplorer(); + return true; + } + + input.openAgentList(); + input.toggleFocusedFileExplorer(); + return true; +} diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 492d1b52e..1f405af0f 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -55,13 +55,12 @@ describe("buildSidebarProjectRowModel", () => { expect(result).toEqual({ kind: "workspace_link", workspace: flattenedWorkspace, - selected: false, chevron: null, trailingAction: "none", }); }); - it("marks flattened non-git project rows as selected when their workspace is active", () => { + it("builds flattened non-git rows without route selection input", () => { const flattenedWorkspace = workspace({ serverId: "srv-2", workspaceId: "ws-non-git", @@ -73,17 +72,15 @@ describe("buildSidebarProjectRowModel", () => { workspaces: [flattenedWorkspace], }), collapsed: false, - serverId: "srv-2", - activeWorkspaceSelection: { - serverId: "srv-2", - workspaceId: "ws-non-git", - }, }); expect(result).toMatchObject({ kind: "workspace_link", - selected: true, + workspace: flattenedWorkspace, + chevron: null, + trailingAction: "none", }); + expect(result).not.toHaveProperty("selected"); }); it("keeps single-workspace git projects as sections with the new worktree action", () => { diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index bf0910c30..12764776d 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -6,7 +6,6 @@ import type { export interface SidebarProjectWorkspaceLinkRowModel { kind: "workspace_link"; workspace: SidebarWorkspaceEntry; - selected: boolean; chevron: null; trailingAction: "new_worktree" | "none"; } @@ -28,23 +27,15 @@ export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean export function buildSidebarProjectRowModel(input: { project: SidebarProjectEntry; collapsed: boolean; - serverId?: string | null; - activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null; }): SidebarProjectRowModel { const flattenedWorkspace = isSidebarProjectFlattened(input.project) ? (input.project.workspaces[0] ?? null) : null; - const selected = - flattenedWorkspace !== null && - Boolean(input.serverId) && - input.activeWorkspaceSelection?.serverId === input.serverId && - input.activeWorkspaceSelection?.workspaceId === flattenedWorkspace.workspaceId; if (flattenedWorkspace) { return { kind: "workspace_link", workspace: flattenedWorkspace, - selected, chevron: null, trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none", }; diff --git a/packages/app/src/utils/sidebar-workspace-archive-redirect.ts b/packages/app/src/utils/sidebar-workspace-archive-redirect.ts new file mode 100644 index 000000000..055067bce --- /dev/null +++ b/packages/app/src/utils/sidebar-workspace-archive-redirect.ts @@ -0,0 +1,26 @@ +import { router } from "expo-router"; +import { getNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { useSessionStore } from "@/stores/session-store"; +import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; + +export function redirectIfArchivingActiveWorkspace(input: { + serverId: string; + workspaceId: string; +}): boolean { + const activeWorkspaceSelection = getNavigationActiveWorkspaceSelection(); + if ( + activeWorkspaceSelection?.serverId !== input.serverId || + activeWorkspaceSelection.workspaceId !== input.workspaceId + ) { + return false; + } + + router.replace( + buildWorkspaceArchiveRedirectRoute({ + serverId: input.serverId, + archivedWorkspaceId: input.workspaceId, + workspaces: useSessionStore.getState().sessions[input.serverId]?.workspaces.values() ?? [], + }), + ); + return true; +} diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index 3e7f78fe0..4772308e3 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -1,9 +1,26 @@ -import { describe, expect, it } from "vitest"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildWorkspaceArchiveRedirectRoute, resolveWorkspaceArchiveRedirectWorkspaceId, } from "@/utils/workspace-archive-navigation"; import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; +import { + activateNavigationWorkspaceSelection, + syncNavigationActiveWorkspace, +} from "@/stores/navigation-active-workspace-store"; +import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; + +const { replaceMock } = vi.hoisted(() => ({ + replaceMock: vi.fn(), +})); + +vi.mock("expo-router", () => ({ + router: { + replace: replaceMock, + }, +})); function workspace( input: Partial & Pick, @@ -76,3 +93,53 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { ).toBe("/h/server-1"); }); }); + +describe("redirectIfArchivingActiveWorkspace", () => { + afterEach(() => { + replaceMock.mockClear(); + syncNavigationActiveWorkspace({ current: null }); + useSessionStore.getState().clearSession("server-1"); + }); + + it("does not replace the route when archiving an inactive workspace", () => { + useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces( + "server-1", + new Map([ + ["main", workspace({ id: "main", workspaceKind: "local_checkout" })], + ["feature", workspace({ id: "feature", name: "feature" })], + ]), + ); + activateNavigationWorkspaceSelection({ serverId: "server-1", workspaceId: "main" }); + + expect( + redirectIfArchivingActiveWorkspace({ + serverId: "server-1", + workspaceId: "feature", + }), + ).toBe(false); + + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("replaces the route at action time when archiving the active workspace", () => { + useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces( + "server-1", + new Map([ + ["main", workspace({ id: "main", workspaceKind: "local_checkout" })], + ["feature", workspace({ id: "feature", name: "feature" })], + ]), + ); + activateNavigationWorkspaceSelection({ serverId: "server-1", workspaceId: "feature" }); + + expect( + redirectIfArchivingActiveWorkspace({ + serverId: "server-1", + workspaceId: "feature", + }), + ).toBe(true); + + expect(replaceMock).toHaveBeenCalledWith("/h/server-1/workspace/main"); + }); +});