diff --git a/packages/app/src/screens/workspace/terminals/state.test.ts b/packages/app/src/screens/workspace/terminals/state.test.ts new file mode 100644 index 000000000..af853bbce --- /dev/null +++ b/packages/app/src/screens/workspace/terminals/state.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + collectKnownTerminalIds, + collectScriptTerminalIds, + collectStandaloneTerminalIds, + reconcilePendingScriptTerminals, + removeTerminalFromPayload, + upsertCreatedTerminalPayload, + type ListTerminalsPayload, +} from "@/screens/workspace/terminals/state"; +import type { CreateTerminalResponse } from "@server/shared/messages"; + +function listedTerminal(id: string): ListTerminalsPayload["terminals"][number] { + return { id, name: id, title: id }; +} + +function createdTerminal(id: string): NonNullable { + return { id, name: id, cwd: "/repo", title: id }; +} + +describe("workspace terminal state", () => { + it("keeps pending script terminals until they appear or a fresher list arrives", () => { + const pending = new Map([ + ["older-than-list", 10], + ["now-live", 20], + ["still-pending", 30], + ]); + + const reconciled = reconcilePendingScriptTerminals(["now-live"], 20)(pending); + + expect(reconciled).toEqual(new Map([["still-pending", 30]])); + }); + + it("returns the same pending map when reconciliation changes nothing", () => { + const pending = new Map([["still-pending", 30]]); + + const reconciled = reconcilePendingScriptTerminals([], 20)(pending); + + expect(reconciled).toBe(pending); + }); + + it("combines live and pending terminal ids without duplicating script terminals", () => { + const pendingScriptTerminalIds = new Map([ + ["script-pending", 10], + ["terminal-1", 10], + ]); + + expect( + collectKnownTerminalIds({ + liveTerminalIds: ["terminal-1", "terminal-2"], + pendingScriptTerminalIds, + }), + ).toEqual(["terminal-1", "terminal-2", "script-pending"]); + expect( + collectScriptTerminalIds({ + pendingScriptTerminalIds, + scripts: [{ terminalId: "script-live" }, { terminalId: null }], + }), + ).toEqual(new Set(["script-pending", "terminal-1", "script-live"])); + expect( + collectStandaloneTerminalIds({ + terminals: [ + listedTerminal("terminal-1"), + listedTerminal("terminal-2"), + listedTerminal("script-live"), + ], + scriptTerminalIds: new Set(["terminal-1", "script-live"]), + }), + ).toEqual(["terminal-2"]); + }); + + it("updates terminal cache entries for created and closed terminals", () => { + const current: ListTerminalsPayload = { + cwd: "/repo", + requestId: "existing", + terminals: [listedTerminal("terminal-1")], + }; + + expect( + upsertCreatedTerminalPayload({ + current, + terminal: createdTerminal("terminal-2"), + workspaceDirectory: "/repo", + }), + ).toEqual({ + cwd: "/repo", + requestId: "existing", + terminals: [ + listedTerminal("terminal-1"), + { id: "terminal-2", name: "terminal-2", title: "terminal-2" }, + ], + }); + expect(removeTerminalFromPayload("terminal-1")(current)).toEqual({ + cwd: "/repo", + requestId: "existing", + terminals: [], + }); + }); +}); diff --git a/packages/app/src/screens/workspace/terminals/state.ts b/packages/app/src/screens/workspace/terminals/state.ts new file mode 100644 index 000000000..1923f28fa --- /dev/null +++ b/packages/app/src/screens/workspace/terminals/state.ts @@ -0,0 +1,106 @@ +import type { CreateTerminalResponse, ListTerminalsResponse } from "@server/shared/messages"; +import { upsertTerminalListEntry } from "@/utils/terminal-list"; + +export const TERMINALS_QUERY_STALE_TIME = 5_000; + +export type ListTerminalsPayload = ListTerminalsResponse["payload"]; +type TerminalEntry = ListTerminalsPayload["terminals"][number]; +type CreatedTerminal = NonNullable; + +export function buildTerminalsQueryKey(serverId: string, workspaceDirectory: string | null) { + return ["terminals", serverId, workspaceDirectory] as const; +} + +export function canCreateWorkspaceTerminal(input: { + isRouteFocused: boolean; + client: unknown; + isConnected: boolean; + workspaceDirectory: string | null; +}): boolean { + return Boolean( + input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory, + ); +} + +export function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) { + return function update(pendingTerminalIds: Map): Map { + if (pendingTerminalIds.size === 0) { + return pendingTerminalIds; + } + const liveIds = new Set(liveTerminalIds); + let changed = false; + const nextTerminalIds = new Map(); + for (const [terminalId, listedAt] of pendingTerminalIds) { + if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) { + changed = true; + continue; + } + nextTerminalIds.set(terminalId, listedAt); + } + return changed ? nextTerminalIds : pendingTerminalIds; + }; +} + +export function collectKnownTerminalIds(input: { + liveTerminalIds: string[]; + pendingScriptTerminalIds: Map; +}): string[] { + const terminalIds = new Set(input.liveTerminalIds); + for (const terminalId of input.pendingScriptTerminalIds.keys()) { + terminalIds.add(terminalId); + } + return Array.from(terminalIds); +} + +export function collectScriptTerminalIds(input: { + pendingScriptTerminalIds: Map; + scripts: Array<{ terminalId?: string | null }>; +}): Set { + const terminalIds = new Set(input.pendingScriptTerminalIds.keys()); + for (const script of input.scripts) { + if (script.terminalId) { + terminalIds.add(script.terminalId); + } + } + return terminalIds; +} + +export function collectStandaloneTerminalIds(input: { + terminals: TerminalEntry[]; + scriptTerminalIds: Set; +}): string[] { + return input.terminals + .filter((terminal) => !input.scriptTerminalIds.has(terminal.id)) + .map((terminal) => terminal.id); +} + +export function removeTerminalFromPayload(terminalId: string) { + return function updatePayload( + current: ListTerminalsPayload | undefined, + ): ListTerminalsPayload | undefined { + if (!current) { + return current; + } + return { + ...current, + terminals: current.terminals.filter((terminal) => terminal.id !== terminalId), + }; + }; +} + +export function upsertCreatedTerminalPayload(input: { + current: ListTerminalsPayload | undefined; + terminal: CreatedTerminal; + workspaceDirectory: string | null; +}): ListTerminalsPayload { + const nextTerminals = upsertTerminalListEntry({ + terminals: input.current?.terminals ?? [], + terminal: input.terminal, + }); + const cwd = input.current?.cwd ?? input.workspaceDirectory; + return { + ...(cwd ? { cwd } : {}), + terminals: nextTerminals, + requestId: input.current?.requestId ?? `terminal-create-${input.terminal.id}`, + }; +} diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts new file mode 100644 index 000000000..e825ca6f0 --- /dev/null +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@server/client/daemon-client"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { + buildTerminalsQueryKey, + canCreateWorkspaceTerminal, + collectKnownTerminalIds, + collectScriptTerminalIds, + collectStandaloneTerminalIds, + reconcilePendingScriptTerminals, + removeTerminalFromPayload, + TERMINALS_QUERY_STALE_TIME, + type ListTerminalsPayload, + upsertCreatedTerminalPayload, +} from "@/screens/workspace/terminals/state"; + +interface PendingTerminalCreateInput { + paneId?: string; +} + +interface UseWorkspaceTerminalsInput { + client: DaemonClient | null; + isConnected: boolean; + isRouteFocused: boolean; + normalizedServerId: string; + normalizedWorkspaceId: string; + workspaceDirectory: string | null; + workspaceScripts: WorkspaceDescriptor["scripts"]; + hasHydratedWorkspaces: boolean; + isMissingWorkspaceExecutionAuthority: boolean; + onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void; + onScriptTerminalSelected: (terminalId: string) => void; + onWorkspacePathUnavailable: () => void; + onTerminalCreateQueued: () => void; +} + +export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { + const { + client, + isConnected, + isRouteFocused, + normalizedServerId, + normalizedWorkspaceId, + workspaceDirectory, + workspaceScripts, + hasHydratedWorkspaces, + isMissingWorkspaceExecutionAuthority, + onTerminalCreated, + onScriptTerminalSelected, + onWorkspacePathUnavailable, + onTerminalCreateQueued, + } = input; + const queryClient = useQueryClient(); + const [pendingCreateInput, setPendingCreateInput] = useState( + null, + ); + const canCreateNow = useMemo( + () => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }), + [isRouteFocused, client, isConnected, workspaceDirectory], + ); + const queryKey = useMemo( + () => buildTerminalsQueryKey(normalizedServerId, workspaceDirectory), + [normalizedServerId, workspaceDirectory], + ); + + const query = useQuery({ + queryKey, + enabled: canCreateNow, + queryFn: async () => { + if (!client || !workspaceDirectory) { + throw new Error("Host is not connected"); + } + return await client.listTerminals(workspaceDirectory); + }, + staleTime: TERMINALS_QUERY_STALE_TIME, + }); + const terminals = useMemo(() => query.data?.terminals ?? [], [query.data]); + const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]); + const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState>( + () => new Map(), + ); + + useEffect(() => { + setPendingScriptTerminalIds(new Map()); + }, [normalizedServerId, normalizedWorkspaceId]); + + const dataUpdatedAt = query.dataUpdatedAt; + useEffect(() => { + setPendingScriptTerminalIds(reconcilePendingScriptTerminals(liveTerminalIds, dataUpdatedAt)); + }, [liveTerminalIds, dataUpdatedAt]); + + const knownTerminalIds = useMemo( + () => collectKnownTerminalIds({ liveTerminalIds, pendingScriptTerminalIds }), + [liveTerminalIds, pendingScriptTerminalIds], + ); + const scriptTerminalIds = useMemo( + () => collectScriptTerminalIds({ pendingScriptTerminalIds, scripts: workspaceScripts }), + [pendingScriptTerminalIds, workspaceScripts], + ); + const standaloneTerminalIds = useMemo( + () => collectStandaloneTerminalIds({ terminals, scriptTerminalIds }), + [scriptTerminalIds, terminals], + ); + + const createMutation = useMutation({ + mutationFn: async (_input?: PendingTerminalCreateInput) => { + if (!client || !workspaceDirectory) { + throw new Error("Host is not connected"); + } + return await client.createTerminal(workspaceDirectory); + }, + onSuccess: (payload, createInput) => { + const createdTerminal = payload.terminal; + if (createdTerminal) { + queryClient.setQueryData(queryKey, (current) => + upsertCreatedTerminalPayload({ + current, + terminal: createdTerminal, + workspaceDirectory, + }), + ); + } + + void queryClient.invalidateQueries({ queryKey }); + if (createdTerminal) { + onTerminalCreated({ + terminalId: createdTerminal.id, + paneId: createInput?.paneId, + }); + } + }, + }); + const killMutation = useMutation({ + mutationFn: async (terminalId: string) => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.killTerminal(terminalId); + if (!payload.success) { + throw new Error("Unable to close terminal"); + } + return payload; + }, + }); + + useEffect(() => { + if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) { + return; + } + + const unsubscribeChanged = client.on("terminals_changed", (message) => { + if (message.payload.cwd !== workspaceDirectory) { + return; + } + + queryClient.setQueryData(queryKey, (current) => ({ + cwd: message.payload.cwd, + terminals: message.payload.terminals, + requestId: current?.requestId ?? `terminals-changed-${Date.now()}`, + })); + }); + + client.subscribeTerminals({ cwd: workspaceDirectory }); + + return () => { + unsubscribeChanged(); + client.unsubscribeTerminals({ cwd: workspaceDirectory }); + }; + }, [client, isConnected, isRouteFocused, queryClient, queryKey, workspaceDirectory]); + + useEffect(() => { + if (!pendingCreateInput) { + return; + } + + if (canCreateNow && !createMutation.isPending) { + const pendingInput = pendingCreateInput; + setPendingCreateInput(null); + createMutation.mutate(pendingInput); + return; + } + + if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) { + setPendingCreateInput(null); + onWorkspacePathUnavailable(); + } + }, [ + canCreateNow, + createMutation, + hasHydratedWorkspaces, + isMissingWorkspaceExecutionAuthority, + onWorkspacePathUnavailable, + pendingCreateInput, + ]); + + const createTerminal = useCallback( + (createInput?: PendingTerminalCreateInput) => { + if (createMutation.isPending || pendingCreateInput) { + return; + } + + if (canCreateNow) { + createMutation.mutate(createInput); + return; + } + + if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) { + onWorkspacePathUnavailable(); + return; + } + + setPendingCreateInput(createInput ?? {}); + onTerminalCreateQueued(); + }, + [ + canCreateNow, + createMutation, + hasHydratedWorkspaces, + isMissingWorkspaceExecutionAuthority, + onTerminalCreateQueued, + onWorkspacePathUnavailable, + pendingCreateInput, + ], + ); + + const handleScriptTerminalStarted = useCallback( + (terminalId: string) => { + setPendingScriptTerminalIds((pendingTerminalIds) => { + if (pendingTerminalIds.get(terminalId) === query.dataUpdatedAt) { + return pendingTerminalIds; + } + const nextTerminalIds = new Map(pendingTerminalIds); + nextTerminalIds.set(terminalId, query.dataUpdatedAt); + return nextTerminalIds; + }); + onScriptTerminalSelected(terminalId); + void queryClient.invalidateQueries({ queryKey }); + }, + [onScriptTerminalSelected, query.dataUpdatedAt, queryClient, queryKey], + ); + + const handleViewScriptTerminal = useCallback( + (terminalId: string) => { + onScriptTerminalSelected(terminalId); + }, + [onScriptTerminalSelected], + ); + + const removeTerminalFromCache = useCallback( + (terminalId: string) => { + queryClient.setQueryData( + queryKey, + removeTerminalFromPayload(terminalId), + ); + }, + [queryClient, queryKey], + ); + + const invalidateTerminals = useCallback(() => { + void queryClient.invalidateQueries({ queryKey }); + }, [queryClient, queryKey]); + + return { + canCreateNow, + createMutation, + createTerminal, + handleScriptTerminalStarted, + handleViewScriptTerminal, + invalidateTerminals, + killMutation, + knownTerminalIds, + liveTerminalIds, + pendingCreateInput, + query, + queryKey, + removeTerminalFromCache, + standaloneTerminalIds, + terminals, + }; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 60356a289..80acadc0a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -11,7 +11,7 @@ import { import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { DiffStat } from "@/components/diff-stat"; @@ -94,8 +94,6 @@ import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import type { CheckoutStatusPayload } from "@/git/use-status-query"; import { checkoutStatusQueryKey } from "@/git/query-keys"; -import type { ListTerminalsResponse } from "@server/shared/messages"; -import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { confirmDialog } from "@/utils/confirm-dialog"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -158,10 +156,12 @@ import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/l import { getIsElectron, isNative, isWeb } from "@/constants/platform"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; +import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state"; +import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; -const TERMINALS_QUERY_STALE_TIME = 5_000; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const EMPTY_UI_TABS: WorkspaceTab[] = []; +const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; const EMPTY_PINNED_AGENT_IDS = new Set(); const EMPTY_SET = new Set(); @@ -1028,8 +1028,6 @@ function WorkspaceHeaderTitleBar({ ); } -type ListTerminalsPayload = ListTerminalsResponse["payload"]; - type PaneDirection = "left" | "right" | "up" | "down"; function parsePaneDirection(actionId: string): PaneDirection | null { @@ -1180,39 +1178,6 @@ function resolveWorkspaceAuthorityState( }; } -function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) { - return function update(pendingTerminalIds: Map): Map { - if (pendingTerminalIds.size === 0) { - return pendingTerminalIds; - } - const liveIds = new Set(liveTerminalIds); - let changed = false; - const nextTerminalIds = new Map(); - for (const [terminalId, listedAt] of pendingTerminalIds) { - if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) { - changed = true; - continue; - } - nextTerminalIds.set(terminalId, listedAt); - } - return changed ? nextTerminalIds : pendingTerminalIds; - }; -} - -function removeTerminalFromPayload(terminalId: string) { - return function updatePayload( - current: ListTerminalsPayload | undefined, - ): ListTerminalsPayload | undefined { - if (!current) { - return current; - } - return { - ...current, - terminals: current.terminals.filter((terminal) => terminal.id !== terminalId), - }; - }; -} - function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string { const trimmed = host?.label?.trim(); return trimmed ? trimmed : fallback; @@ -1359,17 +1324,6 @@ function shouldShowWorkspaceExplorerSidebar(input: { return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input); } -function canCreateWorkspaceTerminal(input: { - isRouteFocused: boolean; - client: unknown; - isConnected: boolean; - workspaceDirectory: string | null; -}): boolean { - return Boolean( - input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory, - ); -} - function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { if (!serverId || !workspaceId) { return null; @@ -1377,6 +1331,108 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): return `${serverId}:${workspaceId}`; } +interface WorkspaceTerminalTabActionsInput { + persistenceKey: string | null; + focusWorkspacePane: (workspaceKey: string, paneId: string) => void; + openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; + toast: { + error: (message: string) => void; + show: (message: string) => void; + }; +} + +interface WorkspaceTerminalTabActions { + handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void; + handleScriptTerminalSelected: (terminalId: string) => void; + handleWorkspacePathUnavailable: () => void; + handleTerminalCreateQueued: () => void; +} + +function useWorkspaceTerminalTabActions({ + persistenceKey, + focusWorkspacePane, + openWorkspaceTabFocused, + toast, +}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions { + const handleTerminalCreated = useCallback( + ({ terminalId, paneId }: { terminalId: string; paneId?: string }) => { + if (!persistenceKey) { + return; + } + if (paneId) { + focusWorkspacePane(persistenceKey, paneId); + } + openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId }); + }, + [focusWorkspacePane, openWorkspaceTabFocused, persistenceKey], + ); + const handleScriptTerminalSelected = useCallback( + (terminalId: string) => { + if (!persistenceKey) { + return; + } + openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId }); + }, + [openWorkspaceTabFocused, persistenceKey], + ); + const handleWorkspacePathUnavailable = useCallback(() => { + toast.error("Workspace path is not available yet"); + }, [toast]); + const handleTerminalCreateQueued = useCallback(() => { + toast.show("Preparing workspace, opening terminal when ready..."); + }, [toast]); + + return { + handleTerminalCreated, + handleScriptTerminalSelected, + handleWorkspacePathUnavailable, + handleTerminalCreateQueued, + }; +} + +function useWorkspaceCheckoutStatus(input: { + client: ReturnType; + isConnected: boolean; + isRouteFocused: boolean; + normalizedServerId: string; + normalizedWorkspaceId: string; + workspaceDirectory: string | null; +}) { + const isCheckoutQueryEnabled = useMemo( + () => + canCreateWorkspaceTerminal({ + isRouteFocused: input.isRouteFocused, + client: input.client, + isConnected: input.isConnected, + workspaceDirectory: input.workspaceDirectory, + }), + [input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory], + ); + const checkoutQuery = useQuery({ + queryKey: checkoutStatusQueryKey( + input.normalizedServerId, + input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`, + ), + enabled: isCheckoutQueryEnabled, + queryFn: async () => { + if (!input.client || !input.workspaceDirectory) { + throw new Error("Host is not connected"); + } + return await input.client.getCheckoutStatus(input.workspaceDirectory); + }, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); + const isCheckoutStatusLoading = useMemo( + () => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError, + [isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError], + ); + + return { checkoutQuery, isCheckoutStatusLoading }; +} + function WorkspaceScreenContent({ serverId, workspaceId, @@ -1405,7 +1461,6 @@ function WorkspaceScreenContent({ scopeKey: workspaceTerminalScopeKey, }); - const queryClient = useQueryClient(); const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); const workspaceAuthority = useMemo( @@ -1430,12 +1485,19 @@ function WorkspaceScreenContent({ useProvidersSnapshot(normalizedServerId, { enabled: isRouteFocused, }); - const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{ - paneId?: string; - } | null>(null); - const canCreateTerminalNow = useMemo( - () => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }), - [isRouteFocused, client, isConnected, workspaceDirectory], + + const persistenceKey = useMemo( + () => + buildWorkspaceTabPersistenceKey({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }), + [normalizedServerId, normalizedWorkspaceId], + ); + const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); + const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); + const hasHydratedWorkspaces = useSessionStore( + (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, ); const workspaceAgentVisibility = useStoreWithEqualityFn( @@ -1449,167 +1511,56 @@ function WorkspaceScreenContent({ workspaceAgentVisibilityEqual, ); - const terminalsQueryKey = useMemo( - () => ["terminals", normalizedServerId, workspaceDirectory] as const, - [normalizedServerId, workspaceDirectory], - ); - const terminalsQuery = useQuery({ - queryKey: terminalsQueryKey, - enabled: canCreateTerminalNow, - queryFn: async () => { - if (!client || !workspaceDirectory) { - throw new Error("Host is not connected"); - } - return await client.listTerminals(workspaceDirectory); - }, - staleTime: TERMINALS_QUERY_STALE_TIME, + const { + handleTerminalCreated, + handleScriptTerminalSelected, + handleWorkspacePathUnavailable, + handleTerminalCreateQueued, + } = useWorkspaceTerminalTabActions({ + persistenceKey, + focusWorkspacePane, + openWorkspaceTabFocused, + toast, }); - const terminals = useMemo(() => terminalsQuery.data?.terminals ?? [], [terminalsQuery.data]); - const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]); - const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState>( - () => new Map(), - ); - useEffect(() => { - setPendingScriptTerminalIds(new Map()); - }, [normalizedServerId, normalizedWorkspaceId]); - const terminalsDataUpdatedAt = terminalsQuery.dataUpdatedAt; - useEffect(() => { - setPendingScriptTerminalIds( - reconcilePendingScriptTerminals(liveTerminalIds, terminalsDataUpdatedAt), - ); - }, [liveTerminalIds, terminalsDataUpdatedAt]); - const knownTerminalIds = useMemo(() => { - const terminalIds = new Set(liveTerminalIds); - for (const terminalId of pendingScriptTerminalIds.keys()) { - terminalIds.add(terminalId); - } - return Array.from(terminalIds); - }, [liveTerminalIds, pendingScriptTerminalIds]); - const scriptTerminalIds = useMemo(() => { - const terminalIds = new Set(pendingScriptTerminalIds.keys()); - for (const script of workspaceDescriptor?.scripts ?? []) { - if (script.terminalId) { - terminalIds.add(script.terminalId); - } - } - return terminalIds; - }, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]); - const standaloneTerminalIds = useMemo( - () => - terminals - .filter((terminal) => !scriptTerminalIds.has(terminal.id)) - .map((terminal) => terminal.id), - [scriptTerminalIds, terminals], - ); - const createTerminalMutation = useMutation({ - mutationFn: async (_input?: { paneId?: string }) => { - if (!client || !workspaceDirectory) { - throw new Error("Host is not connected"); - } - return await client.createTerminal(workspaceDirectory); - }, - onSuccess: (payload, input) => { - const createdTerminal = payload.terminal; - if (createdTerminal) { - queryClient.setQueryData(terminalsQueryKey, (current) => { - const nextTerminals = upsertTerminalListEntry({ - terminals: current?.terminals ?? [], - terminal: createdTerminal, - }); - const cwd = current?.cwd ?? workspaceDirectory; - return { - ...(cwd ? { cwd } : {}), - terminals: nextTerminals, - requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`, - }; - }); - } - - void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); - if (createdTerminal) { - const workspaceKey = buildWorkspaceTabPersistenceKey({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - }); - if (!workspaceKey) { - return; - } - if (input?.paneId) { - focusWorkspacePane(workspaceKey, input.paneId); - } - useWorkspaceLayoutStore - .getState() - .openTabFocused(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id }); - } - }, - }); - const killTerminalMutation = useMutation({ - mutationFn: async (terminalId: string) => { - if (!client) { - throw new Error("Host is not connected"); - } - const payload = await client.killTerminal(terminalId); - if (!payload.success) { - throw new Error("Unable to close terminal"); - } - return payload; - }, + const { + createMutation: createTerminalMutation, + createTerminal, + handleScriptTerminalStarted, + handleViewScriptTerminal, + invalidateTerminals, + killMutation: killTerminalMutation, + knownTerminalIds, + liveTerminalIds, + pendingCreateInput: pendingTerminalCreateInput, + query: terminalsQuery, + removeTerminalFromCache, + standaloneTerminalIds, + terminals, + } = useWorkspaceTerminals({ + client, + isConnected, + isRouteFocused, + normalizedServerId, + normalizedWorkspaceId, + workspaceDirectory, + workspaceScripts: workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS, + hasHydratedWorkspaces, + isMissingWorkspaceExecutionAuthority, + onTerminalCreated: handleTerminalCreated, + onScriptTerminalSelected: handleScriptTerminalSelected, + onWorkspacePathUnavailable: handleWorkspacePathUnavailable, + onTerminalCreateQueued: handleTerminalCreateQueued, }); const { archiveAgent } = useArchiveAgent(); - useEffect(() => { - if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) { - return; - } - - const unsubscribeChanged = client.on("terminals_changed", (message) => { - if (message.payload.cwd !== workspaceDirectory) { - return; - } - - queryClient.setQueryData(terminalsQueryKey, (current) => ({ - cwd: message.payload.cwd, - terminals: message.payload.terminals, - requestId: current?.requestId ?? `terminals-changed-${Date.now()}`, - })); - }); - - client.subscribeTerminals({ cwd: workspaceDirectory }); - - return () => { - unsubscribeChanged(); - client.unsubscribeTerminals({ cwd: workspaceDirectory }); - }; - }, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]); - - const isCheckoutQueryEnabled = useMemo( - () => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }), - [isRouteFocused, client, isConnected, workspaceDirectory], - ); - const checkoutQuery = useQuery({ - queryKey: checkoutStatusQueryKey( - normalizedServerId, - workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`, - ), - enabled: isCheckoutQueryEnabled, - queryFn: async () => { - if (!client || !workspaceDirectory) { - throw new Error("Host is not connected"); - } - return await client.getCheckoutStatus(workspaceDirectory); - }, - staleTime: Infinity, - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, + const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({ + client, + isConnected, + isRouteFocused, + normalizedServerId, + normalizedWorkspaceId, + workspaceDirectory, }); - const isCheckoutStatusLoading = useMemo( - () => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError, - [isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError], - ); - const hasHydratedWorkspaces = useSessionStore( - (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, - ); const hasHydratedAgents = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false, ); @@ -1618,30 +1569,6 @@ function WorkspaceScreenContent({ workspace: workspaceDescriptor, hasHydratedWorkspaces, }); - useEffect(() => { - if (!pendingTerminalCreateInput) { - return; - } - - if (canCreateTerminalNow && !createTerminalMutation.isPending) { - const pendingInput = pendingTerminalCreateInput; - setPendingTerminalCreateInput(null); - createTerminalMutation.mutate(pendingInput); - return; - } - - if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) { - setPendingTerminalCreateInput(null); - toast.error("Workspace path is not available yet"); - } - }, [ - canCreateTerminalNow, - createTerminalMutation, - hasHydratedWorkspaces, - isMissingWorkspaceExecutionAuthority, - pendingTerminalCreateInput, - toast, - ]); const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({ isCheckoutStatusLoading, isError: checkoutQuery.isError, @@ -1738,15 +1665,6 @@ function WorkspaceScreenContent({ return () => handler.remove(); }, [isExplorerOpen, isRouteFocused, showMobileAgent]); - const persistenceKey = useMemo( - () => - buildWorkspaceTabPersistenceKey({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - }), - [normalizedServerId, normalizedWorkspaceId], - ); - const workspaceLayout = useWorkspaceLayoutStore((state) => persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, ); @@ -1761,7 +1679,6 @@ function WorkspaceScreenContent({ [workspaceLayout], ); useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused }); - const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); const openWorkspaceTabInBackground = useWorkspaceLayoutStore( (state) => state.openTabInBackground, ); @@ -1777,39 +1694,6 @@ function WorkspaceScreenContent({ const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); - const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); - const handleScriptTerminalStarted = useCallback( - (terminalId: string) => { - setPendingScriptTerminalIds((pendingTerminalIds) => { - if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) { - return pendingTerminalIds; - } - const nextTerminalIds = new Map(pendingTerminalIds); - nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt); - return nextTerminalIds; - }); - if (persistenceKey) { - openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId }); - } - void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); - }, - [ - openWorkspaceTabFocused, - persistenceKey, - queryClient, - terminalsQuery.dataUpdatedAt, - terminalsQueryKey, - ], - ); - const handleViewScriptTerminal = useCallback( - (terminalId: string) => { - if (!persistenceKey) { - return; - } - openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId }); - }, - [openWorkspaceTabFocused, persistenceKey], - ); const paneFocusSuppressedRef = useRef(false); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); @@ -2200,24 +2084,7 @@ function WorkspaceScreenContent({ [focusWorkspacePane, openWorkspaceDraftTab, persistenceKey], ); - const handleCreateTerminal = useStableEvent((input?: { paneId?: string }) => { - if (createTerminalMutation.isPending || pendingTerminalCreateInput) { - return; - } - - if (canCreateTerminalNow) { - createTerminalMutation.mutate(input); - return; - } - - if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) { - toast.error("Workspace path is not available yet"); - return; - } - - setPendingTerminalCreateInput(input ?? {}); - toast.show("Preparing workspace, opening terminal when ready..."); - }); + const handleCreateTerminal = useStableEvent(createTerminal); const handleCreateBrowserTab = useCallback( (input?: { paneId?: string }) => { @@ -2284,10 +2151,7 @@ function WorkspaceScreenContent({ return; } - queryClient.setQueryData( - terminalsQueryKey, - removeTerminalFromPayload(terminalId), - ); + removeTerminalFromCache(terminalId); setHoveredTabKey((current) => (current === tabId ? null : current)); setHoveredCloseTabKey((current) => (current === tabId ? null : current)); if (persistenceKey) { @@ -2297,18 +2161,16 @@ function WorkspaceScreenContent({ }); } - void killTerminalAsync(terminalId).catch(() => { - void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); - }); + void killTerminalAsync(terminalId).catch(invalidateTerminals); }); }, [ closeTab, closeWorkspaceTabWithCleanup, + invalidateTerminals, killTerminalAsync, persistenceKey, - queryClient, - terminalsQueryKey, + removeTerminalFromCache, ], );