feat: terminal reattach, explorer tab memory, worktree terminals

This commit is contained in:
Mohamed Boudra
2026-02-15 14:05:29 +07:00
parent 1f72ce63cd
commit 9244f1fbd6
31 changed files with 2009 additions and 385 deletions
@@ -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,
+160 -62
View File
@@ -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");
});
});
+116 -10
View File
@@ -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",
});
});
});
+29 -16
View File
@@ -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");
});
});
+83
View File
@@ -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,
});
});
});
+35
View File
@@ -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,
};
}