refactor: remove terminal agent concept entirely

Remove the "agent can be a terminal" branching from the entire codebase.
An agent is now always a session-backed chat agent. Standalone terminal
infrastructure (terminal component, ANSI handling, terminal-stream-protocol)
is preserved.

Server: delete ManagedTerminalAgent, AgentKind, TerminalExitDetails,
launchTerminalAgent, registerTerminalAgent, handleTerminalAgentExited,
supportsTerminalMode capability, buildTerminalCreate/ResumeCommand from
all providers, terminal agent persistence/projections.

App: delete terminal-agent-panel.tsx, terminal-agent-reopen-store.ts,
terminal/terminalExit fields on agent state, "Terminal Agents" launcher
section, terminal-agent workspace setup flow, terminal badge in agent list.

CLI: remove terminal column from ls, terminal-agent error from send.

60 files changed, -3592 lines
This commit is contained in:
Mohamed Boudra
2026-04-02 10:55:54 +07:00
parent 59cb4d7499
commit 060f3d457c
60 changed files with 60 additions and 3592 deletions
+1 -8
View File
@@ -15,7 +15,7 @@ import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive, SquareTerminal } from "lucide-react-native";
import { Archive } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
@@ -158,12 +158,6 @@ function SessionRow({
>
{agent.title || "New session"}
</Text>
{agent.terminal ? (
<SessionBadge
label="Terminal"
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
/>
) : null}
{agent.archivedAt ? (
<SessionBadge
label="Archived"
@@ -261,7 +255,6 @@ export function AgentList({
workspaceId,
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
requestReopen: agent.terminal && agent.status === "closed",
});
router.navigate(route as any);
},
@@ -1,17 +1,13 @@
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { Bot, ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native";
import { ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer";
import { getProviderIcon } from "@/components/provider-icons";
import { Button } from "@/components/ui/button";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProviderRecency } from "@/stores/provider-recency-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -24,8 +20,6 @@ import {
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { MessagePayload } from "./message-input";
type SetupStep = "choose" | "chat" | "terminal-agent";
export function WorkspaceSetupDialog() {
const { theme } = useUnistyles();
const toast = useToast();
@@ -34,15 +28,12 @@ export function WorkspaceSetupDialog() {
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents);
const [step, setStep] = useState<SetupStep>("choose");
const [terminalPrompt, setTerminalPrompt] = useState("");
const [step, setStep] = useState<"choose" | "chat">("choose");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
> | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>(
null,
);
const [pendingAction, setPendingAction] = useState<"chat" | "terminal" | null>(null);
const serverId = pendingWorkspaceSetup?.serverId ?? "";
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
@@ -66,13 +57,9 @@ export function WorkspaceSetupDialog() {
if (!composerState && pendingWorkspaceSetup) {
throw new Error("Workspace setup composer state is required");
}
const { providers: sortedProviders, recordUsage } = useProviderRecency(
composerState?.providerDefinitions ?? [],
);
useEffect(() => {
setStep("choose");
setTerminalPrompt("");
setErrorMessage(null);
setCreatedWorkspace(null);
setPendingAction(null);
@@ -224,58 +211,6 @@ export function WorkspaceSetupDialog() {
],
);
const handleCreateTerminalAgent = useCallback(async () => {
try {
setPendingAction("terminal-agent");
setErrorMessage(null);
const workspace = await ensureWorkspace();
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Workspace setup composer state is required");
}
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
workspaceId: requireWorkspaceRecordId(workspace.id),
terminal: true,
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
});
if (!getIsStillActive()) {
return;
}
recordUsage(composerState.selectedProvider);
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
});
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
} catch (error) {
const message = toErrorMessage(error);
setErrorMessage(message);
toast.error(message);
} finally {
if (getIsStillActive()) {
setPendingAction(null);
}
}
}, [
composerState,
getIsStillActive,
navigateAfterCreation,
recordUsage,
serverId,
setAgents,
ensureWorkspace,
terminalPrompt,
toast,
withConnectedClient,
]);
const handleCreateTerminal = useCallback(async () => {
try {
setPendingAction("terminal");
@@ -344,16 +279,6 @@ export function WorkspaceSetupDialog() {
setStep("chat");
}}
/>
<ChoiceCard
title="Terminal Agent"
description="Launch an agent-backed terminal in an agent tab."
Icon={Bot}
disabled={pendingAction !== null}
onPress={() => {
setErrorMessage(null);
setStep("terminal-agent");
}}
/>
<ChoiceCard
title="Terminal"
description="Create the workspace, then open a standalone terminal tab."
@@ -408,68 +333,6 @@ export function WorkspaceSetupDialog() {
</View>
) : null}
{step === "terminal-agent" ? (
<View style={styles.section}>
<StepHeader
title="Terminal Agent"
onBack={() => {
setErrorMessage(null);
setStep("choose");
}}
/>
<Text style={styles.helper}>
Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches.
</Text>
<View style={styles.providerGrid}>
{sortedProviders.map((provider) => (
<ProviderOption
key={provider.id}
provider={provider}
selected={provider.id === composerState?.selectedProvider}
disabled={pendingAction !== null}
onPress={() => composerState?.setProviderFromUser(provider.id)}
/>
))}
</View>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Initial prompt</Text>
<AdaptiveTextInput
value={terminalPrompt}
onChangeText={setTerminalPrompt}
placeholder="Optional"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
multiline
autoCapitalize="sentences"
autoCorrect={false}
/>
</View>
<View style={styles.actions}>
<Button
variant="secondary"
style={styles.actionButton}
disabled={pendingAction !== null}
onPress={handleClose}
>
Cancel
</Button>
<Button
variant="default"
style={styles.actionButton}
disabled={pendingAction !== null}
onPress={() => {
void handleCreateTerminalAgent();
}}
>
{pendingAction === "terminal-agent" ? "Launching..." : "Launch"}
</Button>
</View>
</View>
) : null}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet>
);
@@ -531,42 +394,6 @@ function ChoiceCard({
);
}
function ProviderOption({
provider,
selected,
disabled,
onPress,
}: {
provider: { id: AgentProvider; label: string; description: string };
selected: boolean;
disabled: boolean;
onPress: () => void;
}) {
const { theme } = useUnistyles();
const Icon = getProviderIcon(provider.id);
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.providerCard,
selected ? styles.providerCardSelected : null,
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
disabled ? styles.cardDisabled : null,
]}
>
<View style={styles.providerIconWrap}>
<Icon size={16} color={theme.colors.foreground} />
</View>
<View style={styles.providerBody}>
<Text style={styles.providerTitle}>{provider.label}</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
gap: theme.spacing[1],
@@ -655,69 +482,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
providerGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerCard: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
providerCardSelected: {
borderColor: theme.colors.accent,
backgroundColor: theme.colors.surface2,
},
providerIconWrap: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
providerBody: {
flex: 1,
},
providerTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
field: {
gap: theme.spacing[2],
},
fieldLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
input: {
minHeight: 80,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
color: theme.colors.foreground,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
textAlignVertical: "top",
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
},
actionButton: {
flex: 1,
},
errorText: {
fontSize: theme.fontSize.sm,
color: theme.colors.destructive,
@@ -7,7 +7,6 @@ function createAgent(status: Agent["status"]): Agent {
serverId: "server-1",
id: "agent-1",
provider: "codex",
terminal: false,
status,
createdAt: new Date(0),
updatedAt: new Date(0),
@@ -20,7 +19,6 @@ function createAgent(status: Agent["status"]): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],
@@ -17,7 +17,6 @@ function createAgent(id: string): Agent {
serverId: "server-1",
id,
provider: "claude",
terminal: false,
status: "running",
createdAt: now,
updatedAt: now,
@@ -30,7 +29,6 @@ function createAgent(id: string): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],
@@ -6,13 +6,6 @@ export interface AgentScreenAgent {
status: "initializing" | "idle" | "running" | "error" | "closed";
cwd: string;
lastError?: string | null;
terminalExit?: {
command: string;
message: string;
exitCode: number | null;
signal: number | null;
outputLines: string[];
} | null;
projectPlacement?: {
checkout?: {
cwd?: string;
@@ -65,7 +65,6 @@ export function useAggregatedAgents(options?: {
serverId,
serverLabel,
title: agent.title ?? null,
terminal: agent.terminal,
status: agent.status,
lastActivityAt: agent.lastActivityAt,
cwd: agent.cwd,
@@ -8,7 +8,6 @@ function makeAgent(input?: Partial<Agent>): Agent {
serverId: "server-1",
id: input?.id ?? "agent-1",
provider: input?.provider ?? "codex",
terminal: input?.terminal ?? false,
status: input?.status ?? "idle",
createdAt: input?.createdAt ?? timestamp,
updatedAt: input?.updatedAt ?? timestamp,
@@ -21,7 +20,6 @@ function makeAgent(input?: Partial<Agent>): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: input?.currentModeId ?? null,
availableModes: input?.availableModes ?? [],
@@ -19,7 +19,6 @@ function toAggregatedAgent(params: {
serverId: params.serverId,
serverLabel: params.serverLabel,
title: source.title ?? null,
terminal: source.terminal,
status: source.status,
lastActivityAt: source.lastActivityAt,
cwd: source.cwd,
@@ -10,7 +10,6 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
serverId: "server-a",
id: "agent-1",
provider: "codex",
terminal: false,
status: "running",
createdAt: new Date("2026-04-01T03:00:00.000Z"),
updatedAt: new Date("2026-04-01T03:00:00.000Z"),
@@ -23,7 +22,6 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],
-28
View File
@@ -26,7 +26,6 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { TerminalAgentPanel } from "@/panels/terminal-agent-panel";
import {
useHostRuntimeClient,
useHostRuntimeConnectionStatus,
@@ -250,11 +249,9 @@ function AgentPanelBody({
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
terminal: agent?.terminal ?? false,
status: agent?.status ?? null,
cwd: agent?.cwd ?? null,
lastError: agent?.lastError ?? null,
terminalExit: agent?.terminalExit ?? null,
archivedAt: agent?.archivedAt ?? null,
};
}),
@@ -378,7 +375,6 @@ function AgentPanelBody({
status: agentState.status,
cwd: agentState.cwd,
lastError: agentState.lastError ?? null,
terminalExit: agentState.terminalExit ?? null,
projectPlacement,
}
: null;
@@ -395,27 +391,6 @@ function AgentPanelBody({
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
if (agentState.terminal) {
return (
<View style={styles.root}>
<TerminalAgentPanel
serverId={serverId}
client={client}
agent={agent}
isPaneFocused={isPaneFocused}
/>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
</View>
) : null}
</View>
);
}
return (
<ChatAgentContent
serverId={serverId}
@@ -480,11 +455,9 @@ function ChatAgentContent({
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
terminal: agent?.terminal ?? false,
status: agent?.status ?? null,
cwd: agent?.cwd ?? null,
lastError: agent?.lastError ?? null,
terminalExit: agent?.terminalExit ?? null,
archivedAt: agent?.archivedAt ?? null,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
@@ -716,7 +689,6 @@ function ChatAgentContent({
status: agentState.status,
cwd: agentState.cwd,
lastError: agentState.lastError ?? null,
terminalExit: agentState.terminalExit ?? null,
projectPlacement,
}
: null;
+3 -238
View File
@@ -1,30 +1,15 @@
import { useCallback, useMemo, useState, type ComponentType } from "react";
import { useCallback, useState, type ComponentType } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { Bot, ChevronDown, Plus, SquarePen, SquareTerminal } from "lucide-react-native";
import { Plus, SquarePen, SquareTerminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { getProviderIcon } from "@/components/provider-icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { generateDraftId } from "@/stores/draft-keys";
import { useProviderRecency } from "@/stores/provider-recency-store";
import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { toErrorMessage } from "@/utils/error-messages";
import {
getWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
const MAX_VISIBLE_PROVIDER_TILES = 4;
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
function useLauncherPanelDescriptor() {
return {
@@ -45,70 +30,10 @@ function LauncherPanel() {
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
const { providers, recordUsage } = useProviderRecency();
const setAgents = useSessionStore((state) => state.setAgents);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
invariant(target.kind === "launcher", "LauncherPanel requires launcher target");
const visibleProviders = useMemo(
() => providers.slice(0, MAX_VISIBLE_PROVIDER_TILES),
[providers],
);
const overflowProviders = useMemo(
() => providers.slice(MAX_VISIBLE_PROVIDER_TILES),
[providers],
);
const launchTerminalAgent = useCallback(
async (providerId: AgentProvider) => {
if (!client || !isConnected || !workspaceDirectory) {
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
return;
}
if (!workspaceAuthority.ok) {
setErrorMessage(workspaceAuthority.message);
return;
}
const persistedWorkspaceId = requireWorkspaceRecordId(workspaceAuthority.authority.workspaceId);
setPendingAction(providerId);
setErrorMessage(null);
try {
const agent = await client.createAgent({
provider: providerId,
cwd: workspaceDirectory,
workspaceId: persistedWorkspaceId,
terminal: true,
});
recordUsage(providerId);
// Retarget first so the launcher converts in place before session reconciliation
// can materialize the new agent as a separate tab.
retargetCurrentTab({ kind: "agent", agentId: agent.id });
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
});
} catch (error) {
setErrorMessage(toErrorMessage(error));
} finally {
setPendingAction((current) => (current === providerId ? null : current));
}
},
[
client,
isConnected,
recordUsage,
retargetCurrentTab,
serverId,
setAgents,
workspaceAuthority,
workspaceDirectory,
],
);
const openDraftTab = useCallback(() => {
setErrorMessage(null);
setPendingAction("draft");
@@ -187,34 +112,6 @@ function LauncherPanel() {
}}
/>
</View>
<Text style={styles.sectionLabel}>Terminal Agents</Text>
<View style={styles.providerGrid}>
{visibleProviders.map((provider) => (
<ProviderTile
key={provider.id}
provider={provider}
disabled={actionsDisabled}
pending={pendingAction === provider.id}
onPress={() => {
void launchTerminalAgent(provider.id);
}}
/>
))}
{overflowProviders.length > 0 ? (
<ViewAllProvidersTile
providers={overflowProviders}
disabled={actionsDisabled}
pendingProviderId={pendingAction}
onSelectProvider={(providerId) => {
void launchTerminalAgent(providerId);
}}
/>
) : null}
</View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
</ScrollView>
@@ -272,91 +169,6 @@ function LauncherTile({
);
}
function ProviderTile({
provider,
disabled,
pending,
onPress,
}: {
provider: { id: string; label: string; description: string };
disabled: boolean;
pending: boolean;
onPress: () => void;
}) {
const { theme } = useUnistyles();
const Icon = getProviderIcon(provider.id);
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.providerTile,
(hovered || pressed) && !disabled ? styles.tileInteractive : null,
disabled ? styles.tileDisabled : null,
]}
>
<View style={styles.providerIconWrap}>
{pending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Icon size={16} color={theme.colors.foreground} />
)}
</View>
<Text style={styles.providerLabel}>{provider.label}</Text>
</Pressable>
);
}
function ViewAllProvidersTile({
providers,
disabled,
pendingProviderId,
onSelectProvider,
}: {
providers: Array<{ id: string; label: string; description: string }>;
disabled: boolean;
pendingProviderId: string | null;
onSelectProvider: (providerId: AgentProvider) => void;
}) {
const { theme } = useUnistyles();
return (
<DropdownMenu>
<DropdownMenuTrigger disabled={disabled} style={styles.providerTile}>
{({ open }) => (
<>
<View style={styles.providerIconWrap}>
<Bot size={16} color={theme.colors.foreground} />
</View>
<Text style={styles.providerLabel}>More</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
{open ? <View style={styles.dropdownOutline} /> : null}
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={260}>
{providers.map((provider) => {
const Icon = getProviderIcon(provider.id);
return (
<DropdownMenuItem
key={provider.id}
description={provider.description}
onSelect={() => onSelectProvider(provider.id as AgentProvider)}
leading={<Icon size={16} color={theme.colors.foregroundMuted} />}
status={pendingProviderId === provider.id ? "pending" : "idle"}
pendingLabel={`Launching ${provider.label}...`}
>
{provider.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const launcherPanelRegistration: PanelRegistration<"launcher"> = {
kind: "launcher",
component: LauncherPanel,
@@ -431,53 +243,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
sectionLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
providerGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerTile: {
position: "relative",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
providerIconWrap: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
providerLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
dropdownOutline: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.accent,
},
errorText: {
fontSize: theme.fontSize.sm,
color: theme.colors.destructive,
@@ -1,316 +0,0 @@
import { useIsFocused } from "@react-navigation/native";
import { useEffect, useRef, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@server/client/daemon-client";
import { TerminalPane } from "@/components/terminal-pane";
import { Fonts } from "@/constants/theme";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { usePaneContext } from "@/panels/pane-context";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import {
buildTerminalAgentReopenKey,
useTerminalAgentReopenStore,
} from "@/stores/terminal-agent-reopen-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
type TerminalAgentPanelProps = {
serverId: string;
client: DaemonClient;
agent: AgentScreenAgent;
isPaneFocused: boolean;
};
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function getTerminalExitTitle(agent: AgentScreenAgent): string {
const exitCode = agent.terminalExit?.exitCode;
const signal = agent.terminalExit?.signal;
if (
agent.status === "error" ||
(exitCode != null && exitCode !== 0) ||
signal != null
) {
return "Terminal session failed";
}
return "Terminal session ended";
}
function getTerminalExitMessage(agent: AgentScreenAgent): string {
const summary = agent.terminalExit?.message?.trim();
if (summary) {
return summary;
}
const lastError = agent.lastError?.trim();
if (lastError) {
return lastError;
}
return "Reopen the agent from the sessions list to start it again.";
}
function isCleanTerminalExit(agent: AgentScreenAgent): boolean {
return agent.status === "closed" && agent.terminalExit?.exitCode === 0 && agent.terminalExit.signal == null;
}
export function TerminalAgentPanel({
serverId,
client,
agent,
isPaneFocused,
}: TerminalAgentPanelProps) {
const isScreenFocused = useIsFocused();
const { theme } = useUnistyles();
const { tabId, workspaceId } = usePaneContext();
const { archiveAgent } = useArchiveAgent();
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const [terminalId, setTerminalId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [didExitInPanel, setDidExitInPanel] = useState(false);
const reopenKey = buildTerminalAgentReopenKey({ serverId, agentId: agent.id });
const reopenIntentVersion = useTerminalAgentReopenStore((state) =>
reopenKey ? (state.reopenIntentVersionByAgentKey[reopenKey] ?? 0) : 0,
);
// Refs for effect guards — these values gate whether the creation effect
// should run, but changes to them should NOT re-trigger the effect.
const isCreatingRef = useRef(false);
const didExitRef = useRef(false);
const lastHandledReopenIntentRef = useRef(reopenIntentVersion);
const isAutoClosingRef = useRef(false);
useEffect(() => {
setTerminalId(null);
setIsCreating(false);
setCreateError(null);
setDidExitInPanel(false);
isCreatingRef.current = false;
didExitRef.current = false;
}, [agent.id, serverId]);
useEffect(() => {
if (reopenIntentVersion <= lastHandledReopenIntentRef.current) {
return;
}
lastHandledReopenIntentRef.current = reopenIntentVersion;
if (!didExitRef.current && !didExitInPanel && !createError) {
return;
}
didExitRef.current = false;
setDidExitInPanel(false);
setCreateError(null);
}, [createError, didExitInPanel, reopenIntentVersion]);
useEffect(() => {
if (!terminalId) {
return;
}
return client.on("terminal_stream_exit", (message) => {
if (message.type !== "terminal_stream_exit" || message.payload.terminalId !== terminalId) {
return;
}
setTerminalId((current) => (current === message.payload.terminalId ? null : current));
setDidExitInPanel(true);
didExitRef.current = true;
});
}, [client, terminalId]);
useEffect(() => {
if (!isCleanTerminalExit(agent) || isAutoClosingRef.current) {
return;
}
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!workspaceKey) {
return;
}
isAutoClosingRef.current = true;
void archiveAgent({ serverId, agentId: agent.id })
.then(() => {
unpinWorkspaceAgent(workspaceKey, agent.id);
closeWorkspaceTab(workspaceKey, tabId);
})
.finally(() => {
isAutoClosingRef.current = false;
});
}, [
agent,
archiveAgent,
closeWorkspaceTab,
serverId,
tabId,
unpinWorkspaceAgent,
workspaceId,
]);
// Create the terminal when the panel becomes visible and no terminal exists yet.
// Guards (isCreatingRef, didExitRef) are refs to avoid re-triggering the effect
// when their values change — we only want this to fire on genuine state transitions
// (focus change, terminal cleared, agent change).
useEffect(() => {
if (
!isScreenFocused ||
!isPaneFocused ||
terminalId ||
isCreatingRef.current ||
didExitRef.current
) {
return;
}
let cancelled = false;
isCreatingRef.current = true;
setIsCreating(true);
setCreateError(null);
void client
.createTerminal(agent.cwd, undefined, undefined, { agentId: agent.id })
.then((payload) => {
if (cancelled) {
return;
}
if (payload.error || !payload.terminal) {
setCreateError(payload.error ?? "Failed to open terminal");
return;
}
setTerminalId(payload.terminal.id);
})
.catch((error) => {
if (cancelled) {
return;
}
setCreateError(toErrorMessage(error));
})
.finally(() => {
if (!cancelled) {
isCreatingRef.current = false;
setIsCreating(false);
}
});
return () => {
cancelled = true;
};
}, [agent.cwd, agent.id, client, isPaneFocused, isScreenFocused, terminalId]);
if (!isScreenFocused) {
return <View style={styles.container} />;
}
if (terminalId) {
return (
<TerminalPane
serverId={serverId}
cwd={agent.cwd}
terminalId={terminalId}
isPaneFocused={isPaneFocused}
/>
);
}
if (isCreating) {
return (
<View style={styles.state} testID="terminal-agent-loading">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.message}>Opening terminal…</Text>
</View>
);
}
if (createError) {
return (
<View style={styles.state} testID="terminal-agent-error">
<Text style={styles.title}>Failed to open terminal</Text>
<Text style={styles.message}>{createError}</Text>
</View>
);
}
if (didExitInPanel || agent.status === "closed" || agent.status === "error") {
const terminalExit = agent.terminalExit ?? null;
const exitMeta =
terminalExit?.exitCode != null
? `Exit code ${terminalExit.exitCode}`
: terminalExit?.signal != null
? `Signal ${terminalExit.signal}`
: null;
return (
<View style={styles.state} testID="terminal-agent-closed">
<Text style={styles.title}>{getTerminalExitTitle(agent)}</Text>
<Text style={styles.message}>{getTerminalExitMessage(agent)}</Text>
{terminalExit ? (
<View style={styles.detailsCard}>
{exitMeta ? <Text style={styles.detailsLabel}>{exitMeta}</Text> : null}
{terminalExit.outputLines.length > 0 ? (
<Text style={styles.output}>{terminalExit.outputLines.join("\n")}</Text>
) : null}
</View>
) : null}
<Text style={styles.message}>Reopen the agent from the sessions list to start it again.</Text>
</View>
);
}
return (
<View style={styles.state} testID="terminal-agent-idle">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
state: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
backgroundColor: theme.colors.surface0,
},
title: {
fontSize: theme.fontSize.lg,
color: theme.colors.foreground,
textAlign: "center",
},
message: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
detailsCard: {
width: "100%",
maxWidth: 560,
padding: theme.spacing[4],
gap: theme.spacing[2],
borderRadius: theme.spacing[3],
backgroundColor: theme.colors.surface1,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
},
detailsLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.4,
},
output: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontFamily: Fonts.mono,
lineHeight: 20,
},
}));
@@ -134,7 +134,6 @@ function makeFetchAgentsEntry(input: {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],
@@ -1182,7 +1181,6 @@ describe("HostRuntimeStore", () => {
const staleAgent: Agent = {
...stale,
serverId: host.serverId,
terminal: false,
createdAt: new Date(stale.createdAt),
updatedAt: new Date(stale.updatedAt),
lastUserMessageAt: null,
@@ -66,7 +66,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: false,
};
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
@@ -832,7 +831,6 @@ function DraftAgentScreenContent({
serverId,
id: draftAgentIdRef.current,
provider,
terminal: false,
status: "running",
createdAt: now,
updatedAt: now,
@@ -19,7 +19,6 @@ function makeAgent(input: {
serverId: "srv",
id: input.id,
provider: "codex",
terminal: false,
status: "idle",
createdAt,
updatedAt: createdAt,
@@ -32,7 +31,6 @@ function makeAgent(input: {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],
@@ -163,9 +161,9 @@ describe("workspace agent visibility", () => {
it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => {
const sessionAgents = new Map<string, Agent>([
[
"terminal-agent",
"recent-agent",
makeAgent({
id: "terminal-agent",
id: "recent-agent",
cwd: "/tmp/workspace-lifecycle-main",
}),
],
@@ -176,8 +174,8 @@ describe("workspace agent visibility", () => {
workspaceDirectory: "/tmp/workspace-lifecycle-main",
});
expect(result.activeAgentIds).toEqual(new Set(["terminal-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["terminal-agent"]));
expect(result.activeAgentIds).toEqual(new Set(["recent-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["recent-agent"]));
});
describe("workspaceAgentVisibilityEqual", () => {
@@ -29,7 +29,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: false,
};
type WorkspaceDraftAgentTabProps = {
@@ -134,7 +133,6 @@ export function WorkspaceDraftAgentTab({
serverId,
id: tabId,
provider: composerState.selectedProvider,
terminal: false,
status: "running",
createdAt: now,
updatedAt: now,
@@ -92,7 +92,7 @@ export const useProviderRecencyStore = create<ProviderRecencyStoreState>()(
},
}),
{
name: "terminal-agent-provider-recency",
name: "agent-provider-recency",
version: PROVIDER_RECENCY_STORE_VERSION,
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
-5
View File
@@ -80,13 +80,10 @@ export interface AgentRuntimeInfo {
extra?: Record<string, unknown>;
}
type TerminalExitDetails = NonNullable<AgentSnapshotPayload["terminalExit"]>;
export interface Agent {
serverId: string;
id: string;
provider: AgentProvider;
terminal: boolean;
status: AgentLifecycleStatus;
createdAt: Date;
updatedAt: Date;
@@ -100,7 +97,6 @@ export interface Agent {
runtimeInfo?: AgentRuntimeInfo;
lastUsage?: AgentUsage;
lastError?: string | null;
terminalExit?: TerminalExitDetails | null;
title: string | null;
cwd: string;
model: string | null;
@@ -1127,7 +1123,6 @@ export const useSessionStore = create<SessionStore>()(
id: agent.id,
serverId,
title: agent.title ?? null,
terminal: agent.terminal,
status: agent.status,
lastActivityAt,
cwd: agent.cwd,
@@ -1,52 +0,0 @@
import { create } from "zustand";
interface BuildTerminalAgentReopenKeyInput {
serverId: string;
agentId: string;
}
interface RequestTerminalAgentReopenInput {
serverId: string;
agentId: string;
}
interface TerminalAgentReopenStore {
reopenIntentVersionByAgentKey: Record<string, number>;
requestReopen: (input: RequestTerminalAgentReopenInput) => void;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function buildTerminalAgentReopenKey(
input: BuildTerminalAgentReopenKeyInput,
): string | null {
const serverId = trimNonEmpty(input.serverId);
const agentId = trimNonEmpty(input.agentId);
if (!serverId || !agentId) {
return null;
}
return `${serverId}:${agentId}`;
}
export const useTerminalAgentReopenStore = create<TerminalAgentReopenStore>()((set) => ({
reopenIntentVersionByAgentKey: {},
requestReopen: ({ serverId, agentId }) => {
const key = buildTerminalAgentReopenKey({ serverId, agentId });
if (!key) {
return;
}
set((state) => ({
reopenIntentVersionByAgentKey: {
...state.reopenIntentVersionByAgentKey,
[key]: (state.reopenIntentVersionByAgentKey[key] ?? 0) + 1,
},
}));
},
}));
@@ -5,7 +5,6 @@ export type AgentDirectoryEntry = Pick<
| "id"
| "serverId"
| "title"
| "terminal"
| "status"
| "lastActivityAt"
| "cwd"
@@ -31,7 +31,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
serverId,
id: snapshot.id,
provider: snapshot.provider,
terminal: snapshot.terminal === true,
status: snapshot.status as AgentLifecycleStatus,
createdAt,
updatedAt,
@@ -45,7 +44,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
runtimeInfo: snapshot.runtimeInfo,
lastUsage: snapshot.lastUsage,
lastError: snapshot.lastError ?? null,
terminalExit: snapshot.terminalExit ?? null,
title: snapshot.title ?? null,
cwd: snapshot.cwd,
model: snapshot.model ?? null,
@@ -23,10 +23,6 @@ vi.mock("@react-native-async-storage/async-storage", () => {
});
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
buildTerminalAgentReopenKey,
useTerminalAgentReopenStore,
} from "@/stores/terminal-agent-reopen-store";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
const SERVER_ID = "server-1";
@@ -40,39 +36,17 @@ describe("prepareWorkspaceTab", () => {
splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
});
useTerminalAgentReopenStore.setState({
reopenIntentVersionByAgentKey: {},
requestReopen: useTerminalAgentReopenStore.getState().requestReopen,
});
});
it("publishes a reopen intent when requested for an agent tab", () => {
it("opens and focuses an agent tab", () => {
const route = prepareWorkspaceTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID },
requestReopen: true,
});
const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID });
expect(reopenKey).toBeTruthy();
expect(route).toBe("/h/server-1/workspace/L3JlcG8vd29ya3RyZWU");
expect(
useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string],
).toBe(1);
});
it("does not publish a reopen intent unless explicitly requested", () => {
prepareWorkspaceTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID },
});
const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID });
expect(reopenKey).toBeTruthy();
expect(
useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string],
).toBeUndefined();
const key = "server-1:/repo/worktree";
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1);
});
});
@@ -1,7 +1,6 @@
import { router } from "expo-router";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { generateDraftId } from "@/stores/draft-keys";
import { useTerminalAgentReopenStore } from "@/stores/terminal-agent-reopen-store";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTabTarget,
@@ -13,7 +12,6 @@ interface PrepareWorkspaceTabInput {
workspaceId: string;
target: WorkspaceTabTarget;
pin?: boolean;
requestReopen?: boolean;
}
interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput {
@@ -45,13 +43,6 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId);
}
if (input.requestReopen && target.kind === "agent") {
useTerminalAgentReopenStore.getState().requestReopen({
serverId: input.serverId,
agentId: target.agentId,
});
}
return buildHostWorkspaceRoute(input.serverId, input.workspaceId);
}