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