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-web": "~0.21.0",
|
||||||
"react-native-webview": "^13.16.0",
|
"react-native-webview": "^13.16.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
|
"use-sync-external-store": "^1.6.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-webview": "^13.16.0",
|
"react-native-webview": "^13.16.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
|
"use-sync-external-store": "^1.6.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"
|
|||||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||||
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
import {
|
import {
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
WEB_NOTIFICATION_CLICK_EVENT,
|
||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
@@ -392,10 +394,10 @@ function AppContainer({
|
|||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||||
const toggleMobileFileExplorer = usePanelStore((state) => state.toggleMobileFileExplorer);
|
|
||||||
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||||
const toggleDesktopFileExplorer = usePanelStore((state) => state.toggleDesktopFileExplorer);
|
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
|
||||||
const toggleDesktopSidebars = usePanelStore((state) => state.toggleDesktopSidebars);
|
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
|
||||||
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
@@ -411,7 +413,21 @@ function AppContainer({
|
|||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
|
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
|
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
|
||||||
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
||||||
// global concerns like keyboard shortcuts. Split those out so settings (and
|
// global concerns like keyboard shortcuts. Split those out so settings (and
|
||||||
@@ -451,7 +467,6 @@ function AppContainer({
|
|||||||
enabled: keyboardShortcutsEnabled,
|
enabled: keyboardShortcutsEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile: isCompactLayout,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
toggleFileExplorer,
|
|
||||||
toggleBothSidebars: toggleDesktopSidebars,
|
toggleBothSidebars: toggleDesktopSidebars,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
cycleTheme,
|
cycleTheme,
|
||||||
|
|||||||
@@ -125,8 +125,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const showMobileFileExplorer = usePanelStore((state) => state.showMobileFileExplorer);
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||||
const openDesktopFileExplorer = usePanelStore((state) => state.openDesktopFileExplorer);
|
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
|
|
||||||
// Get serverId (fallback to agent's serverId if not provided)
|
// Get serverId (fallback to agent's serverId if not provided)
|
||||||
@@ -200,27 +199,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setExplorerTabForCheckout({
|
const checkout = {
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||||
tab: "files",
|
};
|
||||||
|
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||||
|
openFileExplorerForCheckout({
|
||||||
|
isCompact: isMobile,
|
||||||
|
checkout,
|
||||||
});
|
});
|
||||||
if (isMobile) {
|
|
||||||
showMobileFileExplorer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openDesktopFileExplorer();
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
agent.cwd,
|
agent.cwd,
|
||||||
isMobile,
|
isMobile,
|
||||||
openDesktopFileExplorer,
|
openFileExplorerForCheckout,
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
resolvedServerId,
|
resolvedServerId,
|
||||||
router,
|
router,
|
||||||
setExplorerTabForCheckout,
|
setExplorerTabForCheckout,
|
||||||
showMobileFileExplorer,
|
|
||||||
openWorkspaceFile,
|
openWorkspaceFile,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const {
|
|||||||
deleteAttachmentsMock,
|
deleteAttachmentsMock,
|
||||||
encodeImagesMock,
|
encodeImagesMock,
|
||||||
openExternalUrlMock,
|
openExternalUrlMock,
|
||||||
|
markScrollInvestigationRenderMock,
|
||||||
mockSessionState,
|
mockSessionState,
|
||||||
setAgentStreamTailMock,
|
setAgentStreamTailMock,
|
||||||
setAgentStreamHeadMock,
|
setAgentStreamHeadMock,
|
||||||
@@ -135,6 +136,7 @@ const {
|
|||||||
);
|
);
|
||||||
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
|
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
|
||||||
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
|
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
|
||||||
|
const markScrollInvestigationRenderMock = vi.fn();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
@@ -147,6 +149,7 @@ const {
|
|||||||
deleteAttachmentsMock: vi.fn(async () => {}),
|
deleteAttachmentsMock: vi.fn(async () => {}),
|
||||||
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
|
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
|
||||||
openExternalUrlMock: vi.fn(async () => {}),
|
openExternalUrlMock: vi.fn(async () => {}),
|
||||||
|
markScrollInvestigationRenderMock,
|
||||||
mockSessionState,
|
mockSessionState,
|
||||||
setAgentStreamTailMock,
|
setAgentStreamTailMock,
|
||||||
setAgentStreamHeadMock,
|
setAgentStreamHeadMock,
|
||||||
@@ -313,7 +316,7 @@ vi.mock("@/contexts/toast-context", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/utils/scroll-jank-investigation", () => ({
|
vi.mock("@/utils/scroll-jank-investigation", () => ({
|
||||||
markScrollInvestigationRender: vi.fn(),
|
markScrollInvestigationRender: markScrollInvestigationRenderMock,
|
||||||
markScrollInvestigationEvent: vi.fn(),
|
markScrollInvestigationEvent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -547,6 +550,7 @@ beforeEach(() => {
|
|||||||
deleteAttachmentsMock.mockClear();
|
deleteAttachmentsMock.mockClear();
|
||||||
encodeImagesMock.mockClear();
|
encodeImagesMock.mockClear();
|
||||||
openExternalUrlMock.mockClear();
|
openExternalUrlMock.mockClear();
|
||||||
|
markScrollInvestigationRenderMock.mockClear();
|
||||||
setAgentStreamTailMock.mockClear();
|
setAgentStreamTailMock.mockClear();
|
||||||
setAgentStreamHeadMock.mockClear();
|
setAgentStreamHeadMock.mockClear();
|
||||||
setQueuedMessagesMock.mockClear();
|
setQueuedMessagesMock.mockClear();
|
||||||
@@ -663,6 +667,12 @@ function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
|
|||||||
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
|
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", () => {
|
describe("Composer attachments", () => {
|
||||||
it("opens a Plus menu with image and GitHub attachment actions", () => {
|
it("opens a Plus menu with image and GitHub attachment actions", () => {
|
||||||
renderComposer();
|
renderComposer();
|
||||||
@@ -847,6 +857,27 @@ describe("Composer attachments", () => {
|
|||||||
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
|
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", () => {
|
it("locks the preserved draft while submit loading", () => {
|
||||||
renderComposer({
|
renderComposer({
|
||||||
initialText: "keep this prompt",
|
initialText: "keep this prompt",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
|
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
@@ -134,6 +134,7 @@ interface ComposerProps {
|
|||||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||||
|
const StableMessageInput = memo(MessageInput);
|
||||||
|
|
||||||
export function Composer({
|
export function Composer({
|
||||||
agentId,
|
agentId,
|
||||||
@@ -172,6 +173,8 @@ export function Composer({
|
|||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const toastErrorRef = useRef(toast.error);
|
||||||
|
toastErrorRef.current = toast.error;
|
||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||||
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
||||||
@@ -238,6 +241,8 @@ export function Composer({
|
|||||||
messageInputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress);
|
||||||
|
autocompleteOnKeyPressRef.current = autocomplete.onKeyPress;
|
||||||
|
|
||||||
// Clear send error when user edits the input
|
// Clear send error when user edits the input
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -375,67 +380,82 @@ export function Composer({
|
|||||||
[agentId, serverId, setQueuedMessages],
|
[agentId, serverId, setQueuedMessages],
|
||||||
);
|
);
|
||||||
|
|
||||||
function queueMessage(message: string, attachments: ComposerAttachment[]) {
|
const queueMessage = useCallback(
|
||||||
const trimmedMessage = message.trim();
|
(message: string, attachments: ComposerAttachment[]) => {
|
||||||
if (!trimmedMessage && attachments.length === 0) return;
|
const trimmedMessage = message.trim();
|
||||||
|
if (!trimmedMessage && attachments.length === 0) return;
|
||||||
|
|
||||||
const newItem = {
|
const newItem = {
|
||||||
id: generateMessageId(),
|
id: generateMessageId(),
|
||||||
text: trimmedMessage,
|
text: trimmedMessage,
|
||||||
attachments,
|
attachments,
|
||||||
};
|
};
|
||||||
|
|
||||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
setSelectedAttachments([]);
|
setSelectedAttachments([]);
|
||||||
}
|
},
|
||||||
|
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
|
||||||
|
);
|
||||||
|
|
||||||
async function sendMessageWithContent(
|
const sendMessageWithContent = useCallback(
|
||||||
message: string,
|
async (message: string, attachments: ComposerAttachment[], forceSend?: boolean) => {
|
||||||
attachments: ComposerAttachment[],
|
await submitAgentInput({
|
||||||
forceSend?: boolean,
|
message,
|
||||||
) {
|
attachments,
|
||||||
await submitAgentInput({
|
hasExternalContent,
|
||||||
message,
|
allowEmptySubmit,
|
||||||
attachments,
|
forceSend,
|
||||||
hasExternalContent,
|
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,
|
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,
|
clearDraft,
|
||||||
|
hasExternalContent,
|
||||||
|
isAgentRunning,
|
||||||
|
queueMessage,
|
||||||
|
setSelectedAttachments,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
setAttachments: (nextAttachments) => {
|
submitBehavior,
|
||||||
setSelectedAttachments(nextAttachments);
|
submitMessage,
|
||||||
},
|
],
|
||||||
setSendError,
|
);
|
||||||
setIsProcessing,
|
|
||||||
onSubmitError: (error) => {
|
|
||||||
console.error("[AgentInput] Failed to send message:", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(payload: MessagePayload) {
|
const handleSubmit = useCallback(
|
||||||
if (blurOnSubmit) {
|
(payload: MessagePayload) => {
|
||||||
messageInputRef.current?.blur();
|
if (blurOnSubmit) {
|
||||||
}
|
messageInputRef.current?.blur();
|
||||||
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
|
}
|
||||||
}
|
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
|
||||||
|
},
|
||||||
|
[blurOnSubmit, sendMessageWithContent],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePickImage = useCallback(async () => {
|
const handlePickImage = useCallback(async () => {
|
||||||
const result = await pickImages();
|
const result = await pickImages();
|
||||||
@@ -487,6 +507,18 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}, [isAgentRunning, isConnected]);
|
}, [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(
|
const handleKeyboardAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
if (!isPaneFocused) {
|
if (!isPaneFocused) {
|
||||||
@@ -553,18 +585,6 @@ export function Composer({
|
|||||||
mode: "translate",
|
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 isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
||||||
|
|
||||||
const handleToggleRealtimeVoice = useCallback(() => {
|
const handleToggleRealtimeVoice = useCallback(() => {
|
||||||
@@ -582,10 +602,10 @@ export function Composer({
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||||
if (message && message.trim().length > 0) {
|
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) {
|
function handleEditQueuedMessage(id: string) {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
const item = queuedMessages.find((q) => q.id === id);
|
||||||
@@ -612,9 +632,12 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleQueue = useCallback((payload: MessagePayload) => {
|
const handleQueue = useCallback(
|
||||||
queueMessage(payload.text, payload.attachments);
|
(payload: MessagePayload) => {
|
||||||
}, []);
|
queueMessage(payload.text, payload.attachments);
|
||||||
|
},
|
||||||
|
[queueMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||||
|
|
||||||
@@ -633,91 +656,109 @@ export function Composer({
|
|||||||
return true;
|
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,
|
hasSendableContent,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
isCancellingAgent,
|
isCancellingAgent,
|
||||||
isConnected,
|
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 showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
|
||||||
const rightContent =
|
const rightContent = useMemo(
|
||||||
showVoiceModeButton || cancelButton ? (
|
() =>
|
||||||
<View style={styles.rightControls}>
|
showVoiceModeButton || cancelButton ? (
|
||||||
{showVoiceModeButton ? (
|
<View style={styles.rightControls}>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
{showVoiceModeButton ? (
|
||||||
<TooltipTrigger
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
onPress={handleToggleRealtimeVoice}
|
<TooltipTrigger
|
||||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
onPress={handleToggleRealtimeVoice}
|
||||||
accessibilityLabel="Enable Voice mode"
|
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||||
accessibilityRole="button"
|
accessibilityLabel="Enable Voice mode"
|
||||||
style={({ hovered }) => [
|
accessibilityRole="button"
|
||||||
styles.realtimeVoiceButton as any,
|
style={({ hovered }) => [
|
||||||
(hovered ? styles.iconButtonHovered : undefined) as any,
|
styles.realtimeVoiceButton as any,
|
||||||
(!isConnected || voice?.isVoiceSwitching
|
(hovered ? styles.iconButtonHovered : undefined) as any,
|
||||||
? styles.buttonDisabled
|
(!isConnected || voice?.isVoiceSwitching
|
||||||
: undefined) as any,
|
? styles.buttonDisabled
|
||||||
]}
|
: undefined) as any,
|
||||||
>
|
]}
|
||||||
{({ hovered }) =>
|
>
|
||||||
voice?.isVoiceSwitching ? (
|
{({ hovered }) =>
|
||||||
<ActivityIndicator size="small" color="white" />
|
voice?.isVoiceSwitching ? (
|
||||||
) : (
|
<ActivityIndicator size="small" color="white" />
|
||||||
<AudioLines
|
) : (
|
||||||
size={buttonIconSize}
|
<AudioLines
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
size={buttonIconSize}
|
||||||
/>
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
)
|
/>
|
||||||
}
|
)
|
||||||
</TooltipTrigger>
|
}
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
</TooltipTrigger>
|
||||||
<View style={styles.tooltipRow}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
<View style={styles.tooltipRow}>
|
||||||
{voiceToggleKeys ? (
|
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
{voiceToggleKeys ? (
|
||||||
) : null}
|
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
||||||
</View>
|
) : null}
|
||||||
</TooltipContent>
|
</View>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
) : null}
|
</Tooltip>
|
||||||
{cancelButton}
|
) : null}
|
||||||
</View>
|
{cancelButton}
|
||||||
) : null;
|
</View>
|
||||||
|
) : null,
|
||||||
|
[
|
||||||
|
buttonIconSize,
|
||||||
|
cancelButton,
|
||||||
|
handleToggleRealtimeVoice,
|
||||||
|
isConnected,
|
||||||
|
showVoiceModeButton,
|
||||||
|
theme.colors.foreground,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
voice,
|
||||||
|
voiceToggleKeys,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const hasContextWindowMeter =
|
const hasContextWindowMeter =
|
||||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||||
@@ -725,15 +766,18 @@ export function Composer({
|
|||||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||||
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
||||||
|
|
||||||
const beforeVoiceContent = (
|
const beforeVoiceContent = useMemo(
|
||||||
<View style={styles.contextWindowMeterSlot}>
|
() => (
|
||||||
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
<View style={styles.contextWindowMeterSlot}>
|
||||||
<ContextWindowMeter
|
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||||
maxTokens={contextWindowMaxTokens}
|
<ContextWindowMeter
|
||||||
usedTokens={contextWindowUsedTokens}
|
maxTokens={contextWindowMaxTokens}
|
||||||
/>
|
usedTokens={contextWindowUsedTokens}
|
||||||
) : null}
|
/>
|
||||||
</View>
|
) : null}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[contextWindowMaxTokens, contextWindowUsedTokens],
|
||||||
);
|
);
|
||||||
|
|
||||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||||
@@ -808,12 +852,33 @@ export function Composer({
|
|||||||
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const leftContent =
|
const leftContent = useMemo(
|
||||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
() =>
|
||||||
<DraftAgentStatusBar {...statusControls} />
|
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||||
) : (
|
<DraftAgentStatusBar {...statusControls} />
|
||||||
<AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
|
) : (
|
||||||
);
|
<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 (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -924,7 +989,7 @@ export function Composer({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||||
<MessageInput
|
<StableMessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
value={userInput}
|
value={userInput}
|
||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
@@ -938,9 +1003,7 @@ export function Composer({
|
|||||||
attachments={selectedAttachments}
|
attachments={selectedAttachments}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
attachmentMenuItems={attachmentMenuItems}
|
attachmentMenuItems={attachmentMenuItems}
|
||||||
onAttachButtonRef={(node) => {
|
onAttachButtonRef={handleAttachButtonRef}
|
||||||
attachButtonRef.current = node;
|
|
||||||
}}
|
|
||||||
onAddImages={addImages}
|
onAddImages={addImages}
|
||||||
client={client}
|
client={client}
|
||||||
isReadyForDictation={isDictationReady}
|
isReadyForDictation={isDictationReady}
|
||||||
@@ -959,15 +1022,8 @@ export function Composer({
|
|||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
onSelectionChange={(selection) => {
|
onSelectionChange={handleSelectionChange}
|
||||||
setCursorIndex(selection.start);
|
onFocusChange={handleFocusChange}
|
||||||
}}
|
|
||||||
onFocusChange={(focused) => {
|
|
||||||
setIsMessageInputFocused(focused);
|
|
||||||
if (focused) {
|
|
||||||
onAttentionInputFocus?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onHeightChange={onComposerHeightChange}
|
onHeightChange={onComposerHeightChange}
|
||||||
inputWrapperStyle={inputWrapperStyle}
|
inputWrapperStyle={inputWrapperStyle}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { PrPane } from "./pr-pane";
|
|||||||
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
||||||
import {
|
import {
|
||||||
usePanelStore,
|
usePanelStore,
|
||||||
|
selectIsFileExplorerOpen,
|
||||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
@@ -52,8 +53,7 @@ export function ExplorerSidebar({
|
|||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
@@ -82,9 +82,6 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
||||||
|
|
||||||
// Derive isOpen from the unified panel state
|
|
||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -105,8 +102,6 @@ export function ExplorerSidebar({
|
|||||||
logExplorerSidebar("handleClose", {
|
logExplorerSidebar("handleClose", {
|
||||||
reason,
|
reason,
|
||||||
isOpen,
|
isOpen,
|
||||||
mobileView,
|
|
||||||
desktopFileExplorerOpen,
|
|
||||||
});
|
});
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
@@ -114,14 +109,7 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
closeDesktopFileExplorer();
|
closeDesktopFileExplorer();
|
||||||
},
|
},
|
||||||
[
|
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
|
||||||
closeDesktopFileExplorer,
|
|
||||||
desktopFileExplorerOpen,
|
|
||||||
isMobile,
|
|
||||||
isOpen,
|
|
||||||
mobileView,
|
|
||||||
showMobileAgent,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseFromGesture = useCallback(() => {
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
|
|||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
import { ScreenTitle } from "./screen-title";
|
import { ScreenTitle } from "./screen-title";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
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 { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
|
|
||||||
@@ -46,19 +46,16 @@ export function SidebarMenuToggle({
|
|||||||
}: SidebarMenuToggleProps = {}) {
|
}: SidebarMenuToggleProps = {}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
|
||||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
|
||||||
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
|
||||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||||
|
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
const menuIconColor =
|
const menuIconColor =
|
||||||
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderToggleButton
|
<HeaderToggleButton
|
||||||
onPress={isMobile ? toggleMobileAgentList : toggleDesktopAgentList}
|
onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
|
||||||
tooltipLabel="Toggle sidebar"
|
tooltipLabel="Toggle sidebar"
|
||||||
tooltipKeys={toggleShortcutKeys}
|
tooltipKeys={toggleShortcutKeys}
|
||||||
tooltipSide={tooltipSide}
|
tooltipSide={tooltipSide}
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { router, usePathname } from "expo-router";
|
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 { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
@@ -116,8 +121,9 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) =>
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||||
|
);
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
@@ -183,8 +189,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const hostTriggerRef = useRef<View | null>(null);
|
const hostTriggerRef = useRef<View | null>(null);
|
||||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||||
|
|
||||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
|
|
||||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
enabled: isOpen,
|
enabled: isOpen,
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ import type { HostProfile } from "@/types/host-connection";
|
|||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
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", () => ({
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
default: {
|
default: {
|
||||||
@@ -43,6 +49,8 @@ type RenderCounts = {
|
|||||||
frame: number;
|
frame: number;
|
||||||
headers: Record<string, number>;
|
headers: Record<string, number>;
|
||||||
rows: Record<string, number>;
|
rows: Record<string, number>;
|
||||||
|
projectSelection: Record<string, number>;
|
||||||
|
rowSelection: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const runningScript: WorkspaceScriptPayload = {
|
const runningScript: WorkspaceScriptPayload = {
|
||||||
@@ -166,6 +174,8 @@ function resetCounts(counts: RenderCounts): void {
|
|||||||
counts.frame = 0;
|
counts.frame = 0;
|
||||||
counts.headers = {};
|
counts.headers = {};
|
||||||
counts.rows = {};
|
counts.rows = {};
|
||||||
|
counts.projectSelection = {};
|
||||||
|
counts.rowSelection = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function incrementRecord(record: Record<string, number>, key: string): void {
|
function incrementRecord(record: Record<string, number>, key: string): void {
|
||||||
@@ -201,6 +211,37 @@ function WorkspaceRowProbe({
|
|||||||
return null;
|
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 {
|
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||||
counts.frame += 1;
|
counts.frame += 1;
|
||||||
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
|
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
|
||||||
@@ -210,13 +251,20 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
|||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<div key={project.projectKey}>
|
<div key={project.projectKey}>
|
||||||
<ProjectHeaderProbe project={project} counts={counts} />
|
<ProjectHeaderProbe project={project} counts={counts} />
|
||||||
|
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
|
||||||
{project.workspaces.map((workspace) => (
|
{project.workspaces.map((workspace) => (
|
||||||
<WorkspaceRowProbe
|
<React.Fragment key={workspace.workspaceKey}>
|
||||||
key={workspace.workspaceKey}
|
<WorkspaceRowProbe
|
||||||
serverId={workspace.serverId}
|
serverId={workspace.serverId}
|
||||||
workspaceId={workspace.workspaceId}
|
workspaceId={workspace.workspaceId}
|
||||||
counts={counts}
|
counts={counts}
|
||||||
/>
|
/>
|
||||||
|
<WorkspaceSelectionProbe
|
||||||
|
serverId={workspace.serverId}
|
||||||
|
workspaceId={workspace.workspaceId}
|
||||||
|
counts={counts}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -278,6 +326,7 @@ describe("sidebar workspace render isolation", () => {
|
|||||||
container?.remove();
|
container?.remove();
|
||||||
container = null;
|
container = null;
|
||||||
act(() => {
|
act(() => {
|
||||||
|
syncNavigationActiveWorkspace({ current: null });
|
||||||
getHostRuntimeStore().syncHosts([]);
|
getHostRuntimeStore().syncHosts([]);
|
||||||
useSessionStore.getState().clearSession(SERVER_ID);
|
useSessionStore.getState().clearSession(SERVER_ID);
|
||||||
useSidebarOrderStore.setState({
|
useSidebarOrderStore.setState({
|
||||||
@@ -288,7 +337,13 @@ describe("sidebar workspace render isolation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("re-renders only the changed workspace row for a status update", async () => {
|
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));
|
({ root, container } = await renderProbe(counts));
|
||||||
|
|
||||||
act(() => {
|
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 () => {
|
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));
|
({ root, container } = await renderProbe(counts));
|
||||||
|
|
||||||
act(() => {
|
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 () => {
|
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));
|
({ root, container } = await renderProbe(counts));
|
||||||
|
|
||||||
act(() => {
|
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 { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
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 {
|
import {
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
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 { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import {
|
import {
|
||||||
requireWorkspaceExecutionDirectory,
|
requireWorkspaceExecutionDirectory,
|
||||||
@@ -1175,7 +1178,6 @@ function WorkspaceRowWithMenu({
|
|||||||
isCreating?: boolean;
|
isCreating?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
|
||||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||||
@@ -1193,22 +1195,11 @@ function WorkspaceRowWithMenu({
|
|||||||
const isWorktree = workspace.workspaceKind === "worktree";
|
const isWorktree = workspace.workspaceKind === "worktree";
|
||||||
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
|
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
|
||||||
const redirectAfterArchive = useCallback(() => {
|
const redirectAfterArchive = useCallback(() => {
|
||||||
if (
|
redirectIfArchivingActiveWorkspace({
|
||||||
activeWorkspaceSelection?.serverId !== workspace.serverId ||
|
serverId: workspace.serverId,
|
||||||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
|
workspaceId: workspace.workspaceId,
|
||||||
) {
|
});
|
||||||
return;
|
}, [workspace.serverId, workspace.workspaceId]);
|
||||||
}
|
|
||||||
|
|
||||||
router.replace(
|
|
||||||
buildWorkspaceArchiveRedirectRoute({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
archivedWorkspaceId: workspace.workspaceId,
|
|
||||||
workspaces:
|
|
||||||
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
|
||||||
|
|
||||||
const handleArchiveWorktree = useCallback(() => {
|
const handleArchiveWorktree = useCallback(() => {
|
||||||
if (isArchiving) {
|
if (isArchiving) {
|
||||||
@@ -1401,25 +1392,13 @@ function NonGitProjectRowWithMenuContent({
|
|||||||
}) {
|
}) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const contextMenu = useContextMenu();
|
const contextMenu = useContextMenu();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
|
||||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||||
const redirectAfterArchive = useCallback(() => {
|
const redirectAfterArchive = useCallback(() => {
|
||||||
if (
|
redirectIfArchivingActiveWorkspace({
|
||||||
activeWorkspaceSelection?.serverId !== workspace.serverId ||
|
serverId: workspace.serverId,
|
||||||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
|
workspaceId: workspace.workspaceId,
|
||||||
) {
|
});
|
||||||
return;
|
}, [workspace.serverId, workspace.workspaceId]);
|
||||||
}
|
|
||||||
|
|
||||||
router.replace(
|
|
||||||
buildWorkspaceArchiveRedirectRoute({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
archivedWorkspaceId: workspace.workspaceId,
|
|
||||||
workspaces:
|
|
||||||
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
|
|
||||||
}) as any,
|
|
||||||
);
|
|
||||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
|
||||||
|
|
||||||
const handleArchiveWorkspace = useCallback(() => {
|
const handleArchiveWorkspace = useCallback(() => {
|
||||||
if (isArchivingWorkspace) {
|
if (isArchivingWorkspace) {
|
||||||
@@ -1546,6 +1525,7 @@ function FlattenedProjectRow({
|
|||||||
isProjectActive = false,
|
isProjectActive = false,
|
||||||
onRemoveProject,
|
onRemoveProject,
|
||||||
removeProjectStatus,
|
removeProjectStatus,
|
||||||
|
selectionEnabled,
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -1563,8 +1543,14 @@ function FlattenedProjectRow({
|
|||||||
isProjectActive?: boolean;
|
isProjectActive?: boolean;
|
||||||
onRemoveProject?: () => void;
|
onRemoveProject?: () => void;
|
||||||
removeProjectStatus?: "idle" | "pending";
|
removeProjectStatus?: "idle" | "pending";
|
||||||
|
selectionEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
||||||
|
const selected = useIsNavigationWorkspaceSelected({
|
||||||
|
serverId,
|
||||||
|
workspaceId: rowModel.workspace.workspaceId,
|
||||||
|
enabled: selectionEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1577,7 +1563,7 @@ function FlattenedProjectRow({
|
|||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
selected={rowModel.selected}
|
selected={selected}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
@@ -1594,7 +1580,7 @@ function FlattenedProjectRow({
|
|||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
selected={rowModel.selected}
|
selected={selected}
|
||||||
chevron={rowModel.chevron}
|
chevron={rowModel.chevron}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
@@ -1616,7 +1602,6 @@ function FlattenedProjectRow({
|
|||||||
|
|
||||||
function WorkspaceRow({
|
function WorkspaceRow({
|
||||||
workspace,
|
workspace,
|
||||||
selected,
|
|
||||||
shortcutNumber,
|
shortcutNumber,
|
||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -1625,9 +1610,9 @@ function WorkspaceRow({
|
|||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
canCopyBranchName,
|
canCopyBranchName,
|
||||||
isCreating = false,
|
isCreating = false,
|
||||||
|
selectionEnabled,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
selected: boolean;
|
|
||||||
shortcutNumber: number | null;
|
shortcutNumber: number | null;
|
||||||
showShortcutBadge: boolean;
|
showShortcutBadge: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -1636,8 +1621,14 @@ function WorkspaceRow({
|
|||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
canCopyBranchName: boolean;
|
canCopyBranchName: boolean;
|
||||||
isCreating?: boolean;
|
isCreating?: boolean;
|
||||||
|
selectionEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||||
|
const selected = useIsNavigationWorkspaceSelected({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
enabled: selectionEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
if (!hydratedWorkspace) {
|
if (!hydratedWorkspace) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1665,7 +1656,7 @@ function ProjectBlock({
|
|||||||
displayName,
|
displayName,
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
serverId,
|
serverId,
|
||||||
activeWorkspaceSelection,
|
selectionEnabled,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
@@ -1673,6 +1664,7 @@ function ProjectBlock({
|
|||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onWorkspaceReorder,
|
onWorkspaceReorder,
|
||||||
onWorktreeCreated,
|
onWorktreeCreated,
|
||||||
|
currentPathname,
|
||||||
drag,
|
drag,
|
||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
@@ -1684,7 +1676,7 @@ function ProjectBlock({
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
iconDataUri: string | null;
|
iconDataUri: string | null;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
activeWorkspaceSelection: { serverId: string; workspaceId: string } | null;
|
selectionEnabled: boolean;
|
||||||
showShortcutBadges: boolean;
|
showShortcutBadges: boolean;
|
||||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
@@ -1692,6 +1684,7 @@ function ProjectBlock({
|
|||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
|
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
|
||||||
onWorktreeCreated?: (workspaceId: string) => void;
|
onWorktreeCreated?: (workspaceId: string) => void;
|
||||||
|
currentPathname: string | null;
|
||||||
drag: () => void;
|
drag: () => void;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
@@ -1703,18 +1696,19 @@ function ProjectBlock({
|
|||||||
buildSidebarProjectRowModel({
|
buildSidebarProjectRowModel({
|
||||||
project,
|
project,
|
||||||
collapsed,
|
collapsed,
|
||||||
serverId,
|
|
||||||
activeWorkspaceSelection,
|
|
||||||
}),
|
}),
|
||||||
[activeWorkspaceSelection, collapsed, project, serverId],
|
[collapsed, project],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isProjectActive = useMemo(() => {
|
const projectWorkspaceIds = useMemo(
|
||||||
if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) {
|
() => project.workspaces.map((workspace) => workspace.workspaceId),
|
||||||
return false;
|
[project.workspaces],
|
||||||
}
|
);
|
||||||
return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId);
|
const isProjectActive = useIsNavigationProjectActive({
|
||||||
}, [serverId, activeWorkspaceSelection, project.workspaces]);
|
serverId,
|
||||||
|
workspaceIds: projectWorkspaceIds,
|
||||||
|
enabled: selectionEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
const renderWorkspaceRow = useCallback(
|
const renderWorkspaceRow = useCallback(
|
||||||
(
|
(
|
||||||
@@ -1725,25 +1719,20 @@ function ProjectBlock({
|
|||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const isSelected =
|
|
||||||
Boolean(serverId) &&
|
|
||||||
activeWorkspaceSelection?.serverId === serverId &&
|
|
||||||
activeWorkspaceSelection.workspaceId === item.workspaceId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
workspace={item}
|
workspace={item}
|
||||||
selected={isSelected}
|
|
||||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||||
showShortcutBadge={showShortcutBadges}
|
showShortcutBadge={showShortcutBadges}
|
||||||
canCopyBranchName={project.projectKind === "git"}
|
canCopyBranchName={project.projectKind === "git"}
|
||||||
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
||||||
|
selectionEnabled={selectionEnabled}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onWorkspacePress?.();
|
onWorkspacePress?.();
|
||||||
navigateToWorkspace(serverId, item.workspaceId);
|
navigateToWorkspace(serverId, item.workspaceId, { currentPathname });
|
||||||
}}
|
}}
|
||||||
drag={input?.drag ?? (() => {})}
|
drag={input?.drag ?? (() => {})}
|
||||||
isDragging={input?.isDragging ?? false}
|
isDragging={input?.isDragging ?? false}
|
||||||
@@ -1752,11 +1741,12 @@ function ProjectBlock({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeWorkspaceSelection,
|
|
||||||
project.projectKind,
|
project.projectKind,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
|
currentPathname,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
serverId,
|
serverId,
|
||||||
|
selectionEnabled,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
],
|
],
|
||||||
@@ -1843,7 +1833,9 @@ function ProjectBlock({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onWorkspacePress?.();
|
onWorkspacePress?.();
|
||||||
navigateToWorkspace(serverId, rowModel.workspace.workspaceId);
|
navigateToWorkspace(serverId, rowModel.workspace.workspaceId, {
|
||||||
|
currentPathname,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
@@ -1856,6 +1848,7 @@ function ProjectBlock({
|
|||||||
isProjectActive={isProjectActive}
|
isProjectActive={isProjectActive}
|
||||||
onRemoveProject={handleRemoveProject}
|
onRemoveProject={handleRemoveProject}
|
||||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||||
|
selectionEnabled={selectionEnabled}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1914,9 +1907,7 @@ export function SidebarWorkspaceList({
|
|||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
|
||||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -1932,7 +1923,7 @@ export function SidebarWorkspaceList({
|
|||||||
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
|
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
|
||||||
[pathname],
|
[pathname],
|
||||||
);
|
);
|
||||||
const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null;
|
const selectionEnabled = isWorkspaceRoute;
|
||||||
|
|
||||||
const projectIconRequests = useMemo(() => {
|
const projectIconRequests = useMemo(() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
@@ -2137,7 +2128,7 @@ export function SidebarWorkspaceList({
|
|||||||
displayName={item.projectName}
|
displayName={item.projectName}
|
||||||
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
activeWorkspaceSelection={effectiveActiveWorkspaceSelection}
|
selectionEnabled={selectionEnabled}
|
||||||
showShortcutBadges={showShortcutBadges}
|
showShortcutBadges={showShortcutBadges}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
@@ -2145,6 +2136,7 @@ export function SidebarWorkspaceList({
|
|||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
onWorkspaceReorder={handleWorkspaceReorder}
|
onWorkspaceReorder={handleWorkspaceReorder}
|
||||||
onWorktreeCreated={handleWorktreeCreated}
|
onWorktreeCreated={handleWorktreeCreated}
|
||||||
|
currentPathname={pathname}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isActive}
|
isDragging={isActive}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
@@ -2155,13 +2147,14 @@ export function SidebarWorkspaceList({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
effectiveActiveWorkspaceSelection,
|
|
||||||
handleWorktreeCreated,
|
handleWorktreeCreated,
|
||||||
handleWorkspaceReorder,
|
handleWorkspaceReorder,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
|
pathname,
|
||||||
projectIconByProjectKey,
|
projectIconByProjectKey,
|
||||||
|
selectionEnabled,
|
||||||
serverId,
|
serverId,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ interface SplitContainerProps {
|
|||||||
workspaceKey: string;
|
workspaceKey: string;
|
||||||
normalizedServerId: string;
|
normalizedServerId: string;
|
||||||
normalizedWorkspaceId: string;
|
normalizedWorkspaceId: string;
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
uiTabs: WorkspaceTab[];
|
uiTabs: WorkspaceTab[];
|
||||||
hoveredCloseTabKey: string | null;
|
hoveredCloseTabKey: string | null;
|
||||||
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||||
@@ -93,7 +94,6 @@ interface SplitContainerProps {
|
|||||||
onCreateTerminalTab: (input: { paneId?: string }) => void;
|
onCreateTerminalTab: (input: { paneId?: string }) => void;
|
||||||
buildPaneContentModel: (input: {
|
buildPaneContentModel: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
isPaneFocused: boolean;
|
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
}) => WorkspacePaneContentModel;
|
}) => WorkspacePaneContentModel;
|
||||||
onFocusPane: (paneId: string) => void;
|
onFocusPane: (paneId: string) => void;
|
||||||
@@ -158,11 +158,11 @@ interface SplitPaneViewProps
|
|||||||
interface MountedTabSlotProps {
|
interface MountedTabSlotProps {
|
||||||
tabDescriptor: WorkspaceTabDescriptor;
|
tabDescriptor: WorkspaceTabDescriptor;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
paneId: string;
|
paneId: string;
|
||||||
buildPaneContentModel: (input: {
|
buildPaneContentModel: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
isPaneFocused: boolean;
|
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
}) => WorkspacePaneContentModel;
|
}) => WorkspacePaneContentModel;
|
||||||
}
|
}
|
||||||
@@ -170,6 +170,7 @@ interface MountedTabSlotProps {
|
|||||||
const MountedTabSlot = memo(function MountedTabSlot({
|
const MountedTabSlot = memo(function MountedTabSlot({
|
||||||
tabDescriptor,
|
tabDescriptor,
|
||||||
isVisible,
|
isVisible,
|
||||||
|
isWorkspaceFocused,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
paneId,
|
paneId,
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
@@ -178,15 +179,18 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
|||||||
() =>
|
() =>
|
||||||
buildPaneContentModel({
|
buildPaneContentModel({
|
||||||
paneId,
|
paneId,
|
||||||
isPaneFocused,
|
|
||||||
tab: tabDescriptor,
|
tab: tabDescriptor,
|
||||||
}),
|
}),
|
||||||
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
|
[buildPaneContentModel, paneId, tabDescriptor],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
|
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
|
||||||
<WorkspacePaneContent content={content} />
|
<WorkspacePaneContent
|
||||||
|
content={content}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
|
isPaneFocused={isPaneFocused}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -241,6 +245,7 @@ export function SplitContainer({
|
|||||||
workspaceKey,
|
workspaceKey,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
|
isWorkspaceFocused,
|
||||||
uiTabs,
|
uiTabs,
|
||||||
hoveredCloseTabKey,
|
hoveredCloseTabKey,
|
||||||
setHoveredTabKey,
|
setHoveredTabKey,
|
||||||
@@ -511,6 +516,7 @@ export function SplitContainer({
|
|||||||
focusedPaneId={layout.focusedPaneId}
|
focusedPaneId={layout.focusedPaneId}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
@@ -633,6 +639,7 @@ function SplitNodeView({
|
|||||||
focusedPaneId,
|
focusedPaneId,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
|
isWorkspaceFocused,
|
||||||
hoveredCloseTabKey,
|
hoveredCloseTabKey,
|
||||||
setHoveredTabKey,
|
setHoveredTabKey,
|
||||||
setHoveredCloseTabKey,
|
setHoveredCloseTabKey,
|
||||||
@@ -668,6 +675,7 @@ function SplitNodeView({
|
|||||||
isFocused={node.pane.id === focusedPaneId}
|
isFocused={node.pane.id === focusedPaneId}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
@@ -718,6 +726,7 @@ function SplitNodeView({
|
|||||||
focusedPaneId={focusedPaneId}
|
focusedPaneId={focusedPaneId}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
@@ -767,6 +776,7 @@ function SplitPaneView({
|
|||||||
isFocused,
|
isFocused,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
|
isWorkspaceFocused,
|
||||||
hoveredCloseTabKey,
|
hoveredCloseTabKey,
|
||||||
setHoveredTabKey,
|
setHoveredTabKey,
|
||||||
setHoveredCloseTabKey,
|
setHoveredCloseTabKey,
|
||||||
@@ -916,6 +926,7 @@ function SplitPaneView({
|
|||||||
key={tabId}
|
key={tabId}
|
||||||
tabDescriptor={tabDescriptor}
|
tabDescriptor={tabDescriptor}
|
||||||
isVisible={tabId === activeTabDescriptor?.tabId}
|
isVisible={tabId === activeTabDescriptor?.tabId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
||||||
paneId={pane.id}
|
paneId={pane.id}
|
||||||
buildPaneContentModel={buildPaneContentModel}
|
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,
|
Fragment,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -126,6 +126,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const lastTouchClientYRef = useRef<number | null>(null);
|
const lastTouchClientYRef = useRef<number | null>(null);
|
||||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||||
|
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||||
const showDesktopWebScrollbar = !isMobileBreakpoint;
|
const showDesktopWebScrollbar = !isMobileBreakpoint;
|
||||||
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
|
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
|
||||||
enabled: showDesktopWebScrollbar,
|
enabled: showDesktopWebScrollbar,
|
||||||
@@ -181,6 +182,38 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
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 cancelPendingStickToBottom = useCallback(() => {
|
||||||
const pendingFrame = pendingAutoScrollFrameRef.current;
|
const pendingFrame = pendingAutoScrollFrameRef.current;
|
||||||
if (pendingFrame !== null) {
|
if (pendingFrame !== null) {
|
||||||
@@ -696,7 +729,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
<div
|
<div
|
||||||
key={virtualRow.key}
|
key={virtualRow.key}
|
||||||
data-index={virtualRow.index}
|
data-index={virtualRow.index}
|
||||||
ref={rowVirtualizer.measureElement}
|
ref={measureVirtualizedRowElement}
|
||||||
style={renderVirtualRowStyle(virtualRow.start)}
|
style={renderVirtualRowStyle(virtualRow.start)}
|
||||||
>
|
>
|
||||||
{renderHistoryVirtualizedRow(
|
{renderHistoryVirtualizedRow(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -27,7 +26,9 @@ interface TerminalPaneProps {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
|
onOpenFileExplorer: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
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}`;
|
return `${input.serverId}:${input.cwd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: TerminalPaneProps) {
|
export function TerminalPane({
|
||||||
const isScreenFocused = useIsFocused();
|
serverId,
|
||||||
|
cwd,
|
||||||
|
terminalId,
|
||||||
|
isWorkspaceFocused,
|
||||||
|
isPaneFocused,
|
||||||
|
onOpenFileExplorer,
|
||||||
|
}: TerminalPaneProps) {
|
||||||
const isAppVisible = useAppVisible();
|
const isAppVisible = useAppVisible();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||||
const showMobileFileExplorer = usePanelStore((state) => state.showMobileFileExplorer);
|
|
||||||
const swipeGesturesEnabled = isMobile && mobileView === "agent";
|
const swipeGesturesEnabled = isMobile && mobileView === "agent";
|
||||||
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
||||||
mode: "padding",
|
mode: "padding",
|
||||||
@@ -131,7 +137,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile || !isScreenFocused || !isPaneFocused || !terminalId) {
|
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
||||||
lastAutoFocusKeyRef.current = null;
|
lastAutoFocusKeyRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,14 +149,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
|
|
||||||
lastAutoFocusKeyRef.current = nextFocusKey;
|
lastAutoFocusKeyRef.current = nextFocusKey;
|
||||||
requestTerminalFocus();
|
requestTerminalFocus();
|
||||||
}, [isMobile, isPaneFocused, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
|
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPaneFocused && isScreenFocused && isAppVisible && terminalId) {
|
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
requestTerminalReflow();
|
requestTerminalReflow();
|
||||||
}
|
}
|
||||||
}, [isAppVisible, isPaneFocused, isScreenFocused, requestTerminalReflow, terminalId]);
|
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
|
||||||
|
|
||||||
const clearKeyboardRefitTimeouts = useCallback(() => {
|
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||||
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||||
@@ -187,29 +193,27 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
[pulseKeyboardRefits],
|
[pulseKeyboardRefits],
|
||||||
);
|
);
|
||||||
|
|
||||||
useFocusEffect(
|
useEffect(() => {
|
||||||
useCallback(() => {
|
if (!isWorkspaceFocused || !terminalId) {
|
||||||
if (!terminalId) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
// Focus transitions can temporarily report stale dimensions.
|
||||||
// Navigation transitions can temporarily report stale dimensions.
|
// Pulse forced refits so xterm fills the pane when returning to a terminal.
|
||||||
// Pulse forced refits so xterm fills the pane when returning to an agent.
|
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
||||||
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
requestTerminalReflow();
|
||||||
requestTerminalReflow();
|
}, delayMs),
|
||||||
}, delayMs),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const handle of timeoutHandles) {
|
for (const handle of timeoutHandles) {
|
||||||
clearTimeout(handle);
|
clearTimeout(handle);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [requestTerminalReflow, terminalId]),
|
}, [isWorkspaceFocused, requestTerminalReflow, terminalId]);
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !isConnected || !isScreenFocused) {
|
if (!client || !isConnected || !isWorkspaceFocused) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +236,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
});
|
});
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
});
|
});
|
||||||
}, [client, isConnected, workspaceTerminalSession.snapshots]);
|
}, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
@@ -257,14 +261,14 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
client,
|
client,
|
||||||
getPreferredSize: () => lastReportedSizeRef.current,
|
getPreferredSize: () => lastReportedSizeRef.current,
|
||||||
onOutput: ({ terminalId, text }) => {
|
onOutput: ({ terminalId, text }) => {
|
||||||
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
|
if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emulatorRef.current?.writeOutput(text);
|
emulatorRef.current?.writeOutput(text);
|
||||||
},
|
},
|
||||||
onSnapshot: ({ terminalId, state }) => {
|
onSnapshot: ({ terminalId, state }) => {
|
||||||
workspaceTerminalSession.snapshots.set({ terminalId, state });
|
workspaceTerminalSession.snapshots.set({ terminalId, state });
|
||||||
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
|
if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emulatorRef.current?.renderSnapshot(state);
|
emulatorRef.current?.renderSnapshot(state);
|
||||||
@@ -274,7 +278,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
|
|
||||||
streamControllerRef.current = controller;
|
streamControllerRef.current = controller;
|
||||||
controller.setTerminal({
|
controller.setTerminal({
|
||||||
terminalId: isScreenFocused ? terminalIdRef.current : null,
|
terminalId: isWorkspaceFocused ? terminalIdRef.current : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -287,17 +291,17 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
client,
|
client,
|
||||||
handleStreamControllerStatus,
|
handleStreamControllerStatus,
|
||||||
isConnected,
|
isConnected,
|
||||||
isScreenFocused,
|
isWorkspaceFocused,
|
||||||
workspaceTerminalSession.snapshots,
|
workspaceTerminalSession.snapshots,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pendingTerminalInputRef.current = [];
|
pendingTerminalInputRef.current = [];
|
||||||
const nextTerminalId = isScreenFocused ? terminalId : null;
|
const nextTerminalId = isWorkspaceFocused ? terminalId : null;
|
||||||
streamControllerRef.current?.setTerminal({
|
streamControllerRef.current?.setTerminal({
|
||||||
terminalId: nextTerminalId,
|
terminalId: nextTerminalId,
|
||||||
});
|
});
|
||||||
}, [isScreenFocused, terminalId]);
|
}, [isWorkspaceFocused, terminalId]);
|
||||||
|
|
||||||
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||||
const queue = pendingTerminalInputRef.current;
|
const queue = pendingTerminalInputRef.current;
|
||||||
@@ -477,7 +481,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
!client ||
|
!client ||
|
||||||
!terminalId ||
|
!terminalId ||
|
||||||
!isPaneFocused ||
|
!isPaneFocused ||
|
||||||
!isScreenFocused ||
|
!isWorkspaceFocused ||
|
||||||
!isAppVisible ||
|
!isAppVisible ||
|
||||||
rows <= 0 ||
|
rows <= 0 ||
|
||||||
cols <= 0
|
cols <= 0
|
||||||
@@ -550,7 +554,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
|
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
|
||||||
<View style={styles.outputContainer}>
|
<View style={styles.outputContainer}>
|
||||||
{isScreenFocused ? (
|
{isWorkspaceFocused ? (
|
||||||
<View style={styles.terminalGestureContainer}>
|
<View style={styles.terminalGestureContainer}>
|
||||||
<TerminalEmulator
|
<TerminalEmulator
|
||||||
ref={emulatorRef}
|
ref={emulatorRef}
|
||||||
@@ -579,7 +583,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
if (!swipeGesturesEnabled) {
|
if (!swipeGesturesEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showMobileFileExplorer();
|
onOpenFileExplorer();
|
||||||
}}
|
}}
|
||||||
onInput={handleTerminalData}
|
onInput={handleTerminalData}
|
||||||
onResize={handleTerminalResize}
|
onResize={handleTerminalResize}
|
||||||
@@ -594,7 +598,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
|
|||||||
<View style={styles.terminalGestureContainer} />
|
<View style={styles.terminalGestureContainer} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAttaching && isScreenFocused ? (
|
{isAttaching && isWorkspaceFocused ? (
|
||||||
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useWindowDimensions } from "react-native";
|
|||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getRightSidebarAnimationTargets,
|
getRightSidebarAnimationTargets,
|
||||||
shouldSyncSidebarAnimation,
|
shouldSyncSidebarAnimation,
|
||||||
@@ -38,11 +38,9 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
|||||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) =>
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
|
||||||
|
);
|
||||||
// Derive isOpen from the unified panel state
|
|
||||||
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
||||||
|
|
||||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
||||||
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
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 { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getLeftSidebarAnimationTargets,
|
getLeftSidebarAnimationTargets,
|
||||||
shouldSyncSidebarAnimation,
|
shouldSyncSidebarAnimation,
|
||||||
@@ -37,11 +37,9 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
|
|||||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) =>
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||||
|
);
|
||||||
// Derive isOpen from the unified panel state
|
|
||||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
|
|
||||||
// Initialize based on current state
|
// Initialize based on current state
|
||||||
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useId, useMemo } from "react";
|
import { useEffect, useId, useMemo } from "react";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
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 { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||||
@@ -59,10 +59,8 @@ export function useCheckoutDiffQuery({
|
|||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
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 hookInstanceId = useId();
|
||||||
const normalizedCompare = useMemo(
|
const normalizedCompare = useMemo(
|
||||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export function useKeyboardShortcuts({
|
|||||||
enabled,
|
enabled,
|
||||||
isMobile,
|
isMobile,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
toggleFileExplorer,
|
|
||||||
toggleBothSidebars,
|
toggleBothSidebars,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
cycleTheme,
|
cycleTheme,
|
||||||
@@ -40,7 +39,6 @@ export function useKeyboardShortcuts({
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
toggleAgentList: () => void;
|
toggleAgentList: () => void;
|
||||||
toggleFileExplorer?: () => void;
|
|
||||||
toggleBothSidebars?: () => void;
|
toggleBothSidebars?: () => void;
|
||||||
toggleFocusMode?: () => void;
|
toggleFocusMode?: () => void;
|
||||||
cycleTheme?: () => void;
|
cycleTheme?: () => void;
|
||||||
@@ -84,7 +82,7 @@ export function useKeyboardShortcuts({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToWorkspace(target.serverId, target.workspaceId);
|
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
|
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
|
||||||
@@ -100,7 +98,9 @@ export function useKeyboardShortcuts({
|
|||||||
if (!fallback) {
|
if (!fallback) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
navigateToWorkspace(fallback.serverId, fallback.workspaceId);
|
navigateToWorkspace(fallback.serverId, fallback.workspaceId, {
|
||||||
|
currentPathname: pathname,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export function useKeyboardShortcuts({
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
navigateToWorkspace(target.serverId, target.workspaceId);
|
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,10 +257,10 @@ export function useKeyboardShortcuts({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
case "sidebar.toggle.right":
|
case "sidebar.toggle.right":
|
||||||
if (toggleFileExplorer) {
|
return keyboardActionDispatcher.dispatch({
|
||||||
toggleFileExplorer();
|
id: "sidebar.toggle.right",
|
||||||
}
|
scope: "sidebar",
|
||||||
return true;
|
});
|
||||||
case "view.toggle.focus":
|
case "view.toggle.focus":
|
||||||
if (toggleFocusMode) {
|
if (toggleFocusMode) {
|
||||||
toggleFocusMode();
|
toggleFocusMode();
|
||||||
@@ -425,7 +425,6 @@ export function useKeyboardShortcuts({
|
|||||||
pathname,
|
pathname,
|
||||||
resetModifiers,
|
resetModifiers,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
toggleFileExplorer,
|
|
||||||
toggleFocusMode,
|
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(() => {
|
vi.hoisted(() => {
|
||||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
@@ -8,8 +15,12 @@ import {
|
|||||||
appendMissingOrderKeys,
|
appendMissingOrderKeys,
|
||||||
applyStoredOrdering,
|
applyStoredOrdering,
|
||||||
buildSidebarProjectsFromStructure,
|
buildSidebarProjectsFromStructure,
|
||||||
|
useSidebarWorkspacesList,
|
||||||
} from "./use-sidebar-workspaces-list";
|
} from "./use-sidebar-workspaces-list";
|
||||||
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
|
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 {
|
interface OrderedItem {
|
||||||
key: string;
|
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", () => {
|
describe("applyStoredOrdering", () => {
|
||||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||||
const result = applyStoredOrdering({
|
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;
|
const value = options?.serverId;
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}, [options?.serverId]);
|
}, [options?.serverId]);
|
||||||
|
const isActive = Boolean(serverId) && options?.enabled !== false;
|
||||||
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
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) =>
|
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||||
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
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,
|
activateNavigationWorkspaceSelection,
|
||||||
getNavigationActiveWorkspaceSelection,
|
getNavigationActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} 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
|
* Open a workspace. Once the workspace shell is mounted, switching workspaces
|
||||||
* is app-level state so native-stack does not rebuild every retained screen.
|
* 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();
|
const activeWorkspace = getNavigationActiveWorkspaceSelection();
|
||||||
if (activeWorkspace) {
|
if (
|
||||||
|
shouldUseRetainedWorkspaceSwitch({
|
||||||
|
hasActiveWorkspace: activeWorkspace !== null,
|
||||||
|
currentPathname: options.currentPathname,
|
||||||
|
})
|
||||||
|
) {
|
||||||
activateNavigationWorkspaceSelection(
|
activateNavigationWorkspaceSelection(
|
||||||
{ serverId, workspaceId },
|
{ serverId, workspaceId },
|
||||||
{ updateBrowserHistory: true, historyMode: "push" },
|
{ updateBrowserHistory: true, historyMode: "push" },
|
||||||
@@ -21,13 +49,16 @@ export function navigateToWorkspace(serverId: string, workspaceId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||||
router.navigate(href as any);
|
router.navigate(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaceNavigation() {
|
export function useWorkspaceNavigation() {
|
||||||
return {
|
return {
|
||||||
navigateToWorkspace: useCallback((serverId: string, workspaceId: string) => {
|
navigateToWorkspace: useCallback(
|
||||||
navigateToWorkspace(serverId, workspaceId);
|
(serverId: string, workspaceId: string, options?: NavigateToWorkspaceOptions) => {
|
||||||
}, []),
|
navigateToWorkspace(serverId, workspaceId, options);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type KeyboardActionId =
|
|||||||
| "workspace.pane.move-tab.down"
|
| "workspace.pane.move-tab.down"
|
||||||
| "workspace.pane.close"
|
| "workspace.pane.close"
|
||||||
| "workspace.terminal.new"
|
| "workspace.terminal.new"
|
||||||
|
| "sidebar.toggle.right"
|
||||||
| "worktree.new"
|
| "worktree.new"
|
||||||
| "worktree.archive";
|
| "worktree.archive";
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export type KeyboardActionDefinition =
|
|||||||
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
||||||
|
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||||
| { id: "worktree.archive"; 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 { ActivityIndicator, Text, View } from "react-native";
|
||||||
import ReanimatedAnimated from "react-native-reanimated";
|
import ReanimatedAnimated from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { shallow, useShallow } from "zustand/shallow";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
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 { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
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 type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
import {
|
import {
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
@@ -111,7 +111,8 @@ function useAgentPanelDescriptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AgentPanel() {
|
function AgentPanel() {
|
||||||
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext();
|
const { serverId, target, openFileInWorkspace } = usePaneContext();
|
||||||
|
const { isPaneFocused } = usePaneFocus();
|
||||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||||
|
|
||||||
function openWorkspaceFile(input: { filePath: string }) {
|
function openWorkspaceFile(input: { filePath: string }) {
|
||||||
@@ -137,6 +138,12 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
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 {}
|
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);
|
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 =
|
type AgentLookupState =
|
||||||
| { tag: "idle" }
|
| { tag: "idle" }
|
||||||
| { tag: "loading" }
|
| { tag: "loading" }
|
||||||
@@ -455,26 +483,13 @@ function ChatAgentContent({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const agentInputDraft = useAgentInputDraft({
|
|
||||||
draftKey: buildDraftStoreKey({
|
|
||||||
serverId,
|
|
||||||
agentId: agentId ?? "__pending__",
|
|
||||||
}),
|
|
||||||
initialCwd: agentState.cwd ?? "",
|
|
||||||
});
|
|
||||||
const projectPlacement = useStoreWithEqualityFn(
|
const projectPlacement = useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
(state) =>
|
(state) =>
|
||||||
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
||||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
(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 pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||||
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
|
|
||||||
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
|
|
||||||
const isInitializingFromMap = useSessionStore((state) =>
|
const isInitializingFromMap = useSessionStore((state) =>
|
||||||
agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false,
|
agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false,
|
||||||
);
|
);
|
||||||
@@ -489,11 +504,7 @@ function ChatAgentContent({
|
|||||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||||
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
||||||
);
|
);
|
||||||
const allPendingPermissions = useSessionStore(
|
|
||||||
(state) => state.sessions[serverId]?.pendingPermissions,
|
|
||||||
);
|
|
||||||
const setAgents = useSessionStore((state) => state.setAgents);
|
const setAgents = useSessionStore((state) => state.setAgents);
|
||||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
|
||||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||||
const { ensureAgentIsInitialized } = useAgentInitialization({
|
const { ensureAgentIsInitialized } = useAgentInitialization({
|
||||||
@@ -504,38 +515,13 @@ function ChatAgentContent({
|
|||||||
kind: "idle",
|
kind: "idle",
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingCreate = useMemo(() => {
|
const pendingCreate = useMemo(
|
||||||
if (!agentId) {
|
() => findPendingCreateForPanel({ pendingByDraftId, serverId, agentId }),
|
||||||
return null;
|
[agentId, pendingByDraftId, serverId],
|
||||||
}
|
);
|
||||||
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 isPendingCreateForPanel = Boolean(pendingCreate);
|
const isPendingCreateForPanel = Boolean(pendingCreate);
|
||||||
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
|
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({
|
const attentionController = useAgentAttentionClear({
|
||||||
agentId,
|
agentId,
|
||||||
client,
|
client,
|
||||||
@@ -640,38 +626,7 @@ function ChatAgentContent({
|
|||||||
return agentHistorySyncGeneration < historySyncGeneration;
|
return agentHistorySyncGeneration < historySyncGeneration;
|
||||||
}, [agentHistorySyncGeneration, agentId, historySyncGeneration]);
|
}, [agentHistorySyncGeneration, agentId, historySyncGeneration]);
|
||||||
|
|
||||||
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
|
const shouldUseOptimisticStream = isPendingCreateForPanel;
|
||||||
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 authoritativeStatus = agentState.status;
|
const authoritativeStatus = agentState.status;
|
||||||
const isAuthoritativeBootstrapping =
|
const isAuthoritativeBootstrapping =
|
||||||
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
|
authoritativeStatus === "initializing" || authoritativeStatus === "idle";
|
||||||
@@ -745,52 +700,29 @@ function ChatAgentContent({
|
|||||||
[effectiveAgent?.id],
|
[effectiveAgent?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleComposerHeightChange = useCallback(
|
||||||
if (!isPendingCreateForPanel || !pendingCreate) {
|
(height: number) => {
|
||||||
|
if (!agentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logWebStickyBottom("screen_composer_height_change", {
|
||||||
|
agentId,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
streamViewRef.current?.prepareForViewportChange();
|
||||||
|
},
|
||||||
|
[agentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMessageSent = useCallback(() => {
|
||||||
|
if (!agentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasUserMessage = streamItems.some(
|
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
|
||||||
(item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId,
|
agentId,
|
||||||
);
|
});
|
||||||
if (hasUserMessage && canFinalizePendingCreate) {
|
streamViewRef.current?.scrollToBottom("message-sent");
|
||||||
if (agentId && pendingCreate.images && pendingCreate.images.length > 0) {
|
}, [agentId]);
|
||||||
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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
@@ -947,53 +879,35 @@ function ChatAgentContent({
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
|
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
|
||||||
<AgentStreamView
|
<AgentStreamSection
|
||||||
ref={streamViewRef}
|
streamViewRef={streamViewRef}
|
||||||
agentId={effectiveAgent.id}
|
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
agentId={agentId}
|
||||||
agent={effectiveAgent}
|
agent={effectiveAgent}
|
||||||
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
|
pendingCreate={pendingCreate}
|
||||||
pendingPermissions={pendingPermissions}
|
shouldUseOptimisticStream={shouldUseOptimisticStream}
|
||||||
|
canFinalizePendingCreate={canFinalizePendingCreate}
|
||||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||||
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||||
/>
|
/>
|
||||||
</ReanimatedAnimated.View>
|
</ReanimatedAnimated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
|
<AgentComposerSection
|
||||||
<Composer
|
agentId={agentId}
|
||||||
agentId={agentId}
|
serverId={serverId}
|
||||||
serverId={serverId}
|
isPaneFocused={isPaneFocused}
|
||||||
isPaneFocused={isPaneFocused}
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||||
value={agentInputDraft.text}
|
archivedAt={agentState.archivedAt}
|
||||||
onChangeText={agentInputDraft.setText}
|
initialCwd={agentState.cwd ?? ""}
|
||||||
attachments={agentInputDraft.attachments}
|
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||||
onChangeAttachments={agentInputDraft.setAttachments}
|
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||||
cwd={agentInputDraft.cwd}
|
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||||
clearDraft={agentInputDraft.clear}
|
onAddImages={handleAddImagesCallback}
|
||||||
autoFocus={isPaneFocused}
|
onComposerHeightChange={handleComposerHeightChange}
|
||||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
onMessageSent={handleMessageSent}
|
||||||
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}
|
|
||||||
|
|
||||||
{viewState.tag === "ready" &&
|
{viewState.tag === "ready" &&
|
||||||
viewState.sync.status === "catching_up" &&
|
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({
|
function AgentSessionUnavailableState({
|
||||||
serverLabel,
|
serverLabel,
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SquarePen } from "lucide-react-native";
|
import { SquarePen } from "lucide-react-native";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
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 type { PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
@@ -17,15 +17,9 @@ function useDraftPanelDescriptor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DraftPanel() {
|
function DraftPanel() {
|
||||||
const {
|
const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } =
|
||||||
serverId,
|
usePaneContext();
|
||||||
workspaceId,
|
const { isInteractive } = usePaneFocus();
|
||||||
tabId,
|
|
||||||
target,
|
|
||||||
isPaneFocused,
|
|
||||||
openFileInWorkspace,
|
|
||||||
retargetCurrentTab,
|
|
||||||
} = usePaneContext();
|
|
||||||
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
invariant(target.kind === "draft", "DraftPanel requires draft target");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +28,7 @@ function DraftPanel() {
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
tabId={tabId}
|
tabId={tabId}
|
||||||
draftId={target.draftId}
|
draftId={target.draftId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isInteractive}
|
||||||
onOpenWorkspaceFile={({ filePath }) => {
|
onOpenWorkspaceFile={({ filePath }) => {
|
||||||
openFileInWorkspace(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 invariant from "tiny-invariant";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
|
|
||||||
@@ -6,7 +6,6 @@ export interface PaneContextValue {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
tabId: string;
|
tabId: string;
|
||||||
isPaneFocused: boolean;
|
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
openTab(target: WorkspaceTabTarget): void;
|
openTab(target: WorkspaceTabTarget): void;
|
||||||
closeCurrentTab(): void;
|
closeCurrentTab(): void;
|
||||||
@@ -14,7 +13,25 @@ export interface PaneContextValue {
|
|||||||
openFileInWorkspace(filePath: string): void;
|
openFileInWorkspace(filePath: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaneFocusContextValue {
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
|
isPaneFocused: boolean;
|
||||||
|
isInteractive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const PaneContext = createContext<PaneContextValue | null>(null);
|
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({
|
export function PaneProvider({
|
||||||
value,
|
value,
|
||||||
@@ -26,8 +43,24 @@ export function PaneProvider({
|
|||||||
return <PaneContext.Provider value={value}>{children}</PaneContext.Provider>;
|
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 {
|
export function usePaneContext(): PaneContextValue {
|
||||||
const value = useContext(PaneContext);
|
const value = useContext(PaneContext);
|
||||||
invariant(value, "PaneContext is required");
|
invariant(value, "PaneContext is required");
|
||||||
return value;
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Terminal } from "lucide-react-native";
|
import { Terminal } from "lucide-react-native";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { TerminalPane } from "@/components/terminal-pane";
|
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 type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||||
|
|
||||||
@@ -57,15 +58,28 @@ function useTerminalPanelDescriptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TerminalPanel() {
|
function TerminalPanel() {
|
||||||
const isFocused = useIsFocused();
|
const { serverId, workspaceId, target } = usePaneContext();
|
||||||
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
|
||||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
||||||
const workspaceDirectory = workspaceAuthority.ok
|
const workspaceDirectory = workspaceAuthority.ok
|
||||||
? workspaceAuthority.authority.workspaceDirectory
|
? workspaceAuthority.authority.workspaceDirectory
|
||||||
: null;
|
: 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");
|
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||||
|
|
||||||
if (!isFocused) {
|
if (!isWorkspaceFocused) {
|
||||||
return <View style={{ flex: 1 }} />;
|
return <View style={{ flex: 1 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +100,9 @@ function TerminalPanel() {
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
cwd={workspaceDirectory}
|
cwd={workspaceDirectory}
|
||||||
terminalId={target.terminalId}
|
terminalId={target.terminalId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
|
onOpenFileExplorer={handleOpenFileExplorer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,8 @@ import {
|
|||||||
useHostRuntimeIsConnected,
|
useHostRuntimeIsConnected,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||||
setActiveExplorerCheckout,
|
|
||||||
type ExplorerCheckoutContext,
|
|
||||||
} from "@/stores/explorer-checkout-context";
|
|
||||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
import type { Agent } from "@/contexts/session-context";
|
||||||
@@ -55,6 +52,8 @@ import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execut
|
|||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
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 { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||||
@@ -239,18 +238,22 @@ function DraftAgentScreenContent({
|
|||||||
statusControls,
|
statusControls,
|
||||||
} = composerState;
|
} = composerState;
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isExplorerOpen = usePanelStore((state) =>
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
|
||||||
const showMobileFileExplorer = usePanelStore((state) => state.showMobileFileExplorer);
|
);
|
||||||
const toggleMobileFileExplorer = usePanelStore((state) => state.toggleMobileFileExplorer);
|
const canOpenExplorerFromAgentView = usePanelStore(
|
||||||
|
(state) =>
|
||||||
|
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
|
||||||
|
);
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
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 closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||||
|
const toggleFileExplorerForCheckout = usePanelStore(
|
||||||
|
(state) => state.toggleFileExplorerForCheckout,
|
||||||
|
);
|
||||||
const activateExplorerTabForCheckout = usePanelStore(
|
const activateExplorerTabForCheckout = usePanelStore(
|
||||||
(state) => state.activateExplorerTabForCheckout,
|
(state) => state.activateExplorerTabForCheckout,
|
||||||
);
|
);
|
||||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
||||||
|
|
||||||
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
|
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
|
||||||
initialWorktreeMode,
|
initialWorktreeMode,
|
||||||
@@ -651,44 +654,42 @@ function DraftAgentScreenContent({
|
|||||||
if (!draftExplorerCheckout) {
|
if (!draftExplorerCheckout) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activateExplorerTabForCheckout(draftExplorerCheckout);
|
openFileExplorerForCheckout({
|
||||||
if (isMobile) {
|
isCompact: isMobile,
|
||||||
showMobileFileExplorer();
|
checkout: draftExplorerCheckout,
|
||||||
return;
|
});
|
||||||
}
|
}, [draftExplorerCheckout, isMobile, openFileExplorerForCheckout]);
|
||||||
openDesktopFileExplorer();
|
|
||||||
}, [
|
|
||||||
activateExplorerTabForCheckout,
|
|
||||||
draftExplorerCheckout,
|
|
||||||
isMobile,
|
|
||||||
openDesktopFileExplorer,
|
|
||||||
showMobileFileExplorer,
|
|
||||||
]);
|
|
||||||
const handleToggleExplorer = useCallback(() => {
|
const handleToggleExplorer = useCallback(() => {
|
||||||
if (!canOpenExplorer) {
|
if (!canOpenExplorer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isExplorerOpen) {
|
toggleFileExplorerForCheckout({
|
||||||
if (isMobile) {
|
isCompact: isMobile,
|
||||||
toggleMobileFileExplorer();
|
checkout: draftExplorerCheckout,
|
||||||
return;
|
});
|
||||||
}
|
}, [canOpenExplorer, draftExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
|
||||||
toggleDesktopFileExplorer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openExplorerForDraftCheckout();
|
|
||||||
}, [
|
|
||||||
canOpenExplorer,
|
|
||||||
isExplorerOpen,
|
|
||||||
isMobile,
|
|
||||||
openExplorerForDraftCheckout,
|
|
||||||
toggleDesktopFileExplorer,
|
|
||||||
toggleMobileFileExplorer,
|
|
||||||
]);
|
|
||||||
const explorerOpenGesture = useExplorerOpenGesture({
|
const explorerOpenGesture = useExplorerOpenGesture({
|
||||||
enabled: isMobile && mobileView === "agent" && canOpenExplorer,
|
enabled: isMobile && canOpenExplorerFromAgentView && canOpenExplorer,
|
||||||
onOpen: openExplorerForDraftCheckout,
|
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 hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0;
|
||||||
const workingDirSearchError =
|
const workingDirSearchError =
|
||||||
directorySuggestionsQuery.error instanceof Error
|
directorySuggestionsQuery.error instanceof Error
|
||||||
@@ -967,12 +968,6 @@ function DraftAgentScreenContent({
|
|||||||
router.replace(route as any);
|
router.replace(route as any);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
|
||||||
if (!isFocused) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActiveExplorerCheckout(draftExplorerCheckout);
|
|
||||||
}, [draftExplorerCheckout, isFocused, setActiveExplorerCheckout]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFocused || !draftExplorerCheckout) {
|
if (!isFocused || !draftExplorerCheckout) {
|
||||||
return;
|
return;
|
||||||
@@ -996,12 +991,6 @@ function DraftAgentScreenContent({
|
|||||||
isMobile,
|
isMobile,
|
||||||
showMobileAgent,
|
showMobileAgent,
|
||||||
]);
|
]);
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
setActiveExplorerCheckout(null);
|
|
||||||
};
|
|
||||||
}, [setActiveExplorerCheckout]);
|
|
||||||
|
|
||||||
if (daemons.length === 0) {
|
if (daemons.length === 0) {
|
||||||
return (
|
return (
|
||||||
<WelcomeScreen
|
<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 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 { getPanelRegistration } from "@/panels/panel-registry";
|
||||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
@@ -15,7 +20,6 @@ export interface BuildWorkspacePaneContentModelInput {
|
|||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
normalizedServerId: string;
|
normalizedServerId: string;
|
||||||
normalizedWorkspaceId: string;
|
normalizedWorkspaceId: string;
|
||||||
isPaneFocused: boolean;
|
|
||||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||||
onCloseCurrentTab: () => void;
|
onCloseCurrentTab: () => void;
|
||||||
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||||
@@ -26,7 +30,6 @@ export function buildWorkspacePaneContentModel({
|
|||||||
tab,
|
tab,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
isPaneFocused,
|
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
onCloseCurrentTab,
|
onCloseCurrentTab,
|
||||||
onRetargetCurrentTab,
|
onRetargetCurrentTab,
|
||||||
@@ -42,7 +45,6 @@ export function buildWorkspacePaneContentModel({
|
|||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
tabId: tab.tabId,
|
tabId: tab.tabId,
|
||||||
isPaneFocused,
|
|
||||||
target: tab.target,
|
target: tab.target,
|
||||||
openTab: onOpenTab,
|
openTab: onOpenTab,
|
||||||
closeCurrentTab: onCloseCurrentTab,
|
closeCurrentTab: onCloseCurrentTab,
|
||||||
@@ -54,14 +56,30 @@ export function buildWorkspacePaneContentModel({
|
|||||||
|
|
||||||
export interface WorkspacePaneContentProps {
|
export interface WorkspacePaneContentProps {
|
||||||
content: WorkspacePaneContentModel;
|
content: WorkspacePaneContentModel;
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
|
isPaneFocused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspacePaneContent({ content }: WorkspacePaneContentProps) {
|
export function WorkspacePaneContent({
|
||||||
|
content,
|
||||||
|
isWorkspaceFocused,
|
||||||
|
isPaneFocused,
|
||||||
|
}: WorkspacePaneContentProps) {
|
||||||
const { Component, key, paneContextValue } = content;
|
const { Component, key, paneContextValue } = content;
|
||||||
|
const paneFocusValue = useMemo(
|
||||||
|
() =>
|
||||||
|
createPaneFocusContextValue({
|
||||||
|
isWorkspaceFocused,
|
||||||
|
isPaneFocused,
|
||||||
|
}),
|
||||||
|
[isPaneFocused, isWorkspaceFocused],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PaneProvider value={paneContextValue}>
|
<PaneProvider value={paneContextValue}>
|
||||||
<Component key={key} />
|
<PaneFocusProvider value={paneFocusValue}>
|
||||||
|
<Component key={key} />
|
||||||
|
</PaneFocusProvider>
|
||||||
</PaneProvider>
|
</PaneProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,11 +48,8 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-bu
|
|||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||||
setActiveExplorerCheckout,
|
|
||||||
type ExplorerCheckoutContext,
|
|
||||||
} from "@/stores/explorer-checkout-context";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
@@ -513,11 +510,11 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
|||||||
interface MobileMountedTabSlotProps {
|
interface MobileMountedTabSlotProps {
|
||||||
tabDescriptor: WorkspaceTabDescriptor;
|
tabDescriptor: WorkspaceTabDescriptor;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
paneId: string | null;
|
paneId: string | null;
|
||||||
buildPaneContentModel: (input: {
|
buildPaneContentModel: (input: {
|
||||||
paneId: string | null;
|
paneId: string | null;
|
||||||
isPaneFocused: boolean;
|
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
}) => WorkspacePaneContentModel;
|
}) => WorkspacePaneContentModel;
|
||||||
}
|
}
|
||||||
@@ -525,6 +522,7 @@ interface MobileMountedTabSlotProps {
|
|||||||
const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
||||||
tabDescriptor,
|
tabDescriptor,
|
||||||
isVisible,
|
isVisible,
|
||||||
|
isWorkspaceFocused,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
paneId,
|
paneId,
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
@@ -533,15 +531,18 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
|||||||
() =>
|
() =>
|
||||||
buildPaneContentModel({
|
buildPaneContentModel({
|
||||||
paneId,
|
paneId,
|
||||||
isPaneFocused,
|
|
||||||
tab: tabDescriptor,
|
tab: tabDescriptor,
|
||||||
}),
|
}),
|
||||||
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
|
[buildPaneContentModel, paneId, tabDescriptor],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
|
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
|
||||||
<WorkspacePaneContent content={content} />
|
<WorkspacePaneContent
|
||||||
|
content={content}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
|
isPaneFocused={isPaneFocused}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -847,19 +848,19 @@ function WorkspaceScreenContent({
|
|||||||
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isExplorerOpen = usePanelStore((state) =>
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
|
||||||
const showMobileFileExplorer = usePanelStore((state) => state.showMobileFileExplorer);
|
);
|
||||||
const toggleMobileFileExplorer = usePanelStore((state) => state.toggleMobileFileExplorer);
|
const canOpenExplorerFromAgentView = usePanelStore(
|
||||||
const openDesktopFileExplorer = usePanelStore((state) => state.openDesktopFileExplorer);
|
(state) =>
|
||||||
const toggleDesktopFileExplorer = usePanelStore((state) => state.toggleDesktopFileExplorer);
|
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
|
||||||
const activateExplorerTabForCheckout = usePanelStore(
|
);
|
||||||
(state) => state.activateExplorerTabForCheckout,
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||||
|
const toggleFileExplorerForCheckout = usePanelStore(
|
||||||
|
(state) => state.toggleFileExplorerForCheckout,
|
||||||
);
|
);
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
|
|
||||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
||||||
|
|
||||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||||
if (!normalizedServerId || !workspaceDirectory) {
|
if (!normalizedServerId || !workspaceDirectory) {
|
||||||
return null;
|
return null;
|
||||||
@@ -871,51 +872,28 @@ function WorkspaceScreenContent({
|
|||||||
};
|
};
|
||||||
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isRouteFocused) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActiveExplorerCheckout(activeExplorerCheckout);
|
|
||||||
}, [activeExplorerCheckout, isRouteFocused, setActiveExplorerCheckout]);
|
|
||||||
|
|
||||||
const openExplorerForWorkspace = useCallback(() => {
|
const openExplorerForWorkspace = useCallback(() => {
|
||||||
if (!activeExplorerCheckout) {
|
if (!activeExplorerCheckout) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activateExplorerTabForCheckout(activeExplorerCheckout);
|
openFileExplorerForCheckout({
|
||||||
if (isMobile) {
|
isCompact: isMobile,
|
||||||
showMobileFileExplorer();
|
checkout: activeExplorerCheckout,
|
||||||
return;
|
});
|
||||||
}
|
}, [activeExplorerCheckout, isMobile, openFileExplorerForCheckout]);
|
||||||
openDesktopFileExplorer();
|
|
||||||
}, [
|
|
||||||
activateExplorerTabForCheckout,
|
|
||||||
activeExplorerCheckout,
|
|
||||||
isMobile,
|
|
||||||
openDesktopFileExplorer,
|
|
||||||
showMobileFileExplorer,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleToggleExplorer = useCallback(() => {
|
const handleToggleExplorer = useCallback(() => {
|
||||||
if (isExplorerOpen) {
|
if (!activeExplorerCheckout) {
|
||||||
if (isMobile) {
|
|
||||||
toggleMobileFileExplorer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toggleDesktopFileExplorer();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openExplorerForWorkspace();
|
toggleFileExplorerForCheckout({
|
||||||
}, [
|
isCompact: isMobile,
|
||||||
isExplorerOpen,
|
checkout: activeExplorerCheckout,
|
||||||
isMobile,
|
});
|
||||||
openExplorerForWorkspace,
|
}, [activeExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
|
||||||
toggleDesktopFileExplorer,
|
|
||||||
toggleMobileFileExplorer,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const explorerOpenGesture = useExplorerOpenGesture({
|
const explorerOpenGesture = useExplorerOpenGesture({
|
||||||
enabled: isMobile && mobileView === "agent",
|
enabled: isMobile && canOpenExplorerFromAgentView,
|
||||||
onOpen: openExplorerForWorkspace,
|
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(
|
const handleWorkspacePaneAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
if (!persistenceKey || !workspaceLayout) {
|
if (!persistenceKey || !workspaceLayout) {
|
||||||
@@ -1852,6 +1841,15 @@ function WorkspaceScreenContent({
|
|||||||
handle: handleWorkspacePaneAction,
|
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 activeTabDescriptor = activeTab?.descriptor ?? null;
|
||||||
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
|
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
|
||||||
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
|
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
|
||||||
@@ -1865,14 +1863,12 @@ function WorkspaceScreenContent({
|
|||||||
(input: {
|
(input: {
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
paneId?: string | null;
|
paneId?: string | null;
|
||||||
isPaneFocused: boolean;
|
|
||||||
focusPaneBeforeOpen?: boolean;
|
focusPaneBeforeOpen?: boolean;
|
||||||
}) =>
|
}) =>
|
||||||
buildWorkspacePaneContentModel({
|
buildWorkspacePaneContentModel({
|
||||||
tab: input.tab,
|
tab: input.tab,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
isPaneFocused: isRouteFocused && input.isPaneFocused,
|
|
||||||
onOpenTab: (target) => {
|
onOpenTab: (target) => {
|
||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
@@ -1908,7 +1904,6 @@ function WorkspaceScreenContent({
|
|||||||
[
|
[
|
||||||
handleCloseTabById,
|
handleCloseTabById,
|
||||||
handleOpenFileFromChat,
|
handleOpenFileFromChat,
|
||||||
isRouteFocused,
|
|
||||||
focusWorkspacePane,
|
focusWorkspacePane,
|
||||||
navigateToTabId,
|
navigateToTabId,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
@@ -1935,12 +1930,10 @@ function WorkspaceScreenContent({
|
|||||||
function buildMobilePaneContentModel(input: {
|
function buildMobilePaneContentModel(input: {
|
||||||
paneId: string | null;
|
paneId: string | null;
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
isPaneFocused: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return buildPaneContentModel({
|
return buildPaneContentModel({
|
||||||
tab: input.tab,
|
tab: input.tab,
|
||||||
paneId: input.paneId,
|
paneId: input.paneId,
|
||||||
isPaneFocused: input.isPaneFocused,
|
|
||||||
focusPaneBeforeOpen: false,
|
focusPaneBeforeOpen: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1983,7 +1976,8 @@ function WorkspaceScreenContent({
|
|||||||
key={tabId}
|
key={tabId}
|
||||||
tabDescriptor={tabDescriptor}
|
tabDescriptor={tabDescriptor}
|
||||||
isVisible={isRouteFocused && tabId === activeTabDescriptor.tabId}
|
isVisible={isRouteFocused && tabId === activeTabDescriptor.tabId}
|
||||||
isPaneFocused={isRouteFocused && tabId === activeTabDescriptor.tabId}
|
isWorkspaceFocused={isRouteFocused}
|
||||||
|
isPaneFocused={tabId === activeTabDescriptor.tabId}
|
||||||
paneId={focusedPaneId}
|
paneId={focusedPaneId}
|
||||||
buildPaneContentModel={buildMobilePaneContentModel}
|
buildPaneContentModel={buildMobilePaneContentModel}
|
||||||
/>
|
/>
|
||||||
@@ -1992,15 +1986,10 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const buildDesktopPaneContentModel = useCallback(
|
const buildDesktopPaneContentModel = useCallback(
|
||||||
function buildDesktopPaneContentModel(input: {
|
function buildDesktopPaneContentModel(input: { paneId: string; tab: WorkspaceTabDescriptor }) {
|
||||||
paneId: string;
|
|
||||||
tab: WorkspaceTabDescriptor;
|
|
||||||
isPaneFocused: boolean;
|
|
||||||
}) {
|
|
||||||
return buildPaneContentModel({
|
return buildPaneContentModel({
|
||||||
tab: input.tab,
|
tab: input.tab,
|
||||||
paneId: input.paneId,
|
paneId: input.paneId,
|
||||||
isPaneFocused: input.isPaneFocused,
|
|
||||||
focusPaneBeforeOpen: true,
|
focusPaneBeforeOpen: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -2404,6 +2393,7 @@ function WorkspaceScreenContent({
|
|||||||
workspaceKey={persistenceKey}
|
workspaceKey={persistenceKey}
|
||||||
normalizedServerId={normalizedServerId}
|
normalizedServerId={normalizedServerId}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
|
isWorkspaceFocused={isRouteFocused}
|
||||||
uiTabs={uiTabs}
|
uiTabs={uiTabs}
|
||||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
|
|||||||
@@ -3,21 +3,3 @@ export interface ExplorerCheckoutContext {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
isGit: boolean;
|
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 {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
@@ -16,15 +17,24 @@ interface ActivateWorkspaceSelectionOptions {
|
|||||||
historyMode?: "push" | "replace";
|
historyMode?: "push" | "replace";
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavigationRouteLike = {
|
type NavigationRouteParams = {
|
||||||
name?: unknown;
|
serverId?: string | string[];
|
||||||
params?: unknown;
|
workspaceId?: string | string[];
|
||||||
path?: unknown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NavigationRouteLike = {
|
||||||
|
params?: NavigationRouteParams | null;
|
||||||
|
path?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavigationWorkspaceRouteState =
|
||||||
|
| { kind: "workspace"; selection: ActiveWorkspaceSelection }
|
||||||
|
| { kind: "nonWorkspace" }
|
||||||
|
| { kind: "unknown" };
|
||||||
|
|
||||||
interface NavigationObserverRef {
|
interface NavigationObserverRef {
|
||||||
current: {
|
current: {
|
||||||
getCurrentRoute(): unknown;
|
getCurrentRoute(): NavigationRouteLike | null | undefined;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +93,7 @@ function writeBrowserWorkspaceUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractActiveWorkspaceFromRoute(
|
function extractActiveWorkspaceFromRoute(
|
||||||
route: NavigationRouteLike | undefined,
|
route: NavigationRouteLike | null | undefined,
|
||||||
): ActiveWorkspaceSelection | null {
|
): ActiveWorkspaceSelection | null {
|
||||||
if (!route) {
|
if (!route) {
|
||||||
return null;
|
return null;
|
||||||
@@ -96,17 +106,12 @@ function extractActiveWorkspaceFromRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const params =
|
const serverValue = Array.isArray(route.params?.serverId)
|
||||||
route.params && typeof route.params === "object"
|
? route.params.serverId[0]
|
||||||
? (route.params as {
|
: route.params?.serverId;
|
||||||
serverId?: string | string[];
|
const workspaceValue = Array.isArray(route.params?.workspaceId)
|
||||||
workspaceId?: string | string[];
|
? route.params.workspaceId[0]
|
||||||
})
|
: route.params?.workspaceId;
|
||||||
: null;
|
|
||||||
const serverValue = Array.isArray(params?.serverId) ? params?.serverId[0] : params?.serverId;
|
|
||||||
const workspaceValue = Array.isArray(params?.workspaceId)
|
|
||||||
? params?.workspaceId[0]
|
|
||||||
: params?.workspaceId;
|
|
||||||
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
|
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
|
||||||
const workspaceId =
|
const workspaceId =
|
||||||
typeof workspaceValue === "string"
|
typeof workspaceValue === "string"
|
||||||
@@ -120,20 +125,55 @@ function extractActiveWorkspaceFromRoute(
|
|||||||
return { serverId, workspaceId };
|
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) {
|
export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverRef) {
|
||||||
emitIfChanged(
|
emitIfChanged(getActiveWorkspaceForNavigationSync(navigationRef.current?.getCurrentRoute()));
|
||||||
extractActiveWorkspaceFromRoute(
|
|
||||||
navigationRef.current?.getCurrentRoute() as NavigationRouteLike | undefined,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activateNavigationWorkspaceSelection(
|
export function activateNavigationWorkspaceSelection(
|
||||||
next: ActiveWorkspaceSelection,
|
next: ActiveWorkspaceSelection,
|
||||||
options: ActivateWorkspaceSelectionOptions = {},
|
options: ActivateWorkspaceSelectionOptions = {},
|
||||||
) {
|
) {
|
||||||
emitIfChanged(next);
|
|
||||||
writeBrowserWorkspaceUrl(next, options);
|
writeBrowserWorkspaceUrl(next, options);
|
||||||
|
emitIfChanged(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||||
@@ -161,3 +201,45 @@ export function addBrowserActiveWorkspaceLocationListener(): () => void {
|
|||||||
export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
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 {
|
import {
|
||||||
buildExplorerCheckoutKey,
|
buildExplorerCheckoutKey,
|
||||||
resolveExplorerTabForCheckout,
|
resolveExplorerTabForCheckout,
|
||||||
} from "@/stores/explorer-tab-memory";
|
} 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", () => {
|
describe("panel-store explorer tab resolution", () => {
|
||||||
const serverId = "server-1";
|
const serverId = "server-1";
|
||||||
@@ -72,3 +128,120 @@ describe("panel-store explorer tab resolution", () => {
|
|||||||
).toBe("files");
|
).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,
|
resolveExplorerTabForCheckout,
|
||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "./explorer-tab-memory";
|
} from "./explorer-tab-memory";
|
||||||
import {
|
import { type ExplorerCheckoutContext } from "./explorer-checkout-context";
|
||||||
getActiveExplorerCheckout,
|
|
||||||
type ExplorerCheckoutContext,
|
|
||||||
} from "./explorer-checkout-context";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
export type { ExplorerTab } from "./explorer-tab-memory";
|
export type { ExplorerTab } from "./explorer-tab-memory";
|
||||||
export type { ExplorerCheckoutContext } from "./explorer-checkout-context";
|
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
|
* This makes impossible states unrepresentable - you cannot have both
|
||||||
* sidebars open at the same time on mobile.
|
* 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.
|
* 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 MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
|
||||||
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
|
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
|
// Mobile: which panel is currently shown
|
||||||
mobileView: MobilePanelView;
|
mobileView: MobilePanelView;
|
||||||
|
|
||||||
@@ -76,16 +86,16 @@ interface PanelState {
|
|||||||
toggleFocusMode: () => void;
|
toggleFocusMode: () => void;
|
||||||
showMobileAgent: () => void;
|
showMobileAgent: () => void;
|
||||||
showMobileAgentList: () => void;
|
showMobileAgentList: () => void;
|
||||||
showMobileFileExplorer: () => void;
|
|
||||||
toggleMobileAgentList: () => void;
|
toggleMobileAgentList: () => void;
|
||||||
toggleMobileFileExplorer: () => void;
|
|
||||||
openDesktopAgentList: () => void;
|
openDesktopAgentList: () => void;
|
||||||
closeDesktopAgentList: () => void;
|
closeDesktopAgentList: () => void;
|
||||||
toggleDesktopAgentList: () => void;
|
toggleDesktopAgentList: () => void;
|
||||||
openDesktopFileExplorer: () => void;
|
|
||||||
closeDesktopFileExplorer: () => void;
|
closeDesktopFileExplorer: () => void;
|
||||||
toggleDesktopFileExplorer: () => void;
|
openAgentListForLayout: (input: PanelLayoutInput) => void;
|
||||||
toggleDesktopSidebars: () => void;
|
closeAgentListForLayout: (input: PanelLayoutInput) => void;
|
||||||
|
toggleAgentListForLayout: (input: PanelLayoutInput) => void;
|
||||||
|
openFileExplorerForCheckout: (input: ExplorerPanelIntent) => void;
|
||||||
|
toggleFileExplorerForCheckout: (input: ExplorerPanelIntent) => void;
|
||||||
|
|
||||||
// File explorer settings actions
|
// File explorer settings actions
|
||||||
setExplorerTab: (tab: ExplorerTab) => void;
|
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);
|
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExplorerTabFromCheckout(
|
function resolveExplorerTabFromCheckout(state: PanelState, checkout: ExplorerCheckoutContext) {
|
||||||
state: PanelState,
|
|
||||||
checkout: ExplorerCheckoutContext | null,
|
|
||||||
): ExplorerTab | null {
|
|
||||||
if (!checkout) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return resolveExplorerTabForCheckout({
|
return resolveExplorerTabForCheckout({
|
||||||
serverId: checkout.serverId,
|
serverId: checkout.serverId,
|
||||||
cwd: checkout.cwd,
|
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;
|
const DEFAULT_DESKTOP_OPEN = isWeb;
|
||||||
|
|
||||||
export const usePanelStore = create<PanelState>()(
|
export const usePanelStore = create<PanelState>()(
|
||||||
@@ -179,32 +224,11 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
return { mobileView: "agent-list" as const };
|
return { mobileView: "agent-list" as const };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
showMobileFileExplorer: () =>
|
|
||||||
set((state) => {
|
|
||||||
const resolvedTab = resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout());
|
|
||||||
return {
|
|
||||||
mobileView: "file-explorer" as const,
|
|
||||||
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
toggleMobileAgentList: () =>
|
toggleMobileAgentList: () =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list",
|
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: () =>
|
openDesktopAgentList: () =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.desktop.agentListOpen) {
|
if (state.desktop.agentListOpen) {
|
||||||
@@ -226,15 +250,6 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
|
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: () =>
|
closeDesktopFileExplorer: () =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.desktop.fileExplorerOpen) {
|
if (!state.desktop.fileExplorerOpen) {
|
||||||
@@ -243,31 +258,51 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
|
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
toggleDesktopFileExplorer: () =>
|
openAgentListForLayout: ({ isCompact }) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const willOpen = !state.desktop.fileExplorerOpen;
|
if (isCompact) {
|
||||||
const resolvedTab = willOpen
|
return state.mobileView === "agent-list"
|
||||||
? resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout())
|
? state
|
||||||
: null;
|
: { 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 {
|
return {
|
||||||
desktop: { ...state.desktop, fileExplorerOpen: willOpen },
|
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
|
||||||
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
toggleDesktopSidebars: () =>
|
openFileExplorerForCheckout: (input) =>
|
||||||
|
set((state) => buildOpenFileExplorerPatch(state, input)),
|
||||||
|
|
||||||
|
toggleFileExplorerForCheckout: (input) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const anyOpen = state.desktop.agentListOpen || state.desktop.fileExplorerOpen;
|
const isOpen = selectIsFileExplorerOpen(state, input);
|
||||||
if (anyOpen) {
|
if (!isOpen) {
|
||||||
return {
|
return buildOpenFileExplorerPatch(state, input);
|
||||||
desktop: { ...state.desktop, agentListOpen: false, fileExplorerOpen: false },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const resolvedTab = resolveExplorerTabFromCheckout(state, getActiveExplorerCheckout());
|
if (input.isCompact) {
|
||||||
return {
|
return { mobileView: "agent" as const };
|
||||||
desktop: { ...state.desktop, agentListOpen: true, fileExplorerOpen: true },
|
}
|
||||||
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setExplorerTab: (tab) => set({ explorerTab: tab }),
|
setExplorerTab: (tab) => set({ explorerTab: tab }),
|
||||||
@@ -434,54 +469,48 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
* @param isMobile - Whether the current breakpoint is mobile
|
* @param isMobile - Whether the current breakpoint is mobile
|
||||||
*/
|
*/
|
||||||
export function usePanelState(isMobile: boolean) {
|
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 {
|
return {
|
||||||
isAgentListOpen: store.desktop.agentListOpen,
|
isAgentListOpen,
|
||||||
isFileExplorerOpen: store.desktop.fileExplorerOpen,
|
isFileExplorerOpen,
|
||||||
openAgentList: store.openDesktopAgentList,
|
openAgentList: () => openAgentListForLayout({ isCompact: isMobile }),
|
||||||
openFileExplorer: store.openDesktopFileExplorer,
|
closeAgentList: () => closeAgentListForLayout({ isCompact: isMobile }),
|
||||||
closeAgentList: store.closeDesktopAgentList,
|
closeFileExplorer: isMobile ? showMobileAgent : closeDesktopFileExplorer,
|
||||||
closeFileExplorer: store.closeDesktopFileExplorer,
|
toggleAgentList: () => toggleAgentListForLayout({ isCompact: isMobile }),
|
||||||
toggleAgentList: store.toggleDesktopAgentList,
|
explorerTab,
|
||||||
toggleFileExplorer: store.toggleDesktopFileExplorer,
|
explorerTabByCheckout,
|
||||||
// Explorer settings
|
explorerWidth,
|
||||||
explorerTab: store.explorerTab,
|
explorerSortOption,
|
||||||
explorerTabByCheckout: store.explorerTabByCheckout,
|
explorerFilesSplitRatio,
|
||||||
explorerWidth: store.explorerWidth,
|
setExplorerTab,
|
||||||
explorerSortOption: store.explorerSortOption,
|
setExplorerTabForCheckout,
|
||||||
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
activateExplorerTabForCheckout,
|
||||||
setExplorerTab: store.setExplorerTab,
|
setExplorerWidth,
|
||||||
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
|
setExplorerSortOption,
|
||||||
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
|
setExplorerFilesSplitRatio,
|
||||||
setExplorerWidth: store.setExplorerWidth,
|
|
||||||
setExplorerSortOption: store.setExplorerSortOption,
|
|
||||||
setExplorerFilesSplitRatio: store.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({
|
expect(result).toEqual({
|
||||||
kind: "workspace_link",
|
kind: "workspace_link",
|
||||||
workspace: flattenedWorkspace,
|
workspace: flattenedWorkspace,
|
||||||
selected: false,
|
|
||||||
chevron: null,
|
chevron: null,
|
||||||
trailingAction: "none",
|
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({
|
const flattenedWorkspace = workspace({
|
||||||
serverId: "srv-2",
|
serverId: "srv-2",
|
||||||
workspaceId: "ws-non-git",
|
workspaceId: "ws-non-git",
|
||||||
@@ -73,17 +72,15 @@ describe("buildSidebarProjectRowModel", () => {
|
|||||||
workspaces: [flattenedWorkspace],
|
workspaces: [flattenedWorkspace],
|
||||||
}),
|
}),
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
serverId: "srv-2",
|
|
||||||
activeWorkspaceSelection: {
|
|
||||||
serverId: "srv-2",
|
|
||||||
workspaceId: "ws-non-git",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
kind: "workspace_link",
|
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", () => {
|
it("keeps single-workspace git projects as sections with the new worktree action", () => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type {
|
|||||||
export interface SidebarProjectWorkspaceLinkRowModel {
|
export interface SidebarProjectWorkspaceLinkRowModel {
|
||||||
kind: "workspace_link";
|
kind: "workspace_link";
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
selected: boolean;
|
|
||||||
chevron: null;
|
chevron: null;
|
||||||
trailingAction: "new_worktree" | "none";
|
trailingAction: "new_worktree" | "none";
|
||||||
}
|
}
|
||||||
@@ -28,23 +27,15 @@ export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean
|
|||||||
export function buildSidebarProjectRowModel(input: {
|
export function buildSidebarProjectRowModel(input: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
serverId?: string | null;
|
|
||||||
activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null;
|
|
||||||
}): SidebarProjectRowModel {
|
}): SidebarProjectRowModel {
|
||||||
const flattenedWorkspace = isSidebarProjectFlattened(input.project)
|
const flattenedWorkspace = isSidebarProjectFlattened(input.project)
|
||||||
? (input.project.workspaces[0] ?? null)
|
? (input.project.workspaces[0] ?? null)
|
||||||
: null;
|
: null;
|
||||||
const selected =
|
|
||||||
flattenedWorkspace !== null &&
|
|
||||||
Boolean(input.serverId) &&
|
|
||||||
input.activeWorkspaceSelection?.serverId === input.serverId &&
|
|
||||||
input.activeWorkspaceSelection?.workspaceId === flattenedWorkspace.workspaceId;
|
|
||||||
|
|
||||||
if (flattenedWorkspace) {
|
if (flattenedWorkspace) {
|
||||||
return {
|
return {
|
||||||
kind: "workspace_link",
|
kind: "workspace_link",
|
||||||
workspace: flattenedWorkspace,
|
workspace: flattenedWorkspace,
|
||||||
selected,
|
|
||||||
chevron: null,
|
chevron: null,
|
||||||
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
|
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 {
|
import {
|
||||||
buildWorkspaceArchiveRedirectRoute,
|
buildWorkspaceArchiveRedirectRoute,
|
||||||
resolveWorkspaceArchiveRedirectWorkspaceId,
|
resolveWorkspaceArchiveRedirectWorkspaceId,
|
||||||
} from "@/utils/workspace-archive-navigation";
|
} from "@/utils/workspace-archive-navigation";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
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(
|
function workspace(
|
||||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||||
@@ -76,3 +93,53 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
|||||||
).toBe("/h/server-1");
|
).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