Skip to content
Source
Sign inSign up

useRightPanelSurfaceClose.ts

170 lines7.0 KB

1import {
2 AuthOrchestrationOperateScope,
3 AuthPreviewOperateScope,
4 AuthTerminalOperateScope,
5 type ScopedThreadRef,
6} from "@t3tools/contracts";
7import { scopeThreadRef } from "@t3tools/client-runtime/environment";
8import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
9import { agentControlledBrowserCloseConfirmation } from "../components/ChatView.logic";
10import { closePreviewSession } from "../components/preview/closePreviewSession";
11import { useThreadTerminalActions } from "../components/terminal/useThreadTerminalActions";
12import { confirmTerminalClose } from "../lib/terminalCloseConfirm";
13import { focusPanelTerminalAfterClose } from "../lib/terminalFocus";
14import { readLocalApi } from "../localApi";
15import { setActivePreviewTab, useThreadPreviewState } from "../previewStateStore";
16import {
17 selectActiveRightPanelSurface,
18 selectThreadRightPanelState,
19 useRightPanelStore,
20 type RightPanelSurface,
21} from "../rightPanelStore";
22import { appAtomRegistry } from "../rpc/atomRegistry";
23import { readThreadShell } from "../state/entities";
24import { previewEnvironment } from "../state/preview";
25import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
26import { environmentShell } from "../state/shell";
27import { threadEnvironment } from "../state/threads";
28import { useAtomCommand } from "../state/use-atom-command";
29import { useTerminalUiStateStore } from "../terminalUiStateStore";
30import { useStableCallback } from "./useStableCallback";
31​
32/**
33 * Closing releases preview and terminal sessions, and deletes a side chat that never ran.
34 * Agent-driven browsers and terminal tabs require confirmation.
35 */
36export function useRightPanelSurfaceClose(input: {
37 readonly threadRef: ScopedThreadRef | null;
38 readonly terminalLabelsById: ReadonlyMap<string, string>;
39}) {
40 const { threadRef, terminalLabelsById } = input;
41 const closePreview = useAtomCommand(previewEnvironment.close, "preview close");
42 const deleteThread = useAtomCommand(threadEnvironment.delete, "side chat delete");
43 const canOperatePreview = useEnvironmentScope(
44 threadRef?.environmentId ?? null,
45 AuthPreviewOperateScope,
46 );
47 const previewState = useThreadPreviewState(threadRef);
48 const terminalActions = useThreadTerminalActions(threadRef);
49 const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal);
50 const surfaces = useRightPanelStore(
51 (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces,
52 );
53​
54 const releaseSurfaceResources = (ref: ScopedThreadRef, surface: RightPanelSurface) => {
55 // Without preview access, server state remains until an authorized close or restart.
56 if (canOperatePreview && surface.kind === "preview" && surface.resourceId) {
57 void closePreviewSession({
58 closePreview,
59 snapshot: previewState.sessions[surface.resourceId] ?? null,
60 tabId: surface.resourceId,
61 threadRef: ref,
62 });
63 }
64 if (
65 surface.kind === "terminal" &&
66 readEnvironmentScope(ref.environmentId, AuthTerminalOperateScope)
67 ) {
68 for (const terminalId of surface.terminalIds) {
69 storeCloseTerminal(ref, terminalId);
70 terminalActions.closeTerminalSession(terminalId, ref);
71 }
72 }
73 if (
74 surface.kind === "side-chat" &&
75 readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope) &&
76 // A live shell without the side chat means it is already deleted.
77 !(
78 appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live" &&
79 readThreadShell(scopeThreadRef(ref.environmentId, surface.threadId)) === null
80 )
81 ) {
82 // The server keeps a side chat with a run or message. Reopen skips one it deleted.
83 void deleteThread({
84 environmentId: ref.environmentId,
85 input: { threadId: surface.threadId, onlyIfUnused: true },
86 });
87 }
88 };
89 const closeAfterAgentBrowserConfirmation = (
90 toClose: readonly RightPanelSurface[],
91 closeSurfaces: () => void,
92 ) => {
93 const message = agentControlledBrowserCloseConfirmation(toClose, previewState.desktopByTabId);
94 if (!message) {
95 closeSurfaces();
96 return;
97 }
98 const localApi = readLocalApi();
99 if (!localApi) return;
100 void localApi.dialogs.confirm(message, { variant: "destructive" }).then(
101 (confirmed) => {
102 if (confirmed) closeSurfaces();
103 },
104 () => undefined,
105 );
106 };
107 // The active surface closes last so the panel does not flash through it.
108 const finishClose = (toClose: readonly RightPanelSurface[]) => {
109 if (!threadRef) return;
110 const store = useRightPanelStore.getState();
111 const activeId = selectThreadRightPanelState(store.byThreadKey, threadRef).activeSurfaceId;
112 const ordered = toClose.toSorted(
113 (left, right) => Number(left.id === activeId) - Number(right.id === activeId),
114 );
115 for (const surface of ordered) {
116 // Releasing a browser drops its session, which prunes the tab automatically; close it
117 // first so the close stays a user action that hides the panel after the last tab.
118 store.closeSurface(threadRef, surface.id);
119 releaseSurfaceResources(threadRef, surface);
120 }
121 const nextActiveSurface = selectActiveRightPanelSurface(
122 useRightPanelStore.getState().byThreadKey,
123 threadRef,
124 );
125 if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) {
126 setActivePreviewTab(threadRef, nextActiveSurface.resourceId);
127 }
128 focusPanelTerminalAfterClose(threadRef, activeId);
129 };
130 const labelFor = (terminalId: string) =>
131 terminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
132 const closeSurfaces = (toClose: readonly RightPanelSurface[]) => {
133 if (!threadRef) return;
134 closeAfterAgentBrowserConfirmation(toClose, () => finishClose(toClose));
135 };
136​
137 const closeSurface = useStableCallback((surface: RightPanelSurface) => {
138 if (!threadRef) return;
139 if (surface.kind === "preview") {
140 closeSurfaces([surface]);
141 return;
142 }
143 if (
144 surface.kind !== "terminal" ||
145 !readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)
146 ) {
147 finishClose([surface]);
148 return;
149 }
150 const otherLabels = surface.terminalIds
151 .filter((terminalId) => terminalId !== surface.activeTerminalId)
152 .map(labelFor);
153 void confirmTerminalClose([labelFor(surface.activeTerminalId), ...otherLabels]).then(
154 (confirmed) => {
155 if (confirmed) finishClose([surface]);
156 },
157 );
158 });
159 const closeOtherSurfaces = useStableCallback((surface: RightPanelSurface) =>
160 closeSurfaces(surfaces.filter((entry) => entry.id !== surface.id)),
161 );
162 const closeSurfacesToRight = useStableCallback((surface: RightPanelSurface) => {
163 const surfaceIndex = surfaces.findIndex((entry) => entry.id === surface.id);
164 if (surfaceIndex < 0) return;
165 closeSurfaces(surfaces.slice(surfaceIndex + 1));
166 });
167 const closeAllSurfaces = useStableCallback(() => closeSurfaces(surfaces));
168​
169 return { closeSurface, closeOtherSurfaces, closeSurfacesToRight, closeAllSurfaces };