feat: update sidebar agent workflow and host filter UI
This commit is contained in:
@@ -112,6 +112,6 @@ export default {
|
|||||||
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
|
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
owner: "moboudra",
|
owner: "getpaseo",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,6 +123,16 @@ async function selectNewestTerminalTab(page: Page): Promise<void> {
|
|||||||
await tabs.last().click();
|
await tabs.last().click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getFirstTerminalTabTestId(page: Page): Promise<string> {
|
||||||
|
const firstTab = page.locator('[data-testid^="terminal-tab-"]').first();
|
||||||
|
await expect(firstTab).toBeVisible({ timeout: 30000 });
|
||||||
|
const value = await firstTab.getAttribute("data-testid");
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("Expected terminal tab test id");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
|
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
|
||||||
const surface = page.getByTestId("terminal-surface").first();
|
const surface = page.getByTestId("terminal-surface").first();
|
||||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||||
@@ -231,6 +241,39 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("terminal tab is removed when shell exits", async ({ page }) => {
|
||||||
|
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openNewAgentDraft(page);
|
||||||
|
await setWorkingDirectory(page, repo.path);
|
||||||
|
await ensureHostSelected(page);
|
||||||
|
await createAgent(page, "Terminal exit flow");
|
||||||
|
|
||||||
|
await openTerminalsPanel(page);
|
||||||
|
|
||||||
|
const exitedTabTestId = await getFirstTerminalTabTestId(page);
|
||||||
|
|
||||||
|
const surface = page.getByTestId("terminal-surface").first();
|
||||||
|
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||||
|
await surface.click({ force: true });
|
||||||
|
await page.keyboard.type("exit", { delay: 1 });
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expect(page.getByTestId(exitedTabTestId)).toHaveCount(0, {
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
const nextTabTestId = await getFirstTerminalTabTestId(page);
|
||||||
|
expect(nextTabTestId).not.toBe(exitedTabTestId);
|
||||||
|
} finally {
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
||||||
const repo = await createTempGitRepo("paseo-e2e-terminal-share-");
|
const repo = await createTempGitRepo("paseo-e2e-terminal-share-");
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
"production": {}
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"ascAppId": "6758887924"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,16 +16,25 @@ import { SidebarAgentList } from "./sidebar-agent-list";
|
|||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
|
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
|
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||||
|
import {
|
||||||
|
checkoutStatusQueryKey,
|
||||||
|
type CheckoutStatusPayload,
|
||||||
|
} from "@/hooks/use-checkout-status-query";
|
||||||
|
import { queryClient } from "@/query/query-client";
|
||||||
|
import {
|
||||||
|
buildNewAgentRoute,
|
||||||
|
resolveNewAgentWorkingDir,
|
||||||
|
resolveSelectedAgentForNewAgent,
|
||||||
|
} from "@/utils/new-agent-routing";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDraftRoute,
|
|
||||||
buildHostAgentsRoute,
|
buildHostAgentsRoute,
|
||||||
buildHostSettingsRoute,
|
buildHostSettingsRoute,
|
||||||
mapPathnameToServer,
|
mapPathnameToServer,
|
||||||
@@ -85,14 +94,23 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
|
|
||||||
// Derive isOpen from the unified panel state
|
// Derive isOpen from the unified panel state
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||||
|
const [selectedProjectKeys, setSelectedProjectKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sections,
|
entries,
|
||||||
checkoutByAgentKey,
|
projectOptions,
|
||||||
|
hasMoreEntries,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
} = useSidebarAgentsGrouped({ isOpen, serverId: activeServerId });
|
} = useSidebarAgentsGrouped({
|
||||||
|
isOpen,
|
||||||
|
serverId: activeServerId,
|
||||||
|
selectedProjectKeys,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedProjectKeys([]);
|
||||||
|
}, [activeServerId]);
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -102,7 +120,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
isGesturing,
|
isGesturing,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
const trafficLightPadding = useTrafficLightPadding();
|
|
||||||
const dragHandlers = useTauriDragHandlers();
|
const dragHandlers = useTauriDragHandlers();
|
||||||
|
|
||||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||||
@@ -120,13 +137,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
}
|
}
|
||||||
}, [isRevalidating, isManualRefresh]);
|
}, [isRevalidating, isManualRefresh]);
|
||||||
|
|
||||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
|
||||||
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
|
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
|
||||||
(s) => s.setSidebarShortcutAgentKeys
|
(s) => s.setSidebarShortcutAgentKeys
|
||||||
);
|
);
|
||||||
const sidebarShortcutAgentKeys = useMemo(() => {
|
const sidebarShortcutAgentKeys = useMemo(() => {
|
||||||
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
|
return entries.slice(0, 9).map((entry) => `${entry.agent.serverId}:${entry.agent.id}`);
|
||||||
}, [collapsedProjectKeys, sections]);
|
}, [entries]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
|
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
|
||||||
@@ -137,11 +153,34 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
}, [closeToAgent]);
|
}, [closeToAgent]);
|
||||||
|
|
||||||
const handleCreateAgentClean = useCallback(() => {
|
const handleCreateAgentClean = useCallback(() => {
|
||||||
if (!activeServerId) {
|
let targetServerId = activeServerId;
|
||||||
|
let targetWorkingDir: string | null = null;
|
||||||
|
|
||||||
|
const selectedAgent = resolveSelectedAgentForNewAgent({
|
||||||
|
pathname,
|
||||||
|
selectedAgentId,
|
||||||
|
});
|
||||||
|
if (selectedAgent) {
|
||||||
|
targetServerId = selectedAgent.serverId;
|
||||||
|
const agent = useSessionStore
|
||||||
|
.getState()
|
||||||
|
.sessions[selectedAgent.serverId]
|
||||||
|
?.agents?.get(selectedAgent.agentId);
|
||||||
|
const cwd = agent?.cwd?.trim();
|
||||||
|
if (cwd) {
|
||||||
|
const checkout =
|
||||||
|
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||||
|
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
|
||||||
|
) ?? null;
|
||||||
|
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(buildHostAgentDraftRoute(activeServerId) as any);
|
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
|
||||||
}, [activeServerId]);
|
}, [activeServerId, pathname, selectedAgentId]);
|
||||||
|
|
||||||
// Mobile: close sidebar and navigate
|
// Mobile: close sidebar and navigate
|
||||||
const handleCreateAgentCleanMobile = useCallback(() => {
|
const handleCreateAgentCleanMobile = useCallback(() => {
|
||||||
@@ -198,6 +237,27 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const listFooterComponent = useMemo(() => {
|
||||||
|
if (!hasMoreEntries) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable style={styles.listViewMoreButton} onPress={handleViewMore}>
|
||||||
|
{({ hovered }) => (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.listViewMoreButtonText,
|
||||||
|
hovered && styles.listViewMoreButtonTextHovered,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
View more
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}, [handleViewMore, hasMoreEntries]);
|
||||||
|
|
||||||
const handleHostSelect = useCallback(
|
const handleHostSelect = useCallback(
|
||||||
(nextServerId: string) => {
|
(nextServerId: string) => {
|
||||||
if (!nextServerId) {
|
if (!nextServerId) {
|
||||||
@@ -299,9 +359,36 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Middle: scrollable agent list */}
|
||||||
|
{isInitialLoad ? (
|
||||||
|
<SidebarAgentListSkeleton />
|
||||||
|
) : (
|
||||||
|
<SidebarAgentList
|
||||||
|
entries={entries}
|
||||||
|
projectOptions={projectOptions}
|
||||||
|
selectedProjectKeys={selectedProjectKeys}
|
||||||
|
onSelectedProjectKeysChange={setSelectedProjectKeys}
|
||||||
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
listFooterComponent={listFooterComponent}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
onAgentSelect={handleAgentSelectMobile}
|
||||||
|
parentGestureRef={closeGestureRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={styles.sidebarFooter}>
|
||||||
|
<View style={styles.footerHostSlot}>
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={hostTriggerRef}
|
ref={hostTriggerRef}
|
||||||
style={styles.hostTrigger}
|
style={({ hovered = false }) => [
|
||||||
|
styles.hostTrigger,
|
||||||
|
hovered && styles.hostTriggerHovered,
|
||||||
|
]}
|
||||||
onPress={() => setIsHostPickerOpen(true)}
|
onPress={() => setIsHostPickerOpen(true)}
|
||||||
disabled={hostOptions.length === 0}
|
disabled={hostOptions.length === 0}
|
||||||
>
|
>
|
||||||
@@ -316,10 +403,47 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.footerIconRow}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.footerIconButton}
|
||||||
|
testID="sidebar-all-agents"
|
||||||
|
nativeID="sidebar-all-agents"
|
||||||
|
collapsable={false}
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="All agents"
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleViewMore}
|
||||||
|
>
|
||||||
|
{({ hovered }) => (
|
||||||
|
<Users
|
||||||
|
size={20}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={styles.footerIconButton}
|
||||||
|
testID="sidebar-settings"
|
||||||
|
nativeID="sidebar-settings"
|
||||||
|
collapsable={false}
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="Settings"
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleSettingsMobile}
|
||||||
|
>
|
||||||
|
{({ hovered }) => (
|
||||||
|
<Settings
|
||||||
|
size={20}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={hostOptions}
|
options={hostOptions}
|
||||||
value={activeServerId ?? ""}
|
value={activeServerId ?? ""}
|
||||||
onSelect={handleHostSelect}
|
onSelect={handleHostSelect}
|
||||||
|
searchable={false}
|
||||||
title="Switch host"
|
title="Switch host"
|
||||||
searchPlaceholder="Search hosts..."
|
searchPlaceholder="Search hosts..."
|
||||||
open={isHostPickerOpen}
|
open={isHostPickerOpen}
|
||||||
@@ -327,54 +451,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
anchorRef={hostTriggerRef}
|
anchorRef={hostTriggerRef}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Middle: scrollable agent list */}
|
|
||||||
{isInitialLoad ? (
|
|
||||||
<SidebarAgentListSkeleton />
|
|
||||||
) : (
|
|
||||||
<SidebarAgentList
|
|
||||||
sections={sections}
|
|
||||||
checkoutByAgentKey={checkoutByAgentKey}
|
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
selectedAgentId={selectedAgentId}
|
|
||||||
onAgentSelect={handleAgentSelectMobile}
|
|
||||||
parentGestureRef={closeGestureRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<View style={styles.sidebarFooter}>
|
|
||||||
<Pressable
|
|
||||||
style={styles.footerButton}
|
|
||||||
onPress={handleViewMore}
|
|
||||||
>
|
|
||||||
{({ hovered }) => (
|
|
||||||
<>
|
|
||||||
<Users size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
|
||||||
<Text style={[styles.footerButtonText, hovered && styles.footerButtonTextHovered]}>
|
|
||||||
All agents
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
<View style={styles.footerIconRow}>
|
|
||||||
<Pressable
|
|
||||||
style={styles.footerIconButton}
|
|
||||||
testID="sidebar-settings"
|
|
||||||
nativeID="sidebar-settings"
|
|
||||||
collapsable={false}
|
|
||||||
accessible
|
|
||||||
accessibilityLabel="Settings"
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={handleSettingsMobile}
|
|
||||||
>
|
|
||||||
{({ hovered }) => (
|
|
||||||
<Settings size={20} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
@@ -389,11 +465,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||||
{/* Header: New Agent button - top padding area is draggable on Tauri */}
|
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||||
<View
|
|
||||||
style={[styles.sidebarHeader, { paddingTop: trafficLightPadding.top || styles.sidebarHeader.paddingTop }]}
|
|
||||||
{...dragHandlers}
|
|
||||||
>
|
|
||||||
<View style={styles.sidebarHeaderRow}>
|
<View style={styles.sidebarHeaderRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.newAgentButton}
|
style={styles.newAgentButton}
|
||||||
@@ -403,13 +475,38 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<>
|
<>
|
||||||
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Middle: scrollable agent list */}
|
||||||
|
{isInitialLoad ? (
|
||||||
|
<SidebarAgentListSkeleton />
|
||||||
|
) : (
|
||||||
|
<SidebarAgentList
|
||||||
|
entries={entries}
|
||||||
|
projectOptions={projectOptions}
|
||||||
|
selectedProjectKeys={selectedProjectKeys}
|
||||||
|
onSelectedProjectKeysChange={setSelectedProjectKeys}
|
||||||
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
listFooterComponent={listFooterComponent}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={styles.sidebarFooter}>
|
||||||
|
<View style={styles.footerHostSlot}>
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={hostTriggerRef}
|
ref={hostTriggerRef}
|
||||||
style={styles.hostTrigger}
|
style={({ hovered = false }) => [
|
||||||
|
styles.hostTrigger,
|
||||||
|
hovered && styles.hostTriggerHovered,
|
||||||
|
]}
|
||||||
onPress={() => setIsHostPickerOpen(true)}
|
onPress={() => setIsHostPickerOpen(true)}
|
||||||
disabled={hostOptions.length === 0}
|
disabled={hostOptions.length === 0}
|
||||||
>
|
>
|
||||||
@@ -424,47 +521,24 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<Combobox
|
|
||||||
options={hostOptions}
|
|
||||||
value={activeServerId ?? ""}
|
|
||||||
onSelect={handleHostSelect}
|
|
||||||
title="Switch host"
|
|
||||||
searchPlaceholder="Search hosts..."
|
|
||||||
open={isHostPickerOpen}
|
|
||||||
onOpenChange={setIsHostPickerOpen}
|
|
||||||
anchorRef={hostTriggerRef}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Middle: scrollable agent list */}
|
|
||||||
{isInitialLoad ? (
|
|
||||||
<SidebarAgentListSkeleton />
|
|
||||||
) : (
|
|
||||||
<SidebarAgentList
|
|
||||||
sections={sections}
|
|
||||||
checkoutByAgentKey={checkoutByAgentKey}
|
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
selectedAgentId={selectedAgentId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<View style={styles.sidebarFooter}>
|
|
||||||
<Pressable
|
|
||||||
style={styles.footerButton}
|
|
||||||
onPress={handleViewMore}
|
|
||||||
>
|
|
||||||
{({ hovered }) => (
|
|
||||||
<>
|
|
||||||
<Users size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
|
||||||
<Text style={[styles.footerButtonText, hovered && styles.footerButtonTextHovered]}>
|
|
||||||
All agents
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
<View style={styles.footerIconRow}>
|
<View style={styles.footerIconRow}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.footerIconButton}
|
||||||
|
testID="sidebar-all-agents"
|
||||||
|
nativeID="sidebar-all-agents"
|
||||||
|
collapsable={false}
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="All agents"
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={handleViewMore}
|
||||||
|
>
|
||||||
|
{({ hovered }) => (
|
||||||
|
<Users
|
||||||
|
size={20}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.footerIconButton}
|
style={styles.footerIconButton}
|
||||||
testID="sidebar-settings"
|
testID="sidebar-settings"
|
||||||
@@ -480,6 +554,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
<Combobox
|
||||||
|
options={hostOptions}
|
||||||
|
value={activeServerId ?? ""}
|
||||||
|
onSelect={handleHostSelect}
|
||||||
|
searchable={false}
|
||||||
|
title="Switch host"
|
||||||
|
searchPlaceholder="Search hosts..."
|
||||||
|
open={isHostPickerOpen}
|
||||||
|
onOpenChange={setIsHostPickerOpen}
|
||||||
|
anchorRef={hostTriggerRef}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -512,9 +597,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
sidebarHeader: {
|
sidebarHeader: {
|
||||||
paddingHorizontal: theme.spacing[4],
|
height: {
|
||||||
paddingTop: theme.spacing[4],
|
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||||
paddingBottom: theme.spacing[3],
|
md: HEADER_INNER_HEIGHT,
|
||||||
|
},
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
justifyContent: "center",
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
@@ -522,7 +610,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
sidebarHeaderRow: {
|
sidebarHeaderRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "flex-start",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
newAgentButton: {
|
newAgentButton: {
|
||||||
@@ -545,12 +633,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
hostTrigger: {
|
hostTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-start",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
maxWidth: "55%",
|
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
hostTriggerHovered: {
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
},
|
},
|
||||||
hostStatusDot: {
|
hostStatusDot: {
|
||||||
width: 8,
|
width: 8,
|
||||||
@@ -560,6 +654,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
hostTriggerText: {
|
hostTriggerText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
sidebarFooter: {
|
sidebarFooter: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -570,28 +666,43 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: theme.colors.border,
|
borderTopColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
|
footerHostSlot: {
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
marginRight: theme.spacing[2],
|
||||||
|
},
|
||||||
footerIconRow: {
|
footerIconRow: {
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
|
||||||
footerButton: {
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
flexShrink: 0,
|
||||||
paddingHorizontal: theme.spacing[1],
|
|
||||||
},
|
},
|
||||||
footerIconButton: {
|
footerIconButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
},
|
},
|
||||||
footerButtonText: {
|
listViewMoreButton: {
|
||||||
fontSize: theme.fontSize.base,
|
marginTop: theme.spacing[2],
|
||||||
fontWeight: theme.fontWeight.normal,
|
marginHorizontal: theme.spacing[2],
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
},
|
||||||
|
listViewMoreButtonText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
footerButtonTextHovered: {
|
listViewMoreButtonTextHovered: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
hostPickerList: {
|
hostPickerList: {
|
||||||
|
|||||||
@@ -384,7 +384,6 @@ export default function TerminalEmulator({
|
|||||||
terminal.write(outputText);
|
terminal.write(outputText);
|
||||||
renderedOutputRef.current = outputText;
|
renderedOutputRef.current = outputText;
|
||||||
}
|
}
|
||||||
terminal.focus();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Plus, RefreshCw } from "lucide-react-native";
|
import { Plus, X } from "lucide-react-native";
|
||||||
|
import Svg, {
|
||||||
|
Defs,
|
||||||
|
LinearGradient as SvgLinearGradient,
|
||||||
|
Rect,
|
||||||
|
Stop,
|
||||||
|
} from "react-native-svg";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +29,7 @@ interface TerminalPaneProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAX_OUTPUT_CHARS = 200_000;
|
const MAX_OUTPUT_CHARS = 200_000;
|
||||||
|
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||||
|
|
||||||
const MODIFIER_LABELS = {
|
const MODIFIER_LABELS = {
|
||||||
ctrl: "Ctrl",
|
ctrl: "Ctrl",
|
||||||
@@ -58,6 +65,29 @@ function terminalScopeKey(serverId: string, cwd: string): string {
|
|||||||
return `${serverId}:${cwd}`;
|
return `${serverId}:${cwd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.terminalTabCloseGradient} pointerEvents="none">
|
||||||
|
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||||
|
<Defs>
|
||||||
|
<SvgLinearGradient
|
||||||
|
id={gradientId}
|
||||||
|
x1="0%"
|
||||||
|
y1="0%"
|
||||||
|
x2="100%"
|
||||||
|
y2="0%"
|
||||||
|
>
|
||||||
|
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
||||||
|
<Stop offset="10%" stopColor={color} stopOpacity={1} />
|
||||||
|
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
</Defs>
|
||||||
|
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
@@ -85,6 +115,62 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
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);
|
||||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||||
|
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
|
||||||
|
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedTerminalIdRef.current = selectedTerminalId;
|
||||||
|
}, [selectedTerminalId]);
|
||||||
|
|
||||||
|
const clearHoverOutTimeout = useCallback(() => {
|
||||||
|
if (!hoverOutTimeoutRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(hoverOutTimeoutRef.current);
|
||||||
|
hoverOutTimeoutRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTerminalTabHoverIn = useCallback(
|
||||||
|
(terminalId: string) => {
|
||||||
|
clearHoverOutTimeout();
|
||||||
|
setHoveredTerminalId(terminalId);
|
||||||
|
},
|
||||||
|
[clearHoverOutTimeout]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTerminalTabHoverOut = useCallback(
|
||||||
|
(terminalId: string) => {
|
||||||
|
clearHoverOutTimeout();
|
||||||
|
hoverOutTimeoutRef.current = setTimeout(() => {
|
||||||
|
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||||
|
setHoveredCloseTerminalId((current) =>
|
||||||
|
current === terminalId ? null : current
|
||||||
|
);
|
||||||
|
}, 50);
|
||||||
|
},
|
||||||
|
[clearHoverOutTimeout]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTerminalCloseHoverIn = useCallback(
|
||||||
|
(terminalId: string) => {
|
||||||
|
clearHoverOutTimeout();
|
||||||
|
setHoveredTerminalId(terminalId);
|
||||||
|
setHoveredCloseTerminalId(terminalId);
|
||||||
|
},
|
||||||
|
[clearHoverOutTimeout]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTerminalCloseHoverOut = useCallback((terminalId: string) => {
|
||||||
|
setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearHoverOutTimeout();
|
||||||
|
}, [clearHoverOutTimeout]);
|
||||||
|
|
||||||
const terminalsQuery = useQuery({
|
const terminalsQuery = useQuery({
|
||||||
queryKey: ["terminals", serverId, cwd] as const,
|
queryKey: ["terminals", serverId, cwd] as const,
|
||||||
@@ -100,6 +186,42 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
|
|
||||||
const terminals = terminalsQuery.data?.terminals ?? [];
|
const terminals = terminalsQuery.data?.terminals ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || !isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.on("terminal_stream_exit", (message) => {
|
||||||
|
if (message.type !== "terminal_stream_exit") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitedTerminalId = message.payload.terminalId;
|
||||||
|
if (!exitedTerminalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTerminalIdRef.current === exitedTerminalId) {
|
||||||
|
setSelectedTerminalId((current) =>
|
||||||
|
current === exitedTerminalId ? null : current
|
||||||
|
);
|
||||||
|
setActiveStream((current) =>
|
||||||
|
current?.terminalId === exitedTerminalId ? null : current
|
||||||
|
);
|
||||||
|
setIsAttaching(false);
|
||||||
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["terminals", serverId, cwd],
|
||||||
|
});
|
||||||
|
void queryClient.refetchQueries({
|
||||||
|
queryKey: ["terminals", serverId, cwd],
|
||||||
|
type: "active",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [client, cwd, isConnected, queryClient, serverId]);
|
||||||
|
|
||||||
const createTerminalMutation = useMutation({
|
const createTerminalMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -118,6 +240,39 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const killTerminalMutation = useMutation({
|
||||||
|
mutationFn: async (terminalId: string) => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Host is not connected");
|
||||||
|
}
|
||||||
|
const payload = await client.killTerminal(terminalId);
|
||||||
|
if (!payload.success) {
|
||||||
|
throw new Error("Unable to close terminal");
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
onSuccess: (_, terminalId) => {
|
||||||
|
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||||
|
if (selectedTerminalIdRef.current === terminalId) {
|
||||||
|
setSelectedTerminalId((current) =>
|
||||||
|
current === terminalId ? null : current
|
||||||
|
);
|
||||||
|
setActiveStream((current) =>
|
||||||
|
current?.terminalId === terminalId ? null : current
|
||||||
|
);
|
||||||
|
setIsAttaching(false);
|
||||||
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["terminals", serverId, cwd],
|
||||||
|
});
|
||||||
|
void queryClient.refetchQueries({
|
||||||
|
queryKey: ["terminals", serverId, cwd],
|
||||||
|
type: "active",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
|
setSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
@@ -263,14 +418,23 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
? (outputByTerminalId.get(selectedTerminalId) ?? "")
|
? (outputByTerminalId.get(selectedTerminalId) ?? "")
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
|
||||||
void terminalsQuery.refetch();
|
|
||||||
}, [terminalsQuery]);
|
|
||||||
|
|
||||||
const handleCreateTerminal = useCallback(() => {
|
const handleCreateTerminal = useCallback(() => {
|
||||||
createTerminalMutation.mutate();
|
createTerminalMutation.mutate();
|
||||||
}, [createTerminalMutation]);
|
}, [createTerminalMutation]);
|
||||||
|
|
||||||
|
const handleCloseTerminal = useCallback(
|
||||||
|
(terminalId: string) => {
|
||||||
|
if (
|
||||||
|
killTerminalMutation.isPending &&
|
||||||
|
killTerminalMutation.variables === terminalId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
killTerminalMutation.mutate(terminalId);
|
||||||
|
},
|
||||||
|
[killTerminalMutation]
|
||||||
|
);
|
||||||
|
|
||||||
const requestTerminalFocus = useCallback(() => {
|
const requestTerminalFocus = useCallback(() => {
|
||||||
setFocusRequestToken((current) => current + 1);
|
setFocusRequestToken((current) => current + 1);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -430,12 +594,15 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
const queryError =
|
const queryError =
|
||||||
terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
|
terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
|
||||||
const isCreating = createTerminalMutation.isPending;
|
const isCreating = createTerminalMutation.isPending;
|
||||||
const isRefreshing = terminalsQuery.isFetching;
|
|
||||||
const createError =
|
const createError =
|
||||||
createTerminalMutation.error instanceof Error
|
createTerminalMutation.error instanceof Error
|
||||||
? createTerminalMutation.error.message
|
? createTerminalMutation.error.message
|
||||||
: null;
|
: null;
|
||||||
const combinedError = streamError ?? createError ?? queryError;
|
const closeError =
|
||||||
|
killTerminalMutation.error instanceof Error
|
||||||
|
? killTerminalMutation.error.message
|
||||||
|
: null;
|
||||||
|
const combinedError = streamError ?? closeError ?? createError ?? queryError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -448,40 +615,82 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
>
|
>
|
||||||
{terminals.map((terminal) => {
|
{terminals.map((terminal) => {
|
||||||
const isActive = terminal.id === selectedTerminalId;
|
const isActive = terminal.id === selectedTerminalId;
|
||||||
|
const isTabHovered = hoveredTerminalId === terminal.id;
|
||||||
|
const isCloseHovered = hoveredCloseTerminalId === terminal.id;
|
||||||
|
const isClosingTerminal =
|
||||||
|
killTerminalMutation.isPending &&
|
||||||
|
killTerminalMutation.variables === terminal.id;
|
||||||
|
const shouldShowCloseButton =
|
||||||
|
isTabHovered || isCloseHovered || isClosingTerminal;
|
||||||
|
const gradientId = `terminal-close-gradient-${terminal.id.replace(
|
||||||
|
/[^a-zA-Z0-9_-]/g,
|
||||||
|
"-"
|
||||||
|
)}`;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={terminal.id}
|
key={terminal.id}
|
||||||
testID={`terminal-tab-${terminal.id}`}
|
testID={`terminal-tab-${terminal.id}`}
|
||||||
onPress={() => setSelectedTerminalId(terminal.id)}
|
onPress={() => setSelectedTerminalId(terminal.id)}
|
||||||
|
onHoverIn={() => handleTerminalTabHoverIn(terminal.id)}
|
||||||
|
onHoverOut={() => handleTerminalTabHoverOut(terminal.id)}
|
||||||
style={({ pressed, hovered }) => [
|
style={({ pressed, hovered }) => [
|
||||||
styles.terminalTab,
|
styles.terminalTab,
|
||||||
isActive && styles.terminalTabActive,
|
isActive && styles.terminalTabActive,
|
||||||
|
shouldShowCloseButton && styles.terminalTabHovered,
|
||||||
(pressed || hovered) && styles.terminalTabHovered,
|
(pressed || hovered) && styles.terminalTabHovered,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}>
|
<Text
|
||||||
|
style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="tail"
|
||||||
|
>
|
||||||
{terminal.name}
|
{terminal.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
testID={`terminal-close-${terminal.id}`}
|
||||||
|
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
||||||
|
disabled={!shouldShowCloseButton || isClosingTerminal}
|
||||||
|
onHoverIn={() => handleTerminalCloseHoverIn(terminal.id)}
|
||||||
|
onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
|
||||||
|
onPress={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleCloseTerminal(terminal.id);
|
||||||
|
}}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.terminalTabCloseButton,
|
||||||
|
shouldShowCloseButton
|
||||||
|
? styles.terminalTabCloseButtonShown
|
||||||
|
: styles.terminalTabCloseButtonHidden,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{({ hovered = false, pressed = false }) => {
|
||||||
|
const iconColor =
|
||||||
|
hovered || pressed
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.foregroundMuted;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TerminalCloseGradient
|
||||||
|
color={theme.colors.surface2}
|
||||||
|
gradientId={gradientId}
|
||||||
|
/>
|
||||||
|
<View style={styles.terminalTabCloseIcon}>
|
||||||
|
{isClosingTerminal ? (
|
||||||
|
<ActivityIndicator size={12} color={iconColor} />
|
||||||
|
) : (
|
||||||
|
<X size={12} color={iconColor} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
<Pressable
|
|
||||||
testID="terminals-refresh-button"
|
|
||||||
onPress={handleRefresh}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.headerIconButton,
|
|
||||||
(hovered || pressed) && styles.headerIconButtonHovered,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isRefreshing ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<RefreshCw size={16} color={theme.colors.foregroundMuted} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="terminals-create-button"
|
testID="terminals-create-button"
|
||||||
onPress={handleCreateTerminal}
|
onPress={handleCreateTerminal}
|
||||||
@@ -605,9 +814,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[1],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
tabsScroll: {
|
tabsScroll: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -620,27 +829,59 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[2],
|
||||||
},
|
},
|
||||||
terminalTab: {
|
terminalTab: {
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
backgroundColor: theme.colors.surface1,
|
justifyContent: "center",
|
||||||
|
maxWidth: TERMINAL_TAB_MAX_WIDTH,
|
||||||
|
minWidth: 96,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
},
|
},
|
||||||
terminalTabHovered: {
|
terminalTabHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
terminalTabActive: {
|
terminalTabActive: {
|
||||||
borderColor: theme.colors.primary,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
terminalTabText: {
|
terminalTabText: {
|
||||||
|
minWidth: 0,
|
||||||
|
flexShrink: 1,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
terminalTabTextActive: {
|
terminalTabTextActive: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontWeight: theme.fontWeight.medium,
|
},
|
||||||
|
terminalTabCloseButton: {
|
||||||
|
position: "absolute",
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 32,
|
||||||
|
borderRadius: theme.borderRadius.sm,
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingRight: theme.spacing[2],
|
||||||
|
},
|
||||||
|
terminalTabCloseButtonShown: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
terminalTabCloseButtonHidden: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
terminalTabCloseGradient: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
borderRadius: theme.borderRadius.sm,
|
||||||
|
overflow: "hidden",
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
terminalTabCloseIcon: {
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
headerActions: {
|
headerActions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface ComboboxProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onSearchQueryChange?: (query: string) => void;
|
onSearchQueryChange?: (query: string) => void;
|
||||||
|
searchable?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
@@ -45,6 +46,7 @@ export interface ComboboxProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
desktopPlacement?: "top-start" | "bottom-start";
|
||||||
anchorRef: React.RefObject<View | null>;
|
anchorRef: React.RefObject<View | null>;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -158,6 +160,7 @@ export function Combobox({
|
|||||||
value,
|
value,
|
||||||
onSelect,
|
onSelect,
|
||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
|
searchable = true,
|
||||||
placeholder = "Search...",
|
placeholder = "Search...",
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
emptyText = "No options match your search.",
|
emptyText = "No options match your search.",
|
||||||
@@ -167,6 +170,7 @@ export function Combobox({
|
|||||||
title = "Select",
|
title = "Select",
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
desktopPlacement = "top-start",
|
||||||
anchorRef,
|
anchorRef,
|
||||||
children,
|
children,
|
||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
@@ -245,7 +249,7 @@ export function Combobox({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { refs, floatingStyles, update } = useFloating({
|
const { refs, floatingStyles, update } = useFloating({
|
||||||
placement: Platform.OS === "web" ? "top-start" : "bottom-start",
|
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
|
||||||
middleware,
|
middleware,
|
||||||
sameScrollView: false,
|
sameScrollView: false,
|
||||||
elements: {
|
elements: {
|
||||||
@@ -261,7 +265,7 @@ export function Combobox({
|
|||||||
}
|
}
|
||||||
const raf = requestAnimationFrame(() => update());
|
const raf = requestAnimationFrame(() => update());
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [isMobile, update, isOpen]);
|
}, [desktopPlacement, isMobile, update, isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
@@ -293,7 +297,7 @@ export function Combobox({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalizedSearch = searchQuery.trim().toLowerCase();
|
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||||
const filteredOptions = useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
if (!normalizedSearch) {
|
if (!normalizedSearch) {
|
||||||
return options;
|
return options;
|
||||||
@@ -308,6 +312,7 @@ export function Combobox({
|
|||||||
|
|
||||||
const sanitizedSearchValue = searchQuery.trim();
|
const sanitizedSearchValue = searchQuery.trim();
|
||||||
const showCustomOption =
|
const showCustomOption =
|
||||||
|
searchable &&
|
||||||
allowCustomValue &&
|
allowCustomValue &&
|
||||||
sanitizedSearchValue.length > 0 &&
|
sanitizedSearchValue.length > 0 &&
|
||||||
!options.some(
|
!options.some(
|
||||||
@@ -463,7 +468,7 @@ export function Combobox({
|
|||||||
|
|
||||||
const content = children ?? (
|
const content = children ?? (
|
||||||
<>
|
<>
|
||||||
{searchInput}
|
{searchable ? searchInput : null}
|
||||||
{optionsList}
|
{optionsList}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -537,7 +542,7 @@ export function Combobox({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{searchInput}
|
{searchable ? searchInput : null}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.desktopScrollContent}
|
contentContainerStyle={styles.desktopScrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
@@ -581,7 +586,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: 0,
|
||||||
...(IS_WEB
|
...(IS_WEB
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -605,6 +605,10 @@ export function SessionProvider({
|
|||||||
console.log("[Session] Agent update:", agent.id, agent.status);
|
console.log("[Session] Agent update:", agent.id, agent.status);
|
||||||
|
|
||||||
setAgents(serverId, (prev) => {
|
setAgents(serverId, (prev) => {
|
||||||
|
const current = prev.get(agent.id);
|
||||||
|
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(agent.id, agent);
|
next.set(agent.id, agent);
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
|||||||
const pendingPermissions = new Map();
|
const pendingPermissions = new Map();
|
||||||
const agentLastActivity = new Map();
|
const agentLastActivity = new Map();
|
||||||
|
|
||||||
for (const snapshot of agentsList) {
|
for (const { agent: snapshot } of agentsList.entries) {
|
||||||
const agent = normalizeAgentSnapshot(snapshot, serverId);
|
const agent = normalizeAgentSnapshot(snapshot, serverId);
|
||||||
agents.set(agent.id, agent);
|
agents.set(agent.id, agent);
|
||||||
agentLastActivity.set(agent.id, agent.lastActivityAt);
|
agentLastActivity.set(agent.id, agent.lastActivityAt);
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ export function useAllAgentsList(options?: {
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Daemon client not available");
|
throw new Error("Daemon client not available");
|
||||||
}
|
}
|
||||||
return await client.fetchAgents();
|
return await client.fetchAgents({
|
||||||
|
filter: { labels: { ui: "true" } },
|
||||||
|
});
|
||||||
},
|
},
|
||||||
enabled: canFetch,
|
enabled: canFetch,
|
||||||
staleTime: ALL_AGENTS_STALE_TIME,
|
staleTime: ALL_AGENTS_STALE_TIME,
|
||||||
@@ -76,11 +78,12 @@ export function useAllAgentsList(options?: {
|
|||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const data = agentsQuery.data ?? [];
|
const data = agentsQuery.data?.entries ?? [];
|
||||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||||
const list: AggregatedAgent[] = [];
|
const list: AggregatedAgent[] = [];
|
||||||
|
|
||||||
for (const snapshot of data) {
|
for (const entry of data) {
|
||||||
|
const snapshot = entry.agent;
|
||||||
const normalized = normalizeAgentSnapshot(snapshot, serverId);
|
const normalized = normalizeAgentSnapshot(snapshot, serverId);
|
||||||
const live = liveAgents?.get(snapshot.id);
|
const live = liveAgents?.get(snapshot.id);
|
||||||
list.push(
|
list.push(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
buildNewAgentRoute,
|
buildNewAgentRoute,
|
||||||
|
resolveSelectedAgentForNewAgent,
|
||||||
resolveNewAgentWorkingDir,
|
resolveNewAgentWorkingDir,
|
||||||
} from "@/utils/new-agent-routing";
|
} from "@/utils/new-agent-routing";
|
||||||
import {
|
import {
|
||||||
@@ -95,21 +96,23 @@ export function useKeyboardShortcuts({
|
|||||||
const navigateToNewAgent = (): boolean => {
|
const navigateToNewAgent = (): boolean => {
|
||||||
let targetServerId = parseServerIdFromPathname(pathname);
|
let targetServerId = parseServerIdFromPathname(pathname);
|
||||||
let targetWorkingDir: string | null = null;
|
let targetWorkingDir: string | null = null;
|
||||||
if (selectedAgentId) {
|
const selectedAgent = resolveSelectedAgentForNewAgent({
|
||||||
const separatorIndex = selectedAgentId.indexOf(":");
|
pathname,
|
||||||
if (separatorIndex > 0) {
|
selectedAgentId,
|
||||||
const serverId = selectedAgentId.slice(0, separatorIndex);
|
});
|
||||||
const agentId = selectedAgentId.slice(separatorIndex + 1);
|
if (selectedAgent) {
|
||||||
targetServerId = serverId;
|
targetServerId = selectedAgent.serverId;
|
||||||
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
|
const agent = useSessionStore
|
||||||
const cwd = agent?.cwd?.trim();
|
.getState()
|
||||||
if (cwd) {
|
.sessions[selectedAgent.serverId]
|
||||||
const checkout =
|
?.agents?.get(selectedAgent.agentId);
|
||||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
const cwd = agent?.cwd?.trim();
|
||||||
checkoutStatusQueryKey(serverId, cwd)
|
if (cwd) {
|
||||||
) ?? null;
|
const checkout =
|
||||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||||
}
|
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
|
||||||
|
) ?? null;
|
||||||
|
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,129 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import {
|
import {
|
||||||
useSectionOrderStore,
|
deriveSidebarStateBucket,
|
||||||
sortProjectsByStoredOrder,
|
isSidebarActiveAgent,
|
||||||
} from "@/stores/section-order-store";
|
} from "@/utils/sidebar-agent-state";
|
||||||
import type { FetchAgentsGroupedByProjectResponseMessage } from "@server/shared/messages";
|
import type { ProjectPlacementPayload } from "@server/shared/messages";
|
||||||
|
|
||||||
const SIDEBAR_GROUPS_STALE_TIME = 15_000;
|
const SIDEBAR_AGENTS_STALE_TIME = 15_000;
|
||||||
const SIDEBAR_GROUPS_REFETCH_INTERVAL = 10_000;
|
const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000;
|
||||||
const MAX_AGENTS_PER_PROJECT = 5;
|
const SIDEBAR_DONE_FILL_TARGET = 50;
|
||||||
|
|
||||||
type SidebarGroupsPayload =
|
export interface SidebarProjectOption {
|
||||||
FetchAgentsGroupedByProjectResponseMessage["payload"];
|
|
||||||
export type SidebarCheckoutLite =
|
|
||||||
SidebarGroupsPayload["groups"][number]["agents"][number]["checkout"];
|
|
||||||
type MutableSidebarGroup = {
|
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
agents: AggregatedAgent[];
|
activeCount: number;
|
||||||
};
|
totalCount: number;
|
||||||
|
serverId: string;
|
||||||
|
workingDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SidebarSectionData {
|
export interface SidebarAgentListEntry {
|
||||||
key: string;
|
agent: AggregatedAgent & { createdAt: Date };
|
||||||
projectKey: string;
|
project: ProjectPlacementPayload;
|
||||||
title: string;
|
|
||||||
agents: AggregatedAgent[];
|
|
||||||
firstAgentServerId?: string;
|
|
||||||
firstAgentId?: string;
|
|
||||||
workingDir?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarAgentsGroupedResult {
|
export interface SidebarAgentsGroupedResult {
|
||||||
sections: SidebarSectionData[];
|
entries: SidebarAgentListEntry[];
|
||||||
checkoutByAgentKey: Map<string, SidebarCheckoutLite>;
|
projectOptions: SidebarProjectOption[];
|
||||||
|
hasMoreEntries: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isInitialLoad: boolean;
|
isInitialLoad: boolean;
|
||||||
isRevalidating: boolean;
|
isRevalidating: boolean;
|
||||||
refreshAll: () => void;
|
refreshAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareByLastActivityDesc(
|
||||||
|
left: SidebarAgentListEntry,
|
||||||
|
right: SidebarAgentListEntry
|
||||||
|
): number {
|
||||||
|
return right.agent.lastActivityAt.getTime() - left.agent.lastActivityAt.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareByTitleAsc(
|
||||||
|
left: SidebarAgentListEntry,
|
||||||
|
right: SidebarAgentListEntry
|
||||||
|
): number {
|
||||||
|
const leftTitle = (left.agent.title?.trim() || "New agent").toLocaleLowerCase();
|
||||||
|
const rightTitle = (right.agent.title?.trim() || "New agent").toLocaleLowerCase();
|
||||||
|
const titleCmp = leftTitle.localeCompare(rightTitle, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
if (titleCmp !== 0) {
|
||||||
|
return titleCmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic tie-breaker so running rows stay stable while status updates stream.
|
||||||
|
return left.agent.id.localeCompare(right.agent.id, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySidebarDefaultOrdering(
|
||||||
|
entries: SidebarAgentListEntry[]
|
||||||
|
): { entries: SidebarAgentListEntry[]; hasMore: boolean } {
|
||||||
|
const needsInput: SidebarAgentListEntry[] = [];
|
||||||
|
const failed: SidebarAgentListEntry[] = [];
|
||||||
|
const running: SidebarAgentListEntry[] = [];
|
||||||
|
const attention: SidebarAgentListEntry[] = [];
|
||||||
|
const done: SidebarAgentListEntry[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const bucket = deriveSidebarStateBucket({
|
||||||
|
status: entry.agent.status,
|
||||||
|
requiresAttention: entry.agent.requiresAttention,
|
||||||
|
attentionReason: entry.agent.attentionReason,
|
||||||
|
});
|
||||||
|
if (bucket === "needs_input") {
|
||||||
|
needsInput.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (bucket === "failed") {
|
||||||
|
failed.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (bucket === "running") {
|
||||||
|
running.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (bucket === "attention") {
|
||||||
|
attention.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
done.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
needsInput.sort(compareByLastActivityDesc);
|
||||||
|
failed.sort(compareByLastActivityDesc);
|
||||||
|
running.sort(compareByTitleAsc);
|
||||||
|
attention.sort(compareByLastActivityDesc);
|
||||||
|
done.sort(compareByLastActivityDesc);
|
||||||
|
|
||||||
|
const active = [...needsInput, ...failed, ...running, ...attention];
|
||||||
|
if (active.length >= SIDEBAR_DONE_FILL_TARGET) {
|
||||||
|
return { entries: active, hasMore: done.length > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingDoneSlots = SIDEBAR_DONE_FILL_TARGET - active.length;
|
||||||
|
const shownDone = done.slice(0, remainingDoneSlots);
|
||||||
|
return {
|
||||||
|
entries: [...active, ...shownDone],
|
||||||
|
hasMore: done.length > shownDone.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toAggregatedAgent(params: {
|
function toAggregatedAgent(params: {
|
||||||
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
|
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
serverLabel: string;
|
serverLabel: string;
|
||||||
}): AggregatedAgent {
|
}): AggregatedAgent & { createdAt: Date } {
|
||||||
const source = params.source;
|
const source = params.source;
|
||||||
return {
|
return {
|
||||||
id: source.id,
|
id: source.id,
|
||||||
@@ -55,6 +131,7 @@ function toAggregatedAgent(params: {
|
|||||||
serverLabel: params.serverLabel,
|
serverLabel: params.serverLabel,
|
||||||
title: source.title ?? null,
|
title: source.title ?? null,
|
||||||
status: source.status,
|
status: source.status,
|
||||||
|
createdAt: source.createdAt,
|
||||||
lastActivityAt: source.lastActivityAt,
|
lastActivityAt: source.lastActivityAt,
|
||||||
cwd: source.cwd,
|
cwd: source.cwd,
|
||||||
provider: source.provider,
|
provider: source.provider,
|
||||||
@@ -69,6 +146,7 @@ function toAggregatedAgent(params: {
|
|||||||
export function useSidebarAgentsGrouped(options?: {
|
export function useSidebarAgentsGrouped(options?: {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
|
selectedProjectKeys?: string[];
|
||||||
}): SidebarAgentsGroupedResult {
|
}): SidebarAgentsGroupedResult {
|
||||||
const { connectionStates } = useDaemonConnections();
|
const { connectionStates } = useDaemonConnections();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -79,6 +157,15 @@ export function useSidebarAgentsGrouped(options?: {
|
|||||||
? value.trim()
|
? value.trim()
|
||||||
: null;
|
: null;
|
||||||
}, [options?.serverId]);
|
}, [options?.serverId]);
|
||||||
|
const selectedProjectKeys = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
(options?.selectedProjectKeys ?? [])
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
),
|
||||||
|
[options?.selectedProjectKeys]
|
||||||
|
);
|
||||||
|
|
||||||
const session = useSessionStore((state) =>
|
const session = useSessionStore((state) =>
|
||||||
serverId ? state.sessions[serverId] : undefined
|
serverId ? state.sessions[serverId] : undefined
|
||||||
@@ -88,189 +175,160 @@ export function useSidebarAgentsGrouped(options?: {
|
|||||||
const isConnected = session?.connection.isConnected ?? false;
|
const isConnected = session?.connection.isConnected ?? false;
|
||||||
const canFetch = Boolean(serverId && client && isConnected);
|
const canFetch = Boolean(serverId && client && isConnected);
|
||||||
|
|
||||||
const groupedQuery = useQuery({
|
const agentsQuery = useQuery({
|
||||||
queryKey: ["sidebarAgentsGrouped", serverId] as const,
|
queryKey: ["sidebarAgentsList", serverId] as const,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Daemon client not available");
|
throw new Error("Daemon client not available");
|
||||||
}
|
}
|
||||||
return await client.fetchAgentsGroupedByProject({
|
return await client.fetchAgents({
|
||||||
filter: { labels: { ui: "true" } },
|
filter: { labels: { ui: "true" } },
|
||||||
|
sort: [
|
||||||
|
{ key: "status_priority", direction: "asc" },
|
||||||
|
{ key: "updated_at", direction: "desc" },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: canFetch,
|
enabled: canFetch,
|
||||||
staleTime: SIDEBAR_GROUPS_STALE_TIME,
|
staleTime: SIDEBAR_AGENTS_STALE_TIME,
|
||||||
refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false,
|
refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false,
|
||||||
refetchIntervalInBackground: isOpen,
|
refetchIntervalInBackground: isOpen,
|
||||||
refetchOnMount: "always" as const,
|
refetchOnMount: "always" as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
|
const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
|
||||||
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
|
||||||
|
|
||||||
const { sections, checkoutByAgentKey, hasAnyData } = useMemo(() => {
|
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return {
|
return {
|
||||||
sections: [] as SidebarSectionData[],
|
entries: [] as SidebarAgentListEntry[],
|
||||||
checkoutByAgentKey: new Map<string, SidebarCheckoutLite>(),
|
projectOptions: [] as SidebarProjectOption[],
|
||||||
hasAnyData: false,
|
hasAnyData: false,
|
||||||
|
hasMoreEntries: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupsByKey = new Map<string, MutableSidebarGroup>();
|
|
||||||
const checkoutLookup = new Map<string, SidebarCheckoutLite>();
|
|
||||||
const seenAgentKeys = new Set<string>();
|
|
||||||
const payload = groupedQuery.data as SidebarGroupsPayload | undefined;
|
|
||||||
const groupedFetchReady = groupedQuery.isFetched;
|
|
||||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||||
|
const seenAgentIds = new Set<string>();
|
||||||
|
const byProject = new Map<string, SidebarProjectOption>();
|
||||||
|
const mergedEntries: SidebarAgentListEntry[] = [];
|
||||||
|
|
||||||
if (payload) {
|
const pushEntry = (entry: SidebarAgentListEntry): void => {
|
||||||
for (const group of payload.groups) {
|
if (entry.agent.archivedAt) {
|
||||||
const existing: MutableSidebarGroup =
|
return;
|
||||||
groupsByKey.get(group.projectKey) ??
|
|
||||||
{
|
|
||||||
projectKey: group.projectKey,
|
|
||||||
projectName: group.projectName,
|
|
||||||
agents: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const entry of group.agents) {
|
|
||||||
const normalized = normalizeAgentSnapshot(entry.agent, serverId);
|
|
||||||
const live = liveAgents?.get(entry.agent.id);
|
|
||||||
const nextAgent = toAggregatedAgent({
|
|
||||||
source: live ?? normalized,
|
|
||||||
serverId,
|
|
||||||
serverLabel,
|
|
||||||
});
|
|
||||||
if (nextAgent.archivedAt) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentKey = `${serverId}:${entry.agent.id}`;
|
|
||||||
seenAgentKeys.add(agentKey);
|
|
||||||
checkoutLookup.set(
|
|
||||||
agentKey,
|
|
||||||
live?.projectPlacement?.checkout ?? entry.checkout
|
|
||||||
);
|
|
||||||
existing.agents.push(nextAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
groupsByKey.set(group.projectKey, existing);
|
|
||||||
}
|
}
|
||||||
|
const dedupeKey = `${entry.agent.serverId}:${entry.agent.id}`;
|
||||||
|
if (seenAgentIds.has(dedupeKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenAgentIds.add(dedupeKey);
|
||||||
|
mergedEntries.push(entry);
|
||||||
|
|
||||||
|
const existing = byProject.get(entry.project.projectKey);
|
||||||
|
const isActive = isSidebarActiveAgent({
|
||||||
|
status: entry.agent.status,
|
||||||
|
requiresAttention: entry.agent.requiresAttention,
|
||||||
|
attentionReason: entry.agent.attentionReason,
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
existing.totalCount += 1;
|
||||||
|
if (isActive) {
|
||||||
|
existing.activeCount += 1;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byProject.set(entry.project.projectKey, {
|
||||||
|
projectKey: entry.project.projectKey,
|
||||||
|
projectName: entry.project.projectName,
|
||||||
|
activeCount: isActive ? 1 : 0,
|
||||||
|
totalCount: 1,
|
||||||
|
serverId,
|
||||||
|
workingDir: entry.project.checkout.cwd,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchedEntries = agentsQuery.data?.entries ?? [];
|
||||||
|
for (const fetchedEntry of fetchedEntries) {
|
||||||
|
const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId);
|
||||||
|
const live = liveAgents?.get(fetchedEntry.agent.id);
|
||||||
|
const project = live?.projectPlacement ?? fetchedEntry.project;
|
||||||
|
if (!project) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const agent = toAggregatedAgent({
|
||||||
|
source: live ?? normalized,
|
||||||
|
serverId,
|
||||||
|
serverLabel,
|
||||||
|
});
|
||||||
|
pushEntry({ agent, project });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupedFetchReady && liveAgents) {
|
if (liveAgents) {
|
||||||
for (const live of liveAgents.values()) {
|
for (const live of liveAgents.values()) {
|
||||||
if (live.archivedAt || live.labels.ui !== "true") {
|
if (live.archivedAt || live.labels.ui !== "true") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!live.projectPlacement) {
|
if (!live.projectPlacement) {
|
||||||
// Ignore fetchAgents-hydrated snapshots for sidebar placement.
|
|
||||||
// Sidebar should derive placement from grouped RPC or project-enriched agent_update.
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const agentKey = `${serverId}:${live.id}`;
|
const agent = toAggregatedAgent({
|
||||||
if (seenAgentKeys.has(agentKey)) {
|
source: live,
|
||||||
continue;
|
serverId,
|
||||||
}
|
serverLabel,
|
||||||
|
});
|
||||||
const livePlacement = live.projectPlacement;
|
pushEntry({ agent, project: live.projectPlacement });
|
||||||
const projectKey = livePlacement.projectKey;
|
|
||||||
const existing: MutableSidebarGroup =
|
|
||||||
groupsByKey.get(projectKey) ??
|
|
||||||
{
|
|
||||||
projectKey,
|
|
||||||
projectName: livePlacement.projectName,
|
|
||||||
agents: [],
|
|
||||||
};
|
|
||||||
existing.agents.push(
|
|
||||||
toAggregatedAgent({
|
|
||||||
source: live,
|
|
||||||
serverId,
|
|
||||||
serverLabel,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
checkoutLookup.set(agentKey, livePlacement.checkout);
|
|
||||||
groupsByKey.set(projectKey, existing);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedGroups = Array.from(groupsByKey.values())
|
const filteredEntries =
|
||||||
.map((group) => {
|
selectedProjectKeys.size > 0
|
||||||
const agents = [...group.agents].sort(
|
? mergedEntries.filter((entry) =>
|
||||||
(left, right) =>
|
selectedProjectKeys.has(entry.project.projectKey)
|
||||||
right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
)
|
||||||
);
|
: mergedEntries;
|
||||||
return {
|
|
||||||
...group,
|
|
||||||
agents: agents.slice(0, MAX_AGENTS_PER_PROJECT),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((group) => group.agents.length > 0)
|
|
||||||
.sort((left, right) => {
|
|
||||||
const leftRecent = left.agents[0]?.lastActivityAt.getTime() ?? 0;
|
|
||||||
const rightRecent = right.agents[0]?.lastActivityAt.getTime() ?? 0;
|
|
||||||
return rightRecent - leftRecent;
|
|
||||||
});
|
|
||||||
|
|
||||||
const orderedGroups = sortProjectsByStoredOrder(sortedGroups, projectOrder);
|
const ordered = applySidebarDefaultOrdering(filteredEntries);
|
||||||
const nextSections = orderedGroups.map((group) => {
|
const options = Array.from(byProject.values()).sort((left, right) => {
|
||||||
const firstAgent = group.agents[0];
|
if (left.activeCount !== right.activeCount) {
|
||||||
return {
|
return right.activeCount - left.activeCount;
|
||||||
key: `project:${group.projectKey}`,
|
}
|
||||||
projectKey: group.projectKey,
|
return left.projectName.localeCompare(right.projectName);
|
||||||
title: group.projectName,
|
|
||||||
agents: group.agents,
|
|
||||||
firstAgentServerId: firstAgent?.serverId,
|
|
||||||
firstAgentId: firstAgent?.id,
|
|
||||||
workingDir: firstAgent?.cwd,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sections: nextSections,
|
entries: ordered.entries,
|
||||||
checkoutByAgentKey: checkoutLookup,
|
projectOptions: options,
|
||||||
hasAnyData: nextSections.length > 0,
|
hasAnyData: ordered.entries.length > 0,
|
||||||
|
hasMoreEntries: ordered.hasMore,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
agentsQuery.data?.entries,
|
||||||
connectionStates,
|
connectionStates,
|
||||||
groupedQuery.data,
|
|
||||||
groupedQuery.isFetched,
|
|
||||||
liveAgents,
|
liveAgents,
|
||||||
projectOrder,
|
selectedProjectKeys,
|
||||||
serverId,
|
serverId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentKeys = sections.map((section) => section.projectKey);
|
|
||||||
const storedKeys = new Set(projectOrder);
|
|
||||||
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
|
|
||||||
if (newKeys.length > 0) {
|
|
||||||
setProjectOrder([...projectOrder, ...newKeys]);
|
|
||||||
}
|
|
||||||
}, [sections, projectOrder, setProjectOrder]);
|
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["sidebarAgentsGrouped", serverId],
|
queryKey: ["sidebarAgentsList", serverId],
|
||||||
});
|
});
|
||||||
}, [queryClient, serverId]);
|
}, [queryClient, serverId]);
|
||||||
|
|
||||||
const isFetching =
|
const isFetching =
|
||||||
canFetch && (groupedQuery.isPending || groupedQuery.isFetching);
|
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
|
||||||
const isInitialLoad = isFetching && !hasAnyData;
|
const isInitialLoad = isFetching && !hasAnyData;
|
||||||
const isRevalidating = isFetching && hasAnyData;
|
const isRevalidating = isFetching && hasAnyData;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sections,
|
entries,
|
||||||
checkoutByAgentKey,
|
projectOptions,
|
||||||
|
hasMoreEntries,
|
||||||
isLoading: isFetching,
|
isLoading: isFetching,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
isRevalidating,
|
isRevalidating,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
textTransform: "uppercase",
|
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
@@ -180,8 +179,19 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
hostCardPressed: {
|
hostSettingsButton: {
|
||||||
opacity: 0.85,
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "transparent",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
marginLeft: theme.spacing[2],
|
||||||
|
},
|
||||||
|
hostSettingsButtonActive: {
|
||||||
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
advancedTrigger: {
|
advancedTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -646,7 +656,7 @@ export default function SettingsScreen() {
|
|||||||
connectionStatus={connectionStatus}
|
connectionStatus={connectionStatus}
|
||||||
activeConnection={activeConnection}
|
activeConnection={activeConnection}
|
||||||
lastError={lastConnectionError}
|
lastError={lastConnectionError}
|
||||||
onPress={handleEditDaemon}
|
onOpenSettings={handleEditDaemon}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -885,8 +895,6 @@ function HostDetailModal({
|
|||||||
}: HostDetailModalProps) {
|
}: HostDetailModalProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [draftLabel, setDraftLabel] = useState("");
|
const [draftLabel, setDraftLabel] = useState("");
|
||||||
const [isDraftLabelDirty, setIsDraftLabelDirty] = useState(false);
|
|
||||||
const activeServerIdRef = useRef<string | null>(null);
|
|
||||||
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
|
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
|
||||||
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
|
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
|
||||||
|
|
||||||
@@ -1033,28 +1041,18 @@ function HostDetailModal({
|
|||||||
|
|
||||||
const handleDraftLabelChange = useCallback((nextValue: string) => {
|
const handleDraftLabelChange = useCallback((nextValue: string) => {
|
||||||
setDraftLabel(nextValue);
|
setDraftLabel(nextValue);
|
||||||
setIsDraftLabelDirty(true);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !host) return;
|
if (!visible || !host) return;
|
||||||
const hostChanged = activeServerIdRef.current !== host.serverId;
|
// Initialize once per modal open / host switch; keep user edits fully local while typing.
|
||||||
if (hostChanged) {
|
setDraftLabel(host.label ?? "");
|
||||||
setDraftLabel(host.label ?? "");
|
}, [visible, host?.serverId]);
|
||||||
setIsDraftLabelDirty(false);
|
|
||||||
activeServerIdRef.current = host.serverId;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isDraftLabelDirty) {
|
|
||||||
setDraftLabel(host.label ?? "");
|
|
||||||
}
|
|
||||||
}, [visible, host?.serverId, host?.label, isDraftLabelDirty]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
activeServerIdRef.current = null;
|
|
||||||
setIsRestarting(false);
|
setIsRestarting(false);
|
||||||
setIsDraftLabelDirty(false);
|
setDraftLabel("");
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
@@ -1304,7 +1302,7 @@ interface DaemonCardProps {
|
|||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
activeConnection: ActiveConnection | null;
|
activeConnection: ActiveConnection | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
onPress: (daemon: HostProfile) => void;
|
onOpenSettings: (daemon: HostProfile) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DaemonCard({
|
function DaemonCard({
|
||||||
@@ -1312,7 +1310,7 @@ function DaemonCard({
|
|||||||
connectionStatus,
|
connectionStatus,
|
||||||
activeConnection,
|
activeConnection,
|
||||||
lastError,
|
lastError,
|
||||||
onPress,
|
onOpenSettings,
|
||||||
}: DaemonCardProps) {
|
}: DaemonCardProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const statusLabel = formatConnectionStatus(connectionStatus);
|
const statusLabel = formatConnectionStatus(connectionStatus);
|
||||||
@@ -1347,12 +1345,9 @@ function DaemonCard({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<View
|
||||||
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
style={styles.hostCard}
|
||||||
onPress={() => onPress(daemon)}
|
|
||||||
testID={`daemon-card-${daemon.serverId}`}
|
testID={`daemon-card-${daemon.serverId}`}
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`${daemon.label}, ${statusLabel}`}
|
|
||||||
>
|
>
|
||||||
<View style={styles.hostCardContent}>
|
<View style={styles.hostCardContent}>
|
||||||
<View style={styles.hostHeaderRow}>
|
<View style={styles.hostHeaderRow}>
|
||||||
@@ -1375,10 +1370,28 @@ function DaemonCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed, hovered }) => [
|
||||||
|
styles.hostSettingsButton,
|
||||||
|
(pressed || hovered) && styles.hostSettingsButtonActive,
|
||||||
|
]}
|
||||||
|
onPress={() => onOpenSettings(daemon)}
|
||||||
|
testID={`daemon-card-settings-${daemon.serverId}`}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Open settings for ${daemon.label}`}
|
||||||
|
>
|
||||||
|
{({ pressed, hovered }) => (
|
||||||
|
<Settings
|
||||||
|
size={16}
|
||||||
|
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||||
import {
|
import {
|
||||||
buildNewAgentRoute,
|
buildNewAgentRoute,
|
||||||
|
parseAgentKey,
|
||||||
resolveNewAgentWorkingDir,
|
resolveNewAgentWorkingDir,
|
||||||
|
resolveSelectedAgentForNewAgent,
|
||||||
} from "./new-agent-routing";
|
} from "./new-agent-routing";
|
||||||
|
|
||||||
describe("buildNewAgentRoute", () => {
|
describe("buildNewAgentRoute", () => {
|
||||||
@@ -35,3 +37,60 @@ describe("resolveNewAgentWorkingDir", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseAgentKey", () => {
|
||||||
|
it("parses server and agent ids from combined key", () => {
|
||||||
|
expect(parseAgentKey("srv-1:agent-9")).toEqual({
|
||||||
|
serverId: "srv-1",
|
||||||
|
agentId: "agent-9",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the last separator to preserve server ids with colons", () => {
|
||||||
|
expect(parseAgentKey("localhost:6767:agent-9")).toEqual({
|
||||||
|
serverId: "localhost:6767",
|
||||||
|
agentId: "agent-9",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for malformed keys", () => {
|
||||||
|
expect(parseAgentKey("")).toBeNull();
|
||||||
|
expect(parseAgentKey("only-server")).toBeNull();
|
||||||
|
expect(parseAgentKey(":agent-1")).toBeNull();
|
||||||
|
expect(parseAgentKey("srv-1:")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSelectedAgentForNewAgent", () => {
|
||||||
|
it("prefers the agent in the current route", () => {
|
||||||
|
expect(
|
||||||
|
resolveSelectedAgentForNewAgent({
|
||||||
|
pathname: "/h/srv-1/agent/agent-2",
|
||||||
|
selectedAgentId: "srv-9:agent-9",
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
serverId: "srv-1",
|
||||||
|
agentId: "agent-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to selected agent key when route has no agent", () => {
|
||||||
|
expect(
|
||||||
|
resolveSelectedAgentForNewAgent({
|
||||||
|
pathname: "/h/srv-1/settings",
|
||||||
|
selectedAgentId: "srv-1:agent-7",
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
serverId: "srv-1",
|
||||||
|
agentId: "agent-7",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when neither route nor selection has an agent", () => {
|
||||||
|
expect(
|
||||||
|
resolveSelectedAgentForNewAgent({
|
||||||
|
pathname: "/h/srv-1/settings",
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
import {
|
||||||
|
buildHostAgentDraftRoute,
|
||||||
|
parseHostAgentRouteFromPathname,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
|
export function parseAgentKey(
|
||||||
|
key: string | null | undefined
|
||||||
|
): { serverId: string; agentId: string } | null {
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sep = key.lastIndexOf(":");
|
||||||
|
if (sep <= 0 || sep >= key.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const serverId = key.slice(0, sep).trim();
|
||||||
|
const agentId = key.slice(sep + 1).trim();
|
||||||
|
if (!serverId || !agentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { serverId, agentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSelectedAgentForNewAgent(input: {
|
||||||
|
pathname: string;
|
||||||
|
selectedAgentId?: string;
|
||||||
|
}): { serverId: string; agentId: string } | null {
|
||||||
|
return (
|
||||||
|
parseHostAgentRouteFromPathname(input.pathname) ??
|
||||||
|
parseAgentKey(input.selectedAgentId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveNewAgentWorkingDir(
|
export function resolveNewAgentWorkingDir(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
|
|
||||||
|
export type SidebarAttentionReason =
|
||||||
|
| "finished"
|
||||||
|
| "error"
|
||||||
|
| "permission"
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export type SidebarStateBucket =
|
||||||
|
| "needs_input"
|
||||||
|
| "failed"
|
||||||
|
| "running"
|
||||||
|
| "attention"
|
||||||
|
| "done";
|
||||||
|
|
||||||
|
export function deriveSidebarStateBucket(input: {
|
||||||
|
status: AgentLifecycleStatus;
|
||||||
|
requiresAttention?: boolean;
|
||||||
|
attentionReason?: SidebarAttentionReason;
|
||||||
|
}): SidebarStateBucket {
|
||||||
|
if (input.requiresAttention && input.attentionReason === "permission") {
|
||||||
|
return "needs_input";
|
||||||
|
}
|
||||||
|
if (input.status === "error" || input.attentionReason === "error") {
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
if (input.status === "running") {
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
if (input.requiresAttention) {
|
||||||
|
// Unread/attention-needed completed agents are active in sidebar logic.
|
||||||
|
return "attention";
|
||||||
|
}
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSidebarActiveAgent(input: {
|
||||||
|
status: AgentLifecycleStatus;
|
||||||
|
requiresAttention?: boolean;
|
||||||
|
attentionReason?: SidebarAttentionReason;
|
||||||
|
}): boolean {
|
||||||
|
return deriveSidebarStateBucket(input) !== "done";
|
||||||
|
}
|
||||||
@@ -111,7 +111,10 @@ export async function runLsCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let agents = await client.fetchAgents()
|
const fetchPayload = await client.fetchAgents({
|
||||||
|
filter: options.all ? { includeArchived: true } : undefined,
|
||||||
|
})
|
||||||
|
let agents = fetchPayload.entries.map((entry) => entry.agent)
|
||||||
|
|
||||||
// By default, exclude archived agents. `-a` includes them.
|
// By default, exclude archived agents. `-a` includes them.
|
||||||
if (!options.all) {
|
if (!options.all) {
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ export async function runStopCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let agents = await client.fetchAgents()
|
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||||
|
let agents = fetchPayload.entries.map((entry) => entry.agent)
|
||||||
const stoppedIds: string[] = []
|
const stoppedIds: string[] = []
|
||||||
|
|
||||||
if (options.all) {
|
if (options.all) {
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ export async function runStatusCommand(
|
|||||||
const client = await tryConnectToDaemon({ host, timeout: 1500 })
|
const client = await tryConnectToDaemon({ host, timeout: 1500 })
|
||||||
if (client) {
|
if (client) {
|
||||||
try {
|
try {
|
||||||
const agents = await client.fetchAgents()
|
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||||
|
const agents = agentsPayload.entries.map((entry) => entry.agent)
|
||||||
runningAgents = agents.filter(a => a.status === 'running').length
|
runningAgents = agents.filter(a => a.status === 'running').length
|
||||||
idleAgents = agents.filter(a => a.status === 'idle').length
|
idleAgents = agents.filter(a => a.status === 'idle').length
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ export async function runLsCommand(options: PermitLsOptions, _command: Command):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agents = await client.fetchAgents()
|
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||||
|
const agents = agentsPayload.entries.map((entry) => entry.agent)
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
// Collect all pending permissions from all agents
|
// Collect all pending permissions from all agents
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ export async function runLsCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agents = await client.fetchAgents()
|
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
|
||||||
|
const agents = agentsPayload.entries.map((entry) => entry.agent)
|
||||||
|
|
||||||
// Get worktree list from daemon
|
// Get worktree list from daemon
|
||||||
const response = await client.getPaseoWorktreeList({})
|
const response = await client.getPaseoWorktreeList({})
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ describe("DaemonClient", () => {
|
|||||||
expect(request.message.requestId.length).toBeGreaterThan(0);
|
expect(request.message.requestId.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fetches project-grouped agents via RPC", async () => {
|
test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|
||||||
@@ -471,29 +471,49 @@ describe("DaemonClient", () => {
|
|||||||
mock.triggerOpen();
|
mock.triggerOpen();
|
||||||
await connectPromise;
|
await connectPromise;
|
||||||
|
|
||||||
const promise = client.fetchAgentsGroupedByProject({
|
const promise = client.fetchAgents({
|
||||||
filter: { labels: { ui: "true" } },
|
filter: { labels: { ui: "true" } },
|
||||||
|
sort: [
|
||||||
|
{ key: "status_priority", direction: "asc" },
|
||||||
|
{ key: "created_at", direction: "desc" },
|
||||||
|
],
|
||||||
|
page: { limit: 25, cursor: "cursor-1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mock.sent).toHaveLength(1);
|
expect(mock.sent).toHaveLength(1);
|
||||||
const request = JSON.parse(mock.sent[0]) as {
|
const request = JSON.parse(mock.sent[0]) as {
|
||||||
type: "session";
|
type: "session";
|
||||||
message: {
|
message: {
|
||||||
type: "fetch_agents_grouped_by_project_request";
|
type: "fetch_agents_request";
|
||||||
requestId: string;
|
requestId: string;
|
||||||
filter?: { labels?: Record<string, string> };
|
filter?: { labels?: Record<string, string> };
|
||||||
|
sort?: Array<{
|
||||||
|
key: "status_priority" | "created_at" | "updated_at" | "title";
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
}>;
|
||||||
|
page?: { limit: number; cursor?: string };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
expect(request.message.type).toBe("fetch_agents_grouped_by_project_request");
|
expect(request.message.type).toBe("fetch_agents_request");
|
||||||
|
expect(request.message.sort).toEqual([
|
||||||
|
{ key: "status_priority", direction: "asc" },
|
||||||
|
{ key: "created_at", direction: "desc" },
|
||||||
|
]);
|
||||||
|
expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
||||||
|
|
||||||
mock.triggerMessage(
|
mock.triggerMessage(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "session",
|
type: "session",
|
||||||
message: {
|
message: {
|
||||||
type: "fetch_agents_grouped_by_project_response",
|
type: "fetch_agents_response",
|
||||||
payload: {
|
payload: {
|
||||||
requestId: request.message.requestId,
|
requestId: request.message.requestId,
|
||||||
groups: [],
|
entries: [],
|
||||||
|
pageInfo: {
|
||||||
|
nextCursor: null,
|
||||||
|
prevCursor: "cursor-1",
|
||||||
|
hasMore: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -501,7 +521,12 @@ describe("DaemonClient", () => {
|
|||||||
|
|
||||||
await expect(promise).resolves.toEqual({
|
await expect(promise).resolves.toEqual({
|
||||||
requestId: request.message.requestId,
|
requestId: request.message.requestId,
|
||||||
groups: [],
|
entries: [],
|
||||||
|
pageInfo: {
|
||||||
|
nextCursor: null,
|
||||||
|
prevCursor: "cursor-1",
|
||||||
|
hasMore: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -244,10 +244,19 @@ type AgentRefreshedStatusPayload = z.infer<
|
|||||||
type RestartRequestedStatusPayload = z.infer<
|
type RestartRequestedStatusPayload = z.infer<
|
||||||
typeof RestartRequestedStatusPayloadSchema
|
typeof RestartRequestedStatusPayloadSchema
|
||||||
>;
|
>;
|
||||||
type FetchAgentsGroupedByProjectPayload = Extract<
|
type FetchAgentsPayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "fetch_agents_grouped_by_project_response" }
|
{ type: "fetch_agents_response" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
|
type FetchAgentsRequest = Extract<
|
||||||
|
SessionInboundMessage,
|
||||||
|
{ type: "fetch_agents_request" }
|
||||||
|
>;
|
||||||
|
export type FetchAgentsOptions = Omit<FetchAgentsRequest, "type" | "requestId"> & {
|
||||||
|
requestId?: string;
|
||||||
|
};
|
||||||
|
export type FetchAgentsEntry = FetchAgentsPayload["entries"][number];
|
||||||
|
export type FetchAgentsPageInfo = FetchAgentsPayload["pageInfo"];
|
||||||
|
|
||||||
export type WaitForFinishResult = {
|
export type WaitForFinishResult = {
|
||||||
status: "idle" | "error" | "permission" | "timeout";
|
status: "idle" | "error" | "permission" | "timeout";
|
||||||
@@ -976,15 +985,14 @@ export class DaemonClient {
|
|||||||
// Agent RPCs (requestId-correlated)
|
// Agent RPCs (requestId-correlated)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async fetchAgents(options?: {
|
async fetchAgents(options?: FetchAgentsOptions): Promise<FetchAgentsPayload> {
|
||||||
filter?: { labels?: Record<string, string> };
|
|
||||||
requestId?: string;
|
|
||||||
}): Promise<AgentSnapshotPayload[]> {
|
|
||||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
type: "fetch_agents_request",
|
type: "fetch_agents_request",
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
...(options?.filter ? { filter: options.filter } : {}),
|
...(options?.filter ? { filter: options.filter } : {}),
|
||||||
|
...(options?.sort ? { sort: options.sort } : {}),
|
||||||
|
...(options?.page ? { page: options.page } : {}),
|
||||||
});
|
});
|
||||||
return this.sendRequest({
|
return this.sendRequest({
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
@@ -998,33 +1006,6 @@ export class DaemonClient {
|
|||||||
if (msg.payload.requestId !== resolvedRequestId) {
|
if (msg.payload.requestId !== resolvedRequestId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return msg.payload.agents;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchAgentsGroupedByProject(options?: {
|
|
||||||
filter?: { labels?: Record<string, string> };
|
|
||||||
requestId?: string;
|
|
||||||
}): Promise<FetchAgentsGroupedByProjectPayload> {
|
|
||||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "fetch_agents_grouped_by_project_request",
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
...(options?.filter ? { filter: options.filter } : {}),
|
|
||||||
});
|
|
||||||
return this.sendRequest({
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
message,
|
|
||||||
timeout: 15000,
|
|
||||||
options: { skipQueue: true },
|
|
||||||
select: (msg) => {
|
|
||||||
if (msg.type !== "fetch_agents_grouped_by_project_response") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (msg.payload.requestId !== resolvedRequestId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return msg.payload;
|
return msg.payload;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -953,6 +953,9 @@ export class AgentManager {
|
|||||||
|
|
||||||
agent.pendingRun = streamForwarder;
|
agent.pendingRun = streamForwarder;
|
||||||
agent.lifecycle = "running";
|
agent.lifecycle = "running";
|
||||||
|
// Bump updatedAt when lifecycle changes so downstream consumers can
|
||||||
|
// deterministically order idle->running transitions.
|
||||||
|
agent.updatedAt = new Date();
|
||||||
self.emitState(agent);
|
self.emitState(agent);
|
||||||
|
|
||||||
return streamForwarder;
|
return streamForwarder;
|
||||||
|
|||||||
@@ -317,6 +317,44 @@ const shouldRun = !process.env.CI;
|
|||||||
30000
|
30000
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"emits terminal_stream_exit and removes terminal when shell exits",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const list = await ctx.client.listTerminals(cwd);
|
||||||
|
const terminalId = list.terminals[0].id;
|
||||||
|
|
||||||
|
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
|
||||||
|
expect(attach.error).toBeNull();
|
||||||
|
const streamId = attach.streamId!;
|
||||||
|
|
||||||
|
let sawExit = false;
|
||||||
|
const unsubscribeExit = ctx.client.on("terminal_stream_exit", (message) => {
|
||||||
|
if (message.type !== "terminal_stream_exit") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
message.payload.terminalId === terminalId &&
|
||||||
|
message.payload.streamId === streamId
|
||||||
|
) {
|
||||||
|
sawExit = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.client.sendTerminalStreamKey(streamId, { key: "d", ctrl: true });
|
||||||
|
|
||||||
|
await waitForCondition(() => sawExit, 10000);
|
||||||
|
|
||||||
|
const next = await ctx.client.listTerminals(cwd);
|
||||||
|
expect(next.terminals).toHaveLength(1);
|
||||||
|
expect(next.terminals[0].id).not.toBe(terminalId);
|
||||||
|
|
||||||
|
unsubscribeExit();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
30000
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"replays detached terminal output from resume offset (scrollback continuity)",
|
"replays detached terminal output from resume offset (scrollback continuity)",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
|
import type { TerminalSession } from "../terminal/terminal.js";
|
||||||
import {
|
import {
|
||||||
BinaryMuxChannel,
|
BinaryMuxChannel,
|
||||||
TerminalBinaryFlags,
|
TerminalBinaryFlags,
|
||||||
@@ -148,8 +149,6 @@ const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
|||||||
let restartRequested = false;
|
let restartRequested = false;
|
||||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
||||||
const RESTART_EXIT_DELAY_MS = 250;
|
const RESTART_EXIT_DELAY_MS = 250;
|
||||||
const PROJECT_PLACEMENT_CACHE_TTL_MS = 10_000;
|
|
||||||
const MAX_AGENTS_PER_PROJECT = 5;
|
|
||||||
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
|
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
|
||||||
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
|
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
|
||||||
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
|
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
|
||||||
@@ -280,6 +279,30 @@ type TerminalStreamPendingChunk = {
|
|||||||
replay: boolean;
|
replay: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FetchAgentsRequestMessage = Extract<
|
||||||
|
SessionInboundMessage,
|
||||||
|
{ type: "fetch_agents_request" }
|
||||||
|
>;
|
||||||
|
type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage["sort"]>[number];
|
||||||
|
type FetchAgentsResponsePayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "fetch_agents_response" }
|
||||||
|
>["payload"];
|
||||||
|
type FetchAgentsResponseEntry = FetchAgentsResponsePayload["entries"][number];
|
||||||
|
type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
|
||||||
|
type FetchAgentsCursor = {
|
||||||
|
sort: FetchAgentsRequestSort[];
|
||||||
|
values: Record<string, string | number | null>;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SessionRequestError extends Error {
|
||||||
|
constructor(readonly code: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SessionRequestError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const PCM_SAMPLE_RATE = 16000;
|
const PCM_SAMPLE_RATE = 16000;
|
||||||
const PCM_CHANNELS = 1;
|
const PCM_CHANNELS = 1;
|
||||||
const PCM_BITS_PER_SAMPLE = 16;
|
const PCM_BITS_PER_SAMPLE = 16;
|
||||||
@@ -524,10 +547,6 @@ export class Session {
|
|||||||
filter?: { labels?: Record<string, string>; agentId?: string };
|
filter?: { labels?: Record<string, string>; agentId?: string };
|
||||||
}
|
}
|
||||||
| null = null;
|
| null = null;
|
||||||
private readonly projectPlacementCache = new Map<
|
|
||||||
string,
|
|
||||||
{ expiresAt: number; promise: Promise<ProjectPlacementPayload> }
|
|
||||||
>();
|
|
||||||
private clientActivity: {
|
private clientActivity: {
|
||||||
deviceType: "web" | "mobile";
|
deviceType: "web" | "mobile";
|
||||||
focusedAgentId: string | null;
|
focusedAgentId: string | null;
|
||||||
@@ -538,6 +557,7 @@ export class Session {
|
|||||||
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
||||||
private readonly terminalManager: TerminalManager | null;
|
private readonly terminalManager: TerminalManager | null;
|
||||||
private terminalSubscriptions: Map<string, () => void> = new Map();
|
private terminalSubscriptions: Map<string, () => void> = new Map();
|
||||||
|
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
||||||
private readonly terminalStreams = new Map<
|
private readonly terminalStreams = new Map<
|
||||||
number,
|
number,
|
||||||
{
|
{
|
||||||
@@ -1111,21 +1131,6 @@ export class Session {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
|
||||||
const now = Date.now();
|
|
||||||
const cached = this.projectPlacementCache.get(cwd);
|
|
||||||
if (cached && cached.expiresAt > now) {
|
|
||||||
return cached.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = this.buildProjectPlacement(cwd);
|
|
||||||
this.projectPlacementCache.set(cwd, {
|
|
||||||
expiresAt: now + PROJECT_PLACEMENT_CACHE_TTL_MS,
|
|
||||||
promise,
|
|
||||||
});
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const subscription = this.agentUpdatesSubscription;
|
const subscription = this.agentUpdatesSubscription;
|
||||||
@@ -1137,7 +1142,7 @@ export class Session {
|
|||||||
const matches = this.matchesAgentFilter(payload, subscription.filter);
|
const matches = this.matchesAgentFilter(payload, subscription.filter);
|
||||||
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
const project = await this.getProjectPlacement(payload.cwd);
|
const project = await this.buildProjectPlacement(payload.cwd);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "agent_update",
|
type: "agent_update",
|
||||||
payload: { kind: "upsert", agent: payload, project },
|
payload: { kind: "upsert", agent: payload, project },
|
||||||
@@ -1173,11 +1178,7 @@ export class Session {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "fetch_agents_request":
|
case "fetch_agents_request":
|
||||||
await this.handleFetchAgents(msg.requestId, msg.filter);
|
await this.handleFetchAgents(msg);
|
||||||
break;
|
|
||||||
|
|
||||||
case "fetch_agents_grouped_by_project_request":
|
|
||||||
await this.handleFetchAgentsGroupedByProject(msg.requestId, msg.filter);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "fetch_agent_request":
|
case "fetch_agent_request":
|
||||||
@@ -4876,102 +4877,320 @@ export class Session {
|
|||||||
return this.buildStoredAgentPayload(record);
|
return this.buildStoredAgentPayload(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFetchAgents(
|
private normalizeFetchAgentsSort(
|
||||||
requestId: string,
|
sort: FetchAgentsRequestSort[] | undefined
|
||||||
filter?: { labels?: Record<string, string> }
|
): FetchAgentsRequestSort[] {
|
||||||
): Promise<void> {
|
const fallback: FetchAgentsRequestSort[] = [
|
||||||
try {
|
{ key: "updated_at", direction: "desc" },
|
||||||
const agents = await this.listAgentPayloads(filter);
|
];
|
||||||
this.emit({
|
if (!sort || sort.length === 0) {
|
||||||
type: "fetch_agents_response",
|
return fallback;
|
||||||
payload: { requestId, agents },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.sessionLogger.error({ err: error }, "Failed to handle fetch_agents_request");
|
|
||||||
this.emit({
|
|
||||||
type: "fetch_agents_response",
|
|
||||||
payload: { requestId, agents: [] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAgentsGroupedByProjectPayload(filter?: {
|
|
||||||
labels?: Record<string, string>;
|
|
||||||
}): Promise<Array<{
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
agents: Array<{
|
|
||||||
agent: AgentSnapshotPayload;
|
|
||||||
checkout: ProjectCheckoutLitePayload;
|
|
||||||
}>;
|
|
||||||
}>> {
|
|
||||||
const agents = await this.listAgentPayloads(filter);
|
|
||||||
const visibleAgents = agents
|
|
||||||
.filter((agent) => !agent.archivedAt)
|
|
||||||
.sort(
|
|
||||||
(left, right) =>
|
|
||||||
Date.parse(right.updatedAt || "") - Date.parse(left.updatedAt || "")
|
|
||||||
);
|
|
||||||
|
|
||||||
const grouped = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
agents: Array<{
|
|
||||||
agent: AgentSnapshotPayload;
|
|
||||||
checkout: ProjectCheckoutLitePayload;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
// Warm project placement status for all visible roots up front to avoid serial N+1 latency.
|
|
||||||
for (const agent of visibleAgents) {
|
|
||||||
void this.getProjectPlacement(agent.cwd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const agent of visibleAgents) {
|
const deduped: FetchAgentsRequestSort[] = [];
|
||||||
const project = await this.getProjectPlacement(agent.cwd);
|
const seen = new Set<string>();
|
||||||
const projectKey = project.projectKey;
|
for (const entry of sort) {
|
||||||
|
if (seen.has(entry.key)) {
|
||||||
let group = grouped.get(projectKey);
|
|
||||||
if (!group) {
|
|
||||||
group = {
|
|
||||||
projectKey,
|
|
||||||
projectName: project.projectName,
|
|
||||||
agents: [],
|
|
||||||
};
|
|
||||||
grouped.set(projectKey, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (group.agents.length >= MAX_AGENTS_PER_PROJECT) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
seen.add(entry.key);
|
||||||
group.agents.push({ agent, checkout: project.checkout });
|
deduped.push(entry);
|
||||||
}
|
}
|
||||||
|
return deduped.length > 0 ? deduped : fallback;
|
||||||
return Array.from(grouped.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFetchAgentsGroupedByProject(
|
private getStatusPriority(agent: AgentSnapshotPayload): number {
|
||||||
requestId: string,
|
const requiresAttention = agent.requiresAttention ?? false;
|
||||||
filter?: { labels?: Record<string, string> }
|
const attentionReason = agent.attentionReason ?? null;
|
||||||
|
if (requiresAttention && attentionReason === "permission") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (agent.status === "error" || attentionReason === "error") {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (agent.status === "running") {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (agent.status === "initializing") {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFetchAgentsSortValue(
|
||||||
|
entry: FetchAgentsResponseEntry,
|
||||||
|
key: FetchAgentsRequestSort["key"]
|
||||||
|
): string | number | null {
|
||||||
|
switch (key) {
|
||||||
|
case "status_priority":
|
||||||
|
return this.getStatusPriority(entry.agent);
|
||||||
|
case "created_at":
|
||||||
|
return Date.parse(entry.agent.createdAt);
|
||||||
|
case "updated_at":
|
||||||
|
return Date.parse(entry.agent.updatedAt);
|
||||||
|
case "title":
|
||||||
|
return entry.agent.title?.toLocaleLowerCase() ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private compareSortValues(
|
||||||
|
left: string | number | null,
|
||||||
|
right: string | number | null
|
||||||
|
): number {
|
||||||
|
if (left === right) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (left === null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (right === null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (typeof left === "number" && typeof right === "number") {
|
||||||
|
return left < right ? -1 : 1;
|
||||||
|
}
|
||||||
|
return String(left).localeCompare(String(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
private compareFetchAgentsEntries(
|
||||||
|
left: FetchAgentsResponseEntry,
|
||||||
|
right: FetchAgentsResponseEntry,
|
||||||
|
sort: FetchAgentsRequestSort[]
|
||||||
|
): number {
|
||||||
|
for (const spec of sort) {
|
||||||
|
const leftValue = this.getFetchAgentsSortValue(left, spec.key);
|
||||||
|
const rightValue = this.getFetchAgentsSortValue(right, spec.key);
|
||||||
|
const base = this.compareSortValues(leftValue, rightValue);
|
||||||
|
if (base === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return spec.direction === "asc" ? base : -base;
|
||||||
|
}
|
||||||
|
return left.agent.id.localeCompare(right.agent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private encodeFetchAgentsCursor(
|
||||||
|
entry: FetchAgentsResponseEntry,
|
||||||
|
sort: FetchAgentsRequestSort[]
|
||||||
|
): string {
|
||||||
|
const values: Record<string, string | number | null> = {};
|
||||||
|
for (const spec of sort) {
|
||||||
|
values[spec.key] = this.getFetchAgentsSortValue(entry, spec.key);
|
||||||
|
}
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
sort,
|
||||||
|
values,
|
||||||
|
id: entry.agent.id,
|
||||||
|
}),
|
||||||
|
"utf8"
|
||||||
|
).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeFetchAgentsCursor(
|
||||||
|
cursor: string,
|
||||||
|
sort: FetchAgentsRequestSort[]
|
||||||
|
): FetchAgentsCursor {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsed as {
|
||||||
|
sort?: unknown;
|
||||||
|
values?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Array.isArray(payload.sort) || typeof payload.id !== "string") {
|
||||||
|
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
|
||||||
|
}
|
||||||
|
if (!payload.values || typeof payload.values !== "object") {
|
||||||
|
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorSort: FetchAgentsRequestSort[] = [];
|
||||||
|
for (const item of payload.sort) {
|
||||||
|
if (
|
||||||
|
!item ||
|
||||||
|
typeof item !== "object" ||
|
||||||
|
typeof (item as { key?: unknown }).key !== "string" ||
|
||||||
|
typeof (item as { direction?: unknown }).direction !== "string"
|
||||||
|
) {
|
||||||
|
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = (item as { key: string }).key;
|
||||||
|
const direction = (item as { direction: string }).direction;
|
||||||
|
if (
|
||||||
|
(key !== "status_priority" &&
|
||||||
|
key !== "created_at" &&
|
||||||
|
key !== "updated_at" &&
|
||||||
|
key !== "title") ||
|
||||||
|
(direction !== "asc" && direction !== "desc")
|
||||||
|
) {
|
||||||
|
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
|
||||||
|
}
|
||||||
|
cursorSort.push({ key, direction });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cursorSort.length !== sort.length ||
|
||||||
|
cursorSort.some(
|
||||||
|
(entry, index) =>
|
||||||
|
entry.key !== sort[index]?.key ||
|
||||||
|
entry.direction !== sort[index]?.direction
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new SessionRequestError(
|
||||||
|
"invalid_cursor",
|
||||||
|
"fetch_agents cursor does not match current sort"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sort: cursorSort,
|
||||||
|
values: payload.values as Record<string, string | number | null>,
|
||||||
|
id: payload.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private compareEntryWithCursor(
|
||||||
|
entry: FetchAgentsResponseEntry,
|
||||||
|
cursor: FetchAgentsCursor,
|
||||||
|
sort: FetchAgentsRequestSort[]
|
||||||
|
): number {
|
||||||
|
for (const spec of sort) {
|
||||||
|
const leftValue = this.getFetchAgentsSortValue(entry, spec.key);
|
||||||
|
const rightValue =
|
||||||
|
cursor.values[spec.key] !== undefined ? cursor.values[spec.key] ?? null : null;
|
||||||
|
const base = this.compareSortValues(leftValue, rightValue);
|
||||||
|
if (base === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return spec.direction === "asc" ? base : -base;
|
||||||
|
}
|
||||||
|
return entry.agent.id.localeCompare(cursor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listFetchAgentsEntries(
|
||||||
|
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>
|
||||||
|
): Promise<{
|
||||||
|
entries: FetchAgentsResponseEntry[];
|
||||||
|
pageInfo: FetchAgentsResponsePageInfo;
|
||||||
|
}> {
|
||||||
|
const filter = request.filter;
|
||||||
|
const sort = this.normalizeFetchAgentsSort(request.sort);
|
||||||
|
const includeArchived = filter?.includeArchived ?? false;
|
||||||
|
|
||||||
|
let agents = await this.listAgentPayloads({
|
||||||
|
labels: filter?.labels,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!includeArchived) {
|
||||||
|
agents = agents.filter((agent) => !agent.archivedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.statuses && filter.statuses.length > 0) {
|
||||||
|
const statuses = new Set(filter.statuses);
|
||||||
|
agents = agents.filter((agent) => statuses.has(agent.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter?.requiresAttention === "boolean") {
|
||||||
|
agents = agents.filter(
|
||||||
|
(agent) =>
|
||||||
|
(agent.requiresAttention ?? false) === filter.requiresAttention
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload>>();
|
||||||
|
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload> => {
|
||||||
|
const existing = placementByCwd.get(cwd);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const placementPromise = this.buildProjectPlacement(cwd);
|
||||||
|
placementByCwd.set(cwd, placementPromise);
|
||||||
|
return placementPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = await Promise.all(
|
||||||
|
agents.map(async (agent) => ({
|
||||||
|
agent,
|
||||||
|
project: await getPlacement(agent.cwd),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filter?.projectKeys && filter.projectKeys.length > 0) {
|
||||||
|
const projectKeys = new Set(filter.projectKeys.filter((item) => item.trim().length > 0));
|
||||||
|
entries = entries.filter((entry) => projectKeys.has(entry.project.projectKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((left, right) =>
|
||||||
|
this.compareFetchAgentsEntries(left, right, sort)
|
||||||
|
);
|
||||||
|
|
||||||
|
const cursorToken = request.page?.cursor;
|
||||||
|
if (cursorToken) {
|
||||||
|
const cursor = this.decodeFetchAgentsCursor(cursorToken, sort);
|
||||||
|
entries = entries.filter(
|
||||||
|
(entry) => this.compareEntryWithCursor(entry, cursor, sort) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = request.page?.limit ?? entries.length;
|
||||||
|
const pagedEntries = entries.slice(0, limit);
|
||||||
|
const hasMore = entries.length > limit;
|
||||||
|
const nextCursor =
|
||||||
|
hasMore && pagedEntries.length > 0
|
||||||
|
? this.encodeFetchAgentsCursor(
|
||||||
|
pagedEntries[pagedEntries.length - 1],
|
||||||
|
sort
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: pagedEntries,
|
||||||
|
pageInfo: {
|
||||||
|
nextCursor,
|
||||||
|
prevCursor: request.page?.cursor ?? null,
|
||||||
|
hasMore,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleFetchAgents(
|
||||||
|
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const groups = await this.listAgentsGroupedByProjectPayload(filter);
|
const payload = await this.listFetchAgentsEntries(request);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "fetch_agents_grouped_by_project_response",
|
type: "fetch_agents_response",
|
||||||
payload: { requestId, groups },
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
...payload,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.error(
|
const code =
|
||||||
{ err: error },
|
error instanceof SessionRequestError ? error.code : "fetch_agents_failed";
|
||||||
"Failed to handle fetch_agents_grouped_by_project_request"
|
const message =
|
||||||
);
|
error instanceof Error ? error.message : "Failed to fetch agents";
|
||||||
|
this.sessionLogger.error({ err: error }, "Failed to handle fetch_agents_request");
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "fetch_agents_grouped_by_project_response",
|
type: "rpc_error",
|
||||||
payload: { requestId, groups: [] },
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
requestType: request.type,
|
||||||
|
error: message,
|
||||||
|
code,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5962,6 +6181,10 @@ export class Session {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
}
|
}
|
||||||
this.terminalSubscriptions.clear();
|
this.terminalSubscriptions.clear();
|
||||||
|
for (const unsubscribeExit of this.terminalExitSubscriptions.values()) {
|
||||||
|
unsubscribeExit();
|
||||||
|
}
|
||||||
|
this.terminalExitSubscriptions.clear();
|
||||||
this.detachAllTerminalStreams({ emitExit: false });
|
this.detachAllTerminalStreams({ emitExit: false });
|
||||||
|
|
||||||
for (const target of this.checkoutDiffTargets.values()) {
|
for (const target of this.checkoutDiffTargets.values()) {
|
||||||
@@ -5975,6 +6198,43 @@ export class Session {
|
|||||||
// Terminal Handlers
|
// Terminal Handlers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
private ensureTerminalExitSubscription(terminal: TerminalSession): void {
|
||||||
|
if (this.terminalExitSubscriptions.has(terminal.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribeExit = terminal.onExit(() => {
|
||||||
|
this.handleTerminalExited(terminal.id);
|
||||||
|
});
|
||||||
|
this.terminalExitSubscriptions.set(terminal.id, unsubscribeExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTerminalExited(terminalId: string): void {
|
||||||
|
const unsubscribeExit = this.terminalExitSubscriptions.get(terminalId);
|
||||||
|
if (unsubscribeExit) {
|
||||||
|
unsubscribeExit();
|
||||||
|
this.terminalExitSubscriptions.delete(terminalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = this.terminalSubscriptions.get(terminalId);
|
||||||
|
if (unsubscribe) {
|
||||||
|
try {
|
||||||
|
unsubscribe();
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.warn(
|
||||||
|
{ err: error, terminalId },
|
||||||
|
"Failed to unsubscribe terminal after process exit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.terminalSubscriptions.delete(terminalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamId = this.terminalStreamByTerminalId.get(terminalId);
|
||||||
|
if (typeof streamId === "number") {
|
||||||
|
this.detachTerminalStream(streamId, { emitExit: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise<void> {
|
private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise<void> {
|
||||||
if (!this.terminalManager) {
|
if (!this.terminalManager) {
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -5990,6 +6250,9 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const terminals = await this.terminalManager.getTerminals(msg.cwd);
|
const terminals = await this.terminalManager.getTerminals(msg.cwd);
|
||||||
|
for (const terminal of terminals) {
|
||||||
|
this.ensureTerminalExitSubscription(terminal);
|
||||||
|
}
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "list_terminals_response",
|
type: "list_terminals_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -6029,6 +6292,7 @@ export class Session {
|
|||||||
cwd: msg.cwd,
|
cwd: msg.cwd,
|
||||||
name: msg.name,
|
name: msg.name,
|
||||||
});
|
});
|
||||||
|
this.ensureTerminalExitSubscription(session);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "create_terminal_response",
|
type: "create_terminal_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -6077,6 +6341,7 @@ export class Session {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.ensureTerminalExitSubscription(session);
|
||||||
|
|
||||||
// Unsubscribe from previous subscription if any
|
// Unsubscribe from previous subscription if any
|
||||||
const existing = this.terminalSubscriptions.get(msg.terminalId);
|
const existing = this.terminalSubscriptions.get(msg.terminalId);
|
||||||
@@ -6128,6 +6393,7 @@ export class Session {
|
|||||||
this.sessionLogger.warn({ terminalId: msg.terminalId }, "Terminal not found for input");
|
this.sessionLogger.warn({ terminalId: msg.terminalId }, "Terminal not found for input");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.ensureTerminalExitSubscription(session);
|
||||||
|
|
||||||
session.send(msg.message);
|
session.send(msg.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,16 +458,24 @@ export const FetchAgentsRequestMessageSchema = z.object({
|
|||||||
filter: z
|
filter: z
|
||||||
.object({
|
.object({
|
||||||
labels: z.record(z.string()).optional(),
|
labels: z.record(z.string()).optional(),
|
||||||
|
projectKeys: z.array(z.string()).optional(),
|
||||||
|
statuses: z.array(AgentStatusSchema).optional(),
|
||||||
|
includeArchived: z.boolean().optional(),
|
||||||
|
requiresAttention: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
sort: z
|
||||||
|
.array(
|
||||||
export const FetchAgentsGroupedByProjectRequestMessageSchema = z.object({
|
z.object({
|
||||||
type: z.literal("fetch_agents_grouped_by_project_request"),
|
key: z.enum(["status_priority", "created_at", "updated_at", "title"]),
|
||||||
requestId: z.string(),
|
direction: z.enum(["asc", "desc"]),
|
||||||
filter: z
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
page: z
|
||||||
.object({
|
.object({
|
||||||
labels: z.record(z.string()).optional(),
|
limit: z.number().int().positive().max(1000),
|
||||||
|
cursor: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
@@ -1002,7 +1010,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AbortRequestMessageSchema,
|
AbortRequestMessageSchema,
|
||||||
AudioPlayedMessageSchema,
|
AudioPlayedMessageSchema,
|
||||||
FetchAgentsRequestMessageSchema,
|
FetchAgentsRequestMessageSchema,
|
||||||
FetchAgentsGroupedByProjectRequestMessageSchema,
|
|
||||||
FetchAgentRequestMessageSchema,
|
FetchAgentRequestMessageSchema,
|
||||||
SubscribeAgentUpdatesMessageSchema,
|
SubscribeAgentUpdatesMessageSchema,
|
||||||
UnsubscribeAgentUpdatesMessageSchema,
|
UnsubscribeAgentUpdatesMessageSchema,
|
||||||
@@ -1399,26 +1406,17 @@ export const FetchAgentsResponseMessageSchema = z.object({
|
|||||||
type: z.literal("fetch_agents_response"),
|
type: z.literal("fetch_agents_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
agents: z.array(AgentSnapshotPayloadSchema),
|
entries: z.array(
|
||||||
}),
|
z.object({
|
||||||
});
|
agent: AgentSnapshotPayloadSchema,
|
||||||
|
project: ProjectPlacementPayloadSchema,
|
||||||
const ProjectGroupedAgentEntryPayloadSchema = z.object({
|
})
|
||||||
agent: AgentSnapshotPayloadSchema,
|
),
|
||||||
checkout: ProjectCheckoutLitePayloadSchema,
|
pageInfo: z.object({
|
||||||
});
|
nextCursor: z.string().nullable(),
|
||||||
|
prevCursor: z.string().nullable(),
|
||||||
const ProjectGroupPayloadSchema = z.object({
|
hasMore: z.boolean(),
|
||||||
projectKey: z.string(),
|
}),
|
||||||
projectName: z.string(),
|
|
||||||
agents: z.array(ProjectGroupedAgentEntryPayloadSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const FetchAgentsGroupedByProjectResponseMessageSchema = z.object({
|
|
||||||
type: z.literal("fetch_agents_grouped_by_project_response"),
|
|
||||||
payload: z.object({
|
|
||||||
requestId: z.string(),
|
|
||||||
groups: z.array(ProjectGroupPayloadSchema),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1972,7 +1970,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentStreamMessageSchema,
|
AgentStreamMessageSchema,
|
||||||
AgentStatusMessageSchema,
|
AgentStatusMessageSchema,
|
||||||
FetchAgentsResponseMessageSchema,
|
FetchAgentsResponseMessageSchema,
|
||||||
FetchAgentsGroupedByProjectResponseMessageSchema,
|
|
||||||
FetchAgentResponseMessageSchema,
|
FetchAgentResponseMessageSchema,
|
||||||
FetchAgentTimelineResponseMessageSchema,
|
FetchAgentTimelineResponseMessageSchema,
|
||||||
SendAgentMessageResponseMessageSchema,
|
SendAgentMessageResponseMessageSchema,
|
||||||
@@ -2042,9 +2039,6 @@ export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSche
|
|||||||
export type FetchAgentsResponseMessage = z.infer<
|
export type FetchAgentsResponseMessage = z.infer<
|
||||||
typeof FetchAgentsResponseMessageSchema
|
typeof FetchAgentsResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
export type FetchAgentsGroupedByProjectResponseMessage = z.infer<
|
|
||||||
typeof FetchAgentsGroupedByProjectResponseMessageSchema
|
|
||||||
>;
|
|
||||||
export type FetchAgentResponseMessage = z.infer<
|
export type FetchAgentResponseMessage = z.infer<
|
||||||
typeof FetchAgentResponseMessageSchema
|
typeof FetchAgentResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
@@ -2079,9 +2073,6 @@ export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
|||||||
// Type exports for inbound message types
|
// Type exports for inbound message types
|
||||||
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
|
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
|
||||||
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
|
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
|
||||||
export type FetchAgentsGroupedByProjectRequestMessage = z.infer<
|
|
||||||
typeof FetchAgentsGroupedByProjectRequestMessageSchema
|
|
||||||
>;
|
|
||||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
||||||
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
|
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
||||||
|
|
||||||
|
async function waitForCondition(
|
||||||
|
predicate: () => boolean,
|
||||||
|
timeoutMs: number,
|
||||||
|
intervalMs = 25
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (predicate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
|
||||||
|
}
|
||||||
|
|
||||||
describe("TerminalManager", () => {
|
describe("TerminalManager", () => {
|
||||||
let manager: TerminalManager;
|
let manager: TerminalManager;
|
||||||
|
|
||||||
@@ -136,6 +151,21 @@ describe("TerminalManager", () => {
|
|||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
expect(() => manager.killTerminal("unknown-id")).not.toThrow();
|
expect(() => manager.killTerminal("unknown-id")).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-removes terminal when shell exits", async () => {
|
||||||
|
manager = createTerminalManager();
|
||||||
|
const terminals = await manager.getTerminals("/tmp");
|
||||||
|
const exitedId = terminals[0].id;
|
||||||
|
terminals[0].send({ type: "input", data: "\u0004" });
|
||||||
|
|
||||||
|
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
|
||||||
|
|
||||||
|
expect(manager.getTerminal(exitedId)).toBeUndefined();
|
||||||
|
|
||||||
|
const remaining = await manager.getTerminals("/tmp");
|
||||||
|
expect(remaining).toHaveLength(1);
|
||||||
|
expect(remaining[0].id).not.toBe(exitedId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("listDirectories", () => {
|
describe("listDirectories", () => {
|
||||||
@@ -161,12 +191,14 @@ describe("TerminalManager", () => {
|
|||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const tmpTerminals = await manager.getTerminals("/tmp");
|
const tmpTerminals = await manager.getTerminals("/tmp");
|
||||||
const homeTerminals = await manager.getTerminals("/home");
|
const homeTerminals = await manager.getTerminals("/home");
|
||||||
|
const tmpId = tmpTerminals[0].id;
|
||||||
|
const homeId = homeTerminals[0].id;
|
||||||
|
|
||||||
manager.killAll();
|
manager.killAll();
|
||||||
|
|
||||||
expect(manager.listDirectories()).toEqual([]);
|
expect(manager.listDirectories()).toEqual([]);
|
||||||
expect(manager.getTerminal(tmpTerminals[0].id)).toBeUndefined();
|
expect(manager.getTerminal(tmpId)).toBeUndefined();
|
||||||
expect(manager.getTerminal(homeTerminals[0].id)).toBeUndefined();
|
expect(manager.getTerminal(homeId)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface TerminalManager {
|
|||||||
export function createTerminalManager(): TerminalManager {
|
export function createTerminalManager(): TerminalManager {
|
||||||
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
||||||
const terminalsById = new Map<string, TerminalSession>();
|
const terminalsById = new Map<string, TerminalSession>();
|
||||||
|
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||||
|
|
||||||
function assertAbsolutePath(cwd: string): void {
|
function assertAbsolutePath(cwd: string): void {
|
||||||
if (!cwd.startsWith("/")) {
|
if (!cwd.startsWith("/")) {
|
||||||
@@ -19,16 +20,56 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeSessionById(id: string, options: { kill: boolean }): void {
|
||||||
|
const session = terminalsById.get(id);
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribeExit = terminalExitUnsubscribeById.get(id);
|
||||||
|
if (unsubscribeExit) {
|
||||||
|
unsubscribeExit();
|
||||||
|
terminalExitUnsubscribeById.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalsById.delete(id);
|
||||||
|
|
||||||
|
const terminals = terminalsByCwd.get(session.cwd);
|
||||||
|
if (terminals) {
|
||||||
|
const index = terminals.findIndex((terminal) => terminal.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
terminals.splice(index, 1);
|
||||||
|
}
|
||||||
|
if (terminals.length === 0) {
|
||||||
|
terminalsByCwd.delete(session.cwd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.kill) {
|
||||||
|
session.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSession(session: TerminalSession): TerminalSession {
|
||||||
|
terminalsById.set(session.id, session);
|
||||||
|
const unsubscribeExit = session.onExit(() => {
|
||||||
|
removeSessionById(session.id, { kill: false });
|
||||||
|
});
|
||||||
|
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
||||||
assertAbsolutePath(cwd);
|
assertAbsolutePath(cwd);
|
||||||
|
|
||||||
let terminals = terminalsByCwd.get(cwd);
|
let terminals = terminalsByCwd.get(cwd);
|
||||||
if (!terminals || terminals.length === 0) {
|
if (!terminals || terminals.length === 0) {
|
||||||
const session = await createTerminal({ cwd, name: "Terminal 1" });
|
const session = registerSession(
|
||||||
|
await createTerminal({ cwd, name: "Terminal 1" })
|
||||||
|
);
|
||||||
terminals = [session];
|
terminals = [session];
|
||||||
terminalsByCwd.set(cwd, terminals);
|
terminalsByCwd.set(cwd, terminals);
|
||||||
terminalsById.set(session.id, session);
|
|
||||||
}
|
}
|
||||||
return terminals;
|
return terminals;
|
||||||
},
|
},
|
||||||
@@ -38,14 +79,15 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
|
|
||||||
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
||||||
const defaultName = `Terminal ${terminals.length + 1}`;
|
const defaultName = `Terminal ${terminals.length + 1}`;
|
||||||
const session = await createTerminal({
|
const session = registerSession(
|
||||||
cwd: options.cwd,
|
await createTerminal({
|
||||||
name: options.name ?? defaultName,
|
cwd: options.cwd,
|
||||||
});
|
name: options.name ?? defaultName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
terminals.push(session);
|
terminals.push(session);
|
||||||
terminalsByCwd.set(options.cwd, terminals);
|
terminalsByCwd.set(options.cwd, terminals);
|
||||||
terminalsById.set(session.id, session);
|
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
@@ -55,22 +97,7 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
},
|
},
|
||||||
|
|
||||||
killTerminal(id: string): void {
|
killTerminal(id: string): void {
|
||||||
const session = terminalsById.get(id);
|
removeSessionById(id, { kill: true });
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
session.kill();
|
|
||||||
terminalsById.delete(id);
|
|
||||||
|
|
||||||
const terminals = terminalsByCwd.get(session.cwd);
|
|
||||||
if (terminals) {
|
|
||||||
const index = terminals.indexOf(session);
|
|
||||||
if (index !== -1) {
|
|
||||||
terminals.splice(index, 1);
|
|
||||||
}
|
|
||||||
if (terminals.length === 0) {
|
|
||||||
terminalsByCwd.delete(session.cwd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
listDirectories(): string[] {
|
listDirectories(): string[] {
|
||||||
@@ -78,11 +105,9 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
},
|
},
|
||||||
|
|
||||||
killAll(): void {
|
killAll(): void {
|
||||||
for (const session of terminalsById.values()) {
|
for (const id of Array.from(terminalsById.keys())) {
|
||||||
session.kill();
|
removeSessionById(id, { kill: true });
|
||||||
}
|
}
|
||||||
terminalsByCwd.clear();
|
|
||||||
terminalsById.clear();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface TerminalSession {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
send(msg: ClientMessage): void;
|
send(msg: ClientMessage): void;
|
||||||
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
||||||
|
onExit(listener: () => void): () => void;
|
||||||
subscribeRaw(
|
subscribeRaw(
|
||||||
listener: (chunk: TerminalRawChunk) => void,
|
listener: (chunk: TerminalRawChunk) => void,
|
||||||
options?: { fromOffset?: number }
|
options?: { fromOffset?: number }
|
||||||
@@ -190,6 +191,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const listeners = new Set<(msg: ServerMessage) => void>();
|
const listeners = new Set<(msg: ServerMessage) => void>();
|
||||||
const rawListeners = new Set<(chunk: TerminalRawChunk) => void>();
|
const rawListeners = new Set<(chunk: TerminalRawChunk) => void>();
|
||||||
|
const exitListeners = new Set<() => void>();
|
||||||
const rawOutputChunks: Array<{
|
const rawOutputChunks: Array<{
|
||||||
startOffset: number;
|
startOffset: number;
|
||||||
endOffset: number;
|
endOffset: number;
|
||||||
@@ -199,6 +201,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
let rawBufferBytes = 0;
|
let rawBufferBytes = 0;
|
||||||
let outputOffset = 0;
|
let outputOffset = 0;
|
||||||
let killed = false;
|
let killed = false;
|
||||||
|
let disposed = false;
|
||||||
|
let exitEmitted = false;
|
||||||
|
|
||||||
// Create xterm.js headless terminal
|
// Create xterm.js headless terminal
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
@@ -221,6 +225,32 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function emitExit(): void {
|
||||||
|
if (exitEmitted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exitEmitted = true;
|
||||||
|
for (const listener of Array.from(exitListeners)) {
|
||||||
|
try {
|
||||||
|
listener();
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exitListeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeResources(): void {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
disposed = true;
|
||||||
|
terminal.dispose();
|
||||||
|
listeners.clear();
|
||||||
|
rawListeners.clear();
|
||||||
|
exitListeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function appendRawOutputChunk(data: string): void {
|
function appendRawOutputChunk(data: string): void {
|
||||||
const chunkBytes = Buffer.byteLength(data);
|
const chunkBytes = Buffer.byteLength(data);
|
||||||
if (chunkBytes <= 0) {
|
if (chunkBytes <= 0) {
|
||||||
@@ -292,6 +322,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
|
|
||||||
ptyProcess.onExit(() => {
|
ptyProcess.onExit(() => {
|
||||||
killed = true;
|
killed = true;
|
||||||
|
emitExit();
|
||||||
|
disposeResources();
|
||||||
});
|
});
|
||||||
|
|
||||||
function getState(): TerminalState {
|
function getState(): TerminalState {
|
||||||
@@ -340,6 +372,24 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onExit(listener: () => void): () => void {
|
||||||
|
if (killed) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
try {
|
||||||
|
listener();
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
exitListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
exitListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function subscribeRaw(
|
function subscribeRaw(
|
||||||
listener: (chunk: TerminalRawChunk) => void,
|
listener: (chunk: TerminalRawChunk) => void,
|
||||||
options?: { fromOffset?: number }
|
options?: { fromOffset?: number }
|
||||||
@@ -380,11 +430,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
}
|
}
|
||||||
|
|
||||||
function kill(): void {
|
function kill(): void {
|
||||||
if (killed) return;
|
if (!killed) {
|
||||||
killed = true;
|
killed = true;
|
||||||
ptyProcess.kill();
|
ptyProcess.kill();
|
||||||
terminal.dispose();
|
emitExit();
|
||||||
rawListeners.clear();
|
}
|
||||||
|
disposeResources();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay to let shell initialize
|
// Small delay to let shell initialize
|
||||||
@@ -396,6 +447,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
cwd,
|
cwd,
|
||||||
send,
|
send,
|
||||||
subscribe,
|
subscribe,
|
||||||
|
onExit,
|
||||||
subscribeRaw,
|
subscribeRaw,
|
||||||
getOutputOffset,
|
getOutputOffset,
|
||||||
getState,
|
getState,
|
||||||
|
|||||||
Reference in New Issue
Block a user