From f53c770a71651b46f5df54aaa718d53c13e8b589 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 1 Apr 2026 13:31:38 +0700 Subject: [PATCH] [codex] add batch close rpc for workspace tabs (#165) * style: add subtle teal tint to dark mode surfaces Replace neutral gray surfaces with a teal-tinted palette across the app and website, giving Paseo a warmer, more recognizable feel. App uses a restrained tint (G-R ~3), website is slightly stronger (G-R ~6) as a brand showcase. * fix(opencode): handle message.part.delta events for assistant text streaming OpenCode v2 streams assistant text via `message.part.delta` events (with field "text" or "reasoning"), but the translator only handled `message.part.updated`. This caused assistant messages to be silently dropped during live streaming. * feat: show agent short ID in tab context menu and tooltip (#161) Add agent short ID to the "Copy agent id" menu item as trailing hint text and to the tab tooltip next to the title. Add leading icons to all workspace tab context menu items. * add batch close rpc for workspace tabs --- packages/app/src/components/file-pane.tsx | 2 +- packages/app/src/components/git-diff-pane.tsx | 2 +- .../src/components/web-desktop-scrollbar.tsx | 2 +- .../app/src/hooks/use-archive-agent.test.ts | 80 ++++- packages/app/src/hooks/use-archive-agent.ts | 51 ++- .../workspace/workspace-bulk-close.test.ts | 89 +++++- .../screens/workspace/workspace-bulk-close.ts | 86 +++++ .../workspace/workspace-desktop-tabs-row.tsx | 56 +++- .../screens/workspace/workspace-screen.tsx | 123 ++++---- .../screens/workspace/workspace-tab-menu.ts | 9 + packages/app/src/styles/theme.ts | 44 +-- .../server/src/client/daemon-client.test.ts | 53 ++++ packages/server/src/client/daemon-client.ts | 22 ++ .../server/agent/providers/opencode-agent.ts | 47 ++- ...pencode-assistant-message.real.e2e.test.ts | 73 +++++ .../opencode/event-translator.test.ts | 193 ++++++++++++ packages/server/src/server/session.ts | 92 ++++-- .../src/server/session.workspaces.test.ts | 298 ++++++++++++++++++ packages/server/src/shared/messages.ts | 30 ++ .../website/src/components/landing-page.tsx | 2 +- packages/website/src/routes/__root.tsx | 2 +- packages/website/src/styles.css | 28 +- 22 files changed, 1253 insertions(+), 131 deletions(-) create mode 100644 packages/server/src/server/agent/providers/opencode-assistant-message.real.e2e.test.ts 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;