History

terminals

Julius Marminge committed 6 months ago

Commit 4e38d931 parent cace57836 files · +1429 −412Browse files

Unified · Split

36 files
  1. apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +12 −1
  2. apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +11 −0
  3. apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +223 −61
  4. apps/mobile/src/features/terminal/terminalBufferReplay.test.ts +2 −2
  5. apps/mobile/src/features/terminal/terminalBufferReplay.ts +14 −1
  6. apps/mobile/src/features/terminal/terminalDebugLog.ts +24 −0
  7. apps/mobile/src/features/terminal/terminalMenu.test.ts +48 −16
  8. apps/mobile/src/features/terminal/terminalMenu.ts +28 −39
  9. apps/mobile/src/features/terminal/terminalRouteBootstrap.ts +1 −0
  10. apps/mobile/src/features/threads/ThreadGitControls.tsx +32 −39
  11. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +38 −6
  12. apps/mobile/src/lib/routes.ts +28 −1
  13. apps/mobile/src/state/use-remote-environment-registry.ts +4 −0
  14. apps/mobile/src/state/use-terminal-session.ts +3 −0
  15. apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +1 −0
  16. apps/server/src/server.test.ts +1 −0
  17. apps/server/src/terminal/Layers/Manager.test.ts +29 −13
  18. apps/server/src/terminal/Layers/Manager.ts +188 −52
  19. apps/web/src/components/ChatView.browser.tsx +22 −0
  20. apps/web/src/components/ChatView.tsx +267 −35
  21. apps/web/src/components/ThreadTerminalDrawer.browser.tsx +4 −3
  22. apps/web/src/components/ThreadTerminalDrawer.tsx +63 −30
  23. apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +65 −0
  24. apps/web/src/lib/terminalFocus.test.ts +18 −1
  25. apps/web/src/lib/terminalFocus.ts +3 −1
  26. apps/web/src/localApi.test.ts +1 −0
  27. apps/web/src/terminalUiStateStore.test.ts +51 −31
  28. apps/web/src/terminalUiStateStore.ts +60 −44
  29. apps/web/src/types.ts +1 −1
  30. packages/client-runtime/src/terminalSessionState.test.ts +26 −4
  31. packages/client-runtime/src/terminalSessionState.ts +6 −15
  32. packages/contracts/src/terminal.test.ts +43 −9
  33. packages/contracts/src/terminal.ts +14 −7
  34. packages/shared/package.json +4 −0
  35. packages/shared/src/terminalLabels.test.ts +54 −0
  36. packages/shared/src/terminalLabels.ts +40 −0
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+12−1
@@ -1,4 +1,4 @@
1import { memo, useCallback } from "react";1import { memo, useCallback, useEffect } from "react";
2import {2import {
3 Pressable,3 Pressable,
4 ScrollView,4 ScrollView,
@@ -17,6 +17,7 @@ import { · 12 unmodified lines
17 getPierreTerminalTheme,17 getPierreTerminalTheme,
18 type TerminalTheme,18 type TerminalTheme,
19} from "./terminalTheme";19} from "./terminalTheme";
20import { terminalDebugLog } from "./terminalDebugLog";
20 21
21interface TerminalInputEvent {22interface TerminalInputEvent {
22 readonly data: string;23 readonly data: string;
@@ -168,6 +169,16 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 145 unmodified lines
168 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);169 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
169 const { onInput, onResize } = props;170 const { onInput, onResize } = props;
170 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();171 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
172 const hasNativeSurface = Boolean(NativeTerminalSurfaceView);
173
174 useEffect(() => {
175 terminalDebugLog("native:surface", {
176 terminalKey: props.terminalKey,
177 native: hasNativeSurface,
178 bufferLen: props.buffer.length,
179 isRunning: props.isRunning,
180 });
181 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
171 const handleNativeInput = useCallback(182 const handleNativeInput = useCallback(
172 (event: NativeSyntheticEvent<TerminalInputEvent>) => {183 (event: NativeSyntheticEvent<TerminalInputEvent>) => {
173 onInput(event.nativeEvent.data);184 onInput(event.nativeEvent.data);
apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx+11−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12} from "../../state/use-terminal-session";12} from "../../state/use-terminal-session";
13import { TerminalSurface } from "./NativeTerminalSurface";13import { TerminalSurface } from "./NativeTerminalSurface";
14import { hasNativeTerminalSurface } from "./nativeTerminalModule";14import { hasNativeTerminalSurface } from "./nativeTerminalModule";
15import { terminalDebugLog } from "./terminalDebugLog";
15 16
16interface ThreadTerminalPanelProps {17interface ThreadTerminalPanelProps {
17 readonly environmentId: EnvironmentId;18 readonly environmentId: EnvironmentId;
@@ -51,9 +52,19 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( · 33 unmodified lines
51 52
52 const client = getEnvironmentClient(props.environmentId);53 const client = getEnvironmentClient(props.environmentId);
53 if (!client) {54 if (!client) {
55 terminalDebugLog("panel:attach-skip", {
56 reason: "no-environment-client",
57 environmentId: props.environmentId,
58 });
54 return;59 return;
55 }60 }
56 61
62 terminalDebugLog("panel:attach", {
63 environmentId: props.environmentId,
64 threadId: props.threadId,
65 terminalId,
66 });
67
57 return attachTerminalSession({68 return attachTerminalSession({
58 environmentId: props.environmentId,69 environmentId: props.environmentId,
59 client,70 client,
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+223−61
@@ -1,4 +1,10 @@
1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";1import {
2 DEFAULT_TERMINAL_ID,
3 EnvironmentId,
4 type TerminalAttachStreamEvent,
5 ThreadId,
6} from "@t3tools/contracts";
7import type { KnownTerminalSession } from "@t3tools/client-runtime";
2import { Stack, useLocalSearchParams, useRouter } from "expo-router";8import { Stack, useLocalSearchParams, useRouter } from "expo-router";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";9import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";10import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
@@ -7,7 +13,7 @@ import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-re · 2 unmodified lines
7 13
8import { EmptyState } from "../../components/EmptyState";14import { EmptyState } from "../../components/EmptyState";
9import { LoadingScreen } from "../../components/LoadingScreen";15import { LoadingScreen } from "../../components/LoadingScreen";
10import { buildThreadTerminalRoutePath } from "../../lib/routes";16import { buildThreadTerminalNavigation } from "../../lib/routes";
11import {17import {
12 getEnvironmentClient,18 getEnvironmentClient,
13 useRemoteEnvironmentState,19 useRemoteEnvironmentState,
@@ -23,6 +29,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail"; · 9 unmodified lines
23import { TerminalSurface } from "./NativeTerminalSurface";29import { TerminalSurface } from "./NativeTerminalSurface";
24import { getPierreTerminalTheme } from "./terminalTheme";30import { getPierreTerminalTheme } from "./terminalTheme";
25import { loadPreferences, savePreferencesPatch } from "../../lib/storage";31import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
32import { terminalDebugLog } from "./terminalDebugLog";
26import {33import {
27 getTerminalBufferReplayKey,34 getTerminalBufferReplayKey,
28 getTerminalSurfaceReplayBuffer,35 getTerminalSurfaceReplayBuffer,
@@ -37,9 +44,10 @@ import { · 8 unmodified lines
37import {44import {
38 basename,45 basename,
39 buildTerminalMenuSessions,46 buildTerminalMenuSessions,
40 getTerminalLabel,
41 getTerminalStatusLabel,47 getTerminalStatusLabel,
42 nextTerminalId,48 nextOpenTerminalId,
49 resolveTerminalSessionLabel,
50 type TerminalMenuSession,
43} from "./terminalMenu";51} from "./terminalMenu";
44import {52import {
45 DEFAULT_TERMINAL_FONT_SIZE,53 DEFAULT_TERMINAL_FONT_SIZE,
@@ -71,13 +79,6 @@ type TerminalToolbarAction = · 25 unmodified lines
71 readonly modifier: PendingModifier;79 readonly modifier: PendingModifier;
72 };80 };
73 81
74interface TerminalMenuSession {
75 readonly terminalId: string;
76 readonly cwd: string | null;
77 readonly status: "starting" | "running" | "exited" | "error" | "closed";
78 readonly updatedAt: string | null;
79}
80
81function getTerminalStatusTone(input: {82function getTerminalStatusTone(input: {
82 readonly status: TerminalMenuSession["status"];83 readonly status: TerminalMenuSession["status"];
83 readonly hasRunningSubprocess?: boolean;84 readonly hasRunningSubprocess?: boolean;
@@ -174,6 +175,22 @@ function withAlpha(hexColor: string, alpha: string): string { · 90 unmodified lines
174 return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor;175 return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor;
175}176}
176 177
178function pickRunningTerminalSessionForBootstrap(
179 sessions: ReadonlyArray<KnownTerminalSession>,
180): KnownTerminalSession | null {
181 const running = sessions.filter(
182 (session) => session.state.status === "running" || session.state.status === "starting",
183 );
184 if (running.length === 0) {
185 return null;
186 }
187 return (
188 running.find((session) => session.target.terminalId === DEFAULT_TERMINAL_ID) ??
189 running[0] ??
190 null
191 );
192}
193
177export function ThreadTerminalRouteScreen() {194export function ThreadTerminalRouteScreen() {
178 const router = useRouter();195 const router = useRouter();
179 const keyboard = useAnimatedKeyboard();196 const keyboard = useAnimatedKeyboard();
@@ -217,12 +234,15 @@ export function ThreadTerminalRouteScreen() { · 37 unmodified lines
217 const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);234 const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
218 const hasOpenedRef = useRef(false);235 const hasOpenedRef = useRef(false);
219 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);236 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
237 const attachStreamLogCountRef = useRef(0);
238 const firstNonEmptyBufferLoggedRef = useRef(false);
220 const lastBufferReplayKeyRef = useRef<string | null>(null);239 const lastBufferReplayKeyRef = useRef<string | null>(null);
221 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);240 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);
222 const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(241 const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(
223 cachedFontSize !== null,242 cachedFontSize !== null,
224 );243 );
225 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(cachedRouteGridSize !== null);244 /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */
245 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true);
226 const [pendingModifierState, setPendingModifierState] = useState<{246 const [pendingModifierState, setPendingModifierState] = useState<{
227 readonly terminalId: string;247 readonly terminalId: string;
228 readonly value: PendingModifier | null;248 readonly value: PendingModifier | null;
@@ -252,22 +272,79 @@ export function ThreadTerminalRouteScreen() { · 23 unmodified lines
252 readyReplayKey: readyBufferReplayKey,272 readyReplayKey: readyBufferReplayKey,
253 });273 });
254 const isRunning = terminal.status === "running" || terminal.status === "starting";274 const isRunning = terminal.status === "running" || terminal.status === "starting";
275
276 useEffect(() => {
277 terminalDebugLog("surface:props", {
278 terminalKey,
279 atomBufferLen: terminal.buffer.length,
280 surfaceBufferLen: terminalSurfaceBuffer.length,
281 replayKey: bufferReplayKey,
282 readyReplayKey: readyBufferReplayKey,
283 status: terminal.status,
284 version: terminal.version,
285 });
286 }, [
287 bufferReplayKey,
288 readyBufferReplayKey,
289 terminal.buffer.length,
290 terminal.status,
291 terminal.version,
292 terminalKey,
293 terminalSurfaceBuffer.length,
294 ]);
295
296 useEffect(() => {
297 terminalDebugLog("session:status", {
298 terminalKey,
299 status: terminal.status,
300 error: terminal.error,
301 summary: terminal.summary?.cwd ?? null,
302 bufferLen: terminal.buffer.length,
303 version: terminal.version,
304 });
305 }, [
306 terminal.buffer.length,
307 terminal.error,
308 terminal.status,
309 terminal.summary?.cwd,
310 terminal.version,
311 terminalKey,
312 ]);
313
314 useEffect(() => {
315 if (terminal.buffer.length === 0 || firstNonEmptyBufferLoggedRef.current) {
316 return;
317 }
318 firstNonEmptyBufferLoggedRef.current = true;
319 terminalDebugLog("session:first-nonempty-buffer", {
320 terminalKey,
321 length: terminal.buffer.length,
322 preview: terminal.buffer.slice(0, 160),
323 });
324 }, [terminal.buffer, terminal.buffer.length, terminalKey]);
255 const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null;325 const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null;
256 const hostPlatform = useMemo(326 const hostPlatform = useMemo(
257 () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null),327 () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null),
258 [selectedEnvironmentConnection?.environmentLabel],328 [selectedEnvironmentConnection?.environmentLabel],
259 );329 );
260 const runningSession = useMemo(330 const runningSession = useMemo(
261 () =>331 () => pickRunningTerminalSessionForBootstrap(knownSessions),
262 knownSessions.find(
263 (session) => session.state.status === "running" || session.state.status === "starting",
264 ) ?? null,
265 [knownSessions],332 [knownSessions],
266 );333 );
267 const activeKnownSession = useMemo(334 const activeKnownSession = useMemo(
268 () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null,335 () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null,
269 [knownSessions, terminalId],336 [knownSessions, terminalId],
270 );337 );
338
339 const terminalAttachLaunchHintsRef = useRef({
340 terminalSummary: terminal.summary,
341 activeKnownSummary: activeKnownSession?.state.summary ?? null,
342 });
343 terminalAttachLaunchHintsRef.current = {
344 terminalSummary: terminal.summary,
345 activeKnownSummary: activeKnownSession?.state.summary ?? null,
346 };
347
271 const terminalStatusTone = useMemo(348 const terminalStatusTone = useMemo(
272 () =>349 () =>
273 getTerminalStatusTone({350 getTerminalStatusTone({
@@ -339,6 +416,7 @@ export function ThreadTerminalRouteScreen() { · 65 unmodified lines
339 cwd: cwd ?? null,416 cwd: cwd ?? null,
340 status: terminal.status,417 status: terminal.status,
341 hasRunningSubprocess: terminal.hasRunningSubprocess,418 hasRunningSubprocess: terminal.hasRunningSubprocess,
419 displayLabel: resolveTerminalSessionLabel(terminalId, terminal.summary),
342 updatedAt: terminal.updatedAt,420 updatedAt: terminal.updatedAt,
343 },421 },
344 }),422 }),
@@ -347,19 +425,47 @@ export function ThreadTerminalRouteScreen() { · 2 unmodified lines
347 knownSessions,425 knownSessions,
348 selectedThreadProject?.workspaceRoot,426 selectedThreadProject?.workspaceRoot,
349 terminal.hasRunningSubprocess,427 terminal.hasRunningSubprocess,
428 terminal.summary,
350 terminal.status,429 terminal.status,
351 terminal.updatedAt,430 terminal.updatedAt,
352 terminalId,431 terminalId,
353 ],432 ],
354 );433 );
355 434
435 const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => {
436 const n = ++attachStreamLogCountRef.current;
437 if (event.type === "output" && n > 32 && n % 64 !== 0) {
438 return;
439 }
440 if (event.type === "snapshot") {
441 terminalDebugLog("attach:stream", {
442 n,
443 type: event.type,
444 status: event.snapshot.status,
445 historyLen: event.snapshot.history.length,
446 cwd: event.snapshot.cwd,
447 });
448 return;
449 }
450 if (event.type === "output") {
451 terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length });
452 return;
453 }
454 terminalDebugLog("attach:stream", { n, type: event.type });
455 }, []);
456
356 const attachTerminal = useCallback(() => {457 const attachTerminal = useCallback(() => {
357 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {458 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
459 terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" });
358 return null;460 return null;
359 }461 }
360 462
361 const client = getEnvironmentClient(selectedThread.environmentId);463 const client = getEnvironmentClient(selectedThread.environmentId);
362 if (!client) {464 if (!client) {
465 terminalDebugLog("attach:abort", {
466 reason: "no-environment-client",
467 environmentId: selectedThread.environmentId,
468 });
363 return null;469 return null;
364 }470 }
365 471
@@ -378,13 +484,22 @@ export function ThreadTerminalRouteScreen() { · 12 unmodified lines
378 worktreePath: pendingLaunch.worktreePath,484 worktreePath: pendingLaunch.worktreePath,
379 }485 }
380 : resolveTerminalOpenLocation({486 : resolveTerminalOpenLocation({
381 terminalLocation: terminal.summary,487 terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary,
382 activeSessionLocation: activeKnownSession?.state.summary ?? null,488 activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary,
383 workspaceRoot: selectedThreadProject.workspaceRoot,489 workspaceRoot: selectedThreadProject.workspaceRoot,
384 threadShellWorktreePath: selectedThread.worktreePath ?? null,490 threadShellWorktreePath: selectedThread.worktreePath ?? null,
385 threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,491 threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,
386 });492 });
387 493
494 terminalDebugLog("attach:start", {
495 terminalId,
496 threadId: selectedThread.id,
497 cols: lastGridSize.cols,
498 rows: lastGridSize.rows,
499 cwd: launchLocation.cwd,
500 worktreePath: launchLocation.worktreePath,
501 });
502
388 return attachTerminalSession({503 return attachTerminalSession({
389 environmentId: selectedThread.environmentId,504 environmentId: selectedThread.environmentId,
390 client,505 client,
@@ -397,6 +512,7 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines
397 rows: lastGridSize.rows,512 rows: lastGridSize.rows,
398 env: pendingLaunch?.env,513 env: pendingLaunch?.env,
399 },514 },
515 onEvent: logAttachStreamEvent,
400 onSnapshot: () => {516 onSnapshot: () => {
401 if (!pendingLaunch?.initialInput || initialInputSent) {517 if (!pendingLaunch?.initialInput || initialInputSent) {
402 return;518 return;
@@ -411,6 +527,9 @@ export function ThreadTerminalRouteScreen() { · 8 unmodified lines
411 },527 },
412 });528 });
413 } catch (error) {529 } catch (error) {
530 terminalDebugLog("attach:error", {
531 message: error instanceof Error ? error.message : String(error),
532 });
414 if (pendingLaunch) {533 if (pendingLaunch) {
415 stagePendingTerminalLaunch({534 stagePendingTerminalLaunch({
416 target: pendingLaunchTarget,535 target: pendingLaunchTarget,
@@ -423,16 +542,34 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines
423 }, [542 }, [
424 lastGridSize.cols,543 lastGridSize.cols,
425 lastGridSize.rows,544 lastGridSize.rows,
426 activeKnownSession?.state.summary,545 logAttachStreamEvent,
427 selectedThreadDetail?.worktreePath,546 selectedThreadDetail?.worktreePath,
428 selectedThread,547 selectedThread,
429 selectedThreadProject?.workspaceRoot,548 selectedThreadProject?.workspaceRoot,
430 terminal.summary,
431 terminalId,549 terminalId,
432 ]);550 ]);
433 551
552 const attachTerminalRef = useRef(attachTerminal);
553 attachTerminalRef.current = attachTerminal;
554 const selectedThreadRef = useRef(selectedThread);
555 selectedThreadRef.current = selectedThread;
556 const selectedThreadProjectBootstrapRef = useRef(selectedThreadProject);
557 selectedThreadProjectBootstrapRef.current = selectedThreadProject;
558 const runningSessionRef = useRef(runningSession);
559 runningSessionRef.current = runningSession;
560 const terminalBootstrapRef = useRef({
561 status: terminal.status,
562 bufferLen: terminal.buffer.length,
563 });
564 terminalBootstrapRef.current = {
565 status: terminal.status,
566 bufferLen: terminal.buffer.length,
567 };
568
434 useEffect(() => {569 useEffect(() => {
435 hasOpenedRef.current = false;570 hasOpenedRef.current = false;
571 attachStreamLogCountRef.current = 0;
572 firstNonEmptyBufferLoggedRef.current = false;
436 }, [terminalKey]);573 }, [terminalKey]);
437 574
438 const clearBufferReplayTimer = useCallback(() => {575 const clearBufferReplayTimer = useCallback(() => {
@@ -445,9 +582,14 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines
445 const scheduleBufferReplayReady = useCallback(() => {582 const scheduleBufferReplayReady = useCallback(() => {
446 clearBufferReplayTimer();583 clearBufferReplayTimer();
447 const replayKey = bufferReplayKey;584 const replayKey = bufferReplayKey;
585 terminalDebugLog("replay:schedule-ready", {
586 replayKey,
587 delayMs: TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS,
588 });
448 bufferReplayTimerRef.current = setTimeout(() => {589 bufferReplayTimerRef.current = setTimeout(() => {
449 bufferReplayTimerRef.current = null;590 bufferReplayTimerRef.current = null;
450 setReadyBufferReplayKey(replayKey);591 setReadyBufferReplayKey(replayKey);
592 terminalDebugLog("replay:ready", { replayKey });
451 }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS);593 }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS);
452 }, [bufferReplayKey, clearBufferReplayTimer]);594 }, [bufferReplayKey, clearBufferReplayTimer]);
453 595
@@ -482,27 +624,9 @@ export function ThreadTerminalRouteScreen() { · 28 unmodified lines
482 rows: DEFAULT_TERMINAL_ROWS,624 rows: DEFAULT_TERMINAL_ROWS,
483 },625 },
484 );626 );
485 setHasMeasuredSurface(627 setHasMeasuredSurface(true);
486 getCachedTerminalGridSize({
487 environmentId: routeEnvironmentId,
488 threadId: routeThreadId,
489 terminalId,
490 }) !== null,
491 );
492 }, [routeEnvironmentId, routeThreadId, terminalId]);628 }, [routeEnvironmentId, routeThreadId, terminalId]);
493 629
494 useEffect(() => {
495 setHasMeasuredSurface(
496 routeEnvironmentId !== null &&
497 routeThreadId !== null &&
498 getCachedTerminalGridSize({
499 environmentId: routeEnvironmentId,
500 threadId: routeThreadId,
501 terminalId,
502 }) !== null,
503 );
504 }, [fontSize, routeEnvironmentId, routeThreadId, terminalId]);
505
506 useEffect(() => {630 useEffect(() => {
507 let cancelled = false;631 let cancelled = false;
508 632
@@ -539,50 +663,79 @@ export function ThreadTerminalRouteScreen() { · 30 unmodified lines
539 });663 });
540 }, [fontSize, hasResolvedFontPreference]);664 }, [fontSize, hasResolvedFontPreference]);
541 665
666 // Subscribes `terminal.attach` once per route+terminal until thread/env/attach args change.
667 // Use refs for `attachTerminal` / `selectedThread` / `runningSession`: their identities change when
668 // unrelated store updates (e.g. terminal buffer) re-render the parent, which was firing cleanup
669 // → detach immediately after the first snapshot.
542 useEffect(() => {670 useEffect(() => {
543 if (!hasResolvedFontPreference || !hasMeasuredSurface) {671 if (!hasResolvedFontPreference || !hasMeasuredSurface) {
544 return;672 return;
545 }673 }
546 674
675 const thread = selectedThreadRef.current;
676 const project = selectedThreadProjectBootstrapRef.current;
677 const running = runningSessionRef.current;
678 const termSnap = terminalBootstrapRef.current;
679
547 const bootstrapAction = resolveTerminalRouteBootstrap({680 const bootstrapAction = resolveTerminalRouteBootstrap({
548 hasThread: selectedThread !== null,681 hasThread: thread !== null,
549 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),682 hasWorkspaceRoot: Boolean(project?.workspaceRoot),
550 hasOpened: hasOpenedRef.current,683 hasOpened: hasOpenedRef.current,
551 requestedTerminalId,684 requestedTerminalId,
552 currentTerminalId: terminalId,685 currentTerminalId: terminalId,
553 runningTerminalId: runningSession?.target.terminalId ?? null,686 runningTerminalId: running?.target.terminalId ?? null,
554 currentTerminalStatus: terminal.status,687 currentTerminalStatus: termSnap.status,
555 hasCurrentTerminalHydration: terminal.summary !== null || terminal.buffer.length > 0,688 // Metadata summary (cwd/status) is not scrollback. Only `terminal.attach` fills `buffer`;
689 // treating summary as "hydrated" skipped attach while status was running → empty surface.
690 hasCurrentTerminalHydration: termSnap.bufferLen > 0,
556 });691 });
557 if (bootstrapAction.kind === "idle" || !selectedThread) {692 if (bootstrapAction.kind !== "idle") {
693 terminalDebugLog("bootstrap:action", {
694 kind: bootstrapAction.kind,
695 hasOpenedBefore: hasOpenedRef.current,
696 hasHydration: termSnap.bufferLen > 0,
697 terminalStatus: termSnap.status,
698 bufLen: termSnap.bufferLen,
699 });
700 }
701 if (bootstrapAction.kind === "idle" || !thread) {
558 return;702 return;
559 }703 }
560 704
561 if (bootstrapAction.kind === "redirect") {705 if (bootstrapAction.kind === "redirect") {
562 router.replace(buildThreadTerminalRoutePath(selectedThread, bootstrapAction.terminalId));706 router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId));
563 return;707 return;
564 }708 }
565 709
566 hasOpenedRef.current = true;710 hasOpenedRef.current = true;
567 try {711 try {
568 return attachTerminal() ?? undefined;712 const detach = attachTerminalRef.current();
569 } catch {713 terminalDebugLog("bootstrap:subscribe", { hasDetach: Boolean(detach) });
714 if (!detach) {
715 hasOpenedRef.current = false;
716 return;
717 }
718 return () => {
719 detach();
720 hasOpenedRef.current = false;
721 terminalDebugLog("bootstrap:unsubscribe");
722 };
723 } catch (error) {
570 hasOpenedRef.current = false;724 hasOpenedRef.current = false;
725 terminalDebugLog("bootstrap:attach-threw", {
726 message: error instanceof Error ? error.message : String(error),
727 });
571 return;728 return;
572 }729 }
573 }, [730 }, [
574 attachTerminal,731 hasMeasuredSurface,
732 hasResolvedFontPreference,
575 requestedTerminalId,733 requestedTerminalId,
576 router,734 router,
577 runningSession,735 selectedThread?.environmentId,
578 selectedThread,736 selectedThread?.id,
579 selectedThreadProject?.workspaceRoot,737 selectedThreadProject?.workspaceRoot,
580 terminal.buffer.length,
581 terminal.summary,
582 terminal.status,
583 terminalId,738 terminalId,
584 hasMeasuredSurface,
585 hasResolvedFontPreference,
586 ]);739 ]);
587 740
588 const writeInput = useCallback(741 const writeInput = useCallback(
@@ -626,6 +779,11 @@ export function ThreadTerminalRouteScreen() { · 37 unmodified lines
626 779
627 const handleResize = useCallback(780 const handleResize = useCallback(
628 (size: { readonly cols: number; readonly rows: number }) => {781 (size: { readonly cols: number; readonly rows: number }) => {
782 terminalDebugLog("native:onResize", {
783 cols: size.cols,
784 rows: size.rows,
785 terminalKey,
786 });
629 setHasMeasuredSurface(true);787 setHasMeasuredSurface(true);
630 if (readyBufferReplayKey !== bufferReplayKey) {788 if (readyBufferReplayKey !== bufferReplayKey) {
631 scheduleBufferReplayReady();789 scheduleBufferReplayReady();
@@ -672,6 +830,7 @@ export function ThreadTerminalRouteScreen() { · 40 unmodified lines
672 scheduleBufferReplayReady,830 scheduleBufferReplayReady,
673 selectedThread,831 selectedThread,
674 terminalId,832 terminalId,
833 terminalKey,
675 ],834 ],
676 );835 );
677 836
@@ -681,7 +840,7 @@ export function ThreadTerminalRouteScreen() { · 3 unmodified lines
681 return;840 return;
682 }841 }
683 842
684 router.replace(buildThreadTerminalRoutePath(selectedThread, nextTerminalId));843 router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId));
685 },844 },
686 [router, selectedThread, terminalId],845 [router, selectedThread, terminalId],
687 );846 );
@@ -692,12 +851,15 @@ export function ThreadTerminalRouteScreen() { · 4 unmodified lines
692 }851 }
693 852
694 router.replace(853 router.replace(
695 buildThreadTerminalRoutePath(854 buildThreadTerminalNavigation(
696 selectedThread,855 selectedThread,
697 nextTerminalId(terminalMenuSessions.map((session) => session.terminalId)),856 nextOpenTerminalId({
857 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
858 activeRouteTerminalId: terminalId,
859 }),
698 ),860 ),
699 );861 );
700 }, [router, selectedThread, terminalMenuSessions]);862 }, [router, selectedThread, terminalId, terminalMenuSessions]);
701 863
702 const adjustFontSize = useCallback((delta: number) => {864 const adjustFontSize = useCallback((delta: number) => {
703 setTimeout(() => {865 setTimeout(() => {
@@ -856,7 +1018,7 @@ export function ThreadTerminalRouteScreen() { · 152 unmodified lines
856 .filter(Boolean)1018 .filter(Boolean)
857 .join(" · ")}1019 .join(" · ")}
858 >1020 >
859 <Stack.Toolbar.Label>{getTerminalLabel(session.terminalId)}</Stack.Toolbar.Label>1021 <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>
860 </Stack.Toolbar.MenuAction>1022 </Stack.Toolbar.MenuAction>
861 ))}1023 ))}
862 <Stack.Toolbar.MenuAction1024 <Stack.Toolbar.MenuAction
apps/mobile/src/features/terminal/terminalBufferReplay.test.ts+2−2
@@ -12,7 +12,7 @@ describe("terminalBufferReplay", () => { · 11 unmodified lines
12 ).toBe("env-1:thread-1:default:10");12 ).toBe("env-1:thread-1:default:10");
13 });13 });
14 14
15 it("holds back terminal history until the measured surface is ready", () => {15 it("shows terminal history while replay key is unset (initial mount / after key change)", () => {
16 const replayKey = getTerminalBufferReplayKey({16 const replayKey = getTerminalBufferReplayKey({
17 terminalKey: "env-1:thread-1:default",17 terminalKey: "env-1:thread-1:default",
18 fontSize: 10,18 fontSize: 10,
@@ -24,7 +24,7 @@ describe("terminalBufferReplay", () => { · 5 unmodified lines
24 replayKey,24 replayKey,
25 readyReplayKey: null,25 readyReplayKey: null,
26 }),26 }),
27 ).toBe("");27 ).toBe("fastfetch output");
28 expect(28 expect(
29 getTerminalSurfaceReplayBuffer({29 getTerminalSurfaceReplayBuffer({
30 buffer: "fastfetch output",30 buffer: "fastfetch output",
apps/mobile/src/features/terminal/terminalBufferReplay.ts+14−1
@@ -1,3 +1,5 @@
1import { terminalDebugLog } from "./terminalDebugLog";
2
1export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180;3export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180;
2 4
3export function getTerminalBufferReplayKey(input: {5export function getTerminalBufferReplayKey(input: {
@@ -12,5 +14,16 @@ export function getTerminalSurfaceReplayBuffer(input: { · 8 unmodified lines
12 readonly replayKey: string;14 readonly replayKey: string;
13 readonly readyReplayKey: string | null;15 readonly readyReplayKey: string | null;
14}): string {16}): string {
15 return input.readyReplayKey === input.replayKey ? input.buffer : "";17 // Pass live buffer whenever ready key is unset or matches. Only return "" when ready key is
18 // stale vs current replay key (e.g. mid font-size transition).
19 if (input.readyReplayKey !== null && input.readyReplayKey !== input.replayKey) {
20 terminalDebugLog("replay:stale-key-hiding-buffer", {
21 replayKey: input.replayKey,
22 readyReplayKey: input.readyReplayKey,
23 bufferLen: input.buffer.length,
24 });
25 return "";
26 }
27
28 return input.buffer;
16}29}
apps/mobile/src/features/terminal/terminalDebugLog.ts+24−0
@@ -0,0 +1,24 @@
1/**
2 * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`.
3 *
4 * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS
5 * debugger / Metro console to trace release/TestFlight builds.
6 */
7export function isTerminalDebugEnabled(): boolean {
8 return (
9 (typeof __DEV__ !== "undefined" && __DEV__) ||
10 (typeof globalThis !== "undefined" &&
11 (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true)
12 );
13}
14
15export function terminalDebugLog(message: string, data?: Record<string, unknown>): void {
16 if (!isTerminalDebugEnabled()) {
17 return;
18 }
19 if (data !== undefined) {
20 console.log(`[t3-terminal] ${message}`, data);
21 } else {
22 console.log(`[t3-terminal] ${message}`);
23 }
24}
apps/mobile/src/features/terminal/terminalMenu.test.ts+48−16
@@ -3,7 +3,14 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines
3import type { KnownTerminalSession } from "@t3tools/client-runtime";3import type { KnownTerminalSession } from "@t3tools/client-runtime";
4import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";4import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
5 5
6import { buildTerminalMenuSessions, resolveProjectScriptTerminalId } from "./terminalMenu";6import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
7
8import {
9 buildTerminalMenuSessions,
10 nextOpenTerminalId,
11 nextTerminalId,
12 resolveProjectScriptTerminalId,
13} from "./terminalMenu";
7 14
8function makeKnownSession(input: {15function makeKnownSession(input: {
9 readonly terminalId: string;16 readonly terminalId: string;
@@ -29,6 +36,7 @@ function makeKnownSession(input: { · 19 unmodified lines
29 exitCode: null,36 exitCode: null,
30 exitSignal: null,37 exitSignal: null,
31 hasRunningSubprocess: false,38 hasRunningSubprocess: false,
39 label: getTerminalLabel(input.terminalId),
32 updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z",40 updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z",
33 }41 }
34 : null,42 : null,
@@ -43,7 +51,7 @@ function makeKnownSession(input: { · 8 unmodified lines
43}51}
44 52
45describe("buildTerminalMenuSessions", () => {53describe("buildTerminalMenuSessions", () => {
46 it("keeps the default shell and only includes running sessions by default", () => {54 it("only lists server-known sessions that are running or starting (plus current)", () => {
47 expect(55 expect(
48 buildTerminalMenuSessions({56 buildTerminalMenuSessions({
49 knownSessions: [57 knownSessions: [
@@ -63,18 +71,12 @@ describe("buildTerminalMenuSessions", () => { · 13 unmodified lines
63 workspaceRoot: "/workspace/root",71 workspaceRoot: "/workspace/root",
64 }),72 }),
65 ).toEqual([73 ).toEqual([
66 {
67 terminalId: DEFAULT_TERMINAL_ID,
68 cwd: "/workspace/root",
69 status: "closed",
70 hasRunningSubprocess: false,
71 updatedAt: null,
72 },
73 {74 {
74 terminalId: "term-3",75 terminalId: "term-3",
75 cwd: "/workspace/feature",76 cwd: "/workspace/feature",
76 status: "running",77 status: "running",
77 hasRunningSubprocess: false,78 hasRunningSubprocess: false,
79 displayLabel: "Terminal 3",
78 updatedAt: "2026-04-15T20:05:00.000Z",80 updatedAt: "2026-04-15T20:05:00.000Z",
79 },81 },
80 ]);82 ]);
@@ -90,28 +92,58 @@ describe("buildTerminalMenuSessions", () => { · 9 unmodified lines
90 cwd: "/workspace/exited",92 cwd: "/workspace/exited",
91 status: "exited",93 status: "exited",
92 hasRunningSubprocess: false,94 hasRunningSubprocess: false,
95 displayLabel: "Terminal 4",
93 updatedAt: "2026-04-15T20:07:00.000Z",96 updatedAt: "2026-04-15T20:07:00.000Z",
94 },97 },
95 }),98 }),
96 ).toEqual([99 ).toEqual([
97 {
98 terminalId: DEFAULT_TERMINAL_ID,
99 cwd: "/workspace/root",
100 status: "closed",
101 hasRunningSubprocess: false,
102 updatedAt: null,
103 },
104 {100 {
105 terminalId: "term-4",101 terminalId: "term-4",
106 cwd: "/workspace/exited",102 cwd: "/workspace/exited",
107 status: "exited",103 status: "exited",
108 hasRunningSubprocess: false,104 hasRunningSubprocess: false,
105 displayLabel: "Terminal 4",
109 updatedAt: "2026-04-15T20:07:00.000Z",106 updatedAt: "2026-04-15T20:07:00.000Z",
110 },107 },
111 ]);108 ]);
112 });109 });
113});110});
114 111
112describe("nextTerminalId", () => {
113 it("uses the primary id when no terminals are listed yet", () => {
114 expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID);
115 });
116
117 it("allocates term-2 when only the primary shell exists", () => {
118 expect(nextTerminalId([DEFAULT_TERMINAL_ID])).toBe("term-2");
119 });
120});
121
122describe("nextOpenTerminalId", () => {
123 it("matches nextTerminalId when not on a terminal route", () => {
124 expect(nextOpenTerminalId({ listedTerminalIds: [] })).toBe(DEFAULT_TERMINAL_ID);
125 expect(nextOpenTerminalId({ listedTerminalIds: [DEFAULT_TERMINAL_ID] })).toBe("term-2");
126 });
127
128 it("avoids the mounted primary tab when the session list is still empty", () => {
129 expect(
130 nextOpenTerminalId({
131 listedTerminalIds: [],
132 activeRouteTerminalId: DEFAULT_TERMINAL_ID,
133 }),
134 ).toBe("term-2");
135 });
136
137 it("does not double-count when the route id is already listed", () => {
138 expect(
139 nextOpenTerminalId({
140 listedTerminalIds: [DEFAULT_TERMINAL_ID],
141 activeRouteTerminalId: DEFAULT_TERMINAL_ID,
142 }),
143 ).toBe("term-2");
144 });
145});
146
115describe("resolveProjectScriptTerminalId", () => {147describe("resolveProjectScriptTerminalId", () => {
116 it("reuses the default shell when no terminal is running", () => {148 it("reuses the default shell when no terminal is running", () => {
117 expect(149 expect(
apps/mobile/src/features/terminal/terminalMenu.ts+28−39
@@ -1,11 +1,20 @@
1import type { KnownTerminalSession } from "@t3tools/client-runtime";1import type { KnownTerminalSession } from "@t3tools/client-runtime";
2import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts";2import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts";
3import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
4
5export {
6 getTerminalLabel,
7 nextTerminalId,
8 resolveTerminalSessionLabel,
9} from "@t3tools/shared/terminalLabels";
3 10
4export interface TerminalMenuSession {11export interface TerminalMenuSession {
5 readonly terminalId: string;12 readonly terminalId: string;
6 readonly cwd: string | null;13 readonly cwd: string | null;
7 readonly status: "starting" | "running" | "exited" | "error" | "closed";14 readonly status: "starting" | "running" | "exited" | "error" | "closed";
8 readonly hasRunningSubprocess: boolean;15 readonly hasRunningSubprocess: boolean;
16 /** Server-authoritative title with the same fallback rules as web. */
17 readonly displayLabel: string;
9 readonly updatedAt: string | null;18 readonly updatedAt: string | null;
10}19}
11 20
@@ -23,19 +32,6 @@ export function basename(input: string | null): string | null { · 11 unmodified lines
23 return segments[segments.length - 1] ?? normalized;32 return segments[segments.length - 1] ?? normalized;
24}33}
25 34
26export function getTerminalLabel(terminalId: string): string {
27 if (terminalId === DEFAULT_TERMINAL_ID) {
28 return "Main shell";
29 }
30
31 const numericSuffix = /^term(?:inal)?-(\d+)$/i.exec(terminalId)?.[1];
32 if (numericSuffix) {
33 return `Terminal ${numericSuffix}`;
34 }
35
36 return terminalId;
37}
38
39export function getTerminalStatusLabel(input: {35export function getTerminalStatusLabel(input: {
40 readonly status: TerminalMenuSession["status"];36 readonly status: TerminalMenuSession["status"];
41 readonly hasRunningSubprocess?: boolean;37 readonly hasRunningSubprocess?: boolean;
@@ -56,14 +52,23 @@ export function getTerminalStatusLabel(input: { · 14 unmodified lines
56 return "Not started";52 return "Not started";
57}53}
58 54
59export function nextTerminalId(existingTerminalIds: ReadonlyArray<string>): string {55/**
60 const usedIds = new Set(existingTerminalIds);56 * Picks an id for "open another shell". Counts the terminal screen already mounted
61 let nextIndex = 2;57 * (`activeRouteTerminalId`) as occupied so an empty session list on the primary route
62 while (usedIds.has(`term-${nextIndex}`)) {58 * still advances to `term-2` instead of `replace`-navigating to the same `default` tab.
63 nextIndex += 1;59 */
60export function nextOpenTerminalId(input: {
61 readonly listedTerminalIds: ReadonlyArray<string>;
62 readonly activeRouteTerminalId?: string | null;
63}): string {
64 const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0);
65 const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null;
66
67 if (!routeId || listed.includes(routeId)) {
68 return nextTerminalId(listed);
64 }69 }
65 70
66 return `term-${nextIndex}`;71 return nextTerminalId([...listed, routeId]);
67}72}
68 73
69export function buildTerminalMenuSessions(input: {74export function buildTerminalMenuSessions(input: {
@@ -73,14 +78,6 @@ export function buildTerminalMenuSessions(input: { · 3 unmodified lines
73}): ReadonlyArray<TerminalMenuSession> {78}): ReadonlyArray<TerminalMenuSession> {
74 const sessionsById = new Map<string, TerminalMenuSession>();79 const sessionsById = new Map<string, TerminalMenuSession>();
75 80
76 sessionsById.set(DEFAULT_TERMINAL_ID, {
77 terminalId: DEFAULT_TERMINAL_ID,
78 cwd: input.workspaceRoot,
79 status: "closed",
80 hasRunningSubprocess: false,
81 updatedAt: null,
82 });
83
84 for (const session of input.knownSessions) {81 for (const session of input.knownSessions) {
85 if (82 if (
86 session.state.status !== "running" &&83 session.state.status !== "running" &&
@@ -95,6 +92,7 @@ export function buildTerminalMenuSessions(input: { · 8 unmodified lines
95 cwd: session.state.summary?.cwd ?? input.workspaceRoot,92 cwd: session.state.summary?.cwd ?? input.workspaceRoot,
96 status: session.state.status,93 status: session.state.status,
97 hasRunningSubprocess: session.state.hasRunningSubprocess,94 hasRunningSubprocess: session.state.hasRunningSubprocess,
95 displayLabel: resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
98 updatedAt: session.state.updatedAt,96 updatedAt: session.state.updatedAt,
99 });97 });
100 }98 }
@@ -103,18 +101,9 @@ export function buildTerminalMenuSessions(input: { · 2 unmodified lines
103 sessionsById.set(input.currentSession.terminalId, input.currentSession);101 sessionsById.set(input.currentSession.terminalId, input.currentSession);
104 }102 }
105 103
106 return Array.from(sessionsById.values()).sort((left, right) => {104 return Array.from(sessionsById.values()).sort((left, right) =>
107 if (left.terminalId === DEFAULT_TERMINAL_ID) return -1;105 left.terminalId.localeCompare(right.terminalId, undefined, { numeric: true }),
108 if (right.terminalId === DEFAULT_TERMINAL_ID) return 1;106 );
109
110 const leftUpdatedAt = left.updatedAt ? Date.parse(left.updatedAt) : 0;
111 const rightUpdatedAt = right.updatedAt ? Date.parse(right.updatedAt) : 0;
112 if (leftUpdatedAt !== rightUpdatedAt) {
113 return rightUpdatedAt - leftUpdatedAt;
114 }
115
116 return left.terminalId.localeCompare(right.terminalId);
117 });
118}107}
119 108
120export function resolveProjectScriptTerminalId(input: {109export function resolveProjectScriptTerminalId(input: {
apps/mobile/src/features/terminal/terminalRouteBootstrap.ts+1−0
@@ -6,6 +6,7 @@ export function resolveTerminalRouteBootstrap(input: { · 5 unmodified lines
6 readonly currentTerminalId: string;6 readonly currentTerminalId: string;
7 readonly runningTerminalId: string | null;7 readonly runningTerminalId: string | null;
8 readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed";8 readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed";
9 /** True once the attach stream has populated scrollback (`buffer` non-empty), not merely metadata. */
9 readonly hasCurrentTerminalHydration: boolean;10 readonly hasCurrentTerminalHydration: boolean;
10}):11}):
11 | { readonly kind: "idle" }12 | { readonly kind: "idle" }
apps/mobile/src/features/threads/ThreadGitControls.tsx+32−39
@@ -11,7 +11,6 @@ import { Alert, Linking } from "react-native"; · 10 unmodified lines
11import { buildThreadReviewRoutePath } from "../../lib/routes";11import { buildThreadReviewRoutePath } from "../../lib/routes";
12import {12import {
13 basename,13 basename,
14 getTerminalLabel,
15 getTerminalStatusLabel,14 getTerminalStatusLabel,
16 projectScriptMenuIcon,15 projectScriptMenuIcon,
17 projectScriptMenuLabel,16 projectScriptMenuLabel,
@@ -183,19 +182,16 @@ export function ThreadGitControls(props: { · 165 unmodified lines
183 <Stack.Toolbar placement="right">182 <Stack.Toolbar placement="right">
184 <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground>183 <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground>
185 {props.projectScripts.length > 0 ? (184 {props.projectScripts.length > 0 ? (
186 <Stack.Toolbar.Menu icon="play" inline title="Project scripts">185 props.projectScripts.map((script) => (
187 <Stack.Toolbar.Label>Project scripts</Stack.Toolbar.Label>186 <Stack.Toolbar.MenuAction
188 {props.projectScripts.map((script) => (187 key={script.id}
189 <Stack.Toolbar.MenuAction188 icon={projectScriptMenuIcon(script.icon)}
190 key={script.id}189 onPress={() => void props.onRunProjectScript(script)}
191 icon={projectScriptMenuIcon(script.icon)}190 subtitle={script.command}
192 onPress={() => void props.onRunProjectScript(script)}191 >
193 subtitle={script.command}192 <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label>
194 >193 </Stack.Toolbar.MenuAction>
195 <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label>194 ))
196 </Stack.Toolbar.MenuAction>
197 ))}
198 </Stack.Toolbar.Menu>
199 ) : (195 ) : (
200 <Stack.Toolbar.MenuAction196 <Stack.Toolbar.MenuAction
201 icon="play"197 icon="play"
@@ -206,34 +202,31 @@ export function ThreadGitControls(props: { · 4 unmodified lines
206 <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label>202 <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label>
207 </Stack.Toolbar.MenuAction>203 </Stack.Toolbar.MenuAction>
208 )}204 )}
209 <Stack.Toolbar.Menu icon="terminal" inline title="Terminals">205 {props.terminalSessions.map((session) => (
210 <Stack.Toolbar.Label>Terminals</Stack.Toolbar.Label>
211 {props.terminalSessions.map((session) => (
212 <Stack.Toolbar.MenuAction
213 key={session.terminalId}
214 icon="terminal"
215 onPress={() => props.onOpenTerminal(session.terminalId)}
216 subtitle={[
217 getTerminalStatusLabel({
218 status: session.status,
219 hasRunningSubprocess: session.hasRunningSubprocess,
220 }),
221 basename(session.cwd),
222 ]
223 .filter(Boolean)
224 .join(" · ")}
225 >
226 <Stack.Toolbar.Label>{getTerminalLabel(session.terminalId)}</Stack.Toolbar.Label>
227 </Stack.Toolbar.MenuAction>
228 ))}
229 <Stack.Toolbar.MenuAction206 <Stack.Toolbar.MenuAction
230 icon="plus"207 key={session.terminalId}
231 onPress={props.onOpenNewTerminal}208 icon="terminal"
232 subtitle="Start another shell for this thread"209 onPress={() => props.onOpenTerminal(session.terminalId)}
210 subtitle={[
211 getTerminalStatusLabel({
212 status: session.status,
213 hasRunningSubprocess: session.hasRunningSubprocess,
214 }),
215 basename(session.cwd),
216 ]
217 .filter(Boolean)
218 .join(" · ")}
233 >219 >
234 <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>220 <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>
235 </Stack.Toolbar.MenuAction>221 </Stack.Toolbar.MenuAction>
236 </Stack.Toolbar.Menu>222 ))}
223 <Stack.Toolbar.MenuAction
224 icon="plus"
225 onPress={props.onOpenNewTerminal}
226 subtitle="Start another shell for this thread"
227 >
228 <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>
229 </Stack.Toolbar.MenuAction>
237 </Stack.Toolbar.Menu>230 </Stack.Toolbar.Menu>
238 <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">231 <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
239 <Stack.Toolbar.MenuAction232 <Stack.Toolbar.MenuAction
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+38−6
@@ -12,7 +12,7 @@ import { dismissGitActionResult, useGitActionProgress } from "../../state/use-gi · 11 unmodified lines
12 12
13import { EmptyState } from "../../components/EmptyState";13import { EmptyState } from "../../components/EmptyState";
14import { LoadingScreen } from "../../components/LoadingScreen";14import { LoadingScreen } from "../../components/LoadingScreen";
15import { buildThreadRoutePath, buildThreadTerminalRoutePath } from "../../lib/routes";15import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes";
16import { scopedThreadKey } from "../../lib/scopedEntities";16import { scopedThreadKey } from "../../lib/scopedEntities";
17import { connectionTone } from "../connection/connectionTone";17import { connectionTone } from "../connection/connectionTone";
18 18
@@ -27,13 +27,14 @@ import { useThreadSelection } from "../../state/use-thread-selection"; · 8 unmodified lines
27import { GitActionProgressOverlay } from "./GitActionProgressOverlay";27import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
28import {28import {
29 buildTerminalMenuSessions,29 buildTerminalMenuSessions,
30 nextTerminalId,30 nextOpenTerminalId,
31 resolveProjectScriptTerminalId,31 resolveProjectScriptTerminalId,
32} from "../terminal/terminalMenu";32} from "../terminal/terminalMenu";
33import {33import {
34 resolvePreferredThreadWorktreePath,34 resolvePreferredThreadWorktreePath,
35 stagePendingTerminalLaunch,35 stagePendingTerminalLaunch,
36} from "../terminal/terminalLaunchContext";36} from "../terminal/terminalLaunchContext";
37import { terminalDebugLog } from "../terminal/terminalDebugLog";
37import { ThreadDetailScreen } from "./ThreadDetailScreen";38import { ThreadDetailScreen } from "./ThreadDetailScreen";
38import { ThreadGitControls } from "./ThreadGitControls";39import { ThreadGitControls } from "./ThreadGitControls";
39import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";40import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
@@ -145,27 +146,52 @@ export function ThreadRouteScreen() { · 105 unmodified lines
145 146
146 const handleOpenTerminal = useCallback(147 const handleOpenTerminal = useCallback(
147 (nextTerminalId?: string | null) => {148 (nextTerminalId?: string | null) => {
149 terminalDebugLog("terminal-menu:open-existing", {
150 terminalId: nextTerminalId ?? null,
151 hasThread: Boolean(selectedThread),
152 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
153 });
154
148 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {155 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
149 return;156 return;
150 }157 }
151 158
152 void router.push(buildThreadTerminalRoutePath(selectedThread, nextTerminalId));159 void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId));
153 },160 },
154 [router, selectedThread, selectedThreadProject?.workspaceRoot],161 [router, selectedThread, selectedThreadProject?.workspaceRoot],
155 );162 );
156 163
157 const handleOpenNewTerminal = useCallback(() => {164 const handleOpenNewTerminal = useCallback(() => {
165 terminalDebugLog("terminal-menu:open-new", {
166 hasThread: Boolean(selectedThread),
167 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
168 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
169 });
170
158 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {171 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
159 return;172 return;
160 }173 }
161 174
162 const nextId = nextTerminalId(terminalMenuSessions.map((session) => session.terminalId));175 const nextId = nextOpenTerminalId({
163 void router.push(buildThreadTerminalRoutePath(selectedThread, nextId));176 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
177 });
178 void router.push(buildThreadTerminalNavigation(selectedThread, nextId));
164 }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);179 }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);
165 180
166 const handleRunProjectScript = useCallback(181 const handleRunProjectScript = useCallback(
167 async (script: ProjectScript) => {182 async (script: ProjectScript) => {
183 terminalDebugLog("project-script:press", {
184 scriptId: script.id,
185 command: script.command,
186 hasThread: Boolean(selectedThread),
187 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
188 });
189
168 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {190 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
191 terminalDebugLog("project-script:abort", {
192 scriptId: script.id,
193 reason: "no-thread-or-workspace",
194 });
169 return;195 return;
170 }196 }
171 197
@@ -200,8 +226,14 @@ export function ThreadRouteScreen() { · 28 unmodified lines
200 initialInput: `${script.command}\r`,226 initialInput: `${script.command}\r`,
201 },227 },
202 });228 });
229 terminalDebugLog("project-script:staged", {
230 scriptId: script.id,
231 terminalId: targetTerminalId,
232 cwd,
233 worktreePath: preferredWorktreePath,
234 });
203 235
204 void router.push(buildThreadTerminalRoutePath(selectedThread, targetTerminalId));236 void router.push(buildThreadTerminalNavigation(selectedThread, targetTerminalId));
205 },237 },
206 [238 [
207 router,239 router,
apps/mobile/src/lib/routes.ts+28−1
@@ -1,4 +1,4 @@
1import type { Router } from "expo-router";1import type { Href, Router } from "expo-router";
2 2
3import type { SelectedThreadRef } from "../state/remote-runtime-types";3import type { SelectedThreadRef } from "../state/remote-runtime-types";
4import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";4import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
@@ -41,6 +41,33 @@ export function buildThreadTerminalRoutePath( · 36 unmodified lines
41 return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`;41 return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`;
42}42}
43 43
44/**
45 * Prefer this over {@link buildThreadTerminalRoutePath} with `router.push(string)` — Expo Router
46 * often does not merge query strings into `useLocalSearchParams`, which breaks terminal bootstrap
47 * (`requestedTerminalId` stays null while the UI assumes `default`).
48 */
49export function buildThreadTerminalNavigation(
50 input: ThreadRouteInput | PlainThreadRouteInput,
51 terminalId?: string | null,
52): Href {
53 const environmentId = String(input.environmentId);
54 const threadId = String("threadId" in input ? input.threadId : input.id);
55
56 const params: { environmentId: string; threadId: string; terminalId?: string } = {
57 environmentId,
58 threadId,
59 };
60
61 if (terminalId != null && terminalId !== "") {
62 params.terminalId = terminalId;
63 }
64
65 return {
66 pathname: "/threads/[environmentId]/[threadId]/terminal",
67 params,
68 };
69}
70
44export function dismissRoute(router: Router) {71export function dismissRoute(router: Router) {
45 if (router.canGoBack()) {72 if (router.canGoBack()) {
46 router.back();73 router.back();
apps/mobile/src/state/use-remote-environment-registry.ts+4−0
@@ -18,6 +18,7 @@ import * as Option from "effect/Option"; · 17 unmodified lines
18import { pipe } from "effect/Function";18import { pipe } from "effect/Function";
19import { Atom } from "effect/unstable/reactivity";19import { Atom } from "effect/unstable/reactivity";
20import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection";20import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection";
21import { terminalDebugLog } from "../features/terminal/terminalDebugLog";
21import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage";22import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage";
22import { appAtomRegistry } from "./atom-registry";23import { appAtomRegistry } from "./atom-registry";
23import { type ConnectedEnvironmentSummary, type EnvironmentSession } from "./remote-runtime-types";24import { type ConnectedEnvironmentSummary, type EnvironmentSession } from "./remote-runtime-types";
@@ -268,6 +269,9 @@ export async function connectSavedEnvironment( · 244 unmodified lines
268 client,269 client,
269 }),270 }),
270 );271 );
272 terminalDebugLog("registry:terminal-metadata-subscribed", {
273 environmentId: connection.environmentId,
274 });
271 notifyEnvironmentConnectionListeners();275 notifyEnvironmentConnectionListeners();
272 276
273 try {277 try {
apps/mobile/src/state/use-terminal-session.ts+3−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13import type {13import type {
14 EnvironmentId,14 EnvironmentId,
15 TerminalAttachInput,15 TerminalAttachInput,
16 TerminalAttachStreamEvent,
16 TerminalMetadataStreamEvent,17 TerminalMetadataStreamEvent,
17 TerminalSessionSnapshot,18 TerminalSessionSnapshot,
18} from "@t3tools/contracts";19} from "@t3tools/contracts";
@@ -43,12 +44,14 @@ export function attachTerminalSession(input: { · 24 unmodified lines
43 readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"];44 readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"];
44 readonly terminal: TerminalAttachInput;45 readonly terminal: TerminalAttachInput;
45 readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void;46 readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void;
47 readonly onEvent?: (event: TerminalAttachStreamEvent) => void;
46}) {48}) {
47 return terminalSessionManager.attach({49 return terminalSessionManager.attach({
48 environmentId: input.environmentId,50 environmentId: input.environmentId,
49 client: input.client,51 client: input.client,
50 terminal: input.terminal,52 terminal: input.terminal,
51 ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}),53 ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}),
54 ...(input.onEvent ? { onEvent: input.onEvent } : {}),
52 });55 });
53}56}
54 57
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts+1−0
@@ -91,6 +91,7 @@ describe("ProjectSetupScriptRunner", () => { · 90 unmodified lines
91 history: "",91 history: "",
92 exitCode: null,92 exitCode: null,
93 exitSignal: null,93 exitSignal: null,
94 label: "setup-setup",
94 updatedAt: "2026-01-01T00:00:00.000Z",95 updatedAt: "2026-01-01T00:00:00.000Z",
95 }),96 }),
96 );97 );
apps/server/src/server.test.ts+1−0
@@ -3929,6 +3929,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 3928 unmodified lines
3929 history: "",3929 history: "",
3930 exitCode: null,3930 exitCode: null,
3931 exitSignal: null,3931 exitSignal: null,
3932 label: "Primary",
3932 updatedAt: new Date().toISOString(),3933 updatedAt: new Date().toISOString(),
3933 };3934 };
3934 3935
apps/server/src/terminal/Layers/Manager.test.ts+29−13
@@ -198,7 +198,10 @@ interface CreateManagerOptions { · 197 unmodified lines
198 shellResolver?: () => string;198 shellResolver?: () => string;
199 platform?: NodeJS.Platform;199 platform?: NodeJS.Platform;
200 env?: NodeJS.ProcessEnv;200 env?: NodeJS.ProcessEnv;
201 subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>;201 subprocessInspector?: (terminalPid: number) => Effect.Effect<{
202 readonly hasRunningSubprocess: boolean;
203 readonly childCommand: string | null;
204 }>;
202 subprocessPollIntervalMs?: number;205 subprocessPollIntervalMs?: number;
203 processKillGraceMs?: number;206 processKillGraceMs?: number;
204 maxRetainedInactiveSessions?: number;207 maxRetainedInactiveSessions?: number;
@@ -234,8 +237,8 @@ const createManager = ( · 29 unmodified lines
234 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),237 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
235 ...(options.platform !== undefined ? { platform: options.platform } : {}),238 ...(options.platform !== undefined ? { platform: options.platform } : {}),
236 ...(options.env !== undefined ? { env: options.env } : {}),239 ...(options.env !== undefined ? { env: options.env } : {}),
237 ...(options.subprocessChecker !== undefined240 ...(options.subprocessInspector !== undefined
238 ? { subprocessChecker: options.subprocessChecker }241 ? { subprocessInspector: options.subprocessInspector }
239 : {}),242 : {}),
240 ...(options.subprocessPollIntervalMs !== undefined243 ...(options.subprocessPollIntervalMs !== undefined
241 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }244 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
@@ -275,7 +278,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 33 unmodified lines
275 const third = yield* manager.open(openInput());278 const third = yield* manager.open(openInput());
276 279
277 assert.equal(first.threadId, "thread-1");280 assert.equal(first.threadId, "thread-1");
278 assert.equal(first.terminalId, "default");281 assert.equal(first.terminalId, DEFAULT_TERMINAL_ID);
279 assert.equal(second.threadId, "thread-1");282 assert.equal(second.threadId, "thread-1");
280 assert.equal(third.threadId, "thread-1");283 assert.equal(third.threadId, "thread-1");
281 expect(ptyAdapter.spawnInputs).toHaveLength(1);284 expect(ptyAdapter.spawnInputs).toHaveLength(1);
@@ -445,7 +448,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 163 unmodified lines
445 (event) =>448 (event) =>
446 event.type === "cleared" &&449 event.type === "cleared" &&
447 event.threadId === "thread-1" &&450 event.threadId === "thread-1" &&
448 event.terminalId === "default",451 event.terminalId === DEFAULT_TERMINAL_ID,
449 ),452 ),
450 ).toBe(true);453 ).toBe(true);
451 }),454 }),
@@ -617,27 +620,40 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 165 unmodified lines
617 620
618 it.effect("emits subprocess activity events when child-process state changes", () =>621 it.effect("emits subprocess activity events when child-process state changes", () =>
619 Effect.gen(function* () {622 Effect.gen(function* () {
620 let hasRunningSubprocess = false;623 let inspect: {
624 readonly hasRunningSubprocess: boolean;
625 readonly childCommand: string | null;
626 } = { hasRunningSubprocess: false, childCommand: null };
621 const { manager, getEvents } = yield* createManager(5, {627 const { manager, getEvents } = yield* createManager(5, {
622 subprocessChecker: () => Effect.succeed(hasRunningSubprocess),628 subprocessInspector: () => Effect.succeed(inspect),
623 subprocessPollIntervalMs: 20,629 subprocessPollIntervalMs: 20,
624 });630 });
625 631
626 yield* manager.open(openInput());632 yield* manager.open(openInput());
627 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);633 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);
628 634
629 hasRunningSubprocess = true;635 inspect = { hasRunningSubprocess: true, childCommand: "vim" };
630 yield* waitFor(636 yield* waitFor(
631 Effect.map(getEvents, (events) =>637 Effect.map(getEvents, (events) =>
632 events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true),638 events.some(
639 (event) =>
640 event.type === "activity" &&
641 event.hasRunningSubprocess === true &&
642 event.label === "vim",
643 ),
633 ),644 ),
634 "1200 millis",645 "1200 millis",
635 );646 );
636 647
637 hasRunningSubprocess = false;648 inspect = { hasRunningSubprocess: false, childCommand: null };
638 yield* waitFor(649 yield* waitFor(
639 Effect.map(getEvents, (events) =>650 Effect.map(getEvents, (events) =>
640 events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false),651 events.some(
652 (event) =>
653 event.type === "activity" &&
654 event.hasRunningSubprocess === false &&
655 event.label === "Terminal 1",
656 ),
641 ),657 ),
642 "1200 millis",658 "1200 millis",
643 );659 );
@@ -648,9 +664,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 4 unmodified lines
648 Effect.gen(function* () {664 Effect.gen(function* () {
649 let checks = 0;665 let checks = 0;
650 const { manager } = yield* createManager(5, {666 const { manager } = yield* createManager(5, {
651 subprocessChecker: () => {667 subprocessInspector: () => {
652 checks += 1;668 checks += 1;
653 return Effect.succeed(false);669 return Effect.succeed({ hasRunningSubprocess: false, childCommand: null });
654 },670 },
655 subprocessPollIntervalMs: 20,671 subprocessPollIntervalMs: 20,
656 });672 });
apps/server/src/terminal/Layers/Manager.ts+188−52
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12 type TerminalSummary,12 type TerminalSummary,
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";14import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
15import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
15import {16import {
16 Effect,17 Effect,
17 Encoding,18 Encoding,
@@ -58,6 +59,7 @@ const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; · 40 unmodified lines
58const DEFAULT_OPEN_COLS = 120;59const DEFAULT_OPEN_COLS = 120;
59const DEFAULT_OPEN_ROWS = 30;60const DEFAULT_OPEN_ROWS = 30;
60const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);61const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
62const MAX_TERMINAL_LABEL_LENGTH = 128;
61 63
62class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(64class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(
63 "TerminalSubprocessCheckError",65 "TerminalSubprocessCheckError",
@@ -78,8 +80,15 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess · 14 unmodified lines
78 },80 },
79) {}81) {}
80 82
81interface TerminalSubprocessChecker {83interface TerminalSubprocessInspectResult {
82 (terminalPid: number): Effect.Effect<boolean, TerminalSubprocessCheckError>;84 readonly hasRunningSubprocess: boolean;
85 readonly childCommand: string | null;
86}
87
88interface TerminalSubprocessInspector {
89 (
90 terminalPid: number,
91 ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>;
83}92}
84 93
85interface ShellCandidate {94interface ShellCandidate {
@@ -119,6 +128,8 @@ interface TerminalSessionState { · 33 unmodified lines
119 unsubscribeData: (() => void) | null;128 unsubscribeData: (() => void) | null;
120 unsubscribeExit: (() => void) | null;129 unsubscribeExit: (() => void) | null;
121 hasRunningSubprocess: boolean;130 hasRunningSubprocess: boolean;
131 /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */
132 childCommandLabel: string | null;
122 runtimeEnv: Record<string, string> | null;133 runtimeEnv: Record<string, string> | null;
123}134}
124 135
@@ -154,6 +165,38 @@ interface TerminalManagerState { · 29 unmodified lines
154 killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>;165 killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>;
155}166}
156 167
168function truncateTerminalWireLabel(value: string): string {
169 if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value;
170 return value.slice(0, MAX_TERMINAL_LABEL_LENGTH);
171}
172
173function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null {
174 let trimmed = raw.trim();
175 if (trimmed.length === 0) return null;
176 if (
177 (trimmed.startsWith("[") && trimmed.endsWith("]")) ||
178 (trimmed.startsWith("(") && trimmed.endsWith(")"))
179 ) {
180 trimmed = trimmed.slice(1, -1).trim();
181 }
182 const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim();
183 if (firstToken.length === 0) return null;
184 const base = platform === "win32" ? path.win32.basename(firstToken) : path.basename(firstToken);
185 const withoutExe =
186 platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base;
187 return withoutExe.length > 0 ? withoutExe : null;
188}
189
190function terminalWireLabel(session: TerminalSessionState): string {
191 if (session.hasRunningSubprocess && session.childCommandLabel) {
192 const trimmed = session.childCommandLabel.trim();
193 if (trimmed.length > 0) {
194 return truncateTerminalWireLabel(trimmed);
195 }
196 }
197 return truncateTerminalWireLabel(getTerminalLabel(session.terminalId));
198}
199
157function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {200function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
158 return {201 return {
159 threadId: session.threadId,202 threadId: session.threadId,
@@ -165,6 +208,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { · 5 unmodified lines
165 history: session.history,208 history: session.history,
166 exitCode: session.exitCode,209 exitCode: session.exitCode,
167 exitSignal: session.exitSignal,210 exitSignal: session.exitSignal,
211 label: terminalWireLabel(session),
168 updatedAt: session.updatedAt,212 updatedAt: session.updatedAt,
169 sequence: session.eventSequence,213 sequence: session.eventSequence,
170 };214 };
@@ -181,6 +225,7 @@ function summary(session: TerminalSessionState): TerminalSummary { · 10 unmodified lines
181 exitCode: session.exitCode,225 exitCode: session.exitCode,
182 exitSignal: session.exitSignal,226 exitSignal: session.exitSignal,
183 hasRunningSubprocess: session.hasRunningSubprocess,227 hasRunningSubprocess: session.hasRunningSubprocess,
228 label: terminalWireLabel(session),
184 updatedAt: session.updatedAt,229 updatedAt: session.updatedAt,
185 };230 };
186}231}
@@ -426,13 +471,25 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { · 239 unmodified lines
426 );471 );
427}472}
428 473
429function checkWindowsSubprocessActivity(474function parseFirstChildPidFromPgrep(stdout: string): number | null {
475 for (const line of stdout.split(/\r?\n/g)) {
476 const n = Number.parseInt(line.trim(), 10);
477 if (Number.isInteger(n) && n > 0) {
478 return n;
479 }
480 }
481 return null;
482}
483
484function windowsInspectSubprocess(
430 terminalPid: number,485 terminalPid: number,
431): Effect.Effect<boolean, TerminalSubprocessCheckError> {486 platform: NodeJS.Platform,
487): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError> {
432 const command = [488 const command = [
433 `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,489 `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`,
434 "if ($children) { exit 0 }",490 "if ($null -eq $c) { exit 1 }",
435 "exit 1",491 "Write-Output $c.Name",
492 "exit 0",
436 ].join("; ");493 ].join("; ");
437 return Effect.tryPromise({494 return Effect.tryPromise({
438 try: () =>495 try: () =>
@@ -444,17 +501,33 @@ function checkWindowsSubprocessActivity( · 5 unmodified lines
444 }),501 }),
445 catch: (cause) =>502 catch: (cause) =>
446 new TerminalSubprocessCheckError({503 new TerminalSubprocessCheckError({
447 message: "Failed to check Windows terminal subprocess activity.",504 message: "Failed to inspect Windows terminal subprocesses.",
448 cause,505 cause,
449 terminalPid,506 terminalPid,
450 command: "powershell",507 command: "powershell",
451 }),508 }),
452 }).pipe(Effect.map((result) => result.code === 0));509 }).pipe(
510 Effect.map((result) => {
511 if (result.code !== 0) {
512 return { hasRunningSubprocess: false, childCommand: null } as const;
513 }
514 const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? "";
515 if (name.length === 0) {
516 return { hasRunningSubprocess: true, childCommand: null } as const;
517 }
518 const normalized = normalizeChildCommandName(name, platform);
519 return {
520 hasRunningSubprocess: true,
521 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
522 } as const;
523 }),
524 );
453}525}
454 526
455const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* (527const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* (
456 terminalPid: number,528 terminalPid: number,
457): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {529 platform: NodeJS.Platform,
530): Effect.fn.Return<TerminalSubprocessInspectResult, TerminalSubprocessCheckError> {
458 const runPgrep = Effect.tryPromise({531 const runPgrep = Effect.tryPromise({
459 try: () =>532 try: () =>
460 runProcess("pgrep", ["-P", String(terminalPid)], {533 runProcess("pgrep", ["-P", String(terminalPid)], {
@@ -489,45 +562,94 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct · 28 unmodified lines
489 }),562 }),
490 });563 });
491 564
565 let childPid: number | null = null;
566
492 const pgrepResult = yield* Effect.exit(runPgrep);567 const pgrepResult = yield* Effect.exit(runPgrep);
493 if (pgrepResult._tag === "Success") {568 if (pgrepResult._tag === "Success") {
494 if (pgrepResult.value.code === 0) {569 if (pgrepResult.value.code === 0) {
495 return pgrepResult.value.stdout.trim().length > 0;570 childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout);
496 }571 } else if (pgrepResult.value.code === 1) {
497 if (pgrepResult.value.code === 1) {572 return { hasRunningSubprocess: false, childCommand: null };
498 return false;
499 }573 }
500 }574 }
501 575
502 const psResult = yield* Effect.exit(runPs);576 if (childPid === null) {
503 if (psResult._tag === "Failure" || psResult.value.code !== 0) {577 const psResult = yield* Effect.exit(runPs);
504 return false;578 if (psResult._tag === "Failure" || psResult.value.code !== 0) {
579 return { hasRunningSubprocess: false, childCommand: null };
580 }
581 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
582 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
583 const pid = Number(pidRaw);
584 const ppid = Number(ppidRaw);
585 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
586 if (ppid === terminalPid) {
587 childPid = pid;
588 break;
589 }
590 }
505 }591 }
506 592
507 for (const line of psResult.value.stdout.split(/\r?\n/g)) {593 if (childPid === null) {
508 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);594 return { hasRunningSubprocess: false, childCommand: null };
509 const pid = Number(pidRaw);
510 const ppid = Number(ppidRaw);
511 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
512 if (ppid === terminalPid) {
513 return true;
514 }
515 }595 }
516 return false;
517});
518 596
519const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* (597 const runComm = Effect.tryPromise({
520 terminalPid: number,598 try: () =>
521): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {599 runProcess("ps", ["-p", String(childPid), "-o", "comm="], {
522 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {600 timeoutMs: 1_000,
523 return false;601 allowNonZeroExit: true,
602 maxBufferBytes: 8_192,
603 outputMode: "truncate",
604 }),
605 catch: () => null,
606 });
607
608 const commResult = yield* Effect.exit(runComm);
609 let rawComm: string | null = null;
610 if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) {
611 rawComm = commResult.value.stdout.trim();
524 }612 }
525 if (process.platform === "win32") {613
526 return yield* checkWindowsSubprocessActivity(terminalPid);614 if (!rawComm || rawComm.length === 0) {
615 const runArgs = Effect.tryPromise({
616 try: () =>
617 runProcess("ps", ["-p", String(childPid), "-o", "args="], {
618 timeoutMs: 1_000,
619 allowNonZeroExit: true,
620 maxBufferBytes: 16_384,
621 outputMode: "truncate",
622 }),
623 catch: () => null,
624 });
625 const argsResult = yield* Effect.exit(runArgs);
626 if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) {
627 const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? "";
628 rawComm = first.length > 0 ? first : null;
629 }
527 }630 }
528 return yield* checkPosixSubprocessActivity(terminalPid);631
632 const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null;
633 return {
634 hasRunningSubprocess: true,
635 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
636 };
529});637});
530 638
639function defaultSubprocessInspectorForPlatform(
640 platform: NodeJS.Platform,
641): TerminalSubprocessInspector {
642 return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) {
643 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
644 return { hasRunningSubprocess: false, childCommand: null };
645 }
646 if (platform === "win32") {
647 return yield* windowsInspectSubprocess(terminalPid, platform);
648 }
649 return yield* posixInspectSubprocess(terminalPid, platform);
650 });
651}
652
531function capHistory(history: string, maxLines: number): string {653function capHistory(history: string, maxLines: number): string {
532 if (history.length === 0) return history;654 if (history.length === 0) return history;
533 const hasTrailingNewline = history.endsWith("\n");655 const hasTrailingNewline = history.endsWith("\n");
@@ -775,7 +897,7 @@ interface TerminalManagerOptions { · 241 unmodified lines
775 shellResolver?: () => string;897 shellResolver?: () => string;
776 platform?: NodeJS.Platform;898 platform?: NodeJS.Platform;
777 env?: NodeJS.ProcessEnv;899 env?: NodeJS.ProcessEnv;
778 subprocessChecker?: TerminalSubprocessChecker;900 subprocessInspector?: TerminalSubprocessInspector;
779 subprocessPollIntervalMs?: number;901 subprocessPollIntervalMs?: number;
780 processKillGraceMs?: number;902 processKillGraceMs?: number;
781 maxRetainedInactiveSessions?: number;903 maxRetainedInactiveSessions?: number;
@@ -801,7 +923,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 19 unmodified lines
801 const platform = options.platform ?? process.platform;923 const platform = options.platform ?? process.platform;
802 const baseEnv = options.env ?? process.env;924 const baseEnv = options.env ?? process.env;
803 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));925 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
804 const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;926 const subprocessInspector =
927 options.subprocessInspector ?? defaultSubprocessInspectorForPlatform(platform);
805 const subprocessPollIntervalMs =928 const subprocessPollIntervalMs =
806 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;929 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
807 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;930 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
@@ -1290,6 +1413,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 482 unmodified lines
1290 session.process = null;1413 session.process = null;
1291 session.pid = null;1414 session.pid = null;
1292 session.hasRunningSubprocess = false;1415 session.hasRunningSubprocess = false;
1416 session.childCommandLabel = null;
1293 session.status = "exited";1417 session.status = "exited";
1294 session.pendingHistoryControlSequence = "";1418 session.pendingHistoryControlSequence = "";
1295 session.pendingProcessEvents = [];1419 session.pendingProcessEvents = [];
@@ -1358,6 +1482,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 62 unmodified lines
1358 session.process = null;1482 session.process = null;
1359 session.pid = null;1483 session.pid = null;
1360 session.hasRunningSubprocess = false;1484 session.hasRunningSubprocess = false;
1485 session.childCommandLabel = null;
1361 session.status = "exited";1486 session.status = "exited";
1362 session.pendingHistoryControlSequence = "";1487 session.pendingHistoryControlSequence = "";
1363 session.pendingProcessEvents = [];1488 session.pendingProcessEvents = [];
@@ -1451,6 +1576,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 87 unmodified lines
1451 session.exitCode = null;1576 session.exitCode = null;
1452 session.exitSignal = null;1577 session.exitSignal = null;
1453 session.hasRunningSubprocess = false;1578 session.hasRunningSubprocess = false;
1579 session.childCommandLabel = null;
1454 session.pendingProcessEvents = [];1580 session.pendingProcessEvents = [];
1455 session.pendingProcessEventIndex = 0;1581 session.pendingProcessEventIndex = 0;
1456 session.processEventDrainRunning = false;1582 session.processEventDrainRunning = false;
@@ -1528,6 +1654,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 71 unmodified lines
1528 session.unsubscribeData = null;1654 session.unsubscribeData = null;
1529 session.unsubscribeExit = null;1655 session.unsubscribeExit = null;
1530 session.hasRunningSubprocess = false;1656 session.hasRunningSubprocess = false;
1657 session.childCommandLabel = null;
1531 session.pendingProcessEvents = [];1658 session.pendingProcessEvents = [];
1532 session.pendingProcessEventIndex = 0;1659 session.pendingProcessEventIndex = 0;
1533 session.processEventDrainRunning = false;1660 session.processEventDrainRunning = false;
@@ -1608,7 +1735,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 74 unmodified lines
1608 session: TerminalSessionState & { pid: number },1735 session: TerminalSessionState & { pid: number },
1609 ) {1736 ) {
1610 const terminalPid = session.pid;1737 const terminalPid = session.pid;
1611 const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe(1738 const inspectResult = yield* subprocessInspector(terminalPid).pipe(
1612 Effect.map(Option.some),1739 Effect.map(Option.some),
1613 Effect.catch((reason) =>1740 Effect.catch((reason) =>
1614 Effect.logWarning("failed to check terminal subprocess activity", {1741 Effect.logWarning("failed to check terminal subprocess activity", {
@@ -1616,14 +1743,17 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 1 unmodified line
1616 terminalId: session.terminalId,1743 terminalId: session.terminalId,
1617 terminalPid,1744 terminalPid,
1618 reason,1745 reason,
1619 }).pipe(Effect.as(Option.none<boolean>())),1746 }).pipe(Effect.as(Option.none<TerminalSubprocessInspectResult>())),
1620 ),1747 ),
1621 );1748 );
1622 1749
1623 if (Option.isNone(hasRunningSubprocess)) {1750 if (Option.isNone(inspectResult)) {
1624 return;1751 return;
1625 }1752 }
1626 1753
1754 const next = inspectResult.value;
1755 const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null;
1756
1627 const event = yield* modifyManagerState((state) => {1757 const event = yield* modifyManagerState((state) => {
1628 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(1758 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
1629 state.sessions.get(toSessionKey(session.threadId, session.terminalId)),1759 state.sessions.get(toSessionKey(session.threadId, session.terminalId)),
@@ -1632,12 +1762,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 2 unmodified lines
1632 Option.isNone(liveSession) ||1762 Option.isNone(liveSession) ||
1633 liveSession.value.status !== "running" ||1763 liveSession.value.status !== "running" ||
1634 liveSession.value.pid !== terminalPid ||1764 liveSession.value.pid !== terminalPid ||
1635 liveSession.value.hasRunningSubprocess === hasRunningSubprocess.value1765 (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess &&
1766 liveSession.value.childCommandLabel === nextChildLabel)
1636 ) {1767 ) {
1637 return [Option.none(), state] as const;1768 return [Option.none(), state] as const;
1638 }1769 }
1639 1770
1640 liveSession.value.hasRunningSubprocess = hasRunningSubprocess.value;1771 liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess;
1772 liveSession.value.childCommandLabel = nextChildLabel;
1641 const eventStamp = advanceEventSequence(liveSession.value);1773 const eventStamp = advanceEventSequence(liveSession.value);
1642 1774
1643 return [1775 return [
@@ -1646,7 +1778,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 2 unmodified lines
1646 threadId: liveSession.value.threadId,1778 threadId: liveSession.value.threadId,
1647 terminalId: liveSession.value.terminalId,1779 terminalId: liveSession.value.terminalId,
1648 sequence: eventStamp.sequence,1780 sequence: eventStamp.sequence,
1649 hasRunningSubprocess: hasRunningSubprocess.value,1781 hasRunningSubprocess: next.hasRunningSubprocess,
1782 label: terminalWireLabel(liveSession.value),
1650 }),1783 }),
1651 state,1784 state,
1652 ] as const;1785 ] as const;
@@ -1711,7 +1844,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 58 unmodified lines
1711 );1844 );
1712 1845
1713 const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) {1846 const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) {
1714 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;1847 const terminalId = input.terminalId;
1715 yield* assertValidCwd(input.cwd);1848 yield* assertValidCwd(input.cwd);
1716 1849
1717 const sessionKey = toSessionKey(input.threadId, terminalId);1850 const sessionKey = toSessionKey(input.threadId, terminalId);
@@ -1743,6 +1876,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 25 unmodified lines
1743 unsubscribeData: null,1876 unsubscribeData: null,
1744 unsubscribeExit: null,1877 unsubscribeExit: null,
1745 hasRunningSubprocess: false,1878 hasRunningSubprocess: false,
1879 childCommandLabel: null,
1746 runtimeEnv: normalizedRuntimeEnv(input.env),1880 runtimeEnv: normalizedRuntimeEnv(input.env),
1747 };1881 };
1748 1882
@@ -1839,7 +1973,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 90 unmodified lines
1839 withThreadLock(1973 withThreadLock(
1840 input.threadId,1974 input.threadId,
1841 Effect.gen(function* () {1975 Effect.gen(function* () {
1842 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;1976 const terminalId = input.terminalId;
1843 const existing = yield* getSession(input.threadId, terminalId);1977 const existing = yield* getSession(input.threadId, terminalId);
1844 1978
1845 if (Option.isNone(existing)) {1979 if (Option.isNone(existing)) {
@@ -1918,10 +2052,12 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 72 unmodified lines
1918 let unsubscribe: (() => void) | null = null;2052 let unsubscribe: (() => void) | null = null;
1919 2053
1920 return Effect.gen(function* () {2054 return Effect.gen(function* () {
1921 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1922 const bufferedEvents: TerminalEvent[] = [];2055 const bufferedEvents: TerminalEvent[] = [];
1923 let deliverLive = false;2056 let deliverLive = false;
1924 2057
2058 const initialSnapshot = yield* openOrAttachForStream(input);
2059 const terminalId = initialSnapshot.terminalId;
2060
1925 unsubscribe = yield* subscribe((event) => {2061 unsubscribe = yield* subscribe((event) => {
1926 if (event.threadId !== input.threadId || event.terminalId !== terminalId) {2062 if (event.threadId !== input.threadId || event.terminalId !== terminalId) {
1927 return Effect.void;2063 return Effect.void;
@@ -1936,7 +2072,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 8 unmodified lines
1936 return attachEvent ? listener(attachEvent) : Effect.void;2072 return attachEvent ? listener(attachEvent) : Effect.void;
1937 });2073 });
1938 2074
1939 const initialSnapshot = yield* openOrAttachForStream(input);
1940 yield* listener({2075 yield* listener({
1941 type: "snapshot",2076 type: "snapshot",
1942 snapshot: initialSnapshot,2077 snapshot: initialSnapshot,
@@ -2044,7 +2179,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 101 unmodified lines
2044 };2179 };
2045 2180
2046 const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) {2181 const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) {
2047 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;2182 const terminalId = input.terminalId;
2048 const session = yield* requireSession(input.threadId, terminalId);2183 const session = yield* requireSession(input.threadId, terminalId);
2049 const process = session.process;2184 const process = session.process;
2050 if (!process || session.status !== "running") {2185 if (!process || session.status !== "running") {
@@ -2058,7 +2193,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 7 unmodified lines
2058 });2193 });
2059 2194
2060 const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) {2195 const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) {
2061 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;2196 const terminalId = input.terminalId;
2062 const session = yield* requireSession(input.threadId, terminalId);2197 const session = yield* requireSession(input.threadId, terminalId);
2063 const process = session.process;2198 const process = session.process;
2064 if (!process || session.status !== "running") {2199 if (!process || session.status !== "running") {
@@ -2077,7 +2212,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 12 unmodified lines
2077 withThreadLock(2212 withThreadLock(
2078 input.threadId,2213 input.threadId,
2079 Effect.gen(function* () {2214 Effect.gen(function* () {
2080 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;2215 const terminalId = input.terminalId;
2081 const session = yield* requireSession(input.threadId, terminalId);2216 const session = yield* requireSession(input.threadId, terminalId);
2082 session.history = "";2217 session.history = "";
2083 session.pendingHistoryControlSequence = "";2218 session.pendingHistoryControlSequence = "";
@@ -2100,7 +2235,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 16 unmodified lines
2100 input.threadId,2235 input.threadId,
2101 Effect.gen(function* () {2236 Effect.gen(function* () {
2102 yield* increment(terminalRestartsTotal, { scope: "thread" });2237 yield* increment(terminalRestartsTotal, { scope: "thread" });
2103 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;2238 const terminalId = input.terminalId;
2104 yield* assertValidCwd(input.cwd);2239 yield* assertValidCwd(input.cwd);
2105 2240
2106 const sessionKey = toSessionKey(input.threadId, terminalId);2241 const sessionKey = toSessionKey(input.threadId, terminalId);
@@ -2131,6 +2266,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 24 unmodified lines
2131 unsubscribeData: null,2266 unsubscribeData: null,
2132 unsubscribeExit: null,2267 unsubscribeExit: null,
2133 hasRunningSubprocess: false,2268 hasRunningSubprocess: false,
2269 childCommandLabel: null,
2134 runtimeEnv: normalizedRuntimeEnv(input.env),2270 runtimeEnv: normalizedRuntimeEnv(input.env),
2135 };2271 };
2136 const createdSession = session;2272 const createdSession = session;
apps/web/src/components/ChatView.browser.tsx+22−0
@@ -993,6 +993,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { · 992 unmodified lines
993 history: "",993 history: "",
994 exitCode: null,994 exitCode: null,
995 exitSignal: null,995 exitSignal: null,
996 label: "Terminal 1",
996 updatedAt: NOW_ISO,997 updatedAt: NOW_ISO,
997 };998 };
998 }999 }
@@ -1767,6 +1768,26 @@ describe("ChatView timeline estimator parity (full app)", () => { · 768 unmodified lines
1767 const mounted = await mountChatView({1768 const mounted = await mountChatView({
1768 viewport: DEFAULT_VIEWPORT,1769 viewport: DEFAULT_VIEWPORT,
1769 snapshot,1770 snapshot,
1771 configureFixture: (nextFixture) => {
1772 nextFixture.terminalMetadataEvents = [
1773 {
1774 type: "upsert",
1775 terminal: {
1776 threadId: THREAD_ID,
1777 terminalId: DEFAULT_TERMINAL_ID,
1778 cwd: "/repo/project",
1779 worktreePath: null,
1780 status: "running",
1781 pid: 123,
1782 exitCode: null,
1783 exitSignal: null,
1784 hasRunningSubprocess: false,
1785 label: "Terminal 1",
1786 updatedAt: isoAt(0),
1787 },
1788 },
1789 ];
1790 },
1770 });1791 });
1771 1792
1772 try {1793 try {
@@ -3550,6 +3571,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1777 unmodified lines
3550 exitCode: null,3571 exitCode: null,
3551 exitSignal: null,3572 exitSignal: null,
3552 hasRunningSubprocess: true,3573 hasRunningSubprocess: true,
3574 label: "Terminal 1",
3553 updatedAt: isoAt(1_200),3575 updatedAt: isoAt(1_200),
3554 },3576 },
3555 },3577 },
apps/web/src/components/ChatView.tsx+267−35
@@ -29,6 +29,7 @@ import { · 28 unmodified lines
29import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model";29import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model";
30import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";30import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
31import { truncate } from "@t3tools/shared/String";31import { truncate } from "@t3tools/shared/String";
32import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
32import { Debouncer } from "@tanstack/react-pacer";33import { Debouncer } from "@tanstack/react-pacer";
33import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";34import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
34import { useNavigate, useSearch } from "@tanstack/react-router";35import { useNavigate, useSearch } from "@tanstack/react-router";
@@ -100,7 +101,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" · 65 unmodified lines
100import PlanSidebar from "./PlanSidebar";101import PlanSidebar from "./PlanSidebar";
101import ThreadTerminalDrawer from "./ThreadTerminalDrawer";102import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
102import { ChevronDownIcon } from "lucide-react";103import { ChevronDownIcon } from "lucide-react";
103import { cn, randomUUID } from "~/lib/utils";104import { cn } from "~/lib/utils";
104import { toastManager } from "./ui/toast";105import { toastManager } from "./ui/toast";
105import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";106import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
106import { type NewProjectScriptInput } from "./ProjectScriptsControl";107import { type NewProjectScriptInput } from "./ProjectScriptsControl";
@@ -133,7 +134,7 @@ import { · 26 unmodified lines
133 type TerminalContextSelection,134 type TerminalContextSelection,
134} from "../lib/terminalContext";135} from "../lib/terminalContext";
135import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";136import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
136import { useThreadRunningTerminalIds } from "../terminalSessionState";137import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState";
137import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";138import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
138import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";139import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
139import { PullRequestThreadDialog } from "./PullRequestThreadDialog";140import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
@@ -408,6 +409,45 @@ function useLocalDispatchState(input: { · 268 unmodified lines
408 };409 };
409}410}
410 411
412/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */
413function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean {
414 if (left.length !== right.length) {
415 return false;
416 }
417 if (left.length === 0) {
418 return true;
419 }
420 const sortedLeft = [...left].sort((a, b) => a.localeCompare(b));
421 const sortedRight = [...right].sort((a, b) => a.localeCompare(b));
422 for (let index = 0; index < sortedLeft.length; index += 1) {
423 if (sortedLeft[index] !== sortedRight[index]) {
424 return false;
425 }
426 }
427 return true;
428}
429
430/**
431 * Server knows about fewer sessions than the client, but every server id still exists locally.
432 * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id
433 * and later re-add it as a separate group (no split layout).
434 */
435function serverTerminalIdsStrictSubsetOfClient(
436 serverIds: readonly string[],
437 clientIds: readonly string[],
438): boolean {
439 if (serverIds.length >= clientIds.length || clientIds.length === 0) {
440 return false;
441 }
442 const clientSet = new Set(clientIds);
443 for (const id of serverIds) {
444 if (!clientSet.has(id)) {
445 return false;
446 }
447 }
448 return true;
449}
450
411interface PersistentThreadTerminalDrawerProps {451interface PersistentThreadTerminalDrawerProps {
412 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };452 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
413 threadId: ThreadId;453 threadId: ThreadId;
@@ -444,11 +484,42 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 30 unmodified lines
444 const terminalUiState = useTerminalUiStateStore((state) =>484 const terminalUiState = useTerminalUiStateStore((state) =>
445 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),485 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),
446 );486 );
487 const knownTerminalSessions = useKnownTerminalSessions({
488 environmentId: threadRef.environmentId,
489 threadId,
490 });
491 const terminalLabelsById = useMemo(() => {
492 const next = new Map<string, string>();
493 for (const session of knownTerminalSessions) {
494 next.set(
495 session.target.terminalId,
496 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
497 );
498 }
499 return next;
500 }, [knownTerminalSessions]);
501 const serverOrderedTerminalIds = useMemo(
502 () => knownTerminalSessions.map((session) => session.target.terminalId),
503 [knownTerminalSessions],
504 );
447 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);505 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);
448 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);506 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);
449 const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal);507 const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal);
450 const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal);508 const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal);
451 const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal);509 const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal);
510 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);
511
512 useEffect(() => {
513 if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) {
514 return;
515 }
516 if (
517 serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds)
518 ) {
519 return;
520 }
521 reconcileTerminalIds(threadRef, serverOrderedTerminalIds);
522 }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]);
452 const [localFocusRequestId, setLocalFocusRequestId] = useState(0);523 const [localFocusRequestId, setLocalFocusRequestId] = useState(0);
453 const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;524 const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
454 const effectiveWorktreePath = useMemo(() => {525 const effectiveWorktreePath = useMemo(() => {
@@ -494,14 +565,68 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 39 unmodified lines
494 );565 );
495 566
496 const splitTerminal = useCallback(() => {567 const splitTerminal = useCallback(() => {
497 storeSplitTerminal(threadRef, `terminal-${randomUUID()}`);568 const api = readEnvironmentApi(threadRef.environmentId);
569 if (!api || !cwd) {
570 return;
571 }
572 const terminalId = nextTerminalId(serverOrderedTerminalIds);
573 storeSplitTerminal(threadRef, terminalId);
498 bumpFocusRequestId();574 bumpFocusRequestId();
499 }, [bumpFocusRequestId, storeSplitTerminal, threadRef]);575 void (async () => {
576 try {
577 await api.terminal.open({
578 threadId,
579 terminalId,
580 cwd,
581 ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
582 env: runtimeEnv,
583 });
584 } catch {
585 // Opening failed; the tab is already in the store — user can retry or close it.
586 }
587 })();
588 }, [
589 bumpFocusRequestId,
590 cwd,
591 effectiveWorktreePath,
592 runtimeEnv,
593 serverOrderedTerminalIds,
594 storeSplitTerminal,
595 threadId,
596 threadRef,
597 ]);
500 598
501 const createNewTerminal = useCallback(() => {599 const createNewTerminal = useCallback(() => {
502 storeNewTerminal(threadRef, `terminal-${randomUUID()}`);600 const api = readEnvironmentApi(threadRef.environmentId);
601 if (!api || !cwd) {
602 return;
603 }
604 const terminalId = nextTerminalId(serverOrderedTerminalIds);
605 storeNewTerminal(threadRef, terminalId);
503 bumpFocusRequestId();606 bumpFocusRequestId();
504 }, [bumpFocusRequestId, storeNewTerminal, threadRef]);607 void (async () => {
608 try {
609 await api.terminal.open({
610 threadId,
611 terminalId,
612 cwd,
613 ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
614 env: runtimeEnv,
615 });
616 } catch {
617 // Opening failed; the tab is already in the store — user can retry or close it.
618 }
619 })();
620 }, [
621 bumpFocusRequestId,
622 cwd,
623 effectiveWorktreePath,
624 runtimeEnv,
625 serverOrderedTerminalIds,
626 storeNewTerminal,
627 threadId,
628 threadRef,
629 ]);
505 630
506 const activateTerminal = useCallback(631 const activateTerminal = useCallback(
507 (terminalId: string) => {632 (terminalId: string) => {
@@ -537,13 +662,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 29 unmodified lines
537 storeCloseTerminal(threadRef, terminalId);662 storeCloseTerminal(threadRef, terminalId);
538 bumpFocusRequestId();663 bumpFocusRequestId();
539 },664 },
540 [665 [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef],
541 bumpFocusRequestId,
542 storeCloseTerminal,
543 terminalUiState.terminalIds.length,
544 threadId,
545 threadRef,
546 ],
547 );666 );
548 667
549 const handleAddTerminalContext = useCallback(668 const handleAddTerminalContext = useCallback(
@@ -570,6 +689,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 20 unmodified lines
570 runtimeEnv={runtimeEnv}689 runtimeEnv={runtimeEnv}
571 visible={visible}690 visible={visible}
572 height={terminalUiState.terminalHeight}691 height={terminalUiState.terminalHeight}
692 // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.
573 terminalIds={terminalUiState.terminalIds}693 terminalIds={terminalUiState.terminalIds}
574 activeTerminalId={terminalUiState.activeTerminalId}694 activeTerminalId={terminalUiState.activeTerminalId}
575 terminalGroups={terminalUiState.terminalGroups}695 terminalGroups={terminalUiState.terminalGroups}
@@ -585,6 +705,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 9 unmodified lines
585 onCloseTerminal={closeTerminal}705 onCloseTerminal={closeTerminal}
586 onHeightChange={setTerminalHeight}706 onHeightChange={setTerminalHeight}
587 onAddTerminalContext={handleAddTerminalContext}707 onAddTerminalContext={handleAddTerminalContext}
708 terminalLabelsById={terminalLabelsById}
588 />709 />
589 </div>710 </div>
590 );711 );
@@ -797,11 +918,52 @@ export default function ChatView(props: ChatViewProps) { · 206 unmodified lines
797 environmentId: activeThread?.environmentId ?? null,918 environmentId: activeThread?.environmentId ?? null,
798 threadId: activeThreadId,919 threadId: activeThreadId,
799 });920 });
921 const activeThreadKnownSessionsRaw = useKnownTerminalSessions({
922 environmentId: activeThread?.environmentId ?? null,
923 threadId: activeThreadId,
924 });
925 const activeThreadKnownSessions = useMemo(() => {
926 if (activeThreadId === null) {
927 return [];
928 }
929 return activeThreadKnownSessionsRaw.filter(
930 (session) => session.target.threadId === activeThreadId,
931 );
932 }, [activeThreadId, activeThreadKnownSessionsRaw]);
933 const activeServerOrderedTerminalIds = useMemo(
934 () => activeThreadKnownSessions.map((session) => session.target.terminalId),
935 [activeThreadKnownSessions],
936 );
937 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);
800 const activeThreadRef = useMemo(938 const activeThreadRef = useMemo(
801 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),939 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
802 [activeThread],940 [activeThread],
803 );941 );
804 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;942 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
943
944 useEffect(() => {
945 if (!activeThreadRef) {
946 return;
947 }
948 if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) {
949 return;
950 }
951 if (
952 serverTerminalIdsStrictSubsetOfClient(
953 activeServerOrderedTerminalIds,
954 terminalUiState.terminalIds,
955 )
956 ) {
957 return;
958 }
959 reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds);
960 }, [
961 activeThreadRef,
962 activeServerOrderedTerminalIds,
963 reconcileTerminalIds,
964 terminalUiState.terminalIds,
965 ]);
966
805 const existingOpenTerminalThreadKeys = useMemo(() => {967 const existingOpenTerminalThreadKeys = useMemo(() => {
806 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);968 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
807 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));969 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
@@ -1581,22 +1743,93 @@ export default function ChatView(props: ChatViewProps) { · 773 unmodified lines
1581 setTerminalOpen(!terminalUiState.terminalOpen);1743 setTerminalOpen(!terminalUiState.terminalOpen);
1582 }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]);1744 }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]);
1583 const splitTerminal = useCallback(() => {1745 const splitTerminal = useCallback(() => {
1584 if (!activeThreadRef || hasReachedSplitLimit) return;1746 if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) {
1585 const terminalId = `terminal-${randomUUID()}`;1747 return;
1748 }
1749 const cwdForOpen = gitCwd ?? activeProject.cwd;
1750 if (!cwdForOpen) {
1751 return;
1752 }
1753 const api = readEnvironmentApi(environmentId);
1754 if (!api) {
1755 return;
1756 }
1757 const terminalId = nextTerminalId(activeServerOrderedTerminalIds);
1586 storeSplitTerminal(activeThreadRef, terminalId);1758 storeSplitTerminal(activeThreadRef, terminalId);
1587 setTerminalFocusRequestId((value) => value + 1);1759 setTerminalFocusRequestId((value) => value + 1);
1588 }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]);1760 void (async () => {
1761 try {
1762 await api.terminal.open({
1763 threadId: activeThreadId,
1764 terminalId,
1765 cwd: cwdForOpen,
1766 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
1767 env: projectScriptRuntimeEnv({
1768 project: { cwd: activeProject.cwd },
1769 worktreePath: activeThreadWorktreePath,
1770 }),
1771 });
1772 } catch {
1773 // Opening failed; the tab is already in the store — user can retry or close it.
1774 }
1775 })();
1776 }, [
1777 activeProject,
1778 activeServerOrderedTerminalIds,
1779 activeThreadId,
1780 activeThreadRef,
1781 activeThreadWorktreePath,
1782 environmentId,
1783 gitCwd,
1784 hasReachedSplitLimit,
1785 storeSplitTerminal,
1786 ]);
1589 const createNewTerminal = useCallback(() => {1787 const createNewTerminal = useCallback(() => {
1590 if (!activeThreadRef) return;1788 if (!activeThreadRef || !activeThreadId || !activeProject) {
1591 const terminalId = `terminal-${randomUUID()}`;1789 return;
1790 }
1791 const cwdForOpen = gitCwd ?? activeProject.cwd;
1792 if (!cwdForOpen) {
1793 return;
1794 }
1795 const api = readEnvironmentApi(environmentId);
1796 if (!api) {
1797 return;
1798 }
1799 const terminalId = nextTerminalId(activeServerOrderedTerminalIds);
1592 storeNewTerminal(activeThreadRef, terminalId);1800 storeNewTerminal(activeThreadRef, terminalId);
1593 setTerminalFocusRequestId((value) => value + 1);1801 setTerminalFocusRequestId((value) => value + 1);
1594 }, [activeThreadRef, storeNewTerminal]);1802 void (async () => {
1803 try {
1804 await api.terminal.open({
1805 threadId: activeThreadId,
1806 terminalId,
1807 cwd: cwdForOpen,
1808 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
1809 env: projectScriptRuntimeEnv({
1810 project: { cwd: activeProject.cwd },
1811 worktreePath: activeThreadWorktreePath,
1812 }),
1813 });
1814 } catch {
1815 // Opening failed; the tab is already in the store — user can retry or close it.
1816 }
1817 })();
1818 }, [
1819 activeProject,
1820 activeServerOrderedTerminalIds,
1821 activeThreadId,
1822 activeThreadRef,
1823 activeThreadWorktreePath,
1824 environmentId,
1825 gitCwd,
1826 storeNewTerminal,
1827 ]);
1595 const closeTerminal = useCallback(1828 const closeTerminal = useCallback(
1596 (terminalId: string) => {1829 (terminalId: string) => {
1597 const api = readEnvironmentApi(environmentId);1830 const api = readEnvironmentApi(environmentId);
1598 if (!activeThreadId || !api) return;1831 if (!activeThreadId || !api || !activeThreadRef) return;
1599 const isFinalTerminal = terminalUiState.terminalIds.length <= 1;1832 const isFinalTerminal = activeServerOrderedTerminalIds.length <= 1;
1600 const fallbackExitWrite = () =>1833 const fallbackExitWrite = () =>
1601 api.terminal1834 api.terminal
1602 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })1835 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })
@@ -1617,17 +1850,15 @@ export default function ChatView(props: ChatViewProps) { · 14 unmodified lines
1617 } else {1850 } else {
1618 void fallbackExitWrite();1851 void fallbackExitWrite();
1619 }1852 }
1620 if (activeThreadRef) {1853 storeCloseTerminal(activeThreadRef, terminalId);
1621 storeCloseTerminal(activeThreadRef, terminalId);
1622 }
1623 setTerminalFocusRequestId((value) => value + 1);1854 setTerminalFocusRequestId((value) => value + 1);
1624 },1855 },
1625 [1856 [
1626 activeThreadId,1857 activeThreadId,
1627 activeThreadRef,1858 activeThreadRef,
1859 activeServerOrderedTerminalIds,
1628 environmentId,1860 environmentId,
1629 storeCloseTerminal,1861 storeCloseTerminal,
1630 terminalUiState.terminalIds.length,
1631 ],1862 ],
1632 );1863 );
1633 const runProjectScript = useCallback(1864 const runProjectScript = useCallback(
@@ -1652,14 +1883,11 @@ export default function ChatView(props: ChatViewProps) { · 18 unmodified lines
1652 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;1883 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
1653 const baseTerminalId =1884 const baseTerminalId =
1654 terminalUiState.activeTerminalId ||1885 terminalUiState.activeTerminalId ||
1655 terminalUiState.terminalIds[0] ||1886 activeServerOrderedTerminalIds[0] ||
1656 DEFAULT_THREAD_TERMINAL_ID;1887 DEFAULT_THREAD_TERMINAL_ID;
1657 const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);1888 const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);
1658 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;1889 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
1659 const shouldCreateNewTerminal = wantsNewTerminal;1890 const shouldCreateNewTerminal = wantsNewTerminal;
1660 const targetTerminalId = shouldCreateNewTerminal
1661 ? `terminal-${randomUUID()}`
1662 : baseTerminalId;
1663 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;1891 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;
1664 1892
1665 setTerminalUiLaunchContext({1893 setTerminalUiLaunchContext({
@@ -1671,11 +1899,6 @@ export default function ChatView(props: ChatViewProps) { · 5 unmodified lines
1671 if (!activeThreadRef) {1899 if (!activeThreadRef) {
1672 return;1900 return;
1673 }1901 }
1674 if (shouldCreateNewTerminal) {
1675 storeNewTerminal(activeThreadRef, targetTerminalId);
1676 } else {
1677 storeSetActiveTerminal(activeThreadRef, targetTerminalId);
1678 }
1679 setTerminalFocusRequestId((value) => value + 1);1902 setTerminalFocusRequestId((value) => value + 1);
1680 1903
1681 const runtimeEnv = projectScriptRuntimeEnv({1904 const runtimeEnv = projectScriptRuntimeEnv({
@@ -1685,6 +1908,9 @@ export default function ChatView(props: ChatViewProps) { · 3 unmodified lines
1685 worktreePath: targetWorktreePath,1908 worktreePath: targetWorktreePath,
1686 ...(options?.env ? { extraEnv: options.env } : {}),1909 ...(options?.env ? { extraEnv: options.env } : {}),
1687 });1910 });
1911 const targetTerminalId = shouldCreateNewTerminal
1912 ? nextTerminalId(activeServerOrderedTerminalIds)
1913 : baseTerminalId;
1688 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal1914 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
1689 ? {1915 ? {
1690 threadId: activeThreadId,1916 threadId: activeThreadId,
@@ -1703,6 +1929,12 @@ export default function ChatView(props: ChatViewProps) { · 12 unmodified lines
1703 env: runtimeEnv,1929 env: runtimeEnv,
1704 };1930 };
1705 1931
1932 if (shouldCreateNewTerminal) {
1933 storeNewTerminal(activeThreadRef, targetTerminalId);
1934 } else {
1935 storeSetActiveTerminal(activeThreadRef, targetTerminalId);
1936 }
1937
1706 try {1938 try {
1707 await api.terminal.open(openTerminalInput);1939 await api.terminal.open(openTerminalInput);
1708 await api.terminal.write({1940 await api.terminal.write({
@@ -1729,9 +1961,9 @@ export default function ChatView(props: ChatViewProps) { · 20 unmodified lines
1729 storeSetActiveTerminal,1961 storeSetActiveTerminal,
1730 setLastInvokedScriptByProjectId,1962 setLastInvokedScriptByProjectId,
1731 environmentId,1963 environmentId,
1964 activeServerOrderedTerminalIds,
1732 runningTerminalIds,1965 runningTerminalIds,
1733 terminalUiState.activeTerminalId,1966 terminalUiState.activeTerminalId,
1734 terminalUiState.terminalIds,
1735 ],1967 ],
1736 );1968 );
1737 1969
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+4−3
@@ -128,7 +128,7 @@ function createEnvironmentApi() { · 127 unmodified lines
128 terminal: {128 terminal: {
129 open: vi.fn(async () => ({129 open: vi.fn(async () => ({
130 threadId: THREAD_ID,130 threadId: THREAD_ID,
131 terminalId: "default",131 terminalId: "term-1",
132 cwd: "/repo/project",132 cwd: "/repo/project",
133 worktreePath: null,133 worktreePath: null,
134 status: "running" as const,134 status: "running" as const,
@@ -136,6 +136,7 @@ function createEnvironmentApi() { · 1 unmodified line
136 history: "",136 history: "",
137 exitCode: null,137 exitCode: null,
138 exitSignal: null,138 exitSignal: null,
139 label: "Terminal 1",
139 updatedAt: "2026-04-07T00:00:00.000Z",140 updatedAt: "2026-04-07T00:00:00.000Z",
140 })),141 })),
141 write: vi.fn(async () => undefined),142 write: vi.fn(async () => undefined),
@@ -168,7 +169,7 @@ async function mountTerminalViewport(props: { · 26 unmodified lines
168 <TerminalViewport169 <TerminalViewport
169 threadRef={props.threadRef}170 threadRef={props.threadRef}
170 threadId={THREAD_ID}171 threadId={THREAD_ID}
171 terminalId="default"172 terminalId="term-1"
172 terminalLabel="Terminal"173 terminalLabel="Terminal"
173 cwd="/repo/project"174 cwd="/repo/project"
174 onSessionExited={() => undefined}175 onSessionExited={() => undefined}
@@ -188,7 +189,7 @@ async function mountTerminalViewport(props: { · 13 unmodified lines
188 <TerminalViewport189 <TerminalViewport
189 threadRef={nextProps.threadRef}190 threadRef={nextProps.threadRef}
190 threadId={THREAD_ID}191 threadId={THREAD_ID}
191 terminalId="default"192 terminalId="term-1"
192 terminalLabel="Terminal"193 terminalLabel="Terminal"
193 cwd="/repo/project"194 cwd="/repo/project"
194 onSessionExited={() => undefined}195 onSessionExited={() => undefined}
apps/web/src/components/ThreadTerminalDrawer.tsx+63−30
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 type TerminalSessionSnapshot,7 type TerminalSessionSnapshot,
8 type ThreadId,8 type ThreadId,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
10import { Terminal, type ITheme } from "@xterm/xterm";11import { Terminal, type ITheme } from "@xterm/xterm";
11import {12import {
12 type PointerEvent as ReactPointerEvent,13 type PointerEvent as ReactPointerEvent,
@@ -41,7 +42,6 @@ import { · 28 unmodified lines
41} from "../keybindings";42} from "../keybindings";
42import {43import {
43 DEFAULT_THREAD_TERMINAL_HEIGHT,44 DEFAULT_THREAD_TERMINAL_HEIGHT,
44 DEFAULT_THREAD_TERMINAL_ID,
45 MAX_TERMINALS_PER_GROUP,45 MAX_TERMINALS_PER_GROUP,
46 type ThreadTerminalGroup,46 type ThreadTerminalGroup,
47} from "../types";47} from "../types";
@@ -763,6 +763,8 @@ interface ThreadTerminalDrawerProps { · 715 unmodified lines
763 onHeightChange: (height: number) => void;763 onHeightChange: (height: number) => void;
764 onAddTerminalContext: (selection: TerminalContextSelection) => void;764 onAddTerminalContext: (selection: TerminalContextSelection) => void;
765 keybindings: ResolvedKeybindingsConfig;765 keybindings: ResolvedKeybindingsConfig;
766 /** Prefer server-provided tab titles when present (e.g. active subprocess name). */
767 terminalLabelsById?: ReadonlyMap<string, string>;
766}768}
767 769
768interface TerminalActionButtonProps {770interface TerminalActionButtonProps {
@@ -817,6 +819,7 @@ export default function ThreadTerminalDrawer({ · 48 unmodified lines
817 onHeightChange,819 onHeightChange,
818 onAddTerminalContext,820 onAddTerminalContext,
819 keybindings,821 keybindings,
822 terminalLabelsById,
820}: ThreadTerminalDrawerProps) {823}: ThreadTerminalDrawerProps) {
821 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));824 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
822 const [resizeEpoch, setResizeEpoch] = useState(0);825 const [resizeEpoch, setResizeEpoch] = useState(0);
@@ -831,15 +834,20 @@ export default function ThreadTerminalDrawer({ · 8 unmodified lines
831 const didResizeDuringDragRef = useRef(false);834 const didResizeDuringDragRef = useRef(false);
832 835
833 const normalizedTerminalIds = useMemo(() => {836 const normalizedTerminalIds = useMemo(() => {
834 const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];837 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
835 return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID];
836 }, [terminalIds]);838 }, [terminalIds]);
837 839
838 const resolvedActiveTerminalId = normalizedTerminalIds.includes(activeTerminalId)840 const resolvedActiveTerminalId =
839 ? activeTerminalId841 normalizedTerminalIds.length === 0
840 : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);842 ? ""
843 : normalizedTerminalIds.includes(activeTerminalId)
844 ? activeTerminalId
845 : (normalizedTerminalIds[0] ?? "");
841 846
842 const resolvedTerminalGroups = useMemo(() => {847 const resolvedTerminalGroups = useMemo(() => {
848 if (normalizedTerminalIds.length === 0) {
849 return [];
850 }
843 const validTerminalIdSet = new Set(normalizedTerminalIds);851 const validTerminalIdSet = new Set(normalizedTerminalIds);
844 const assignedTerminalIds = new Set<string>();852 const assignedTerminalIds = new Set<string>();
845 const usedGroupIds = new Set<string>();853 const usedGroupIds = new Set<string>();
@@ -876,7 +884,7 @@ export default function ThreadTerminalDrawer({ · 30 unmodified lines
876 const baseGroupId =884 const baseGroupId =
877 terminalGroup.id.trim().length > 0885 terminalGroup.id.trim().length > 0
878 ? terminalGroup.id.trim()886 ? terminalGroup.id.trim()
879 : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`;887 : `group-${nextTerminalIds[0] ?? normalizedTerminalIds[0] ?? ""}`;
880 nextGroups.push({888 nextGroups.push({
881 id: assignUniqueGroupId(baseGroupId),889 id: assignUniqueGroupId(baseGroupId),
882 terminalIds: nextTerminalIds,890 terminalIds: nextTerminalIds,
@@ -891,17 +899,17 @@ export default function ThreadTerminalDrawer({ · 8 unmodified lines
891 });899 });
892 }900 }
893 901
894 if (nextGroups.length > 0) {902 const terminalOrderIndex = new Map(
895 return nextGroups;903 normalizedTerminalIds.map((id, index) => [id, index] as const),
896 }904 );
905 nextGroups.sort((left, right) => {
906 const rank = (ids: readonly string[]) =>
907 Math.min(...ids.map((id) => terminalOrderIndex.get(id) ?? Number.POSITIVE_INFINITY));
908 return rank(left.terminalIds) - rank(right.terminalIds);
909 });
897 910
898 return [911 return nextGroups;
899 {912 }, [normalizedTerminalIds, terminalGroups]);
900 id: `group-${resolvedActiveTerminalId}`,
901 terminalIds: [resolvedActiveTerminalId],
902 },
903 ];
904 }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]);
905 913
906 const resolvedActiveGroupIndex = useMemo(() => {914 const resolvedActiveGroupIndex = useMemo(() => {
907 const indexById = resolvedTerminalGroups.findIndex(915 const indexById = resolvedTerminalGroups.findIndex(
@@ -914,22 +922,22 @@ export default function ThreadTerminalDrawer({ · 6 unmodified lines
914 return indexByTerminal >= 0 ? indexByTerminal : 0;922 return indexByTerminal >= 0 ? indexByTerminal : 0;
915 }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]);923 }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]);
916 924
917 const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [925 const visibleTerminalIds =
918 resolvedActiveTerminalId,926 resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ??
919 ];927 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);
920 const hasTerminalSidebar = normalizedTerminalIds.length > 1;928 const hasTerminalSidebar = normalizedTerminalIds.length > 1;
921 const isSplitView = visibleTerminalIds.length > 1;929 const isSplitView = visibleTerminalIds.length > 1;
922 const showGroupHeaders =930 const showGroupHeaders =
923 resolvedTerminalGroups.length > 1 ||931 resolvedTerminalGroups.length > 1 ||
924 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);932 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
925 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;933 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
926 const terminalLabelById = useMemo(934 const terminalLabelById = useMemo(() => {
927 () =>935 const next = new Map<string, string>();
928 new Map(936 for (const terminalId of normalizedTerminalIds) {
929 normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]),937 next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId));
930 ),938 }
931 [normalizedTerminalIds],939 return next;
932 );940 }, [normalizedTerminalIds, terminalLabelsById]);
933 const splitTerminalActionLabel = hasReachedSplitLimit941 const splitTerminalActionLabel = hasReachedSplitLimit
934 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`942 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`
935 : splitShortcutLabel943 : splitShortcutLabel
@@ -1051,6 +1059,33 @@ export default function ThreadTerminalDrawer({ · 115 unmodified lines
1051 };1059 };
1052 }, [syncHeight]);1060 }, [syncHeight]);
1053 1061
1062 if (normalizedTerminalIds.length === 0) {
1063 return (
1064 <aside
1065 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
1066 style={{ height: `${drawerHeight}px` }}
1067 >
1068 <div
1069 className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
1070 onPointerDown={handleResizePointerDown}
1071 onPointerMove={handleResizePointerMove}
1072 onPointerUp={handleResizePointerEnd}
1073 onPointerCancel={handleResizePointerEnd}
1074 />
1075 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground">
1076 <p>No terminal sessions for this thread yet.</p>
1077 <button
1078 type="button"
1079 className="rounded-md border border-border/80 bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
1080 onClick={onNewTerminalAction}
1081 >
1082 {newTerminalActionLabel}
1083 </button>
1084 </div>
1085 </aside>
1086 );
1087 }
1088
1054 return (1089 return (
1055 <aside1090 <aside
1056 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"1091 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
@@ -1216,9 +1251,7 @@ export default function ThreadTerminalDrawer({ · 159 unmodified lines
1216 }`}1251 }`}
1217 onClick={() => onActiveTerminalChange(groupActiveTerminalId)}1252 onClick={() => onActiveTerminalChange(groupActiveTerminalId)}
1218 >1253 >
1219 {terminalGroup.terminalIds.length > 11254 Group {groupIndex + 1}
1220 ? `Split ${groupIndex + 1}`
1221 : `Terminal ${groupIndex + 1}`}
1222 </button>1255 </button>
1223 )}1256 )}
1224 1257
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+65−0
@@ -1,4 +1,5 @@
1import { QueryClient } from "@tanstack/react-query";1import { QueryClient } from "@tanstack/react-query";
2import type { WsRpcClient } from "@t3tools/client-runtime";
2import {3import {
3 EnvironmentId,4 EnvironmentId,
4 ProjectId,5 ProjectId,
@@ -67,6 +68,70 @@ vi.mock("../../rpc/wsRpcClient", () => ({ · 62 unmodified lines
67 createWsRpcClient: mockCreateWsRpcClient,68 createWsRpcClient: mockCreateWsRpcClient,
68}));69}));
69 70
71vi.mock("@t3tools/client-runtime", async (importOriginal) => {
72 const actual = await importOriginal<typeof import("@t3tools/client-runtime")>();
73 const stubWsClient: WsRpcClient = {
74 dispose: async () => undefined,
75 reconnect: async () => undefined,
76 orchestration: {
77 dispatchCommand: vi.fn(),
78 getTurnDiff: vi.fn(),
79 getFullThreadDiff: vi.fn(),
80 subscribeShell: vi.fn(() => () => undefined),
81 subscribeThread: mockSubscribeThread,
82 },
83 terminal: {
84 open: vi.fn(),
85 attach: vi.fn(() => () => undefined),
86 write: vi.fn(),
87 resize: vi.fn(),
88 clear: vi.fn(),
89 restart: vi.fn(),
90 close: vi.fn(),
91 onMetadata: vi.fn(() => () => undefined),
92 },
93 projects: {
94 searchEntries: vi.fn(),
95 writeFile: vi.fn(),
96 },
97 filesystem: {
98 browse: vi.fn(),
99 },
100 shell: {
101 openInEditor: vi.fn(),
102 },
103 git: {
104 pull: vi.fn(),
105 refreshStatus: vi.fn(),
106 onStatus: vi.fn(() => () => undefined),
107 runStackedAction: vi.fn(),
108 listBranches: vi.fn(),
109 createWorktree: vi.fn(),
110 removeWorktree: vi.fn(),
111 createBranch: vi.fn(),
112 checkout: vi.fn(),
113 init: vi.fn(),
114 resolvePullRequest: vi.fn(),
115 preparePullRequestThread: vi.fn(),
116 getReviewDiffs: vi.fn(),
117 },
118 server: {
119 getConfig: vi.fn(),
120 refreshProviders: vi.fn(),
121 upsertKeybinding: vi.fn(),
122 getSettings: vi.fn(),
123 updateSettings: vi.fn(),
124 subscribeConfig: vi.fn(() => () => undefined),
125 subscribeLifecycle: vi.fn(() => () => undefined),
126 subscribeAuthAccess: vi.fn(() => () => undefined),
127 },
128 };
129 return {
130 ...actual,
131 createWsRpcClient: vi.fn(() => stubWsClient),
132 };
133});
134
70vi.mock("../../rpc/wsTransport", () => ({135vi.mock("../../rpc/wsTransport", () => ({
71 WsTransport: MockWsTransport,136 WsTransport: MockWsTransport,
72}));137}));
apps/web/src/lib/terminalFocus.test.ts+18−1
@@ -11,7 +11,13 @@ class MockHTMLElement { · 10 unmodified lines
11 };11 };
12 12
13 closest(selector: string): MockHTMLElement | null {13 closest(selector: string): MockHTMLElement | null {
14 return selector === ".thread-terminal-drawer .xterm" && this.isConnected ? this : null;14 if (!this.isConnected) {
15 return null;
16 }
17 if (selector === ".thread-terminal-drawer .xterm" || selector === ".thread-terminal-drawer") {
18 return this;
19 }
20 return null;
15 }21 }
16}22}
17 23
@@ -53,4 +59,15 @@ describe("isTerminalFocused", () => { · 35 unmodified lines
53 59
54 expect(isTerminalFocused()).toBe(true);60 expect(isTerminalFocused()).toBe(true);
55 });61 });
62
63 it("returns true for focus inside the terminal drawer (e.g. sidebar)", () => {
64 const sidebarButton = new MockHTMLElement();
65 sidebarButton.className = "terminal-sidebar-button";
66 sidebarButton.isConnected = true;
67
68 globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
69 globalThis.document = { activeElement: sidebarButton } as Document;
70
71 expect(isTerminalFocused()).toBe(true);
72 });
56});73});
apps/web/src/lib/terminalFocus.ts+3−1
@@ -3,5 +3,7 @@ export function isTerminalFocused(): boolean { · 2 unmodified lines
3 if (!(activeElement instanceof HTMLElement)) return false;3 if (!(activeElement instanceof HTMLElement)) return false;
4 if (!activeElement.isConnected) return false;4 if (!activeElement.isConnected) return false;
5 if (activeElement.classList.contains("xterm-helper-textarea")) return true;5 if (activeElement.classList.contains("xterm-helper-textarea")) return true;
6 return activeElement.closest(".thread-terminal-drawer .xterm") !== null;6 if (activeElement.closest(".thread-terminal-drawer .xterm") !== null) return true;
7 // Sidebar / toolbar / resize affordances: still "terminal UI" for split vs diff.toggle (⌘D).
8 return activeElement.closest(".thread-terminal-drawer") !== null;
7}9}
apps/web/src/localApi.test.ts+1−0
@@ -341,6 +341,7 @@ describe("wsApi", () => { · 340 unmodified lines
341 exitCode: null,341 exitCode: null,
342 exitSignal: null,342 exitSignal: null,
343 hasRunningSubprocess: true,343 hasRunningSubprocess: true,
344 label: "terminal-1",
344 updatedAt: "2026-02-24T00:00:00.000Z",345 updatedAt: "2026-02-24T00:00:00.000Z",
345 },346 },
346 } satisfies TerminalMetadataStreamEvent;347 } satisfies TerminalMetadataStreamEvent;
apps/web/src/terminalUiStateStore.test.ts+51−31
@@ -20,7 +20,7 @@ describe("terminalUiStateStore actions", () => { · 19 unmodified lines
20 });20 });
21 });21 });
22 22
23 it("returns a closed default terminal UI state for unknown threads", () => {23 it("returns an empty default terminal UI state for unknown threads", () => {
24 const terminalUiState = selectThreadTerminalUiState(24 const terminalUiState = selectThreadTerminalUiState(
25 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,25 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
26 THREAD_REF,26 THREAD_REF,
@@ -28,10 +28,10 @@ describe("terminalUiStateStore actions", () => { · 1 unmodified line
28 expect(terminalUiState).toEqual({28 expect(terminalUiState).toEqual({
29 terminalOpen: false,29 terminalOpen: false,
30 terminalHeight: 280,30 terminalHeight: 280,
31 terminalIds: ["default"],31 terminalIds: [],
32 activeTerminalId: "default",32 activeTerminalId: "",
33 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],33 terminalGroups: [],
34 activeTerminalGroupId: "group-default",34 activeTerminalGroupId: "",
35 });35 });
36 });36 });
37 37
@@ -45,10 +45,10 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines
45 THREAD_REF,45 THREAD_REF,
46 );46 );
47 expect(terminalUiState.terminalOpen).toBe(true);47 expect(terminalUiState.terminalOpen).toBe(true);
48 expect(terminalUiState.terminalIds).toEqual(["default", "terminal-2"]);48 expect(terminalUiState.terminalIds).toEqual(["terminal-2"]);
49 expect(terminalUiState.activeTerminalId).toBe("terminal-2");49 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
50 expect(terminalUiState.terminalGroups).toEqual([50 expect(terminalUiState.terminalGroups).toEqual([
51 { id: "group-default", terminalIds: ["default", "terminal-2"] },51 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
52 ]);52 ]);
53 });53 });
54 54
@@ -58,19 +58,23 @@ describe("terminalUiStateStore actions", () => { · 3 unmodified lines
58 store.splitTerminal(THREAD_REF, "terminal-3");58 store.splitTerminal(THREAD_REF, "terminal-3");
59 store.splitTerminal(THREAD_REF, "terminal-4");59 store.splitTerminal(THREAD_REF, "terminal-4");
60 store.splitTerminal(THREAD_REF, "terminal-5");60 store.splitTerminal(THREAD_REF, "terminal-5");
61 store.splitTerminal(THREAD_REF, "terminal-6");
61 62
62 const terminalUiState = selectThreadTerminalUiState(63 const terminalUiState = selectThreadTerminalUiState(
63 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,64 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
64 THREAD_REF,65 THREAD_REF,
65 );66 );
66 expect(terminalUiState.terminalIds).toEqual([67 expect(terminalUiState.terminalIds).toEqual([
67 "default",
68 "terminal-2",68 "terminal-2",
69 "terminal-3",69 "terminal-3",
70 "terminal-4",70 "terminal-4",
71 "terminal-5",
71 ]);72 ]);
72 expect(terminalUiState.terminalGroups).toEqual([73 expect(terminalUiState.terminalGroups).toEqual([
73 { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] },74 {
75 id: "group-terminal-2",
76 terminalIds: ["terminal-2", "terminal-3", "terminal-4", "terminal-5"],
77 },
74 ]);78 ]);
75 });79 });
76 80
@@ -81,11 +85,10 @@ describe("terminalUiStateStore actions", () => { · 4 unmodified lines
81 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,85 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
82 THREAD_REF,86 THREAD_REF,
83 );87 );
84 expect(terminalUiState.terminalIds).toEqual(["default", "terminal-2"]);88 expect(terminalUiState.terminalIds).toEqual(["terminal-2"]);
85 expect(terminalUiState.activeTerminalId).toBe("terminal-2");89 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
86 expect(terminalUiState.activeTerminalGroupId).toBe("group-terminal-2");90 expect(terminalUiState.activeTerminalGroupId).toBe("group-terminal-2");
87 expect(terminalUiState.terminalGroups).toEqual([91 expect(terminalUiState.terminalGroups).toEqual([
88 { id: "group-default", terminalIds: ["default"] },
89 { id: "group-terminal-2", terminalIds: ["terminal-2"] },92 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
90 ]);93 ]);
91 });94 });
@@ -99,10 +102,9 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines
99 THREAD_REF,102 THREAD_REF,
100 );103 );
101 expect(terminalUiState.terminalOpen).toBe(true);104 expect(terminalUiState.terminalOpen).toBe(true);
102 expect(terminalUiState.terminalIds).toEqual(["default", "setup-setup"]);105 expect(terminalUiState.terminalIds).toEqual(["setup-setup"]);
103 expect(terminalUiState.activeTerminalId).toBe("setup-setup");106 expect(terminalUiState.activeTerminalId).toBe("setup-setup");
104 expect(terminalUiState.terminalGroups).toEqual([107 expect(terminalUiState.terminalGroups).toEqual([
105 { id: "group-default", terminalIds: ["default"] },
106 { id: "group-setup-setup", terminalIds: ["setup-setup"] },108 { id: "group-setup-setup", terminalIds: ["setup-setup"] },
107 ]);109 ]);
108 });110 });
@@ -123,28 +125,28 @@ describe("terminalUiStateStore actions", () => { · 14 unmodified lines
123 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,125 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
124 OTHER_THREAD_REF,126 OTHER_THREAD_REF,
125 ).terminalIds,127 ).terminalIds,
126 ).toEqual(["default", "env-b-terminal"]);128 ).toEqual(["env-b-terminal"]);
127 });129 });
128 130
129 it("migrates legacy persisted terminal UI state using the stored version", () => {131 it("drops persisted entries whose thread keys are not valid scoped keys", () => {
130 const migrated = migratePersistedTerminalUiStateStoreState(132 const migrated = migratePersistedTerminalUiStateStoreState(
131 {133 {
132 terminalStateByThreadKey: {134 terminalStateByThreadKey: {
133 [scopedThreadKey(THREAD_REF)]: {135 [scopedThreadKey(THREAD_REF)]: {
134 terminalOpen: true,136 terminalOpen: true,
135 terminalHeight: 320,137 terminalHeight: 320,
136 terminalIds: ["default"],138 terminalIds: ["term-1"],
137 activeTerminalId: "default",139 activeTerminalId: "term-1",
138 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],140 terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
139 activeTerminalGroupId: "group-default",141 activeTerminalGroupId: "group-term-1",
140 },142 },
141 "legacy-thread-id": {143 "legacy-thread-id": {
142 terminalOpen: true,144 terminalOpen: true,
143 terminalHeight: 320,145 terminalHeight: 320,
144 terminalIds: ["default"],146 terminalIds: ["term-1"],
145 activeTerminalId: "default",147 activeTerminalId: "term-1",
146 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],148 terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
147 activeTerminalGroupId: "group-default",149 activeTerminalGroupId: "group-term-1",
148 },150 },
149 },151 },
150 },152 },
@@ -156,10 +158,10 @@ describe("terminalUiStateStore actions", () => { · 5 unmodified lines
156 [scopedThreadKey(THREAD_REF)]: {158 [scopedThreadKey(THREAD_REF)]: {
157 terminalOpen: true,159 terminalOpen: true,
158 terminalHeight: 320,160 terminalHeight: 320,
159 terminalIds: ["default"],161 terminalIds: ["term-1"],
160 activeTerminalId: "default",162 activeTerminalId: "term-1",
161 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],163 terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
162 activeTerminalGroupId: "group-default",164 activeTerminalGroupId: "group-term-1",
163 },165 },
164 },166 },
165 });167 });
@@ -167,7 +169,8 @@ describe("terminalUiStateStore actions", () => { · 1 unmodified line
167 169
168 it("resets to default and clears persisted entry when closing the last terminal", () => {170 it("resets to default and clears persisted entry when closing the last terminal", () => {
169 const store = useTerminalUiStateStore.getState();171 const store = useTerminalUiStateStore.getState();
170 store.closeTerminal(THREAD_REF, "default");172 store.newTerminal(THREAD_REF, "terminal-only");
173 store.closeTerminal(THREAD_REF, "terminal-only");
171 174
172 expect(175 expect(
173 useTerminalUiStateStore.getState().terminalUiStateByThreadKey[scopedThreadKey(THREAD_REF)],176 useTerminalUiStateStore.getState().terminalUiStateByThreadKey[scopedThreadKey(THREAD_REF)],
@@ -177,7 +180,7 @@ describe("terminalUiStateStore actions", () => { · 3 unmodified lines
177 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,180 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
178 THREAD_REF,181 THREAD_REF,
179 ).terminalIds,182 ).terminalIds,
180 ).toEqual(["default"]);183 ).toEqual([]);
181 });184 });
182 185
183 it("keeps a valid active terminal after closing an active split terminal", () => {186 it("keeps a valid active terminal after closing an active split terminal", () => {
@@ -191,9 +194,26 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines
191 THREAD_REF,194 THREAD_REF,
192 );195 );
193 expect(terminalUiState.activeTerminalId).toBe("terminal-2");196 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
194 expect(terminalUiState.terminalIds).toEqual(["default", "terminal-2"]);197 expect(terminalUiState.terminalIds).toEqual(["terminal-2"]);
198 expect(terminalUiState.terminalGroups).toEqual([
199 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
200 ]);
201 });
202
203 it("reconciles terminal ids from an external ordered list", () => {
204 const store = useTerminalUiStateStore.getState();
205 store.setTerminalOpen(THREAD_REF, true);
206 store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]);
207
208 const terminalUiState = selectThreadTerminalUiState(
209 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
210 THREAD_REF,
211 );
212 expect(terminalUiState.terminalIds).toEqual(["term-a", "term-b"]);
213 expect(terminalUiState.activeTerminalId).toBe("term-a");
195 expect(terminalUiState.terminalGroups).toEqual([214 expect(terminalUiState.terminalGroups).toEqual([
196 { id: "group-default", terminalIds: ["default", "terminal-2"] },215 { id: "group-term-a", terminalIds: ["term-a"] },
216 { id: "group-term-b", terminalIds: ["term-b"] },
197 ]);217 ]);
198 });218 });
199 219
apps/web/src/terminalUiStateStore.ts+60−44
@@ -12,7 +12,6 @@ import { createJSONStorage, persist } from "zustand/middleware"; · 11 unmodified lines
12import { resolveStorage } from "./lib/storage";12import { resolveStorage } from "./lib/storage";
13import {13import {
14 DEFAULT_THREAD_TERMINAL_HEIGHT,14 DEFAULT_THREAD_TERMINAL_HEIGHT,
15 DEFAULT_THREAD_TERMINAL_ID,
16 MAX_TERMINALS_PER_GROUP,15 MAX_TERMINALS_PER_GROUP,
17 type ThreadTerminalGroup,16 type ThreadTerminalGroup,
18} from "./types";17} from "./types";
@@ -36,20 +35,22 @@ interface PersistedTerminalUiStateStoreState { · 17 unmodified lines
36 35
37export function migratePersistedTerminalUiStateStoreState(36export function migratePersistedTerminalUiStateStoreState(
38 persistedState: unknown,37 persistedState: unknown,
39 version: number,38 _version: number,
40): PersistedTerminalUiStateStoreState {39): PersistedTerminalUiStateStoreState {
41 if (version <= 2 && persistedState && typeof persistedState === "object") {40 if (!persistedState || typeof persistedState !== "object") {
42 const candidate = persistedState as PersistedTerminalUiStateStoreState;41 return { terminalUiStateByThreadKey: {} };
43 const persistedUiStateByThreadKey =
44 candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {};
45 const nextTerminalUiStateByThreadKey = Object.fromEntries(
46 Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) =>
47 parseScopedThreadKey(threadKey),
48 ),
49 );
50 return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey };
51 }42 }
52 return { terminalUiStateByThreadKey: {} };43
44 const candidate = persistedState as PersistedTerminalUiStateStoreState;
45 const persistedUiStateByThreadKey =
46 candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {};
47 const terminalUiStateByThreadKey = Object.fromEntries(
48 Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) =>
49 parseScopedThreadKey(threadKey),
50 ),
51 );
52
53 return { terminalUiStateByThreadKey };
53}54}
54 55
55function createTerminalUiStateStorage() {56function createTerminalUiStateStorage() {
@@ -57,8 +58,7 @@ function createTerminalUiStateStorage() { · 1 unmodified line
57}58}
58 59
59function normalizeTerminalIds(terminalIds: string[]): string[] {60function normalizeTerminalIds(terminalIds: string[]): string[] {
60 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];61 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
61 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
62}62}
63 63
64function fallbackGroupId(terminalId: string): string {64function fallbackGroupId(terminalId: string): string {
@@ -91,6 +91,10 @@ function normalizeTerminalGroups( · 26 unmodified lines
91 terminalGroups: ThreadTerminalGroup[],91 terminalGroups: ThreadTerminalGroup[],
92 terminalIds: string[],92 terminalIds: string[],
93): ThreadTerminalGroup[] {93): ThreadTerminalGroup[] {
94 if (terminalIds.length === 0) {
95 return [];
96 }
97
94 const validTerminalIdSet = new Set(terminalIds);98 const validTerminalIdSet = new Set(terminalIds);
95 const assignedTerminalIds = new Set<string>();99 const assignedTerminalIds = new Set<string>();
96 const nextGroups: ThreadTerminalGroup[] = [];100 const nextGroups: ThreadTerminalGroup[] = [];
@@ -109,7 +113,7 @@ function normalizeTerminalGroups( · 12 unmodified lines
109 const baseGroupId =113 const baseGroupId =
110 group.id.trim().length > 0114 group.id.trim().length > 0
111 ? group.id.trim()115 ? group.id.trim()
112 : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);116 : fallbackGroupId(groupTerminalIds[0] ?? terminalIds[0] ?? "");
113 nextGroups.push({117 nextGroups.push({
114 id: assignUniqueGroupId(baseGroupId, usedGroupIds),118 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
115 terminalIds: groupTerminalIds,119 terminalIds: groupTerminalIds,
@@ -124,15 +128,6 @@ function normalizeTerminalGroups( · 8 unmodified lines
124 });128 });
125 }129 }
126 130
127 if (nextGroups.length === 0) {
128 return [
129 {
130 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
131 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
132 },
133 ];
134 }
135
136 return nextGroups;131 return nextGroups;
137}132}
138 133
@@ -173,15 +168,10 @@ function threadTerminalUiStateEqual( · 34 unmodified lines
173const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({168const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({
174 terminalOpen: false,169 terminalOpen: false,
175 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,170 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
176 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],171 terminalIds: [],
177 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,172 activeTerminalId: "",
178 terminalGroups: [173 terminalGroups: [],
179 {174 activeTerminalGroupId: "",
180 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
181 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
182 },
183 ],
184 activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
185});175});
186 176
187function createDefaultThreadTerminalUiState(): ThreadTerminalUiState {177function createDefaultThreadTerminalUiState(): ThreadTerminalUiState {
@@ -197,11 +187,10 @@ function getDefaultThreadTerminalUiState(): ThreadTerminalUiState { · 9 unmodified lines
197}187}
198 188
199function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState {189function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState {
200 const terminalIds = normalizeTerminalIds(state.terminalIds);190 const nextTerminalIds = normalizeTerminalIds(state.terminalIds);
201 const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
202 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)191 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)
203 ? state.activeTerminalId192 ? state.activeTerminalId
204 : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);193 : (nextTerminalIds[0] ?? "");
205 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);194 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
206 const activeGroupIdFromState = terminalGroups.some(195 const activeGroupIdFromState = terminalGroups.some(
207 (group) => group.id === state.activeTerminalGroupId,196 (group) => group.id === state.activeTerminalGroupId,
@@ -221,10 +210,7 @@ function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTer · 13 unmodified lines
221 activeTerminalId,210 activeTerminalId,
222 terminalGroups,211 terminalGroups,
223 activeTerminalGroupId:212 activeTerminalGroupId:
224 activeGroupIdFromState ??213 activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "",
225 activeGroupIdFromTerminal ??
226 terminalGroups[0]?.id ??
227 fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
228 };214 };
229 return threadTerminalUiStateEqual(state, normalized) ? state : normalized;215 return threadTerminalUiStateEqual(state, normalized) ? state : normalized;
230}216}
@@ -255,6 +241,7 @@ function upsertTerminalIntoGroups( · 24 unmodified lines
255 mode: "split" | "new",241 mode: "split" | "new",
256): ThreadTerminalUiState {242): ThreadTerminalUiState {
257 const normalized = normalizeThreadTerminalUiState(state);243 const normalized = normalizeThreadTerminalUiState(state);
244 const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode;
258 if (!isValidTerminalId(terminalId)) {245 if (!isValidTerminalId(terminalId)) {
259 return normalized;246 return normalized;
260 }247 }
@@ -275,7 +262,7 @@ function upsertTerminalIntoGroups( · 14 unmodified lines
275 }262 }
276 }263 }
277 264
278 if (mode === "new") {265 if (effectiveMode === "new") {
279 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));266 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
280 const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);267 const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);
281 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });268 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
@@ -411,7 +398,7 @@ function closeThreadTerminal( · 129 unmodified lines
411 normalized.activeTerminalId === terminalId398 normalized.activeTerminalId === terminalId
412 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??399 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
413 remainingTerminalIds[0] ??400 remainingTerminalIds[0] ??
414 DEFAULT_THREAD_TERMINAL_ID)401 "")
415 : normalized.activeTerminalId;402 : normalized.activeTerminalId;
416 403
417 const terminalGroups = normalized.terminalGroups404 const terminalGroups = normalized.terminalGroups
@@ -436,6 +423,32 @@ function closeThreadTerminal( · 18 unmodified lines
436 });423 });
437}424}
438 425
426function reconcileThreadTerminalSessionIds(
427 state: ThreadTerminalUiState,
428 nextIds: string[],
429): ThreadTerminalUiState {
430 const normalized = normalizeThreadTerminalUiState(state);
431 if (arraysEqual(normalized.terminalIds, nextIds)) {
432 return normalized;
433 }
434
435 const nextActiveTerminalId = nextIds.includes(normalized.activeTerminalId)
436 ? normalized.activeTerminalId
437 : (nextIds[0] ?? "");
438
439 const terminalGroups = normalizeTerminalGroups(normalized.terminalGroups, nextIds);
440 const activeGroupIdFromTerminal =
441 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? null;
442
443 return normalizeThreadTerminalUiState({
444 ...normalized,
445 terminalIds: nextIds,
446 activeTerminalId: nextActiveTerminalId,
447 terminalGroups,
448 activeTerminalGroupId: activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "",
449 });
450}
451
439export function selectThreadTerminalUiState(452export function selectThreadTerminalUiState(
440 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,453 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,
441 threadRef: ScopedThreadRef | null | undefined,454 threadRef: ScopedThreadRef | null | undefined,
@@ -491,6 +504,7 @@ interface TerminalUiStateStoreState { · 49 unmodified lines
491 ) => void;504 ) => void;
492 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;505 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
493 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;506 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
507 reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void;
494 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;508 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;
495 removeTerminalUiState: (threadRef: ScopedThreadRef) => void;509 removeTerminalUiState: (threadRef: ScopedThreadRef) => void;
496 removeOrphanedTerminalUiStates: (activeThreadKeys: Set<string>) => void;510 removeOrphanedTerminalUiStates: (activeThreadKeys: Set<string>) => void;
@@ -553,6 +567,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( · 56 unmodified lines
553 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),567 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),
554 closeTerminal: (threadRef, terminalId) =>568 closeTerminal: (threadRef, terminalId) =>
555 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)),569 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)),
570 reconcileTerminalIds: (threadRef, nextIds) =>
571 updateTerminal(threadRef, (state) => reconcileThreadTerminalSessionIds(state, nextIds)),
556 clearTerminalUiState: (threadRef) =>572 clearTerminalUiState: (threadRef) =>
557 set((state) => {573 set((state) => {
558 const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey(574 const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey(
@@ -600,7 +616,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( · 41 unmodified lines
600 },616 },
601 {617 {
602 name: TERMINAL_UI_STATE_STORAGE_KEY,618 name: TERMINAL_UI_STATE_STORAGE_KEY,
603 version: 3,619 version: 4,
604 storage: createJSONStorage(createTerminalUiStateStorage),620 storage: createJSONStorage(createTerminalUiStateStorage),
605 migrate: migratePersistedTerminalUiStateStoreState,621 migrate: migratePersistedTerminalUiStateStoreState,
606 partialize: (state) => ({622 partialize: (state) => ({
apps/web/src/types.ts+1−1
@@ -22,7 +22,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; · 21 unmodified lines
22 22
23export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default";23export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default";
24export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;24export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
25export const DEFAULT_THREAD_TERMINAL_ID = "default";25export const DEFAULT_THREAD_TERMINAL_ID = "term-1";
26export const MAX_TERMINALS_PER_GROUP = 4;26export const MAX_TERMINALS_PER_GROUP = 4;
27export type ProjectScript = ContractProjectScript;27export type ProjectScript = ContractProjectScript;
28 28
packages/client-runtime/src/terminalSessionState.test.ts+26−4
@@ -28,7 +28,7 @@ function resetAtomRegistry() { · 27 unmodified lines
28const TARGET = {28const TARGET = {
29 environmentId: EnvironmentId.make("env-local"),29 environmentId: EnvironmentId.make("env-local"),
30 threadId: ThreadId.make("thread-1"),30 threadId: ThreadId.make("thread-1"),
31 terminalId: "default",31 terminalId: "term-1",
32} as const;32} as const;
33 33
34const BASE_SNAPSHOT: TerminalSessionSnapshot = {34const BASE_SNAPSHOT: TerminalSessionSnapshot = {
@@ -41,6 +41,7 @@ const BASE_SNAPSHOT: TerminalSessionSnapshot = { · 6 unmodified lines
41 history: "hello",41 history: "hello",
42 exitCode: null,42 exitCode: null,
43 exitSignal: null,43 exitSignal: null,
44 label: "Terminal 1",
44 updatedAt: "2026-04-01T00:00:00.000Z",45 updatedAt: "2026-04-01T00:00:00.000Z",
45};46};
46 47
@@ -161,7 +162,7 @@ describe("createTerminalSessionManager", () => { · 114 unmodified lines
161 const otherTarget = {162 const otherTarget = {
162 environmentId: EnvironmentId.make("env-remote"),163 environmentId: EnvironmentId.make("env-remote"),
163 threadId: ThreadId.make("thread-1"),164 threadId: ThreadId.make("thread-1"),
164 terminalId: "default",165 terminalId: "term-1",
165 } as const;166 } as const;
166 167
167 for (const target of [TARGET, otherTarget]) {168 for (const target of [TARGET, otherTarget]) {
@@ -181,7 +182,7 @@ describe("createTerminalSessionManager", () => { · 13 unmodified lines
181 expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote");182 expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote");
182 });183 });
183 184
184 it("lists known sessions for a thread ordered by recency", () => {185 it("lists known sessions for a thread ordered by terminal id (numeric-aware)", () => {
185 const manager = createTerminalSessionManager({186 const manager = createTerminalSessionManager({
186 getRegistry: () => atomRegistry,187 getRegistry: () => atomRegistry,
187 });188 });
@@ -190,6 +191,19 @@ describe("createTerminalSessionManager", () => { · 2 unmodified lines
190 {191 {
191 type: "snapshot",192 type: "snapshot",
192 terminals: [193 terminals: [
194 {
195 threadId: TARGET.threadId,
196 terminalId: "term-10",
197 cwd: "/repo",
198 worktreePath: null,
199 status: "running",
200 pid: 125,
201 exitCode: null,
202 exitSignal: null,
203 updatedAt: "2026-04-01T00:00:05.000Z",
204 hasRunningSubprocess: false,
205 label: "Terminal 10",
206 },
193 {207 {
194 threadId: TARGET.threadId,208 threadId: TARGET.threadId,
195 terminalId: TARGET.terminalId,209 terminalId: TARGET.terminalId,
@@ -201,6 +215,7 @@ describe("createTerminalSessionManager", () => { · 5 unmodified lines
201 exitSignal: null,215 exitSignal: null,
202 updatedAt: "2026-04-01T00:00:00.000Z",216 updatedAt: "2026-04-01T00:00:00.000Z",
203 hasRunningSubprocess: false,217 hasRunningSubprocess: false,
218 label: "Terminal 1",
204 },219 },
205 {220 {
206 threadId: TARGET.threadId,221 threadId: TARGET.threadId,
@@ -213,6 +228,7 @@ describe("createTerminalSessionManager", () => { · 6 unmodified lines
213 exitSignal: null,228 exitSignal: null,
214 updatedAt: "2026-04-01T00:00:02.000Z",229 updatedAt: "2026-04-01T00:00:02.000Z",
215 hasRunningSubprocess: false,230 hasRunningSubprocess: false,
231 label: "Terminal 2",
216 },232 },
217 ],233 ],
218 },234 },
@@ -225,7 +241,7 @@ describe("createTerminalSessionManager", () => { · 6 unmodified lines
225 threadId: TARGET.threadId,241 threadId: TARGET.threadId,
226 })242 })
227 .map((session) => session.target.terminalId),243 .map((session) => session.target.terminalId),
228 ).toEqual(["term-2", "default"]);244 ).toEqual(["term-1", "term-2", "term-10"]);
229 });245 });
230 246
231 it("drops known sessions when an environment is invalidated", () => {247 it("drops known sessions when an environment is invalidated", () => {
@@ -271,6 +287,7 @@ describe("createTerminalSessionManager", () => { · 39 unmodified lines
271 exitSignal: null,287 exitSignal: null,
272 updatedAt: BASE_SNAPSHOT.updatedAt,288 updatedAt: BASE_SNAPSHOT.updatedAt,
273 hasRunningSubprocess: false,289 hasRunningSubprocess: false,
290 label: "Terminal 1",
274 },291 },
275 },292 },
276 ]);293 ]);
@@ -384,6 +401,7 @@ describe("createTerminalSessionManager", () => { · 107 unmodified lines
384 snapshot: {401 snapshot: {
385 ...BASE_SNAPSHOT,402 ...BASE_SNAPSHOT,
386 terminalId: "term-2",403 terminalId: "term-2",
404 label: "Terminal 2",
387 updatedAt: "2026-04-01T00:00:02.000Z",405 updatedAt: "2026-04-01T00:00:02.000Z",
388 },406 },
389 },407 },
@@ -405,6 +423,7 @@ describe("createTerminalSessionManager", () => { · 15 unmodified lines
405 exitSignal: null,423 exitSignal: null,
406 updatedAt: "2026-04-01T00:00:05.000Z",424 updatedAt: "2026-04-01T00:00:05.000Z",
407 hasRunningSubprocess: true,425 hasRunningSubprocess: true,
426 label: "Terminal 2",
408 },427 },
409 ],428 ],
410 },429 },
@@ -452,6 +471,7 @@ describe("createTerminalSessionManager", () => { · 41 unmodified lines
452 exitSignal: null,471 exitSignal: null,
453 updatedAt: BASE_SNAPSHOT.updatedAt,472 updatedAt: BASE_SNAPSHOT.updatedAt,
454 hasRunningSubprocess: false,473 hasRunningSubprocess: false,
474 label: "Terminal 1",
455 },475 },
456 },476 },
457 ]);477 ]);
@@ -470,6 +490,7 @@ describe("createTerminalSessionManager", () => { · 12 unmodified lines
470 exitSignal: null,490 exitSignal: null,
471 updatedAt: "2026-04-01T00:00:05.000Z",491 updatedAt: "2026-04-01T00:00:05.000Z",
472 hasRunningSubprocess: true,492 hasRunningSubprocess: true,
493 label: "Terminal 1",
473 },494 },
474 },495 },
475 ]);496 ]);
@@ -504,6 +525,7 @@ describe("createTerminalSessionManager", () => { · 28 unmodified lines
504 exitSignal: null,525 exitSignal: null,
505 updatedAt: BASE_SNAPSHOT.updatedAt,526 updatedAt: BASE_SNAPSHOT.updatedAt,
506 hasRunningSubprocess: true,527 hasRunningSubprocess: true,
528 label: "Terminal 1",
507 },529 },
508 },530 },
509 ]);531 ]);
packages/client-runtime/src/terminalSessionState.ts+6−15
@@ -5,7 +5,7 @@ import type { · 4 unmodified lines
5 TerminalSummary,5 TerminalSummary,
6 EnvironmentId,6 EnvironmentId,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { DEFAULT_TERMINAL_ID, ThreadId, type TerminalAttachInput } from "@t3tools/contracts";8import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts";
9import { Atom, type AtomRegistry } from "effect/unstable/reactivity";9import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
10 10
11export interface TerminalSessionState {11export interface TerminalSessionState {
@@ -77,7 +77,7 @@ export interface TerminalMetadataClient { · 65 unmodified lines
77export interface TerminalAttachClient {77export interface TerminalAttachClient {
78 readonly terminal: {78 readonly terminal: {
79 readonly attach: (79 readonly attach: (
80 input: TerminalAttachInput & { readonly terminalId: string },80 input: TerminalAttachInput,
81 listener: (event: TerminalAttachStreamEvent) => void,81 listener: (event: TerminalAttachStreamEvent) => void,
82 options?: { readonly onResubscribe?: () => void },82 options?: { readonly onResubscribe?: () => void },
83 ) => () => void;83 ) => () => void;
@@ -268,14 +268,9 @@ function listKnownSessionsFromMetadata( · 184 unmodified lines
268 target,268 target,
269 state: combineSessionState(summary, getBuffer(target)),269 state: combineSessionState(summary, getBuffer(target)),
270 }))270 }))
271 .sort((left, right) => {271 .sort((left, right) =>
272 const leftUpdatedAt = left.state.updatedAt ? Date.parse(left.state.updatedAt) : 0;272 left.target.terminalId.localeCompare(right.target.terminalId, undefined, { numeric: true }),
273 const rightUpdatedAt = right.state.updatedAt ? Date.parse(right.state.updatedAt) : 0;273 );
274 if (leftUpdatedAt !== rightUpdatedAt) {
275 return rightUpdatedAt - leftUpdatedAt;
276 }
277 return left.target.terminalId.localeCompare(right.target.terminalId);
278 });
279}274}
280 275
281export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) =>276export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) =>
@@ -569,12 +564,8 @@ export function createTerminalSessionManager(config: TerminalSessionManagerConfi · 287 unmodified lines
569 readonly onEvent?: (event: TerminalAttachStreamEvent) => void;564 readonly onEvent?: (event: TerminalAttachStreamEvent) => void;
570 readonly options?: { readonly onResubscribe?: () => void };565 readonly options?: { readonly onResubscribe?: () => void };
571 }): () => void {566 }): () => void {
572 const terminal = {
573 ...input.terminal,
574 terminalId: input.terminal.terminalId ?? DEFAULT_TERMINAL_ID,
575 };
576 return input.client.terminal.attach(567 return input.client.terminal.attach(
577 terminal,568 input.terminal,
578 (event) => {569 (event) => {
579 applyAttachEvent({ environmentId: input.environmentId }, event);570 applyAttachEvent({ environmentId: input.environmentId }, event);
580 input.onEvent?.(event);571 input.onEvent?.(event);
packages/contracts/src/terminal.test.ts+43−9
@@ -31,6 +31,7 @@ describe("TerminalOpenInput", () => { · 30 unmodified lines
31 expect(31 expect(
32 decodes(TerminalOpenInput, {32 decodes(TerminalOpenInput, {
33 threadId: "thread-1",33 threadId: "thread-1",
34 terminalId: DEFAULT_TERMINAL_ID,
34 cwd: "/tmp/project",35 cwd: "/tmp/project",
35 cols: 120,36 cols: 120,
36 rows: 40,37 rows: 40,
@@ -42,6 +43,7 @@ describe("TerminalOpenInput", () => { · 5 unmodified lines
42 expect(43 expect(
43 decodes(TerminalOpenInput, {44 decodes(TerminalOpenInput, {
44 threadId: "thread-1",45 threadId: "thread-1",
46 terminalId: DEFAULT_TERMINAL_ID,
45 cwd: "/tmp/project",47 cwd: "/tmp/project",
46 cols: 10,48 cols: 10,
47 rows: 2,49 rows: 2,
@@ -49,19 +51,21 @@ describe("TerminalOpenInput", () => { · 1 unmodified line
49 ).toBe(false);51 ).toBe(false);
50 });52 });
51 53
52 it("defaults terminalId when missing", () => {54 it("requires terminalId — the client must always pick an id", () => {
53 const parsed = decodeSync(TerminalOpenInput, {55 expect(
54 threadId: "thread-1",56 decodes(TerminalOpenInput, {
55 cwd: "/tmp/project",57 threadId: "thread-1",
56 cols: 100,58 cwd: "/tmp/project",
57 rows: 24,59 cols: 100,
58 });60 rows: 24,
59 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);61 }),
62 ).toBe(false);
60 });63 });
61 64
62 it("accepts optional env overrides", () => {65 it("accepts optional env overrides", () => {
63 const parsed = decodeSync(TerminalOpenInput, {66 const parsed = decodeSync(TerminalOpenInput, {
64 threadId: "thread-1",67 threadId: "thread-1",
68 terminalId: DEFAULT_TERMINAL_ID,
65 cwd: "/tmp/project",69 cwd: "/tmp/project",
66 worktreePath: "/tmp/project/.t3/worktrees/feature-a",70 worktreePath: "/tmp/project/.t3/worktrees/feature-a",
67 cols: 100,71 cols: 100,
@@ -98,6 +102,7 @@ describe("TerminalWriteInput", () => { · 30 unmodified lines
98 expect(102 expect(
99 decodes(TerminalWriteInput, {103 decodes(TerminalWriteInput, {
100 threadId: "thread-1",104 threadId: "thread-1",
105 terminalId: DEFAULT_TERMINAL_ID,
101 data: "echo hello\n",106 data: "echo hello\n",
102 }),107 }),
103 ).toBe(true);108 ).toBe(true);
@@ -107,10 +112,20 @@ describe("TerminalWriteInput", () => { · 3 unmodified lines
107 expect(112 expect(
108 decodes(TerminalWriteInput, {113 decodes(TerminalWriteInput, {
109 threadId: "thread-1",114 threadId: "thread-1",
115 terminalId: DEFAULT_TERMINAL_ID,
110 data: "",116 data: "",
111 }),117 }),
112 ).toBe(false);118 ).toBe(false);
113 });119 });
120
121 it("rejects missing terminalId", () => {
122 expect(
123 decodes(TerminalWriteInput, {
124 threadId: "thread-1",
125 data: "echo hello\n",
126 }),
127 ).toBe(false);
128 });
114});129});
115 130
116describe("TerminalThreadInput", () => {131describe("TerminalThreadInput", () => {
@@ -125,17 +140,33 @@ describe("TerminalResizeInput", () => { · 8 unmodified lines
125 expect(140 expect(
126 decodes(TerminalResizeInput, {141 decodes(TerminalResizeInput, {
127 threadId: "thread-1",142 threadId: "thread-1",
143 terminalId: DEFAULT_TERMINAL_ID,
128 cols: 80,144 cols: 80,
129 rows: 24,145 rows: 24,
130 }),146 }),
131 ).toBe(true);147 ).toBe(true);
132 });148 });
149
150 it("rejects missing terminalId", () => {
151 expect(
152 decodes(TerminalResizeInput, {
153 threadId: "thread-1",
154 cols: 80,
155 rows: 24,
156 }),
157 ).toBe(false);
158 });
133});159});
134 160
135describe("TerminalClearInput", () => {161describe("TerminalClearInput", () => {
136 it("defaults terminal id", () => {162 it("requires terminalId", () => {
163 expect(decodes(TerminalClearInput, { threadId: "thread-1" })).toBe(false);
164 });
165
166 it("accepts an explicit terminalId", () => {
137 const parsed = decodeSync(TerminalClearInput, {167 const parsed = decodeSync(TerminalClearInput, {
138 threadId: "thread-1",168 threadId: "thread-1",
169 terminalId: DEFAULT_TERMINAL_ID,
139 });170 });
140 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);171 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
141 });172 });
@@ -165,6 +196,7 @@ describe("TerminalSessionSnapshot", () => { · 23 unmodified lines
165 history: "hello\n",196 history: "hello\n",
166 exitCode: null,197 exitCode: null,
167 exitSignal: null,198 exitSignal: null,
199 label: "Primary",
168 updatedAt: new Date().toISOString(),200 updatedAt: new Date().toISOString(),
169 }),201 }),
170 ).toBe(true);202 ).toBe(true);
@@ -212,6 +244,7 @@ describe("TerminalEvent", () => { · 41 unmodified lines
212 threadId: "thread-1",244 threadId: "thread-1",
213 terminalId: DEFAULT_TERMINAL_ID,245 terminalId: DEFAULT_TERMINAL_ID,
214 hasRunningSubprocess: true,246 hasRunningSubprocess: true,
247 label: "vim",
215 }),248 }),
216 ).toBe(true);249 ).toBe(true);
217 });250 });
@@ -232,6 +265,7 @@ describe("TerminalEvent", () => { · 14 unmodified lines
232 history: "",265 history: "",
233 exitCode: null,266 exitCode: null,
234 exitSignal: null,267 exitSignal: null,
268 label: "Primary",
235 updatedAt: new Date().toISOString(),269 updatedAt: new Date().toISOString(),
236 },270 },
237 }),271 }),
packages/contracts/src/terminal.ts+14−7
@@ -1,7 +1,12 @@
1import { Effect, Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3 3
4export const DEFAULT_TERMINAL_ID = "default";4/**
5 * Client-side id for the first shell opened on a thread. Ids are uniformly
6 * `term-N`; there's no "default" intrinsic. Kept as a named constant so callers
7 * that want "the primary shell" don't hardcode `"term-1"`.
8 */
9export const DEFAULT_TERMINAL_ID = "term-1";
5 10
6const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;11const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
7const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check(12const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check(
@@ -19,18 +24,15 @@ const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSc · 11 unmodified lines
19 Schema.isMaxProperties(128),24 Schema.isMaxProperties(128),
20);25);
21 26
22const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(
23 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_ID)),
24);
25
26export const TerminalThreadInput = Schema.Struct({27export const TerminalThreadInput = Schema.Struct({
27 threadId: TrimmedNonEmptyStringSchema,28 threadId: TrimmedNonEmptyStringSchema,
28});29});
29export type TerminalThreadInput = typeof TerminalThreadInput.Type;30export type TerminalThreadInput = typeof TerminalThreadInput.Type;
30 31
32/** Terminal ids are ALWAYS chosen by the client and sent explicitly — no server-side allocation. */
31const TerminalSessionInput = Schema.Struct({33const TerminalSessionInput = Schema.Struct({
32 ...TerminalThreadInput.fields,34 ...TerminalThreadInput.fields,
33 terminalId: TerminalIdWithDefaultSchema,35 terminalId: TerminalIdSchema,
34});36});
35export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>;37export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>;
36 38
@@ -100,6 +102,8 @@ export const TerminalSessionSnapshot = Schema.Struct({ · 63 unmodified lines
100 history: Schema.String,102 history: Schema.String,
101 exitCode: Schema.NullOr(Schema.Int),103 exitCode: Schema.NullOr(Schema.Int),
102 exitSignal: Schema.NullOr(Schema.Int),104 exitSignal: Schema.NullOr(Schema.Int),
105 /** Server-computed display title (idle shell vs subprocess command). */
106 label: Schema.String.check(Schema.isMaxLength(128)),
103 updatedAt: Schema.String,107 updatedAt: Schema.String,
104 sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),108 sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
105});109});
@@ -115,6 +119,8 @@ export const TerminalSummary = Schema.Struct({ · 9 unmodified lines
115 exitCode: Schema.NullOr(Schema.Int),119 exitCode: Schema.NullOr(Schema.Int),
116 exitSignal: Schema.NullOr(Schema.Int),120 exitSignal: Schema.NullOr(Schema.Int),
117 hasRunningSubprocess: Schema.Boolean,121 hasRunningSubprocess: Schema.Boolean,
122 /** Server-computed display title (idle shell vs subprocess command). */
123 label: Schema.String.check(Schema.isMaxLength(128)),
118 updatedAt: Schema.String,124 updatedAt: Schema.String,
119});125});
120export type TerminalSummary = typeof TerminalSummary.Type;126export type TerminalSummary = typeof TerminalSummary.Type;
@@ -193,6 +199,7 @@ const TerminalActivityEvent = Schema.Struct({ · 72 unmodified lines
193 ...TerminalEventBaseSchema.fields,199 ...TerminalEventBaseSchema.fields,
194 type: Schema.Literal("activity"),200 type: Schema.Literal("activity"),
195 hasRunningSubprocess: Schema.Boolean,201 hasRunningSubprocess: Schema.Boolean,
202 label: Schema.String.check(Schema.isMaxLength(128)),
196});203});
197 204
198export const TerminalEvent = Schema.Union([205export const TerminalEvent = Schema.Union([
packages/shared/package.json+4−0
@@ -83,6 +83,10 @@ · 82 unmodified lines
83 "./composerTrigger": {83 "./composerTrigger": {
84 "types": "./src/composerTrigger.ts",84 "types": "./src/composerTrigger.ts",
85 "import": "./src/composerTrigger.ts"85 "import": "./src/composerTrigger.ts"
86 },
87 "./terminalLabels": {
88 "types": "./src/terminalLabels.ts",
89 "import": "./src/terminalLabels.ts"
86 }90 }
87 },91 },
88 "scripts": {92 "scripts": {
packages/shared/src/terminalLabels.test.ts+54−0
@@ -0,0 +1,54 @@
1import { describe, expect, it } from "vitest";
2
3import type { TerminalSummary } from "@t3tools/contracts";
4import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts";
5
6import { getTerminalLabel, nextTerminalId, resolveTerminalSessionLabel } from "./terminalLabels.ts";
7
8describe("getTerminalLabel", () => {
9 it("uses the numeric suffix for term-* ids", () => {
10 expect(getTerminalLabel(DEFAULT_TERMINAL_ID)).toBe("Terminal 1");
11 expect(getTerminalLabel("term-2")).toBe("Terminal 2");
12 expect(getTerminalLabel("term-12")).toBe("Terminal 12");
13 expect(getTerminalLabel("terminal-3")).toBe("Terminal 3");
14 });
15
16 it("falls back to the raw id for unknown shapes", () => {
17 expect(getTerminalLabel("custom-session")).toBe("custom-session");
18 });
19});
20
21describe("resolveTerminalSessionLabel", () => {
22 it("prefers a non-empty summary label", () => {
23 const summary = { label: " bun " } as Pick<TerminalSummary, "label">;
24 expect(resolveTerminalSessionLabel("term-1", summary)).toBe("bun");
25 });
26
27 it("falls back to getTerminalLabel when summary is missing or blank", () => {
28 expect(resolveTerminalSessionLabel(DEFAULT_TERMINAL_ID, { label: " " })).toBe("Terminal 1");
29 expect(resolveTerminalSessionLabel(DEFAULT_TERMINAL_ID, null)).toBe("Terminal 1");
30 expect(resolveTerminalSessionLabel("term-2", undefined)).toBe("Terminal 2");
31 });
32});
33
34describe("nextTerminalId", () => {
35 it("allocates term-1 when no terminals are listed yet", () => {
36 expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID);
37 expect(nextTerminalId([])).toBe("term-1");
38 });
39
40 it("allocates term-2 when only term-1 exists", () => {
41 expect(nextTerminalId([DEFAULT_TERMINAL_ID])).toBe("term-2");
42 });
43
44 it("skips over taken term-N slots", () => {
45 expect(nextTerminalId([DEFAULT_TERMINAL_ID, "term-2", "term-3"])).toBe("term-4");
46 expect(nextTerminalId([DEFAULT_TERMINAL_ID, "term-3"])).toBe("term-2");
47 expect(nextTerminalId(["term-2", "term-3"])).toBe("term-1");
48 });
49
50 it("ignores blank/whitespace-only ids", () => {
51 expect(nextTerminalId(["", " ", DEFAULT_TERMINAL_ID])).toBe("term-2");
52 expect(nextTerminalId(["", " "])).toBe("term-1");
53 });
54});
packages/shared/src/terminalLabels.ts+40−0
@@ -0,0 +1,40 @@
1import type { TerminalSummary } from "@t3tools/contracts";
2
3/** Human-readable label for a terminal tab; matches mobile and web sidebars. */
4export function getTerminalLabel(terminalId: string): string {
5 const numericSuffix = /^term(?:inal)?-(\d+)$/i.exec(terminalId)?.[1];
6 if (numericSuffix) {
7 return `Terminal ${numericSuffix}`;
8 }
9
10 return terminalId;
11}
12
13/** Prefer server summary label when present; otherwise fall back to `getTerminalLabel`. */
14export function resolveTerminalSessionLabel(
15 terminalId: string,
16 summary: Pick<TerminalSummary, "label"> | null | undefined,
17): string {
18 const trimmed = summary?.label?.trim();
19 if (trimmed && trimmed.length > 0) {
20 return trimmed;
21 }
22 return getTerminalLabel(terminalId);
23}
24
25/**
26 * Client-side terminal id allocator. Ids are ALWAYS chosen by the client and sent explicitly
27 * on every `terminal.open` / `terminal.attach` call — the server never allocates.
28 *
29 * Returns the lowest unused `term-N` id (starting at `term-1`), skipping any ids already in
30 * `existingTerminalIds`.
31 */
32export function nextTerminalId(existingTerminalIds: ReadonlyArray<string>): string {
33 const usedIds = new Set(existingTerminalIds.filter((id) => id.trim().length > 0));
34 let nextIndex = 1;
35 while (usedIds.has(`term-${nextIndex}`)) {
36 nextIndex += 1;
37 }
38
39 return `term-${nextIndex}`;
40}