diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index dca817235..b6114f165 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -48,6 +48,7 @@ import {
buildHostDraftRoute,
parseHostAgentRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
+ parseHostWorkspaceTabRouteFromPathname,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
@@ -381,6 +382,12 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => {
+ const workspaceTab = parseHostWorkspaceTabRouteFromPathname(pathname);
+ if (workspaceTab?.tabId?.startsWith("agent_")) {
+ const agentId = workspaceTab.tabId.slice("agent_".length).trim();
+ return agentId ? `${workspaceTab.serverId}:${agentId}` : undefined;
+ }
+
const match =
parseHostWorkspaceAgentRouteFromPathname(pathname) ??
parseHostAgentRouteFromPathname(pathname);
@@ -470,6 +477,7 @@ export default function RootLayout() {
+
();
+
+ const tabId = typeof params.tabId === "string" ? params.tabId : "";
+
+ return (
+
+ );
+}
+
diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx
index 68ba549af..d8d6b0bf5 100644
--- a/packages/app/src/components/sortable-inline-list.web.tsx
+++ b/packages/app/src/components/sortable-inline-list.web.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState, type ReactElement } from "react";
+import { useCallback, useState, type ReactElement } from "react";
import {
DndContext,
closestCenter,
@@ -118,14 +118,8 @@ export function SortableInlineList({
onDragBegin?: () => void;
}): ReactElement {
const [activeId, setActiveId] = useState(null);
- const [items, setItems] = useState(() => data);
-
- useEffect(() => {
- if (activeId) {
- return;
- }
- setItems((current) => (current === data ? current : data));
- }, [activeId, data]);
+ const [dragItems, setDragItems] = useState(null);
+ const items = dragItems ?? data;
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -143,10 +137,11 @@ export function SortableInlineList({
if (disabled) {
return;
}
+ setDragItems(data);
setActiveId(String(event.active.id));
onDragBegin?.();
},
- [disabled, onDragBegin]
+ [data, disabled, onDragBegin]
);
const handleDragEnd = useCallback(
@@ -154,6 +149,7 @@ export function SortableInlineList({
const { active, over } = event;
setActiveId(null);
+ setDragItems(null);
if (disabled) {
return;
@@ -169,10 +165,11 @@ export function SortableInlineList({
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex);
- setItems(newItems);
+ setDragItems(newItems);
onDragEnd?.(newItems);
}
}
+
},
[disabled, items, keyExtractor, onDragEnd]
);
diff --git a/packages/app/src/keyboard/keyboard-shortcut-routing.ts b/packages/app/src/keyboard/keyboard-shortcut-routing.ts
index 977e87e65..d393c32b2 100644
--- a/packages/app/src/keyboard/keyboard-shortcut-routing.ts
+++ b/packages/app/src/keyboard/keyboard-shortcut-routing.ts
@@ -3,6 +3,7 @@ import {
parseHostAgentRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
+ parseHostWorkspaceTabRouteFromPathname,
} from "@/utils/host-routes";
const DRAFT_AGENT_ID = "__new_agent__";
@@ -14,6 +15,19 @@ export function resolveSelectedOrRouteAgentKey(input: {
if (input.selectedAgentId) {
return input.selectedAgentId;
}
+
+ const workspaceTabRoute = parseHostWorkspaceTabRouteFromPathname(input.pathname);
+ if (workspaceTabRoute?.tabId) {
+ const tabId = workspaceTabRoute.tabId;
+ if (tabId.startsWith("agent_")) {
+ const agentId = tabId.slice("agent_".length).trim();
+ return agentId ? `${workspaceTabRoute.serverId}:${agentId}` : null;
+ }
+ if (tabId.startsWith("draft_")) {
+ return `${workspaceTabRoute.serverId}:${DRAFT_AGENT_ID}`;
+ }
+ }
+
const route =
parseHostWorkspaceAgentRouteFromPathname(input.pathname) ??
parseHostAgentRouteFromPathname(input.pathname);
diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx
index f88656b61..463477999 100644
--- a/packages/app/src/screens/agent/agent-ready-screen.tsx
+++ b/packages/app/src/screens/agent/agent-ready-screen.tsx
@@ -436,16 +436,28 @@ function AgentScreenContent({
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
- const pendingCreate = useCreateFlowStore((state) => state.pending);
+ const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const markPendingCreateLifecycle = useCreateFlowStore(
(state) => state.markLifecycle
);
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
- const isPendingCreateForRoute =
- Boolean(pendingCreate) &&
- pendingCreate?.lifecycle === "active" &&
- pendingCreate?.serverId === serverId &&
- pendingCreate?.agentId === resolvedAgentId;
+ const pendingCreate = useMemo(() => {
+ if (!resolvedAgentId) {
+ return null;
+ }
+ const values = Object.values(pendingByDraftId);
+ for (const entry of values) {
+ if (
+ entry.lifecycle === "active" &&
+ entry.serverId === serverId &&
+ entry.agentId === resolvedAgentId
+ ) {
+ return entry;
+ }
+ }
+ return null;
+ }, [pendingByDraftId, resolvedAgentId, serverId]);
+ const isPendingCreateForRoute = Boolean(pendingCreate);
// Select only the specific initializing state
const isInitializingFromMap = useSessionStore((state) =>
@@ -740,7 +752,7 @@ function AgentScreenContent({
item.kind === "user_message" &&
item.id === pendingCreate.clientMessageId
);
- if (hasUserMessage && canFinalizePendingCreate) {
+ if (hasUserMessage && canFinalizePendingCreate) {
if (
resolvedAgentId &&
pendingCreate.images &&
@@ -766,8 +778,8 @@ function AgentScreenContent({
return next;
});
}
- markPendingCreateLifecycle("sent");
- clearPendingCreate();
+ markPendingCreateLifecycle({ draftId: pendingCreate.draftId, lifecycle: "sent" });
+ clearPendingCreate({ draftId: pendingCreate.draftId });
}
}, [
canFinalizePendingCreate,
diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx
index 61132a9db..012739dd2 100644
--- a/packages/app/src/screens/agent/draft-agent-screen.tsx
+++ b/packages/app/src/screens/agent/draft-agent-screen.tsx
@@ -51,6 +51,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manife
import { buildHostAgentDetailRoute } from '@/utils/host-routes'
import { useTauriDragHandlers } from '@/utils/tauri-window'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
+import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
const DRAFT_AGENT_ID = '__new_agent__'
const EMPTY_PENDING_PERMISSIONS = new Map()
@@ -1023,9 +1024,15 @@ function DraftAgentScreenContent({
git: gitOptions,
})
- const agentId = (result as { id?: string })?.id
+ const agentId = result.id
if (agentId && selectedServerId) {
- updatePendingAgentId(agentId)
+ updatePendingAgentId({ draftId: draftIdRef.current, agentId })
+ useSessionStore.getState().setAgents(selectedServerId, (prev) => {
+ const next = new Map(prev)
+ next.set(agentId, normalizeAgentSnapshot(result, selectedServerId))
+ return next
+ })
+ void createAgentClient.refreshAgent(agentId).catch(() => undefined)
router.replace(buildHostAgentDetailRoute(selectedServerId, agentId) as any)
return
}
@@ -1035,15 +1042,15 @@ function DraftAgentScreenContent({
message: 'Failed to create agent',
})
onCreateFlowActiveChange?.(false)
- markPendingCreateLifecycle('abandoned')
- clearPendingCreateAttempt()
+ markPendingCreateLifecycle({ draftId: draftIdRef.current, lifecycle: 'abandoned' })
+ clearPendingCreateAttempt({ draftId: draftIdRef.current })
throw new Error('Failed to create agent')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create agent'
dispatch({ type: 'CREATE_FAILED', message })
onCreateFlowActiveChange?.(false)
- markPendingCreateLifecycle('abandoned')
- clearPendingCreateAttempt()
+ markPendingCreateLifecycle({ draftId: draftIdRef.current, lifecycle: 'abandoned' })
+ clearPendingCreateAttempt({ draftId: draftIdRef.current })
throw error // Re-throw so AgentInputArea knows it failed
}
},
diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx
new file mode 100644
index 000000000..8723ed478
--- /dev/null
+++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx
@@ -0,0 +1,427 @@
+import { useCallback, useEffect, useMemo, useReducer } from "react";
+import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import { AgentInputArea } from "@/components/agent-input-area";
+import { AgentConfigRow } from "@/components/agent-form/agent-form-dropdowns";
+import { AgentStreamView } from "@/components/agent-stream-view";
+import { MAX_CONTENT_WIDTH } from "@/constants/layout";
+import { useAgentFormState } from "@/hooks/use-agent-form-state";
+import { useHostRuntimeSession } from "@/runtime/host-runtime";
+import { useCreateFlowStore } from "@/stores/create-flow-store";
+import { useSessionStore, type Agent } from "@/stores/session-store";
+import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from "@/types/stream";
+import { encodeImages } from "@/utils/encode-images";
+import type { AgentCapabilityFlags, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
+import type { AgentSnapshotPayload } from "@server/shared/messages";
+import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
+
+const EMPTY_PENDING_PERMISSIONS = new Map();
+const EMPTY_STREAM_ITEMS: StreamItem[] = [];
+const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
+ supportsStreaming: true,
+ supportsSessionPersistence: false,
+ supportsDynamicModes: false,
+ supportsMcpServers: false,
+ supportsReasoningStream: false,
+ supportsToolInvocations: false,
+};
+
+type WorkspaceDraftAgentTabProps = {
+ serverId: string;
+ workspaceId: string;
+ tabId: string;
+ draftId: string;
+ onCreated: (snapshot: AgentSnapshotPayload) => void;
+};
+
+type CreateAttempt = {
+ clientMessageId: string;
+ text: string;
+ timestamp: Date;
+ images?: UserMessageImageAttachment[];
+};
+
+type DraftAgentMachineState =
+ | { tag: "draft"; promptText: string; errorMessage: string }
+ | { tag: "creating"; attempt: CreateAttempt };
+
+type DraftAgentMachineEvent =
+ | { type: "DRAFT_SET_PROMPT"; text: string }
+ | { type: "DRAFT_SET_ERROR"; message: string }
+ | { type: "SUBMIT"; attempt: CreateAttempt }
+ | { type: "CREATE_FAILED"; message: string };
+
+function assertNever(value: never): never {
+ throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
+}
+
+export function WorkspaceDraftAgentTab({
+ serverId,
+ workspaceId,
+ tabId,
+ draftId,
+ onCreated,
+}: WorkspaceDraftAgentTabProps) {
+ const { client, isConnected } = useHostRuntimeSession(serverId);
+
+ const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
+ const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
+ const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
+ const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
+
+ const {
+ selectedProvider,
+ setProviderFromUser,
+ selectedMode,
+ setModeFromUser,
+ selectedModel,
+ setModelFromUser,
+ selectedThinkingOptionId,
+ setThinkingOptionFromUser,
+ workingDir,
+ setWorkingDir,
+ providerDefinitions,
+ modeOptions,
+ availableModels,
+ availableThinkingOptions,
+ isModelLoading,
+ persistFormPreferences,
+ } = useAgentFormState({
+ initialServerId: serverId,
+ initialValues: { workingDir: workspaceId },
+ isVisible: true,
+ isCreateFlow: true,
+ onlineServerIds: isConnected ? [serverId] : [],
+ });
+
+ // Lock working directory to workspace.
+ useEffect(() => {
+ if (workingDir.trim() === workspaceId.trim()) {
+ return;
+ }
+ setWorkingDir(workspaceId);
+ }, [setWorkingDir, workingDir, workspaceId]);
+
+ const [machine, dispatch] = useReducer(
+ (state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState => {
+ switch (event.type) {
+ case "DRAFT_SET_PROMPT": {
+ if (state.tag !== "draft") {
+ return state;
+ }
+ return { ...state, promptText: event.text };
+ }
+ case "DRAFT_SET_ERROR": {
+ if (state.tag !== "draft") {
+ return state;
+ }
+ return { ...state, errorMessage: event.message };
+ }
+ case "SUBMIT": {
+ return { tag: "creating", attempt: event.attempt };
+ }
+ case "CREATE_FAILED": {
+ if (state.tag !== "creating") {
+ return state;
+ }
+ return { tag: "draft", promptText: state.attempt.text, errorMessage: event.message };
+ }
+ default:
+ return assertNever(event);
+ }
+ },
+ { tag: "draft", promptText: "", errorMessage: "" }
+ );
+
+ const promptValue = machine.tag === "draft" ? machine.promptText : "";
+ const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
+ const isSubmitting = machine.tag === "creating";
+
+ const optimisticStreamItems = useMemo(() => {
+ if (machine.tag !== "creating") {
+ return EMPTY_STREAM_ITEMS;
+ }
+ return [
+ {
+ kind: "user_message",
+ id: machine.attempt.clientMessageId,
+ text: machine.attempt.text,
+ timestamp: machine.attempt.timestamp,
+ ...(machine.attempt.images && machine.attempt.images.length > 0
+ ? { images: machine.attempt.images }
+ : {}),
+ },
+ ];
+ }, [machine]);
+
+ const draftAgent = useMemo(() => {
+ if (machine.tag !== "creating") {
+ return null;
+ }
+ const now = machine.attempt.timestamp;
+ const model = selectedModel.trim() || null;
+ const thinkingOptionId = selectedThinkingOptionId.trim() || null;
+ const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
+ return {
+ serverId,
+ id: tabId,
+ provider: selectedProvider,
+ status: "running",
+ createdAt: now,
+ updatedAt: now,
+ lastUserMessageAt: now,
+ lastActivityAt: now,
+ capabilities: DRAFT_CAPABILITIES,
+ currentModeId: modeId,
+ availableModes: [],
+ pendingPermissions: [],
+ persistence: null,
+ runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId },
+ title: "New agent",
+ cwd: workspaceId,
+ model,
+ thinkingOptionId,
+ labels: {},
+ };
+ }, [
+ machine,
+ modeOptions.length,
+ selectedMode,
+ selectedModel,
+ selectedProvider,
+ selectedThinkingOptionId,
+ serverId,
+ tabId,
+ workspaceId,
+ ]);
+
+ const handleCreateFromInput = useCallback(
+ async ({ text, images }: { text: string; images?: UserMessageImageAttachment[] }) => {
+ if (isSubmitting) {
+ throw new Error("Already loading");
+ }
+ dispatch({ type: "DRAFT_SET_ERROR", message: "" });
+ const trimmedPrompt = text.trim();
+ if (!trimmedPrompt) {
+ dispatch({ type: "DRAFT_SET_ERROR", message: "Initial prompt is required" });
+ throw new Error("Initial prompt is required");
+ }
+ if (providerDefinitions.length === 0) {
+ dispatch({
+ type: "DRAFT_SET_ERROR",
+ message: "No available providers on the selected host",
+ });
+ throw new Error("No available providers on the selected host");
+ }
+ if (!client) {
+ dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" });
+ throw new Error("Host is not connected");
+ }
+
+ const attempt: CreateAttempt = {
+ clientMessageId: generateMessageId(),
+ text: trimmedPrompt,
+ timestamp: new Date(),
+ ...(images && images.length > 0 ? { images } : {}),
+ };
+
+ setPendingCreateAttempt({
+ draftId,
+ serverId,
+ agentId: null,
+ clientMessageId: attempt.clientMessageId,
+ text: attempt.text,
+ timestamp: attempt.timestamp.getTime(),
+ ...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
+ });
+
+ const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
+ const trimmedModel = selectedModel.trim();
+ const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
+ const config: AgentSessionConfig = {
+ provider: selectedProvider,
+ cwd: workspaceId,
+ ...(modeId ? { modeId } : {}),
+ ...(trimmedModel ? { model: trimmedModel } : {}),
+ ...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
+ };
+
+ void persistFormPreferences();
+ if (Platform.OS === "web") {
+ (document.activeElement as HTMLElement | null)?.blur?.();
+ }
+ Keyboard.dismiss();
+ dispatch({ type: "SUBMIT", attempt });
+
+ try {
+ const imagesData = await encodeImages(images);
+ const result = await client.createAgent({
+ config,
+ labels: { ui: "true" },
+ initialPrompt: trimmedPrompt,
+ clientMessageId: attempt.clientMessageId,
+ ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
+ });
+
+ const agentId = result.id;
+ updatePendingAgentId({ draftId, agentId });
+
+ const normalized = normalizeAgentSnapshot(result, serverId);
+ useSessionStore.getState().setAgents(serverId, (prev) => {
+ const next = new Map(prev);
+ next.set(agentId, normalized);
+ return next;
+ });
+
+ void client.refreshAgent(agentId).catch(() => undefined);
+ onCreated(result);
+ return;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to create agent";
+ dispatch({ type: "CREATE_FAILED", message });
+ markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
+ clearPendingCreateAttempt({ draftId });
+ throw error;
+ }
+ },
+ [
+ clearPendingCreateAttempt,
+ client,
+ draftId,
+ isSubmitting,
+ markPendingCreateLifecycle,
+ modeOptions.length,
+ onCreated,
+ persistFormPreferences,
+ providerDefinitions.length,
+ selectedMode,
+ selectedModel,
+ selectedProvider,
+ selectedThinkingOptionId,
+ serverId,
+ setPendingCreateAttempt,
+ updatePendingAgentId,
+ workspaceId,
+ ]
+ );
+
+ const draftCommandConfig = useMemo(() => {
+ return {
+ provider: selectedProvider,
+ cwd: workspaceId,
+ ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
+ ...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
+ ...(selectedThinkingOptionId.trim()
+ ? { thinkingOptionId: selectedThinkingOptionId.trim() }
+ : {}),
+ };
+ }, [
+ modeOptions.length,
+ selectedMode,
+ selectedModel,
+ selectedProvider,
+ selectedThinkingOptionId,
+ workspaceId,
+ ]);
+
+ return (
+
+
+ {machine.tag === "creating" && draftAgent ? (
+
+
+
+ ) : (
+
+
+
+
+ {formErrorMessage ? (
+
+ {formErrorMessage}
+
+ ) : null}
+
+
+ )}
+
+
+
+ dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
+ autoFocus={machine.tag === "draft"}
+ commandDraftConfig={draftCommandConfig}
+ draftId={draftId}
+ />
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ flex: 1,
+ width: "100%",
+ alignSelf: "center",
+ maxWidth: MAX_CONTENT_WIDTH,
+ },
+ contentContainer: {
+ flex: 1,
+ },
+ streamContainer: {
+ flex: 1,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ configScrollContent: {
+ paddingHorizontal: theme.spacing[4],
+ paddingTop: theme.spacing[4],
+ paddingBottom: theme.spacing[6],
+ },
+ configSection: {
+ gap: theme.spacing[3],
+ },
+ inputAreaWrapper: {
+ width: "100%",
+ },
+ errorContainer: {
+ marginTop: theme.spacing[2],
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[2],
+ borderRadius: theme.borderRadius.md,
+ backgroundColor: theme.colors.surface2,
+ borderWidth: 1,
+ borderColor: theme.colors.destructive,
+ },
+ errorText: {
+ color: theme.colors.destructive,
+ },
+}));
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index e767025e0..3d120abd5 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -20,6 +20,7 @@ import {
MoreVertical,
PanelRight,
Plus,
+ Pencil,
SquareTerminal,
Terminal,
X,
@@ -55,22 +56,22 @@ import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
-import { useWorkspaceFileTabsStore } from "@/stores/workspace-file-tabs-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceTabsStore,
type WorkspaceTabTarget,
+ type WorkspaceTab,
} from "@/stores/workspace-tabs-store";
import {
buildHostAgentDetailRoute,
buildHostWorkspaceRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceFileRoute,
+ buildHostWorkspaceTabRoute,
buildHostWorkspaceTerminalRoute,
encodeFilePathForPathSegment,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
-import { buildNewAgentRoute } from "@/utils/new-agent-routing";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
checkoutStatusQueryKey,
@@ -84,13 +85,15 @@ import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { buildProviderCommand } from "@/utils/provider-command-templates";
+import { generateDraftId } from "@/stores/draft-keys";
+import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const DROPDOWN_WIDTH = 220;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
-const EMPTY_TAB_ORDER: string[] = [];
const EMPTY_OPEN_FILE_PATHS: string[] = [];
+const EMPTY_WORKSPACE_TABS: WorkspaceTab[] = [];
type TabAvailability = "available" | "invalid" | "unknown";
@@ -100,11 +103,21 @@ type WorkspaceScreenProps = {
serverId: string;
workspaceId: string;
routeTab: RouteTabTarget;
+ routeTabId?: string | null;
};
type WorkspaceTabDescriptor =
| {
key: string;
+ tabId: string;
+ kind: "draft";
+ draftId: string;
+ label: string;
+ subtitle: string;
+ }
+ | {
+ key: string;
+ tabId: string;
kind: "agent";
agentId: string;
provider: Agent["provider"];
@@ -113,6 +126,7 @@ type WorkspaceTabDescriptor =
}
| {
key: string;
+ tabId: string;
kind: "terminal";
terminalId: string;
label: string;
@@ -120,6 +134,7 @@ type WorkspaceTabDescriptor =
}
| {
key: string;
+ tabId: string;
kind: "file";
filePath: string;
label: string;
@@ -219,6 +234,13 @@ function normalizeWorkspaceTab(
if (!value || typeof value !== "object") {
return null;
}
+ if (value.kind === "draft") {
+ const draftId = trimNonEmpty(decodeSegment(value.draftId));
+ if (!draftId) {
+ return null;
+ }
+ return { kind: "draft", draftId };
+ }
if (value.kind === "agent") {
const agentId = trimNonEmpty(decodeSegment(value.agentId));
if (!agentId) {
@@ -250,6 +272,9 @@ function tabEquals(left: WorkspaceTabTarget | null, right: WorkspaceTabTarget |
if (left.kind !== right.kind) {
return false;
}
+ if (left.kind === "draft" && right.kind === "draft") {
+ return left.draftId === right.draftId;
+ }
if (left.kind === "agent" && right.kind === "agent") {
return left.agentId === right.agentId;
}
@@ -267,6 +292,13 @@ function buildTabRoute(input: {
workspaceId: string;
tab: WorkspaceTabTarget;
}): string {
+ if (input.tab.kind === "draft") {
+ return buildHostWorkspaceTabRoute(
+ input.serverId,
+ input.workspaceId,
+ input.tab.draftId
+ );
+ }
if (input.tab.kind === "agent") {
return buildHostWorkspaceAgentRoute(
input.serverId,
@@ -295,6 +327,9 @@ function resolveTabAvailability(input: {
agentsById: Map;
terminalIds: Set;
}): TabAvailability {
+ if (input.tab.kind === "draft") {
+ return "available";
+ }
if (input.tab.kind === "agent") {
if (!input.agentsHydrated) {
return "unknown";
@@ -324,6 +359,9 @@ function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] {
function toWorkspaceTabTarget(
tab: WorkspaceTabDescriptor
): WorkspaceTabTarget {
+ if (tab.kind === "draft") {
+ return { kind: "draft", draftId: tab.draftId };
+ }
if (tab.kind === "agent") {
return { kind: "agent", agentId: tab.agentId };
}
@@ -337,10 +375,16 @@ export function WorkspaceScreen({
serverId,
workspaceId,
routeTab,
+ routeTabId,
}: WorkspaceScreenProps) {
return (
-
+
);
}
@@ -349,6 +393,7 @@ function WorkspaceScreenContent({
serverId,
workspaceId,
routeTab,
+ routeTabId,
}: WorkspaceScreenProps) {
const { theme } = useUnistyles();
const toast = useToast();
@@ -432,7 +477,22 @@ function WorkspaceScreenContent({
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
if (createdTerminal) {
- navigateToTab({ kind: "terminal", terminalId: createdTerminal.id });
+ const tabId = useWorkspaceTabsStore
+ .getState()
+ .openOrFocusTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ target: { kind: "terminal", terminalId: createdTerminal.id },
+ });
+ if (tabId) {
+ router.replace(
+ buildHostWorkspaceTabRoute(
+ normalizedServerId,
+ normalizedWorkspaceId,
+ tabId
+ ) as any
+ );
+ }
}
},
});
@@ -625,216 +685,335 @@ function WorkspaceScreenContent({
[normalizedServerId, normalizedWorkspaceId]
);
- const tabOrder = useWorkspaceTabsStore((state) =>
+ const openTabs = useWorkspaceTabsStore((state) =>
persistenceKey
- ? state.tabOrderByWorkspace[persistenceKey] ?? EMPTY_TAB_ORDER
- : EMPTY_TAB_ORDER
+ ? state.openTabsByWorkspace[persistenceKey] ?? EMPTY_WORKSPACE_TABS
+ : EMPTY_WORKSPACE_TABS
);
- const lastFocusedTabByWorkspace = useWorkspaceTabsStore(
- (state) => state.lastFocusedTabByWorkspace
+ const openTabIdSet = useMemo(() => new Set(openTabs.map((tab) => tab.tabId)), [openTabs]);
+ const focusedTabId = useWorkspaceTabsStore((state) =>
+ persistenceKey ? state.focusedTabIdByWorkspace[persistenceKey] ?? "" : ""
);
- const setLastFocusedTab = useWorkspaceTabsStore(
- (state) => state.setLastFocusedTab
- );
- const setTabOrder = useWorkspaceTabsStore((state) => state.setTabOrder);
-
- const openFileTab = useWorkspaceFileTabsStore((state) => state.openFileTab);
- const closeFileTab = useWorkspaceFileTabsStore((state) => state.closeFileTab);
+ const openDraftTab = useWorkspaceTabsStore((state) => state.openDraftTab);
+ const seedWorkspaceTabs = useWorkspaceTabsStore((state) => state.seedWorkspaceTabs);
+ const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab);
+ const focusTab = useWorkspaceTabsStore((state) => state.focusTab);
+ const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
+ const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
+ const replaceWorkspaceTabTarget = useWorkspaceTabsStore((state) => state.replaceTabTarget);
useEffect(() => {
- if (requestedTab?.kind !== "file") {
+ if (!normalizedServerId || !normalizedWorkspaceId) {
return;
}
- openFileTab({
+ if (openTabs.length > 0) {
+ return;
+ }
+ if (!areWorkspaceAgentsHydrated || !areWorkspaceTerminalsHydrated) {
+ return;
+ }
+ const targets: WorkspaceTabTarget[] = [];
+ for (const agent of workspaceAgents) {
+ targets.push({ kind: "agent", agentId: agent.id });
+ }
+ for (const terminal of terminals) {
+ targets.push({ kind: "terminal", terminalId: terminal.id });
+ }
+ seedWorkspaceTabs({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
- filePath: requestedTab.path,
+ targets,
+ focusedTabId: focusedTabId || null,
});
- }, [normalizedServerId, normalizedWorkspaceId, openFileTab, requestedTab]);
+ }, [
+ areWorkspaceAgentsHydrated,
+ areWorkspaceTerminalsHydrated,
+ focusedTabId,
+ normalizedServerId,
+ normalizedWorkspaceId,
+ openTabs.length,
+ seedWorkspaceTabs,
+ terminals,
+ workspaceAgents,
+ ]);
- const openFilePaths = useWorkspaceFileTabsStore((state) =>
- persistenceKey
- ? state.openFilePathsByWorkspace[persistenceKey] ?? EMPTY_OPEN_FILE_PATHS
- : EMPTY_OPEN_FILE_PATHS
- );
+ useEffect(() => {
+ const normalized = typeof routeTabId === "string" ? routeTabId.trim() : "";
+ if (!normalized || !persistenceKey) {
+ return;
+ }
+ const alreadyOpen = openTabs.some((tab) => tab.tabId === normalized);
+ if (alreadyOpen) {
+ focusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: normalized });
+ return;
+ }
- const baseTabs = useMemo(() => {
+ // If the canonical tab route is opened without local state (fresh load / reconnect),
+ // reconstruct the tab target from the tabId prefix.
+ if (normalized.startsWith("agent_")) {
+ const agentId = normalized.slice("agent_".length).trim();
+ if (agentId) {
+ const tabId = openOrFocusTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ target: { kind: "agent", agentId },
+ });
+ if (tabId) {
+ focusTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ tabId,
+ });
+ }
+ }
+ return;
+ }
+ if (normalized.startsWith("terminal_")) {
+ const terminalId = normalized.slice("terminal_".length).trim();
+ if (terminalId) {
+ const tabId = openOrFocusTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ target: { kind: "terminal", terminalId },
+ });
+ if (tabId) {
+ focusTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ tabId,
+ });
+ }
+ }
+ return;
+ }
+ if (normalized.startsWith("draft_")) {
+ const tabId = openDraftTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ draftId: normalized,
+ });
+ if (tabId) {
+ focusTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ tabId,
+ });
+ }
+ }
+ }, [
+ focusTab,
+ openDraftTab,
+ openOrFocusTab,
+ openTabs,
+ persistenceKey,
+ normalizedServerId,
+ normalizedWorkspaceId,
+ routeTabId,
+ ]);
+
+ useEffect(() => {
+ if (!requestedTab || !persistenceKey) {
+ return;
+ }
+ const tabId = openOrFocusTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ target: requestedTab,
+ });
+ if (tabId) {
+ focusTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ tabId,
+ });
+ }
+ }, [
+ focusTab,
+ normalizedServerId,
+ normalizedWorkspaceId,
+ openOrFocusTab,
+ persistenceKey,
+ requestedTab,
+ ]);
+
+ const activeTabId = useMemo(() => {
+ const fromRoute =
+ typeof routeTabId === "string" ? trimNonEmpty(routeTabId.trim()) : null;
+ if (fromRoute && openTabIdSet.has(fromRoute)) {
+ return fromRoute;
+ }
+ const fromFocus = trimNonEmpty(focusedTabId);
+ if (fromFocus && openTabIdSet.has(fromFocus)) {
+ return fromFocus;
+ }
+ return openTabs[0]?.tabId ?? null;
+ }, [focusedTabId, openTabIdSet, openTabs, routeTabId]);
+
+ useEffect(() => {
+ if (!persistenceKey) {
+ return;
+ }
+
+ const invalidTabIds = openTabs
+ .filter((tab) => {
+ const availability = resolveTabAvailability({
+ tab: tab.target,
+ agentsHydrated: areWorkspaceAgentsHydrated,
+ terminalsHydrated: areWorkspaceTerminalsHydrated,
+ agentsById,
+ terminalIds,
+ });
+ return availability === "invalid";
+ })
+ .map((tab) => tab.tabId);
+
+ if (invalidTabIds.length === 0) {
+ return;
+ }
+
+ for (const tabId of invalidTabIds) {
+ closeWorkspaceTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ tabId,
+ });
+ }
+ }, [
+ agentsById,
+ areWorkspaceAgentsHydrated,
+ areWorkspaceTerminalsHydrated,
+ closeWorkspaceTab,
+ normalizedServerId,
+ normalizedWorkspaceId,
+ openTabs,
+ persistenceKey,
+ terminalIds,
+ ]);
+
+ useEffect(() => {
+ if (!activeTabId || !persistenceKey) {
+ return;
+ }
+ focusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: activeTabId });
+ }, [activeTabId, focusTab, normalizedServerId, normalizedWorkspaceId, persistenceKey]);
+
+ const lastCanonicalTabIdRef = useRef(null);
+ useEffect(() => {
+ if (!activeTabId || !normalizedServerId || !normalizedWorkspaceId) {
+ return;
+ }
+ if (routeTabId && routeTabId.trim() === activeTabId) {
+ lastCanonicalTabIdRef.current = activeTabId;
+ return;
+ }
+ if (lastCanonicalTabIdRef.current === activeTabId) {
+ return;
+ }
+ lastCanonicalTabIdRef.current = activeTabId;
+ router.replace(
+ buildHostWorkspaceTabRoute(normalizedServerId, normalizedWorkspaceId, activeTabId) as any
+ );
+ }, [activeTabId, normalizedServerId, normalizedWorkspaceId, router, routeTabId]);
+
+ const activeTab = useMemo(() => openTabs.find((tab) => tab.tabId === activeTabId) ?? null, [
+ activeTabId,
+ openTabs,
+ ]);
+ const activeTabAvailability = useMemo(() => {
+ if (!activeTab) {
+ return "unknown" as TabAvailability;
+ }
+ return resolveTabAvailability({
+ tab: activeTab.target,
+ agentsHydrated: areWorkspaceAgentsHydrated,
+ terminalsHydrated: areWorkspaceTerminalsHydrated,
+ agentsById,
+ terminalIds,
+ });
+ }, [
+ activeTab,
+ agentsById,
+ areWorkspaceAgentsHydrated,
+ areWorkspaceTerminalsHydrated,
+ terminalIds,
+ ]);
+
+ const tabs = useMemo(() => {
const next: WorkspaceTabDescriptor[] = [];
-
- for (const agent of workspaceAgents) {
- next.push({
- key: `agent:${agent.id}`,
- kind: "agent",
- agentId: agent.id,
- provider: agent.provider,
- label: agent.title?.trim() || "New agent",
- subtitle: `${formatProviderLabel(agent.provider)} agent`,
- });
- }
-
- for (const terminal of terminals) {
- next.push({
- key: `terminal:${terminal.id}`,
- kind: "terminal",
- terminalId: terminal.id,
- label: terminal.name,
- subtitle: "Terminal",
- });
- }
-
- for (const filePath of openFilePaths) {
+ for (const tab of openTabs) {
+ const target = tab.target;
+ if (target.kind === "draft") {
+ next.push({
+ key: tab.tabId,
+ tabId: tab.tabId,
+ kind: "draft",
+ draftId: target.draftId,
+ label: "New agent",
+ subtitle: "Draft",
+ });
+ continue;
+ }
+ if (target.kind === "agent") {
+ const agent = sessionAgents?.get(target.agentId) ?? null;
+ const provider = agent?.provider ?? "claude";
+ next.push({
+ key: tab.tabId,
+ tabId: tab.tabId,
+ kind: "agent",
+ agentId: target.agentId,
+ provider,
+ label: agent?.title?.trim() || "Agent",
+ subtitle: `${formatProviderLabel(provider)} agent`,
+ });
+ continue;
+ }
+ if (target.kind === "terminal") {
+ const terminal = terminals.find((t) => t.id === target.terminalId) ?? null;
+ next.push({
+ key: tab.tabId,
+ tabId: tab.tabId,
+ kind: "terminal",
+ terminalId: target.terminalId,
+ label: terminal?.name ?? "Terminal",
+ subtitle: "Terminal",
+ });
+ continue;
+ }
+ const filePath = target.path;
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;
next.push({
- key: `file:${filePath}`,
+ key: tab.tabId,
+ tabId: tab.tabId,
kind: "file",
filePath,
label: fileName,
subtitle: filePath,
});
}
-
return next;
- }, [openFilePaths, terminals, workspaceAgents]);
-
- const tabs = useMemo(
- () => applyWorkspaceTabOrder({ tabs: baseTabs, keys: tabOrder }),
- [baseTabs, tabOrder]
- );
-
- const storedTab = useMemo(() => {
- if (!persistenceKey) {
- return null;
- }
- return normalizeWorkspaceTab(lastFocusedTabByWorkspace[persistenceKey]);
- }, [lastFocusedTabByWorkspace, persistenceKey]);
-
- const fallbackTab = useMemo(() => {
- const first = tabs[0];
- if (!first) {
- return null;
- }
- if (first.kind === "agent") {
- return { kind: "agent", agentId: first.agentId };
- }
- if (first.kind === "file") {
- return { kind: "file", path: first.filePath };
- }
- return { kind: "terminal", terminalId: first.terminalId };
- }, [tabs]);
+ }, [openTabs, sessionAgents, terminals]);
const handleReorderTabs = useCallback(
(nextTabs: WorkspaceTabDescriptor[]) => {
- setTabOrder({
+ reorderWorkspaceTabs({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
- keys: nextTabs.map((tab) => tab.key),
+ tabIds: nextTabs.map((tab) => tab.tabId),
});
},
- [normalizedServerId, normalizedWorkspaceId, setTabOrder]
+ [normalizedServerId, normalizedWorkspaceId, reorderWorkspaceTabs]
);
- const requestedTabAvailability = useMemo(() => {
- if (!requestedTab) {
- return null;
- }
- return resolveTabAvailability({
- tab: requestedTab,
- agentsHydrated: areWorkspaceAgentsHydrated,
- terminalsHydrated: areWorkspaceTerminalsHydrated,
- agentsById,
- terminalIds,
- });
- }, [
- agentsById,
- areWorkspaceAgentsHydrated,
- areWorkspaceTerminalsHydrated,
- requestedTab,
- terminalIds,
- ]);
-
- const storedTabAvailability = useMemo(() => {
- if (!storedTab) {
- return null;
- }
- return resolveTabAvailability({
- tab: storedTab,
- agentsHydrated: areWorkspaceAgentsHydrated,
- terminalsHydrated: areWorkspaceTerminalsHydrated,
- agentsById,
- terminalIds,
- });
- }, [
- agentsById,
- areWorkspaceAgentsHydrated,
- areWorkspaceTerminalsHydrated,
- storedTab,
- terminalIds,
- ]);
-
- const resolvedTab = useMemo(() => {
- if (requestedTab && requestedTabAvailability !== "invalid") {
- return requestedTab;
- }
-
- if (storedTab && storedTabAvailability !== "invalid") {
- return storedTab;
- }
-
- return fallbackTab;
- }, [fallbackTab, requestedTab, requestedTabAvailability, storedTab, storedTabAvailability]);
-
- const resolvedTabAvailability = useMemo(() => {
- if (!resolvedTab) {
- return null;
- }
-
- return resolveTabAvailability({
- tab: resolvedTab,
- agentsHydrated: areWorkspaceAgentsHydrated,
- terminalsHydrated: areWorkspaceTerminalsHydrated,
- agentsById,
- terminalIds,
- });
- }, [
- agentsById,
- areWorkspaceAgentsHydrated,
- areWorkspaceTerminalsHydrated,
- resolvedTab,
- terminalIds,
- ]);
-
- const navigateToTab = useCallback(
- (tab: WorkspaceTabTarget) => {
- if (tabEquals(tab, resolvedTab)) {
+ const navigateToTabId = useCallback(
+ (tabId: string) => {
+ if (!tabId || !normalizedServerId || !normalizedWorkspaceId) {
return;
}
- if (tab.kind === "file") {
- openFileTab({
- serverId: normalizedServerId,
- workspaceId: normalizedWorkspaceId,
- filePath: tab.path,
- });
- }
- const targetRoute = buildTabRoute({
- serverId: normalizedServerId,
- workspaceId: normalizedWorkspaceId,
- tab,
- });
- setLastFocusedTab({
- serverId: normalizedServerId,
- workspaceId: normalizedWorkspaceId,
- tab,
- });
- router.replace(targetRoute as any);
+ router.replace(
+ buildHostWorkspaceTabRoute(normalizedServerId, normalizedWorkspaceId, tabId) as any
+ );
},
- [
- normalizedServerId,
- normalizedWorkspaceId,
- openFileTab,
- resolvedTab,
- router,
- setLastFocusedTab,
- ]
+ [normalizedServerId, normalizedWorkspaceId, router]
);
const handleOpenFileFromExplorer = useCallback(
@@ -842,56 +1021,18 @@ function WorkspaceScreenContent({
if (isMobile) {
closeToAgent();
}
- navigateToTab({ kind: "file", path: filePath });
+ const tabId = openOrFocusTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ target: { kind: "file", path: filePath },
+ });
+ if (tabId) {
+ navigateToTabId(tabId);
+ }
},
- [closeToAgent, isMobile, navigateToTab]
+ [closeToAgent, isMobile, navigateToTabId, normalizedServerId, normalizedWorkspaceId, openOrFocusTab]
);
- useEffect(() => {
- if (!resolvedTab) {
- return;
- }
- if (resolvedTabAvailability !== "available") {
- return;
- }
-
- setLastFocusedTab({
- serverId: normalizedServerId,
- workspaceId: normalizedWorkspaceId,
- tab: resolvedTab,
- });
- }, [
- normalizedServerId,
- normalizedWorkspaceId,
- resolvedTab,
- resolvedTabAvailability,
- setLastFocusedTab,
- ]);
-
- useEffect(() => {
- if (!resolvedTab) {
- return;
- }
-
- if (tabEquals(requestedTab, resolvedTab)) {
- return;
- }
-
- const targetRoute = buildTabRoute({
- serverId: normalizedServerId,
- workspaceId: normalizedWorkspaceId,
- tab: resolvedTab,
- });
-
- router.replace(targetRoute as any);
- }, [
- normalizedServerId,
- normalizedWorkspaceId,
- requestedTab,
- resolvedTab,
- router,
- ]);
-
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
const [isNewTerminalHovered, setIsNewTerminalHovered] = useState(false);
const [hoveredTabKey, setHoveredTabKey] = useState(null);
@@ -901,33 +1042,14 @@ function WorkspaceScreenContent({
const tabSwitcherAnchorRef = useRef(null);
const tabByKey = useMemo(() => {
- const map = new Map();
+ const map = new Map();
for (const tab of tabs) {
- if (tab.kind === "agent") {
- map.set(tab.key, { kind: "agent", agentId: tab.agentId });
- continue;
- }
- if (tab.kind === "file") {
- map.set(tab.key, { kind: "file", path: tab.filePath });
- continue;
- }
- map.set(tab.key, { kind: "terminal", terminalId: tab.terminalId });
+ map.set(tab.key, tab);
}
return map;
}, [tabs]);
- const activeTabKey = useMemo(() => {
- if (!resolvedTab) {
- return "";
- }
- if (resolvedTab.kind === "agent") {
- return `agent:${resolvedTab.agentId}`;
- }
- if (resolvedTab.kind === "file") {
- return `file:${resolvedTab.path}`;
- }
- return `terminal:${resolvedTab.terminalId}`;
- }, [resolvedTab]);
+ const activeTabKey = activeTabId ?? "";
const tabSwitcherOptions = useMemo(
() =>
@@ -940,25 +1062,31 @@ function WorkspaceScreenContent({
);
const activeAgent = useMemo(() => {
- if (resolvedTab?.kind !== "agent") {
+ if (activeTab?.target.kind !== "agent") {
return null;
}
- return agentsById.get(resolvedTab.agentId) ?? null;
- }, [agentsById, resolvedTab]);
+ return sessionAgents?.get(activeTab.target.agentId) ?? null;
+ }, [activeTab, sessionAgents]);
const activeTabLabel = useMemo(() => {
const active = tabs.find((tab) => tab.key === activeTabKey);
return active?.label ?? "Select tab";
}, [activeTabKey, tabs]);
- const handleCreateAgent = useCallback(() => {
- if (!normalizedServerId) {
+ const handleCreateDraftTab = useCallback(() => {
+ if (!normalizedServerId || !normalizedWorkspaceId) {
return;
}
- router.push(
- buildNewAgentRoute(normalizedServerId, normalizedWorkspaceId) as any
- );
- }, [normalizedServerId, normalizedWorkspaceId, router]);
+ const draftId = generateDraftId();
+ const tabId = openDraftTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ draftId,
+ });
+ if (tabId) {
+ navigateToTabId(tabId);
+ }
+ }, [navigateToTabId, normalizedServerId, normalizedWorkspaceId, openDraftTab]);
const handleCreateTerminal = useCallback(() => {
if (createTerminalMutation.isPending) {
@@ -972,50 +1100,28 @@ function WorkspaceScreenContent({
const handleSelectSwitcherTab = useCallback(
(key: string) => {
- const tab = tabByKey.get(key);
- if (!tab) {
- return;
- }
setIsTabSwitcherOpen(false);
- navigateToTab(tab);
+ navigateToTabId(key);
},
- [navigateToTab, tabByKey]
+ [navigateToTabId]
);
const handleSelectNewTabOption = useCallback(
(key: typeof NEW_TAB_AGENT_OPTION_ID | typeof NEW_TAB_TERMINAL_OPTION_ID) => {
if (key === NEW_TAB_AGENT_OPTION_ID) {
- handleCreateAgent();
+ handleCreateDraftTab();
return;
}
if (key === NEW_TAB_TERMINAL_OPTION_ID) {
handleCreateTerminal();
}
},
- [handleCreateAgent, handleCreateTerminal]
- );
-
- const getTabAfterClosing = useCallback(
- (tabKey: string): WorkspaceTabTarget | null => {
- const currentIndex = tabs.findIndex((tab) => tab.key === tabKey);
- const nextTabs = tabs.filter((tab) => tab.key !== tabKey);
- if (nextTabs.length === 0) {
- return null;
- }
- const safeIndex = currentIndex < 0
- ? 0
- : Math.min(currentIndex, nextTabs.length - 1);
- const candidate = nextTabs[safeIndex] ?? nextTabs[0];
- if (!candidate) {
- return null;
- }
- return toWorkspaceTabTarget(candidate);
- },
- [tabs]
+ [handleCreateDraftTab, handleCreateTerminal]
);
const handleCloseTerminalTab = useCallback(
- async (terminalId: string) => {
+ async (input: { tabId: string; terminalId: string }) => {
+ const { tabId, terminalId } = input;
if (
killTerminalMutation.isPending &&
killTerminalMutation.variables === terminalId
@@ -1036,11 +1142,8 @@ function WorkspaceScreenContent({
killTerminalMutation.mutate(terminalId, {
onSuccess: () => {
- const tabKey = `terminal:${terminalId}`;
- setHoveredTabKey((current) => (current === tabKey ? null : current));
- setHoveredCloseTabKey((current) =>
- current === tabKey ? null : current
- );
+ setHoveredTabKey((current) => (current === tabId ? null : current));
+ setHoveredCloseTabKey((current) => (current === tabId ? null : current));
queryClient.setQueryData(
terminalsQueryKey,
@@ -1050,43 +1153,38 @@ function WorkspaceScreenContent({
}
return {
...current,
- terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
+ terminals: current.terminals.filter(
+ (terminal) => terminal.id !== terminalId
+ ),
};
}
);
- if (resolvedTab?.kind === "terminal" && resolvedTab.terminalId === terminalId) {
- const nextTab = getTabAfterClosing(`terminal:${terminalId}`);
- if (nextTab) {
- navigateToTab(nextTab);
- } else {
- router.replace(
- buildHostWorkspaceRoute(
- normalizedServerId,
- normalizedWorkspaceId
- ) as any
- );
- }
- }
+ closeWorkspaceTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ tabId,
+ });
},
});
},
[
- getTabAfterClosing,
+ closeWorkspaceTab,
killTerminalMutation,
- navigateToTab,
normalizedServerId,
normalizedWorkspaceId,
queryClient,
- resolvedTab,
- router,
terminalsQueryKey,
]
);
const handleCloseAgentTab = useCallback(
- async (agentId: string) => {
- if (!normalizedServerId || isArchivingAgent({ serverId: normalizedServerId, agentId })) {
+ async (input: { tabId: string; agentId: string }) => {
+ const { tabId, agentId } = input;
+ if (
+ !normalizedServerId ||
+ isArchivingAgent({ serverId: normalizedServerId, agentId })
+ ) {
return;
}
@@ -1102,76 +1200,53 @@ function WorkspaceScreenContent({
}
await archiveAgent({ serverId: normalizedServerId, agentId });
-
- const tabKey = `agent:${agentId}`;
- setHoveredTabKey((current) => (current === tabKey ? null : current));
- setHoveredCloseTabKey((current) => (current === tabKey ? null : current));
-
- if (resolvedTab?.kind === "agent" && resolvedTab.agentId === agentId) {
- const nextTab = getTabAfterClosing(tabKey);
- if (nextTab) {
- navigateToTab(nextTab);
- } else {
- router.replace(
- buildHostWorkspaceRoute(
- normalizedServerId,
- normalizedWorkspaceId
- ) as any
- );
- }
- }
+ setHoveredTabKey((current) => (current === tabId ? null : current));
+ setHoveredCloseTabKey((current) => (current === tabId ? null : current));
+ closeWorkspaceTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ tabId,
+ });
},
[
archiveAgent,
- getTabAfterClosing,
+ closeWorkspaceTab,
isArchivingAgent,
- navigateToTab,
normalizedServerId,
normalizedWorkspaceId,
- resolvedTab,
- router,
]
);
- const handleCloseFileTab = useCallback(
- (filePath: string) => {
- const tabKey = `file:${filePath}`;
- const nextTab = resolvedTab?.kind === "file" && resolvedTab.path === filePath
- ? getTabAfterClosing(tabKey)
- : null;
-
- closeFileTab({
+ const handleCloseDraftOrFileTab = useCallback(
+ (tabId: string) => {
+ setHoveredTabKey((current) => (current === tabId ? null : current));
+ setHoveredCloseTabKey((current) => (current === tabId ? null : current));
+ closeWorkspaceTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
- filePath,
+ tabId,
});
+ },
+ [closeWorkspaceTab, normalizedServerId, normalizedWorkspaceId]
+ );
- setHoveredTabKey((current) => (current === tabKey ? null : current));
- setHoveredCloseTabKey((current) => (current === tabKey ? null : current));
-
- if (nextTab) {
- navigateToTab(nextTab);
+ const handleCloseTabById = useCallback(
+ async (tabId: string) => {
+ const tab = tabByKey.get(tabId);
+ if (!tab) {
return;
}
-
- if (resolvedTab?.kind === "file" && resolvedTab.path === filePath) {
- router.replace(
- buildHostWorkspaceRoute(
- normalizedServerId,
- normalizedWorkspaceId
- ) as any
- );
+ if (tab.kind === "terminal") {
+ await handleCloseTerminalTab({ tabId, terminalId: tab.terminalId });
+ return;
}
+ if (tab.kind === "agent") {
+ await handleCloseAgentTab({ tabId, agentId: tab.agentId });
+ return;
+ }
+ handleCloseDraftOrFileTab(tabId);
},
- [
- closeFileTab,
- getTabAfterClosing,
- navigateToTab,
- normalizedServerId,
- normalizedWorkspaceId,
- resolvedTab,
- router,
- ]
+ [handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab, tabByKey]
);
const handleCopyAgentId = useCallback(
@@ -1190,7 +1265,7 @@ function WorkspaceScreenContent({
const handleCopyResumeCommand = useCallback(
async (agentId: string) => {
if (!agentId) return;
- const agent = agentsById.get(agentId) ?? null;
+ const agent = sessionAgents?.get(agentId) ?? null;
const providerSessionId =
agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null;
if (!agent || !providerSessionId) {
@@ -1215,12 +1290,12 @@ function WorkspaceScreenContent({
toast.error("Copy failed");
}
},
- [agentsById, toast]
+ [sessionAgents, toast]
);
const handleCloseTabsToRight = useCallback(
async (tabKey: string) => {
- const startIndex = tabs.findIndex((tab) => tab.key === tabKey);
+ const startIndex = tabs.findIndex((tab) => tab.tabId === tabKey);
if (startIndex < 0) {
return;
}
@@ -1229,35 +1304,35 @@ function WorkspaceScreenContent({
return;
}
- const agentIds: string[] = [];
- const terminalIdsToClose: string[] = [];
- const filePathsToClose: string[] = [];
+ const agentTabs: Array<{ tabId: string; agentId: string }> = [];
+ const terminalTabs: Array<{ tabId: string; terminalId: string }> = [];
+ const otherTabs: Array<{ tabId: string }> = [];
for (const tab of toClose) {
if (tab.kind === "agent") {
- agentIds.push(tab.agentId);
+ agentTabs.push({ tabId: tab.tabId, agentId: tab.agentId });
} else if (tab.kind === "terminal") {
- terminalIdsToClose.push(tab.terminalId);
+ terminalTabs.push({ tabId: tab.tabId, terminalId: tab.terminalId });
} else {
- filePathsToClose.push(tab.filePath);
+ otherTabs.push({ tabId: tab.tabId });
}
}
const confirmed = await confirmDialog({
title: "Close tabs to the right?",
message:
- agentIds.length > 0 && terminalIdsToClose.length > 0 && filePathsToClose.length > 0
- ? `This will archive ${agentIds.length} agent(s), close ${terminalIdsToClose.length} terminal(s), and close ${filePathsToClose.length} file(s). Any running process in a closed terminal will be stopped immediately.`
- : agentIds.length > 0 && terminalIdsToClose.length > 0
- ? `This will archive ${agentIds.length} agent(s) and close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
- : terminalIdsToClose.length > 0 && filePathsToClose.length > 0
- ? `This will close ${terminalIdsToClose.length} terminal(s) and close ${filePathsToClose.length} file(s). Any running process in a closed terminal will be stopped immediately.`
- : agentIds.length > 0 && filePathsToClose.length > 0
- ? `This will archive ${agentIds.length} agent(s) and close ${filePathsToClose.length} file(s).`
- : terminalIdsToClose.length > 0
- ? `This will close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
- : filePathsToClose.length > 0
- ? `This will close ${filePathsToClose.length} file(s).`
- : `This will archive ${agentIds.length} agent(s).`,
+ agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0
+ ? `This will archive ${agentTabs.length} agent(s), close ${terminalTabs.length} terminal(s), and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`
+ : agentTabs.length > 0 && terminalTabs.length > 0
+ ? `This will archive ${agentTabs.length} agent(s) and close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
+ : terminalTabs.length > 0 && otherTabs.length > 0
+ ? `This will close ${terminalTabs.length} terminal(s) and close ${otherTabs.length} tab(s). Any running process in a closed terminal will be stopped immediately.`
+ : agentTabs.length > 0 && otherTabs.length > 0
+ ? `This will archive ${agentTabs.length} agent(s) and close ${otherTabs.length} tab(s).`
+ : terminalTabs.length > 0
+ ? `This will close ${terminalTabs.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
+ : otherTabs.length > 0
+ ? `This will close ${otherTabs.length} tab(s).`
+ : `This will archive ${agentTabs.length} agent(s).`,
confirmLabel: "Close",
cancelLabel: "Cancel",
destructive: true,
@@ -1266,7 +1341,7 @@ function WorkspaceScreenContent({
return;
}
- for (const terminalId of terminalIdsToClose) {
+ for (const { tabId, terminalId } of terminalTabs) {
try {
await killTerminalMutation.mutateAsync(terminalId);
queryClient.setQueryData(terminalsQueryKey, (current) => {
@@ -1278,58 +1353,51 @@ function WorkspaceScreenContent({
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
});
+ closeWorkspaceTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ tabId,
+ });
} catch (error) {
console.warn("[WorkspaceScreen] Failed to close terminal tab to the right", { terminalId, error });
}
}
- for (const agentId of agentIds) {
+ for (const { tabId, agentId } of agentTabs) {
if (!normalizedServerId) {
continue;
}
try {
await archiveAgent({ serverId: normalizedServerId, agentId });
+ closeWorkspaceTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ tabId,
+ });
} catch (error) {
console.warn("[WorkspaceScreen] Failed to archive agent tab to the right", { agentId, error });
}
}
- for (const filePath of filePathsToClose) {
- closeFileTab({
+ for (const { tabId } of otherTabs) {
+ closeWorkspaceTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
- filePath,
+ tabId,
});
}
- const resolvedTabKey = resolvedTab?.kind === "agent"
- ? `agent:${resolvedTab.agentId}`
- : resolvedTab?.kind === "terminal"
- ? `terminal:${resolvedTab.terminalId}`
- : resolvedTab?.kind === "file"
- ? `file:${resolvedTab.path}`
- : null;
const closedKeys = new Set(toClose.map((tab) => tab.key));
- if (resolvedTabKey && closedKeys.has(resolvedTabKey)) {
- const target = tabByKey.get(tabKey);
- if (target) {
- navigateToTab(target);
- }
- }
-
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[
archiveAgent,
- closeFileTab,
+ closeWorkspaceTab,
killTerminalMutation,
- navigateToTab,
normalizedServerId,
normalizedWorkspaceId,
queryClient,
- resolvedTab,
- tabByKey,
tabs,
terminalsQueryKey,
]
@@ -1345,7 +1413,8 @@ function WorkspaceScreenContent({
}, [activeAgent, normalizedServerId, router]);
const renderContent = () => {
- if (!resolvedTab) {
+ const target = activeTab?.target ?? null;
+ if (!target) {
return (
@@ -1354,12 +1423,41 @@ function WorkspaceScreenContent({
);
}
+ if (activeTabAvailability === "invalid") {
+ return (
+
+
+ This tab is no longer available. It will be removed from your workspace.
+
+
+ );
+ }
- if (resolvedTab.kind === "agent") {
+ if (target.kind === "draft") {
+ return (
+ {
+ const tabId = activeTabId ?? target.draftId;
+ replaceWorkspaceTabTarget({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ tabId,
+ target: { kind: "agent", agentId: agentSnapshot.id },
+ });
+ }}
+ />
+ );
+ }
+
+ if (target.kind === "agent") {
return (
);
}
@@ -1381,12 +1479,19 @@ function WorkspaceScreenContent({
{
if (!terminalId) {
return;
}
- navigateToTab({ kind: "terminal", terminalId });
+ const tabId = openOrFocusTab({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ target: { kind: "terminal", terminalId },
+ });
+ if (tabId) {
+ navigateToTabId(tabId);
+ }
}}
hideHeader
manageTerminalDirectorySubscription={false}
@@ -1517,6 +1622,14 @@ function WorkspaceScreenContent({
return ;
}
+ if (activeDescriptor.kind === "draft") {
+ return ;
+ }
+
+ if (activeDescriptor.kind !== "agent") {
+ return ;
+ }
+
const tabAgent = agentsById.get(activeDescriptor.agentId) ?? null;
const tabAgentStatusBucket = tabAgent
? deriveSidebarStateBucket({
@@ -1704,6 +1817,8 @@ function WorkspaceScreenContent({
/>
) : null}
+ ) : tab.kind === "draft" ? (
+
) : tab.kind === "file" ? (
) : (
@@ -1731,18 +1846,7 @@ function WorkspaceScreenContent({
);
}}
onPress={() => {
- if (tab.kind === "agent") {
- navigateToTab({ kind: "agent", agentId: tab.agentId });
- return;
- }
- if (tab.kind === "file") {
- navigateToTab({ kind: "file", path: tab.filePath });
- return;
- }
- navigateToTab({
- kind: "terminal",
- terminalId: tab.terminalId,
- });
+ navigateToTabId(tab.tabId);
}}
>
{
event.stopPropagation?.();
- if (tab.kind === "agent") {
- void handleCloseAgentTab(tab.agentId);
- return;
- }
- if (tab.kind === "file") {
- handleCloseFileTab(tab.filePath);
- return;
- }
- void handleCloseTerminalTab(tab.terminalId);
+ void handleCloseTabById(tab.tabId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
@@ -1853,7 +1951,7 @@ function WorkspaceScreenContent({
tabs.findIndex((t) => t.key === tab.key) === tabs.length - 1
}
onSelect={() => {
- void handleCloseTabsToRight(tab.key);
+ void handleCloseTabsToRight(tab.tabId);
}}
>
Close to the right
@@ -1861,15 +1959,7 @@ function WorkspaceScreenContent({
{
- if (tab.kind === "agent") {
- void handleCloseAgentTab(tab.agentId);
- return;
- }
- if (tab.kind === "file") {
- handleCloseFileTab(tab.filePath);
- return;
- }
- void handleCloseTerminalTab(tab.terminalId);
+ void handleCloseTabById(tab.tabId);
}}
>
Close
diff --git a/packages/app/src/stores/create-flow-store.test.ts b/packages/app/src/stores/create-flow-store.test.ts
index 55f080cf4..78f0d6ec4 100644
--- a/packages/app/src/stores/create-flow-store.test.ts
+++ b/packages/app/src/stores/create-flow-store.test.ts
@@ -3,7 +3,7 @@ import { useCreateFlowStore } from "./create-flow-store";
describe("create-flow-store", () => {
beforeEach(() => {
- useCreateFlowStore.setState({ pending: null });
+ useCreateFlowStore.setState({ pendingByDraftId: {} });
});
it("tracks lifecycle transitions explicitly", () => {
@@ -18,10 +18,10 @@ describe("create-flow-store", () => {
images: [],
});
- expect(useCreateFlowStore.getState().pending?.lifecycle).toBe("active");
+ expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]?.lifecycle).toBe("active");
- useCreateFlowStore.getState().markLifecycle("abandoned");
- expect(useCreateFlowStore.getState().pending?.lifecycle).toBe("abandoned");
+ useCreateFlowStore.getState().markLifecycle({ draftId: "draft-1", lifecycle: "abandoned" });
+ expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]?.lifecycle).toBe("abandoned");
});
it("rekeys draft id idempotently", () => {
@@ -44,6 +44,31 @@ describe("create-flow-store", () => {
toDraftId: "draft-b",
});
- expect(useCreateFlowStore.getState().pending?.draftId).toBe("draft-b");
+ expect(useCreateFlowStore.getState().pendingByDraftId["draft-b"]?.draftId).toBe("draft-b");
+ });
+
+ it("supports multiple pending attempts", () => {
+ useCreateFlowStore.getState().setPending({
+ draftId: "draft-1",
+ serverId: "server-1",
+ agentId: null,
+ clientMessageId: "msg-1",
+ text: "one",
+ timestamp: Date.now(),
+ });
+ useCreateFlowStore.getState().setPending({
+ draftId: "draft-2",
+ serverId: "server-1",
+ agentId: null,
+ clientMessageId: "msg-2",
+ text: "two",
+ timestamp: Date.now(),
+ });
+
+ const state = useCreateFlowStore.getState();
+ expect(Object.keys(state.pendingByDraftId).sort()).toEqual(["draft-1", "draft-2"]);
+
+ useCreateFlowStore.getState().clear({ draftId: "draft-1" });
+ expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined();
});
});
diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts
index 62c83ac4a..ba566468a 100644
--- a/packages/app/src/stores/create-flow-store.ts
+++ b/packages/app/src/stores/create-flow-store.ts
@@ -15,63 +15,82 @@ type PendingCreateAttempt = {
};
type CreateFlowState = {
- pending: PendingCreateAttempt | null;
+ pendingByDraftId: Record;
setPending: (
pending: Omit
) => void;
- updateAgentId: (agentId: string) => void;
- markLifecycle: (lifecycle: CreateFlowLifecycleState) => void;
+ updateAgentId: (input: { draftId: string; agentId: string }) => void;
+ markLifecycle: (input: {
+ draftId: string;
+ lifecycle: CreateFlowLifecycleState;
+ }) => void;
rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void;
- clear: () => void;
+ clear: (input: { draftId: string }) => void;
+ clearAll: () => void;
};
export const useCreateFlowStore = create((set) => ({
- pending: null,
+ pendingByDraftId: {},
setPending: (pending) =>
- set({
- pending: {
- ...pending,
- lifecycle: "active",
+ set((state) => ({
+ pendingByDraftId: {
+ ...state.pendingByDraftId,
+ [pending.draftId]: {
+ ...pending,
+ lifecycle: "active",
+ },
},
- }),
- updateAgentId: (agentId) =>
+ })),
+ updateAgentId: ({ draftId, agentId }) =>
set((state) => {
- if (!state.pending || state.pending.agentId === agentId) {
+ const current = state.pendingByDraftId[draftId];
+ if (!current || current.agentId === agentId) {
return state;
}
return {
- pending: {
- ...state.pending,
- agentId,
+ pendingByDraftId: {
+ ...state.pendingByDraftId,
+ [draftId]: { ...current, agentId },
},
};
}),
- markLifecycle: (lifecycle) =>
+ markLifecycle: ({ draftId, lifecycle }) =>
set((state) => {
- if (!state.pending || state.pending.lifecycle === lifecycle) {
+ const current = state.pendingByDraftId[draftId];
+ if (!current || current.lifecycle === lifecycle) {
return state;
}
return {
- pending: {
- ...state.pending,
- lifecycle,
+ pendingByDraftId: {
+ ...state.pendingByDraftId,
+ [draftId]: { ...current, lifecycle },
},
};
}),
rekeyDraft: ({ fromDraftId, toDraftId }) =>
set((state) => {
- if (!state.pending || state.pending.draftId !== fromDraftId) {
+ const current = state.pendingByDraftId[fromDraftId];
+ if (!current) {
return state;
}
- if (state.pending.draftId === toDraftId) {
+ if (fromDraftId === toDraftId) {
return state;
}
+ const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
return {
- pending: {
- ...state.pending,
- draftId: toDraftId,
+ pendingByDraftId: {
+ ...rest,
+ [toDraftId]: { ...current, draftId: toDraftId },
},
};
}),
- clear: () => set({ pending: null }),
+ clear: ({ draftId }) =>
+ set((state) => {
+ if (!state.pendingByDraftId[draftId]) {
+ return state;
+ }
+ const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
+ return { pendingByDraftId: rest };
+ }),
+ clearAll: () => set({ pendingByDraftId: {} }),
}));
diff --git a/packages/app/src/stores/draft-store.ts b/packages/app/src/stores/draft-store.ts
index 6e198dc3c..5ecc76c43 100644
--- a/packages/app/src/stores/draft-store.ts
+++ b/packages/app/src/stores/draft-store.ts
@@ -251,8 +251,11 @@ async function runAttachmentGc(): Promise {
import("@/stores/session-store"),
]);
- const pendingCreate = useCreateFlowStore.getState().pending;
- if (pendingCreate?.lifecycle === "active" && pendingCreate.images) {
+ const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId;
+ for (const pendingCreate of Object.values(pendingByDraftId)) {
+ if (pendingCreate.lifecycle !== "active" || !pendingCreate.images) {
+ continue;
+ }
for (const image of pendingCreate.images) {
referencedIds.add(image.id);
}
diff --git a/packages/app/src/stores/workspace-file-tabs-store.ts b/packages/app/src/stores/workspace-file-tabs-store.ts
deleted file mode 100644
index e8955d777..000000000
--- a/packages/app/src/stores/workspace-file-tabs-store.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { create } from "zustand";
-import { buildWorkspaceTabPersistenceKey } from "./workspace-tabs-store";
-
-function trimNonEmpty(value: string | null | undefined): string | null {
- if (typeof value !== "string") {
- return null;
- }
- const trimmed = value.trim();
- return trimmed.length > 0 ? trimmed : null;
-}
-
-function normalizeFilePath(value: string | null | undefined): string | null {
- const trimmed = trimNonEmpty(value);
- if (!trimmed) {
- return null;
- }
- return trimmed.replace(/\\/g, "/");
-}
-
-type WorkspaceFileTabsState = {
- openFilePathsByWorkspace: Record;
- openFileTab: (input: {
- serverId: string;
- workspaceId: string;
- filePath: string;
- }) => void;
- closeFileTab: (input: {
- serverId: string;
- workspaceId: string;
- filePath: string;
- }) => void;
- isFileTabOpen: (input: {
- serverId: string;
- workspaceId: string;
- filePath: string;
- }) => boolean;
-};
-
-export const useWorkspaceFileTabsStore = create()(
- (set, get) => ({
- openFilePathsByWorkspace: {},
- openFileTab: ({ serverId, workspaceId, filePath }) => {
- const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
- const normalizedFilePath = normalizeFilePath(filePath);
- if (!key || !normalizedFilePath) {
- return;
- }
-
- set((state) => {
- const current = state.openFilePathsByWorkspace[key] ?? [];
- if (current.includes(normalizedFilePath)) {
- return state;
- }
- return {
- openFilePathsByWorkspace: {
- ...state.openFilePathsByWorkspace,
- [key]: [...current, normalizedFilePath],
- },
- };
- });
- },
- closeFileTab: ({ serverId, workspaceId, filePath }) => {
- const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
- const normalizedFilePath = normalizeFilePath(filePath);
- if (!key || !normalizedFilePath) {
- return;
- }
-
- set((state) => {
- const current = state.openFilePathsByWorkspace[key] ?? [];
- if (current.length === 0 || !current.includes(normalizedFilePath)) {
- return state;
- }
- const next = current.filter((path) => path !== normalizedFilePath);
- if (next.length === 0) {
- const { [key]: _removed, ...rest } = state.openFilePathsByWorkspace;
- return { openFilePathsByWorkspace: rest };
- }
- return {
- openFilePathsByWorkspace: {
- ...state.openFilePathsByWorkspace,
- [key]: next,
- },
- };
- });
- },
- isFileTabOpen: ({ serverId, workspaceId, filePath }) => {
- const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
- const normalizedFilePath = normalizeFilePath(filePath);
- if (!key || !normalizedFilePath) {
- return false;
- }
- const current = get().openFilePathsByWorkspace[key] ?? [];
- return current.includes(normalizedFilePath);
- },
- })
-);
-
diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts
index 8b0c7e841..c8bdfeb11 100644
--- a/packages/app/src/stores/workspace-tabs-store.ts
+++ b/packages/app/src/stores/workspace-tabs-store.ts
@@ -3,25 +3,16 @@ import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type WorkspaceTabTarget =
+ | { kind: "draft"; draftId: string }
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string };
-function normalizeKeys(keys: string[]): string[] {
- const seen = new Set();
- const normalized: string[] = [];
-
- for (const rawKey of keys) {
- const key = rawKey.trim();
- if (!key || seen.has(key)) {
- continue;
- }
- seen.add(key);
- normalized.push(key);
- }
-
- return normalized;
-}
+export type WorkspaceTab = {
+ tabId: string;
+ target: WorkspaceTabTarget;
+ createdAt: number;
+};
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
@@ -31,31 +22,8 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
-function normalizeWorkspaceTab(
- value: WorkspaceTabTarget | null | undefined
-): WorkspaceTabTarget | null {
- if (!value || typeof value !== "object" || typeof value.kind !== "string") {
- return null;
- }
- if (value.kind === "agent") {
- const agentId = trimNonEmpty(value.agentId);
- if (!agentId) {
- return null;
- }
- return { kind: "agent", agentId };
- }
- if (value.kind === "terminal") {
- const terminalId = trimNonEmpty(value.terminalId);
- if (!terminalId) {
- return null;
- }
- return { kind: "terminal", terminalId };
- }
- if (value.kind === "file") {
- // File tabs are session-only; do not persist in workspace tab memory.
- return null;
- }
- return null;
+function normalizeWorkspaceId(value: string): string {
+ return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
}
export function buildWorkspaceTabPersistenceKey(input: {
@@ -67,140 +35,433 @@ export function buildWorkspaceTabPersistenceKey(input: {
if (!serverId || !workspaceId) {
return null;
}
- return `${serverId}:${workspaceId}`;
+ return `${serverId}:${normalizeWorkspaceId(workspaceId)}`;
+}
+
+function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): WorkspaceTabTarget | null {
+ if (!value || typeof value !== "object" || typeof value.kind !== "string") {
+ return null;
+ }
+ if (value.kind === "draft") {
+ const draftId = trimNonEmpty(value.draftId);
+ return draftId ? { kind: "draft", draftId } : null;
+ }
+ if (value.kind === "agent") {
+ const agentId = trimNonEmpty(value.agentId);
+ return agentId ? { kind: "agent", agentId } : null;
+ }
+ if (value.kind === "terminal") {
+ const terminalId = trimNonEmpty(value.terminalId);
+ return terminalId ? { kind: "terminal", terminalId } : null;
+ }
+ if (value.kind === "file") {
+ const path = trimNonEmpty(value.path);
+ return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
+ }
+ return null;
+}
+
+function buildDeterministicTabId(target: WorkspaceTabTarget): string {
+ if (target.kind === "draft") {
+ return target.draftId;
+ }
+ if (target.kind === "agent") {
+ return `agent_${target.agentId}`;
+ }
+ if (target.kind === "terminal") {
+ return `terminal_${target.terminalId}`;
+ }
+ // File tabs are session-only, so stable IDs are still useful for in-session routing.
+ return `file_${target.path}`;
+}
+
+function targetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
+ if (left.kind !== right.kind) {
+ return false;
+ }
+ if (left.kind === "draft" && right.kind === "draft") {
+ return left.draftId === right.draftId;
+ }
+ if (left.kind === "agent" && right.kind === "agent") {
+ return left.agentId === right.agentId;
+ }
+ if (left.kind === "terminal" && right.kind === "terminal") {
+ return left.terminalId === right.terminalId;
+ }
+ if (left.kind === "file" && right.kind === "file") {
+ return left.path === right.path;
+ }
+ return false;
}
type WorkspaceTabsState = {
- lastFocusedTabByWorkspace: Record;
- tabOrderByWorkspace: Record;
- setLastFocusedTab: (input: {
+ openTabsByWorkspace: Record;
+ focusedTabIdByWorkspace: Record;
+ openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null;
+ seedWorkspaceTabs: (input: {
serverId: string;
workspaceId: string;
- tab: WorkspaceTabTarget;
+ targets: WorkspaceTabTarget[];
+ focusedTabId?: string | null;
}) => void;
- getLastFocusedTab: (input: {
+ openOrFocusTab: (input: {
serverId: string;
workspaceId: string;
- }) => WorkspaceTabTarget | null;
- setTabOrder: (input: {
+ target: WorkspaceTabTarget;
+ }) => string | null;
+ focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
+ closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
+ reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
+ replaceTabTarget: (input: {
serverId: string;
workspaceId: string;
- keys: string[];
+ tabId: string;
+ target: WorkspaceTabTarget;
}) => void;
+ getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
};
export const useWorkspaceTabsStore = create()(
persist(
(set, get) => ({
- lastFocusedTabByWorkspace: {},
- tabOrderByWorkspace: {},
- setLastFocusedTab: ({ serverId, workspaceId, tab }) => {
+ openTabsByWorkspace: {},
+ focusedTabIdByWorkspace: {},
+ openDraftTab: ({ serverId, workspaceId, draftId }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
- const normalizedTab = normalizeWorkspaceTab(tab);
- if (!key || !normalizedTab) {
- return;
- }
-
- set((state) => {
- const current = state.lastFocusedTabByWorkspace[key];
- if (
- current &&
- current.kind === normalizedTab.kind &&
- ((current.kind === "agent" && normalizedTab.kind === "agent"
- ? current.agentId === normalizedTab.agentId
- : current.kind === "terminal" && normalizedTab.kind === "terminal"
- ? current.terminalId === normalizedTab.terminalId
- : false))
- ) {
- return state;
- }
-
- return {
- lastFocusedTabByWorkspace: {
- ...state.lastFocusedTabByWorkspace,
- [key]: normalizedTab,
- },
- };
- });
- },
- getLastFocusedTab: ({ serverId, workspaceId }) => {
- const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
- if (!key) {
+ const normalizedDraftId = trimNonEmpty(draftId);
+ if (!key || !normalizedDraftId) {
return null;
}
- const value = get().lastFocusedTabByWorkspace[key];
- return normalizeWorkspaceTab(value);
+
+ const target: WorkspaceTabTarget = { kind: "draft", draftId: normalizedDraftId };
+ const tabId = buildDeterministicTabId(target);
+ const now = Date.now();
+ set((state) => {
+ const current = state.openTabsByWorkspace[key] ?? [];
+ if (current.some((tab) => tab.tabId === tabId)) {
+ return {
+ ...state,
+ focusedTabIdByWorkspace: {
+ ...state.focusedTabIdByWorkspace,
+ [key]: tabId,
+ },
+ };
+ }
+ const nextTabs: WorkspaceTab[] = [...current, { tabId, target, createdAt: now }];
+ return {
+ openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
+ focusedTabIdByWorkspace: { ...state.focusedTabIdByWorkspace, [key]: tabId },
+ };
+ });
+ return tabId;
},
- setTabOrder: ({ serverId, workspaceId, keys }) => {
+ seedWorkspaceTabs: ({ serverId, workspaceId, targets, focusedTabId }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!key) {
return;
}
- const normalized = normalizeKeys(keys);
+ const now = Date.now();
+ const nextTabs: WorkspaceTab[] = [];
+ const used = new Set();
+ for (const raw of targets) {
+ const normalized = normalizeTabTarget(raw);
+ if (!normalized) {
+ continue;
+ }
+ const tabId = buildDeterministicTabId(normalized);
+ if (used.has(tabId)) {
+ continue;
+ }
+ used.add(tabId);
+ nextTabs.push({ tabId, target: normalized, createdAt: now });
+ }
+ if (nextTabs.length === 0) {
+ return;
+ }
set((state) => {
- const current = state.tabOrderByWorkspace[key] ?? [];
- if (current.length === normalized.length) {
- let isSame = true;
- for (let index = 0; index < current.length; index += 1) {
- if (current[index] !== normalized[index]) {
- isSame = false;
- break;
- }
- }
- if (isSame) {
+ const current = state.openTabsByWorkspace[key] ?? [];
+ if (current.length > 0) {
+ return state;
+ }
+ const resolvedFocus = trimNonEmpty(focusedTabId) ?? nextTabs[0]?.tabId ?? null;
+ return {
+ openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
+ ...(resolvedFocus
+ ? {
+ focusedTabIdByWorkspace: {
+ ...state.focusedTabIdByWorkspace,
+ [key]: resolvedFocus,
+ },
+ }
+ : {}),
+ };
+ });
+ },
+ openOrFocusTab: ({ serverId, workspaceId, target }) => {
+ const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
+ const normalizedTarget = normalizeTabTarget(target);
+ if (!key || !normalizedTarget) {
+ return null;
+ }
+ const tabId = buildDeterministicTabId(normalizedTarget);
+ const now = Date.now();
+ set((state) => {
+ const current = state.openTabsByWorkspace[key] ?? [];
+ const existing = current.find((tab) => tab.tabId === tabId);
+ if (existing) {
+ if (state.focusedTabIdByWorkspace[key] === tabId) {
return state;
}
+ return {
+ ...state,
+ focusedTabIdByWorkspace: {
+ ...state.focusedTabIdByWorkspace,
+ [key]: tabId,
+ },
+ };
}
-
+ const nextTabs: WorkspaceTab[] = [...current, { tabId, target: normalizedTarget, createdAt: now }];
return {
- tabOrderByWorkspace: {
- ...state.tabOrderByWorkspace,
- [key]: normalized,
+ openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
+ focusedTabIdByWorkspace: { ...state.focusedTabIdByWorkspace, [key]: tabId },
+ };
+ });
+ return tabId;
+ },
+ focusTab: ({ serverId, workspaceId, tabId }) => {
+ const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
+ const normalizedTabId = trimNonEmpty(tabId);
+ if (!key || !normalizedTabId) {
+ return;
+ }
+ set((state) => {
+ if (state.focusedTabIdByWorkspace[key] === normalizedTabId) {
+ return state;
+ }
+ return {
+ ...state,
+ focusedTabIdByWorkspace: {
+ ...state.focusedTabIdByWorkspace,
+ [key]: normalizedTabId,
},
};
});
},
+ closeTab: ({ serverId, workspaceId, tabId }) => {
+ const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
+ const normalizedTabId = trimNonEmpty(tabId);
+ if (!key || !normalizedTabId) {
+ return;
+ }
+ set((state) => {
+ const currentTabs = state.openTabsByWorkspace[key] ?? [];
+ if (currentTabs.length === 0 || !currentTabs.some((tab) => tab.tabId === normalizedTabId)) {
+ return state;
+ }
+ const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId);
+ const nextOpenTabsByWorkspace =
+ nextTabs.length === 0
+ ? (() => {
+ const { [key]: _removed, ...rest } = state.openTabsByWorkspace;
+ return rest;
+ })()
+ : { ...state.openTabsByWorkspace, [key]: nextTabs };
+
+ const currentFocused = state.focusedTabIdByWorkspace[key] ?? null;
+ const nextFocused =
+ currentFocused !== normalizedTabId
+ ? currentFocused
+ : nextTabs[nextTabs.length - 1]?.tabId ?? null;
+ const nextFocusedByWorkspace = (() => {
+ if (!nextFocused) {
+ const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
+ return rest;
+ }
+ return { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
+ })();
+
+ return {
+ openTabsByWorkspace: nextOpenTabsByWorkspace,
+ focusedTabIdByWorkspace: nextFocusedByWorkspace,
+ };
+ });
+ },
+ reorderTabs: ({ serverId, workspaceId, tabIds }) => {
+ const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
+ if (!key) {
+ return;
+ }
+ const normalized = tabIds.map((id) => id.trim()).filter(Boolean);
+ set((state) => {
+ const current = state.openTabsByWorkspace[key] ?? [];
+ if (current.length <= 1) {
+ return state;
+ }
+ const byId = new Map(current.map((tab) => [tab.tabId, tab]));
+ const used = new Set();
+ const next: WorkspaceTab[] = [];
+ for (const id of normalized) {
+ const tab = byId.get(id);
+ if (!tab || used.has(id)) {
+ continue;
+ }
+ used.add(id);
+ next.push(tab);
+ }
+ for (const tab of current) {
+ if (used.has(tab.tabId)) {
+ continue;
+ }
+ next.push(tab);
+ }
+ if (next.length !== current.length) {
+ return state;
+ }
+ let same = true;
+ for (let i = 0; i < next.length; i += 1) {
+ if (next[i]?.tabId !== current[i]?.tabId) {
+ same = false;
+ break;
+ }
+ }
+ if (same) {
+ return state;
+ }
+ return {
+ ...state,
+ openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: next },
+ };
+ });
+ },
+ replaceTabTarget: ({ serverId, workspaceId, tabId, target }) => {
+ const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
+ const normalizedTabId = trimNonEmpty(tabId);
+ const normalizedTarget = normalizeTabTarget(target);
+ if (!key || !normalizedTabId || !normalizedTarget) {
+ return;
+ }
+
+ set((state) => {
+ const current = state.openTabsByWorkspace[key] ?? [];
+ const idx = current.findIndex((tab) => tab.tabId === normalizedTabId);
+ if (idx < 0) {
+ return state;
+ }
+ const existing = current[idx];
+ if (existing && targetsEqual(existing.target, normalizedTarget)) {
+ return state;
+ }
+ const nextTabs = [...current];
+ nextTabs[idx] = {
+ tabId: normalizedTabId,
+ target: normalizedTarget,
+ createdAt: existing?.createdAt ?? Date.now(),
+ };
+ return {
+ ...state,
+ openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
+ };
+ });
+ },
+ getWorkspaceTabs: ({ serverId, workspaceId }) => {
+ const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
+ if (!key) {
+ return [];
+ }
+ return get().openTabsByWorkspace[key] ?? [];
+ },
}),
{
name: "workspace-tabs-state",
- version: 2,
+ version: 3,
storage: createJSONStorage(() => AsyncStorage),
+ partialize: (state) => {
+ const nextOpenTabsByWorkspace: Record = {};
+ for (const key in state.openTabsByWorkspace) {
+ const tabs = state.openTabsByWorkspace[key] ?? [];
+ const persistedTabs = tabs.filter((tab) => tab.target.kind !== "file");
+ if (persistedTabs.length > 0) {
+ nextOpenTabsByWorkspace[key] = persistedTabs;
+ }
+ }
+ return {
+ openTabsByWorkspace: nextOpenTabsByWorkspace,
+ focusedTabIdByWorkspace: state.focusedTabIdByWorkspace,
+ } as WorkspaceTabsState;
+ },
migrate: (persistedState) => {
- const state = persistedState as
+ // Previous versions stored tab order + last focused tab by workspace using agent/terminal targets.
+ const legacy = persistedState as
| {
- lastFocusedTabByWorkspace?: Record;
+ version?: number;
+ state?: any;
+ lastFocusedTabByWorkspace?: Record;
tabOrderByWorkspace?: Record;
}
| undefined;
- const raw = state?.lastFocusedTabByWorkspace ?? {};
- const next: Record = {};
+ const rawState = (legacy as any)?.state ?? legacy ?? {};
+ const legacyFocused = rawState.lastFocusedTabByWorkspace ?? {};
+ const legacyOrder = rawState.tabOrderByWorkspace ?? {};
- for (const key in raw) {
- const value = raw[key];
- const normalized = normalizeWorkspaceTab(value);
- if (normalized) {
- next[key] = normalized;
+ const openTabsByWorkspace: Record = {};
+ const focusedTabIdByWorkspace: Record = {};
+
+ for (const key in legacyOrder) {
+ const list = legacyOrder[key];
+ if (!Array.isArray(list) || list.length === 0) {
+ continue;
+ }
+ const now = Date.now();
+ const tabs: WorkspaceTab[] = [];
+ for (const entry of list) {
+ const raw = typeof entry === "string" ? entry.trim() : "";
+ if (!raw) continue;
+ if (raw.startsWith("agent:")) {
+ const agentId = raw.slice("agent:".length).trim();
+ if (!agentId) continue;
+ tabs.push({
+ tabId: `agent_${agentId}`,
+ target: { kind: "agent", agentId },
+ createdAt: now,
+ });
+ continue;
+ }
+ if (raw.startsWith("terminal:")) {
+ const terminalId = raw.slice("terminal:".length).trim();
+ if (!terminalId) continue;
+ tabs.push({
+ tabId: `terminal_${terminalId}`,
+ target: { kind: "terminal", terminalId },
+ createdAt: now,
+ });
+ }
+ }
+ if (tabs.length > 0) {
+ openTabsByWorkspace[key] = tabs;
}
}
- const rawOrder = state?.tabOrderByWorkspace ?? {};
- const nextOrder: Record = {};
- for (const key in rawOrder) {
- const list = rawOrder[key];
- if (!Array.isArray(list)) {
+ for (const key in legacyFocused) {
+ const value = legacyFocused[key];
+ if (!value || typeof value !== "object" || typeof value.kind !== "string") {
continue;
}
- const normalized = normalizeKeys(list.map((value) => String(value)));
- if (normalized.length > 0) {
- nextOrder[key] = normalized;
+ if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
+ focusedTabIdByWorkspace[key] = `agent_${value.agentId.trim()}`;
+ }
+ if (
+ value.kind === "terminal" &&
+ typeof value.terminalId === "string" &&
+ value.terminalId.trim()
+ ) {
+ focusedTabIdByWorkspace[key] = `terminal_${value.terminalId.trim()}`;
}
}
return {
- ...state,
- lastFocusedTabByWorkspace: next,
- tabOrderByWorkspace: nextOrder,
+ openTabsByWorkspace,
+ focusedTabIdByWorkspace,
};
},
}
diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts
index b0dd0dfb2..1db108c1a 100644
--- a/packages/app/src/utils/host-routes.test.ts
+++ b/packages/app/src/utils/host-routes.test.ts
@@ -11,6 +11,7 @@ import {
parseHostDraftRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
parseHostWorkspaceFileRouteFromPathname,
+ parseHostWorkspaceTabRouteFromPathname,
parseHostWorkspaceTerminalRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "./host-routes";
@@ -73,6 +74,15 @@ describe("workspace route parsing", () => {
});
});
+ it("parses workspace route for /tab targets", () => {
+ expect(
+ parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123")
+ ).toEqual({
+ serverId: "local",
+ workspaceId: "/tmp/repo",
+ });
+ });
+
it("parses workspace file route", () => {
const encodedPath = encodeFilePathForPathSegment("src/index.ts");
expect(
@@ -110,6 +120,18 @@ describe("workspace route parsing", () => {
});
});
+ it("parses workspace tab route", () => {
+ expect(
+ parseHostWorkspaceTabRouteFromPathname(
+ "/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123"
+ )
+ ).toEqual({
+ serverId: "local",
+ workspaceId: "/tmp/repo",
+ tabId: "draft_abc123",
+ });
+ });
+
it("still parses legacy percent-encoded workspace routes", () => {
expect(
parseHostWorkspaceAgentRouteFromPathname(
diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts
index 3cab853ed..a47952015 100644
--- a/packages/app/src/utils/host-routes.ts
+++ b/packages/app/src/utils/host-routes.ts
@@ -231,6 +231,10 @@ export function parseHostWorkspaceRouteFromPathname(
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx);
}
+ const tabIdx = pathname.lastIndexOf("/tab/");
+ if (tabIdx >= 0 && tabIdx > workspaceIdStart) {
+ workspaceIdEnd = Math.min(workspaceIdEnd, tabIdx);
+ }
const fileIdx = pathname.lastIndexOf("/file/");
if (fileIdx >= 0 && fileIdx > workspaceIdStart) {
workspaceIdEnd = Math.min(workspaceIdEnd, fileIdx);
@@ -344,6 +348,55 @@ export function parseHostWorkspaceTerminalRouteFromPathname(
return { serverId, workspaceId, terminalId };
}
+export function parseHostWorkspaceTabRouteFromPathname(
+ pathname: string
+): { serverId: string; workspaceId: string; tabId: string } | null {
+ const prefix = "/h/";
+ if (!pathname.startsWith(prefix)) {
+ return null;
+ }
+
+ const serverIdStart = prefix.length;
+ const serverIdEnd = pathname.indexOf("/", serverIdStart);
+ if (serverIdEnd < 0) {
+ return null;
+ }
+ const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
+ const serverId = trimNonEmpty(decodeSegment(rawServerId));
+ if (!serverId) {
+ return null;
+ }
+
+ const workspacePrefix = "/workspace/";
+ if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
+ return null;
+ }
+
+ const workspaceIdStart = serverIdEnd + workspacePrefix.length;
+ const tabMarker = "/tab/";
+ const tabIdx = pathname.lastIndexOf(tabMarker);
+ if (tabIdx < 0 || tabIdx <= workspaceIdStart) {
+ return null;
+ }
+
+ const rawWorkspaceId = pathname.slice(workspaceIdStart, tabIdx).replace(/\/+$/, "");
+ const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
+ if (!workspaceId) {
+ return null;
+ }
+
+ const tabIdStart = tabIdx + tabMarker.length;
+ const tabIdEnd = pathname.indexOf("/", tabIdStart);
+ const rawTabId =
+ tabIdEnd < 0 ? pathname.slice(tabIdStart) : pathname.slice(tabIdStart, tabIdEnd);
+ const tabId = trimNonEmpty(decodeSegment(rawTabId));
+ if (!tabId) {
+ return null;
+ }
+
+ return { serverId, workspaceId, tabId };
+}
+
export function parseHostWorkspaceFileRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string; filePath: string } | null {
@@ -435,6 +488,19 @@ export function buildHostWorkspaceTerminalRoute(
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
}
+export function buildHostWorkspaceTabRoute(
+ serverId: string,
+ workspaceId: string,
+ tabId: string
+): string {
+ const base = buildHostWorkspaceRoute(serverId, workspaceId);
+ const normalizedTabId = trimNonEmpty(tabId);
+ if (base === "/" || !normalizedTabId) {
+ return "/";
+ }
+ return `${base}/tab/${encodeSegment(normalizedTabId)}`;
+}
+
export function buildHostWorkspaceFileRoute(
serverId: string,
workspaceId: string,
diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts
index 7f5598400..badb1a11e 100644
--- a/packages/server/src/server/file-explorer/service.test.ts
+++ b/packages/server/src/server/file-explorer/service.test.ts
@@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
-import { listDirectoryEntries } from "./service.js";
+import { listDirectoryEntries, readExplorerFile } from "./service.js";
async function createTempDir(prefix: string): Promise {
return mkdtemp(path.join(os.tmpdir(), prefix));
@@ -31,4 +31,69 @@ describe("file explorer service", () => {
await rm(root, { recursive: true, force: true });
}
});
+
+ it("reads .ex files as text", async () => {
+ const root = await createTempDir("paseo-file-explorer-");
+
+ try {
+ const filePath = path.join(root, "sample.ex");
+ const content = "defmodule Sample do\nend\n";
+ await writeFile(filePath, content, "utf-8");
+
+ const result = await readExplorerFile({
+ root,
+ relativePath: "sample.ex",
+ });
+
+ expect(result.kind).toBe("text");
+ expect(result.encoding).toBe("utf-8");
+ expect(result.mimeType).toBe("text/plain");
+ expect(result.content).toBe(content);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("reads unknown extension text files as text", async () => {
+ const root = await createTempDir("paseo-file-explorer-");
+
+ try {
+ const filePath = path.join(root, "notes.customext");
+ const content = "hello from a custom text file\n";
+ await writeFile(filePath, content, "utf-8");
+
+ const result = await readExplorerFile({
+ root,
+ relativePath: "notes.customext",
+ });
+
+ expect(result.kind).toBe("text");
+ expect(result.encoding).toBe("utf-8");
+ expect(result.mimeType).toBe("text/plain");
+ expect(result.content).toBe(content);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("classifies files with null bytes as binary", async () => {
+ const root = await createTempDir("paseo-file-explorer-");
+
+ try {
+ const filePath = path.join(root, "blob.weird");
+ await writeFile(filePath, Buffer.from([0x48, 0x65, 0x00, 0x6c, 0x6f]));
+
+ const result = await readExplorerFile({
+ root,
+ relativePath: "blob.weird",
+ });
+
+ expect(result.kind).toBe("binary");
+ expect(result.encoding).toBe("none");
+ expect(result.content).toBeUndefined();
+ expect(result.mimeType).toBe("application/octet-stream");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ });
});
diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts
index 6852448ab..856b43317 100644
--- a/packages/server/src/server/file-explorer/service.ts
+++ b/packages/server/src/server/file-explorer/service.ts
@@ -38,24 +38,6 @@ export interface FileExplorerFile {
modifiedAt: string;
}
-const TEXT_EXTENSIONS = new Set([
- ".ts",
- ".tsx",
- ".js",
- ".jsx",
- ".json",
- ".md",
- ".txt",
- ".yml",
- ".yaml",
- ".css",
- ".scss",
- ".html",
- ".mjs",
- ".cjs",
- ".sh",
-]);
-
const TEXT_MIME_TYPES: Record = {
".json": "application/json",
};
@@ -156,17 +138,6 @@ export async function readExplorerFile({
modifiedAt: stats.mtime.toISOString(),
};
- if (TEXT_EXTENSIONS.has(ext)) {
- const content = await fs.readFile(filePath, "utf-8");
- return {
- ...basePayload,
- kind: "text",
- encoding: "utf-8",
- content,
- mimeType: TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE,
- };
- }
-
if (ext in IMAGE_MIME_TYPES) {
const buffer = await fs.readFile(filePath);
return {
@@ -178,11 +149,22 @@ export async function readExplorerFile({
};
}
+ const buffer = await fs.readFile(filePath);
+ if (isLikelyBinary(buffer)) {
+ return {
+ ...basePayload,
+ kind: "binary",
+ encoding: "none",
+ mimeType: "application/octet-stream",
+ };
+ }
+
return {
...basePayload,
- kind: "binary",
- encoding: "none",
- mimeType: "application/octet-stream",
+ kind: "text",
+ encoding: "utf-8",
+ content: buffer.toString("utf-8"),
+ mimeType: textMimeTypeForExtension(ext),
};
}
@@ -204,11 +186,23 @@ export async function getDownloadableFileInfo({
}
const ext = path.extname(filePath).toLowerCase();
- const mimeType = TEXT_EXTENSIONS.has(ext)
- ? TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE
- : ext in IMAGE_MIME_TYPES
- ? IMAGE_MIME_TYPES[ext]
- : "application/octet-stream";
+ let mimeType = "application/octet-stream";
+ if (ext in IMAGE_MIME_TYPES) {
+ mimeType = IMAGE_MIME_TYPES[ext];
+ } else {
+ // Read only a small prefix to classify likely text vs binary.
+ const handle = await fs.open(filePath, "r");
+ const sample = Buffer.alloc(8192);
+ try {
+ const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
+ const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample;
+ if (!isLikelyBinary(chunk)) {
+ mimeType = textMimeTypeForExtension(ext);
+ }
+ } finally {
+ await handle.close();
+ }
+ }
return {
path: normalizeRelativePath({ root, targetPath: filePath }),
@@ -270,3 +264,33 @@ function normalizeRelativePath({
const relative = path.relative(normalizedRoot, normalizedTarget);
return relative === "" ? "." : relative.split(path.sep).join("/");
}
+
+function textMimeTypeForExtension(ext: string): string {
+ return TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE;
+}
+
+function isLikelyBinary(buffer: Buffer): boolean {
+ if (buffer.length === 0) {
+ return false;
+ }
+
+ let suspicious = 0;
+ for (let idx = 0; idx < buffer.length; idx += 1) {
+ const byte = buffer[idx];
+ if (byte === 0) {
+ return true;
+ }
+
+ const isControl =
+ byte < 32 &&
+ byte !== 9 && // tab
+ byte !== 10 && // newline
+ byte !== 13; // carriage return
+
+ if (isControl || byte === 127) {
+ suspicious += 1;
+ }
+ }
+
+ return suspicious / buffer.length > 0.3;
+}