Improve workspace navigation and render isolation

This commit is contained in:
Mohamed Boudra
2026-04-20 16:14:22 +07:00
parent 403fb03c97
commit a6423f7f0a
46 changed files with 2801 additions and 862 deletions
+1
View File
@@ -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"
},
+1
View File
@@ -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"
},
+20 -5
View File
@@ -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,
@@ -125,8 +125,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
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<AgentStreamViewHandle, AgentStreamVi
setCurrentPath: false,
});
setExplorerTabForCheckout({
const checkout = {
serverId: resolvedServerId,
cwd: agent.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
tab: "files",
};
setExplorerTabForCheckout({ ...checkout, tab: "files" });
openFileExplorerForCheckout({
isCompact: isMobile,
checkout,
});
if (isMobile) {
showMobileFileExplorer();
return;
}
openDesktopFileExplorer();
},
[
agent.cwd,
isMobile,
openDesktopFileExplorer,
openFileExplorerForCheckout,
requestDirectoryListing,
resolvedServerId,
router,
setExplorerTabForCheckout,
showMobileFileExplorer,
openWorkspaceFile,
workspaceId,
],
+32 -1
View File
@@ -20,6 +20,7 @@ const {
deleteAttachmentsMock,
encodeImagesMock,
openExternalUrlMock,
markScrollInvestigationRenderMock,
mockSessionState,
setAgentStreamTailMock,
setAgentStreamHeadMock,
@@ -135,6 +136,7 @@ const {
);
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
const markScrollInvestigationRenderMock = vi.fn();
return {
theme,
@@ -147,6 +149,7 @@ const {
deleteAttachmentsMock: vi.fn(async () => {}),
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<HTMLElement> {
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",
+230 -174
View File
@@ -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<string, QueuedMessage[]>) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
return next;
});
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
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 ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button"
style={[
styles.cancelButton as any,
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
]}
>
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={buttonIconSize} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null,
[
autocomplete,
buttonIconSize,
dictationCancelKeys,
handleCancelAgent,
hasSendableContent,
isAgentRunning,
isCancellingAgent,
isConnected,
handleCancelAgent,
isProcessing,
],
);
const cancelButton =
isAgentRunning && !hasSendableContent && !isProcessing ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button"
style={[
styles.cancelButton as any,
(!isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
]}
>
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={buttonIconSize} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null;
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
const rightContent =
showVoiceModeButton || cancelButton ? (
<View style={styles.rightControls}>
{showVoiceModeButton ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
>
{({ hovered }) =>
voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null}
{cancelButton}
</View>
) : null;
const rightContent = useMemo(
() =>
showVoiceModeButton || cancelButton ? (
<View style={styles.rightControls}>
{showVoiceModeButton ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
>
{({ hovered }) =>
voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null}
{cancelButton}
</View>
) : 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 = (
<View style={styles.contextWindowMeterSlot}>
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
/>
) : null}
</View>
const beforeVoiceContent = useMemo(
() => (
<View style={styles.contextWindowMeterSlot}>
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
/>
) : null}
</View>
),
[contextWindowMaxTokens, contextWindowUsedTokens],
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
@@ -808,12 +852,33 @@ export function Composer({
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
);
const leftContent =
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
) : (
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
);
const leftContent = useMemo(
() =>
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
) : (
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
),
[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 (
<Animated.View
@@ -924,7 +989,7 @@ export function Composer({
) : null}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
@@ -938,9 +1003,7 @@ export function Composer({
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={(node) => {
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}
/>
@@ -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(() => {
@@ -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 (
<HeaderToggleButton
onPress={isMobile ? toggleMobileAgentList : toggleDesktopAgentList}
onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
+9 -5
View File
@@ -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<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
enabled: isOpen,
@@ -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<string, number>;
rows: Record<string, number>;
projectSelection: Record<string, number>;
rowSelection: Record<string, number>;
};
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<string, number>, 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) => (
<div key={project.projectKey}>
<ProjectHeaderProbe project={project} counts={counts} />
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
{project.workspaces.map((workspace) => (
<WorkspaceRowProbe
key={workspace.workspaceKey}
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
counts={counts}
/>
<React.Fragment key={workspace.workspaceKey}>
<WorkspaceRowProbe
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
counts={counts}
/>
<WorkspaceSelectionProbe
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
counts={counts}
/>
</React.Fragment>
))}
</div>
))}
@@ -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,
});
});
});
@@ -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<string, number>;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
@@ -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 (
<WorkspaceRow
workspace={item}
selected={isSelected}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={project.projectKind === "git"}
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
selectionEnabled={selectionEnabled}
onPress={() => {
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<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
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,
@@ -76,6 +76,7 @@ interface SplitContainerProps {
workspaceKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
isWorkspaceFocused: boolean;
uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
@@ -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 (
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
<WorkspacePaneContent content={content} />
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
});
@@ -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}
@@ -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 <div style={{ height: 24 }}>{item.id}</div>;
},
renderHistoryMountedRow: (item) => <div>{item.id}</div>,
renderLiveHeadRow: (item) => <div>{item.id}</div>,
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<StreamViewportHandle>();
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);
});
});
@@ -1,4 +1,4 @@
import {
import React, {
Fragment,
type CSSProperties,
useCallback,
@@ -126,6 +126,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
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
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
{renderHistoryVirtualizedRow(
+43 -39
View File
@@ -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 (
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
<View style={styles.outputContainer}>
{isScreenFocused ? (
{isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
ref={emulatorRef}
@@ -579,7 +583,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
if (!swipeGesturesEnabled) {
return;
}
showMobileFileExplorer();
onOpenFileExplorer();
}}
onInput={handleTerminalData}
onResize={handleTerminalResize}
@@ -594,7 +598,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
<View style={styles.terminalGestureContainer} />
)}
{isAttaching && isScreenFocused ? (
{isAttaching && isWorkspaceFocused ? (
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
@@ -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<ExplorerSidebarAnimationCo
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => 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 });
@@ -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<SidebarAnimationContextValue | nul
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => 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 });
@@ -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 }),
@@ -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,
]);
}
@@ -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);
});
});
@@ -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,
);
@@ -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",
});
});
});
@@ -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);
},
[],
),
};
}
@@ -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 };
@@ -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<number, number>;
borderRadius: Record<string, number>;
fontSize: Record<string, number>;
fontWeight: Record<string, string>;
iconSize: Record<string, number>;
};
type PanelTestStyles = Record<string, unknown>;
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 }) => <div>{children}</div>,
},
}));
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 }) => <div>{children}</div>,
}));
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 }) => (
<div data-testid="archived-agent-callout">{agentId}</div>
),
}));
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 <div data-testid="composer">{cwd}</div>;
},
}));
vi.mock("@/components/agent-stream-view", () => ({
AgentStreamView: React.memo(
React.forwardRef<
{ prepareForViewportChange: () => void; scrollToBottom: (reason: "message-sent") => void },
{ pendingPermissions: Map<string, PendingPermission>; 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 <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
}),
),
}));
function makeClient(): DaemonClient {
return new DaemonClient({
url: "ws://127.0.0.1:1",
clientId: "panel-render-isolation-test",
});
}
function makeAgent(overrides: Partial<Agent> = {}): 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(
<QueryClientProvider client={queryClient}>
<PaneProvider
value={{
serverId: "server",
workspaceId: "workspace",
tabId: "agent-agent",
target: { kind: "agent", agentId: "agent" },
openTab: vi.fn(),
closeCurrentTab: vi.fn(),
retargetCurrentTab: vi.fn(),
openFileInWorkspace: vi.fn(),
}}
>
<PaneFocusProvider
value={{ isWorkspaceFocused: true, isPaneFocused: false, isInteractive: false }}
>
<AgentPanel />
</PaneFocusProvider>
</PaneProvider>
</QueryClientProvider>,
);
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);
});
});
+338 -165
View File
@@ -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<string, PendingPermission>();
const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = [];
type RouteBottomAnchorRequest = ReturnType<typeof deriveRouteBottomAnchorRequest>;
type PendingCreateByDraftId = ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
type PendingCreateAttempt = PendingCreateByDraftId[string];
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): 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<string, PendingPermission>();
}
const filtered = new Map<string, PendingPermission>();
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<StreamItem[]>(() => {
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<StreamItem[]>(() => {
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({
<View style={styles.container}>
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
<AgentStreamView
ref={streamViewRef}
agentId={effectiveAgent.id}
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
pendingCreate={pendingCreate}
shouldUseOptimisticStream={shouldUseOptimisticStream}
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
<Composer
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
attachments={agentInputDraft.attachments}
onChangeAttachments={agentInputDraft.setAttachments}
cwd={agentInputDraft.cwd}
clearDraft={agentInputDraft.clear}
autoFocus={isPaneFocused}
isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={(height) => {
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 ? (
<ArchivedAgentCallout serverId={serverId} agentId={agentId} />
) : null}
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
initialCwd={agentState.cwd ?? ""}
isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
{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<AgentStreamViewHandle | null>;
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<StreamItem[]>(() => {
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<StreamItem[]>(() => {
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 (
<AgentStreamView
ref={streamViewRef}
agentId={agent.id}
serverId={serverId}
agent={agent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
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 <ArchivedAgentCallout serverId={serverId} agentId={agentId} />;
}
if (isArchivingCurrentAgent) {
return null;
}
return (
<ActiveAgentComposer
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
initialCwd={initialCwd}
isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
/>
);
}
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 (
<Composer
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
onChangeText={agentInputDraft.setText}
attachments={agentInputDraft.attachments}
onChangeAttachments={agentInputDraft.setAttachments}
cwd={agentInputDraft.cwd}
clearDraft={agentInputDraft.clear}
autoFocus={isPaneFocused}
isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
/>
);
}
function AgentSessionUnavailableState({
serverLabel,
connectionStatus,
+5 -11
View File
@@ -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);
}}
@@ -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,
});
});
});
+35 -2
View File
@@ -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<PaneContextValue | null>(null);
const PaneFocusContext = createContext<PaneFocusContextValue | null>(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 <PaneContext.Provider value={value}>{children}</PaneContext.Provider>;
}
export function PaneFocusProvider({
value,
children,
}: {
value: PaneFocusContextValue;
children: ReactNode;
}) {
return <PaneFocusContext.Provider value={value}>{children}</PaneFocusContext.Provider>;
}
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;
}
+21 -5
View File
@@ -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 <View style={{ flex: 1 }} />;
}
@@ -86,7 +100,9 @@ function TerminalPanel() {
serverId={serverId}
cwd={workspaceDirectory}
terminalId={target.terminalId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onOpenFileExplorer={handleOpenFileExplorer}
/>
);
}
@@ -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 (
<WelcomeScreen
@@ -0,0 +1,120 @@
/**
* @vitest-environment jsdom
*/
import { act } from "@testing-library/react";
import React, { useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspacePaneContentModel,
WorkspacePaneContent,
} from "@/screens/workspace/workspace-pane-content";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { usePaneContext, usePaneFocus, type PaneContextValue } from "@/panels/pane-context";
vi.mock("@/panels/register-panels", () => ({
ensurePanelsRegistered: vi.fn(),
}));
vi.mock("@/panels/panel-registry", () => ({
getPanelRegistration: () => ({
kind: "agent",
component: ProbePanel,
useDescriptor: vi.fn(),
}),
}));
interface ProbeSnapshot {
paneContextValue: PaneContextValue;
focus: ReturnType<typeof usePaneFocus>;
}
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(
<WorkspacePaneContent content={content} isPaneFocused={false} isWorkspaceFocused={true} />,
);
});
act(() => {
root?.render(
<WorkspacePaneContent content={content} isPaneFocused isWorkspaceFocused={true} />,
);
});
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,
});
});
});
@@ -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 (
<PaneProvider value={paneContextValue}>
<Component key={key} />
<PaneFocusProvider value={paneFocusValue}>
<Component key={key} />
</PaneFocusProvider>
</PaneProvider>
);
}
@@ -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 (
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
<WorkspacePaneContent content={content} />
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
});
@@ -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<ExplorerCheckoutContext | null>(() => {
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}
@@ -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;
}
@@ -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();
});
});
@@ -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,
);
}
+174 -1
View File
@@ -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<string, string>();
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");
});
});
+142 -113
View File
@@ -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<PanelState> {
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<PanelState>()(
@@ -179,32 +224,11 @@ export const usePanelStore = create<PanelState>()(
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<PanelState>()(
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<PanelState>()(
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<PanelState>()(
* @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,
};
}
@@ -0,0 +1,9 @@
declare module "use-sync-external-store/shim/with-selector" {
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot: () => Snapshot,
selector: (snapshot: Snapshot) => Selection,
isEqual?: (left: Selection, right: Selection) => boolean,
): Selection;
}
@@ -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();
});
});
@@ -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;
}
@@ -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", () => {
@@ -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",
};
@@ -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;
}
@@ -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<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
@@ -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");
});
});