Improve workspace navigation and render isolation
This commit is contained in:
Generated
+1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user