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> {
|
async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
|
||||||
return await page.evaluate(() => {
|
return await page.evaluate(() => {
|
||||||
const raw = localStorage.getItem("panel-state");
|
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 }) => {
|
test("terminal tab is removed when shell exits", async ({ page }) => {
|
||||||
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
|
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function AgentStreamView({
|
|||||||
const streamItemCountRef = useRef(0);
|
const streamItemCountRef = useRef(0);
|
||||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
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)
|
// Get serverId (fallback to agent's serverId if not provided)
|
||||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||||
@@ -154,7 +154,12 @@ export function AgentStreamView({
|
|||||||
requestFilePreview(agentId, normalized.file);
|
requestFilePreview(agentId, normalized.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
setExplorerTab("files");
|
setExplorerTabForCheckout({
|
||||||
|
serverId: resolvedServerId,
|
||||||
|
cwd: agent.cwd,
|
||||||
|
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||||
|
tab: "files",
|
||||||
|
});
|
||||||
openFileExplorer();
|
openFileExplorer();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -163,7 +168,7 @@ export function AgentStreamView({
|
|||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
requestFilePreview,
|
requestFilePreview,
|
||||||
selectExplorerEntry,
|
selectExplorerEntry,
|
||||||
setExplorerTab,
|
setExplorerTabForCheckout,
|
||||||
openFileExplorer,
|
openFileExplorer,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
|
||||||
import { GitDiffPane } from "./git-diff-pane";
|
import { GitDiffPane } from "./git-diff-pane";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
import { TerminalPane } from "./terminal-pane";
|
import { TerminalPane } from "./terminal-pane";
|
||||||
@@ -40,9 +39,10 @@ interface ExplorerSidebarProps {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
isGit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps) {
|
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
@@ -52,7 +52,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
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 setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||||
const { width: viewportWidth } = useWindowDimensions();
|
const { width: viewportWidth } = useWindowDimensions();
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
@@ -102,9 +102,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
|
|
||||||
const handleTabPress = useCallback(
|
const handleTabPress = useCallback(
|
||||||
(tab: ExplorerTab) => {
|
(tab: ExplorerTab) => {
|
||||||
setExplorerTab(tab);
|
setExplorerTabForCheckout({ serverId, cwd, isGit, tab });
|
||||||
},
|
},
|
||||||
[setExplorerTab]
|
[cwd, isGit, serverId, setExplorerTabForCheckout]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Swipe gesture to close (swipe right on mobile)
|
// Swipe gesture to close (swipe right on mobile)
|
||||||
@@ -268,6 +268,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
|
isGit={isGit}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -300,6 +301,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
|
isGit={isGit}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -313,6 +315,7 @@ interface SidebarContentProps {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
isGit: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,19 +326,12 @@ function SidebarContent({
|
|||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
cwd,
|
cwd,
|
||||||
|
isGit,
|
||||||
isMobile,
|
isMobile,
|
||||||
}: SidebarContentProps) {
|
}: SidebarContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { status } = useCheckoutStatusQuery({ serverId, cwd });
|
const resolvedTab: ExplorerTab =
|
||||||
const isGit = status?.isGit ?? false;
|
!isGit && activeTab === "changes" ? "files" : activeTab;
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
@@ -345,13 +341,13 @@ function SidebarContent({
|
|||||||
{isGit && (
|
{isGit && (
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="explorer-tab-changes"
|
testID="explorer-tab-changes"
|
||||||
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
|
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
|
||||||
onPress={() => onTabPress("changes")}
|
onPress={() => onTabPress("changes")}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tabText,
|
styles.tabText,
|
||||||
activeTab === "changes" && styles.tabTextActive,
|
resolvedTab === "changes" && styles.tabTextActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
Changes
|
Changes
|
||||||
@@ -360,13 +356,13 @@ function SidebarContent({
|
|||||||
)}
|
)}
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="explorer-tab-files"
|
testID="explorer-tab-files"
|
||||||
style={[styles.tab, activeTab === "files" && styles.tabActive]}
|
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
|
||||||
onPress={() => onTabPress("files")}
|
onPress={() => onTabPress("files")}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tabText,
|
styles.tabText,
|
||||||
activeTab === "files" && styles.tabTextActive,
|
resolvedTab === "files" && styles.tabTextActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
Files
|
Files
|
||||||
@@ -374,13 +370,13 @@ function SidebarContent({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="explorer-tab-terminals"
|
testID="explorer-tab-terminals"
|
||||||
style={[styles.tab, activeTab === "terminals" && styles.tabActive]}
|
style={[styles.tab, resolvedTab === "terminals" && styles.tabActive]}
|
||||||
onPress={() => onTabPress("terminals")}
|
onPress={() => onTabPress("terminals")}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tabText,
|
styles.tabText,
|
||||||
activeTab === "terminals" && styles.tabTextActive,
|
resolvedTab === "terminals" && styles.tabTextActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
Terminals
|
Terminals
|
||||||
@@ -398,13 +394,13 @@ function SidebarContent({
|
|||||||
|
|
||||||
{/* Content based on active tab */}
|
{/* Content based on active tab */}
|
||||||
<View style={styles.contentArea} testID="explorer-content-area">
|
<View style={styles.contentArea} testID="explorer-content-area">
|
||||||
{activeTab === "changes" && (
|
{resolvedTab === "changes" && (
|
||||||
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "files" && (
|
{resolvedTab === "files" && (
|
||||||
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "terminals" && (
|
{resolvedTab === "terminals" && (
|
||||||
<TerminalPane serverId={serverId} cwd={cwd} />
|
<TerminalPane serverId={serverId} cwd={cwd} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -5,92 +5,8 @@ import { getIsTauri } from "@/constants/layout";
|
|||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
|
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
|
||||||
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"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyboardShortcutsDialog() {
|
export function KeyboardShortcutsDialog() {
|
||||||
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
||||||
@@ -99,7 +15,7 @@ export function KeyboardShortcutsDialog() {
|
|||||||
const isMac = getShortcutOs() === "mac";
|
const isMac = getShortcutOs() === "mac";
|
||||||
const isTauri = getIsTauri();
|
const isTauri = getIsTauri();
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() => buildShortcutHelpSections({ isMac, isTauri }),
|
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
|
||||||
[isMac, isTauri]
|
[isMac, isTauri]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ export default function TerminalEmulator({
|
|||||||
fitAndEmitResize();
|
fitAndEmitResize();
|
||||||
});
|
});
|
||||||
resizeObserver.observe(root);
|
resizeObserver.observe(root);
|
||||||
|
resizeObserver.observe(host);
|
||||||
|
|
||||||
const windowResizeHandler = () => fitAndEmitResize();
|
const windowResizeHandler = () => fitAndEmitResize();
|
||||||
window.addEventListener("resize", windowResizeHandler);
|
window.addEventListener("resize", windowResizeHandler);
|
||||||
@@ -378,6 +379,25 @@ export default function TerminalEmulator({
|
|||||||
fitAndEmitResize();
|
fitAndEmitResize();
|
||||||
}, 250);
|
}, 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);
|
window.setTimeout(() => fitAndEmitResize(true), 0);
|
||||||
|
|
||||||
if (outputText.length > 0) {
|
if (outputText.length > 0) {
|
||||||
@@ -391,6 +411,10 @@ export default function TerminalEmulator({
|
|||||||
window.removeEventListener("resize", windowResizeHandler);
|
window.removeEventListener("resize", windowResizeHandler);
|
||||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
||||||
window.clearInterval(fitInterval);
|
window.clearInterval(fitInterval);
|
||||||
|
for (const handle of fitTimeoutHandles) {
|
||||||
|
window.clearTimeout(handle);
|
||||||
|
}
|
||||||
|
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
|
||||||
root.removeEventListener("touchstart", rootTouchStartHandler);
|
root.removeEventListener("touchstart", rootTouchStartHandler);
|
||||||
root.removeEventListener("touchmove", rootTouchMoveHandler);
|
root.removeEventListener("touchmove", rootTouchMoveHandler);
|
||||||
root.removeEventListener("touchend", rootTouchEndHandler);
|
root.removeEventListener("touchend", rootTouchEndHandler);
|
||||||
@@ -478,12 +502,10 @@ export default function TerminalEmulator({
|
|||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "relative",
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
|||||||
@@ -18,9 +18,17 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
hasPendingTerminalModifiers,
|
hasPendingTerminalModifiers,
|
||||||
mapTerminalDataToKey,
|
|
||||||
normalizeTerminalTransportKey,
|
normalizeTerminalTransportKey,
|
||||||
|
resolvePendingModifierDataInput,
|
||||||
} from "@/utils/terminal-keys";
|
} from "@/utils/terminal-keys";
|
||||||
|
import {
|
||||||
|
getTerminalResumeOffset,
|
||||||
|
getTerminalAttachRetryDelayMs,
|
||||||
|
isTerminalAttachRetryableError,
|
||||||
|
updateTerminalResumeOffset,
|
||||||
|
waitForDuration,
|
||||||
|
withPromiseTimeout,
|
||||||
|
} from "@/utils/terminal-attach";
|
||||||
import TerminalEmulator from "./terminal-emulator";
|
import TerminalEmulator from "./terminal-emulator";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
@@ -30,6 +38,8 @@ interface TerminalPaneProps {
|
|||||||
|
|
||||||
const MAX_OUTPUT_CHARS = 200_000;
|
const MAX_OUTPUT_CHARS = 200_000;
|
||||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||||
|
const TERMINAL_ATTACH_MAX_ATTEMPTS = 4;
|
||||||
|
const TERMINAL_ATTACH_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
const MODIFIER_LABELS = {
|
const MODIFIER_LABELS = {
|
||||||
ctrl: "Ctrl",
|
ctrl: "Ctrl",
|
||||||
@@ -61,8 +71,8 @@ const EMPTY_MODIFIERS: ModifierState = {
|
|||||||
alt: false,
|
alt: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function terminalScopeKey(serverId: string, cwd: string): string {
|
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
||||||
return `${serverId}:${cwd}`;
|
return `${input.serverId}:${input.cwd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) {
|
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
|
(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 selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
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 [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
||||||
const [outputByTerminalId, setOutputByTerminalId] = useState<Map<string, string>>(
|
const [outputByTerminalId, setOutputByTerminalId] = useState<Map<string, string>>(
|
||||||
@@ -111,6 +122,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
terminalId: string;
|
terminalId: string;
|
||||||
streamId: number;
|
streamId: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [attachGeneration, setAttachGeneration] = useState(0);
|
||||||
const [isAttaching, setIsAttaching] = useState(false);
|
const [isAttaching, setIsAttaching] = useState(false);
|
||||||
const [streamError, setStreamError] = useState<string | null>(null);
|
const [streamError, setStreamError] = useState<string | null>(null);
|
||||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
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 hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||||
|
const activeStreamRef = useRef<{
|
||||||
|
terminalId: string;
|
||||||
|
streamId: number;
|
||||||
|
} | null>(activeStream);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedTerminalIdRef.current = selectedTerminalId;
|
selectedTerminalIdRef.current = selectedTerminalId;
|
||||||
}, [selectedTerminalId]);
|
}, [selectedTerminalId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeStreamRef.current = activeStream;
|
||||||
|
}, [activeStream]);
|
||||||
|
|
||||||
const clearHoverOutTimeout = useCallback(() => {
|
const clearHoverOutTimeout = useCallback(() => {
|
||||||
if (!hoverOutTimeoutRef.current) {
|
if (!hoverOutTimeoutRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -201,14 +221,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTerminalIdRef.current === exitedTerminalId) {
|
const activeStreamForTerminal = activeStreamRef.current;
|
||||||
setSelectedTerminalId((current) =>
|
if (
|
||||||
current === exitedTerminalId ? null : current
|
selectedTerminalIdRef.current === exitedTerminalId &&
|
||||||
);
|
activeStreamForTerminal?.terminalId === exitedTerminalId &&
|
||||||
|
activeStreamForTerminal.streamId === message.payload.streamId
|
||||||
|
) {
|
||||||
setActiveStream((current) =>
|
setActiveStream((current) =>
|
||||||
current?.terminalId === exitedTerminalId ? null : current
|
current?.terminalId === exitedTerminalId ? null : current
|
||||||
);
|
);
|
||||||
setIsAttaching(false);
|
setStreamError("Terminal stream ended. Reconnecting…");
|
||||||
|
setIsAttaching(true);
|
||||||
|
setAttachGeneration((current) => current + 1);
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +277,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
},
|
},
|
||||||
onSuccess: (_, terminalId) => {
|
onSuccess: (_, terminalId) => {
|
||||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||||
|
resumeOffsetByTerminalIdRef.current.delete(terminalId);
|
||||||
if (selectedTerminalIdRef.current === terminalId) {
|
if (selectedTerminalIdRef.current === terminalId) {
|
||||||
setSelectedTerminalId((current) =>
|
setSelectedTerminalId((current) =>
|
||||||
current === terminalId ? null : current
|
current === terminalId ? null : current
|
||||||
@@ -310,6 +335,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
}
|
}
|
||||||
}, [scopeKey, terminals, selectedTerminalId]);
|
}, [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) => {
|
const appendOutput = useCallback((terminalId: string, text: string) => {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
@@ -343,40 +382,102 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
|
|
||||||
setIsAttaching(true);
|
setIsAttaching(true);
|
||||||
setStreamError(null);
|
setStreamError(null);
|
||||||
lastReportedSizeRef.current = null;
|
|
||||||
|
|
||||||
const attach = async () => {
|
const attach = async () => {
|
||||||
try {
|
try {
|
||||||
const attachPayload = await client.attachTerminalStream(terminalId);
|
let lastErrorMessage = "Unable to attach terminal stream";
|
||||||
if (isCancelled) {
|
const totalAttempts = TERMINAL_ATTACH_MAX_ATTEMPTS;
|
||||||
if (typeof attachPayload.streamId === "number") {
|
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
|
||||||
void client.detachTerminalStream(attachPayload.streamId).catch(() => {});
|
try {
|
||||||
}
|
const lastSize = lastReportedSizeRef.current;
|
||||||
return;
|
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") {
|
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
|
||||||
setStreamError(attachPayload.error ?? "Unable to attach terminal stream");
|
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
|
||||||
setActiveStream(null);
|
const hasRemainingAttempts = attempt < totalAttempts - 1;
|
||||||
return;
|
if (
|
||||||
}
|
hasRemainingAttempts &&
|
||||||
|
isTerminalAttachRetryableError({ message: lastErrorMessage })
|
||||||
|
) {
|
||||||
|
await waitForDuration({
|
||||||
|
durationMs: getTerminalAttachRetryDelayMs({ attempt }),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
setStreamError(lastErrorMessage);
|
||||||
|
setActiveStream(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
streamId = attachPayload.streamId;
|
streamId = attachPayload.streamId;
|
||||||
decoder = new TextDecoder();
|
updateTerminalResumeOffset({
|
||||||
setActiveStream({ terminalId, streamId });
|
terminalId,
|
||||||
|
offset: attachPayload.currentOffset,
|
||||||
|
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
||||||
|
});
|
||||||
|
decoder = new TextDecoder();
|
||||||
|
setActiveStream({ terminalId, streamId });
|
||||||
|
setStreamError(null);
|
||||||
|
|
||||||
unsubscribe = client.onTerminalStreamData(streamId, (chunk) => {
|
unsubscribe = client.onTerminalStreamData(streamId, (chunk) => {
|
||||||
if (isCancelled) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const text = decoder?.decode(chunk.data, { stream: true }) ?? "";
|
}
|
||||||
appendOutput(terminalId, text);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
setStreamError(
|
setStreamError(lastErrorMessage);
|
||||||
error instanceof Error ? error.message : "Unable to attach terminal stream"
|
|
||||||
);
|
|
||||||
setActiveStream(null);
|
setActiveStream(null);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -401,8 +502,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
if (streamId !== null) {
|
if (streamId !== null) {
|
||||||
void client.detachTerminalStream(streamId).catch(() => {});
|
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 =
|
const activeStreamId =
|
||||||
activeStream && activeStream.terminalId === selectedTerminalId
|
activeStream && activeStream.terminalId === selectedTerminalId
|
||||||
@@ -477,21 +582,27 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasPendingTerminalModifiers(modifiers)) {
|
if (hasPendingTerminalModifiers(modifiers)) {
|
||||||
const mappedKey = mapTerminalDataToKey(data);
|
const pendingResolution = resolvePendingModifierDataInput({
|
||||||
if (
|
data,
|
||||||
mappedKey &&
|
pendingModifiers: modifiers,
|
||||||
sendTerminalKey(
|
});
|
||||||
{
|
if (pendingResolution.mode === "key") {
|
||||||
key: mappedKey,
|
if (
|
||||||
|
sendTerminalKey({
|
||||||
|
key: pendingResolution.key,
|
||||||
ctrl: modifiers.ctrl,
|
ctrl: modifiers.ctrl,
|
||||||
shift: modifiers.shift,
|
shift: modifiers.shift,
|
||||||
alt: modifiers.alt,
|
alt: modifiers.alt,
|
||||||
meta: false,
|
meta: false,
|
||||||
}
|
})
|
||||||
)
|
) {
|
||||||
) {
|
clearPendingModifiers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingResolution.clearPendingModifiers) {
|
||||||
clearPendingModifiers();
|
clearPendingModifiers();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,9 +855,8 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isAttaching ? (
|
{isAttaching ? (
|
||||||
<View style={styles.attachOverlay}>
|
<View style={styles.attachOverlay} testID="terminal-attach-loading">
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.attachOverlayText}>Attaching terminal…</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -912,22 +1022,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
attachOverlay: {
|
attachOverlay: {
|
||||||
position: "absolute",
|
...StyleSheet.absoluteFillObject,
|
||||||
top: theme.spacing[3],
|
|
||||||
right: theme.spacing[3],
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
justifyContent: "center",
|
||||||
borderWidth: 1,
|
backgroundColor: "rgba(0, 0, 0, 0.16)",
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
errorRow: {
|
errorRow: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@/utils/new-agent-routing";
|
} from "@/utils/new-agent-routing";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDetailRoute,
|
buildHostAgentDetailRoute,
|
||||||
|
parseHostAgentDraftRouteFromPathname,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
@@ -33,12 +34,17 @@ function resolveSelectedOrRouteAgentKey(input: {
|
|||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
|
const DRAFT_AGENT_ID = "__new_agent__";
|
||||||
if (input.selectedAgentId) {
|
if (input.selectedAgentId) {
|
||||||
return input.selectedAgentId;
|
return input.selectedAgentId;
|
||||||
}
|
}
|
||||||
const route = parseHostAgentRouteFromPathname(input.pathname);
|
const route = parseHostAgentRouteFromPathname(input.pathname);
|
||||||
if (!route) {
|
if (!route) {
|
||||||
return null;
|
const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname);
|
||||||
|
if (!draftRoute) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${draftRoute.serverId}:${DRAFT_AGENT_ID}`;
|
||||||
}
|
}
|
||||||
return `${route.serverId}:${route.agentId}`;
|
return `${route.serverId}:${route.agentId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildKeyboardShortcutHelpSections,
|
||||||
resolveKeyboardShortcut,
|
resolveKeyboardShortcut,
|
||||||
type KeyboardShortcutContext,
|
type KeyboardShortcutContext,
|
||||||
} from "./keyboard-shortcuts";
|
} from "./keyboard-shortcuts";
|
||||||
@@ -148,6 +149,21 @@ describe("keyboard-shortcuts", () => {
|
|||||||
expect(match?.payload).toEqual({ kind: "voice-mute-toggle" });
|
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", () => {
|
it("parses Alt+digit sidebar shortcut payload", () => {
|
||||||
const match = resolveKeyboardShortcut({
|
const match = resolveKeyboardShortcut({
|
||||||
event: keyboardEvent({
|
event: keyboardEvent({
|
||||||
@@ -162,3 +178,56 @@ describe("keyboard-shortcuts", () => {
|
|||||||
expect(match?.payload).toEqual({ digit: 2 });
|
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 {
|
import type {
|
||||||
KeyboardActionId,
|
KeyboardActionId,
|
||||||
KeyboardFocusScope,
|
KeyboardFocusScope,
|
||||||
@@ -20,6 +21,33 @@ export type KeyboardShortcutMatch = {
|
|||||||
stopPropagation: boolean;
|
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 = {
|
type KeyboardShortcutBinding = {
|
||||||
id: string;
|
id: string;
|
||||||
action: KeyboardActionId;
|
action: KeyboardActionId;
|
||||||
@@ -28,6 +56,15 @@ type KeyboardShortcutBinding = {
|
|||||||
payload?: (event: KeyboardEvent) => KeyboardShortcutPayload;
|
payload?: (event: KeyboardEvent) => KeyboardShortcutPayload;
|
||||||
preventDefault?: boolean;
|
preventDefault?: boolean;
|
||||||
stopPropagation?: 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 {
|
function isMod(event: KeyboardEvent): boolean {
|
||||||
@@ -82,6 +119,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
||||||
when: () => true,
|
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",
|
id: "agent-new-tauri-mod-n",
|
||||||
@@ -92,6 +136,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
||||||
when: (context) => context.isTauri,
|
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",
|
id: "command-center-toggle",
|
||||||
@@ -102,12 +153,25 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
|
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
|
||||||
when: () => true,
|
when: () => true,
|
||||||
|
help: {
|
||||||
|
id: "toggle-command-center",
|
||||||
|
section: "global",
|
||||||
|
label: "Toggle command center",
|
||||||
|
keys: ["mod", "K"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "shortcuts-dialog-toggle-question-mark",
|
id: "shortcuts-dialog-toggle-question-mark",
|
||||||
action: "shortcuts.dialog.toggle",
|
action: "shortcuts.dialog.toggle",
|
||||||
matches: isQuestionMarkShortcut,
|
matches: isQuestionMarkShortcut,
|
||||||
when: (context) => context.focusScope === "other",
|
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",
|
id: "sidebar-toggle-left-mac-cmd-b",
|
||||||
@@ -119,6 +183,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "KeyB" || event.key.toLowerCase() === "b"),
|
(event.code === "KeyB" || event.key.toLowerCase() === "b"),
|
||||||
when: (context) => context.isMac,
|
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",
|
id: "sidebar-toggle-left-mod-period",
|
||||||
@@ -129,6 +200,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "Period" || event.key === "."),
|
(event.code === "Period" || event.key === "."),
|
||||||
when: (context) => !context.commandCenterOpen,
|
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",
|
id: "sidebar-toggle-right-mod-e",
|
||||||
@@ -139,6 +217,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
!event.shiftKey &&
|
!event.shiftKey &&
|
||||||
(event.code === "KeyE" || event.key.toLowerCase() === "e"),
|
(event.code === "KeyE" || event.key.toLowerCase() === "e"),
|
||||||
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
|
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",
|
id: "sidebar-toggle-right-ctrl-backquote",
|
||||||
@@ -163,6 +247,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
payload: withMessageInputAction("voice-toggle"),
|
payload: withMessageInputAction("voice-toggle"),
|
||||||
when: (context) =>
|
when: (context) =>
|
||||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
!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",
|
id: "message-input-dictation-toggle",
|
||||||
@@ -175,6 +265,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
payload: withMessageInputAction("dictation-toggle"),
|
payload: withMessageInputAction("dictation-toggle"),
|
||||||
when: (context) =>
|
when: (context) =>
|
||||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||||
|
help: {
|
||||||
|
id: "dictation-toggle",
|
||||||
|
section: "agent-input",
|
||||||
|
label: "Start/stop dictation",
|
||||||
|
keys: ["mod", "D"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "message-input-dictation-cancel",
|
id: "message-input-dictation-cancel",
|
||||||
@@ -183,6 +279,14 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
payload: withMessageInputAction("dictation-cancel"),
|
payload: withMessageInputAction("dictation-cancel"),
|
||||||
when: (context) =>
|
when: (context) =>
|
||||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
!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",
|
id: "message-input-voice-mute-toggle",
|
||||||
@@ -197,6 +301,12 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
payload: withMessageInputAction("voice-mute-toggle"),
|
payload: withMessageInputAction("voice-mute-toggle"),
|
||||||
when: (context) =>
|
when: (context) =>
|
||||||
!context.commandCenterOpen && context.focusScope === "other",
|
!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",
|
id: "sidebar-shortcut-alt-digit",
|
||||||
@@ -207,6 +317,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
return digit ? { digit } : null;
|
return digit ? { digit } : null;
|
||||||
},
|
},
|
||||||
when: (context) => !context.commandCenterOpen,
|
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",
|
id: "sidebar-shortcut-tauri-mod-digit",
|
||||||
@@ -217,6 +334,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
return digit ? { digit } : null;
|
return digit ? { digit } : null;
|
||||||
},
|
},
|
||||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
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;
|
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 { useFocusEffect } from "@react-navigation/native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import ReanimatedAnimated, {
|
import ReanimatedAnimated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -59,7 +60,11 @@ import { extractAgentModel } from "@/utils/extract-agent-model";
|
|||||||
import { startPerfMonitor } from "@/utils/perf-monitor";
|
import { startPerfMonitor } from "@/utils/perf-monitor";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
|
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 { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||||
@@ -67,6 +72,7 @@ import {
|
|||||||
derivePendingPermissionKey,
|
derivePendingPermissionKey,
|
||||||
normalizeAgentSnapshot,
|
normalizeAgentSnapshot,
|
||||||
} from "@/utils/agent-snapshots";
|
} from "@/utils/agent-snapshots";
|
||||||
|
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -75,6 +81,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||||
|
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
@@ -210,6 +217,8 @@ function AgentScreenContent({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const resolvedAgentId = agentId;
|
||||||
|
|
||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||||
|
|
||||||
@@ -229,22 +238,100 @@ function AgentScreenContent({
|
|||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
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
|
// Derive isExplorerOpen from the unified panel state
|
||||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
const openExplorerWithDefaultTab = useCallback(() => {
|
// Select only the specific agent
|
||||||
// Generic explorer toggles should land on Changes by default.
|
const agent = useSessionStore((state) =>
|
||||||
setExplorerTab("changes");
|
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();
|
||||||
}, [openFileExplorer, setExplorerTab]);
|
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
|
||||||
const handleToggleExplorer = useCallback(() => {
|
const handleToggleExplorer = useCallback(() => {
|
||||||
if (isExplorerOpen) {
|
if (isExplorerOpen) {
|
||||||
toggleFileExplorer();
|
toggleFileExplorer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openExplorerWithDefaultTab();
|
openExplorerForActiveCheckout();
|
||||||
}, [isExplorerOpen, openExplorerWithDefaultTab, toggleFileExplorer]);
|
}, [isExplorerOpen, openExplorerForActiveCheckout, toggleFileExplorer]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
translateX: explorerTranslateX,
|
translateX: explorerTranslateX,
|
||||||
@@ -254,6 +341,10 @@ function AgentScreenContent({
|
|||||||
animateToClose: animateExplorerToClose,
|
animateToClose: animateExplorerToClose,
|
||||||
isGesturing: isExplorerGesturing,
|
isGesturing: isExplorerGesturing,
|
||||||
} = useExplorerSidebarAnimation();
|
} = useExplorerSidebarAnimation();
|
||||||
|
const handleOpenExplorerFromGesture = useCallback(() => {
|
||||||
|
openExplorerForActiveCheckout();
|
||||||
|
animateExplorerToOpen();
|
||||||
|
}, [animateExplorerToOpen, openExplorerForActiveCheckout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
@@ -293,8 +384,7 @@ function AgentScreenContent({
|
|||||||
// Open if dragged more than 1/3 of window or fast swipe left
|
// Open if dragged more than 1/3 of window or fast swipe left
|
||||||
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
|
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
animateExplorerToOpen();
|
runOnJS(handleOpenExplorerFromGesture)();
|
||||||
runOnJS(openExplorerWithDefaultTab)();
|
|
||||||
} else {
|
} else {
|
||||||
animateExplorerToClose();
|
animateExplorerToClose();
|
||||||
}
|
}
|
||||||
@@ -308,9 +398,8 @@ function AgentScreenContent({
|
|||||||
explorerWindowWidth,
|
explorerWindowWidth,
|
||||||
explorerTranslateX,
|
explorerTranslateX,
|
||||||
explorerBackdropOpacity,
|
explorerBackdropOpacity,
|
||||||
animateExplorerToOpen,
|
|
||||||
animateExplorerToClose,
|
animateExplorerToClose,
|
||||||
openExplorerWithDefaultTab,
|
handleOpenExplorerFromGesture,
|
||||||
isExplorerGesturing,
|
isExplorerGesturing,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -330,14 +419,43 @@ function AgentScreenContent({
|
|||||||
return () => handler.remove();
|
return () => handler.remove();
|
||||||
}, [isExplorerOpen, closeToAgent]);
|
}, [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
|
useEffect(() => {
|
||||||
const agent = useSessionStore((state) =>
|
setActiveExplorerCheckout(activeExplorerCheckout);
|
||||||
resolvedAgentId
|
}, [activeExplorerCheckout, setActiveExplorerCheckout]);
|
||||||
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
|
|
||||||
: undefined
|
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
|
// Select only the specific stream tail - use stable empty array to avoid infinite loop
|
||||||
const streamItemsRaw = useSessionStore((state) =>
|
const streamItemsRaw = useSessionStore((state) =>
|
||||||
@@ -480,12 +598,7 @@ function AgentScreenContent({
|
|||||||
};
|
};
|
||||||
}, [showConnectedNotice]);
|
}, [showConnectedNotice]);
|
||||||
|
|
||||||
// Checkout status for header subtitle
|
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
|
||||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
|
||||||
serverId,
|
|
||||||
cwd: agent?.cwd ?? "",
|
|
||||||
});
|
|
||||||
const checkout = checkoutStatusQuery.status;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resolvedAgentId) {
|
if (!resolvedAgentId) {
|
||||||
@@ -1105,7 +1218,12 @@ function AgentScreenContent({
|
|||||||
|
|
||||||
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
|
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
|
||||||
{!isMobile && isExplorerOpen && resolvedAgentId && (
|
{!isMobile && isExplorerOpen && resolvedAgentId && (
|
||||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
|
<ExplorerSidebar
|
||||||
|
serverId={serverId}
|
||||||
|
agentId={resolvedAgentId}
|
||||||
|
cwd={effectiveAgent.cwd}
|
||||||
|
isGit={isGitCheckout}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -1122,7 +1240,12 @@ function AgentScreenContent({
|
|||||||
|
|
||||||
{/* Mobile Explorer Sidebar Overlay */}
|
{/* Mobile Explorer Sidebar Overlay */}
|
||||||
{isMobile && resolvedAgentId && (
|
{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 { persist, createJSONStorage } from "zustand/middleware";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { Platform } from "react-native";
|
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.
|
* Mobile panel state machine.
|
||||||
@@ -27,8 +35,12 @@ interface DesktopSidebarState {
|
|||||||
fileExplorerOpen: boolean;
|
fileExplorerOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExplorerTab = "changes" | "files" | "terminals";
|
|
||||||
export type SortOption = "name" | "modified" | "size";
|
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 DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400;
|
||||||
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
||||||
@@ -48,6 +60,8 @@ interface PanelState {
|
|||||||
|
|
||||||
// File explorer settings (shared between mobile/desktop)
|
// File explorer settings (shared between mobile/desktop)
|
||||||
explorerTab: ExplorerTab;
|
explorerTab: ExplorerTab;
|
||||||
|
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||||
|
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
||||||
explorerWidth: number;
|
explorerWidth: number;
|
||||||
explorerSortOption: SortOption;
|
explorerSortOption: SortOption;
|
||||||
explorerFilesSplitRatio: number;
|
explorerFilesSplitRatio: number;
|
||||||
@@ -61,6 +75,9 @@ interface PanelState {
|
|||||||
|
|
||||||
// File explorer settings actions
|
// File explorer settings actions
|
||||||
setExplorerTab: (tab: ExplorerTab) => void;
|
setExplorerTab: (tab: ExplorerTab) => void;
|
||||||
|
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
||||||
|
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||||
|
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
||||||
setExplorerWidth: (width: number) => void;
|
setExplorerWidth: (width: number) => void;
|
||||||
setExplorerSortOption: (option: SortOption) => void;
|
setExplorerSortOption: (option: SortOption) => void;
|
||||||
setExplorerFilesSplitRatio: (ratio: number) => 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);
|
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";
|
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
|
||||||
|
|
||||||
export const usePanelStore = create<PanelState>()(
|
export const usePanelStore = create<PanelState>()(
|
||||||
@@ -97,6 +126,8 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
|
|
||||||
// File explorer defaults
|
// File explorer defaults
|
||||||
explorerTab: "changes",
|
explorerTab: "changes",
|
||||||
|
explorerTabByCheckout: {},
|
||||||
|
activeExplorerCheckout: null,
|
||||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||||
explorerSortOption: "name",
|
explorerSortOption: "name",
|
||||||
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||||
@@ -108,10 +139,14 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
openFileExplorer: () =>
|
openFileExplorer: () =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
mobileView: "file-explorer",
|
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
|
||||||
desktop: { ...state.desktop, fileExplorerOpen: true },
|
return {
|
||||||
})),
|
mobileView: "file-explorer",
|
||||||
|
desktop: { ...state.desktop, fileExplorerOpen: true },
|
||||||
|
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
closeToAgent: () =>
|
closeToAgent: () =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -142,17 +177,63 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
toggleFileExplorer: () =>
|
toggleFileExplorer: () =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
// Mobile: toggle between agent and file-explorer
|
// Mobile: toggle between agent and file-explorer
|
||||||
const newMobileView = state.mobileView === "file-explorer" ? "agent" : "file-explorer";
|
const willOpenMobile = state.mobileView !== "file-explorer";
|
||||||
return {
|
const willOpenDesktop = !state.desktop.fileExplorerOpen;
|
||||||
mobileView: newMobileView,
|
const nextState: Partial<PanelState> = {
|
||||||
|
mobileView: willOpenMobile ? "file-explorer" : "agent",
|
||||||
desktop: {
|
desktop: {
|
||||||
...state.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 }),
|
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) }),
|
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
|
||||||
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
||||||
setExplorerFilesSplitRatio: (ratio) =>
|
setExplorerFilesSplitRatio: (ratio) =>
|
||||||
@@ -164,7 +245,7 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "panel-state",
|
name: "panel-state",
|
||||||
version: 3,
|
version: 4,
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
migrate: (persistedState, version) => {
|
migrate: (persistedState, version) => {
|
||||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
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;
|
return state as PanelState;
|
||||||
},
|
},
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
mobileView: state.mobileView,
|
mobileView: state.mobileView,
|
||||||
desktop: state.desktop,
|
desktop: state.desktop,
|
||||||
explorerTab: state.explorerTab,
|
explorerTab: state.explorerTab,
|
||||||
|
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||||
explorerWidth: state.explorerWidth,
|
explorerWidth: state.explorerWidth,
|
||||||
explorerSortOption: state.explorerSortOption,
|
explorerSortOption: state.explorerSortOption,
|
||||||
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
|
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
|
||||||
@@ -234,10 +332,14 @@ export function usePanelState(isMobile: boolean) {
|
|||||||
toggleFileExplorer: store.toggleFileExplorer,
|
toggleFileExplorer: store.toggleFileExplorer,
|
||||||
// Explorer settings
|
// Explorer settings
|
||||||
explorerTab: store.explorerTab,
|
explorerTab: store.explorerTab,
|
||||||
|
explorerTabByCheckout: store.explorerTabByCheckout,
|
||||||
explorerWidth: store.explorerWidth,
|
explorerWidth: store.explorerWidth,
|
||||||
explorerSortOption: store.explorerSortOption,
|
explorerSortOption: store.explorerSortOption,
|
||||||
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
||||||
setExplorerTab: store.setExplorerTab,
|
setExplorerTab: store.setExplorerTab,
|
||||||
|
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
|
||||||
|
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
|
||||||
|
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
|
||||||
setExplorerWidth: store.setExplorerWidth,
|
setExplorerWidth: store.setExplorerWidth,
|
||||||
setExplorerSortOption: store.setExplorerSortOption,
|
setExplorerSortOption: store.setExplorerSortOption,
|
||||||
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
||||||
@@ -262,10 +364,14 @@ export function usePanelState(isMobile: boolean) {
|
|||||||
toggleFileExplorer: store.toggleFileExplorer,
|
toggleFileExplorer: store.toggleFileExplorer,
|
||||||
// Explorer settings
|
// Explorer settings
|
||||||
explorerTab: store.explorerTab,
|
explorerTab: store.explorerTab,
|
||||||
|
explorerTabByCheckout: store.explorerTabByCheckout,
|
||||||
explorerWidth: store.explorerWidth,
|
explorerWidth: store.explorerWidth,
|
||||||
explorerSortOption: store.explorerSortOption,
|
explorerSortOption: store.explorerSortOption,
|
||||||
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
||||||
setExplorerTab: store.setExplorerTab,
|
setExplorerTab: store.setExplorerTab,
|
||||||
|
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
|
||||||
|
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
|
||||||
|
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
|
||||||
setExplorerWidth: store.setExplorerWidth,
|
setExplorerWidth: store.setExplorerWidth,
|
||||||
setExplorerSortOption: store.setExplorerSortOption,
|
setExplorerSortOption: store.setExplorerSortOption,
|
||||||
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
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);
|
return encodeURIComponent(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeSegment(value: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseServerIdFromPathname(pathname: string): string | null {
|
export function parseServerIdFromPathname(pathname: string): string | null {
|
||||||
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -21,11 +29,7 @@ export function parseServerIdFromPathname(pathname: string): string | null {
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
return trimNonEmpty(decodeSegment(raw));
|
||||||
return trimNonEmpty(decodeURIComponent(raw));
|
|
||||||
} catch {
|
|
||||||
return trimNonEmpty(raw);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseHostAgentRouteFromPathname(
|
export function parseHostAgentRouteFromPathname(
|
||||||
@@ -41,16 +45,8 @@ export function parseHostAgentRouteFromPathname(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decode = (value: string) => {
|
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
|
||||||
try {
|
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
|
||||||
return decodeURIComponent(value);
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const serverId = trimNonEmpty(decode(encodedServerId));
|
|
||||||
const agentId = trimNonEmpty(decode(encodedAgentId));
|
|
||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -58,6 +54,24 @@ export function parseHostAgentRouteFromPathname(
|
|||||||
return { serverId, agentId };
|
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 {
|
export function buildHostAgentDraftRoute(serverId: string): string {
|
||||||
const normalized = trimNonEmpty(serverId);
|
const normalized = trimNonEmpty(serverId);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -115,4 +129,3 @@ export function mapPathnameToServer(
|
|||||||
}
|
}
|
||||||
return `${base}/agent`;
|
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,
|
mergeTerminalModifiers,
|
||||||
normalizeDomTerminalKey,
|
normalizeDomTerminalKey,
|
||||||
normalizeTerminalTransportKey,
|
normalizeTerminalTransportKey,
|
||||||
|
resolvePendingModifierDataInput,
|
||||||
shouldInterceptDomTerminalKey,
|
shouldInterceptDomTerminalKey,
|
||||||
} from "./terminal-keys";
|
} from "./terminal-keys";
|
||||||
|
|
||||||
@@ -96,4 +97,41 @@ describe("terminal key helpers", () => {
|
|||||||
expect(mapTerminalDataToKey("\x03")).toBeNull();
|
expect(mapTerminalDataToKey("\x03")).toBeNull();
|
||||||
expect(mapTerminalDataToKey("")).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;
|
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({
|
manager.receiveChunk({
|
||||||
chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }),
|
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({
|
manager.subscribe({
|
||||||
streamId: 7,
|
streamId: 7,
|
||||||
@@ -77,6 +78,9 @@ describe("TerminalStreamManager", () => {
|
|||||||
manager.receiveChunk({
|
manager.receiveChunk({
|
||||||
chunk: createChunk({ streamId: 5, offset: 2, data: "C" }),
|
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[] = [];
|
const seen: string[] = [];
|
||||||
manager.subscribe({
|
manager.subscribe({
|
||||||
@@ -87,8 +91,7 @@ describe("TerminalStreamManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(seen).toEqual(["B", "C"]);
|
expect(seen).toEqual(["B", "C"]);
|
||||||
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 2 });
|
expect(sendAck).toHaveBeenCalledTimes(3);
|
||||||
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 3 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tracks explicit ack offsets and skips stale auto-acks", () => {
|
test("tracks explicit ack offsets and skips stale auto-acks", () => {
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ export class TerminalStreamManager {
|
|||||||
const streamHandlers = this.handlers.get(chunk.streamId);
|
const streamHandlers = this.handlers.get(chunk.streamId);
|
||||||
if (!streamHandlers || streamHandlers.size === 0) {
|
if (!streamHandlers || streamHandlers.size === 0) {
|
||||||
this.bufferChunk({ chunk });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,14 +46,21 @@ import { toAgentPayload } from "./agent-projections.js";
|
|||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||||
import { AgentStorage } from "./agent-storage.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 { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||||
import { expandUserPath } from "../path-utils.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 {
|
export interface AgentManagementMcpOptions {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
|
terminalManager?: TerminalManager | null;
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
@@ -313,12 +320,13 @@ export async function createAgentManagementMcpServer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let resolvedCwd = expandUserPath(cwd);
|
let resolvedCwd = expandUserPath(cwd);
|
||||||
|
let worktreeConfig: WorktreeConfig | undefined;
|
||||||
|
|
||||||
if (worktreeName) {
|
if (worktreeName) {
|
||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
throw new Error("baseBranch is required when creating a worktree");
|
throw new Error("baseBranch is required when creating a worktree");
|
||||||
}
|
}
|
||||||
const worktree = await createWorktree({
|
const worktree = await createAgentWorktree({
|
||||||
branchName: worktreeName,
|
branchName: worktreeName,
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
@@ -326,6 +334,7 @@ export async function createAgentManagementMcpServer(
|
|||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
});
|
});
|
||||||
resolvedCwd = worktree.worktreePath;
|
resolvedCwd = worktree.worktreePath;
|
||||||
|
worktreeConfig = worktree;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider: AgentProvider = agentType ?? "claude";
|
const provider: AgentProvider = agentType ?? "claude";
|
||||||
@@ -337,6 +346,21 @@ export async function createAgentManagementMcpServer(
|
|||||||
title: normalizedTitle ?? undefined,
|
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();
|
const trimmedPrompt = initialPrompt?.trim();
|
||||||
if (trimmedPrompt) {
|
if (trimmedPrompt) {
|
||||||
scheduleAgentMetadataGeneration({
|
scheduleAgentMetadataGeneration({
|
||||||
|
|||||||
@@ -23,6 +23,39 @@ type McpClient = {
|
|||||||
close: () => Promise<void>;
|
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> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
@@ -53,10 +86,13 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForAgentCompletion(client: McpClient, agentId: string): Promise<void> {
|
async function waitForAgentCompletion(options: {
|
||||||
const waitResult = (await client.callTool({
|
client: McpClient;
|
||||||
|
agentId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const waitResult = (await options.client.callTool({
|
||||||
name: "wait_for_agent",
|
name: "wait_for_agent",
|
||||||
args: { agentId },
|
args: { agentId: options.agentId },
|
||||||
})) as McpToolResult;
|
})) as McpToolResult;
|
||||||
const payload = getStructuredContent(waitResult);
|
const payload = getStructuredContent(waitResult);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@@ -127,7 +163,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
|||||||
agentId = (payload?.agentId as string | undefined) ?? null;
|
agentId = (payload?.agentId as string | undefined) ?? null;
|
||||||
expect(agentId).toBeTruthy();
|
expect(agentId).toBeTruthy();
|
||||||
|
|
||||||
await waitForAgentCompletion(client, agentId!);
|
await waitForAgentCompletion({ client, agentId: agentId! });
|
||||||
|
|
||||||
if (existsSync(filePath)) {
|
if (existsSync(filePath)) {
|
||||||
const contents = await readFile(filePath, "utf8");
|
const contents = await readFile(filePath, "utf8");
|
||||||
@@ -148,4 +184,115 @@ describe("agent MCP end-to-end (offline)", () => {
|
|||||||
},
|
},
|
||||||
30_000
|
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 { curateAgentActivity } from "./activity-curator.js";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||||
import { AgentStorage } from "./agent-storage.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 { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -36,10 +37,16 @@ import type {
|
|||||||
VoiceSpeakHandler,
|
VoiceSpeakHandler,
|
||||||
} from "../voice-types.js";
|
} from "../voice-types.js";
|
||||||
import { expandUserPath, resolvePathFromBase } from "../path-utils.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 {
|
export interface AgentMcpServerOptions {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
|
terminalManager?: TerminalManager | null;
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
/**
|
/**
|
||||||
* ID of the agent that is connecting to this MCP server.
|
* ID of the agent that is connecting to this MCP server.
|
||||||
@@ -289,6 +296,7 @@ export async function createAgentMcpServer(
|
|||||||
const {
|
const {
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
terminalManager,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
resolveSpeakHandler,
|
resolveSpeakHandler,
|
||||||
resolveCallerContext,
|
resolveCallerContext,
|
||||||
@@ -469,6 +477,7 @@ export async function createAgentMcpServer(
|
|||||||
|
|
||||||
let resolvedCwd: string;
|
let resolvedCwd: string;
|
||||||
let resolvedMode: string | undefined;
|
let resolvedMode: string | undefined;
|
||||||
|
let worktreeConfig: WorktreeConfig | undefined;
|
||||||
|
|
||||||
if (callerAgentId) {
|
if (callerAgentId) {
|
||||||
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
|
||||||
@@ -514,7 +523,7 @@ export async function createAgentMcpServer(
|
|||||||
if (!baseBranch) {
|
if (!baseBranch) {
|
||||||
throw new Error("baseBranch is required when creating a worktree");
|
throw new Error("baseBranch is required when creating a worktree");
|
||||||
}
|
}
|
||||||
const worktree = await createWorktree({
|
const worktree = await createAgentWorktree({
|
||||||
branchName: worktreeName,
|
branchName: worktreeName,
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
@@ -522,6 +531,7 @@ export async function createAgentMcpServer(
|
|||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
});
|
});
|
||||||
resolvedCwd = worktree.worktreePath;
|
resolvedCwd = worktree.worktreePath;
|
||||||
|
worktreeConfig = worktree;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedMode = initialMode;
|
resolvedMode = initialMode;
|
||||||
@@ -542,6 +552,21 @@ export async function createAgentMcpServer(
|
|||||||
childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined
|
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();
|
const trimmedPrompt = initialPrompt.trim();
|
||||||
scheduleAgentMetadataGeneration({
|
scheduleAgentMetadataGeneration({
|
||||||
agentManager,
|
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({
|
const agentMcpServer = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
terminalManager,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
enableVoiceTools: false,
|
enableVoiceTools: false,
|
||||||
resolveSpeakHandler: (callerAgentId) => wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null,
|
resolveSpeakHandler: (callerAgentId) => wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null,
|
||||||
@@ -329,6 +330,7 @@ export async function createPaseoDaemon(
|
|||||||
const agentMcpServer = await createAgentMcpServer({
|
const agentMcpServer = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
terminalManager,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
enableVoiceTools: false,
|
enableVoiceTools: false,
|
||||||
@@ -448,6 +450,7 @@ export async function createPaseoDaemon(
|
|||||||
return createAgentMcpServer({
|
return createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
terminalManager,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
voiceOnly: true,
|
voiceOnly: true,
|
||||||
|
|||||||
@@ -16,18 +16,16 @@ function tmpCwd(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function withTimeout<T>(
|
async function withTimeout<T>(
|
||||||
promise: Promise<T>,
|
options: { promise: Promise<T>; timeoutMs: number; label: string }
|
||||||
timeoutMs: number,
|
|
||||||
label: string
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||||
const timeout = new Promise<never>((_, reject) => {
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
timeoutHandle = setTimeout(() => {
|
timeoutHandle = setTimeout(() => {
|
||||||
reject(new Error(`Timed out after ${timeoutMs}ms (${label})`));
|
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
|
||||||
}, timeoutMs);
|
}, options.timeoutMs);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
return await Promise.race([promise, timeout]);
|
return await Promise.race([options.promise, timeout]);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutHandle) {
|
if (timeoutHandle) {
|
||||||
clearTimeout(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
|
// 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_MODEL = "gpt-5.1-codex-mini";
|
||||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||||
@@ -352,8 +365,8 @@ describe("daemon E2E", () => {
|
|||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
const agent = await withTimeout(
|
const agent = await withTimeout({
|
||||||
ctx.client.createAgent({
|
promise: ctx.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: CODEX_TEST_MODEL,
|
model: CODEX_TEST_MODEL,
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
@@ -367,9 +380,9 @@ describe("daemon E2E", () => {
|
|||||||
worktreeSlug: "async-setup-test",
|
worktreeSlug: "async-setup-test",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
2500,
|
timeoutMs: 2500,
|
||||||
"createAgent should not block on setup"
|
label: "createAgent should not block on setup",
|
||||||
);
|
});
|
||||||
|
|
||||||
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
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, "setup-done.txt"))).toBe(false);
|
||||||
@@ -396,6 +409,107 @@ describe("daemon E2E", () => {
|
|||||||
60000
|
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(
|
test(
|
||||||
"reports failures via timeline tool_call without deleting the created worktree",
|
"reports failures via timeline tool_call without deleting the created worktree",
|
||||||
async () => {
|
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';
|
'echo "started" > "$PASEO_WORKTREE_PATH/setup-start.txt"; sleep 0.1; echo "boom" 1>&2; exit 7';
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
path.join(repoRoot, "paseo.json"),
|
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 add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", {
|
execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", {
|
||||||
@@ -429,8 +553,8 @@ describe("daemon E2E", () => {
|
|||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
const agent = await withTimeout(
|
const agent = await withTimeout({
|
||||||
ctx.client.createAgent({
|
promise: ctx.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: CODEX_TEST_MODEL,
|
model: CODEX_TEST_MODEL,
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
@@ -444,9 +568,9 @@ describe("daemon E2E", () => {
|
|||||||
worktreeSlug: "async-setup-failure-test",
|
worktreeSlug: "async-setup-failure-test",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
2500,
|
timeoutMs: 2500,
|
||||||
"createAgent should not block on failing setup"
|
label: "createAgent should not block on failing setup",
|
||||||
);
|
});
|
||||||
|
|
||||||
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
||||||
expect(existsSync(agent.cwd)).toBe(true);
|
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, "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 output = failed.detail.type === "unknown" ? failed.detail.output as any : undefined;
|
||||||
const commands = output?.commands as any[] | undefined;
|
const commands = output?.commands as any[] | undefined;
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import type {
|
|||||||
} from "./agent/agent-manager.js";
|
} from "./agent/agent-manager.js";
|
||||||
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
||||||
import { toAgentPayload } from "./agent/agent-projections.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 { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
|
||||||
import {
|
import {
|
||||||
StructuredAgentResponseError,
|
StructuredAgentResponseError,
|
||||||
@@ -83,7 +84,6 @@ import type {
|
|||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentTimelineItem,
|
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.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 { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import { PushTokenStore } from "./push/token-store.js";
|
import { PushTokenStore } from "./push/token-store.js";
|
||||||
import {
|
import {
|
||||||
createWorktree,
|
|
||||||
runWorktreeSetupCommands,
|
|
||||||
WorktreeSetupError,
|
|
||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
type WorktreeSetupCommandResult,
|
slugify,
|
||||||
slugify,
|
validateBranchSlug,
|
||||||
validateBranchSlug,
|
listPaseoWorktrees,
|
||||||
listPaseoWorktrees,
|
deletePaseoWorktree,
|
||||||
deletePaseoWorktree,
|
isPaseoOwnedWorktreeCwd,
|
||||||
isPaseoOwnedWorktreeCwd,
|
resolvePaseoWorktreeRootForCwd,
|
||||||
resolvePaseoWorktreeRootForCwd,
|
} from "../utils/worktree.js";
|
||||||
} from "../utils/worktree.js";
|
import {
|
||||||
|
createAgentWorktree,
|
||||||
|
runAsyncWorktreeBootstrap,
|
||||||
|
} from "./worktree-bootstrap.js";
|
||||||
import {
|
import {
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
@@ -208,19 +208,22 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
|||||||
return `remote:${cleanedHost}/${cleanedPath}`;
|
return `remote:${cleanedHost}/${cleanedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveProjectGroupingKey(cwd: string, remoteUrl: string | null): string {
|
function deriveProjectGroupingKey(options: {
|
||||||
const remoteKey = deriveRemoteProjectKey(remoteUrl);
|
cwd: string;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
}): string {
|
||||||
|
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
|
||||||
if (remoteKey) {
|
if (remoteKey) {
|
||||||
return remoteKey;
|
return remoteKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreeMarker = ".paseo/worktrees/";
|
const worktreeMarker = ".paseo/worktrees/";
|
||||||
const idx = cwd.indexOf(worktreeMarker);
|
const idx = options.cwd.indexOf(worktreeMarker);
|
||||||
if (idx !== -1) {
|
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 {
|
function deriveProjectGroupingName(projectKey: string): string {
|
||||||
@@ -1123,7 +1126,10 @@ export class Session {
|
|||||||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
|
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
|
||||||
.then((status) => this.toProjectCheckoutLite(cwd, status))
|
.then((status) => this.toProjectCheckoutLite(cwd, status))
|
||||||
.catch(() => this.buildFallbackProjectCheckout(cwd));
|
.catch(() => this.buildFallbackProjectCheckout(cwd));
|
||||||
const projectKey = deriveProjectGroupingKey(cwd, checkout.remoteUrl);
|
const projectKey = deriveProjectGroupingKey({
|
||||||
|
cwd,
|
||||||
|
remoteUrl: checkout.remoteUrl,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
projectKey,
|
projectKey,
|
||||||
projectName: deriveProjectGroupingName(projectKey),
|
projectName: deriveProjectGroupingName(projectKey),
|
||||||
@@ -2398,7 +2404,18 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (worktreeConfig) {
|
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(
|
this.sessionLogger.info(
|
||||||
@@ -2627,12 +2644,11 @@ export class Session {
|
|||||||
}' for branch ${targetBranch}`
|
}' for branch ${targetBranch}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const createdWorktree = await createWorktree({
|
const createdWorktree = await createAgentWorktree({
|
||||||
branchName: targetBranch,
|
branchName: targetBranch,
|
||||||
cwd,
|
cwd,
|
||||||
baseBranch: normalized.baseBranch!,
|
baseBranch: normalized.baseBranch!,
|
||||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||||
runSetup: false,
|
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
});
|
});
|
||||||
cwd = createdWorktree.worktreePath;
|
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(
|
private async handleListProviderModelsRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
|
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
|
||||||
): Promise<void> {
|
): 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 {
|
import {
|
||||||
createWorktree,
|
createWorktree,
|
||||||
deletePaseoWorktree,
|
deletePaseoWorktree,
|
||||||
|
getWorktreeTerminalSpecs,
|
||||||
isPaseoOwnedWorktreeCwd,
|
isPaseoOwnedWorktreeCwd,
|
||||||
listPaseoWorktrees,
|
listPaseoWorktrees,
|
||||||
slugify,
|
slugify,
|
||||||
@@ -262,6 +263,43 @@ describe("createWorktree", () => {
|
|||||||
// Verify worktree was cleaned up
|
// Verify worktree was cleaned up
|
||||||
expect(existsSync(expectedWorktreePath)).toBe(false);
|
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", () => {
|
describe("paseo worktree manager", () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface PaseoConfig {
|
|||||||
worktree?: {
|
worktree?: {
|
||||||
setup?: string[];
|
setup?: string[];
|
||||||
destroy?: string[];
|
destroy?: string[];
|
||||||
|
terminals?: WorktreeTerminalConfig[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +34,11 @@ export type WorktreeSetupCommandResult = {
|
|||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface WorktreeTerminalConfig {
|
||||||
|
name?: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class WorktreeSetupError extends Error {
|
export class WorktreeSetupError extends Error {
|
||||||
readonly results: WorktreeSetupCommandResult[];
|
readonly results: WorktreeSetupCommandResult[];
|
||||||
|
|
||||||
@@ -107,6 +113,42 @@ export function getWorktreeDestroyCommands(repoRoot: string): string[] {
|
|||||||
return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
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(
|
async function execSetupCommand(
|
||||||
command: string,
|
command: string,
|
||||||
options: { cwd: string; env: NodeJS.ProcessEnv }
|
options: { cwd: string; env: NodeJS.ProcessEnv }
|
||||||
|
|||||||
Reference in New Issue
Block a user