diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 7f475a5a5..40400ed0d 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -119,7 +119,7 @@ function FilePreviewBody({ filePath, }: FilePreviewBodyProps) { const { theme } = useUnistyles(); - const isDark = theme.colors.surface0 === "#18181c"; + const isDark = theme.colors.surface0 === "#181B1A"; const colorMap = isDark ? darkHighlightColors : lightHighlightColors; const baseColor = isDark ? "#c9d1d9" : "#24292f"; diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index b50f84e7e..6cf0d59c9 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -86,7 +86,7 @@ interface HighlightedTextProps { function HighlightedText({ tokens, lineType }: HighlightedTextProps) { const { theme } = useUnistyles(); - const isDark = theme.colors.surface0 === "#18181c"; + const isDark = theme.colors.surface0 === "#181B1A"; // Get color for a highlight style const getTokenColor = (style: HighlightStyle | null): string => { diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx index 1e54de0d6..d8fb148a3 100644 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ b/packages/app/src/components/web-desktop-scrollbar.tsx @@ -350,7 +350,7 @@ export function WebDesktopScrollbarOverlay({ ? HANDLE_OPACITY_VISIBLE : 0; const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE; - const isDark = theme.colors.surface0 === "#18181c"; + const isDark = theme.colors.surface0 === "#181B1A"; const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700]; const handleCursor = isDragging ? "grabbing" : "grab"; const handleTravelDurationMs = diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index 95b5f3ee4..173aa9ba1 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -1,8 +1,46 @@ +import type { DaemonClient } from "@server/client/daemon-client"; import { QueryClient } from "@tanstack/react-query"; -import { describe, expect, it } from "vitest"; -import { __private__ } from "./use-archive-agent"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { Agent } from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; +import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent"; + +function makeAgent(overrides: Partial = {}): Agent { + return { + serverId: "server-a", + id: "agent-1", + provider: "codex", + status: "running", + createdAt: new Date("2026-04-01T03:00:00.000Z"), + updatedAt: new Date("2026-04-01T03:00:00.000Z"), + lastUserMessageAt: null, + lastActivityAt: new Date("2026-04-01T03:00:00.000Z"), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Agent 1", + cwd: "/repo", + model: null, + labels: {}, + archivedAt: null, + ...overrides, + }; +} describe("useArchiveAgent", () => { + beforeEach(() => { + useSessionStore.setState((state) => ({ ...state, sessions: {} })); + }); + it("tracks pending archive state in shared react-query cache", () => { const queryClient = new QueryClient(); @@ -63,4 +101,42 @@ describe("useArchiveAgent", () => { expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]); expect(next.pageInfo).toEqual({ hasMore: false }); }); + + it("applies archived agent close results to session state and cached lists", async () => { + const queryClient = new QueryClient(); + useSessionStore + .getState() + .initializeSession("server-a", {} as DaemonClient); + useSessionStore.getState().setAgents( + "server-a", + new Map([ + [ + "agent-1", + makeAgent(), + ], + ]), + ); + queryClient.setQueryData(["sidebarAgentsList", "server-a"], { + entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], + }); + queryClient.setQueryData(["allAgents", "server-a"], { + entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], + }); + + applyArchivedAgentCloseResults({ + queryClient, + serverId: "server-a", + results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }], + }); + + expect( + useSessionStore.getState().sessions["server-a"]?.agents.get("agent-1")?.archivedAt?.toISOString(), + ).toBe("2026-04-01T04:00:00.000Z"); + expect(queryClient.getQueryData(["sidebarAgentsList", "server-a"])).toEqual({ + entries: [{ agent: { id: "agent-2" } }], + }); + expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({ + entries: [{ agent: { id: "agent-2" } }], + }); + }); }); diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 1f4798bab..278319454 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -9,6 +9,11 @@ export interface ArchiveAgentInput { agentId: string; } +export interface ArchivedAgentCloseResult { + agentId: string; + archivedAt: string; +} + type ArchiveAgentPendingState = Record; interface SetAgentArchivingInput extends ArchiveAgentInput { @@ -130,6 +135,39 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin }); } +interface ApplyArchivedAgentCloseResultsInput { + queryClient: QueryClient; + serverId: string; + results: ArchivedAgentCloseResult[]; +} + +export function applyArchivedAgentCloseResults( + input: ApplyArchivedAgentCloseResultsInput, +): void { + if (input.results.length === 0) { + return; + } + + for (const result of input.results) { + markAgentArchivedInStore({ + serverId: input.serverId, + agentId: result.agentId, + archivedAt: result.archivedAt, + }); + removeAgentFromCachedLists(input.queryClient, { + serverId: input.serverId, + agentId: result.agentId, + }); + } + + void input.queryClient.invalidateQueries({ + queryKey: ["sidebarAgentsList", input.serverId], + }); + void input.queryClient.invalidateQueries({ + queryKey: ["allAgents", input.serverId], + }); +} + export function clearArchiveAgentPending(input: IsAgentArchivingInput): void { setAgentArchiving({ ...input, @@ -165,17 +203,10 @@ export function useArchiveAgent() { }); }, onSuccess: (result, input) => { - markAgentArchivedInStore({ + applyArchivedAgentCloseResults({ + queryClient, serverId: input.serverId, - agentId: input.agentId, - archivedAt: result.archivedAt, - }); - removeAgentFromCachedLists(queryClient, input); - void queryClient.invalidateQueries({ - queryKey: ["sidebarAgentsList", input.serverId], - }); - void queryClient.invalidateQueries({ - queryKey: ["allAgents", input.serverId], + results: [{ agentId: input.agentId, archivedAt: result.archivedAt }], }); }, onSettled: (_result, _error, input) => { diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts index 5ae5b19f3..e154824c4 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildBulkCloseConfirmationMessage, classifyBulkClosableTabs, + closeBulkWorkspaceTabs, } from "@/screens/workspace/workspace-bulk-close"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; @@ -71,4 +72,90 @@ describe("workspace bulk close helpers", () => { "This will close 1 terminal(s). Any running process in a closed terminal will be stopped immediately.", ); }); + + it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => { + const groups = classifyBulkClosableTabs([ + makeAgentTab("a1"), + makeTerminalTab("t1"), + makeTerminalTab("t2"), + makeFileTab("/repo/README.md"), + ]); + const closedTabIds: string[] = []; + const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = []; + const closeItems = vi.fn(async () => ({ + agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }], + terminals: [ + { terminalId: "t1", success: true }, + { terminalId: "t2", success: false }, + ], + requestId: "req-1", + })); + + const result = await closeBulkWorkspaceTabs({ + groups, + client: { closeItems }, + closeTab: async (tabId, action) => { + closedTabIds.push(tabId); + await action(); + }, + closeWorkspaceTabWithCleanup: (input) => { + cleanupCalls.push(input); + }, + logLabel: "all tabs", + }); + + expect(closeItems).toHaveBeenCalledTimes(1); + expect(closeItems).toHaveBeenCalledWith({ + agentIds: ["a1"], + terminalIds: ["t1", "t2"], + }); + expect(result).toEqual({ + agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }], + terminals: [ + { terminalId: "t1", success: true }, + { terminalId: "t2", success: false }, + ], + requestId: "req-1", + }); + expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]); + expect(cleanupCalls).toEqual([ + { tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } }, + { tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } }, + { tabId: "file_/repo/README.md" }, + ]); + }); + + it("still closes passive tabs when the mixed closeItems RPC fails", async () => { + const groups = classifyBulkClosableTabs([ + makeAgentTab("a1"), + makeTerminalTab("t1"), + makeFileTab("/repo/README.md"), + ]); + const closedTabIds: string[] = []; + const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = []; + const warn = vi.fn(); + + const result = await closeBulkWorkspaceTabs({ + groups, + client: { + closeItems: async () => { + throw new Error("rpc failed"); + }, + }, + closeTab: async (tabId, action) => { + closedTabIds.push(tabId); + await action(); + }, + closeWorkspaceTabWithCleanup: (input) => { + cleanupCalls.push(input); + }, + warn, + logLabel: "others", + }); + + expect(warn).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + expect(closedTabIds).toEqual(["file_/repo/README.md"]); + expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.ts b/packages/app/src/screens/workspace/workspace-bulk-close.ts index 3b0ba9bf1..70b662d12 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.ts @@ -1,3 +1,4 @@ +import type { DaemonClient } from "@server/client/daemon-client"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; export type BulkClosableTabGroups = { @@ -6,6 +7,22 @@ export type BulkClosableTabGroups = { otherTabs: Array<{ tabId: string }>; }; +type CloseItemsPayload = Awaited>; + +interface CloseWorkspaceTabWithCleanupInput { + tabId: string; + target?: WorkspaceTabDescriptor["target"]; +} + +interface CloseBulkWorkspaceTabsInput { + client: Pick | null; + groups: BulkClosableTabGroups; + closeTab: (tabId: string, action: () => Promise) => Promise; + closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void; + logLabel: string; + warn?: (message: string, payload: object) => void; +} + export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups { const groups: BulkClosableTabGroups = { agentTabs: [], @@ -50,3 +67,72 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups): } return `This will archive ${agentTabs.length} agent(s).`; } + +function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set { + return new Set(payload?.agents.map((agent) => agent.agentId) ?? []); +} + +function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set { + return new Set( + payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ?? + [], + ); +} + +export async function closeBulkWorkspaceTabs( + input: CloseBulkWorkspaceTabsInput, +): Promise { + const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input; + const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0; + let payload: CloseItemsPayload | null = null; + + if (hasDestructiveTabs && client) { + try { + payload = await client.closeItems({ + agentIds: groups.agentTabs.map((tab) => tab.agentId), + terminalIds: groups.terminalTabs.map((tab) => tab.terminalId), + }); + } catch (error) { + warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error }); + } + } else if (hasDestructiveTabs) { + warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { + error: new Error("Daemon client not available"), + }); + } + + const successfulAgentIds = toSuccessfulAgentIds(payload); + const successfulTerminalIds = toSuccessfulTerminalIds(payload); + + for (const { tabId, agentId } of groups.agentTabs) { + if (!successfulAgentIds.has(agentId)) { + continue; + } + await closeTab(tabId, async () => { + closeWorkspaceTabWithCleanup({ + tabId, + target: { kind: "agent", agentId }, + }); + }); + } + + for (const { tabId, terminalId } of groups.terminalTabs) { + if (!successfulTerminalIds.has(terminalId)) { + continue; + } + await closeTab(tabId, async () => { + closeWorkspaceTabWithCleanup({ + tabId, + target: { kind: "terminal", terminalId }, + }); + }); + } + + for (const { tabId } of groups.otherTabs) { + await closeTab(tabId, async () => { + closeWorkspaceTabWithCleanup({ tabId }); + }); + } + + return payload; +} 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 851aab132..fe69bc008 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -8,7 +8,17 @@ import { View, type LayoutChangeEvent, } from "react-native"; -import { Columns2, Rows2, SquarePen, SquareTerminal, X } from "lucide-react-native"; +import { + CopyX, + ArrowLeftToLine, + ArrowRightToLine, + Columns2, + Copy, + Rows2, + SquarePen, + SquareTerminal, + X, +} from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { SortableInlineList } from "@/components/sortable-inline-list"; import { @@ -258,7 +268,14 @@ function TabChip({ - {tooltipLabel} + {tab.target.kind === "agent" ? ( + + {tooltipLabel} + {tab.target.agentId.slice(0, 7)} + + ) : ( + {tooltipLabel} + )} @@ -273,6 +290,28 @@ function TabChip({ disabled={entry.disabled} destructive={entry.destructive} onSelect={entry.onSelect} + leading={(() => { + const iconColor = theme.colors.foregroundMuted; + switch (entry.icon) { + case "copy": + return ; + case "arrow-left-to-line": + return ; + case "arrow-right-to-line": + return ; + case "copy-x": + return ; + case "x": + return ; + default: + return undefined; + } + })()} + trailing={ + entry.hint ? ( + {entry.hint} + ) : undefined + } > {entry.label} @@ -792,4 +831,17 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface3, borderColor: theme.colors.borderAccent, }, + tooltipAgentRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + tooltipAgentId: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + menuItemHint: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, })); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index e9b9c9fe9..a6311e0f5 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -14,6 +14,9 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; import { + CopyX, + ArrowLeftToLine, + ArrowRightToLine, ChevronDown, Copy, Ellipsis, @@ -21,6 +24,7 @@ import { PanelRight, SquarePen, SquareTerminal, + X, } from "lucide-react-native"; import { GestureDetector } from "react-native-gesture-handler"; import Animated from "react-native-reanimated"; @@ -73,7 +77,7 @@ import { import type { ListTerminalsResponse } from "@server/shared/messages"; import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { confirmDialog } from "@/utils/confirm-dialog"; -import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-archive-agent"; import { useStableEvent } from "@/hooks/use-stable-event"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; @@ -103,6 +107,7 @@ import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; import { buildBulkCloseConfirmationMessage, classifyBulkClosableTabs, + closeBulkWorkspaceTabs, } from "@/screens/workspace/workspace-bulk-close"; import { findAdjacentPane } from "@/utils/split-navigation"; @@ -332,6 +337,28 @@ function MobileWorkspaceTabOption({ disabled={entry.disabled} destructive={entry.destructive} onSelect={entry.onSelect} + leading={(() => { + const iconColor = theme.colors.foregroundMuted; + switch (entry.icon) { + case "copy": + return ; + case "arrow-left-to-line": + return ; + case "arrow-right-to-line": + return ; + case "copy-x": + return ; + case "x": + return ; + default: + return undefined; + } + })()} + trailing={ + entry.hint ? ( + {entry.hint} + ) : undefined + } > {entry.label} @@ -1381,62 +1408,45 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return; } - for (const { tabId, terminalId } of groups.terminalTabs) { - await closeTab(tabId, async () => { - try { - await killTerminalAsync(terminalId); - queryClient.setQueryData(terminalsQueryKey, (current) => { - if (!current) { - return current; - } - return { - ...current, - terminals: current.terminals.filter((terminal) => terminal.id !== terminalId), - }; - }); - if (persistenceKey) { - closeWorkspaceTabWithCleanup({ - tabId, - target: { kind: "terminal", terminalId }, - }); - } - } catch (error) { - console.warn(`[WorkspaceScreen] Failed to close terminal tab ${logLabel}`, { - terminalId, - error, - }); + const closeItemsPayload = await closeBulkWorkspaceTabs({ + client, + groups, + closeTab, + closeWorkspaceTabWithCleanup: (cleanupInput) => { + if (!persistenceKey) { + return; } - }); - } + closeWorkspaceTabWithCleanup(cleanupInput); + }, + logLabel, + warn: (message, payload) => { + console.warn(message, payload); + }, + }); - for (const { tabId, agentId } of groups.agentTabs) { - if (!normalizedServerId) { - continue; + if (closeItemsPayload) { + for (const terminal of closeItemsPayload.terminals) { + if (!terminal.success) { + continue; + } + queryClient.setQueryData(terminalsQueryKey, (current) => { + if (!current) { + return current; + } + return { + ...current, + terminals: current.terminals.filter((entry) => entry.id !== terminal.terminalId), + }; + }); } - await closeTab(tabId, async () => { - try { - await archiveAgent({ serverId: normalizedServerId, agentId }); - if (persistenceKey) { - closeWorkspaceTabWithCleanup({ - tabId, - target: { kind: "agent", agentId }, - }); - } - } catch (error) { - console.warn(`[WorkspaceScreen] Failed to archive agent tab ${logLabel}`, { - agentId, - error, - }); - } - }); - } - for (const { tabId } of groups.otherTabs) { - await closeTab(tabId, async () => { - if (persistenceKey) { - closeWorkspaceTabWithCleanup({ tabId }); - } - }); + if (normalizedServerId) { + applyArchivedAgentCloseResults({ + queryClient, + serverId: normalizedServerId, + results: closeItemsPayload.agents, + }); + } } const closedKeys = new Set(tabsToClose.map((tab) => tab.key)); @@ -1444,10 +1454,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current)); }, [ - archiveAgent, + client, closeTab, closeWorkspaceTabWithCleanup, - killTerminalAsync, normalizedServerId, persistenceKey, queryClient, @@ -2421,6 +2430,10 @@ const styles = StyleSheet.create((theme) => ({ mobileTabMenuTriggerActive: { backgroundColor: theme.colors.surface2, }, + menuItemHint: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, tabsContainer: { borderBottomWidth: 1, borderBottomColor: theme.colors.border, diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index bb4039712..fea7255f4 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -8,6 +8,8 @@ export type WorkspaceTabMenuEntry = kind: "item"; key: string; label: string; + icon?: "copy" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x"; + hint?: string; disabled?: boolean; destructive?: boolean; testID: string; @@ -106,6 +108,7 @@ export function buildWorkspaceTabMenuEntries( kind: "item", key: "copy-resume-command", label: "Copy resume command", + icon: "copy", testID: `${menuTestIDBase}-copy-resume-command`, onSelect: () => { void onCopyResumeCommand(agentId); @@ -115,6 +118,8 @@ export function buildWorkspaceTabMenuEntries( kind: "item", key: "copy-agent-id", label: "Copy agent id", + icon: "copy", + hint: agentId.slice(0, 7), testID: `${menuTestIDBase}-copy-agent-id`, onSelect: () => { void onCopyAgentId(agentId); @@ -130,6 +135,7 @@ export function buildWorkspaceTabMenuEntries( kind: "item", key: "close-before", label: buildCloseBeforeLabel(surface), + icon: "arrow-left-to-line", disabled: isFirstTab, testID: `${menuTestIDBase}-${buildCloseBeforeTestIDSuffix(surface)}`, onSelect: () => { @@ -140,6 +146,7 @@ export function buildWorkspaceTabMenuEntries( kind: "item", key: "close-after", label: buildCloseAfterLabel(surface), + icon: "arrow-right-to-line", disabled: isLastTab, testID: `${menuTestIDBase}-${buildCloseAfterTestIDSuffix(surface)}`, onSelect: () => { @@ -150,6 +157,7 @@ export function buildWorkspaceTabMenuEntries( kind: "item", key: "close-others", label: "Close other tabs", + icon: "copy-x", disabled: isOnlyTab, testID: `${menuTestIDBase}-close-others`, onSelect: () => { @@ -160,6 +168,7 @@ export function buildWorkspaceTabMenuEntries( kind: "item", key: "close", label: "Close", + icon: "x", testID: `${menuTestIDBase}-close`, onSelect: () => { void onCloseTab(tab.tabId); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 93434335b..31c08c83c 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -175,21 +175,21 @@ const lightSemanticColors = { } as const; const darkSemanticColors = { - // Surfaces (layers) - surface0: "#18181c", // App background - surface1: "#1f1f23", // Subtle hover - surface2: "#27272a", // Elevated: badges, inputs, sheets - surface3: "#3f3f46", // Highest elevation - surface4: "#52525b", // Extra emphasis - surfaceSidebar: "#121216", // Sidebar background (darker than main) + // Surfaces (layers) — subtle teal tint + surface0: "#181B1A", // App background + surface1: "#1E2120", // Subtle hover + surface2: "#272A29", // Elevated: badges, inputs, sheets + surface3: "#434645", // Highest elevation + surface4: "#595B5B", // Extra emphasis + surfaceSidebar: "#141716", // Sidebar background (darker than main) // Text foreground: "#fafafa", - foregroundMuted: "#a1a1aa", + foregroundMuted: "#A1A5A4", // Borders - border: "#27272a", - borderAccent: "#34343a", + border: "#252B2A", + borderAccent: "#2F3534", // Brand accent: "#20744A", @@ -203,28 +203,28 @@ const darkSemanticColors = { successForeground: "#ffffff", // Legacy aliases (for gradual migration) - background: "#18181c", - popover: "#27272a", + background: "#181B1A", + popover: "#272A29", popoverForeground: "#fafafa", primary: "#fafafa", - primaryForeground: "#18181b", - secondary: "#27272a", + primaryForeground: "#181B1A", + secondary: "#272A29", secondaryForeground: "#fafafa", - muted: "#27272a", - mutedForeground: "#a1a1aa", - accentBorder: "#34343a", - input: "#27272a", + muted: "#272A29", + mutedForeground: "#A1A5A4", + accentBorder: "#2F3534", + input: "#272A29", ring: "#d4d4d8", terminal: { - background: "#18181c", + background: "#181B1A", foreground: "#fafafa", cursor: "#fafafa", - cursorAccent: "#18181c", + cursorAccent: "#181B1A", selectionBackground: "rgba(255, 255, 255, 0.2)", selectionForeground: "#fafafa", - black: "#121214", + black: "#141716", red: "#ef4444", green: "#22c55e", yellow: "#f59e0b", @@ -233,7 +233,7 @@ const darkSemanticColors = { cyan: "#06b6d4", white: "#e4e4e7", - brightBlack: "#3f3f46", + brightBlack: "#434645", brightRed: "#f87171", brightGreen: "#4ade80", brightYellow: "#fbbf24", diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index eed79a7e0..144032ed8 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1927,6 +1927,59 @@ describe("DaemonClient", () => { ]); }); + test("sends close_items_request and resolves close_items_response", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.closeItems( + { + agentIds: ["agent-1"], + terminalIds: ["term-1"], + }, + "req-close-items", + ); + + expect(JSON.parse(String(mock.sent[0]))).toEqual({ + type: "session", + message: { + type: "close_items_request", + agentIds: ["agent-1"], + terminalIds: ["term-1"], + requestId: "req-close-items", + }, + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "close_items_response", + payload: { + agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }], + terminals: [{ terminalId: "term-1", success: true }], + requestId: "req-close-items", + }, + }), + ); + + await expect(responsePromise).resolves.toEqual({ + agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }], + terminals: [{ terminalId: "term-1", success: true }], + requestId: "req-close-items", + }); + }); + test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline", async () => { vi.useFakeTimers(); try { diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 13d35ab3e..8f1c1178e 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -43,6 +43,7 @@ import type { CreateTerminalResponse, SubscribeTerminalResponse, TerminalState, + CloseItemsResponse, KillTerminalResponse, CaptureTerminalResponse, TerminalInput, @@ -237,6 +238,7 @@ type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; +type CloseItemsPayload = CloseItemsResponse["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"]; type CaptureTerminalPayload = CaptureTerminalResponse["payload"]; type ChatCreatePayload = Extract< @@ -2829,6 +2831,26 @@ export class DaemonClient { }); } + async closeItems( + input: { agentIds?: string[]; terminalIds?: string[] }, + requestId?: string, + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "close_items_request", + agentIds: input.agentIds ?? [], + terminalIds: input.terminalIds ?? [], + requestId: resolvedRequestId, + }); + return this.sendCorrelatedRequest({ + requestId: resolvedRequestId, + message, + responseType: "close_items_response", + timeout: 10000, + options: { skipQueue: true }, + }); + } + async captureTerminal( terminalId: string, options?: { start?: number; end?: number; stripAnsi?: boolean }, diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 1c018400b..c5214e8aa 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -792,6 +792,52 @@ export function translateOpenCodeEvent( break; } + case "message.part.delta": { + const deltaSessionId = props.sessionID as string | undefined; + if (deltaSessionId !== state.sessionId) { + break; + } + + const deltaMessageId = props.messageID as string | undefined; + const deltaMessageRole = deltaMessageId + ? state.messageRoles.get(deltaMessageId) + : undefined; + const deltaField = props.field as string | undefined; + const deltaText = props.delta as string | undefined; + + if (!deltaText || !deltaField) { + break; + } + + if (deltaField === "text") { + if (deltaMessageRole === "user") { + break; + } + const partId = props.partID as string | undefined; + const partKey = partId ? `text:${partId}` : null; + if (partKey) { + state.streamedPartKeys.add(partKey); + } + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: deltaText }, + }); + } else if (deltaField === "reasoning") { + const partId = props.partID as string | undefined; + const partKey = partId ? `reasoning:${partId}` : null; + if (partKey) { + state.streamedPartKeys.add(partKey); + } + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "reasoning", text: deltaText }, + }); + } + break; + } + case "permission.asked": { const sessionId = props.sessionID as string | undefined; if (sessionId !== state.sessionId) { @@ -1095,7 +1141,6 @@ class OpenCodeAgentSession implements AgentSession { const turnId = this.createTurnId(); this.activeForegroundTurnId = turnId; - void this.consumeEventStream(); return { turnId }; diff --git a/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts new file mode 100644 index 000000000..869cac313 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; + +describe("OpenCode assistant message", () => { + test.runIf(isProviderAvailable("opencode"))( + "assistant_message appears in live stream with opencode/big-pickle", + async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-")); + const logger = pino({ level: "silent" }); + const client = new OpenCodeAgentClient(logger); + + try { + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "opencode/big-pickle", + modeId: "build", + }); + + const result = await session.run("Say hello back in one sentence."); + + const assistantItems = result.timeline.filter( + (item) => item.type === "assistant_message", + ); + expect(assistantItems.length).toBeGreaterThan(0); + expect(result.finalText.length).toBeGreaterThan(0); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, + ); + + test.runIf(isProviderAvailable("opencode"))( + "streamHistory returns assistant_message after a completed turn", + async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-")); + const logger = pino({ level: "silent" }); + const client = new OpenCodeAgentClient(logger); + + try { + const session = await client.createSession({ + provider: "opencode", + cwd, + }); + + const result = await session.run("Say hello back in one sentence."); + expect(result.timeline.some((item) => item.type === "assistant_message")).toBe(true); + + const historyEvents: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + historyEvents.push(event); + } + + const historyAssistant = historyEvents.filter( + (e) => e.type === "timeline" && e.item.type === "assistant_message", + ); + expect(historyAssistant.length).toBeGreaterThan(0); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, + ); + +}); diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 9d3a3a9cc..ccc0cc9f8 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -170,6 +170,199 @@ describe("translateOpenCodeEvent", () => { ]); }); + it("emits assistant text from message.part.delta events", () => { + const state = createState(); + + // Register message role + translateOpenCodeEvent( + { + type: "message.updated", + properties: { + info: { id: "msg-d1", sessionID: "session-1", role: "assistant" }, + }, + }, + state, + ); + + // OpenCode v2 can send streaming text as message.part.delta + const delta1 = translateOpenCodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "session-1", + messageID: "msg-d1", + partID: "part-d1", + field: "text", + delta: "hey! ", + }, + }, + state, + ); + + const delta2 = translateOpenCodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "session-1", + messageID: "msg-d1", + partID: "part-d1", + field: "text", + delta: "what's up?", + }, + }, + state, + ); + + expect([...delta1, ...delta2]).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: "hey! " }, + }, + { + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: "what's up?" }, + }, + ]); + }); + + it("emits reasoning from message.part.delta events", () => { + const state = createState(); + + const delta = translateOpenCodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "session-1", + messageID: "msg-r1", + partID: "rp-1", + field: "reasoning", + delta: "The user said hello.", + }, + }, + state, + ); + + expect(delta).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { type: "reasoning", text: "The user said hello." }, + }, + ]); + }); + + it("deduplicates when message.part.delta is followed by completed message.part.updated", () => { + const state = createState(); + + translateOpenCodeEvent( + { + type: "message.updated", + properties: { + info: { id: "msg-dd1", sessionID: "session-1", role: "assistant" }, + }, + }, + state, + ); + + // Stream via delta event + const streamed = translateOpenCodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "session-1", + messageID: "msg-dd1", + partID: "part-dd1", + field: "text", + delta: "hello there", + }, + }, + state, + ); + + // Completed part echoes the same text + const completed = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "part-dd1", + sessionID: "session-1", + messageID: "msg-dd1", + type: "text", + text: "hello there", + time: { start: 1, end: 2 }, + }, + }, + }, + state, + ); + + const all = [...streamed, ...completed].filter( + (e) => e.type === "timeline" && e.item.type === "assistant_message", + ); + // Only the delta, not the completed echo + expect(all).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: "hello there" }, + }, + ]); + }); + + it("ignores message.part.delta for wrong session", () => { + const state = createState(); + + const result = translateOpenCodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "other-session", + messageID: "msg-1", + partID: "part-1", + field: "text", + delta: "should not appear", + }, + }, + state, + ); + + expect(result).toEqual([]); + }); + + it("ignores message.part.delta for user messages", () => { + const state = createState(); + + // Register as user message + translateOpenCodeEvent( + { + type: "message.updated", + properties: { + info: { id: "msg-u1", sessionID: "session-1", role: "user" }, + }, + }, + state, + ); + + const result = translateOpenCodeEvent( + { + type: "message.part.delta", + properties: { + sessionID: "session-1", + messageID: "msg-u1", + partID: "part-u1", + field: "text", + delta: "user typing", + }, + }, + state, + ); + + expect(result).toEqual([]); + }); + it("emits structured assistant output when schema mode completes without text parts", () => { const state = createState(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ee1ac2af9..6339211f3 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -22,6 +22,7 @@ import { type SubscribeTerminalRequest, type UnsubscribeTerminalRequest, type TerminalInput, + type CloseItemsRequest, type KillTerminalRequest, type CaptureTerminalRequest, type SubscribeCheckoutDiffRequest, @@ -1474,6 +1475,10 @@ export class Session { await this.handleArchiveAgentRequest(msg.agentId, msg.requestId); break; + case "close_items_request": + await this.handleCloseItemsRequest(msg); + break; + case "update_agent_request": await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId); break; @@ -1986,6 +1991,20 @@ export class Session { } private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise { + const result = await this.archiveAgentForClose(agentId); + this.emit({ + type: "agent_archived", + payload: { + agentId: result.agentId, + archivedAt: result.archivedAt, + requestId, + }, + }); + } + + private async archiveAgentForClose( + agentId: string, + ): Promise<{ agentId: string; archivedAt: string }> { this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`); if (this.agentManager.getAgent(agentId)) { @@ -2023,12 +2042,44 @@ export class Session { await this.emitWorkspaceUpdateForCwd(payload.cwd); } + return { agentId, archivedAt }; + } + + private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise { + const agents = []; + for (const agentId of msg.agentIds) { + try { + agents.push(await this.archiveAgentForClose(agentId)); + } catch (error: any) { + this.sessionLogger.warn( + { err: error, agentId, requestId: msg.requestId }, + "Failed to archive agent during close_items batch", + ); + } + } + + const terminals = []; + for (const terminalId of msg.terminalIds) { + try { + terminals.push(this.killTerminalForClose(terminalId)); + } catch (error: any) { + this.sessionLogger.warn( + { err: error, terminalId, requestId: msg.requestId }, + "Failed to kill terminal during close_items batch", + ); + terminals.push({ + terminalId, + success: false, + }); + } + } + this.emit({ - type: "agent_archived", + type: "close_items_response", payload: { - agentId, - archivedAt, - requestId, + agents, + terminals, + requestId: msg.requestId, }, }); } @@ -7491,29 +7542,32 @@ export class Session { } private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise { - if (!this.terminalManager) { - this.emit({ - type: "kill_terminal_response", - payload: { - terminalId: msg.terminalId, - success: false, - requestId: msg.requestId, - }, - }); - return; - } - - this.killTrackedTerminal(msg.terminalId, { emitExit: true }); + const result = this.killTerminalForClose(msg.terminalId); this.emit({ type: "kill_terminal_response", payload: { - terminalId: msg.terminalId, - success: true, + terminalId: result.terminalId, + success: result.success, requestId: msg.requestId, }, }); } + private killTerminalForClose(terminalId: string): { terminalId: string; success: boolean } { + if (!this.terminalManager) { + return { + terminalId, + success: false, + }; + } + + this.killTrackedTerminal(terminalId, { emitExit: true }); + return { + terminalId, + success: true, + }; + } + private async handleCaptureTerminalRequest(msg: CaptureTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index bd6442633..4f85436ca 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -272,6 +272,304 @@ describe("workspace aggregation", () => { }); }); + test("close_items_request archives agents and kills terminals in one batch", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const archivedAt = "2026-04-01T00:00:00.000Z"; + const sessionLogger = { + child: () => sessionLogger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const archivedRecord = { + id: "agent-1", + provider: "codex", + cwd: "/tmp/repo", + model: null, + thinkingOptionId: null, + effectiveThinkingOptionId: null, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { provider: "codex", sessionId: null }, + title: null, + labels: {}, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + }; + const session = new Session({ + clientId: "test-client", + onMessage: (message) => emitted.push(message as any), + logger: sessionLogger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null), + archiveAgent: async () => ({ archivedAt }), + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, + } as any, + agentStorage: { + list: async () => [], + get: async (agentId: string) => { + if (agentId !== "agent-1") { + return null; + } + archivedRecord.archivedAt = archivedAt; + archivedRecord.updatedAt = archivedAt; + return archivedRecord; + }, + } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + checkoutDiffManager: { + subscribe: async () => ({ + initial: { cwd: "/tmp", files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + } as any, + createAgentMcpTransport: async () => { + throw new Error("not used"); + }, + stt: null, + tts: null, + terminalManager: { + killTerminal: vi.fn(), + subscribeTerminalsChanged: () => () => {}, + } as any, + }) as any; + + session.agentUpdatesSubscription = { + subscriptionId: "sub-agents", + filter: { includeArchived: true }, + isBootstrapping: false, + pendingUpdatesByAgentId: new Map(), + }; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + session.interruptAgentIfRunning = vi.fn(); + + await session.handleMessage({ + type: "close_items_request", + agentIds: ["agent-1"], + terminalIds: ["term-1"], + requestId: "req-close-items", + }); + + expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1"); + expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1"); + expect( + emitted.find((message) => message.type === "close_items_response")?.payload, + ).toEqual({ + agents: [{ agentId: "agent-1", archivedAt }], + terminals: [{ terminalId: "term-1", success: true }], + requestId: "req-close-items", + }); + expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ + kind: "upsert", + agent: { + id: "agent-1", + archivedAt, + }, + }); + }); + + test("close_items_request continues after an archive failure", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const sessionLogger = { + child: () => sessionLogger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const archivedAt = "2026-04-01T00:00:00.000Z"; + const goodRecord = { + ...makeAgent({ + id: "agent-good", + cwd: "/tmp/repo", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + archivedAt: null as string | null, + }; + const session = new Session({ + clientId: "test-client", + onMessage: (message) => emitted.push(message as any), + logger: sessionLogger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: (agentId: string) => + agentId === "agent-bad" || agentId === "agent-good" ? { id: agentId } : null, + archiveAgent: async (agentId: string) => { + if (agentId === "agent-bad") { + throw new Error("archive failed"); + } + return { archivedAt }; + }, + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, + } as any, + agentStorage: { + list: async () => [], + get: async (agentId: string) => { + if (agentId !== "agent-good") { + return null; + } + goodRecord.archivedAt = archivedAt; + goodRecord.updatedAt = archivedAt; + return goodRecord; + }, + } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + checkoutDiffManager: { + subscribe: async () => ({ + initial: { cwd: "/tmp", files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + } as any, + createAgentMcpTransport: async () => { + throw new Error("not used"); + }, + stt: null, + tts: null, + terminalManager: { + killTerminal: vi.fn(), + subscribeTerminalsChanged: () => () => {}, + } as any, + }) as any; + + session.agentUpdatesSubscription = { + subscriptionId: "sub-agents", + filter: { includeArchived: true }, + isBootstrapping: false, + pendingUpdatesByAgentId: new Map(), + }; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + session.interruptAgentIfRunning = vi.fn(); + + await session.handleMessage({ + type: "close_items_request", + agentIds: ["agent-bad", "agent-good"], + terminalIds: ["term-1"], + requestId: "req-close-best-effort", + }); + + expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-bad"); + expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-good"); + expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1"); + expect( + emitted.find((message) => message.type === "close_items_response")?.payload, + ).toEqual({ + agents: [{ agentId: "agent-good", archivedAt }], + terminals: [{ terminalId: "term-1", success: true }], + requestId: "req-close-best-effort", + }); + expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ + kind: "upsert", + agent: { + id: "agent-good", + archivedAt, + }, + }); + expect(sessionLogger.warn).toHaveBeenCalled(); + }); + test("non-git workspace uses deterministic directory name and no unknown branch fallback", async () => { const session = createSessionForWorkspaceTests() as any; session.workspaceRegistry.list = async () => [ diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 84f771030..55ffa15af 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -525,6 +525,13 @@ export const ArchiveAgentRequestMessageSchema = z.object({ requestId: z.string(), }); +export const CloseItemsRequestMessageSchema = z.object({ + type: z.literal("close_items_request"), + agentIds: z.array(z.string()).default([]), + terminalIds: z.array(z.string()).default([]), + requestId: z.string(), +}); + export const UpdateAgentRequestMessageSchema = z.object({ type: z.literal("update_agent_request"), agentId: z.string(), @@ -1190,6 +1197,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentRequestMessageSchema, DeleteAgentRequestMessageSchema, ArchiveAgentRequestMessageSchema, + CloseItemsRequestMessageSchema, UpdateAgentRequestMessageSchema, SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, @@ -1805,6 +1813,25 @@ export const AgentArchivedMessageSchema = z.object({ }), }); +const CloseItemsAgentResultSchema = z.object({ + agentId: z.string(), + archivedAt: z.string(), +}); + +const CloseItemsTerminalResultSchema = z.object({ + terminalId: z.string(), + success: z.boolean(), +}); + +export const CloseItemsResponseSchema = z.object({ + type: z.literal("close_items_response"), + payload: z.object({ + agents: z.array(CloseItemsAgentResultSchema), + terminals: z.array(CloseItemsTerminalResultSchema), + requestId: z.string(), + }), +}); + const AheadBehindSchema = z.object({ ahead: z.number(), behind: z.number(), @@ -2270,6 +2297,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentPermissionResolvedMessageSchema, AgentDeletedMessageSchema, AgentArchivedMessageSchema, + CloseItemsResponseSchema, CheckoutStatusResponseSchema, SubscribeCheckoutDiffResponseSchema, CheckoutDiffUpdateSchema, @@ -2487,6 +2515,8 @@ export type TerminalCell = z.infer; export type TerminalCursorStyle = z.infer; export type TerminalCursor = z.infer; export type TerminalState = z.infer; +export type CloseItemsRequest = z.infer; +export type CloseItemsResponse = z.infer; export type KillTerminalRequest = z.infer; export type KillTerminalResponse = z.infer; export type CaptureTerminalRequest = z.infer; diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 9cea3e24c..6d2b21ffe 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -54,7 +54,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { {/* Content section */} -
+
diff --git a/packages/website/src/routes/__root.tsx b/packages/website/src/routes/__root.tsx index 74304907a..fe7db8cb2 100644 --- a/packages/website/src/routes/__root.tsx +++ b/packages/website/src/routes/__root.tsx @@ -6,7 +6,7 @@ export const Route = createRootRoute({ meta: [ { charSet: "utf-8" }, { name: "viewport", content: "width=device-width, initial-scale=1" }, - { name: "theme-color", content: "#0a0a0a" }, + { name: "theme-color", content: "#101615" }, { property: "og:site_name", content: "Paseo" }, { property: "og:type", content: "website" }, { property: "og:image", content: "https://paseo.sh/og-image.png" }, diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index cfc36d171..c54937692 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -187,27 +187,27 @@ } @theme { - --color-background: #0a0a0a; + --color-background: #101615; --color-foreground: #fafafa; - --color-muted: #262626; - --color-muted-foreground: #b6b6b6; - --color-card: #171717; - --color-border: #27272a; + --color-muted: #252B2A; + --color-muted-foreground: #A8ADAC; + --color-card: #171D1C; + --color-border: #252B2A; --color-primary: #3b82f6; --color-primary-foreground: #ffffff; - --color-secondary: #27272a; + --color-secondary: #252B2A; --color-secondary-foreground: #fafafa; /* Mockup palette (from app unistyles theme) */ - --color-mock-surface0: #18181c; - --color-mock-surface1: #1f1f23; - --color-mock-surface2: #27272a; - --color-mock-surface3: #3f3f46; - --color-mock-sidebar: #121216; + --color-mock-surface0: #181B1A; + --color-mock-surface1: #1E2120; + --color-mock-surface2: #272A29; + --color-mock-surface3: #434645; + --color-mock-sidebar: #141716; --color-mock-fg: #fafafa; - --color-mock-fg-muted: #a1a1aa; - --color-mock-border: #27272a; - --color-mock-border-accent: #34343a; + --color-mock-fg-muted: #A1A5A4; + --color-mock-border: #272A29; + --color-mock-border-accent: #313433; --color-mock-accent: #20744A; --color-mock-green: #22c55e; --color-mock-green-400: #4ade80;