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:
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user