From 73eabd2eeae2652649f3e9df8a9d0af14097f1da Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Mar 2026 20:11:55 +0700 Subject: [PATCH] feat: canonical workspace tab IDs with URL routing and draft agent tabs Rewrite workspace tab management to use stable tab IDs (agent_, terminal_, draft_, file_) with dedicated /tab/[tabId] routes, replace separate file-tabs store with unified workspace-tabs store, support multiple concurrent draft agent tabs, and detect text files by content instead of extension allowlist. Co-Authored-By: Claude Opus 4.6 --- packages/app/src/app/_layout.tsx | 8 + .../workspace/[workspaceId]/tab/[tabId].tsx | 22 + .../components/sortable-inline-list.web.tsx | 19 +- .../src/keyboard/keyboard-shortcut-routing.ts | 14 + .../src/screens/agent/agent-ready-screen.tsx | 30 +- .../src/screens/agent/draft-agent-screen.tsx | 19 +- .../workspace/workspace-draft-agent-tab.tsx | 427 ++++++++ .../screens/workspace/workspace-screen.tsx | 994 ++++++++++-------- .../app/src/stores/create-flow-store.test.ts | 35 +- packages/app/src/stores/create-flow-store.ts | 71 +- packages/app/src/stores/draft-store.ts | 7 +- .../src/stores/workspace-file-tabs-store.ts | 98 -- .../app/src/stores/workspace-tabs-store.ts | 503 ++++++--- packages/app/src/utils/host-routes.test.ts | 22 + packages/app/src/utils/host-routes.ts | 66 ++ .../src/server/file-explorer/service.test.ts | 67 +- .../src/server/file-explorer/service.ts | 98 +- 17 files changed, 1732 insertions(+), 768 deletions(-) create mode 100644 packages/app/src/app/h/[serverId]/workspace/[workspaceId]/tab/[tabId].tsx create mode 100644 packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx delete mode 100644 packages/app/src/stores/workspace-file-tabs-store.ts 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; +}