Skip to content
Source
Sign inSign up

PersistentThreadTerminals.tsx

416 lines15.7 KB

1import {
2 AuthTerminalOperateScope,
3 type EnvironmentId,
4 type ResolvedKeybindingsConfig,
5 type ScopedThreadRef,
6 type ThreadId,
7} from "@t3tools/contracts";
8import { scopeProjectRef } from "@t3tools/client-runtime/environment";
9import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
10import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
11import { memo, useEffect } from "react";
12import { useComposerDraftStore } from "../composerDraftStore";
13import { useStableCallback } from "../hooks/useStableCallback";
14import type { TerminalContextSelection } from "../lib/terminalContext";
15import { focusActiveDrawerTerminal, focusTerminal } from "../lib/terminalFocus";
16import { cn } from "~/lib/utils";
17import {
18 selectThreadRightPanelState,
19 useRightPanelStore,
20 type RightPanelSurface,
21} from "../rightPanelStore";
22import { useProject, useQuietThreadShell } from "../state/entities";
23import { useEnvironmentScope } from "../state/session";
24import { useKnownTerminalSessions } from "../state/terminalSessions";
25import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
26import { useThreadTerminalActions } from "./terminal/useThreadTerminalActions";
27import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
28import { resolveTerminalWorktreePath } from "./ChatView.logic";
29​
30/** Where a drawer or panel opens new terminals when a script run overrides the thread's own. */
31export type PersistentTerminalLaunchContext = {
32 readonly cwd: string;
33 readonly worktreePath: string | null;
34};
35​
36function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean {
37 if (left.length !== right.length) {
38 return false;
39 }
40 if (left.length === 0) {
41 return true;
42 }
43 const sortedLeft = left.toSorted((a, b) => a.localeCompare(b));
44 const sortedRight = right.toSorted((a, b) => a.localeCompare(b));
45 for (let index = 0; index < sortedLeft.length; index += 1) {
46 if (sortedLeft[index] !== sortedRight[index]) {
47 return false;
48 }
49 }
50 return true;
52​
53/** Server sessions lag terminal.open; reconciling strict subsets would discard new split layouts. */
54function serverTerminalIdsStrictSubsetOfClient(
55 serverIds: readonly string[],
56 clientIds: readonly string[],
57): boolean {
58 if (serverIds.length >= clientIds.length || clientIds.length === 0) {
59 return false;
60 }
61 const clientSet = new Set(clientIds);
62 for (const id of serverIds) {
63 if (!clientSet.has(id)) {
64 return false;
65 }
66 }
67 return true;
69​
70interface TerminalLaunchLocationWithEnv {
71 readonly cwd: string;
72 readonly worktreePath: string | null;
73 readonly runtimeEnv: Record<string, string>;
75​
76interface PersistentThreadTerminalDrawerProps {
77 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
78 threadId: ThreadId;
79 active: boolean;
80 composerHeight: number;
81 launchContext: PersistentTerminalLaunchContext | null;
82 splitShortcutLabel: string | undefined;
83 splitVerticalShortcutLabel: string | undefined;
84 newShortcutLabel: string | undefined;
85 closeShortcutLabel: string | undefined;
86 keybindings: ResolvedKeybindingsConfig;
87 onAddTerminalContext: (selection: TerminalContextSelection) => void;
89​
90export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({
91 threadRef,
92 threadId,
93 active,
94 composerHeight,
95 launchContext,
96 splitShortcutLabel,
97 splitVerticalShortcutLabel,
98 newShortcutLabel,
99 closeShortcutLabel,
100 keybindings,
101 onAddTerminalContext,
102}: PersistentThreadTerminalDrawerProps) {
103 const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope);
104 const terminalActions = useThreadTerminalActions(threadRef);
105 const serverThread = useQuietThreadShell(threadRef);
106 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
107 const projectRef = serverThread
108 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
109 : draftThread
110 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
111 : null;
112 const project = useProject(projectRef);
113 const terminalUiState = useTerminalUiStateStore((state) =>
114 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),
115 );
116 const visible = active && terminalUiState.terminalOpen;
117 const knownTerminalSessions = useKnownTerminalSessions({
118 environmentId: threadRef.environmentId,
119 threadId,
120 });
121 const panelSurfaces = useRightPanelStore(
122 (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces,
123 );
124 const panelTerminalIds = new Set(
125 panelSurfaces.flatMap((surface) => (surface.kind === "terminal" ? surface.terminalIds : [])),
126 );
127 const drawerTerminalSessions =
128 knownTerminalSessions?.filter((session) => !panelTerminalIds.has(session.target.terminalId)) ??
129 [];
130 const terminalLabelsById = new Map<string, string>();
131 for (const session of drawerTerminalSessions) {
132 terminalLabelsById.set(
133 session.target.terminalId,
134 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
135 );
136 }
137 const terminalLaunchLocationsById = new Map<string, TerminalLaunchLocationWithEnv>();
138 for (const session of drawerTerminalSessions) {
139 const summary = session.state.summary;
140 if (!project || !summary) continue;
141 const worktreePathForLaunch =
142 launchContext !== null ? launchContext.worktreePath : summary.worktreePath;
143 terminalLaunchLocationsById.set(session.target.terminalId, {
144 cwd: launchContext?.cwd ?? summary.cwd,
145 worktreePath: worktreePathForLaunch,
146 runtimeEnv: projectScriptRuntimeEnv({
147 project: { cwd: project.workspaceRoot },
148 worktreePath: worktreePathForLaunch,
149 }),
150 });
151 }
152 const serverOrderedTerminalIds = drawerTerminalSessions.map(
153 (session) => session.target.terminalId,
154 );
155 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);
156 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);
157 const storeSplitTerminalVertical = useTerminalUiStateStore(
158 (state) => state.splitTerminalVertical,
159 );
160 const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal);
161 const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal);
162 const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal);
163 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);
164​
165 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reconciles the terminal UI store's ids with the server's terminal list when they diverge
166 useEffect(() => {
167 if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) {
168 return;
169 }
170 if (
171 serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds)
172 ) {
173 return;
174 }
175 reconcileTerminalIds(threadRef, serverOrderedTerminalIds);
176 }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]);
177 const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
178 const effectiveWorktreePath = launchContext !== null ? launchContext.worktreePath : worktreePath;
179 const cwd =
180 launchContext?.cwd ??
181 (project
182 ? projectScriptCwd({
183 project: { cwd: project.workspaceRoot },
184 worktreePath: effectiveWorktreePath,
185 })
186 : null);
187 const runtimeEnv = project
188 ? projectScriptRuntimeEnv({
189 project: { cwd: project.workspaceRoot },
190 worktreePath: effectiveWorktreePath,
191 })
192 : {};
193​
194 const setTerminalHeight = (height: number) => {
195 storeSetTerminalHeight(threadRef, height);
196 };
197​
198 const launchDrawerTerminal = (place: (terminalId: string) => void) => {
199 if (!cwd) return;
200 const launched = terminalActions.launchTerminal({
201 place,
202 location: { cwd, worktreePath: effectiveWorktreePath },
203 });
204 if (!launched) return;
205 if (visible) focusTerminal(threadRef, launched.terminalId);
206 void launched.result;
207 };
208 const splitTerminal = useStableCallback(() =>
209 launchDrawerTerminal((terminalId) => storeSplitTerminal(threadRef, terminalId)),
210 );
211 const splitTerminalVertical = useStableCallback(() =>
212 launchDrawerTerminal((terminalId) => storeSplitTerminalVertical(threadRef, terminalId)),
213 );
214 const createNewTerminal = useStableCallback(() =>
215 launchDrawerTerminal((terminalId) => storeNewTerminal(threadRef, terminalId)),
216 );
217​
218 const activateTerminal = (terminalId: string) => {
219 storeSetActiveTerminal(threadRef, terminalId);
220 if (visible) focusTerminal(threadRef, terminalId);
221 };
222​
223 const closeTerminal = useStableCallback((terminalId: string) => {
224 if (!terminalActions.canWrite()) return;
225 terminalActions.closeTerminalSession(terminalId);
226 storeCloseTerminal(threadRef, terminalId);
227 if (visible) focusActiveDrawerTerminal(threadRef);
228 });
229​
230 const handleAddTerminalContext = (selection: TerminalContextSelection) => {
231 if (!visible) {
232 return;
233 }
234 onAddTerminalContext(selection);
235 };
236​
237 if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) {
238 return null;
239 }
240​
241 return (
242 <div
243 className={cn(
244 "grid shrink-0 overflow-clip",
245 active ? (visible ? "grid-rows-[1fr]" : "grid-rows-[0fr]") : "hidden",
246 active &&
247 "[[data-panel-animations=true]_&]:transition-[grid-template-rows] [[data-panel-animations=true]_&]:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:ease-out",
248 active && visible && "[[data-panel-animations=true]_&]:starting:grid-rows-[0fr]!",
249 )}
250 >
251 <div className="min-h-0 overflow-clip">
252 <ThreadTerminalDrawer
253 threadRef={threadRef}
254 threadId={threadId}
255 cwd={cwd}
256 worktreePath={effectiveWorktreePath}
257 runtimeEnv={runtimeEnv}
258 visible={visible}
259 height={terminalUiState.terminalHeight}
260 composerHeight={composerHeight}
261 // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.
262 terminalIds={canOperateTerminal ? terminalUiState.terminalIds : serverOrderedTerminalIds}
263 activeTerminalId={terminalUiState.activeTerminalId}
264 terminalGroups={terminalUiState.terminalGroups}
265 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}
266 onSplitTerminal={splitTerminal}
267 onSplitTerminalVertical={splitTerminalVertical}
268 onNewTerminal={createNewTerminal}
269 splitShortcutLabel={visible ? splitShortcutLabel : undefined}
270 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
271 newShortcutLabel={visible ? newShortcutLabel : undefined}
272 closeShortcutLabel={visible ? closeShortcutLabel : undefined}
273 keybindings={keybindings}
274 onActiveTerminalChange={activateTerminal}
275 onCloseTerminal={closeTerminal}
276 onHeightChange={setTerminalHeight}
277 onAddTerminalContext={handleAddTerminalContext}
278 terminalLabelsById={terminalLabelsById}
279 terminalLaunchLocationsById={terminalLaunchLocationsById}
280 />
281 </div>
282 </div>
283 );
284});
285​
286export interface PersistentThreadTerminalPanelProps {
287 visible: boolean;
288 threadRef: ScopedThreadRef;
289 surface: Extract<RightPanelSurface, { kind: "terminal" }>;
290 launchContext: PersistentTerminalLaunchContext | null;
291 keybindings: ResolvedKeybindingsConfig;
292 onAddTerminalContext: (selection: TerminalContextSelection) => void;
293 onSplitTerminal: () => void;
294 onSplitTerminalVertical: () => void;
295 onNewTerminal: () => void;
296 onActiveTerminalChange: (terminalId: string) => void;
297 onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void;
298 splitShortcutLabel?: string | undefined;
299 splitVerticalShortcutLabel?: string | undefined;
300 newShortcutLabel?: string | undefined;
301 closeShortcutLabel?: string | undefined;
303​
304export const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({
305 visible,
306 threadRef,
307 surface,
308 launchContext,
309 keybindings,
310 onAddTerminalContext,
311 onSplitTerminal,
312 onSplitTerminalVertical,
313 onNewTerminal,
314 onActiveTerminalChange,
315 onCloseTerminal,
316 splitShortcutLabel,
317 splitVerticalShortcutLabel,
318 newShortcutLabel,
319 closeShortcutLabel,
320}: PersistentThreadTerminalPanelProps) {
321 const serverThread = useQuietThreadShell(threadRef);
322 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
323 const projectRef = serverThread
324 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
325 : draftThread
326 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
327 : null;
328 const project = useProject(projectRef);
329 const knownTerminalSessions = useKnownTerminalSessions({
330 environmentId: threadRef.environmentId,
331 threadId: threadRef.threadId,
332 });
333 const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
334 const activeSummary =
335 knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId)
336 ?.state.summary ?? null;
337 const worktreePath = resolveTerminalWorktreePath({
338 launchContext,
339 summary: activeSummary,
340 threadWorktreePath,
341 });
342 const cwd =
343 launchContext?.cwd ??
344 activeSummary?.cwd ??
345 (project ? projectScriptCwd({ project: { cwd: project.workspaceRoot }, worktreePath }) : null);
346 const runtimeEnv = project
347 ? projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath })
348 : {};
349 const terminalLabelsById = new Map<string, string>();
350 const terminalLaunchLocationsById = new Map<string, TerminalLaunchLocationWithEnv>();
351 for (const terminalId of surface.terminalIds) {
352 const summary =
353 knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state
354 .summary ?? null;
355 terminalLabelsById.set(terminalId, resolveTerminalSessionLabel(terminalId, summary));
356 if (!project) continue;
357 const terminalWorktreePath = resolveTerminalWorktreePath({
358 launchContext,
359 summary,
360 threadWorktreePath,
361 });
362 terminalLaunchLocationsById.set(terminalId, {
363 cwd:
364 launchContext?.cwd ??
365 summary?.cwd ??
366 projectScriptCwd({
367 project: { cwd: project.workspaceRoot },
368 worktreePath: terminalWorktreePath,
369 }),
370 worktreePath: terminalWorktreePath,
371 runtimeEnv: projectScriptRuntimeEnv({
372 project: { cwd: project.workspaceRoot },
373 worktreePath: terminalWorktreePath,
374 }),
375 });
376 }
377​
378 if (!project || !cwd) return null;
379​
380 return (
381 <ThreadTerminalDrawer
382 mode="panel"
383 visible={visible}
384 threadRef={threadRef}
385 threadId={threadRef.threadId}
386 cwd={cwd}
387 worktreePath={worktreePath}
388 runtimeEnv={runtimeEnv}
389 height={0}
390 terminalIds={surface.terminalIds}
391 activeTerminalId={surface.activeTerminalId}
392 terminalGroups={[
393 {
394 id: surface.id,
395 terminalIds: surface.terminalIds,
396 ...(surface.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}),
397 },
398 ]}
399 activeTerminalGroupId={surface.id}
400 onSplitTerminal={onSplitTerminal}
401 onSplitTerminalVertical={onSplitTerminalVertical}
402 onNewTerminal={onNewTerminal}
403 splitShortcutLabel={splitShortcutLabel}
404 splitVerticalShortcutLabel={splitVerticalShortcutLabel}
405 newShortcutLabel={newShortcutLabel}
406 closeShortcutLabel={closeShortcutLabel}
407 onActiveTerminalChange={onActiveTerminalChange}
408 onCloseTerminal={onCloseTerminal}
409 onHeightChange={() => undefined}
410 onAddTerminalContext={onAddTerminalContext}
411 terminalLabelsById={terminalLabelsById}
412 terminalLaunchLocationsById={terminalLaunchLocationsById}
413 keybindings={keybindings}
414 />
415 );
416});