feat: terminal reattach, explorer tab memory, worktree terminals
This commit is contained in:
@@ -103,7 +103,7 @@ export function AgentStreamView({
|
||||
const streamItemCountRef = useRef(0);
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
@@ -154,7 +154,12 @@ export function AgentStreamView({
|
||||
requestFilePreview(agentId, normalized.file);
|
||||
}
|
||||
|
||||
setExplorerTab("files");
|
||||
setExplorerTabForCheckout({
|
||||
serverId: resolvedServerId,
|
||||
cwd: agent.cwd,
|
||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||
tab: "files",
|
||||
});
|
||||
openFileExplorer();
|
||||
},
|
||||
[
|
||||
@@ -163,7 +168,7 @@ export function AgentStreamView({
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
selectExplorerEntry,
|
||||
setExplorerTab,
|
||||
setExplorerTabForCheckout,
|
||||
openFileExplorer,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { TerminalPane } from "./terminal-pane";
|
||||
@@ -40,9 +39,10 @@ interface ExplorerSidebarProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps) {
|
||||
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile =
|
||||
@@ -52,7 +52,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
@@ -102,9 +102,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTab(tab);
|
||||
setExplorerTabForCheckout({ serverId, cwd, isGit, tab });
|
||||
},
|
||||
[setExplorerTab]
|
||||
[cwd, isGit, serverId, setExplorerTabForCheckout]
|
||||
);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
@@ -268,6 +268,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
isGit={isGit}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -300,6 +301,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -313,6 +315,7 @@ interface SidebarContentProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
@@ -323,19 +326,12 @@ function SidebarContent({
|
||||
serverId,
|
||||
agentId,
|
||||
cwd,
|
||||
isGit,
|
||||
isMobile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { status } = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const isGit = status?.isGit ?? false;
|
||||
const hasResolvedCheckoutStatus = status !== null;
|
||||
|
||||
// Switch to Files tab if Changes tab is hidden and user was on it
|
||||
useEffect(() => {
|
||||
if (hasResolvedCheckoutStatus && !isGit && activeTab === "changes") {
|
||||
onTabPress("files");
|
||||
}
|
||||
}, [hasResolvedCheckoutStatus, isGit, activeTab, onTabPress]);
|
||||
const resolvedTab: ExplorerTab =
|
||||
!isGit && activeTab === "changes" ? "files" : activeTab;
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -345,13 +341,13 @@ function SidebarContent({
|
||||
{isGit && (
|
||||
<Pressable
|
||||
testID="explorer-tab-changes"
|
||||
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
|
||||
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
|
||||
onPress={() => onTabPress("changes")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "changes" && styles.tabTextActive,
|
||||
resolvedTab === "changes" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Changes
|
||||
@@ -360,13 +356,13 @@ function SidebarContent({
|
||||
)}
|
||||
<Pressable
|
||||
testID="explorer-tab-files"
|
||||
style={[styles.tab, activeTab === "files" && styles.tabActive]}
|
||||
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
|
||||
onPress={() => onTabPress("files")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "files" && styles.tabTextActive,
|
||||
resolvedTab === "files" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Files
|
||||
@@ -374,13 +370,13 @@ function SidebarContent({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="explorer-tab-terminals"
|
||||
style={[styles.tab, activeTab === "terminals" && styles.tabActive]}
|
||||
style={[styles.tab, resolvedTab === "terminals" && styles.tabActive]}
|
||||
onPress={() => onTabPress("terminals")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "terminals" && styles.tabTextActive,
|
||||
resolvedTab === "terminals" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Terminals
|
||||
@@ -398,13 +394,13 @@ function SidebarContent({
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{activeTab === "changes" && (
|
||||
{resolvedTab === "changes" && (
|
||||
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
||||
)}
|
||||
{activeTab === "files" && (
|
||||
{resolvedTab === "files" && (
|
||||
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
||||
)}
|
||||
{activeTab === "terminals" && (
|
||||
{resolvedTab === "terminals" && (
|
||||
<TerminalPane serverId={serverId} cwd={cwd} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -5,92 +5,8 @@ import { getIsTauri } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
type ShortcutHelpRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
keys: ShortcutKey[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type ShortcutHelpSection = {
|
||||
title: string;
|
||||
rows: ShortcutHelpRow[];
|
||||
};
|
||||
|
||||
function buildShortcutHelpSections(input: {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
}): ShortcutHelpSection[] {
|
||||
const leftSidebarKeys: ShortcutKey[] = input.isMac ? ["mod", "B"] : ["mod", "."];
|
||||
const newAgentKeys: ShortcutKey[] = input.isTauri
|
||||
? ["mod", "N"]
|
||||
: ["mod", "alt", "N"];
|
||||
const quickAgentKeys: ShortcutKey[] = input.isTauri
|
||||
? ["mod", "1-9"]
|
||||
: ["alt", "1-9"];
|
||||
|
||||
return [
|
||||
{
|
||||
title: "Global",
|
||||
rows: [
|
||||
{
|
||||
id: "show-shortcuts",
|
||||
label: "Show keyboard shortcuts",
|
||||
keys: ["?"],
|
||||
note: "Available when focus is not in a text field or terminal.",
|
||||
},
|
||||
{
|
||||
id: "toggle-left-sidebar",
|
||||
label: "Toggle left sidebar",
|
||||
keys: leftSidebarKeys,
|
||||
},
|
||||
{
|
||||
id: "toggle-right-sidebar",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
{
|
||||
id: "toggle-command-center",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
{
|
||||
id: "new-agent",
|
||||
label: "Create new agent",
|
||||
keys: newAgentKeys,
|
||||
},
|
||||
{
|
||||
id: "quick-open-agent",
|
||||
label: "Open sidebar agent shortcut",
|
||||
keys: quickAgentKeys,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Agent Input",
|
||||
rows: [
|
||||
{
|
||||
id: "dictation-toggle",
|
||||
label: "Start/stop dictation",
|
||||
keys: ["mod", "D"],
|
||||
},
|
||||
{
|
||||
id: "voice-toggle",
|
||||
label: "Toggle voice mode",
|
||||
keys: ["mod", "shift", "D"],
|
||||
},
|
||||
{
|
||||
id: "voice-mute-toggle",
|
||||
label: "Mute/unmute voice mode",
|
||||
keys: ["Space"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
||||
@@ -99,7 +15,7 @@ export function KeyboardShortcutsDialog() {
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isTauri = getIsTauri();
|
||||
const sections = useMemo(
|
||||
() => buildShortcutHelpSections({ isMac, isTauri }),
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
|
||||
[isMac, isTauri]
|
||||
);
|
||||
|
||||
|
||||
@@ -365,6 +365,7 @@ export default function TerminalEmulator({
|
||||
fitAndEmitResize();
|
||||
});
|
||||
resizeObserver.observe(root);
|
||||
resizeObserver.observe(host);
|
||||
|
||||
const windowResizeHandler = () => fitAndEmitResize();
|
||||
window.addEventListener("resize", windowResizeHandler);
|
||||
@@ -378,6 +379,25 @@ export default function TerminalEmulator({
|
||||
fitAndEmitResize();
|
||||
}, 250);
|
||||
|
||||
const fitTimeoutHandles = [0, 16, 48, 120, 250, 500, 1_000, 2_000].map((delay) =>
|
||||
window.setTimeout(() => {
|
||||
fitAndEmitResize(true);
|
||||
}, delay)
|
||||
);
|
||||
|
||||
const fontSet = document.fonts;
|
||||
const fontReadyHandler = () => {
|
||||
fitAndEmitResize(true);
|
||||
};
|
||||
fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
|
||||
void fontSet?.ready
|
||||
.then(() => {
|
||||
fitAndEmitResize(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// no-op
|
||||
});
|
||||
|
||||
window.setTimeout(() => fitAndEmitResize(true), 0);
|
||||
|
||||
if (outputText.length > 0) {
|
||||
@@ -391,6 +411,10 @@ export default function TerminalEmulator({
|
||||
window.removeEventListener("resize", windowResizeHandler);
|
||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
||||
window.clearInterval(fitInterval);
|
||||
for (const handle of fitTimeoutHandles) {
|
||||
window.clearTimeout(handle);
|
||||
}
|
||||
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
|
||||
root.removeEventListener("touchstart", rootTouchStartHandler);
|
||||
root.removeEventListener("touchmove", rootTouchMoveHandler);
|
||||
root.removeEventListener("touchend", rootTouchEndHandler);
|
||||
@@ -478,12 +502,10 @@ export default function TerminalEmulator({
|
||||
ref={rootRef}
|
||||
data-testid={testId}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
backgroundColor,
|
||||
|
||||
@@ -18,9 +18,17 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
hasPendingTerminalModifiers,
|
||||
mapTerminalDataToKey,
|
||||
normalizeTerminalTransportKey,
|
||||
resolvePendingModifierDataInput,
|
||||
} from "@/utils/terminal-keys";
|
||||
import {
|
||||
getTerminalResumeOffset,
|
||||
getTerminalAttachRetryDelayMs,
|
||||
isTerminalAttachRetryableError,
|
||||
updateTerminalResumeOffset,
|
||||
waitForDuration,
|
||||
withPromiseTimeout,
|
||||
} from "@/utils/terminal-attach";
|
||||
import TerminalEmulator from "./terminal-emulator";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
@@ -30,6 +38,8 @@ interface TerminalPaneProps {
|
||||
|
||||
const MAX_OUTPUT_CHARS = 200_000;
|
||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||
const TERMINAL_ATTACH_MAX_ATTEMPTS = 4;
|
||||
const TERMINAL_ATTACH_TIMEOUT_MS = 12_000;
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
ctrl: "Ctrl",
|
||||
@@ -61,8 +71,8 @@ const EMPTY_MODIFIERS: ModifierState = {
|
||||
alt: false,
|
||||
};
|
||||
|
||||
function terminalScopeKey(serverId: string, cwd: string): string {
|
||||
return `${serverId}:${cwd}`;
|
||||
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
||||
return `${input.serverId}:${input.cwd}`;
|
||||
}
|
||||
|
||||
function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) {
|
||||
@@ -99,9 +109,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey(serverId, cwd), [serverId, cwd]);
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const resumeOffsetByTerminalIdRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
||||
const [outputByTerminalId, setOutputByTerminalId] = useState<Map<string, string>>(
|
||||
@@ -111,6 +122,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
} | null>(null);
|
||||
const [attachGeneration, setAttachGeneration] = useState(0);
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
@@ -121,11 +133,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
);
|
||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||
const activeStreamRef = useRef<{
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
} | null>(activeStream);
|
||||
|
||||
useEffect(() => {
|
||||
selectedTerminalIdRef.current = selectedTerminalId;
|
||||
}, [selectedTerminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
activeStreamRef.current = activeStream;
|
||||
}, [activeStream]);
|
||||
|
||||
const clearHoverOutTimeout = useCallback(() => {
|
||||
if (!hoverOutTimeoutRef.current) {
|
||||
return;
|
||||
@@ -201,14 +221,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTerminalIdRef.current === exitedTerminalId) {
|
||||
setSelectedTerminalId((current) =>
|
||||
current === exitedTerminalId ? null : current
|
||||
);
|
||||
const activeStreamForTerminal = activeStreamRef.current;
|
||||
if (
|
||||
selectedTerminalIdRef.current === exitedTerminalId &&
|
||||
activeStreamForTerminal?.terminalId === exitedTerminalId &&
|
||||
activeStreamForTerminal.streamId === message.payload.streamId
|
||||
) {
|
||||
setActiveStream((current) =>
|
||||
current?.terminalId === exitedTerminalId ? null : current
|
||||
);
|
||||
setIsAttaching(false);
|
||||
setStreamError("Terminal stream ended. Reconnecting…");
|
||||
setIsAttaching(true);
|
||||
setAttachGeneration((current) => current + 1);
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}
|
||||
|
||||
@@ -253,6 +277,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
},
|
||||
onSuccess: (_, terminalId) => {
|
||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||
resumeOffsetByTerminalIdRef.current.delete(terminalId);
|
||||
if (selectedTerminalIdRef.current === terminalId) {
|
||||
setSelectedTerminalId((current) =>
|
||||
current === terminalId ? null : current
|
||||
@@ -310,6 +335,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}
|
||||
}, [scopeKey, terminals, selectedTerminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminals.length === 0) {
|
||||
resumeOffsetByTerminalIdRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalIdSet = new Set(terminals.map((terminal) => terminal.id));
|
||||
for (const terminalId of Array.from(resumeOffsetByTerminalIdRef.current.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
resumeOffsetByTerminalIdRef.current.delete(terminalId);
|
||||
}
|
||||
}
|
||||
}, [terminals]);
|
||||
|
||||
const appendOutput = useCallback((terminalId: string, text: string) => {
|
||||
if (!text) {
|
||||
return;
|
||||
@@ -343,40 +382,102 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
|
||||
setIsAttaching(true);
|
||||
setStreamError(null);
|
||||
lastReportedSizeRef.current = null;
|
||||
|
||||
const attach = async () => {
|
||||
try {
|
||||
const attachPayload = await client.attachTerminalStream(terminalId);
|
||||
if (isCancelled) {
|
||||
if (typeof attachPayload.streamId === "number") {
|
||||
void client.detachTerminalStream(attachPayload.streamId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
let lastErrorMessage = "Unable to attach terminal stream";
|
||||
const totalAttempts = TERMINAL_ATTACH_MAX_ATTEMPTS;
|
||||
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
|
||||
try {
|
||||
const lastSize = lastReportedSizeRef.current;
|
||||
const resumeOffset = getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
||||
});
|
||||
const attachPayload = await withPromiseTimeout({
|
||||
promise: client.attachTerminalStream(terminalId, {
|
||||
...(resumeOffset !== undefined ? { resumeOffset } : {}),
|
||||
...(lastSize
|
||||
? {
|
||||
rows: lastSize.rows,
|
||||
cols: lastSize.cols,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
timeoutMs: TERMINAL_ATTACH_TIMEOUT_MS,
|
||||
timeoutMessage: "Timed out attaching terminal stream",
|
||||
});
|
||||
if (isCancelled) {
|
||||
if (typeof attachPayload.streamId === "number") {
|
||||
void client.detachTerminalStream(attachPayload.streamId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
|
||||
setStreamError(attachPayload.error ?? "Unable to attach terminal stream");
|
||||
setActiveStream(null);
|
||||
return;
|
||||
}
|
||||
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
|
||||
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
|
||||
const hasRemainingAttempts = attempt < totalAttempts - 1;
|
||||
if (
|
||||
hasRemainingAttempts &&
|
||||
isTerminalAttachRetryableError({ message: lastErrorMessage })
|
||||
) {
|
||||
await waitForDuration({
|
||||
durationMs: getTerminalAttachRetryDelayMs({ attempt }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
setStreamError(lastErrorMessage);
|
||||
setActiveStream(null);
|
||||
return;
|
||||
}
|
||||
|
||||
streamId = attachPayload.streamId;
|
||||
decoder = new TextDecoder();
|
||||
setActiveStream({ terminalId, streamId });
|
||||
streamId = attachPayload.streamId;
|
||||
updateTerminalResumeOffset({
|
||||
terminalId,
|
||||
offset: attachPayload.currentOffset,
|
||||
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
||||
});
|
||||
decoder = new TextDecoder();
|
||||
setActiveStream({ terminalId, streamId });
|
||||
setStreamError(null);
|
||||
|
||||
unsubscribe = client.onTerminalStreamData(streamId, (chunk) => {
|
||||
if (isCancelled) {
|
||||
unsubscribe = client.onTerminalStreamData(streamId, (chunk) => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
updateTerminalResumeOffset({
|
||||
terminalId,
|
||||
offset: chunk.endOffset,
|
||||
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
||||
});
|
||||
const text = decoder?.decode(chunk.data, { stream: true }) ?? "";
|
||||
appendOutput(terminalId, text);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
lastErrorMessage =
|
||||
error instanceof Error ? error.message : "Unable to attach terminal stream";
|
||||
const hasRemainingAttempts = attempt < totalAttempts - 1;
|
||||
if (
|
||||
hasRemainingAttempts &&
|
||||
isTerminalAttachRetryableError({ message: lastErrorMessage })
|
||||
) {
|
||||
await waitForDuration({
|
||||
durationMs: getTerminalAttachRetryDelayMs({ attempt }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
setStreamError(lastErrorMessage);
|
||||
setActiveStream(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const text = decoder?.decode(chunk.data, { stream: true }) ?? "";
|
||||
appendOutput(terminalId, text);
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
setStreamError(
|
||||
error instanceof Error ? error.message : "Unable to attach terminal stream"
|
||||
);
|
||||
setStreamError(lastErrorMessage);
|
||||
setActiveStream(null);
|
||||
}
|
||||
} finally {
|
||||
@@ -401,8 +502,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
if (streamId !== null) {
|
||||
void client.detachTerminalStream(streamId).catch(() => {});
|
||||
}
|
||||
setActiveStream((current) =>
|
||||
current?.terminalId === terminalId ? null : current
|
||||
);
|
||||
setIsAttaching(false);
|
||||
};
|
||||
}, [appendOutput, client, isConnected, selectedTerminalId]);
|
||||
}, [appendOutput, attachGeneration, client, isConnected, selectedTerminalId]);
|
||||
|
||||
const activeStreamId =
|
||||
activeStream && activeStream.terminalId === selectedTerminalId
|
||||
@@ -477,21 +582,27 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}
|
||||
|
||||
if (hasPendingTerminalModifiers(modifiers)) {
|
||||
const mappedKey = mapTerminalDataToKey(data);
|
||||
if (
|
||||
mappedKey &&
|
||||
sendTerminalKey(
|
||||
{
|
||||
key: mappedKey,
|
||||
const pendingResolution = resolvePendingModifierDataInput({
|
||||
data,
|
||||
pendingModifiers: modifiers,
|
||||
});
|
||||
if (pendingResolution.mode === "key") {
|
||||
if (
|
||||
sendTerminalKey({
|
||||
key: pendingResolution.key,
|
||||
ctrl: modifiers.ctrl,
|
||||
shift: modifiers.shift,
|
||||
alt: modifiers.alt,
|
||||
meta: false,
|
||||
}
|
||||
)
|
||||
) {
|
||||
})
|
||||
) {
|
||||
clearPendingModifiers();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingResolution.clearPendingModifiers) {
|
||||
clearPendingModifiers();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,9 +855,8 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
)}
|
||||
|
||||
{isAttaching ? (
|
||||
<View style={styles.attachOverlay}>
|
||||
<View style={styles.attachOverlay} testID="terminal-attach-loading">
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.attachOverlayText}>Attaching terminal…</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -912,22 +1022,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
},
|
||||
attachOverlay: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[3],
|
||||
right: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
attachOverlayText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.16)",
|
||||
},
|
||||
errorRow: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/utils/new-agent-routing";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
parseHostAgentDraftRouteFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
@@ -33,12 +34,17 @@ function resolveSelectedOrRouteAgentKey(input: {
|
||||
selectedAgentId?: string;
|
||||
pathname: string;
|
||||
}): string | null {
|
||||
const DRAFT_AGENT_ID = "__new_agent__";
|
||||
if (input.selectedAgentId) {
|
||||
return input.selectedAgentId;
|
||||
}
|
||||
const route = parseHostAgentRouteFromPathname(input.pathname);
|
||||
if (!route) {
|
||||
return null;
|
||||
const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname);
|
||||
if (!draftRoute) {
|
||||
return null;
|
||||
}
|
||||
return `${draftRoute.serverId}:${DRAFT_AGENT_ID}`;
|
||||
}
|
||||
return `${route.serverId}:${route.agentId}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
resolveKeyboardShortcut,
|
||||
type KeyboardShortcutContext,
|
||||
} from "./keyboard-shortcuts";
|
||||
@@ -148,6 +149,21 @@ describe("keyboard-shortcuts", () => {
|
||||
expect(match?.payload).toEqual({ kind: "voice-mute-toggle" });
|
||||
});
|
||||
|
||||
it("lets Escape continue to local handlers while routing dictation cancel", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "Escape",
|
||||
code: "Escape",
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("message-input.action");
|
||||
expect(match?.payload).toEqual({ kind: "dictation-cancel" });
|
||||
expect(match?.preventDefault).toBe(false);
|
||||
expect(match?.stopPropagation).toBe(false);
|
||||
});
|
||||
|
||||
it("parses Alt+digit sidebar shortcut payload", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
@@ -162,3 +178,56 @@ describe("keyboard-shortcuts", () => {
|
||||
expect(match?.payload).toEqual({ digit: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard-shortcut help sections", () => {
|
||||
function findRow(
|
||||
sections: ReturnType<typeof buildKeyboardShortcutHelpSections>,
|
||||
id: string
|
||||
) {
|
||||
for (const section of sections) {
|
||||
const row = section.rows.find((candidate) => candidate.id === id);
|
||||
if (row) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
it("uses non-tauri defaults for new-agent and quick-open", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: true,
|
||||
isTauri: false,
|
||||
});
|
||||
|
||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]);
|
||||
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
|
||||
"alt",
|
||||
"1-9",
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches to tauri bindings in help rows", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: true,
|
||||
isTauri: true,
|
||||
});
|
||||
|
||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]);
|
||||
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
|
||||
"mod",
|
||||
"1-9",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses mod+period as non-mac left sidebar shortcut", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: false,
|
||||
isTauri: false,
|
||||
});
|
||||
|
||||
expect(findRow(sections, "toggle-left-sidebar")?.keys).toEqual([
|
||||
"mod",
|
||||
".",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import type {
|
||||
KeyboardActionId,
|
||||
KeyboardFocusScope,
|
||||
@@ -20,6 +21,33 @@ export type KeyboardShortcutMatch = {
|
||||
stopPropagation: boolean;
|
||||
};
|
||||
|
||||
export type KeyboardShortcutHelpRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
keys: ShortcutKey[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type KeyboardShortcutHelpSection = {
|
||||
id: "global" | "agent-input";
|
||||
title: string;
|
||||
rows: KeyboardShortcutHelpRow[];
|
||||
};
|
||||
|
||||
type KeyboardShortcutPlatformContext = {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
};
|
||||
|
||||
type KeyboardShortcutHelpEntry = {
|
||||
id: string;
|
||||
section: KeyboardShortcutHelpSection["id"];
|
||||
label: string;
|
||||
keys: ShortcutKey[];
|
||||
note?: string;
|
||||
when?: (context: KeyboardShortcutPlatformContext) => boolean;
|
||||
};
|
||||
|
||||
type KeyboardShortcutBinding = {
|
||||
id: string;
|
||||
action: KeyboardActionId;
|
||||
@@ -28,6 +56,15 @@ type KeyboardShortcutBinding = {
|
||||
payload?: (event: KeyboardEvent) => KeyboardShortcutPayload;
|
||||
preventDefault?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
help?: KeyboardShortcutHelpEntry;
|
||||
};
|
||||
|
||||
const SHORTCUT_HELP_SECTION_TITLES: Record<
|
||||
KeyboardShortcutHelpSection["id"],
|
||||
string
|
||||
> = {
|
||||
global: "Global",
|
||||
"agent-input": "Agent Input",
|
||||
};
|
||||
|
||||
function isMod(event: KeyboardEvent): boolean {
|
||||
@@ -82,6 +119,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
||||
when: () => true,
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "global",
|
||||
label: "Create new agent",
|
||||
keys: ["mod", "alt", "N"],
|
||||
when: (context) => !context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent-new-tauri-mod-n",
|
||||
@@ -92,6 +136,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
||||
when: (context) => context.isTauri,
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "global",
|
||||
label: "Create new agent",
|
||||
keys: ["mod", "N"],
|
||||
when: (context) => context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "command-center-toggle",
|
||||
@@ -102,12 +153,25 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
|
||||
when: () => true,
|
||||
help: {
|
||||
id: "toggle-command-center",
|
||||
section: "global",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "shortcuts-dialog-toggle-question-mark",
|
||||
action: "shortcuts.dialog.toggle",
|
||||
matches: isQuestionMarkShortcut,
|
||||
when: (context) => context.focusScope === "other",
|
||||
help: {
|
||||
id: "show-shortcuts",
|
||||
section: "global",
|
||||
label: "Show keyboard shortcuts",
|
||||
keys: ["?"],
|
||||
note: "Available when focus is not in a text field or terminal.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-left-mac-cmd-b",
|
||||
@@ -119,6 +183,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyB" || event.key.toLowerCase() === "b"),
|
||||
when: (context) => context.isMac,
|
||||
help: {
|
||||
id: "toggle-left-sidebar",
|
||||
section: "global",
|
||||
label: "Toggle left sidebar",
|
||||
keys: ["mod", "B"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-left-mod-period",
|
||||
@@ -129,6 +200,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
(event.code === "Period" || event.key === "."),
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "toggle-left-sidebar",
|
||||
section: "global",
|
||||
label: "Toggle left sidebar",
|
||||
keys: ["mod", "."],
|
||||
when: (context) => !context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-right-mod-e",
|
||||
@@ -139,6 +217,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyE" || event.key.toLowerCase() === "e"),
|
||||
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "toggle-right-sidebar",
|
||||
section: "global",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-right-ctrl-backquote",
|
||||
@@ -163,6 +247,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
payload: withMessageInputAction("voice-toggle"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
help: {
|
||||
id: "voice-toggle",
|
||||
section: "agent-input",
|
||||
label: "Toggle voice mode",
|
||||
keys: ["mod", "shift", "D"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-dictation-toggle",
|
||||
@@ -175,6 +265,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
payload: withMessageInputAction("dictation-toggle"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
help: {
|
||||
id: "dictation-toggle",
|
||||
section: "agent-input",
|
||||
label: "Start/stop dictation",
|
||||
keys: ["mod", "D"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-dictation-cancel",
|
||||
@@ -183,6 +279,14 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
payload: withMessageInputAction("dictation-cancel"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
help: {
|
||||
id: "dictation-cancel",
|
||||
section: "agent-input",
|
||||
label: "Cancel dictation",
|
||||
keys: ["Esc"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-voice-mute-toggle",
|
||||
@@ -197,6 +301,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
payload: withMessageInputAction("voice-mute-toggle"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope === "other",
|
||||
help: {
|
||||
id: "voice-mute-toggle",
|
||||
section: "agent-input",
|
||||
label: "Mute/unmute voice mode",
|
||||
keys: ["Space"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-shortcut-alt-digit",
|
||||
@@ -207,6 +317,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
return digit ? { digit } : null;
|
||||
},
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "quick-open-agent",
|
||||
section: "global",
|
||||
label: "Open sidebar agent shortcut",
|
||||
keys: ["alt", "1-9"],
|
||||
when: (context) => !context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-shortcut-tauri-mod-digit",
|
||||
@@ -217,6 +334,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
return digit ? { digit } : null;
|
||||
},
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "quick-open-agent",
|
||||
section: "global",
|
||||
label: "Open sidebar agent shortcut",
|
||||
keys: ["mod", "1-9"],
|
||||
when: (context) => context.isTauri,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -242,3 +366,58 @@ export function resolveKeyboardShortcut(input: {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildKeyboardShortcutHelpSections(
|
||||
input: KeyboardShortcutPlatformContext
|
||||
): KeyboardShortcutHelpSection[] {
|
||||
const seenRows = new Set<string>();
|
||||
const rowsBySection = new Map<KeyboardShortcutHelpSection["id"], KeyboardShortcutHelpRow[]>([
|
||||
["global", []],
|
||||
["agent-input", []],
|
||||
]);
|
||||
|
||||
for (const binding of SHORTCUT_BINDINGS) {
|
||||
const help = binding.help;
|
||||
if (!help) {
|
||||
continue;
|
||||
}
|
||||
if (help.when && !help.when(input)) {
|
||||
continue;
|
||||
}
|
||||
const rowKey = `${help.section}:${help.id}`;
|
||||
if (seenRows.has(rowKey)) {
|
||||
continue;
|
||||
}
|
||||
seenRows.add(rowKey);
|
||||
|
||||
const rows = rowsBySection.get(help.section);
|
||||
if (!rows) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
id: help.id,
|
||||
label: help.label,
|
||||
keys: help.keys,
|
||||
...(help.note ? { note: help.note } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const sectionOrder: KeyboardShortcutHelpSection["id"][] = [
|
||||
"global",
|
||||
"agent-input",
|
||||
];
|
||||
|
||||
return sectionOrder.flatMap((sectionId) => {
|
||||
const rows = rowsBySection.get(sectionId) ?? [];
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: sectionId,
|
||||
title: SHORTCUT_HELP_SECTION_TITLES[sectionId],
|
||||
rows,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as Clipboard from "expo-clipboard";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import ReanimatedAnimated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -59,7 +60,11 @@ import { extractAgentModel } from "@/utils/extract-agent-model";
|
||||
import { startPerfMonitor } from "@/utils/perf-monitor";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
useCheckoutStatusQuery,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
@@ -67,6 +72,7 @@ import {
|
||||
derivePendingPermissionKey,
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -75,6 +81,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
@@ -210,6 +217,8 @@ function AgentScreenContent({
|
||||
const toast = useToast();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const resolvedAgentId = agentId;
|
||||
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
|
||||
@@ -229,22 +238,100 @@ function AgentScreenContent({
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout);
|
||||
const activateExplorerTabForCheckout = usePanelStore(
|
||||
(state) => state.activateExplorerTabForCheckout
|
||||
);
|
||||
|
||||
// Derive isExplorerOpen from the unified panel state
|
||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const openExplorerWithDefaultTab = useCallback(() => {
|
||||
// Generic explorer toggles should land on Changes by default.
|
||||
setExplorerTab("changes");
|
||||
// Select only the specific agent
|
||||
const agent = useSessionStore((state) =>
|
||||
resolvedAgentId
|
||||
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
|
||||
: undefined
|
||||
);
|
||||
// Checkout status for header subtitle + git fallback when cached project placement is absent
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: agent?.cwd ?? "",
|
||||
});
|
||||
const checkout = checkoutStatusQuery.status;
|
||||
const resolveCachedCheckoutIsGit = useCallback(
|
||||
(params: {
|
||||
agentId?: string | null;
|
||||
cwd?: string | null;
|
||||
projectPlacementIsGit?: boolean;
|
||||
checkoutStatusIsGit?: boolean;
|
||||
}): boolean | null => {
|
||||
if (typeof params.projectPlacementIsGit === "boolean") {
|
||||
return params.projectPlacementIsGit;
|
||||
}
|
||||
|
||||
const agentId = params.agentId?.trim();
|
||||
if (agentId) {
|
||||
const sidebarAgents = queryClient.getQueryData<{
|
||||
entries: FetchAgentsEntry[];
|
||||
}>(["sidebarAgentsList", serverId]);
|
||||
const sidebarIsGit = sidebarAgents?.entries.find(
|
||||
(entry) => entry.agent.id === agentId
|
||||
)?.project?.checkout?.isGit;
|
||||
if (typeof sidebarIsGit === "boolean") {
|
||||
return sidebarIsGit;
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = params.cwd?.trim();
|
||||
if (!cwd) {
|
||||
return null;
|
||||
}
|
||||
const cachedCheckout = queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
);
|
||||
if (typeof cachedCheckout?.isGit === "boolean") {
|
||||
return cachedCheckout.isGit;
|
||||
}
|
||||
if (typeof params.checkoutStatusIsGit === "boolean") {
|
||||
return params.checkoutStatusIsGit;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[queryClient, serverId]
|
||||
);
|
||||
const resolveCurrentExplorerCheckout = useCallback((): ExplorerCheckoutContext | null => {
|
||||
if (!resolvedAgentId) {
|
||||
return null;
|
||||
}
|
||||
const currentAgent = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]
|
||||
?.agents?.get(resolvedAgentId);
|
||||
const cwd = currentAgent?.cwd?.trim();
|
||||
const isGit = resolveCachedCheckoutIsGit({
|
||||
agentId: resolvedAgentId,
|
||||
cwd,
|
||||
projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit,
|
||||
checkoutStatusIsGit: checkout?.isGit,
|
||||
});
|
||||
if (!cwd || typeof isGit !== "boolean") {
|
||||
return null;
|
||||
}
|
||||
return { serverId, cwd, isGit };
|
||||
}, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]);
|
||||
const openExplorerForActiveCheckout = useCallback(() => {
|
||||
const checkoutContext = resolveCurrentExplorerCheckout();
|
||||
if (checkoutContext) {
|
||||
activateExplorerTabForCheckout(checkoutContext);
|
||||
}
|
||||
openFileExplorer();
|
||||
}, [openFileExplorer, setExplorerTab]);
|
||||
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
|
||||
const handleToggleExplorer = useCallback(() => {
|
||||
if (isExplorerOpen) {
|
||||
toggleFileExplorer();
|
||||
return;
|
||||
}
|
||||
openExplorerWithDefaultTab();
|
||||
}, [isExplorerOpen, openExplorerWithDefaultTab, toggleFileExplorer]);
|
||||
openExplorerForActiveCheckout();
|
||||
}, [isExplorerOpen, openExplorerForActiveCheckout, toggleFileExplorer]);
|
||||
|
||||
const {
|
||||
translateX: explorerTranslateX,
|
||||
@@ -254,6 +341,10 @@ function AgentScreenContent({
|
||||
animateToClose: animateExplorerToClose,
|
||||
isGesturing: isExplorerGesturing,
|
||||
} = useExplorerSidebarAnimation();
|
||||
const handleOpenExplorerFromGesture = useCallback(() => {
|
||||
openExplorerForActiveCheckout();
|
||||
animateExplorerToOpen();
|
||||
}, [animateExplorerToOpen, openExplorerForActiveCheckout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
@@ -293,8 +384,7 @@ function AgentScreenContent({
|
||||
// Open if dragged more than 1/3 of window or fast swipe left
|
||||
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldOpen) {
|
||||
animateExplorerToOpen();
|
||||
runOnJS(openExplorerWithDefaultTab)();
|
||||
runOnJS(handleOpenExplorerFromGesture)();
|
||||
} else {
|
||||
animateExplorerToClose();
|
||||
}
|
||||
@@ -308,9 +398,8 @@ function AgentScreenContent({
|
||||
explorerWindowWidth,
|
||||
explorerTranslateX,
|
||||
explorerBackdropOpacity,
|
||||
animateExplorerToOpen,
|
||||
animateExplorerToClose,
|
||||
openExplorerWithDefaultTab,
|
||||
handleOpenExplorerFromGesture,
|
||||
isExplorerGesturing,
|
||||
]
|
||||
);
|
||||
@@ -330,14 +419,43 @@ function AgentScreenContent({
|
||||
return () => handler.remove();
|
||||
}, [isExplorerOpen, closeToAgent]);
|
||||
|
||||
const resolvedAgentId = agentId;
|
||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||
const cwd = agent?.cwd?.trim();
|
||||
const isGit = resolveCachedCheckoutIsGit({
|
||||
agentId: resolvedAgentId,
|
||||
cwd,
|
||||
projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit,
|
||||
checkoutStatusIsGit: checkout?.isGit,
|
||||
});
|
||||
if (!cwd || typeof isGit !== "boolean") {
|
||||
return null;
|
||||
}
|
||||
return { serverId, cwd, isGit };
|
||||
}, [
|
||||
agent?.cwd,
|
||||
agent?.projectPlacement?.checkout?.isGit,
|
||||
resolveCachedCheckoutIsGit,
|
||||
resolvedAgentId,
|
||||
checkout?.isGit,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
// Select only the specific agent
|
||||
const agent = useSessionStore((state) =>
|
||||
resolvedAgentId
|
||||
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
|
||||
: undefined
|
||||
);
|
||||
useEffect(() => {
|
||||
setActiveExplorerCheckout(activeExplorerCheckout);
|
||||
}, [activeExplorerCheckout, setActiveExplorerCheckout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeExplorerCheckout) {
|
||||
return;
|
||||
}
|
||||
activateExplorerTabForCheckout(activeExplorerCheckout);
|
||||
}, [activateExplorerTabForCheckout, activeExplorerCheckout]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setActiveExplorerCheckout(null);
|
||||
};
|
||||
}, [setActiveExplorerCheckout]);
|
||||
|
||||
// Select only the specific stream tail - use stable empty array to avoid infinite loop
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
@@ -480,12 +598,7 @@ function AgentScreenContent({
|
||||
};
|
||||
}, [showConnectedNotice]);
|
||||
|
||||
// Checkout status for header subtitle
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: agent?.cwd ?? "",
|
||||
});
|
||||
const checkout = checkoutStatusQuery.status;
|
||||
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
@@ -1105,7 +1218,12 @@ function AgentScreenContent({
|
||||
|
||||
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
|
||||
{!isMobile && isExplorerOpen && resolvedAgentId && (
|
||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
|
||||
<ExplorerSidebar
|
||||
serverId={serverId}
|
||||
agentId={resolvedAgentId}
|
||||
cwd={effectiveAgent.cwd}
|
||||
isGit={isGitCheckout}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -1122,7 +1240,12 @@ function AgentScreenContent({
|
||||
|
||||
{/* Mobile Explorer Sidebar Overlay */}
|
||||
{isMobile && resolvedAgentId && (
|
||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
|
||||
<ExplorerSidebar
|
||||
serverId={serverId}
|
||||
agentId={resolvedAgentId}
|
||||
cwd={effectiveAgent.cwd}
|
||||
isGit={isGitCheckout}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export type ExplorerTab = "changes" | "files" | "terminals";
|
||||
|
||||
export function isExplorerTab(value: unknown): value is ExplorerTab {
|
||||
return value === "changes" || value === "files" || value === "terminals";
|
||||
}
|
||||
|
||||
export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null {
|
||||
const trimmedServerId = serverId.trim();
|
||||
const trimmedCwd = cwd.trim();
|
||||
if (!trimmedServerId || !trimmedCwd) {
|
||||
return null;
|
||||
}
|
||||
return `${trimmedServerId}::${trimmedCwd}`;
|
||||
}
|
||||
|
||||
export function coerceExplorerTabForCheckout(tab: ExplorerTab, isGit: boolean): ExplorerTab {
|
||||
if (!isGit && tab === "changes") {
|
||||
return "files";
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
export function resolveExplorerTabForCheckout(params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
}): ExplorerTab {
|
||||
const key = buildExplorerCheckoutKey(params.serverId, params.cwd);
|
||||
const stored = key ? params.explorerTabByCheckout[key] : null;
|
||||
const defaultTab: ExplorerTab = params.isGit ? "changes" : "files";
|
||||
const nextTab = stored && isExplorerTab(stored) ? stored : defaultTab;
|
||||
return coerceExplorerTabForCheckout(nextTab, params.isGit);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExplorerCheckoutKey,
|
||||
resolveExplorerTabForCheckout,
|
||||
} from "@/stores/explorer-tab-memory";
|
||||
|
||||
describe("panel-store explorer tab resolution", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo";
|
||||
|
||||
it("defaults to changes for git checkouts", () => {
|
||||
expect(
|
||||
resolveExplorerTabForCheckout({
|
||||
serverId,
|
||||
cwd,
|
||||
isGit: true,
|
||||
explorerTabByCheckout: {},
|
||||
})
|
||||
).toBe("changes");
|
||||
});
|
||||
|
||||
it("defaults to files for non-git checkouts", () => {
|
||||
expect(
|
||||
resolveExplorerTabForCheckout({
|
||||
serverId,
|
||||
cwd,
|
||||
isGit: false,
|
||||
explorerTabByCheckout: {},
|
||||
})
|
||||
).toBe("files");
|
||||
});
|
||||
|
||||
it("restores a stored files tab for git checkouts", () => {
|
||||
const key = buildExplorerCheckoutKey(serverId, cwd)!;
|
||||
expect(
|
||||
resolveExplorerTabForCheckout({
|
||||
serverId,
|
||||
cwd,
|
||||
isGit: true,
|
||||
explorerTabByCheckout: {
|
||||
[key]: "files",
|
||||
},
|
||||
})
|
||||
).toBe("files");
|
||||
});
|
||||
|
||||
it("restores a stored terminals tab for git checkouts", () => {
|
||||
const key = buildExplorerCheckoutKey(serverId, cwd)!;
|
||||
expect(
|
||||
resolveExplorerTabForCheckout({
|
||||
serverId,
|
||||
cwd,
|
||||
isGit: true,
|
||||
explorerTabByCheckout: {
|
||||
[key]: "terminals",
|
||||
},
|
||||
})
|
||||
).toBe("terminals");
|
||||
});
|
||||
|
||||
it("coerces stored changes to files for non-git checkouts", () => {
|
||||
const key = buildExplorerCheckoutKey(serverId, cwd)!;
|
||||
expect(
|
||||
resolveExplorerTabForCheckout({
|
||||
serverId,
|
||||
cwd,
|
||||
isGit: false,
|
||||
explorerTabByCheckout: {
|
||||
[key]: "changes",
|
||||
},
|
||||
})
|
||||
).toBe("files");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,14 @@ import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
buildExplorerCheckoutKey,
|
||||
coerceExplorerTabForCheckout,
|
||||
isExplorerTab,
|
||||
resolveExplorerTabForCheckout,
|
||||
type ExplorerTab,
|
||||
} from "./explorer-tab-memory";
|
||||
export type { ExplorerTab } from "./explorer-tab-memory";
|
||||
|
||||
/**
|
||||
* Mobile panel state machine.
|
||||
@@ -27,8 +35,12 @@ interface DesktopSidebarState {
|
||||
fileExplorerOpen: boolean;
|
||||
}
|
||||
|
||||
export type ExplorerTab = "changes" | "files" | "terminals";
|
||||
export type SortOption = "name" | "modified" | "size";
|
||||
export interface ExplorerCheckoutContext {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400;
|
||||
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
||||
@@ -48,6 +60,8 @@ interface PanelState {
|
||||
|
||||
// File explorer settings (shared between mobile/desktop)
|
||||
explorerTab: ExplorerTab;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
||||
explorerWidth: number;
|
||||
explorerSortOption: SortOption;
|
||||
explorerFilesSplitRatio: number;
|
||||
@@ -61,6 +75,9 @@ interface PanelState {
|
||||
|
||||
// File explorer settings actions
|
||||
setExplorerTab: (tab: ExplorerTab) => void;
|
||||
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
||||
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
||||
setExplorerWidth: (width: number) => void;
|
||||
setExplorerSortOption: (option: SortOption) => void;
|
||||
setExplorerFilesSplitRatio: (ratio: number) => void;
|
||||
@@ -81,6 +98,18 @@ function clampExplorerFilesSplitRatio(ratio: number): number {
|
||||
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
|
||||
}
|
||||
|
||||
function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab | null {
|
||||
if (!state.activeExplorerCheckout) {
|
||||
return null;
|
||||
}
|
||||
return resolveExplorerTabForCheckout({
|
||||
serverId: state.activeExplorerCheckout.serverId,
|
||||
cwd: state.activeExplorerCheckout.cwd,
|
||||
isGit: state.activeExplorerCheckout.isGit,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
@@ -97,6 +126,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
|
||||
// File explorer defaults
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: {},
|
||||
activeExplorerCheckout: null,
|
||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
explorerSortOption: "name",
|
||||
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||
@@ -108,10 +139,14 @@ export const usePanelStore = create<PanelState>()(
|
||||
})),
|
||||
|
||||
openFileExplorer: () =>
|
||||
set((state) => ({
|
||||
mobileView: "file-explorer",
|
||||
desktop: { ...state.desktop, fileExplorerOpen: true },
|
||||
})),
|
||||
set((state) => {
|
||||
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
|
||||
return {
|
||||
mobileView: "file-explorer",
|
||||
desktop: { ...state.desktop, fileExplorerOpen: true },
|
||||
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
||||
};
|
||||
}),
|
||||
|
||||
closeToAgent: () =>
|
||||
set((state) => ({
|
||||
@@ -142,17 +177,63 @@ export const usePanelStore = create<PanelState>()(
|
||||
toggleFileExplorer: () =>
|
||||
set((state) => {
|
||||
// Mobile: toggle between agent and file-explorer
|
||||
const newMobileView = state.mobileView === "file-explorer" ? "agent" : "file-explorer";
|
||||
return {
|
||||
mobileView: newMobileView,
|
||||
const willOpenMobile = state.mobileView !== "file-explorer";
|
||||
const willOpenDesktop = !state.desktop.fileExplorerOpen;
|
||||
const nextState: Partial<PanelState> = {
|
||||
mobileView: willOpenMobile ? "file-explorer" : "agent",
|
||||
desktop: {
|
||||
...state.desktop,
|
||||
fileExplorerOpen: !state.desktop.fileExplorerOpen,
|
||||
fileExplorerOpen: willOpenDesktop,
|
||||
},
|
||||
};
|
||||
if (willOpenMobile || willOpenDesktop) {
|
||||
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
|
||||
if (resolvedTab) {
|
||||
nextState.explorerTab = resolvedTab;
|
||||
}
|
||||
}
|
||||
return nextState;
|
||||
}),
|
||||
|
||||
setExplorerTab: (tab) => set({ explorerTab: tab }),
|
||||
setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) =>
|
||||
set((state) => {
|
||||
const resolvedTab = coerceExplorerTabForCheckout(tab, isGit);
|
||||
const key = buildExplorerCheckoutKey(serverId, cwd);
|
||||
const nextState: Partial<PanelState> = { explorerTab: resolvedTab };
|
||||
if (key) {
|
||||
const current = state.explorerTabByCheckout[key];
|
||||
if (current !== resolvedTab) {
|
||||
nextState.explorerTabByCheckout = {
|
||||
...state.explorerTabByCheckout,
|
||||
[key]: resolvedTab,
|
||||
};
|
||||
}
|
||||
}
|
||||
return nextState;
|
||||
}),
|
||||
activateExplorerTabForCheckout: (checkout) =>
|
||||
set((state) => ({
|
||||
activeExplorerCheckout: checkout,
|
||||
explorerTab: resolveExplorerTabForCheckout({
|
||||
serverId: checkout.serverId,
|
||||
cwd: checkout.cwd,
|
||||
isGit: checkout.isGit,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
}),
|
||||
})),
|
||||
setActiveExplorerCheckout: (checkout) =>
|
||||
set((state) => {
|
||||
const current = state.activeExplorerCheckout;
|
||||
if (
|
||||
current?.serverId === checkout?.serverId &&
|
||||
current?.cwd === checkout?.cwd &&
|
||||
current?.isGit === checkout?.isGit
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return { activeExplorerCheckout: checkout };
|
||||
}),
|
||||
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
|
||||
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
||||
setExplorerFilesSplitRatio: (ratio) =>
|
||||
@@ -164,7 +245,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 3,
|
||||
version: 4,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
migrate: (persistedState, version) => {
|
||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||
@@ -197,12 +278,29 @@ export const usePanelStore = create<PanelState>()(
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) {
|
||||
state.explorerTabByCheckout = {};
|
||||
} else {
|
||||
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>);
|
||||
const next: Record<string, ExplorerTab> = {};
|
||||
for (const [key, value] of entries) {
|
||||
if (!isExplorerTab(value)) {
|
||||
continue;
|
||||
}
|
||||
next[key] = value;
|
||||
}
|
||||
state.explorerTabByCheckout = next;
|
||||
}
|
||||
|
||||
state.activeExplorerCheckout = null;
|
||||
|
||||
return state as PanelState;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
mobileView: state.mobileView,
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
explorerWidth: state.explorerWidth,
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
|
||||
@@ -234,10 +332,14 @@ export function usePanelState(isMobile: boolean) {
|
||||
toggleFileExplorer: store.toggleFileExplorer,
|
||||
// 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,
|
||||
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
|
||||
setExplorerWidth: store.setExplorerWidth,
|
||||
setExplorerSortOption: store.setExplorerSortOption,
|
||||
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
||||
@@ -262,10 +364,14 @@ export function usePanelState(isMobile: boolean) {
|
||||
toggleFileExplorer: store.toggleFileExplorer,
|
||||
// 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,
|
||||
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
|
||||
setExplorerWidth: store.setExplorerWidth,
|
||||
setExplorerSortOption: store.setExplorerSortOption,
|
||||
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseHostAgentDraftRouteFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
} from "./host-routes";
|
||||
|
||||
describe("parseHostAgentDraftRouteFromPathname", () => {
|
||||
it("parses draft route server id", () => {
|
||||
expect(parseHostAgentDraftRouteFromPathname("/h/local/agent")).toEqual({
|
||||
serverId: "local",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses encoded server id", () => {
|
||||
expect(
|
||||
parseHostAgentDraftRouteFromPathname("/h/team%20host/agent")
|
||||
).toEqual({
|
||||
serverId: "team host",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not match agent detail routes", () => {
|
||||
expect(parseHostAgentDraftRouteFromPathname("/h/local/agent/abc123")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHostAgentRouteFromPathname", () => {
|
||||
it("continues parsing detail routes", () => {
|
||||
expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({
|
||||
serverId: "local",
|
||||
agentId: "abc123",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,14 @@ function encodeSegment(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function decodeSegment(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseServerIdFromPathname(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
@@ -21,11 +29,7 @@ export function parseServerIdFromPathname(pathname: string): string | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return trimNonEmpty(decodeURIComponent(raw));
|
||||
} catch {
|
||||
return trimNonEmpty(raw);
|
||||
}
|
||||
return trimNonEmpty(decodeSegment(raw));
|
||||
}
|
||||
|
||||
export function parseHostAgentRouteFromPathname(
|
||||
@@ -41,16 +45,8 @@ export function parseHostAgentRouteFromPathname(
|
||||
return null;
|
||||
}
|
||||
|
||||
const decode = (value: string) => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const serverId = trimNonEmpty(decode(encodedServerId));
|
||||
const agentId = trimNonEmpty(decode(encodedAgentId));
|
||||
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
|
||||
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
@@ -58,6 +54,24 @@ export function parseHostAgentRouteFromPathname(
|
||||
return { serverId, agentId };
|
||||
}
|
||||
|
||||
export function parseHostAgentDraftRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string } | null {
|
||||
const match = pathname.match(/^\/h\/([^/]+)\/agent\/?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const encodedServerId = match[1];
|
||||
if (!encodedServerId) {
|
||||
return null;
|
||||
}
|
||||
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
return { serverId };
|
||||
}
|
||||
|
||||
export function buildHostAgentDraftRoute(serverId: string): string {
|
||||
const normalized = trimNonEmpty(serverId);
|
||||
if (!normalized) {
|
||||
@@ -115,4 +129,3 @@ export function mapPathnameToServer(
|
||||
}
|
||||
return `${base}/agent`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getTerminalResumeOffset,
|
||||
getTerminalAttachRetryDelayMs,
|
||||
isTerminalAttachRetryableError,
|
||||
updateTerminalResumeOffset,
|
||||
withPromiseTimeout,
|
||||
} from "./terminal-attach";
|
||||
|
||||
describe("terminal-attach", () => {
|
||||
it("computes bounded exponential retry delays", () => {
|
||||
expect(getTerminalAttachRetryDelayMs({ attempt: 0 })).toBe(250);
|
||||
expect(getTerminalAttachRetryDelayMs({ attempt: 1 })).toBe(500);
|
||||
expect(getTerminalAttachRetryDelayMs({ attempt: 2 })).toBe(1_000);
|
||||
expect(getTerminalAttachRetryDelayMs({ attempt: 3 })).toBe(2_000);
|
||||
expect(getTerminalAttachRetryDelayMs({ attempt: 8 })).toBe(2_000);
|
||||
});
|
||||
|
||||
it("matches retryable attach errors", () => {
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "Terminal not found while attaching" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "Network disconnected during attach" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "stream ended before ack" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "permission denied" })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reads and updates resume offsets monotonically", () => {
|
||||
const offsets = new Map<string, number>();
|
||||
const terminalId = "term-1";
|
||||
|
||||
expect(
|
||||
getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetByTerminalId: offsets,
|
||||
})
|
||||
).toBeUndefined();
|
||||
|
||||
updateTerminalResumeOffset({
|
||||
terminalId,
|
||||
offset: 8,
|
||||
resumeOffsetByTerminalId: offsets,
|
||||
});
|
||||
expect(
|
||||
getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetByTerminalId: offsets,
|
||||
})
|
||||
).toBe(8);
|
||||
|
||||
// Stale offsets must not move resume backwards.
|
||||
updateTerminalResumeOffset({
|
||||
terminalId,
|
||||
offset: 3,
|
||||
resumeOffsetByTerminalId: offsets,
|
||||
});
|
||||
expect(
|
||||
getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetByTerminalId: offsets,
|
||||
})
|
||||
).toBe(8);
|
||||
});
|
||||
|
||||
it("resolves before timeout when promise completes", async () => {
|
||||
await expect(
|
||||
withPromiseTimeout({
|
||||
promise: Promise.resolve("ok"),
|
||||
timeoutMs: 50,
|
||||
timeoutMessage: "timed out",
|
||||
})
|
||||
).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it("rejects when timeout wins", async () => {
|
||||
await expect(
|
||||
withPromiseTimeout({
|
||||
promise: new Promise<string>(() => {}),
|
||||
timeoutMs: 10,
|
||||
timeoutMessage: "timed out",
|
||||
})
|
||||
).rejects.toThrow("timed out");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [
|
||||
"terminal not found",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"connection",
|
||||
"network",
|
||||
"disconnected",
|
||||
"stream ended",
|
||||
] as const;
|
||||
|
||||
export function getTerminalResumeOffset(input: {
|
||||
terminalId: string;
|
||||
resumeOffsetByTerminalId: Map<string, number>;
|
||||
}): number | undefined {
|
||||
const offset = input.resumeOffsetByTerminalId.get(input.terminalId);
|
||||
if (typeof offset !== "number" || !Number.isFinite(offset)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset));
|
||||
return normalizedOffset;
|
||||
}
|
||||
|
||||
export function updateTerminalResumeOffset(input: {
|
||||
terminalId: string;
|
||||
offset: number;
|
||||
resumeOffsetByTerminalId: Map<string, number>;
|
||||
}): void {
|
||||
if (!Number.isFinite(input.offset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedOffset = Math.max(0, Math.floor(input.offset));
|
||||
const previousOffset =
|
||||
getTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
resumeOffsetByTerminalId: input.resumeOffsetByTerminalId,
|
||||
}) ?? -1;
|
||||
if (normalizedOffset <= previousOffset) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.resumeOffsetByTerminalId.set(input.terminalId, normalizedOffset);
|
||||
}
|
||||
|
||||
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
|
||||
const clampedAttempt = Math.max(0, input.attempt);
|
||||
const exponentialDelay = 250 * (2 ** clampedAttempt);
|
||||
return Math.min(2_000, exponentialDelay);
|
||||
}
|
||||
|
||||
export function isTerminalAttachRetryableError(input: { message: string }): boolean {
|
||||
const normalized = input.message.toLowerCase();
|
||||
return TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS.some((pattern) =>
|
||||
normalized.includes(pattern)
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForDuration(input: { durationMs: number }): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, Math.max(0, input.durationMs));
|
||||
});
|
||||
}
|
||||
|
||||
export async function withPromiseTimeout<T>(input: {
|
||||
promise: Promise<T>;
|
||||
timeoutMs: number;
|
||||
timeoutMessage: string;
|
||||
}): Promise<T> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(input.timeoutMessage));
|
||||
}, Math.max(0, input.timeoutMs));
|
||||
});
|
||||
try {
|
||||
return await Promise.race([input.promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
mergeTerminalModifiers,
|
||||
normalizeDomTerminalKey,
|
||||
normalizeTerminalTransportKey,
|
||||
resolvePendingModifierDataInput,
|
||||
shouldInterceptDomTerminalKey,
|
||||
} from "./terminal-keys";
|
||||
|
||||
@@ -96,4 +97,41 @@ describe("terminal key helpers", () => {
|
||||
expect(mapTerminalDataToKey("\x03")).toBeNull();
|
||||
expect(mapTerminalDataToKey("")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears pending modifiers when fallback input cannot map to a key", () => {
|
||||
expect(
|
||||
resolvePendingModifierDataInput({
|
||||
data: "hello",
|
||||
pendingModifiers: { ctrl: true, shift: false, alt: false },
|
||||
})
|
||||
).toEqual({
|
||||
mode: "raw",
|
||||
clearPendingModifiers: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps pending modifier fallback to key transport when possible", () => {
|
||||
expect(
|
||||
resolvePendingModifierDataInput({
|
||||
data: "c",
|
||||
pendingModifiers: { ctrl: true, shift: false, alt: false },
|
||||
})
|
||||
).toEqual({
|
||||
mode: "key",
|
||||
key: "c",
|
||||
clearPendingModifiers: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps raw mode unchanged when no pending modifiers exist", () => {
|
||||
expect(
|
||||
resolvePendingModifierDataInput({
|
||||
data: "c",
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
})
|
||||
).toEqual({
|
||||
mode: "raw",
|
||||
clearPendingModifiers: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,3 +157,38 @@ export function mapTerminalDataToKey(data: string): string | null {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePendingModifierDataInput(args: {
|
||||
data: string;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
}):
|
||||
| {
|
||||
mode: "key";
|
||||
key: string;
|
||||
clearPendingModifiers: true;
|
||||
}
|
||||
| {
|
||||
mode: "raw";
|
||||
clearPendingModifiers: boolean;
|
||||
} {
|
||||
if (!hasPendingTerminalModifiers(args.pendingModifiers)) {
|
||||
return {
|
||||
mode: "raw",
|
||||
clearPendingModifiers: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mappedKey = mapTerminalDataToKey(args.data);
|
||||
if (!mappedKey) {
|
||||
return {
|
||||
mode: "raw",
|
||||
clearPendingModifiers: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "key",
|
||||
key: mappedKey,
|
||||
clearPendingModifiers: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user