refactor(app): extract workspace terminal lifecycle (#881)

This commit is contained in:
Mohamed Boudra
2026-05-10 08:24:19 +00:00
committed by GitHub
parent e4acd6cb7a
commit 93cd4734ce
4 changed files with 655 additions and 307 deletions
@@ -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<CreateTerminalResponse["payload"]["terminal"]> {
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: [],
});
});
});
@@ -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<CreateTerminalResponse["payload"]["terminal"]>;
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<string, number>): Map<string, number> {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
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, number>;
}): 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<string, number>;
scripts: Array<{ terminalId?: string | null }>;
}): Set<string> {
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>;
}): 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}`,
};
}
@@ -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<PendingTerminalCreateInput | null>(
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<Map<string, number>>(
() => 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<ListTerminalsPayload>(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<ListTerminalsPayload>(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<ListTerminalsPayload>(
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,
};
}