diff --git a/packages/app/app.config.js b/packages/app/app.config.js index bd434a08f..95bb459a5 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -112,6 +112,6 @@ export default { projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a", }, }, - owner: "moboudra", + owner: "getpaseo", }, }; diff --git a/packages/app/e2e/terminal-pane.spec.ts b/packages/app/e2e/terminal-pane.spec.ts index 74c256f8e..f50f55b14 100644 --- a/packages/app/e2e/terminal-pane.spec.ts +++ b/packages/app/e2e/terminal-pane.spec.ts @@ -123,6 +123,16 @@ async function selectNewestTerminalTab(page: Page): Promise { await tabs.last().click(); } +async function getFirstTerminalTabTestId(page: Page): Promise { + 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 { const surface = page.getByTestId("terminal-surface").first(); 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 }) => { const repo = await createTempGitRepo("paseo-e2e-terminal-share-"); diff --git a/packages/app/eas.json b/packages/app/eas.json index b12790095..32cc8e130 100644 --- a/packages/app/eas.json +++ b/packages/app/eas.json @@ -30,6 +30,10 @@ } }, "submit": { - "production": {} + "production": { + "ios": { + "ascAppId": "6758887924" + } + } } } diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index f73bbb8d5..29e2d25b8 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -2,11 +2,10 @@ import { View, Text, Pressable, - Modal, Image, Platform, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useQueries } from "@tanstack/react-query"; import { useCallback, useMemo, @@ -19,13 +18,11 @@ import { import { router, usePathname } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { type GestureType } from "react-native-gesture-handler"; -import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native"; +import { Archive, Check, ChevronDown } from "lucide-react-native"; import { DraggableList, type DraggableRenderItemInfo, } from "./draggable-list"; -import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping"; -import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { buildAgentNavigationKey, @@ -35,25 +32,27 @@ import { buildHostAgentDetailRoute, parseHostAgentRouteFromPathname, } from "@/utils/host-routes"; -import { buildNewAgentRoute } from "@/utils/new-agent-routing"; +import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { - useSectionOrderStore, -} from "@/stores/section-order-store"; -import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; -import { - type SidebarCheckoutLite, - type SidebarSectionData, + type SidebarAgentListEntry, + type SidebarProjectOption, } from "@/hooks/use-sidebar-agents-grouped"; -import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { getIsTauri } from "@/constants/layout"; import { AgentStatusDot } from "@/components/agent-status-dot"; +import { Combobox } from "@/components/ui/combobox"; +import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts"; +import { parseRepoNameFromRemoteUrl } from "@/utils/agent-grouping"; +import { formatTimeAgo } from "@/utils/time"; +import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state"; -type SectionData = SidebarSectionData; +type EntryData = SidebarAgentListEntry; interface SidebarAgentListProps { - sections: SidebarSectionData[]; - checkoutByAgentKey: Map; + entries: SidebarAgentListEntry[]; + projectOptions: SidebarProjectOption[]; + selectedProjectKeys: string[]; + onSelectedProjectKeysChange: (keys: string[]) => void; isRefreshing?: boolean; onRefresh?: () => void; selectedAgentId?: string; @@ -63,145 +62,98 @@ interface SidebarAgentListProps { parentGestureRef?: MutableRefObject; } -interface SectionHeaderProps { - section: SectionData; - checkout: SidebarCheckoutLite | null; - isCollapsed: boolean; - onToggle: () => void; - onCreateAgent: (serverId: string, workingDir: string) => void; - onDrag: () => void; - isDragging: boolean; +interface ProjectFilterOptionRowProps { + option: SidebarProjectOption; + selected: boolean; + iconDataUri: string | null; + displayName: string; + onToggle: (projectKey: string) => void; } -function SectionHeader({ - section, - checkout, - isCollapsed, +function ProjectFilterOptionRow({ + option, + selected, + iconDataUri, + displayName, onToggle, - onCreateAgent, - onDrag, - isDragging, -}: SectionHeaderProps) { +}: ProjectFilterOptionRowProps) { const { theme } = useUnistyles(); - const [isHovered, setIsHovered] = useState(false); - - // Get project icon - const iconQuery = useProjectIconQuery({ - serverId: section.firstAgentServerId ?? "", - cwd: section.workingDir ?? "", - }); - const icon = iconQuery.icon; - - // Derive display title: prefer repo name from remote URL, fallback to path-based name - let displayTitle = section.title; - if (checkout?.isGit && checkout.remoteUrl) { - const isGitHubRemote = - checkout.remoteUrl.includes("github.com") || - checkout.remoteUrl.includes("git@github.com:"); - - const repoName = isGitHubRemote - ? parseRepoNameFromRemoteUrl(checkout.remoteUrl) - : parseRepoShortNameFromRemoteUrl(checkout.remoteUrl); - - if (repoName) { - displayTitle = repoName; - } - } - - const createAgentWorkingDir = - (checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? section.workingDir; - - const handleCreatePress = useCallback( - (e: { stopPropagation: () => void }) => { - e.stopPropagation(); - const serverId = section.firstAgentServerId; - if (createAgentWorkingDir && serverId) { - onCreateAgent(serverId, createAgentWorkingDir); - } - }, - [onCreateAgent, createAgentWorkingDir, section.firstAgentServerId] - ); return ( [ - styles.sectionHeader, - isHovered && styles.sectionHeaderHovered, - pressed && styles.sectionHeaderPressed, - !isCollapsed && styles.sectionHeaderExpanded, - isDragging && styles.sectionHeaderDragging, - Platform.OS === "web" && ({ cursor: "grab" } as any), + style={({ pressed, hovered = false }) => [ + styles.filterOption, + hovered && styles.filterOptionHovered, + pressed && styles.filterOptionPressed, ]} - onPress={onToggle} - onLongPress={onDrag} - delayLongPress={200} - onHoverIn={() => setIsHovered(true)} - onHoverOut={() => setIsHovered(false)} + onPress={() => onToggle(option.projectKey)} > - - - {isCollapsed ? ( - - ) : ( - - )} - - {icon ? ( + + {iconDataUri ? ( ) : ( - - - {(section.workingDir?.split("/").pop() ?? displayTitle).charAt(0).toUpperCase()} + + + {displayName.charAt(0).toUpperCase()} )} - - {displayTitle} + + {displayName} - - {createAgentWorkingDir && ( - true} - onStartShouldSetResponderCapture={() => true} - > - {({ hovered, pressed }) => ( - - )} - - )} + + + {option.activeCount > 0 ? ( + {option.activeCount} + ) : null} + {selected ? : null} ); } interface SidebarAgentRowProps { - agent: AggregatedAgent; - checkout: SidebarCheckoutLite | null; + entry: SidebarAgentListEntry; + projectDisplayName: string; + projectIconDataUri: string | null; isSelected: boolean; + isInSelectionMode: boolean; + isBatchSelected: boolean; shortcutNumber: number | null; onPress: () => void; onLongPress: () => void; - onArchive: (e: { stopPropagation: () => void }) => void; + onArchive: () => void; + onToggleBatch: () => void; +} + +function resolveBranchLabel(entry: SidebarAgentListEntry): string | null { + const checkout = entry.project.checkout; + if (!checkout.isGit) { + return null; + } + const branch = checkout.currentBranch; + if (!branch || branch === "HEAD" || branch === "main") { + return null; + } + return branch; } function SidebarAgentRow({ - agent, - checkout, + entry, + projectDisplayName, + projectIconDataUri, isSelected, + isInSelectionMode, + isBatchSelected, shortcutNumber, onPress, onLongPress, onArchive, + onToggleBatch, }: SidebarAgentRowProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); @@ -209,6 +161,19 @@ function SidebarAgentRow({ const [isArchiveConfirmVisible, setIsArchiveConfirmVisible] = useState(false); const hoverOutTimeoutRef = useRef | null>(null); + const branchLabel = resolveBranchLabel(entry); + const relativeCreatedAt = formatTimeAgo(entry.agent.createdAt); + const isActive = isSidebarActiveAgent({ + status: entry.agent.status, + requiresAttention: entry.agent.requiresAttention, + attentionReason: entry.agent.attentionReason, + }); + const shouldApplyInactiveStyles = !isActive && !isSelected; + const showArchive = + !isInSelectionMode && + shortcutNumber === null && + (isHovered || isArchiveHovered || isArchiveConfirmVisible); + const clearHoverOutTimeout = useCallback(() => { if (!hoverOutTimeoutRef.current) { return; @@ -234,127 +199,232 @@ function SidebarAgentRow({ setIsArchiveConfirmVisible(false); }, 50); }, [clearHoverOutTimeout]); - const activeBranchLabel = checkout?.isGit - ? ((checkout.currentBranch && checkout.currentBranch !== "HEAD" - ? checkout.currentBranch - : null) ?? - "git") - : null; - - const canArchive = agent.status !== "running" && !agent.requiresAttention; - const showArchive = canArchive && - shortcutNumber === null && - (isHovered || isArchiveHovered || isArchiveConfirmVisible); - const reserveArchiveSlot = canArchive && shortcutNumber === null && !activeBranchLabel; return ( [ styles.agentItem, - !isSelected && styles.agentItemUnselected, isSelected && styles.agentItemSelected, isHovered && styles.agentItemHovered, pressed && styles.agentItemPressed, ]} onPress={onPress} + onLongPress={onLongPress} onPressIn={() => { if (Platform.OS !== "web") { return; } handleHoverIn(); }} - onLongPress={onLongPress} onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} - testID={`agent-row-${agent.serverId}-${agent.id}`} + testID={`agent-row-${entry.agent.serverId}-${entry.agent.id}`} > - - - - + {isInSelectionMode ? ( + { + event.stopPropagation(); + onToggleBatch(); + }} style={[ - styles.agentTitle, - (isSelected || isHovered) && styles.agentTitleHighlighted, + styles.checkbox, + isBatchSelected && styles.checkboxSelected, ]} - numberOfLines={1} > - {agent.title || "New agent"} - - {shortcutNumber !== null ? ( - - - {shortcutNumber} - - - ) : showArchive ? ( - { - clearHoverOutTimeout(); - setIsHovered(true); - setIsArchiveHovered(true); - }} - onHoverOut={() => { - setIsArchiveHovered(false); - }} - onPress={(e) => { - e.stopPropagation(); - if (!isArchiveConfirmVisible) { - setIsArchiveConfirmVisible(true); - return; - } - onArchive(e); - setIsArchiveConfirmVisible(false); - }} - testID={ - isArchiveConfirmVisible - ? `agent-archive-confirm-${agent.serverId}-${agent.id}` - : `agent-archive-${agent.serverId}-${agent.id}` + {isBatchSelected ? ( + + ) : null} + + ) : ( + + )} + + + {entry.agent.title || "New agent"} + + + {showArchive ? ( + { + clearHoverOutTimeout(); + setIsHovered(true); + setIsArchiveHovered(true); + }} + onHoverOut={() => { + setIsArchiveHovered(false); + }} + onPress={(event) => { + event.stopPropagation(); + if (!isArchiveConfirmVisible) { + setIsArchiveConfirmVisible(true); + return; } - > - {({ hovered: archiveHovered }) => - isArchiveConfirmVisible ? ( - - Confirm - - ) : ( - - ) - } - - ) : activeBranchLabel ? ( - - - {activeBranchLabel} - - - ) : reserveArchiveSlot ? ( - - ) : null} - + onArchive(); + setIsArchiveConfirmVisible(false); + }} + style={styles.archiveButton} + testID={ + isArchiveConfirmVisible + ? `agent-archive-confirm-${entry.agent.serverId}-${entry.agent.id}` + : `agent-archive-${entry.agent.serverId}-${entry.agent.id}` + } + > + {({ hovered: archiveHovered }) => + isArchiveConfirmVisible ? ( + + ) : ( + + ) + } + + ) : ( + {relativeCreatedAt} + )} + + {shortcutNumber !== null && !isInSelectionMode ? ( + + {shortcutNumber} + + ) : null} + + + + + {projectIconDataUri ? ( + + ) : ( + + + {projectDisplayName.charAt(0).toUpperCase()} + + + )} + + {projectDisplayName} + + {branchLabel ? ( + + + {branchLabel} + + + ) : null} ); } +function deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys: string[]) { + const map = new Map(); + for (let i = 0; i < sidebarShortcutAgentKeys.length; i += 1) { + const key = sidebarShortcutAgentKeys[i]; + if (!key) continue; + map.set(key, i + 1); + } + return map; +} + +function resolveSelectedProjectLabel(input: { + selectedProjectKeys: string[]; + projectOptions: SidebarProjectOption[]; +}): string { + if (input.selectedProjectKeys.length === 0) { + return "Project"; + } + if (input.selectedProjectKeys.length === 1) { + const selected = input.projectOptions.find( + (option) => option.projectKey === input.selectedProjectKeys[0] + ); + if (selected) { + return deriveProjectDisplayName({ + projectKey: selected.projectKey, + projectName: selected.projectName, + remoteUrl: null, + }); + } + return deriveProjectDisplayName({ + projectKey: input.selectedProjectKeys[0] ?? "", + projectName: "", + remoteUrl: null, + }); + } + return "Projects"; +} + +function deriveProjectDisplayName(input: { + projectKey: string; + projectName: string; + remoteUrl: string | null; +}): string { + const remoteRepoName = parseRepoNameFromRemoteUrl(input.remoteUrl); + if (remoteRepoName) { + return remoteRepoName; + } + + const githubPrefix = "remote:github.com/"; + if (input.projectKey.startsWith(githubPrefix)) { + return input.projectKey.slice(githubPrefix.length); + } + + if (input.projectKey.startsWith("remote:")) { + const withoutPrefix = input.projectKey.slice("remote:".length); + const slashIdx = withoutPrefix.indexOf("/"); + if (slashIdx >= 0) { + const remotePath = withoutPrefix.slice(slashIdx + 1).trim(); + if (remotePath.length > 0) { + return remotePath; + } + } + return withoutPrefix; + } + + const trimmedProjectName = input.projectName.trim(); + if (trimmedProjectName.length > 0) { + return trimmedProjectName; + } + + return input.projectKey; +} + export function SidebarAgentList({ - sections, - checkoutByAgentKey, + entries, + projectOptions, + selectedProjectKeys, + onSelectedProjectKeysChange, isRefreshing = false, onRefresh, selectedAgentId, @@ -364,11 +434,11 @@ export function SidebarAgentList({ }: SidebarAgentListProps) { const { theme } = useUnistyles(); const pathname = usePathname(); - const insets = useSafeAreaInsets(); - const [actionAgent, setActionAgent] = useState(null); + const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false); + const projectFilterAnchorRef = useRef(null); - const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys); - const toggleProjectCollapsed = useSidebarCollapsedSectionsStore((s) => s.toggleProjectCollapsed); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedBatchKeys, setSelectedBatchKeys] = useState>(new Set()); const altDown = useKeyboardShortcutsStore((s) => s.altDown); const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown); @@ -376,174 +446,388 @@ export function SidebarAgentList({ (s) => s.sidebarShortcutAgentKeys ); const isTauri = getIsTauri(); - const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown); - const shortcutIndexByAgentKey = useMemo(() => { - const map = new Map(); - for (let i = 0; i < sidebarShortcutAgentKeys.length; i++) { - const key = sidebarShortcutAgentKeys[i]; - if (!key) continue; - map.set(key, i + 1); - } - return map; - }, [sidebarShortcutAgentKeys]); - - const actionClient = useSessionStore((state) => - actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null + const showShortcutBadges = !isSelectionMode && (altDown || (isTauri && cmdOrCtrlDown)); + const shortcutIndexByAgentKey = useMemo( + () => deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys), + [sidebarShortcutAgentKeys] ); - const isActionSheetVisible = actionAgent !== null; - const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); + const selectedProjectLabel = useMemo( + () => resolveSelectedProjectLabel({ selectedProjectKeys, projectOptions }), + [projectOptions, selectedProjectKeys] + ); + + const selectedProjectOption = useMemo(() => { + if (selectedProjectKeys.length !== 1) { + return null; + } + return ( + projectOptions.find((option) => option.projectKey === selectedProjectKeys[0]) ?? null + ); + }, [projectOptions, selectedProjectKeys]); + + const sessions = useSessionStore((state) => state.sessions); + const projectIconRequests = useMemo(() => { + const unique = new Map(); + for (const option of projectOptions) { + if (!option.serverId || !option.workingDir) { + continue; + } + unique.set(`${option.serverId}:${option.workingDir}`, { + serverId: option.serverId, + cwd: option.workingDir, + }); + } + for (const entry of entries) { + const serverId = entry.agent.serverId; + const cwd = entry.project.checkout.cwd; + if (!serverId || !cwd) { + continue; + } + unique.set(`${serverId}:${cwd}`, { serverId, cwd }); + } + return Array.from(unique.values()); + }, [entries, projectOptions]); + + const projectIconQueries = useQueries({ + queries: projectIconRequests.map((request) => ({ + queryKey: projectIconQueryKey(request.serverId, request.cwd), + queryFn: async () => { + const client = + useSessionStore.getState().sessions[request.serverId]?.client ?? null; + if (!client) { + return null; + } + const result = await client.requestProjectIcon(request.cwd); + return result.icon; + }, + enabled: Boolean( + sessions[request.serverId]?.client && + sessions[request.serverId]?.connection.isConnected && + request.cwd + ), + staleTime: Infinity, + gcTime: 1000 * 60 * 60, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + })), + }); + + const projectIconByQueryKey = useMemo(() => { + const map = new Map(); + for (let i = 0; i < projectIconRequests.length; i += 1) { + const request = projectIconRequests[i]; + if (!request) { + continue; + } + const icon = projectIconQueries[i]?.data ?? null; + const dataUri = icon + ? `data:${icon.mimeType};base64,${icon.data}` + : null; + map.set(`${request.serverId}:${request.cwd}`, dataUri); + } + return map; + }, [projectIconQueries, projectIconRequests]); + + const projectIconByProjectKey = useMemo(() => { + const map = new Map(); + for (const option of projectOptions) { + map.set( + option.projectKey, + projectIconByQueryKey.get(`${option.serverId}:${option.workingDir}`) ?? null + ); + } + for (const entry of entries) { + if (map.has(entry.project.projectKey)) { + continue; + } + map.set( + entry.project.projectKey, + projectIconByQueryKey.get( + `${entry.agent.serverId}:${entry.project.checkout.cwd}` + ) ?? null + ); + } + return map; + }, [entries, projectIconByQueryKey, projectOptions]); + + const selectedProjectIconUri = useMemo(() => { + if (!selectedProjectOption) { + return null; + } + return projectIconByProjectKey.get(selectedProjectOption.projectKey) ?? null; + }, [projectIconByProjectKey, selectedProjectOption]); + + const handleToggleProject = useCallback( + (projectKey: string) => { + const next = new Set(selectedProjectKeys); + if (next.has(projectKey)) { + next.delete(projectKey); + } else { + next.add(projectKey); + } + onSelectedProjectKeysChange(Array.from(next)); + }, + [onSelectedProjectKeysChange, selectedProjectKeys] + ); + + const handleClearProjectFilter = useCallback(() => { + onSelectedProjectKeysChange([]); + }, [onSelectedProjectKeysChange]); const handleAgentPress = useCallback( - (serverId: string, agentId: string) => { - if (isActionSheetVisible) { + (entry: SidebarAgentListEntry) => { + const key = `${entry.agent.serverId}:${entry.agent.id}`; + if (isSelectionMode) { + setSelectedBatchKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); return; } - // Clear attention flag when opening agent - const session = useSessionStore.getState().sessions[serverId]; + const session = useSessionStore.getState().sessions[entry.agent.serverId]; if (session?.client) { - session.client.clearAgentAttention(agentId); + session.client.clearAgentAttention(entry.agent.id); } - const navigationKey = buildAgentNavigationKey(serverId, agentId); + const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id); startNavigationTiming(navigationKey, { from: "home", to: "agent", - params: { serverId, agentId }, + params: { serverId: entry.agent.serverId, agentId: entry.agent.id }, }); const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); const navigate = shouldReplace ? router.replace : router.push; onAgentSelect?.(); - - navigate(buildHostAgentDetailRoute(serverId, agentId) as any); + navigate(buildHostAgentDetailRoute(entry.agent.serverId, entry.agent.id) as any); }, - [isActionSheetVisible, pathname, onAgentSelect] + [isSelectionMode, onAgentSelect, pathname] ); - const handleAgentLongPress = useCallback((agent: AggregatedAgent) => { - setActionAgent(agent); + const handleAgentLongPress = useCallback((entry: SidebarAgentListEntry) => { + const key = `${entry.agent.serverId}:${entry.agent.id}`; + setIsSelectionMode(true); + setSelectedBatchKeys(new Set([key])); }, []); - const handleCloseActionSheet = useCallback(() => { - setActionAgent(null); - }, []); - - const handleArchiveFromSheet = useCallback(() => { - if (!actionAgent || !actionClient) { + const handleArchiveSingle = useCallback((entry: SidebarAgentListEntry) => { + const client = useSessionStore.getState().sessions[entry.agent.serverId]?.client ?? null; + if (!client) { return; } - void actionClient.archiveAgent(actionAgent.id); - setActionAgent(null); - }, [actionAgent, actionClient]); + void client.archiveAgent(entry.agent.id).catch((error) => { + console.warn("[archive_agent] failed", error); + }); + }, []); - const handleCreateAgentInProject = useCallback( - (serverId: string, workingDir: string) => { - onAgentSelect?.(); - router.push(buildNewAgentRoute(serverId, workingDir) as any); - }, - [onAgentSelect] - ); + const handleArchiveBatch = useCallback(() => { + if (selectedBatchKeys.size === 0) { + setIsSelectionMode(false); + return; + } - // Section order from store - const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder); - - const handleDragEnd = useCallback( - (newData: SectionData[]) => { - const newOrder = newData.map((section) => section.projectKey); - setProjectOrder(newOrder); - }, - [setProjectOrder] - ); - - const handleArchiveAgent = useCallback( - (e: { stopPropagation: () => void }, agent: AggregatedAgent) => { - e.stopPropagation(); - const session = useSessionStore.getState().sessions[agent.serverId]; - const client = session?.client ?? null; - if (client) { - void client.archiveAgent(agent.id).catch((error) => { - console.warn("[archive_agent] failed", error); - }); + const requests: Promise[] = []; + const store = useSessionStore.getState(); + for (const key of selectedBatchKeys) { + const parsed = parseSidebarAgentKey(key); + if (!parsed) { + continue; } - }, - [] - ); + const client = store.sessions[parsed.serverId]?.client ?? null; + if (!client) { + continue; + } + requests.push( + client + .archiveAgent(parsed.agentId) + .then(() => undefined) + .catch((error) => { + console.warn("[archive_agent_batch] failed", { key, error }); + }) + ); + } - const renderSection = useCallback( - ({ item: section, drag, isActive }: DraggableRenderItemInfo) => { - const isCollapsed = collapsedProjectKeys.has(section.projectKey); - const firstAgent = section.agents[0]; - const firstAgentKey = firstAgent - ? `${firstAgent.serverId}:${firstAgent.id}` - : null; - const firstAgentCheckout = firstAgentKey - ? (checkoutByAgentKey.get(firstAgentKey) ?? null) - : null; + void Promise.all(requests).finally(() => { + setSelectedBatchKeys(new Set()); + setIsSelectionMode(false); + }); + }, [selectedBatchKeys]); + const handleSelectionBack = useCallback(() => { + setSelectedBatchKeys(new Set()); + setIsSelectionMode(false); + }, []); + + const renderRow = useCallback( + ({ item }: DraggableRenderItemInfo) => { + const key = `${item.agent.serverId}:${item.agent.id}`; + const projectDisplayName = deriveProjectDisplayName({ + projectKey: item.project.projectKey, + projectName: item.project.projectName, + remoteUrl: item.project.checkout.remoteUrl, + }); return ( - - toggleProjectCollapsed(section.projectKey)} - onCreateAgent={handleCreateAgentInProject} - onDrag={drag} - isDragging={isActive} - /> - {!isCollapsed && - section.agents.map((agent) => ( - handleAgentPress(agent.serverId, agent.id)} - onLongPress={() => handleAgentLongPress(agent)} - onArchive={(e) => handleArchiveAgent(e, agent)} - /> - ))} - + handleAgentPress(item)} + onLongPress={() => handleAgentLongPress(item)} + onArchive={() => handleArchiveSingle(item)} + onToggleBatch={() => { + setSelectedBatchKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }} + /> ); }, [ - collapsedProjectKeys, handleAgentLongPress, handleAgentPress, - handleArchiveAgent, - handleCreateAgentInProject, - checkoutByAgentKey, + handleArchiveSingle, + isSelectionMode, selectedAgentId, - showShortcutBadges, + selectedBatchKeys, shortcutIndexByAgentKey, - toggleProjectCollapsed, + showShortcutBadges, + projectIconByProjectKey, ] ); const keyExtractor = useCallback( - (section: SectionData) => section.key, + (entry: EntryData) => `${entry.agent.serverId}:${entry.agent.id}`, [] ); return ( - <> + + + [ + styles.filterTrigger, + (selectedProjectKeys.length > 0 || hovered || pressed) && + styles.filterTriggerActive, + ]} + onPress={() => setIsProjectFilterOpen(true)} + > + {({ hovered = false, pressed }) => { + const isInteracting = hovered || pressed; + const showActiveForeground = + selectedProjectKeys.length > 0 || isInteracting; + return ( + <> + {selectedProjectKeys.length === 1 && selectedProjectIconUri ? ( + + ) : selectedProjectKeys.length > 1 ? ( + + + {selectedProjectKeys.length} + + + ) : null} + + {selectedProjectLabel} + + + + ); + }} + + + {selectedProjectKeys.length > 0 ? ( + + Clear + + ) : null} + + {}} + title="Filter by project" + placeholder="Search projects" + searchPlaceholder="Search projects" + desktopPlacement="bottom-start" + open={isProjectFilterOpen} + onOpenChange={setIsProjectFilterOpen} + anchorRef={projectFilterAnchorRef} + > + + {projectOptions.length === 0 ? ( + No projects + ) : ( + projectOptions.map((option) => ( + + )) + )} + + + + {}} showsVerticalScrollIndicator={false} ListFooterComponent={listFooterComponent} refreshing={isRefreshing} @@ -551,152 +835,172 @@ export function SidebarAgentList({ simultaneousGestureRef={parentGestureRef} /> - - + {isSelectionMode ? ( + + + Back + - - - - {isActionDaemonUnavailable - ? "Host offline" - : "Archive this agent?"} - - - - Cancel - - - - Archive - - - - + style={[ + styles.selectionAction, + styles.selectionArchiveAction, + selectedBatchKeys.size === 0 && styles.selectionArchiveActionDisabled, + ]} + disabled={selectedBatchKeys.size === 0} + onPress={handleArchiveBatch} + > + Archive + - - + ) : null} + ); } const styles = StyleSheet.create((theme) => ({ - list: { + container: { flex: 1, }, - listContent: { + filtersRow: { + paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], - paddingBottom: theme.spacing[4], + paddingBottom: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], }, - sectionHeader: { + filterTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + alignSelf: "flex-start", + maxWidth: 260, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.full, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + backgroundColor: theme.colors.surface1, + }, + filterTriggerActive: { + borderColor: theme.colors.borderAccent, + }, + filterTriggerText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + maxWidth: 180, + }, + filterTriggerTextMuted: { + color: theme.colors.foregroundMuted, + }, + selectedProjectIcon: { + width: 14, + height: 14, + borderRadius: theme.borderRadius.sm, + }, + projectCountBadge: { + minWidth: 16, + height: 16, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 4, + backgroundColor: theme.colors.surface2, + }, + projectCountBadgeText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + clearFilterButton: { + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + }, + clearFilterText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + filterOptionsList: { + gap: theme.spacing[1], + }, + filterEmptyText: { + color: theme.colors.foregroundMuted, + textAlign: "center", + paddingVertical: theme.spacing[4], + }, + filterOption: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + borderRadius: 0, flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - marginTop: theme.spacing[2], - borderRadius: theme.borderRadius.md, - }, - sectionHeaderHovered: { - backgroundColor: theme.colors.surface1, - }, - sectionHeaderPressed: { - backgroundColor: theme.colors.surface2, - }, - sectionHeaderExpanded: { - marginBottom: theme.spacing[2], - }, - sectionHeaderDragging: { - backgroundColor: theme.colors.surface2, - }, - sectionContainer: { - marginHorizontal: theme.spacing[2], - marginBottom: theme.spacing[2], - }, - sectionDragging: { - opacity: 0.9, - transform: [{ scale: 1.02 }], - }, - chevron: { - opacity: 0.5, - }, - sectionHeaderLeft: { - flexDirection: "row", - alignItems: "center", - justifyContent: "flex-start", - flex: 1, - minWidth: 0, gap: theme.spacing[2], }, + filterOptionHovered: { + backgroundColor: theme.colors.surface1, + }, + filterOptionPressed: { + backgroundColor: theme.colors.surface2, + }, + filterOptionLeft: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + flex: 1, + minWidth: 0, + }, + filterOptionRight: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + flexShrink: 0, + }, + filterOptionLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + flex: 1, + }, + filterOptionCount: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, projectIcon: { - width: 16, - height: 16, + width: 14, + height: 14, borderRadius: theme.borderRadius.sm, }, - projectIconPlaceholder: { - width: 16, - height: 16, + projectIconFallback: { + width: 14, + height: 14, borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", }, - projectIconPlaceholderText: { - fontSize: 10, - fontWeight: "500", + projectIconFallbackInactive: { + opacity: 0.5, + }, + projectIconFallbackText: { color: theme.colors.foregroundMuted, + fontSize: 9, }, - sectionHeaderRight: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: theme.spacing[2], - marginLeft: theme.spacing[2], - }, - createAgentButton: { - width: 18, - height: 18, - alignItems: "center", - justifyContent: "center", - }, - sectionTitle: { - fontSize: theme.fontSize.sm, - fontWeight: "normal", - color: theme.colors.foregroundMuted, + list: { flex: 1, - textAlign: "left", + }, + listContent: { + paddingHorizontal: theme.spacing[2], + paddingBottom: theme.spacing[4], + }, + listContentSelectionMode: { + paddingBottom: theme.spacing[16], }, agentItem: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], - marginLeft: theme.spacing[1], borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[1], }, - agentItemUnselected: { - opacity: 1, - }, agentItemSelected: { backgroundColor: theme.colors.surface2, }, @@ -706,129 +1010,136 @@ const styles = StyleSheet.create((theme) => ({ agentItemPressed: { backgroundColor: theme.colors.surface2, }, - agentContent: { - flex: 1, - gap: theme.spacing[0], - }, - row: { + agentRowTop: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], + minHeight: 20, }, - agentTitle: { - flex: 1, - fontSize: theme.fontSize.base, - fontWeight: "400", - color: theme.colors.foreground, - opacity: 0.8, - }, - archiveButton: { - alignItems: "center", - justifyContent: "center", - marginLeft: theme.spacing[1], - }, - branchBadge: { - minWidth: 20, - maxWidth: "40%", - height: 20, - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, + checkbox: { + width: 16, + height: 16, + borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", }, - branchBadgeText: { - fontSize: theme.fontSize.xs, + checkboxSelected: { + backgroundColor: theme.colors.primary, + borderColor: theme.colors.primary, + }, + agentTitle: { + flex: 1, + fontSize: theme.fontSize.base, + color: theme.colors.foreground, + opacity: 0.85, + }, + agentTitleInactive: { color: theme.colors.foregroundMuted, - lineHeight: 12, }, - branchBadgePlaceholder: { - opacity: 0, - borderColor: "transparent", - }, - archiveConfirmBadge: { - minWidth: 76, - maxWidth: 9999, - flexShrink: 0, - }, - archiveConfirmText: { - color: theme.colors.foreground, - }, - archiveConfirmTextHovered: { - opacity: 0.9, - }, - agentTitleHighlighted: { - color: theme.colors.foreground, + agentTitleSelected: { opacity: 1, }, - secondaryRow: { - fontSize: theme.fontSize.sm, - fontWeight: "300", + relativeTimeText: { color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, }, - sheetOverlay: { - flex: 1, - justifyContent: "flex-end", - }, - sheetBackdrop: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - backgroundColor: "rgba(0,0,0,0.35)", - }, - sheetContainer: { - backgroundColor: theme.colors.surface2, - borderTopLeftRadius: theme.borderRadius["2xl"], - borderTopRightRadius: theme.borderRadius["2xl"], - paddingHorizontal: theme.spacing[6], - paddingTop: theme.spacing[4], - gap: theme.spacing[4], - }, - sheetHandle: { - alignSelf: "center", - width: 40, - height: 4, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.foregroundMuted, - opacity: 0.3, - }, - sheetTitle: { - fontSize: theme.fontSize.lg, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - textAlign: "center", - }, - sheetButtonRow: { - flexDirection: "row", - gap: theme.spacing[3], - }, - sheetButton: { - flex: 1, - borderRadius: theme.borderRadius.lg, - paddingVertical: theme.spacing[4], + shortcutBadge: { + minWidth: 20, + height: 20, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", + paddingHorizontal: theme.spacing[1], }, - sheetArchiveButton: { - backgroundColor: theme.colors.primary, + shortcutBadgeText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, }, - sheetArchiveText: { - color: theme.colors.primaryForeground, - fontWeight: theme.fontWeight.semibold, - fontSize: theme.fontSize.base, + archiveButton: { + minWidth: 24, + height: 20, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[1], }, - sheetArchiveTextDisabled: { + agentMetaRow: { + marginTop: theme.spacing[1], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + agentMetaProjectIcon: { + width: 14, + height: 14, + borderRadius: theme.borderRadius.sm, + }, + agentMetaProjectIconInactive: { opacity: 0.5, }, - sheetCancelButton: { + agentMetaProjectName: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + flexShrink: 1, + minWidth: 0, + }, + agentMetaBranchBadge: { + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + paddingHorizontal: theme.spacing[1], + paddingVertical: 1, + flexShrink: 0, + }, + agentMetaBranchBadgeSelected: { + borderColor: theme.colors.borderAccent, + }, + agentMetaBranchText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + selectionBar: { + position: "absolute", + left: theme.spacing[2], + right: theme.spacing[2], + bottom: theme.spacing[2], + borderRadius: theme.borderRadius.xl, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + padding: theme.spacing[2], + flexDirection: "row", + gap: theme.spacing[2], + }, + selectionAction: { + flex: 1, + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + paddingVertical: theme.spacing[2], backgroundColor: theme.colors.surface1, }, - sheetCancelText: { + selectionActionText: { color: theme.colors.foreground, - fontWeight: theme.fontWeight.semibold, - fontSize: theme.fontSize.base, + fontSize: theme.fontSize.sm, + }, + selectionArchiveAction: { + backgroundColor: theme.colors.primary, + borderColor: theme.colors.primary, + }, + selectionArchiveActionDisabled: { + opacity: 0.5, + }, + selectionArchiveActionText: { + color: theme.colors.primaryForeground, + fontSize: theme.fontSize.sm, }, })); diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 183a2776b..4d9f679dc 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -16,16 +16,25 @@ import { SidebarAgentList } from "./sidebar-agent-list"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; -import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; -import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; +import { useTauriDragHandlers } from "@/utils/tauri-window"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts"; import { Combobox } from "@/components/ui/combobox"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { useSessionStore } from "@/stores/session-store"; 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 { - buildHostAgentDraftRoute, buildHostAgentsRoute, buildHostSettingsRoute, mapPathnameToServer, @@ -85,14 +94,23 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { // Derive isOpen from the unified panel state const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; + const [selectedProjectKeys, setSelectedProjectKeys] = useState([]); const { - sections, - checkoutByAgentKey, + entries, + projectOptions, + hasMoreEntries, isInitialLoad, isRevalidating, refreshAll, - } = useSidebarAgentsGrouped({ isOpen, serverId: activeServerId }); + } = useSidebarAgentsGrouped({ + isOpen, + serverId: activeServerId, + selectedProjectKeys, + }); + useEffect(() => { + setSelectedProjectKeys([]); + }, [activeServerId]); const { translateX, backdropOpacity, @@ -102,7 +120,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { isGesturing, closeGestureRef, } = useSidebarAnimation(); - const trafficLightPadding = useTrafficLightPadding(); const dragHandlers = useTauriDragHandlers(); // Track user-initiated refresh to avoid showing spinner on background revalidation @@ -120,13 +137,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { } }, [isRevalidating, isManualRefresh]); - const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys); const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore( (s) => s.setSidebarShortcutAgentKeys ); const sidebarShortcutAgentKeys = useMemo(() => { - return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9); - }, [collapsedProjectKeys, sections]); + return entries.slice(0, 9).map((entry) => `${entry.agent.serverId}:${entry.agent.id}`); + }, [entries]); useEffect(() => { setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys); @@ -137,11 +153,34 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { }, [closeToAgent]); 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( + checkoutStatusQueryKey(selectedAgent.serverId, cwd) + ) ?? null; + targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout); + } + } + + if (!targetServerId) { return; } - router.push(buildHostAgentDraftRoute(activeServerId) as any); - }, [activeServerId]); + router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any); + }, [activeServerId, pathname, selectedAgentId]); // Mobile: close sidebar and navigate const handleCreateAgentCleanMobile = useCallback(() => { @@ -198,6 +237,27 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { windowWidth, ]); + const listFooterComponent = useMemo(() => { + if (!hasMoreEntries) { + return null; + } + + return ( + + {({ hovered }) => ( + + View more + + )} + + ); + }, [handleViewMore, hasMoreEntries]); + const handleHostSelect = useCallback( (nextServerId: string) => { if (!nextServerId) { @@ -299,9 +359,36 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { )} + + + + {/* Middle: scrollable agent list */} + {isInitialLoad ? ( + + ) : ( + + )} + + {/* Footer */} + + [ + styles.hostTrigger, + hovered && styles.hostTriggerHovered, + ]} onPress={() => setIsHostPickerOpen(true)} disabled={hostOptions.length === 0} > @@ -316,10 +403,47 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { + + + {({ hovered }) => ( + + )} + + + {({ hovered }) => ( + + )} + + - - {/* Middle: scrollable agent list */} - {isInitialLoad ? ( - - ) : ( - - )} - - {/* Footer */} - - - {({ hovered }) => ( - <> - - - All agents - - - )} - - - - {({ hovered }) => ( - - )} - - - @@ -389,11 +465,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { return ( - {/* Header: New Agent button - top padding area is draggable on Tauri */} - + ( <> - New agent + New agent )} + + + + {/* Middle: scrollable agent list */} + {isInitialLoad ? ( + + ) : ( + + )} + + {/* Footer */} + + [ + styles.hostTrigger, + hovered && styles.hostTriggerHovered, + ]} onPress={() => setIsHostPickerOpen(true)} disabled={hostOptions.length === 0} > @@ -424,47 +521,24 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { - - - - {/* Middle: scrollable agent list */} - {isInitialLoad ? ( - - ) : ( - - )} - - {/* Footer */} - - - {({ hovered }) => ( - <> - - - All agents - - - )} - + + {({ hovered }) => ( + + )} + + ); @@ -512,9 +597,12 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface0, }, sidebarHeader: { - paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[4], - paddingBottom: theme.spacing[3], + height: { + xs: HEADER_INNER_HEIGHT_MOBILE, + md: HEADER_INNER_HEIGHT, + }, + paddingHorizontal: theme.spacing[2], + justifyContent: "center", borderBottomWidth: 1, borderBottomColor: theme.colors.border, userSelect: "none", @@ -522,7 +610,7 @@ const styles = StyleSheet.create((theme) => ({ sidebarHeaderRow: { flexDirection: "row", alignItems: "center", - justifyContent: "space-between", + justifyContent: "flex-start", gap: theme.spacing[2], }, newAgentButton: { @@ -545,12 +633,18 @@ const styles = StyleSheet.create((theme) => ({ hostTrigger: { flexDirection: "row", alignItems: "center", - justifyContent: "flex-end", + justifyContent: "flex-start", gap: theme.spacing[2], minWidth: 0, - maxWidth: "55%", 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: { width: 8, @@ -560,6 +654,8 @@ const styles = StyleSheet.create((theme) => ({ hostTriggerText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, + flexShrink: 1, + minWidth: 0, }, sidebarFooter: { flexDirection: "row", @@ -570,28 +666,43 @@ const styles = StyleSheet.create((theme) => ({ borderTopWidth: 1, borderTopColor: theme.colors.border, }, + footerHostSlot: { + flexGrow: 0, + flexShrink: 1, + minWidth: 0, + marginRight: theme.spacing[2], + }, footerIconRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - }, - footerButton: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1], + flexShrink: 0, }, footerIconButton: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], }, - footerButtonText: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, + listViewMoreButton: { + marginTop: theme.spacing[2], + 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, }, - footerButtonTextHovered: { + listViewMoreButtonTextHovered: { color: theme.colors.foreground, }, hostPickerList: { diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index fb917e62a..2266f2ad1 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -384,7 +384,6 @@ export default function TerminalEmulator({ terminal.write(outputText); renderedOutputRef.current = outputText; } - terminal.focus(); return () => { inputDisposable.dispose(); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 40fefbaac..5685fcabd 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -7,7 +7,13 @@ import { Text, View, } 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 { useSessionStore } from "@/stores/session-store"; import { @@ -23,6 +29,7 @@ interface TerminalPaneProps { } const MAX_OUTPUT_CHARS = 200_000; +const TERMINAL_TAB_MAX_WIDTH = 220; const MODIFIER_LABELS = { ctrl: "Ctrl", @@ -58,6 +65,29 @@ function terminalScopeKey(serverId: string, cwd: string): string { return `${serverId}:${cwd}`; } +function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) { + return ( + + + + + + + + + + + + + ); +} + export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { const { theme } = useUnistyles(); const isMobile = @@ -85,6 +115,62 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { const [streamError, setStreamError] = useState(null); const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); const [focusRequestToken, setFocusRequestToken] = useState(0); + const [hoveredTerminalId, setHoveredTerminalId] = useState(null); + const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState( + null + ); + const hoverOutTimeoutRef = useRef | null>(null); + const selectedTerminalIdRef = useRef(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({ queryKey: ["terminals", serverId, cwd] as const, @@ -100,6 +186,42 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { 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({ mutationFn: async () => { 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(() => { setSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null); lastReportedSizeRef.current = null; @@ -263,14 +418,23 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { ? (outputByTerminalId.get(selectedTerminalId) ?? "") : ""; - const handleRefresh = useCallback(() => { - void terminalsQuery.refetch(); - }, [terminalsQuery]); - const handleCreateTerminal = useCallback(() => { createTerminalMutation.mutate(); }, [createTerminalMutation]); + const handleCloseTerminal = useCallback( + (terminalId: string) => { + if ( + killTerminalMutation.isPending && + killTerminalMutation.variables === terminalId + ) { + return; + } + killTerminalMutation.mutate(terminalId); + }, + [killTerminalMutation] + ); + const requestTerminalFocus = useCallback(() => { setFocusRequestToken((current) => current + 1); }, []); @@ -430,12 +594,15 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null; const isCreating = createTerminalMutation.isPending; - const isRefreshing = terminalsQuery.isFetching; const createError = createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null; - const combinedError = streamError ?? createError ?? queryError; + const closeError = + killTerminalMutation.error instanceof Error + ? killTerminalMutation.error.message + : null; + const combinedError = streamError ?? closeError ?? createError ?? queryError; return ( @@ -448,40 +615,82 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { > {terminals.map((terminal) => { 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 ( setSelectedTerminalId(terminal.id)} + onHoverIn={() => handleTerminalTabHoverIn(terminal.id)} + onHoverOut={() => handleTerminalTabHoverOut(terminal.id)} style={({ pressed, hovered }) => [ styles.terminalTab, isActive && styles.terminalTabActive, + shouldShowCloseButton && styles.terminalTabHovered, (pressed || hovered) && styles.terminalTabHovered, ]} > - + {terminal.name} + 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 ( + <> + + + {isClosingTerminal ? ( + + ) : ( + + )} + + + ); + }} + ); })} - [ - styles.headerIconButton, - (hovered || pressed) && styles.headerIconButtonHovered, - ]} - > - {isRefreshing ? ( - - ) : ( - - )} - ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[2], + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[1], + paddingVertical: theme.spacing[1], }, tabsScroll: { flex: 1, @@ -620,27 +829,59 @@ const styles = StyleSheet.create((theme) => ({ paddingRight: theme.spacing[2], }, terminalTab: { - borderWidth: 1, - borderColor: theme.colors.border, borderRadius: theme.borderRadius.md, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], - backgroundColor: theme.colors.surface1, + justifyContent: "center", + maxWidth: TERMINAL_TAB_MAX_WIDTH, + minWidth: 96, + overflow: "hidden", + position: "relative", }, terminalTabHovered: { backgroundColor: theme.colors.surface2, }, terminalTabActive: { - borderColor: theme.colors.primary, backgroundColor: theme.colors.surface2, }, terminalTabText: { + minWidth: 0, + flexShrink: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, }, terminalTabTextActive: { 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: { flexDirection: "row", diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index cb002ab6e..2d4735466 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -36,6 +36,7 @@ export interface ComboboxProps { value: string; onSelect: (id: string) => void; onSearchQueryChange?: (query: string) => void; + searchable?: boolean; placeholder?: string; searchPlaceholder?: string; emptyText?: string; @@ -45,6 +46,7 @@ export interface ComboboxProps { title?: string; open?: boolean; onOpenChange?: (open: boolean) => void; + desktopPlacement?: "top-start" | "bottom-start"; anchorRef: React.RefObject; children?: ReactNode; } @@ -158,6 +160,7 @@ export function Combobox({ value, onSelect, onSearchQueryChange, + searchable = true, placeholder = "Search...", searchPlaceholder, emptyText = "No options match your search.", @@ -167,6 +170,7 @@ export function Combobox({ title = "Select", open, onOpenChange, + desktopPlacement = "top-start", anchorRef, children, }: ComboboxProps): ReactElement { @@ -245,7 +249,7 @@ export function Combobox({ ); const { refs, floatingStyles, update } = useFloating({ - placement: Platform.OS === "web" ? "top-start" : "bottom-start", + placement: Platform.OS === "web" ? desktopPlacement : "bottom-start", middleware, sameScrollView: false, elements: { @@ -261,7 +265,7 @@ export function Combobox({ } const raf = requestAnimationFrame(() => update()); return () => cancelAnimationFrame(raf); - }, [isMobile, update, isOpen]); + }, [desktopPlacement, isMobile, update, isOpen]); useEffect(() => { if (!isMobile) return; @@ -293,7 +297,7 @@ export function Combobox({ [] ); - const normalizedSearch = searchQuery.trim().toLowerCase(); + const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; const filteredOptions = useMemo(() => { if (!normalizedSearch) { return options; @@ -308,6 +312,7 @@ export function Combobox({ const sanitizedSearchValue = searchQuery.trim(); const showCustomOption = + searchable && allowCustomValue && sanitizedSearchValue.length > 0 && !options.some( @@ -463,7 +468,7 @@ export function Combobox({ const content = children ?? ( <> - {searchInput} + {searchable ? searchInput : null} {optionsList} ); @@ -537,7 +542,7 @@ export function Combobox({ ) : ( <> - {searchInput} + {searchable ? searchInput : null} ({ gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], - borderRadius: theme.borderRadius.md, + borderRadius: 0, ...(IS_WEB ? {} : { diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index cfd760e13..3136c7122 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -605,6 +605,10 @@ export function SessionProvider({ console.log("[Session] Agent update:", agent.id, agent.status); setAgents(serverId, (prev) => { + const current = prev.get(agent.id); + if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) { + return prev; + } const next = new Map(prev); next.set(agent.id, agent); return next; diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index e6b0a0593..701c8159f 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -57,7 +57,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult { const pendingPermissions = new Map(); const agentLastActivity = new Map(); - for (const snapshot of agentsList) { + for (const { agent: snapshot } of agentsList.entries) { const agent = normalizeAgentSnapshot(snapshot, serverId); agents.set(agent.id, agent); agentLastActivity.set(agent.id, agent.lastActivityAt); diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index 14c695f6e..f4a73af78 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -56,7 +56,9 @@ export function useAllAgentsList(options?: { if (!client) { throw new Error("Daemon client not available"); } - return await client.fetchAgents(); + return await client.fetchAgents({ + filter: { labels: { ui: "true" } }, + }); }, enabled: canFetch, staleTime: ALL_AGENTS_STALE_TIME, @@ -76,11 +78,12 @@ export function useAllAgentsList(options?: { if (!serverId) { return []; } - const data = agentsQuery.data ?? []; + const data = agentsQuery.data?.entries ?? []; const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; const list: AggregatedAgent[] = []; - for (const snapshot of data) { + for (const entry of data) { + const snapshot = entry.agent; const normalized = normalizeAgentSnapshot(snapshot, serverId); const live = liveAgents?.get(snapshot.id); list.push( diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 50efb0a29..de3eacff0 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -13,6 +13,7 @@ import { import { queryClient } from "@/query/query-client"; import { buildNewAgentRoute, + resolveSelectedAgentForNewAgent, resolveNewAgentWorkingDir, } from "@/utils/new-agent-routing"; import { @@ -95,21 +96,23 @@ export function useKeyboardShortcuts({ const navigateToNewAgent = (): boolean => { let targetServerId = parseServerIdFromPathname(pathname); let targetWorkingDir: string | null = null; - if (selectedAgentId) { - const separatorIndex = selectedAgentId.indexOf(":"); - if (separatorIndex > 0) { - const serverId = selectedAgentId.slice(0, separatorIndex); - const agentId = selectedAgentId.slice(separatorIndex + 1); - targetServerId = serverId; - const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId); - const cwd = agent?.cwd?.trim(); - if (cwd) { - const checkout = - queryClient.getQueryData( - checkoutStatusQueryKey(serverId, cwd) - ) ?? null; - targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout); - } + 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( + checkoutStatusQueryKey(selectedAgent.serverId, cwd) + ) ?? null; + targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout); } } diff --git a/packages/app/src/hooks/use-sidebar-agents-grouped.ts b/packages/app/src/hooks/use-sidebar-agents-grouped.ts index 928e54af8..c9208faa7 100644 --- a/packages/app/src/hooks/use-sidebar-agents-grouped.ts +++ b/packages/app/src/hooks/use-sidebar-agents-grouped.ts @@ -1,53 +1,129 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionStore, type Agent } from "@/stores/session-store"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { - useSectionOrderStore, - sortProjectsByStoredOrder, -} from "@/stores/section-order-store"; -import type { FetchAgentsGroupedByProjectResponseMessage } from "@server/shared/messages"; + deriveSidebarStateBucket, + isSidebarActiveAgent, +} from "@/utils/sidebar-agent-state"; +import type { ProjectPlacementPayload } from "@server/shared/messages"; -const SIDEBAR_GROUPS_STALE_TIME = 15_000; -const SIDEBAR_GROUPS_REFETCH_INTERVAL = 10_000; -const MAX_AGENTS_PER_PROJECT = 5; +const SIDEBAR_AGENTS_STALE_TIME = 15_000; +const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000; +const SIDEBAR_DONE_FILL_TARGET = 50; -type SidebarGroupsPayload = - FetchAgentsGroupedByProjectResponseMessage["payload"]; -export type SidebarCheckoutLite = - SidebarGroupsPayload["groups"][number]["agents"][number]["checkout"]; -type MutableSidebarGroup = { +export interface SidebarProjectOption { projectKey: string; projectName: string; - agents: AggregatedAgent[]; -}; + activeCount: number; + totalCount: number; + serverId: string; + workingDir: string; +} -export interface SidebarSectionData { - key: string; - projectKey: string; - title: string; - agents: AggregatedAgent[]; - firstAgentServerId?: string; - firstAgentId?: string; - workingDir?: string; +export interface SidebarAgentListEntry { + agent: AggregatedAgent & { createdAt: Date }; + project: ProjectPlacementPayload; } export interface SidebarAgentsGroupedResult { - sections: SidebarSectionData[]; - checkoutByAgentKey: Map; + entries: SidebarAgentListEntry[]; + projectOptions: SidebarProjectOption[]; + hasMoreEntries: boolean; isLoading: boolean; isInitialLoad: boolean; isRevalidating: boolean; 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: { source: Agent | ReturnType; serverId: string; serverLabel: string; -}): AggregatedAgent { +}): AggregatedAgent & { createdAt: Date } { const source = params.source; return { id: source.id, @@ -55,6 +131,7 @@ function toAggregatedAgent(params: { serverLabel: params.serverLabel, title: source.title ?? null, status: source.status, + createdAt: source.createdAt, lastActivityAt: source.lastActivityAt, cwd: source.cwd, provider: source.provider, @@ -69,6 +146,7 @@ function toAggregatedAgent(params: { export function useSidebarAgentsGrouped(options?: { isOpen?: boolean; serverId?: string | null; + selectedProjectKeys?: string[]; }): SidebarAgentsGroupedResult { const { connectionStates } = useDaemonConnections(); const queryClient = useQueryClient(); @@ -79,6 +157,15 @@ export function useSidebarAgentsGrouped(options?: { ? value.trim() : null; }, [options?.serverId]); + const selectedProjectKeys = useMemo( + () => + new Set( + (options?.selectedProjectKeys ?? []) + .map((item) => item.trim()) + .filter((item) => item.length > 0) + ), + [options?.selectedProjectKeys] + ); const session = useSessionStore((state) => serverId ? state.sessions[serverId] : undefined @@ -88,189 +175,160 @@ export function useSidebarAgentsGrouped(options?: { const isConnected = session?.connection.isConnected ?? false; const canFetch = Boolean(serverId && client && isConnected); - const groupedQuery = useQuery({ - queryKey: ["sidebarAgentsGrouped", serverId] as const, + const agentsQuery = useQuery({ + queryKey: ["sidebarAgentsList", serverId] as const, queryFn: async () => { if (!client) { throw new Error("Daemon client not available"); } - return await client.fetchAgentsGroupedByProject({ + return await client.fetchAgents({ filter: { labels: { ui: "true" } }, + sort: [ + { key: "status_priority", direction: "asc" }, + { key: "updated_at", direction: "desc" }, + ], }); }, enabled: canFetch, - staleTime: SIDEBAR_GROUPS_STALE_TIME, - refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false, + staleTime: SIDEBAR_AGENTS_STALE_TIME, + refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false, refetchIntervalInBackground: isOpen, refetchOnMount: "always" as const, }); - const projectOrder = useSectionOrderStore((state) => state.projectOrder); - const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder); - - const { sections, checkoutByAgentKey, hasAnyData } = useMemo(() => { + const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => { if (!serverId) { return { - sections: [] as SidebarSectionData[], - checkoutByAgentKey: new Map(), + entries: [] as SidebarAgentListEntry[], + projectOptions: [] as SidebarProjectOption[], hasAnyData: false, + hasMoreEntries: false, }; } - const groupsByKey = new Map(); - const checkoutLookup = new Map(); - const seenAgentKeys = new Set(); - const payload = groupedQuery.data as SidebarGroupsPayload | undefined; - const groupedFetchReady = groupedQuery.isFetched; const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; + const seenAgentIds = new Set(); + const byProject = new Map(); + const mergedEntries: SidebarAgentListEntry[] = []; - if (payload) { - for (const group of payload.groups) { - const existing: MutableSidebarGroup = - 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 pushEntry = (entry: SidebarAgentListEntry): void => { + if (entry.agent.archivedAt) { + return; } + 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()) { if (live.archivedAt || live.labels.ui !== "true") { continue; } if (!live.projectPlacement) { - // Ignore fetchAgents-hydrated snapshots for sidebar placement. - // Sidebar should derive placement from grouped RPC or project-enriched agent_update. continue; } - const agentKey = `${serverId}:${live.id}`; - if (seenAgentKeys.has(agentKey)) { - continue; - } - - const livePlacement = 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 agent = toAggregatedAgent({ + source: live, + serverId, + serverLabel, + }); + pushEntry({ agent, project: live.projectPlacement }); } } - const sortedGroups = Array.from(groupsByKey.values()) - .map((group) => { - const agents = [...group.agents].sort( - (left, right) => - right.lastActivityAt.getTime() - left.lastActivityAt.getTime() - ); - 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 filteredEntries = + selectedProjectKeys.size > 0 + ? mergedEntries.filter((entry) => + selectedProjectKeys.has(entry.project.projectKey) + ) + : mergedEntries; - const orderedGroups = sortProjectsByStoredOrder(sortedGroups, projectOrder); - const nextSections = orderedGroups.map((group) => { - const firstAgent = group.agents[0]; - return { - key: `project:${group.projectKey}`, - projectKey: group.projectKey, - title: group.projectName, - agents: group.agents, - firstAgentServerId: firstAgent?.serverId, - firstAgentId: firstAgent?.id, - workingDir: firstAgent?.cwd, - }; + const ordered = applySidebarDefaultOrdering(filteredEntries); + const options = Array.from(byProject.values()).sort((left, right) => { + if (left.activeCount !== right.activeCount) { + return right.activeCount - left.activeCount; + } + return left.projectName.localeCompare(right.projectName); }); return { - sections: nextSections, - checkoutByAgentKey: checkoutLookup, - hasAnyData: nextSections.length > 0, + entries: ordered.entries, + projectOptions: options, + hasAnyData: ordered.entries.length > 0, + hasMoreEntries: ordered.hasMore, }; }, [ + agentsQuery.data?.entries, connectionStates, - groupedQuery.data, - groupedQuery.isFetched, liveAgents, - projectOrder, + selectedProjectKeys, 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(() => { if (!serverId) { return; } void queryClient.invalidateQueries({ - queryKey: ["sidebarAgentsGrouped", serverId], + queryKey: ["sidebarAgentsList", serverId], }); }, [queryClient, serverId]); const isFetching = - canFetch && (groupedQuery.isPending || groupedQuery.isFetching); + canFetch && (agentsQuery.isPending || agentsQuery.isFetching); const isInitialLoad = isFetching && !hasAnyData; const isRevalidating = isFetching && hasAnyData; return { - sections, - checkoutByAgentKey, + entries, + projectOptions, + hasMoreEntries, isLoading: isFetching, isInitialLoad, isRevalidating, refreshAll, }; } - diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 04cb529d5..5b06afe07 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -85,7 +85,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, letterSpacing: 0.4, - textTransform: "uppercase", marginBottom: theme.spacing[2], }, input: { @@ -180,8 +179,19 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, flexShrink: 1, }, - hostCardPressed: { - opacity: 0.85, + hostSettingsButton: { + 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: { flexDirection: "row", @@ -646,7 +656,7 @@ export default function SettingsScreen() { connectionStatus={connectionStatus} activeConnection={activeConnection} lastError={lastConnectionError} - onPress={handleEditDaemon} + onOpenSettings={handleEditDaemon} /> ); }) @@ -885,8 +895,6 @@ function HostDetailModal({ }: HostDetailModalProps) { const { theme } = useUnistyles(); const [draftLabel, setDraftLabel] = useState(""); - const [isDraftLabelDirty, setIsDraftLabelDirty] = useState(false); - const activeServerIdRef = useRef(null); const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null); const [isRemovingConnection, setIsRemovingConnection] = useState(false); @@ -1033,28 +1041,18 @@ function HostDetailModal({ const handleDraftLabelChange = useCallback((nextValue: string) => { setDraftLabel(nextValue); - setIsDraftLabelDirty(true); }, []); useEffect(() => { if (!visible || !host) return; - const hostChanged = activeServerIdRef.current !== host.serverId; - if (hostChanged) { - setDraftLabel(host.label ?? ""); - setIsDraftLabelDirty(false); - activeServerIdRef.current = host.serverId; - return; - } - if (!isDraftLabelDirty) { - setDraftLabel(host.label ?? ""); - } - }, [visible, host?.serverId, host?.label, isDraftLabelDirty]); + // Initialize once per modal open / host switch; keep user edits fully local while typing. + setDraftLabel(host.label ?? ""); + }, [visible, host?.serverId]); useEffect(() => { if (!visible) { - activeServerIdRef.current = null; setIsRestarting(false); - setIsDraftLabelDirty(false); + setDraftLabel(""); } }, [visible]); @@ -1304,7 +1302,7 @@ interface DaemonCardProps { connectionStatus: ConnectionStatus; activeConnection: ActiveConnection | null; lastError: string | null; - onPress: (daemon: HostProfile) => void; + onOpenSettings: (daemon: HostProfile) => void; } function DaemonCard({ @@ -1312,7 +1310,7 @@ function DaemonCard({ connectionStatus, activeConnection, lastError, - onPress, + onOpenSettings, }: DaemonCardProps) { const { theme } = useUnistyles(); const statusLabel = formatConnectionStatus(connectionStatus); @@ -1347,12 +1345,9 @@ function DaemonCard({ })(); return ( - [styles.hostCard, pressed && styles.hostCardPressed]} - onPress={() => onPress(daemon)} + @@ -1375,10 +1370,28 @@ function DaemonCard({ ) : null} ) : null} + + [ + 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 }) => ( + + )} + {connectionError ? {connectionError} : null} - + ); } diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts index a9486afea..47f0e111c 100644 --- a/packages/app/src/utils/new-agent-routing.test.ts +++ b/packages/app/src/utils/new-agent-routing.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest"; import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; import { buildNewAgentRoute, + parseAgentKey, resolveNewAgentWorkingDir, + resolveSelectedAgentForNewAgent, } from "./new-agent-routing"; 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(); + }); +}); diff --git a/packages/app/src/utils/new-agent-routing.ts b/packages/app/src/utils/new-agent-routing.ts index 7d097ed8a..8a3fd1d7a 100644 --- a/packages/app/src/utils/new-agent-routing.ts +++ b/packages/app/src/utils/new-agent-routing.ts @@ -1,5 +1,36 @@ 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( cwd: string, diff --git a/packages/app/src/utils/sidebar-agent-state.ts b/packages/app/src/utils/sidebar-agent-state.ts new file mode 100644 index 000000000..ff2c23fde --- /dev/null +++ b/packages/app/src/utils/sidebar-agent-state.ts @@ -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"; +} diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index 3a2b7fd78..5fa5586b0 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -111,7 +111,10 @@ export async function runLsCommand( } 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. if (!options.all) { diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index 9d84e1d07..bd43af2ce 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -53,7 +53,8 @@ export async function runStopCommand( } 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[] = [] if (options.all) { diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 2ff172949..3b52a95d7 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -111,7 +111,8 @@ export async function runStatusCommand( const client = await tryConnectToDaemon({ host, timeout: 1500 }) if (client) { 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 idleAgents = agents.filter(a => a.status === 'idle').length } catch { diff --git a/packages/cli/src/commands/permit/ls.ts b/packages/cli/src/commands/permit/ls.ts index a35904292..de00a5ca1 100644 --- a/packages/cli/src/commands/permit/ls.ts +++ b/packages/cli/src/commands/permit/ls.ts @@ -57,7 +57,8 @@ export async function runLsCommand(options: PermitLsOptions, _command: Command): } 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() // Collect all pending permissions from all agents diff --git a/packages/cli/src/commands/worktree/ls.ts b/packages/cli/src/commands/worktree/ls.ts index e6bbe81a5..4ab473f97 100644 --- a/packages/cli/src/commands/worktree/ls.ts +++ b/packages/cli/src/commands/worktree/ls.ts @@ -76,7 +76,8 @@ export async function runLsCommand( } 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 const response = await client.getPaseoWorktreeList({}) diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index e7f56c772..d3ecaa215 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -455,7 +455,7 @@ describe("DaemonClient", () => { 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 mock = createMockTransport(); @@ -471,29 +471,49 @@ describe("DaemonClient", () => { mock.triggerOpen(); await connectPromise; - const promise = client.fetchAgentsGroupedByProject({ + const promise = client.fetchAgents({ 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); const request = JSON.parse(mock.sent[0]) as { type: "session"; message: { - type: "fetch_agents_grouped_by_project_request"; + type: "fetch_agents_request"; requestId: string; filter?: { labels?: Record }; + 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( JSON.stringify({ type: "session", message: { - type: "fetch_agents_grouped_by_project_response", + type: "fetch_agents_response", payload: { 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({ requestId: request.message.requestId, - groups: [], + entries: [], + pageInfo: { + nextCursor: null, + prevCursor: "cursor-1", + hasMore: false, + }, }); }); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index ddc8cfe04..e11f114fb 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -244,10 +244,19 @@ type AgentRefreshedStatusPayload = z.infer< type RestartRequestedStatusPayload = z.infer< typeof RestartRequestedStatusPayloadSchema >; -type FetchAgentsGroupedByProjectPayload = Extract< +type FetchAgentsPayload = Extract< SessionOutboundMessage, - { type: "fetch_agents_grouped_by_project_response" } + { type: "fetch_agents_response" } >["payload"]; +type FetchAgentsRequest = Extract< + SessionInboundMessage, + { type: "fetch_agents_request" } +>; +export type FetchAgentsOptions = Omit & { + requestId?: string; +}; +export type FetchAgentsEntry = FetchAgentsPayload["entries"][number]; +export type FetchAgentsPageInfo = FetchAgentsPayload["pageInfo"]; export type WaitForFinishResult = { status: "idle" | "error" | "permission" | "timeout"; @@ -976,15 +985,14 @@ export class DaemonClient { // Agent RPCs (requestId-correlated) // ============================================================================ - async fetchAgents(options?: { - filter?: { labels?: Record }; - requestId?: string; - }): Promise { + async fetchAgents(options?: FetchAgentsOptions): Promise { const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agents_request", requestId: resolvedRequestId, ...(options?.filter ? { filter: options.filter } : {}), + ...(options?.sort ? { sort: options.sort } : {}), + ...(options?.page ? { page: options.page } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, @@ -998,33 +1006,6 @@ export class DaemonClient { if (msg.payload.requestId !== resolvedRequestId) { return null; } - return msg.payload.agents; - }, - }); - } - - async fetchAgentsGroupedByProject(options?: { - filter?: { labels?: Record }; - requestId?: string; - }): Promise { - 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; }, }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 89d963352..08003deba 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -953,6 +953,9 @@ export class AgentManager { agent.pendingRun = streamForwarder; agent.lifecycle = "running"; + // Bump updatedAt when lifecycle changes so downstream consumers can + // deterministically order idle->running transitions. + agent.updatedAt = new Date(); self.emitState(agent); return streamForwarder; diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 063b5426e..d83293243 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -317,6 +317,44 @@ const shouldRun = !process.env.CI; 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( "replays detached terminal output from resume offset (scrollback continuity)", async () => { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 852366302..18782bd71 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -28,6 +28,7 @@ import { type ProjectPlacementPayload, } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; +import type { TerminalSession } from "../terminal/terminal.js"; import { BinaryMuxChannel, TerminalBinaryFlags, @@ -148,8 +149,6 @@ const pendingAgentInitializations = new Map>(); let restartRequested = false; const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; 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_FALLBACK_REFRESH_MS = 5_000; const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024; @@ -280,6 +279,30 @@ type TerminalStreamPendingChunk = { replay: boolean; }; +type FetchAgentsRequestMessage = Extract< + SessionInboundMessage, + { type: "fetch_agents_request" } +>; +type FetchAgentsRequestSort = NonNullable[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; + id: string; +}; + +class SessionRequestError extends Error { + constructor(readonly code: string, message: string) { + super(message); + this.name = "SessionRequestError"; + } +} + const PCM_SAMPLE_RATE = 16000; const PCM_CHANNELS = 1; const PCM_BITS_PER_SAMPLE = 16; @@ -524,10 +547,6 @@ export class Session { filter?: { labels?: Record; agentId?: string }; } | null = null; - private readonly projectPlacementCache = new Map< - string, - { expiresAt: number; promise: Promise } - >(); private clientActivity: { deviceType: "web" | "mobile"; focusedAgentId: string | null; @@ -538,6 +557,7 @@ export class Session { private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly terminalManager: TerminalManager | null; private terminalSubscriptions: Map void> = new Map(); + private terminalExitSubscriptions: Map void> = new Map(); private readonly terminalStreams = new Map< number, { @@ -1111,21 +1131,6 @@ export class Session { }; } - private getProjectPlacement(cwd: string): Promise { - 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 { try { const subscription = this.agentUpdatesSubscription; @@ -1137,7 +1142,7 @@ export class Session { const matches = this.matchesAgentFilter(payload, subscription.filter); if (matches) { - const project = await this.getProjectPlacement(payload.cwd); + const project = await this.buildProjectPlacement(payload.cwd); this.emit({ type: "agent_update", payload: { kind: "upsert", agent: payload, project }, @@ -1173,11 +1178,7 @@ export class Session { break; case "fetch_agents_request": - await this.handleFetchAgents(msg.requestId, msg.filter); - break; - - case "fetch_agents_grouped_by_project_request": - await this.handleFetchAgentsGroupedByProject(msg.requestId, msg.filter); + await this.handleFetchAgents(msg); break; case "fetch_agent_request": @@ -4876,102 +4877,320 @@ export class Session { return this.buildStoredAgentPayload(record); } - private async handleFetchAgents( - requestId: string, - filter?: { labels?: Record } - ): Promise { - try { - const agents = await this.listAgentPayloads(filter); - this.emit({ - type: "fetch_agents_response", - 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; - }): Promise; - }>> { - 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); + private normalizeFetchAgentsSort( + sort: FetchAgentsRequestSort[] | undefined + ): FetchAgentsRequestSort[] { + const fallback: FetchAgentsRequestSort[] = [ + { key: "updated_at", direction: "desc" }, + ]; + if (!sort || sort.length === 0) { + return fallback; } - for (const agent of visibleAgents) { - const project = await this.getProjectPlacement(agent.cwd); - const projectKey = project.projectKey; - - 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) { + const deduped: FetchAgentsRequestSort[] = []; + const seen = new Set(); + for (const entry of sort) { + if (seen.has(entry.key)) { continue; } - - group.agents.push({ agent, checkout: project.checkout }); + seen.add(entry.key); + deduped.push(entry); } - - return Array.from(grouped.values()); + return deduped.length > 0 ? deduped : fallback; } - private async handleFetchAgentsGroupedByProject( - requestId: string, - filter?: { labels?: Record } + private getStatusPriority(agent: AgentSnapshotPayload): number { + const requiresAttention = agent.requiresAttention ?? false; + 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 = {}; + 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, + 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 + ): 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>(); + const getPlacement = (cwd: string): Promise => { + 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 ): Promise { try { - const groups = await this.listAgentsGroupedByProjectPayload(filter); + const payload = await this.listFetchAgentsEntries(request); this.emit({ - type: "fetch_agents_grouped_by_project_response", - payload: { requestId, groups }, + type: "fetch_agents_response", + payload: { + requestId: request.requestId, + ...payload, + }, }); } catch (error) { - this.sessionLogger.error( - { err: error }, - "Failed to handle fetch_agents_grouped_by_project_request" - ); + const code = + error instanceof SessionRequestError ? error.code : "fetch_agents_failed"; + const message = + error instanceof Error ? error.message : "Failed to fetch agents"; + this.sessionLogger.error({ err: error }, "Failed to handle fetch_agents_request"); this.emit({ - type: "fetch_agents_grouped_by_project_response", - payload: { requestId, groups: [] }, + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code, + }, }); } } @@ -5962,6 +6181,10 @@ export class Session { unsubscribe(); } this.terminalSubscriptions.clear(); + for (const unsubscribeExit of this.terminalExitSubscriptions.values()) { + unsubscribeExit(); + } + this.terminalExitSubscriptions.clear(); this.detachAllTerminalStreams({ emitExit: false }); for (const target of this.checkoutDiffTargets.values()) { @@ -5975,6 +6198,43 @@ export class Session { // 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 { if (!this.terminalManager) { this.emit({ @@ -5990,6 +6250,9 @@ export class Session { try { const terminals = await this.terminalManager.getTerminals(msg.cwd); + for (const terminal of terminals) { + this.ensureTerminalExitSubscription(terminal); + } this.emit({ type: "list_terminals_response", payload: { @@ -6029,6 +6292,7 @@ export class Session { cwd: msg.cwd, name: msg.name, }); + this.ensureTerminalExitSubscription(session); this.emit({ type: "create_terminal_response", payload: { @@ -6077,6 +6341,7 @@ export class Session { }); return; } + this.ensureTerminalExitSubscription(session); // Unsubscribe from previous subscription if any 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"); return; } + this.ensureTerminalExitSubscription(session); session.send(msg.message); } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 81d7a9058..fd3ae1c41 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -458,16 +458,24 @@ export const FetchAgentsRequestMessageSchema = z.object({ filter: z .object({ 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(), -}); - -export const FetchAgentsGroupedByProjectRequestMessageSchema = z.object({ - type: z.literal("fetch_agents_grouped_by_project_request"), - requestId: z.string(), - filter: z + sort: z + .array( + z.object({ + key: z.enum(["status_priority", "created_at", "updated_at", "title"]), + direction: z.enum(["asc", "desc"]), + }) + ) + .optional(), + page: z .object({ - labels: z.record(z.string()).optional(), + limit: z.number().int().positive().max(1000), + cursor: z.string().min(1).optional(), }) .optional(), }); @@ -1002,7 +1010,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AbortRequestMessageSchema, AudioPlayedMessageSchema, FetchAgentsRequestMessageSchema, - FetchAgentsGroupedByProjectRequestMessageSchema, FetchAgentRequestMessageSchema, SubscribeAgentUpdatesMessageSchema, UnsubscribeAgentUpdatesMessageSchema, @@ -1399,26 +1406,17 @@ export const FetchAgentsResponseMessageSchema = z.object({ type: z.literal("fetch_agents_response"), payload: z.object({ requestId: z.string(), - agents: z.array(AgentSnapshotPayloadSchema), - }), -}); - -const ProjectGroupedAgentEntryPayloadSchema = z.object({ - agent: AgentSnapshotPayloadSchema, - checkout: ProjectCheckoutLitePayloadSchema, -}); - -const ProjectGroupPayloadSchema = z.object({ - 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), + entries: z.array( + z.object({ + agent: AgentSnapshotPayloadSchema, + project: ProjectPlacementPayloadSchema, + }) + ), + pageInfo: z.object({ + nextCursor: z.string().nullable(), + prevCursor: z.string().nullable(), + hasMore: z.boolean(), + }), }), }); @@ -1972,7 +1970,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentStreamMessageSchema, AgentStatusMessageSchema, FetchAgentsResponseMessageSchema, - FetchAgentsGroupedByProjectResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, SendAgentMessageResponseMessageSchema, @@ -2042,9 +2039,6 @@ export type ProjectPlacementPayload = z.infer; -export type FetchAgentsGroupedByProjectResponseMessage = z.infer< - typeof FetchAgentsGroupedByProjectResponseMessageSchema ->; export type FetchAgentResponseMessage = z.infer< typeof FetchAgentResponseMessageSchema >; @@ -2079,9 +2073,6 @@ export type ActivityLogPayload = z.infer; // Type exports for inbound message types export type VoiceAudioChunkMessage = z.infer; export type FetchAgentsRequestMessage = z.infer; -export type FetchAgentsGroupedByProjectRequestMessage = z.infer< - typeof FetchAgentsGroupedByProjectRequestMessageSchema ->; export type FetchAgentRequestMessage = z.infer; export type SendAgentMessageRequest = z.infer; export type WaitForFinishRequest = z.infer; diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 5b9e27926..5d6933571 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -1,6 +1,21 @@ import { describe, it, expect, afterEach } from "vitest"; import { createTerminalManager, type TerminalManager } from "./terminal-manager.js"; +async function waitForCondition( + predicate: () => boolean, + timeoutMs: number, + intervalMs = 25 +): Promise { + 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", () => { let manager: TerminalManager; @@ -136,6 +151,21 @@ describe("TerminalManager", () => { manager = createTerminalManager(); 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", () => { @@ -161,12 +191,14 @@ describe("TerminalManager", () => { manager = createTerminalManager(); const tmpTerminals = await manager.getTerminals("/tmp"); const homeTerminals = await manager.getTerminals("/home"); + const tmpId = tmpTerminals[0].id; + const homeId = homeTerminals[0].id; manager.killAll(); expect(manager.listDirectories()).toEqual([]); - expect(manager.getTerminal(tmpTerminals[0].id)).toBeUndefined(); - expect(manager.getTerminal(homeTerminals[0].id)).toBeUndefined(); + expect(manager.getTerminal(tmpId)).toBeUndefined(); + expect(manager.getTerminal(homeId)).toBeUndefined(); }); }); }); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 1fe8847a7..970557eb1 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -12,6 +12,7 @@ export interface TerminalManager { export function createTerminalManager(): TerminalManager { const terminalsByCwd = new Map(); const terminalsById = new Map(); + const terminalExitUnsubscribeById = new Map void>(); function assertAbsolutePath(cwd: string): void { 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 { async getTerminals(cwd: string): Promise { assertAbsolutePath(cwd); let terminals = terminalsByCwd.get(cwd); if (!terminals || terminals.length === 0) { - const session = await createTerminal({ cwd, name: "Terminal 1" }); + const session = registerSession( + await createTerminal({ cwd, name: "Terminal 1" }) + ); terminals = [session]; terminalsByCwd.set(cwd, terminals); - terminalsById.set(session.id, session); } return terminals; }, @@ -38,14 +79,15 @@ export function createTerminalManager(): TerminalManager { const terminals = terminalsByCwd.get(options.cwd) ?? []; const defaultName = `Terminal ${terminals.length + 1}`; - const session = await createTerminal({ - cwd: options.cwd, - name: options.name ?? defaultName, - }); + const session = registerSession( + await createTerminal({ + cwd: options.cwd, + name: options.name ?? defaultName, + }) + ); terminals.push(session); terminalsByCwd.set(options.cwd, terminals); - terminalsById.set(session.id, session); return session; }, @@ -55,22 +97,7 @@ export function createTerminalManager(): TerminalManager { }, killTerminal(id: string): void { - const session = terminalsById.get(id); - 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); - } - } + removeSessionById(id, { kill: true }); }, listDirectories(): string[] { @@ -78,11 +105,9 @@ export function createTerminalManager(): TerminalManager { }, killAll(): void { - for (const session of terminalsById.values()) { - session.kill(); + for (const id of Array.from(terminalsById.keys())) { + removeSessionById(id, { kill: true }); } - terminalsByCwd.clear(); - terminalsById.clear(); }, }; } diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 8c2b5a6ad..a88ace471 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -64,6 +64,7 @@ export interface TerminalSession { cwd: string; send(msg: ClientMessage): void; subscribe(listener: (msg: ServerMessage) => void): () => void; + onExit(listener: () => void): () => void; subscribeRaw( listener: (chunk: TerminalRawChunk) => void, options?: { fromOffset?: number } @@ -190,6 +191,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void>(); const rawListeners = new Set<(chunk: TerminalRawChunk) => void>(); + const exitListeners = new Set<() => void>(); const rawOutputChunks: Array<{ startOffset: number; endOffset: number; @@ -199,6 +201,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { killed = true; + emitExit(); + disposeResources(); }); function getState(): TerminalState { @@ -340,6 +372,24 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void): () => void { + if (killed) { + queueMicrotask(() => { + try { + listener(); + } catch { + // no-op + } + }); + return () => {}; + } + + exitListeners.add(listener); + return () => { + exitListeners.delete(listener); + }; + } + function subscribeRaw( listener: (chunk: TerminalRawChunk) => void, options?: { fromOffset?: number } @@ -380,11 +430,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise