From 1d1c7058f130d1071a1cde0304ef47409b79fc07 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 7 Mar 2026 12:32:49 +0700 Subject: [PATCH] feat: add agent delete command and bulk tab close operations --- cli-client-id | 1 + .../e2e/workspace-header-tabs-restore.spec.ts | 33 ++++ .../src/components/sidebar-workspace-list.tsx | 15 +- packages/app/src/components/synced-loader.tsx | 170 ++++++++++++++++++ .../workspace/workspace-bulk-close.test.ts | 82 +++++++++ .../screens/workspace/workspace-bulk-close.ts | 52 ++++++ .../workspace/workspace-desktop-tabs-row.tsx | 40 ++++- .../screens/workspace/workspace-screen.tsx | 104 ++++++----- .../workspace/workspace-tab-presentation.tsx | 13 ++ packages/app/src/utils/status-loader.test.ts | 13 ++ packages/app/src/utils/status-loader.ts | 12 ++ packages/cli/src/cli.ts | 13 +- packages/cli/src/commands/agent/archive.ts | 2 +- packages/cli/src/commands/agent/delete.ts | 114 ++++++++++++ packages/cli/src/commands/agent/index.ts | 13 +- packages/cli/src/commands/agent/stop.ts | 11 +- packages/cli/tests/07-agent-stop.test.ts | 2 +- packages/cli/tests/27-agent-delete.test.ts | 118 ++++++++++++ .../cli/tests/e2e/agent-lifecycle.test.ts | 35 ++-- packages/cli/tests/e2e/agent-send.test.ts | 12 +- packages/cli/tests/e2e/permissions.test.ts | 14 +- 21 files changed, 783 insertions(+), 86 deletions(-) create mode 100644 cli-client-id create mode 100644 packages/app/src/components/synced-loader.tsx create mode 100644 packages/app/src/screens/workspace/workspace-bulk-close.test.ts create mode 100644 packages/app/src/screens/workspace/workspace-bulk-close.ts create mode 100644 packages/app/src/utils/status-loader.test.ts create mode 100644 packages/app/src/utils/status-loader.ts create mode 100644 packages/cli/src/commands/agent/delete.ts create mode 100644 packages/cli/tests/27-agent-delete.test.ts diff --git a/cli-client-id b/cli-client-id new file mode 100644 index 000000000..a86f22c08 --- /dev/null +++ b/cli-client-id @@ -0,0 +1 @@ +cid_518a41c4c44340aea1120d2b760fc6c6 \ No newline at end of file diff --git a/packages/app/e2e/workspace-header-tabs-restore.spec.ts b/packages/app/e2e/workspace-header-tabs-restore.spec.ts index 349671359..899be4a7e 100644 --- a/packages/app/e2e/workspace-header-tabs-restore.spec.ts +++ b/packages/app/e2e/workspace-header-tabs-restore.spec.ts @@ -102,6 +102,39 @@ test("workspace new-tab buttons stay on-screen during horizontal scroll", async } }); +test("workspace new-tab buttons sit immediately after tabs before overflow", async ({ page }) => { + const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-adjacent-"); + + try { + await openWorkspaceWithAgent(page, repo.path); + + const agentButton = page.getByTestId("workspace-new-agent-tab").first(); + const workspaceTabs = page.locator( + '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])' + ); + + await expect(agentButton).toBeVisible({ timeout: 30000 }); + await expect(workspaceTabs).toHaveCount(1, { timeout: 30000 }); + + const lastTabBounds = await workspaceTabs.last().boundingBox(); + const agentBounds = await agentButton.boundingBox(); + + expect(lastTabBounds).not.toBeNull(); + expect(agentBounds).not.toBeNull(); + + if (!lastTabBounds || !agentBounds) { + return; + } + + const horizontalGap = agentBounds.x - (lastTabBounds.x + lastTabBounds.width); + + expect(horizontalGap).toBeGreaterThanOrEqual(0); + expect(horizontalGap).toBeLessThanOrEqual(24); + } finally { + await repo.cleanup(); + } +}); + test("workspace explorer toggle opens and closes explorer", async ({ page }) => { const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-"); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 4eb46ea30..bb6adad35 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -49,6 +49,7 @@ import { ContextMenuTrigger, useContextMenu, } from '@/components/ui/context-menu' +import { SyncedLoader } from '@/components/synced-loader' import { useToast } from '@/contexts/toast-context' import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store' import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts' @@ -56,6 +57,7 @@ import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reor import { decideLongPressMove } from '@/utils/sidebar-gesture-arbitration' import { confirmDialog } from '@/utils/confirm-dialog' import { projectIconPlaceholderLabelFromDisplayName } from '@/utils/project-display-name' +import { shouldRenderSyncedStatusLoader } from '@/utils/status-loader' const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees' @@ -144,11 +146,14 @@ function WorkspaceStatusIndicator({ }) { const { theme } = useUnistyles() const color = resolveStatusDotColor({ theme, bucket }) + const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }) return ( {loading ? ( + ) : shouldShowSyncedLoader ? ( + ) : ( )} @@ -1340,17 +1345,16 @@ const styles = StyleSheet.create((theme) => ({ position: 'relative', }, workspaceStatusDot: { - width: 8, - height: 8, + width: 16, + height: 16, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: 'center', justifyContent: 'center', - overflow: 'hidden', }, workspaceStatusDotFill: { - width: 8, - height: 8, + width: 7, + height: 7, borderRadius: theme.borderRadius.full, }, workspaceArchivingOverlay: { @@ -1371,6 +1375,7 @@ const styles = StyleSheet.create((theme) => ({ workspaceBranchText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, + lineHeight: 20, opacity: 0.76, flex: 1, minWidth: 0, diff --git a/packages/app/src/components/synced-loader.tsx b/packages/app/src/components/synced-loader.tsx new file mode 100644 index 000000000..8e665e5ed --- /dev/null +++ b/packages/app/src/components/synced-loader.tsx @@ -0,0 +1,170 @@ +import { View } from "react-native"; +import Animated, { + Easing, + makeMutable, + type SharedValue, + useAnimatedStyle, + withRepeat, + withTiming, +} from "react-native-reanimated"; +import { useEffect } from "react"; + +const SYNCED_LOADER_DURATION_MS = 950; +const SYNCED_LOADER_EPOCH_MS = 0; +const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const; +const DOT_COUNT = DOT_SEQUENCE.length; +const GRID_ROWS = 3; +const GRID_COLUMNS = 2; +const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const; +const SNAKE_OPACITIES = [1, 0.72, 0.46, 0.22, 0] as const; +const sharedStepProgress = makeMutable(0); +let sharedLoopStarted = false; + +function ensureSharedStepLoopStarted(): void { + if (sharedLoopStarted) { + return; + } + + sharedLoopStarted = true; + const elapsedMs = + (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS; + sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT; + sharedStepProgress.value = withTiming( + DOT_COUNT, + { + duration: Math.max(1, Math.round(SYNCED_LOADER_DURATION_MS - elapsedMs)), + easing: Easing.linear, + }, + (finished) => { + if (!finished) { + sharedLoopStarted = false; + return; + } + sharedStepProgress.value = 0; + sharedStepProgress.value = withRepeat( + withTiming(DOT_COUNT, { + duration: SYNCED_LOADER_DURATION_MS, + easing: Easing.linear, + }), + -1, + false + ); + } + ); +} + +export function SyncedLoader({ + size = 10, + color, +}: { + size?: number; + color: string; +}) { + useEffect(() => { + ensureSharedStepLoopStarted(); + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: 1, + })); + + const gap = Math.max(1, Math.round(size * 0.12)); + const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3)); + const gridWidth = dotSize * 2 + gap; + const gridHeight = dotSize * 3 + gap * 2; + + return ( + + + {Array.from({ length: DOT_COUNT }).map((_, dotIndex) => { + const rowIndex = Math.floor(dotIndex / GRID_COLUMNS); + const columnIndex = dotIndex % GRID_COLUMNS; + const sequenceIndex = DOT_SEQUENCE.indexOf( + dotIndex as (typeof DOT_SEQUENCE)[number] + ); + + return ( + + ); + })} + + + ); +} + +function SpinnerDot({ + color, + dotSize, + sequenceIndex, + progress, + style, +}: { + color: string; + dotSize: number; + sequenceIndex: number; + progress: SharedValue; + style: { + position: "absolute"; + left: number; + top: number; + }; +}) { + const animatedStyle = useAnimatedStyle(() => { + const headIndex = Math.floor(progress.value) % DOT_COUNT; + let opacity = 0; + + for (let segmentIndex = 0; segmentIndex < SNAKE_SEGMENT_OFFSETS.length; segmentIndex += 1) { + const activeSequenceIndex = + (headIndex + SNAKE_SEGMENT_OFFSETS[segmentIndex] + DOT_COUNT) % DOT_COUNT; + if (sequenceIndex === activeSequenceIndex) { + opacity = SNAKE_OPACITIES[segmentIndex] ?? 0; + break; + } + } + + return { + opacity, + }; + }); + + return ( + + ); +} diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts new file mode 100644 index 000000000..2ca95cdc7 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + buildBulkCloseConfirmationMessage, + classifyBulkClosableTabs, +} from "@/screens/workspace/workspace-bulk-close"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +function makeAgentTab(id: string): WorkspaceTabDescriptor { + return { + key: `agent_${id}`, + tabId: `agent_${id}`, + kind: "agent", + agentId: id, + provider: "codex", + label: `Agent ${id}`, + subtitle: "", + titleState: "ready", + }; +} + +function makeTerminalTab(id: string): WorkspaceTabDescriptor { + return { + key: `terminal_${id}`, + tabId: `terminal_${id}`, + kind: "terminal", + terminalId: id, + label: `Terminal ${id}`, + subtitle: "", + }; +} + +function makeFileTab(path: string): WorkspaceTabDescriptor { + return { + key: `file_${path}`, + tabId: `file_${path}`, + kind: "file", + filePath: path, + label: path.split("/").pop() ?? path, + subtitle: path, + }; +} + +describe("workspace bulk close helpers", () => { + it("classifies agent, terminal, and passive tabs for shared bulk close handling", () => { + const groups = classifyBulkClosableTabs([ + makeAgentTab("a1"), + makeTerminalTab("t1"), + makeFileTab("/repo/README.md"), + ]); + + expect(groups).toEqual({ + agentTabs: [{ tabId: "agent_a1", agentId: "a1" }], + terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }], + otherTabs: [{ tabId: "file_/repo/README.md" }], + }); + }); + + it("describes mixed destructive bulk close operations in the confirmation copy", () => { + const message = buildBulkCloseConfirmationMessage( + classifyBulkClosableTabs([ + makeAgentTab("a1"), + makeAgentTab("a2"), + makeTerminalTab("t1"), + makeFileTab("/repo/README.md"), + ]) + ); + + expect(message).toBe( + "This will archive 2 agent(s), close 1 terminal(s), and close 1 tab(s). Any running process in a closed terminal will be stopped immediately." + ); + }); + + it("keeps terminal-only confirmations explicit about stopping running processes", () => { + const message = buildBulkCloseConfirmationMessage( + classifyBulkClosableTabs([makeTerminalTab("t1")]) + ); + + expect(message).toBe( + "This will close 1 terminal(s). Any running process in a closed terminal will be stopped immediately." + ); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.ts b/packages/app/src/screens/workspace/workspace-bulk-close.ts new file mode 100644 index 000000000..1acc27c5a --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-bulk-close.ts @@ -0,0 +1,52 @@ +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +export type BulkClosableTabGroups = { + agentTabs: Array<{ tabId: string; agentId: string }>; + terminalTabs: Array<{ tabId: string; terminalId: string }>; + otherTabs: Array<{ tabId: string }>; +}; + +export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups { + const groups: BulkClosableTabGroups = { + agentTabs: [], + terminalTabs: [], + otherTabs: [], + }; + + for (const tab of tabs) { + if (tab.kind === "agent") { + groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.agentId }); + continue; + } + if (tab.kind === "terminal") { + groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.terminalId }); + continue; + } + groups.otherTabs.push({ tabId: tab.tabId }); + } + + return groups; +} + +export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups): string { + const { agentTabs, terminalTabs, otherTabs } = input; + if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) { + return `This will archive ${agentTabs.length} agent(s), close ${terminalTabs.length} terminal(s), and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`; + } + if (agentTabs.length > 0 && terminalTabs.length > 0) { + return `This will archive ${agentTabs.length} agent(s) and close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`; + } + if (terminalTabs.length > 0 && otherTabs.length > 0) { + return `This will close ${terminalTabs.length} terminal(s) and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`; + } + if (agentTabs.length > 0 && otherTabs.length > 0) { + return `This will archive ${agentTabs.length} agent(s) and close ${otherTabs.length} tab(s).`; + } + if (terminalTabs.length > 0) { + return `This will close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`; + } + if (otherTabs.length > 0) { + return `This will close ${otherTabs.length} tab(s).`; + } + return `This will archive ${agentTabs.length} agent(s).`; +} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 24ce8c8e9..23def4f96 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -39,7 +39,9 @@ type WorkspaceDesktopTabsRowProps = { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; onSelectNewTabOption: (optionId: NewTabOptionId) => void; newTabAgentOptionId: NewTabOptionId; newTabTerminalOptionId: NewTabOptionId; @@ -64,7 +66,9 @@ export function WorkspaceDesktopTabsRow({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCloseTabsToLeft, onCloseTabsToRight, + onCloseOtherTabs, onSelectNewTabOption, newTabAgentOptionId, newTabTerminalOptionId, @@ -129,7 +133,10 @@ export function WorkspaceDesktopTabsRow({ horizontal scrollEnabled={layout.requiresHorizontalScrollFallback} testID="workspace-tabs-scroll" - style={styles.tabsScroll} + style={[ + styles.tabsScroll, + layout.requiresHorizontalScrollFallback ? styles.tabsScrollOverflow : styles.tabsScrollFitContent, + ]} contentContainerStyle={styles.tabsContent} showsHorizontalScrollIndicator={false} > @@ -161,6 +168,9 @@ export function WorkspaceDesktopTabsRow({ const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent }); const contextMenuTestId = `workspace-tab-context-${tab.key}`; + const isFirstTab = index === 0; + const isLastTab = index === tabs.length - 1; + const isOnlyTab = tabs.length <= 1; return ( @@ -290,15 +300,33 @@ export function WorkspaceDesktopTabsRow({ + { + void onCloseTabsToLeft(tab.tabId); + }} + > + Close to the left + t.key === tab.key) === tabs.length - 1} + disabled={isLastTab} onSelect={() => { void onCloseTabsToRight(tab.tabId); }} > Close to the right + { + void onCloseOtherTabs(tab.tabId); + }} + > + Close other tabs + { @@ -375,9 +403,15 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", }, tabsScroll: { - flex: 1, minWidth: 0, }, + tabsScrollFitContent: { + flexGrow: 0, + flexShrink: 1, + }, + tabsScrollOverflow: { + flex: 1, + }, tabsContent: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 72fa6d947..0e0d79c13 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -83,6 +83,10 @@ import { import { deriveWorkspaceTabModel, } from "@/screens/workspace/workspace-tab-model"; +import { + buildBulkCloseConfirmationMessage, + classifyBulkClosableTabs, +} from "@/screens/workspace/workspace-bulk-close"; const TERMINALS_QUERY_STALE_TIME = 5_000; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; @@ -954,46 +958,17 @@ function WorkspaceScreenContent({ [sessionAgents, toast] ); - const handleCloseTabsToRight = useCallback( - async (tabKey: string) => { - const startIndex = tabs.findIndex((tab) => tab.tabId === tabKey); - if (startIndex < 0) { - return; - } - const toClose = tabs.slice(startIndex + 1); - if (toClose.length === 0) { + const handleBulkCloseTabs = useCallback( + async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => { + const { tabsToClose, title, logLabel } = input; + if (tabsToClose.length === 0) { return; } - const agentTabs: Array<{ tabId: string; agentId: string }> = []; - const terminalTabs: Array<{ tabId: string; terminalId: string }> = []; - const otherTabs: Array<{ tabId: string }> = []; - for (const tab of toClose) { - if (tab.kind === "agent") { - agentTabs.push({ tabId: tab.tabId, agentId: tab.agentId }); - } else if (tab.kind === "terminal") { - terminalTabs.push({ tabId: tab.tabId, terminalId: tab.terminalId }); - } else { - otherTabs.push({ tabId: tab.tabId }); - } - } - + const groups = classifyBulkClosableTabs(tabsToClose); const confirmed = await confirmDialog({ - title: "Close tabs to the right?", - message: - agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0 - ? `This will archive ${agentTabs.length} agent(s), close ${terminalTabs.length} terminal(s), and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.` - : agentTabs.length > 0 && terminalTabs.length > 0 - ? `This will archive ${agentTabs.length} agent(s) and close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.` - : terminalTabs.length > 0 && otherTabs.length > 0 - ? `This will close ${terminalTabs.length} terminal(s) and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.` - : agentTabs.length > 0 && otherTabs.length > 0 - ? `This will archive ${agentTabs.length} agent(s) and close ${otherTabs.length} tab(s).` - : terminalTabs.length > 0 - ? `This will close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.` - : otherTabs.length > 0 - ? `This will close ${otherTabs.length} tab(s).` - : `This will archive ${agentTabs.length} agent(s).`, + title, + message: buildBulkCloseConfirmationMessage(groups), confirmLabel: "Close", cancelLabel: "Cancel", destructive: true, @@ -1002,7 +977,7 @@ function WorkspaceScreenContent({ return; } - for (const { tabId, terminalId } of terminalTabs) { + for (const { tabId, terminalId } of groups.terminalTabs) { try { await killTerminalMutation.mutateAsync(terminalId); queryClient.setQueryData(terminalsQueryKey, (current) => { @@ -1020,11 +995,11 @@ function WorkspaceScreenContent({ tabId, }); } catch (error) { - console.warn("[WorkspaceScreen] Failed to close terminal tab to the right", { terminalId, error }); + console.warn(`[WorkspaceScreen] Failed to close terminal tab ${logLabel}`, { terminalId, error }); } } - for (const { tabId, agentId } of agentTabs) { + for (const { tabId, agentId } of groups.agentTabs) { if (!normalizedServerId) { continue; } @@ -1036,11 +1011,11 @@ function WorkspaceScreenContent({ tabId, }); } catch (error) { - console.warn("[WorkspaceScreen] Failed to archive agent tab to the right", { agentId, error }); + console.warn(`[WorkspaceScreen] Failed to archive agent tab ${logLabel}`, { agentId, error }); } } - for (const { tabId } of otherTabs) { + for (const { tabId } of groups.otherTabs) { closeWorkspaceTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -1048,7 +1023,7 @@ function WorkspaceScreenContent({ }); } - const closedKeys = new Set(toClose.map((tab) => tab.key)); + const closedKeys = new Set(tabsToClose.map((tab) => tab.key)); setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current)); setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current)); }, @@ -1059,11 +1034,52 @@ function WorkspaceScreenContent({ normalizedServerId, normalizedWorkspaceId, queryClient, - tabs, terminalsQueryKey, ] ); + const handleCloseTabsToLeft = useCallback( + async (tabId: string) => { + const index = tabs.findIndex((tab) => tab.tabId === tabId); + if (index < 0) { + return; + } + await handleBulkCloseTabs({ + tabsToClose: tabs.slice(0, index), + title: "Close tabs to the left?", + logLabel: "to the left", + }); + }, + [handleBulkCloseTabs, tabs] + ); + + const handleCloseTabsToRight = useCallback( + async (tabId: string) => { + const index = tabs.findIndex((tab) => tab.tabId === tabId); + if (index < 0) { + return; + } + await handleBulkCloseTabs({ + tabsToClose: tabs.slice(index + 1), + title: "Close tabs to the right?", + logLabel: "to the right", + }); + }, + [handleBulkCloseTabs, tabs] + ); + + const handleCloseOtherTabs = useCallback( + async (tabId: string) => { + const tabsToClose = tabs.filter((tab) => tab.tabId !== tabId); + await handleBulkCloseTabs({ + tabsToClose, + title: "Close other tabs?", + logLabel: "from close other tabs", + }); + }, + [handleBulkCloseTabs, tabs] + ); + useEffect(() => { if (!workspaceTabActionRequest) { return; @@ -1444,7 +1460,9 @@ function WorkspaceScreenContent({ onCloseTab={handleCloseTabById} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToRight={handleCloseTabsToRight} + onCloseOtherTabs={handleCloseOtherTabs} onSelectNewTabOption={handleSelectNewTabOption} newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID} newTabTerminalOptionId={NEW_TAB_TERMINAL_OPTION_ID} diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index de6035afd..85c953441 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -4,6 +4,7 @@ import { Bot, Check, FileText, Pencil, Terminal } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; +import { SyncedLoader } from "@/components/synced-loader"; import type { Agent } from "@/stores/session-store"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { @@ -11,6 +12,7 @@ import { type SidebarStateBucket, } from "@/utils/sidebar-agent-state"; import { getStatusDotColor } from "@/utils/status-dot-color"; +import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; export type WorkspaceTabPresentation = { key: string; @@ -69,8 +71,19 @@ export function WorkspaceTabIcon({ bucket: presentation.statusBucket, showDoneAsInactive: false, }); + const shouldShowLoader = shouldRenderSyncedStatusLoader({ + bucket: presentation.statusBucket, + }); if (presentation.kind === "agent") { + if (shouldShowLoader) { + return ( + + + + ); + } + return ( {presentation.provider === "claude" ? ( diff --git a/packages/app/src/utils/status-loader.test.ts b/packages/app/src/utils/status-loader.test.ts new file mode 100644 index 000000000..8ec146957 --- /dev/null +++ b/packages/app/src/utils/status-loader.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { shouldRenderSyncedStatusLoader } from "./status-loader"; + +describe("shouldRenderSyncedStatusLoader", () => { + it("renders the synced loader only for running status", () => { + expect(shouldRenderSyncedStatusLoader({ bucket: "running" })).toBe(true); + expect(shouldRenderSyncedStatusLoader({ bucket: "needs_input" })).toBe(false); + expect(shouldRenderSyncedStatusLoader({ bucket: "failed" })).toBe(false); + expect(shouldRenderSyncedStatusLoader({ bucket: "attention" })).toBe(false); + expect(shouldRenderSyncedStatusLoader({ bucket: "done" })).toBe(false); + expect(shouldRenderSyncedStatusLoader({ bucket: null })).toBe(false); + }); +}); diff --git a/packages/app/src/utils/status-loader.ts b/packages/app/src/utils/status-loader.ts new file mode 100644 index 000000000..8e8e3b061 --- /dev/null +++ b/packages/app/src/utils/status-loader.ts @@ -0,0 +1,12 @@ +export type StatusLoaderBucket = + | "needs_input" + | "failed" + | "running" + | "attention" + | "done"; + +export function shouldRenderSyncedStatusLoader(input: { + bucket: StatusLoaderBucket | null | undefined; +}): boolean { + return input.bucket === "running"; +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 900f849ee..9868f48e4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,6 +13,7 @@ import { runDaemonUpdateCommandOrExit } from './commands/daemon/update.js' import { runLsCommand } from './commands/agent/ls.js' import { runRunCommand } from './commands/agent/run.js' import { runLogsCommand } from './commands/agent/logs.js' +import { runDeleteCommand } from './commands/agent/delete.js' import { runStopCommand } from './commands/agent/stop.js' import { runSendCommand } from './commands/agent/send.js' import { runInspectCommand } from './commands/agent/inspect.js' @@ -108,7 +109,7 @@ export function createCli(): Command { program .command('stop') - .description('Stop an agent (cancel if running, then terminate)') + .description('Interrupt an agent if it is running (no-op for idle agents)') .argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified') .option('--all', 'Stop all agents') .option('--cwd ', 'Stop all agents in directory') @@ -116,6 +117,16 @@ export function createCli(): Command { .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runStopCommand)) + program + .command('delete') + .description('Delete an agent (interrupt if running, then hard-delete)') + .argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified') + .option('--all', 'Delete all agents') + .option('--cwd ', 'Delete all agents in directory') + .option('--json', 'Output in JSON format') + .option('--host ', 'Daemon host:port (default: localhost:6767)') + .action(withOutput(runDeleteCommand)) + program .command('send') .description('Send a message/task to an existing agent') diff --git a/packages/cli/src/commands/agent/archive.ts b/packages/cli/src/commands/agent/archive.ts index f8d14bb37..d842d128b 100644 --- a/packages/cli/src/commands/agent/archive.ts +++ b/packages/cli/src/commands/agent/archive.ts @@ -85,7 +85,7 @@ export async function runArchiveCommand( code: 'AGENT_RUNNING', message: `Agent ${agentId.slice(0, 7)} is currently running`, details: - 'Use --force to archive a running agent (it will interrupt the active run), or stop it first with: paseo agent stop', + 'Use --force to archive a running agent (it will interrupt the active run), or stop it first with: paseo agent stop. Use paseo agent delete to hard-delete it.', } throw error } diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts new file mode 100644 index 000000000..3f05d365f --- /dev/null +++ b/packages/cli/src/commands/agent/delete.ts @@ -0,0 +1,114 @@ +import type { Command } from 'commander' +import { connectToDaemon, getDaemonHost } from '../../utils/client.js' +import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js' + +export interface DeleteResult { + deletedCount: number + agentIds: string[] +} + +export const deleteSchema: OutputSchema = { + idField: (item) => item.agentIds.join('\n'), + columns: [{ header: 'DELETED', field: 'deletedCount' }], +} + +export interface AgentDeleteOptions extends CommandOptions { + all?: boolean + cwd?: string +} + +export type AgentDeleteResult = SingleResult + +export async function runDeleteCommand( + id: string | undefined, + options: AgentDeleteOptions, + _command: Command +): Promise { + const host = getDaemonHost({ host: options.host as string | undefined }) + + if (!id && !options.all && !options.cwd) { + const error: CommandError = { + code: 'MISSING_ARGUMENT', + message: 'Agent ID required unless --all or --cwd is specified', + details: 'Usage: paseo agent delete | --all | --cwd ', + } + throw error + } + + let client + try { + client = await connectToDaemon({ host: options.host as string | undefined }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const error: CommandError = { + code: 'DAEMON_NOT_RUNNING', + message: `Cannot connect to daemon at ${host}: ${message}`, + details: 'Start the daemon with: paseo daemon start', + } + throw error + } + + try { + const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } }) + let agents = fetchPayload.entries.map((entry) => entry.agent) + const deletedIds: string[] = [] + + if (options.all) { + agents = agents.filter((a) => !a.archivedAt) + } else if (options.cwd) { + const filterCwd = options.cwd + agents = agents.filter((a) => { + if (a.archivedAt) return false + const agentCwd = a.cwd.replace(/\/$/, '') + const targetCwd = filterCwd.replace(/\/$/, '') + return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/') + }) + } else if (id) { + const fetchResult = await client.fetchAgent(id) + if (!fetchResult) { + const error: CommandError = { + code: 'AGENT_NOT_FOUND', + message: `No agent found matching: ${id}`, + details: 'Use `paseo ls` to list available agents', + } + throw error + } + agents = [fetchResult.agent] + } + + for (const agent of agents) { + try { + if (agent.status === 'running') { + await client.cancelAgent(agent.id) + } + await client.deleteAgent(agent.id) + deletedIds.push(agent.id) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`Warning: Failed to delete agent ${agent.id.slice(0, 7)}: ${message}`) + } + } + + await client.close() + + return { + type: 'single', + data: { + deletedCount: deletedIds.length, + agentIds: deletedIds, + }, + schema: deleteSchema, + } + } catch (err) { + await client.close().catch(() => {}) + if (err && typeof err === 'object' && 'code' in err) { + throw err + } + const message = err instanceof Error ? err.message : String(err) + const error: CommandError = { + code: 'DELETE_AGENT_FAILED', + message: `Failed to delete agent(s): ${message}`, + } + throw error + } +} diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index ab94a7c95..154f13cd4 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander' import { runModeCommand } from './mode.js' import { runArchiveCommand } from './archive.js' +import { runDeleteCommand } from './delete.js' import { runLsCommand } from './ls.js' import { runRunCommand } from './run.js' import { runLogsCommand } from './logs.js' @@ -68,7 +69,7 @@ export function createAgentCommand(): Command { agent .command('stop') - .description('Stop an agent (cancel if running, then terminate)') + .description('Interrupt an agent if it is running (no-op for idle agents)') .argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified') .option('--all', 'Stop all agents') .option('--cwd ', 'Stop all agents in directory') @@ -76,6 +77,16 @@ export function createAgentCommand(): Command { .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runStopCommand)) + agent + .command('delete') + .description('Delete an agent (interrupt if running, then hard-delete)') + .argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified') + .option('--all', 'Delete all agents') + .option('--cwd ', 'Delete all agents in directory') + .option('--json', 'Output in JSON format') + .option('--host ', 'Daemon host:port (default: localhost:6767)') + .action(withOutput(runDeleteCommand)) + agent .command('send') .description('Send a message/task to an existing agent') diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index 6ac71107d..f6c24a915 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -12,7 +12,7 @@ export interface StopResult { export const stopSchema: OutputSchema = { // For quiet mode, output the stopped agent IDs (one per line) idField: (item) => item.agentIds.join('\n'), - columns: [{ header: 'STOPPED', field: 'stoppedCount' }], + columns: [{ header: 'INTERRUPTED', field: 'stoppedCount' }], } export interface AgentStopOptions extends CommandOptions { @@ -83,18 +83,15 @@ export async function runStopCommand( agents = [fetchResult.agent] } - // Stop each agent + // Interrupt each running agent. Idle agents are a no-op. for (const agent of agents) { try { - // Cancel if running if (agent.status === 'running') { await client.cancelAgent(agent.id) + stoppedIds.push(agent.id) } - // Delete the agent - await client.deleteAgent(agent.id) - stoppedIds.push(agent.id) } catch (err) { - // Continue stopping other agents even if one fails + // Continue interrupting other agents even if one fails const message = err instanceof Error ? err.message : String(err) console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`) } diff --git a/packages/cli/tests/07-agent-stop.test.ts b/packages/cli/tests/07-agent-stop.test.ts index 7a0ab4acb..11f554844 100644 --- a/packages/cli/tests/07-agent-stop.test.ts +++ b/packages/cli/tests/07-agent-stop.test.ts @@ -3,7 +3,7 @@ /** * Phase 6: Stop Command Tests * - * Tests the stop command - stopping agents (cancel if running, then terminate) (top-level command). + * Tests the stop command - interrupting agents (no-op if idle) (top-level command). * Since daemon may not be running, we test both: * - Help and argument parsing * - Graceful error handling when daemon not running diff --git a/packages/cli/tests/27-agent-delete.test.ts b/packages/cli/tests/27-agent-delete.test.ts new file mode 100644 index 000000000..29a7eb767 --- /dev/null +++ b/packages/cli/tests/27-agent-delete.test.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env npx tsx + +/** + * Delete Command Tests + * + * Tests the delete command - hard-deleting agents (interrupt if running first). + * Since daemon may not be running, we test both: + * - Help and argument parsing + * - Graceful error handling when daemon not running + * - All flags are accepted + */ + +import assert from 'node:assert' +import { $ } from 'zx' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +$.verbose = false + +console.log('=== Delete Command Tests ===\n') + +const port = 10000 + Math.floor(Math.random() * 50000) +const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-delete-test-home-')) + +try { + { + console.log('Test 1: delete --help shows options') + const result = await $`npm run cli -- delete --help`.nothrow() + assert.strictEqual(result.exitCode, 0, 'delete --help should exit 0') + assert(result.stdout.includes('--all'), 'help should mention --all flag') + assert(result.stdout.includes('--cwd'), 'help should mention --cwd option') + assert(result.stdout.includes('--host'), 'help should mention --host option') + assert(result.stdout.includes('[id]'), 'help should mention optional id argument') + console.log('✓ delete --help shows options\n') + } + + { + console.log('Test 2: delete requires ID, --all, or --cwd') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm run cli -- delete`.nothrow() + assert.notStrictEqual(result.exitCode, 0, 'should fail without id, --all, or --cwd') + const output = result.stdout + result.stderr + const hasError = + output.toLowerCase().includes('missing') || + output.toLowerCase().includes('required') || + output.toLowerCase().includes('argument') || + output.toLowerCase().includes('id') + assert(hasError, 'error should mention missing argument') + console.log('✓ delete requires ID, --all, or --cwd\n') + } + + { + console.log('Test 3: delete handles daemon not running') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm run cli -- delete abc123`.nothrow() + assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') + const output = result.stdout + result.stderr + const hasError = + output.toLowerCase().includes('daemon') || + output.toLowerCase().includes('connect') || + output.toLowerCase().includes('cannot') + assert(hasError, 'error message should mention connection issue') + console.log('✓ delete handles daemon not running\n') + } + + { + console.log('Test 4: delete --all flag is accepted') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm run cli -- delete --all`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept --all flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ delete --all flag is accepted\n') + } + + { + console.log('Test 5: delete --cwd flag is accepted') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm run cli -- delete --cwd /tmp`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept --cwd flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ delete --cwd flag is accepted\n') + } + + { + console.log('Test 6: delete with ID and --host flag is accepted') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm run cli -- delete abc123 --host localhost:${port}`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept --host flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ delete with ID and --host flag is accepted\n') + } + + { + console.log('Test 7: paseo --help shows delete command') + const result = await $`npm run cli -- --help`.nothrow() + assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0') + assert(result.stdout.includes('delete'), 'help should mention delete command') + console.log('✓ paseo --help shows delete command\n') + } + + { + console.log('Test 8: -q (quiet) flag is accepted with delete') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm run cli -- -q delete abc123`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept -q flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ -q (quiet) flag is accepted with delete\n') + } +} finally { + await rm(paseoHome, { recursive: true, force: true }) +} + +console.log('=== All delete tests passed ===') diff --git a/packages/cli/tests/e2e/agent-lifecycle.test.ts b/packages/cli/tests/e2e/agent-lifecycle.test.ts index d1875884f..4e08b0558 100644 --- a/packages/cli/tests/e2e/agent-lifecycle.test.ts +++ b/packages/cli/tests/e2e/agent-lifecycle.test.ts @@ -12,8 +12,9 @@ * 3. List agents with `paseo ls` * 4. Wait for agent with `paseo wait ` * 5. Inspect agent with `paseo inspect ` - * 6. Stop agent with `paseo stop ` - * 7. Cleanup: stop daemon, remove temp dirs + * 6. Stop agent with `paseo stop ` and verify it remains inspectable + * 7. Delete agent with `paseo delete ` + * 8. Cleanup: stop daemon, remove temp dirs * * CRITICAL RULES: * - NEVER use port 6767 (user's running daemon) @@ -197,17 +198,28 @@ async function test_agent_stop(agentId: string): Promise { assert.strictEqual(result.exitCode, 0, 'agent stop should succeed') - // Verify agent is no longer in running list - const psResult = await ctx.paseo(['ls', '--json']) - const agents = JSON.parse(psResult.stdout.trim()) as Array<{ id: string; status?: string }> + const inspectResult = await ctx.paseo(['inspect', agentId]) + assert.strictEqual(inspectResult.exitCode, 0, 'agent should remain inspectable after stop') + assert(!inspectResult.stdout.toLowerCase().includes('running'), 'Agent should not be running after stop') - // Agent might still be in list but should not be running - const ourAgent = agents.find((a) => agentId.startsWith(a.id) || a.id.startsWith(agentId.slice(0, 7))) - if (ourAgent) { - assert(ourAgent.status !== 'running', 'Agent should not be running after stop') - } + console.log('PASS: agent stop interrupted without deleting the agent') +} - console.log('PASS: agent stop completed successfully') +async function test_agent_delete(agentId: string): Promise { + console.log('\n--- Test: agent delete ---') + + const result = await ctx.paseo(['delete', agentId]) + + console.log('Exit code:', result.exitCode) + console.log('Stdout:', result.stdout) + if (result.stderr) console.log('Stderr:', result.stderr) + + assert.strictEqual(result.exitCode, 0, 'agent delete should succeed') + + const inspectResult = await ctx.paseo(['inspect', agentId]) + assert.notStrictEqual(inspectResult.exitCode, 0, 'deleted agent should not be inspectable') + + console.log('PASS: agent delete removed the agent') } async function main(): Promise { @@ -232,6 +244,7 @@ async function main(): Promise { await test_agent_logs(agentId) await test_agent_stop(agentId) + await test_agent_delete(agentId) console.log('\n=== All E2E tests passed! ===') } catch (err) { diff --git a/packages/cli/tests/e2e/agent-send.test.ts b/packages/cli/tests/e2e/agent-send.test.ts index fbdaa5cd8..e4e63a546 100644 --- a/packages/cli/tests/e2e/agent-send.test.ts +++ b/packages/cli/tests/e2e/agent-send.test.ts @@ -157,18 +157,18 @@ async function test_verify_logs(agentId: string): Promise { console.log('PASS: Agent logs show activity from both tasks') } -async function test_agent_stop(agentId: string): Promise { - console.log('\n--- Test: Stop agent ---') +async function test_agent_delete(agentId: string): Promise { + console.log('\n--- Test: Delete agent ---') - const result = await ctx.paseo(['stop', agentId]) + const result = await ctx.paseo(['delete', agentId]) console.log('Exit code:', result.exitCode) console.log('Stdout:', result.stdout) if (result.stderr) console.log('Stderr:', result.stderr) - assert.strictEqual(result.exitCode, 0, 'agent stop should succeed') + assert.strictEqual(result.exitCode, 0, 'agent delete should succeed') - console.log('PASS: Agent stopped successfully') + console.log('PASS: Agent deleted successfully') } async function main(): Promise { @@ -190,7 +190,7 @@ async function main(): Promise { await test_verify_logs(agentId) // Cleanup agent - await test_agent_stop(agentId) + await test_agent_delete(agentId) console.log('\n=== All agent-send E2E tests passed! ===') } catch (err) { diff --git a/packages/cli/tests/e2e/permissions.test.ts b/packages/cli/tests/e2e/permissions.test.ts index 8cbc4354c..9acf5bec5 100644 --- a/packages/cli/tests/e2e/permissions.test.ts +++ b/packages/cli/tests/e2e/permissions.test.ts @@ -215,18 +215,18 @@ async function test_agent_continues(agentId: string): Promise { console.log('PASS: Agent completed after permission was granted') } -async function test_agent_stop(agentId: string): Promise { - console.log('\n--- Test: Stop agent ---') +async function test_agent_delete(agentId: string): Promise { + console.log('\n--- Test: Delete agent ---') - const result = await ctx.paseo(['stop', agentId]) + const result = await ctx.paseo(['delete', agentId]) console.log('Exit code:', result.exitCode) console.log('Stdout:', result.stdout) if (result.stderr) console.log('Stderr:', result.stderr) - assert.strictEqual(result.exitCode, 0, 'agent stop should succeed') + assert.strictEqual(result.exitCode, 0, 'agent delete should succeed') - console.log('PASS: Agent stopped successfully') + console.log('PASS: Agent deleted successfully') } async function main(): Promise { @@ -270,10 +270,10 @@ async function main(): Promise { console.error(err) process.exitCode = 1 } finally { - // Always try to stop the agent if it was created + // Always try to delete the agent if it was created if (agentId) { try { - await test_agent_stop(agentId) + await test_agent_delete(agentId) } catch { // Ignore errors during cleanup }