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
+43
View File
@@ -95,6 +95,15 @@ async function openTerminalsPanel(page: Page): Promise<void> {
});
}
async function openFilesPanel(page: Page): Promise<void> {
const filesTab = page.getByTestId("explorer-tab-files").first();
await expect(filesTab).toBeVisible({ timeout: 30000 });
await filesTab.click();
await expect(page.getByTestId("files-pane-header").first()).toBeVisible({
timeout: 30000,
});
}
async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
return await page.evaluate(() => {
const raw = localStorage.getItem("panel-state");
@@ -241,6 +250,40 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
}
});
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
await runTerminalCommand(
page,
"for i in $(seq 1 12000); do echo reattach-$i; done",
"reattach-12000"
);
for (let attempt = 0; attempt < 4; attempt += 1) {
await openFilesPanel(page);
await openTerminalsPanel(page);
await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
timeout: 30000,
});
await expect(page.getByTestId("terminal-attach-loading")).toHaveCount(0, {
timeout: 30000,
});
}
const marker = `reattach-health-${Date.now()}`;
await runTerminalCommand(page, `echo ${marker}`, marker);
} finally {
await repo.cleanup();
}
});
test("terminal tab is removed when shell exits", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
@@ -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,
};
}
@@ -28,7 +28,8 @@ describe("TerminalStreamManager", () => {
manager.receiveChunk({
chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }),
});
expect(sendAck).not.toHaveBeenCalled();
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
manager.subscribe({
streamId: 7,
@@ -77,6 +78,9 @@ describe("TerminalStreamManager", () => {
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 2, data: "C" }),
});
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 1 });
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 2 });
expect(sendAck).toHaveBeenNthCalledWith(3, { streamId: 5, offset: 3 });
const seen: string[] = [];
manager.subscribe({
@@ -87,8 +91,7 @@ describe("TerminalStreamManager", () => {
});
expect(seen).toEqual(["B", "C"]);
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 2 });
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 3 });
expect(sendAck).toHaveBeenCalledTimes(3);
});
test("tracks explicit ack offsets and skips stale auto-acks", () => {
@@ -79,6 +79,12 @@ export class TerminalStreamManager {
const streamHandlers = this.handlers.get(chunk.streamId);
if (!streamHandlers || streamHandlers.size === 0) {
this.bufferChunk({ chunk });
// Ack buffered chunks immediately to keep the daemon-side stream window
// moving even before a UI subscriber is attached.
this.maybeAckChunk({
streamId: chunk.streamId,
endOffset: chunk.endOffset,
});
return;
}
@@ -46,14 +46,21 @@ import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { createWorktree } from "../../utils/worktree.js";
import { appendTimelineItemIfAgentKnown } from "./timeline-append.js";
import { type WorktreeConfig } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "../worktree-bootstrap.js";
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
paseoHome?: string;
logger: Logger;
}
@@ -313,12 +320,13 @@ export async function createAgentManagementMcpServer(
};
let resolvedCwd = expandUserPath(cwd);
let worktreeConfig: WorktreeConfig | undefined;
if (worktreeName) {
if (!baseBranch) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktree = await createWorktree({
const worktree = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
@@ -326,6 +334,7 @@ export async function createAgentManagementMcpServer(
paseoHome: options.paseoHome,
});
resolvedCwd = worktree.worktreePath;
worktreeConfig = worktree;
}
const provider: AgentProvider = agentType ?? "claude";
@@ -337,6 +346,21 @@ export async function createAgentManagementMcpServer(
title: normalizedTitle ?? undefined,
});
if (worktreeConfig) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
terminalManager: options.terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
logger: childLogger,
});
}
const trimmedPrompt = initialPrompt?.trim();
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
@@ -23,6 +23,39 @@ type McpClient = {
close: () => Promise<void>;
};
async function withTimeout<T>(
options: { promise: Promise<T>; timeoutMs: number; label: string }
): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
}, options.timeoutMs);
});
try {
return await Promise.race([options.promise, timeout]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function waitForPathExists(
options: { targetPath: string; timeoutMs: number }
): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (existsSync(options.targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`Timed out after ${options.timeoutMs}ms waiting for path: ${options.targetPath}`
);
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
@@ -53,10 +86,13 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null {
return null;
}
async function waitForAgentCompletion(client: McpClient, agentId: string): Promise<void> {
const waitResult = (await client.callTool({
async function waitForAgentCompletion(options: {
client: McpClient;
agentId: string;
}): Promise<void> {
const waitResult = (await options.client.callTool({
name: "wait_for_agent",
args: { agentId },
args: { agentId: options.agentId },
})) as McpToolResult;
const payload = getStructuredContent(waitResult);
if (!payload) {
@@ -127,7 +163,7 @@ describe("agent MCP end-to-end (offline)", () => {
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
await waitForAgentCompletion(client, agentId!);
await waitForAgentCompletion({ client, agentId: agentId! });
if (existsSync(filePath)) {
const contents = await readFile(filePath, "utf8");
@@ -148,4 +184,115 @@ describe("agent MCP end-to-end (offline)", () => {
},
30_000
);
test(
"create_agent with worktree is async and boots terminals only after setup success",
async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
const port = await getAvailablePort();
const daemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
};
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
await daemon.start();
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`)
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
let agentId: string | null = null;
try {
const { execSync } = await import("node:child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
await writeFile(path.join(repoRoot, "file.txt"), "hello\n", "utf8");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
const setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
await writeFile(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
],
},
}),
"utf8"
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add worktree config'", {
cwd: repoRoot,
stdio: "pipe",
});
const result = (await withTimeout({
promise: client.callTool({
name: "create_agent",
args: {
cwd: repoRoot,
title: "MCP worktree setup terminals",
agentType: "claude",
initialMode: "bypassPermissions",
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
baseBranch: "main",
background: true,
},
}),
timeoutMs: 2500,
label: "create_agent should not block on setup",
})) as McpToolResult;
const payload = getStructuredContent(result);
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
const worktreePath = (payload?.cwd as string | undefined) ?? "";
expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`);
expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false);
await writeFile(path.join(worktreePath, "allow-setup"), "ok\n", "utf8");
await waitForPathExists({
targetPath: path.join(worktreePath, "setup-done.txt"),
timeoutMs: 15000,
});
await waitForPathExists({
targetPath: path.join(worktreePath, "dev-terminal.txt"),
timeoutMs: 15000,
});
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
}
await client.close();
await daemon.stop();
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
await rm(repoRoot, { recursive: true, force: true });
}
},
60_000
);
});
+27 -2
View File
@@ -28,7 +28,8 @@ import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { createWorktree } from "../../utils/worktree.js";
import { appendTimelineItemIfAgentKnown } from "./timeline-append.js";
import { type WorktreeConfig } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type {
@@ -36,10 +37,16 @@ import type {
VoiceSpeakHandler,
} from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "../worktree-bootstrap.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
paseoHome?: string;
/**
* ID of the agent that is connecting to this MCP server.
@@ -289,6 +296,7 @@ export async function createAgentMcpServer(
const {
agentManager,
agentStorage,
terminalManager,
callerAgentId,
resolveSpeakHandler,
resolveCallerContext,
@@ -469,6 +477,7 @@ export async function createAgentMcpServer(
let resolvedCwd: string;
let resolvedMode: string | undefined;
let worktreeConfig: WorktreeConfig | undefined;
if (callerAgentId) {
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
@@ -514,7 +523,7 @@ export async function createAgentMcpServer(
if (!baseBranch) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktree = await createWorktree({
const worktree = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
@@ -522,6 +531,7 @@ export async function createAgentMcpServer(
paseoHome: options.paseoHome,
});
resolvedCwd = worktree.worktreePath;
worktreeConfig = worktree;
}
resolvedMode = initialMode;
@@ -542,6 +552,21 @@ export async function createAgentMcpServer(
childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined
);
if (worktreeConfig) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
terminalManager: terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
logger: childLogger,
});
}
const trimmedPrompt = initialPrompt.trim();
scheduleAgentMetadataGeneration({
agentManager,
@@ -0,0 +1,23 @@
import type { AgentManager } from "./agent-manager.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
export interface AppendTimelineItemIfAgentKnownOptions {
agentManager: AgentManager;
agentId: string;
item: AgentTimelineItem;
}
export async function appendTimelineItemIfAgentKnown(
options: AppendTimelineItemIfAgentKnownOptions
): Promise<boolean> {
try {
await options.agentManager.appendTimelineItem(options.agentId, options.item);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Unknown agent")) {
return false;
}
throw error;
}
}
+3
View File
@@ -306,6 +306,7 @@ export async function createPaseoDaemon(
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
enableVoiceTools: false,
resolveSpeakHandler: (callerAgentId) => wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null,
@@ -329,6 +330,7 @@ export async function createPaseoDaemon(
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
callerAgentId,
enableVoiceTools: false,
@@ -448,6 +450,7 @@ export async function createPaseoDaemon(
return createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
callerAgentId,
voiceOnly: true,
@@ -16,18 +16,16 @@ function tmpCwd(): string {
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string
options: { promise: Promise<T>; timeoutMs: number; label: string }
): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Timed out after ${timeoutMs}ms (${label})`));
}, timeoutMs);
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
}, options.timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
return await Promise.race([options.promise, timeout]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
@@ -92,6 +90,21 @@ async function waitForTimelineToolCall(
);
}
async function waitForPathExists(
options: { targetPath: string; timeoutMs: number; label: string }
): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (existsSync(options.targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`Timed out after ${options.timeoutMs}ms waiting for ${options.label}: ${options.targetPath}`
);
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
@@ -352,8 +365,8 @@ describe("daemon E2E", () => {
stdio: "pipe",
});
const agent = await withTimeout(
ctx.client.createAgent({
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
@@ -367,9 +380,9 @@ describe("daemon E2E", () => {
worktreeSlug: "async-setup-test",
},
}),
2500,
"createAgent should not block on setup"
);
timeoutMs: 2500,
label: "createAgent should not block on setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
@@ -396,6 +409,107 @@ describe("daemon E2E", () => {
60000
);
test(
"bootstraps configured worktree terminals after setup succeeds",
async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
const setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
{
command: 'echo "lint-watch" > lint-terminal.txt; tail -f /dev/null',
},
],
},
})
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", {
cwd: repoRoot,
stdio: "pipe",
});
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Async Worktree Setup + Terminals Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-terminals-test",
worktreeSlug: "async-setup-terminals-test",
},
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false);
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
20000
);
await waitForPathExists({
targetPath: path.join(agent.cwd, "dev-terminal.txt"),
timeoutMs: 15000,
label: "dev terminal marker",
});
await waitForPathExists({
targetPath: path.join(agent.cwd, "lint-terminal.txt"),
timeoutMs: 15000,
label: "lint terminal marker",
});
const list = await ctx.client.listTerminals(agent.cwd);
expect(list.error).toBeUndefined();
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
expect(list.terminals.length).toBeGreaterThanOrEqual(2);
await ctx.client.deleteAgent(agent.id);
rmSync(repoRoot, { recursive: true, force: true });
},
60000
);
test(
"reports failures via timeline tool_call without deleting the created worktree",
async () => {
@@ -421,7 +535,17 @@ describe("daemon E2E", () => {
'echo "started" > "$PASEO_WORKTREE_PATH/setup-start.txt"; sleep 0.1; echo "boom" 1>&2; exit 7';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: [setupCommand] } })
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Should Not Start",
command: 'echo "should-not-run" > should-not-run.txt; tail -f /dev/null',
},
],
},
})
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", {
@@ -429,8 +553,8 @@ describe("daemon E2E", () => {
stdio: "pipe",
});
const agent = await withTimeout(
ctx.client.createAgent({
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
@@ -444,9 +568,9 @@ describe("daemon E2E", () => {
worktreeSlug: "async-setup-failure-test",
},
}),
2500,
"createAgent should not block on failing setup"
);
timeoutMs: 2500,
label: "createAgent should not block on failing setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(agent.cwd)).toBe(true);
@@ -469,6 +593,7 @@ describe("daemon E2E", () => {
);
expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true);
expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false);
const output = failed.detail.type === "unknown" ? failed.detail.output as any : undefined;
const commands = output?.commands as any[] | undefined;
+37 -123
View File
@@ -68,6 +68,7 @@ import type {
} from "./agent/agent-manager.js";
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
import { toAgentPayload } from "./agent/agent-projections.js";
import { appendTimelineItemIfAgentKnown } from "./agent/timeline-append.js";
import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
import {
StructuredAgentResponseError,
@@ -83,7 +84,6 @@ import type {
AgentStreamEvent,
AgentProvider,
AgentPersistenceHandle,
AgentTimelineItem,
} from "./agent/agent-sdk-types.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
@@ -101,18 +101,18 @@ import {
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import {
createWorktree,
runWorktreeSetupCommands,
WorktreeSetupError,
type WorktreeConfig,
type WorktreeSetupCommandResult,
slugify,
validateBranchSlug,
listPaseoWorktrees,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
} from "../utils/worktree.js";
slugify,
validateBranchSlug,
listPaseoWorktrees,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
} from "../utils/worktree.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "./worktree-bootstrap.js";
import {
getCheckoutDiff,
getCheckoutStatus,
@@ -208,19 +208,22 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
return `remote:${cleanedHost}/${cleanedPath}`;
}
function deriveProjectGroupingKey(cwd: string, remoteUrl: string | null): string {
const remoteKey = deriveRemoteProjectKey(remoteUrl);
function deriveProjectGroupingKey(options: {
cwd: string;
remoteUrl: string | null;
}): string {
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
if (remoteKey) {
return remoteKey;
}
const worktreeMarker = ".paseo/worktrees/";
const idx = cwd.indexOf(worktreeMarker);
const idx = options.cwd.indexOf(worktreeMarker);
if (idx !== -1) {
return cwd.slice(0, idx).replace(/\/$/, "");
return options.cwd.slice(0, idx).replace(/\/$/, "");
}
return cwd;
return options.cwd;
}
function deriveProjectGroupingName(projectKey: string): string {
@@ -1123,7 +1126,10 @@ export class Session {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
.then((status) => this.toProjectCheckoutLite(cwd, status))
.catch(() => this.buildFallbackProjectCheckout(cwd));
const projectKey = deriveProjectGroupingKey(cwd, checkout.remoteUrl);
const projectKey = deriveProjectGroupingKey({
cwd,
remoteUrl: checkout.remoteUrl,
});
return {
projectKey,
projectName: deriveProjectGroupingName(projectKey),
@@ -2398,7 +2404,18 @@ export class Session {
}
if (worktreeConfig) {
void this.runAsyncWorktreeSetup(snapshot.id, worktreeConfig);
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
terminalManager: this.terminalManager,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId: snapshot.id,
item,
}),
logger: this.sessionLogger,
});
}
this.sessionLogger.info(
@@ -2627,12 +2644,11 @@ export class Session {
}' for branch ${targetBranch}`
);
const createdWorktree = await createWorktree({
const createdWorktree = await createAgentWorktree({
branchName: targetBranch,
cwd,
baseBranch: normalized.baseBranch!,
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
runSetup: false,
paseoHome: this.paseoHome,
});
cwd = createdWorktree.worktreePath;
@@ -2656,108 +2672,6 @@ export class Session {
};
}
private async runAsyncWorktreeSetup(
agentId: string,
worktree: WorktreeConfig
): Promise<void> {
const callId = uuidv4();
let results: WorktreeSetupCommandResult[] = [];
try {
const started = await this.safeAppendTimelineItem(agentId, {
type: "tool_call",
name: "paseo_worktree_setup",
callId,
status: "running",
detail: {
type: "unknown",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: null,
},
error: null,
});
if (!started) {
return;
}
results = await runWorktreeSetupCommands({
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
cleanupOnFailure: false,
});
await this.safeAppendTimelineItem(agentId, {
type: "tool_call",
name: "paseo_worktree_setup",
callId,
status: "completed",
detail: {
type: "unknown",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: {
worktreePath: worktree.worktreePath,
commands: results.map((result) => ({
command: result.command,
cwd: result.cwd,
exitCode: result.exitCode,
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
})),
},
},
error: null,
});
} catch (error: any) {
if (error instanceof WorktreeSetupError) {
results = error.results;
}
const message = error instanceof Error ? error.message : String(error);
await this.safeAppendTimelineItem(agentId, {
type: "tool_call",
name: "paseo_worktree_setup",
callId,
status: "failed",
detail: {
type: "unknown",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: {
worktreePath: worktree.worktreePath,
commands: results.map((result) => ({
command: result.command,
cwd: result.cwd,
exitCode: result.exitCode,
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
})),
},
},
error: { message },
});
}
}
private async safeAppendTimelineItem(
agentId: string,
item: AgentTimelineItem
): Promise<boolean> {
try {
await this.agentManager.appendTimelineItem(agentId, item);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Unknown agent")) {
return false;
}
throw error;
}
}
private async handleListProviderModelsRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
): Promise<void> {
@@ -0,0 +1,309 @@
import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import {
createWorktree,
getWorktreeTerminalSpecs,
runWorktreeSetupCommands,
WorktreeSetupError,
type WorktreeConfig,
type WorktreeSetupCommandResult,
} from "../utils/worktree.js";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
export interface WorktreeBootstrapTerminalResult {
name: string | null;
command: string;
status: "started" | "failed";
terminalId: string | null;
error: string | null;
}
export interface RunAsyncWorktreeBootstrapOptions {
agentId: string;
worktree: WorktreeConfig;
terminalManager: TerminalManager | null;
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
logger?: Logger;
}
export interface CreateAgentWorktreeOptions {
cwd: string;
branchName: string;
baseBranch: string;
worktreeSlug: string;
paseoHome?: string;
}
export async function createAgentWorktree(
options: CreateAgentWorktreeOptions
): Promise<WorktreeConfig> {
return createWorktree({
branchName: options.branchName,
cwd: options.cwd,
baseBranch: options.baseBranch,
worktreeSlug: options.worktreeSlug,
runSetup: false,
paseoHome: options.paseoHome,
});
}
function buildSetupTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
worktree: WorktreeConfig;
results: WorktreeSetupCommandResult[];
errorMessage: string | null;
}): AgentTimelineItem {
const detailInput = {
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
};
const detailOutput = {
worktreePath: input.worktree.worktreePath,
commands: input.results.map((result) => ({
command: result.command,
cwd: result.cwd,
exitCode: result.exitCode,
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
})),
};
if (input.status === "running") {
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "running",
detail: {
type: "unknown",
input: detailInput,
output: null,
},
error: null,
};
}
if (input.status === "completed") {
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "completed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: null,
};
}
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "failed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: { message: input.errorMessage ?? "Worktree setup failed" },
};
}
function buildTerminalTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
worktree: WorktreeConfig;
results: WorktreeBootstrapTerminalResult[];
errorMessage: string | null;
}): AgentTimelineItem {
const detailInput = {
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
};
const detailOutput = {
worktreePath: input.worktree.worktreePath,
terminals: input.results,
};
if (input.status === "running") {
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "running",
detail: {
type: "unknown",
input: detailInput,
output: null,
},
error: null,
};
}
if (input.status === "completed") {
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "completed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: null,
};
}
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "failed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: { message: input.errorMessage ?? "Worktree terminal bootstrap failed" },
};
}
async function runWorktreeTerminalBootstrap(
options: RunAsyncWorktreeBootstrapOptions
): Promise<void> {
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
if (terminalSpecs.length === 0) {
return;
}
const callId = uuidv4();
const started = await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "running",
worktree: options.worktree,
results: [],
errorMessage: null,
})
);
if (!started) {
return;
}
if (!options.terminalManager) {
await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "failed",
worktree: options.worktree,
results: [],
errorMessage: "Terminal manager not available",
})
);
return;
}
const results: WorktreeBootstrapTerminalResult[] = [];
for (const spec of terminalSpecs) {
try {
const terminal = await options.terminalManager.createTerminal({
cwd: options.worktree.worktreePath,
name: spec.name,
});
terminal.send({
type: "input",
data: `${spec.command}\r`,
});
results.push({
name: terminal.name ?? spec.name ?? null,
command: spec.command,
status: "started",
terminalId: terminal.id,
error: null,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.logger?.warn(
{ agentId: options.agentId, command: spec.command, err: error },
"Failed to bootstrap worktree terminal"
);
results.push({
name: spec.name ?? null,
command: spec.command,
status: "failed",
terminalId: null,
error: message,
});
}
}
await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "completed",
worktree: options.worktree,
results,
errorMessage: null,
})
);
}
export async function runAsyncWorktreeBootstrap(
options: RunAsyncWorktreeBootstrapOptions
): Promise<void> {
const setupCallId = uuidv4();
let setupResults: WorktreeSetupCommandResult[] = [];
try {
const started = await options.appendTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "running",
worktree: options.worktree,
results: [],
errorMessage: null,
})
);
if (!started) {
return;
}
setupResults = await runWorktreeSetupCommands({
worktreePath: options.worktree.worktreePath,
branchName: options.worktree.branchName,
cleanupOnFailure: false,
});
await options.appendTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "completed",
worktree: options.worktree,
results: setupResults,
errorMessage: null,
})
);
} catch (error) {
if (error instanceof WorktreeSetupError) {
setupResults = error.results;
}
const message = error instanceof Error ? error.message : String(error);
await options.appendTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "failed",
worktree: options.worktree,
results: setupResults,
errorMessage: message,
})
);
return;
}
await runWorktreeTerminalBootstrap(options);
}
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
createWorktree,
deletePaseoWorktree,
getWorktreeTerminalSpecs,
isPaseoOwnedWorktreeCwd,
listPaseoWorktrees,
slugify,
@@ -262,6 +263,43 @@ describe("createWorktree", () => {
// Verify worktree was cleaned up
expect(existsSync(expectedWorktreePath)).toBe(false);
});
it("reads worktree terminal specs from paseo.json with optional name", async () => {
const paseoConfig = {
worktree: {
terminals: [
{ name: "Dev Server", command: "npm run dev" },
{ command: "cd packages/app && npm run dev" },
],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
{ name: "Dev Server", command: "npm run dev" },
{ command: "cd packages/app && npm run dev" },
]);
});
it("filters invalid worktree terminal specs", async () => {
const paseoConfig = {
worktree: {
terminals: [
null,
{},
{ name: " ", command: " " },
{ name: " Watch ", command: "npm run watch", cwd: "packages/app" },
{ name: 123, command: "npm run test" },
],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
{ name: "Watch", command: "npm run watch" },
{ command: "npm run test" },
]);
});
});
describe("paseo worktree manager", () => {
+42
View File
@@ -11,6 +11,7 @@ interface PaseoConfig {
worktree?: {
setup?: string[];
destroy?: string[];
terminals?: WorktreeTerminalConfig[];
};
}
@@ -33,6 +34,11 @@ export type WorktreeSetupCommandResult = {
exitCode: number | null;
};
export interface WorktreeTerminalConfig {
name?: string;
command: string;
}
export class WorktreeSetupError extends Error {
readonly results: WorktreeSetupCommandResult[];
@@ -107,6 +113,42 @@ export function getWorktreeDestroyCommands(repoRoot: string): string[] {
return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
}
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
const config = readPaseoConfig(repoRoot);
const terminals = config?.worktree?.terminals;
if (!Array.isArray(terminals) || terminals.length === 0) {
return [];
}
const specs: WorktreeTerminalConfig[] = [];
for (const terminal of terminals) {
if (!terminal || typeof terminal !== "object") {
continue;
}
const rawCommand = terminal.command;
if (typeof rawCommand !== "string") {
continue;
}
const command = rawCommand.trim();
if (!command) {
continue;
}
const rawName = terminal.name;
const name = typeof rawName === "string" && rawName.trim().length > 0
? rawName.trim()
: undefined;
specs.push({
...(name ? { name } : {}),
command,
});
}
return specs;
}
async function execSetupCommand(
command: string,
options: { cwd: string; env: NodeJS.ProcessEnv }