[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
This commit is contained in:
Mohamed Boudra
2026-04-01 14:31:38 +08:00
committed by GitHub
parent 8d55764313
commit f53c770a71
22 changed files with 1253 additions and 131 deletions
@@ -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" }]);
});
});
@@ -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<ReturnType<DaemonClient["closeItems"]>>;
interface CloseWorkspaceTabWithCleanupInput {
tabId: string;
target?: WorkspaceTabDescriptor["target"];
}
interface CloseBulkWorkspaceTabsInput {
client: Pick<DaemonClient, "closeItems"> | null;
groups: BulkClosableTabGroups;
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
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<string> {
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
}
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
return new Set(
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
[],
);
}
export async function closeBulkWorkspaceTabs(
input: CloseBulkWorkspaceTabsInput,
): Promise<CloseItemsPayload | null> {
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;
}
@@ -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({
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
{tab.target.kind === "agent" ? (
<View style={styles.tooltipAgentRow}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
<Text style={styles.tooltipAgentId}>{tab.target.agentId.slice(0, 7)}</Text>
</View>
) : (
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
)}
</TooltipContent>
</Tooltip>
@@ -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 <Copy size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
return <ArrowRightToLine size={16} color={iconColor} />;
case "copy-x":
return <CopyX size={16} color={iconColor} />;
case "x":
return <X size={16} color={iconColor} />;
default:
return undefined;
}
})()}
trailing={
entry.hint ? (
<Text style={styles.menuItemHint}>{entry.hint}</Text>
) : undefined
}
>
{entry.label}
</ContextMenuItem>
@@ -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,
},
}));
@@ -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 <Copy size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
return <ArrowRightToLine size={16} color={iconColor} />;
case "copy-x":
return <CopyX size={16} color={iconColor} />;
case "x":
return <X size={16} color={iconColor} />;
default:
return undefined;
}
})()}
trailing={
entry.hint ? (
<Text style={styles.menuItemHint}>{entry.hint}</Text>
) : undefined
}
>
{entry.label}
</DropdownMenuItem>
@@ -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<ListTerminalsPayload>(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<ListTerminalsPayload>(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,
@@ -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);