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 @@
1−import { memo, useCallback } from "react";
1+import { memo, useCallback, useEffect } from "react";
22 import {
33 Pressable,
44 ScrollView,
@@ -17,6 +17,7 @@ import { · 12 unmodified lines
1717 getPierreTerminalTheme,
1818 type TerminalTheme,
1919 } from "./terminalTheme";
20+import { terminalDebugLog } from "./terminalDebugLog";
2021
2122 interface TerminalInputEvent {
2223 readonly data: string;
@@ -168,6 +169,16 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 145 unmodified lines
168169 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
169170 const { onInput, onResize } = props;
170171 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]);
171182 const handleNativeInput = useCallback(
172183 (event: NativeSyntheticEvent<TerminalInputEvent>) => {
173184 onInput(event.nativeEvent.data);
apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx+11−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 } from "../../state/use-terminal-session";
1313 import { TerminalSurface } from "./NativeTerminalSurface";
1414 import { hasNativeTerminalSurface } from "./nativeTerminalModule";
15+import { terminalDebugLog } from "./terminalDebugLog";
1516
1617 interface ThreadTerminalPanelProps {
1718 readonly environmentId: EnvironmentId;
@@ -51,9 +52,19 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( · 33 unmodified lines
5152
5253 const client = getEnvironmentClient(props.environmentId);
5354 if (!client) {
55+ terminalDebugLog("panel:attach-skip", {
56+ reason: "no-environment-client",
57+ environmentId: props.environmentId,
58+ });
5459 return;
5560 }
5661
62+ terminalDebugLog("panel:attach", {
63+ environmentId: props.environmentId,
64+ threadId: props.threadId,
65+ terminalId,
66+ });
67+
5768 return attachTerminalSession({
5869 environmentId: props.environmentId,
5970 client,
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+223−61
@@ -1,4 +1,10 @@
1−import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
1+import {
2+ DEFAULT_TERMINAL_ID,
3+ EnvironmentId,
4+ type TerminalAttachStreamEvent,
5+ ThreadId,
6+} from "@t3tools/contracts";
7+import type { KnownTerminalSession } from "@t3tools/client-runtime";
28 import { Stack, useLocalSearchParams, useRouter } from "expo-router";
39 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
410 import { 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
713
814 import { EmptyState } from "../../components/EmptyState";
915 import { LoadingScreen } from "../../components/LoadingScreen";
10−import { buildThreadTerminalRoutePath } from "../../lib/routes";
16+import { buildThreadTerminalNavigation } from "../../lib/routes";
1117 import {
1218 getEnvironmentClient,
1319 useRemoteEnvironmentState,
@@ -23,6 +29,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail"; · 9 unmodified lines
2329 import { TerminalSurface } from "./NativeTerminalSurface";
2430 import { getPierreTerminalTheme } from "./terminalTheme";
2531 import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
32+import { terminalDebugLog } from "./terminalDebugLog";
2633 import {
2734 getTerminalBufferReplayKey,
2835 getTerminalSurfaceReplayBuffer,
@@ -37,9 +44,10 @@ import { · 8 unmodified lines
3744 import {
3845 basename,
3946 buildTerminalMenuSessions,
40− getTerminalLabel,
4147 getTerminalStatusLabel,
42− nextTerminalId,
48+ nextOpenTerminalId,
49+ resolveTerminalSessionLabel,
50+ type TerminalMenuSession,
4351 } from "./terminalMenu";
4452 import {
4553 DEFAULT_TERMINAL_FONT_SIZE,
@@ -71,13 +79,6 @@ type TerminalToolbarAction = · 25 unmodified lines
7179 readonly modifier: PendingModifier;
7280 };
7381
74−interface 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−
8182 function getTerminalStatusTone(input: {
8283 readonly status: TerminalMenuSession["status"];
8384 readonly hasRunningSubprocess?: boolean;
@@ -174,6 +175,22 @@ function withAlpha(hexColor: string, alpha: string): string { · 90 unmodified lines
174175 return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor;
175176 }
176177
178+function 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+
177194 export function ThreadTerminalRouteScreen() {
178195 const router = useRouter();
179196 const keyboard = useAnimatedKeyboard();
@@ -217,12 +234,15 @@ export function ThreadTerminalRouteScreen() { · 37 unmodified lines
217234 const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
218235 const hasOpenedRef = useRef(false);
219236 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
237+ const attachStreamLogCountRef = useRef(0);
238+ const firstNonEmptyBufferLoggedRef = useRef(false);
220239 const lastBufferReplayKeyRef = useRef<string | null>(null);
221240 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);
222241 const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(
223242 cachedFontSize !== null,
224243 );
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);
226246 const [pendingModifierState, setPendingModifierState] = useState<{
227247 readonly terminalId: string;
228248 readonly value: PendingModifier | null;
@@ -252,22 +272,79 @@ export function ThreadTerminalRouteScreen() { · 23 unmodified lines
252272 readyReplayKey: readyBufferReplayKey,
253273 });
254274 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]);
255325 const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null;
256326 const hostPlatform = useMemo(
257327 () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null),
258328 [selectedEnvironmentConnection?.environmentLabel],
259329 );
260330 const runningSession = useMemo(
261− () =>
262− knownSessions.find(
263− (session) => session.state.status === "running" || session.state.status === "starting",
264− ) ?? null,
331+ () => pickRunningTerminalSessionForBootstrap(knownSessions),
265332 [knownSessions],
266333 );
267334 const activeKnownSession = useMemo(
268335 () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null,
269336 [knownSessions, terminalId],
270337 );
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+
271348 const terminalStatusTone = useMemo(
272349 () =>
273350 getTerminalStatusTone({
@@ -339,6 +416,7 @@ export function ThreadTerminalRouteScreen() { · 65 unmodified lines
339416 cwd: cwd ?? null,
340417 status: terminal.status,
341418 hasRunningSubprocess: terminal.hasRunningSubprocess,
419+ displayLabel: resolveTerminalSessionLabel(terminalId, terminal.summary),
342420 updatedAt: terminal.updatedAt,
343421 },
344422 }),
@@ -347,19 +425,47 @@ export function ThreadTerminalRouteScreen() { · 2 unmodified lines
347425 knownSessions,
348426 selectedThreadProject?.workspaceRoot,
349427 terminal.hasRunningSubprocess,
428+ terminal.summary,
350429 terminal.status,
351430 terminal.updatedAt,
352431 terminalId,
353432 ],
354433 );
355434
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+
356457 const attachTerminal = useCallback(() => {
357458 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
459+ terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" });
358460 return null;
359461 }
360462
361463 const client = getEnvironmentClient(selectedThread.environmentId);
362464 if (!client) {
465+ terminalDebugLog("attach:abort", {
466+ reason: "no-environment-client",
467+ environmentId: selectedThread.environmentId,
468+ });
363469 return null;
364470 }
365471
@@ -378,13 +484,22 @@ export function ThreadTerminalRouteScreen() { · 12 unmodified lines
378484 worktreePath: pendingLaunch.worktreePath,
379485 }
380486 : resolveTerminalOpenLocation({
381− terminalLocation: terminal.summary,
382− activeSessionLocation: activeKnownSession?.state.summary ?? null,
487+ terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary,
488+ activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary,
383489 workspaceRoot: selectedThreadProject.workspaceRoot,
384490 threadShellWorktreePath: selectedThread.worktreePath ?? null,
385491 threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,
386492 });
387493
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+
388503 return attachTerminalSession({
389504 environmentId: selectedThread.environmentId,
390505 client,
@@ -397,6 +512,7 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines
397512 rows: lastGridSize.rows,
398513 env: pendingLaunch?.env,
399514 },
515+ onEvent: logAttachStreamEvent,
400516 onSnapshot: () => {
401517 if (!pendingLaunch?.initialInput || initialInputSent) {
402518 return;
@@ -411,6 +527,9 @@ export function ThreadTerminalRouteScreen() { · 8 unmodified lines
411527 },
412528 });
413529 } catch (error) {
530+ terminalDebugLog("attach:error", {
531+ message: error instanceof Error ? error.message : String(error),
532+ });
414533 if (pendingLaunch) {
415534 stagePendingTerminalLaunch({
416535 target: pendingLaunchTarget,
@@ -423,16 +542,34 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines
423542 }, [
424543 lastGridSize.cols,
425544 lastGridSize.rows,
426− activeKnownSession?.state.summary,
545+ logAttachStreamEvent,
427546 selectedThreadDetail?.worktreePath,
428547 selectedThread,
429548 selectedThreadProject?.workspaceRoot,
430− terminal.summary,
431549 terminalId,
432550 ]);
433551
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+
434569 useEffect(() => {
435570 hasOpenedRef.current = false;
571+ attachStreamLogCountRef.current = 0;
572+ firstNonEmptyBufferLoggedRef.current = false;
436573 }, [terminalKey]);
437574
438575 const clearBufferReplayTimer = useCallback(() => {
@@ -445,9 +582,14 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines
445582 const scheduleBufferReplayReady = useCallback(() => {
446583 clearBufferReplayTimer();
447584 const replayKey = bufferReplayKey;
585+ terminalDebugLog("replay:schedule-ready", {
586+ replayKey,
587+ delayMs: TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS,
588+ });
448589 bufferReplayTimerRef.current = setTimeout(() => {
449590 bufferReplayTimerRef.current = null;
450591 setReadyBufferReplayKey(replayKey);
592+ terminalDebugLog("replay:ready", { replayKey });
451593 }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS);
452594 }, [bufferReplayKey, clearBufferReplayTimer]);
453595
@@ -482,27 +624,9 @@ export function ThreadTerminalRouteScreen() { · 28 unmodified lines
482624 rows: DEFAULT_TERMINAL_ROWS,
483625 },
484626 );
485− setHasMeasuredSurface(
486− getCachedTerminalGridSize({
487− environmentId: routeEnvironmentId,
488− threadId: routeThreadId,
489− terminalId,
490− }) !== null,
491− );
627+ setHasMeasuredSurface(true);
492628 }, [routeEnvironmentId, routeThreadId, terminalId]);
493629
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−
506630 useEffect(() => {
507631 let cancelled = false;
508632
@@ -539,50 +663,79 @@ export function ThreadTerminalRouteScreen() { · 30 unmodified lines
539663 });
540664 }, [fontSize, hasResolvedFontPreference]);
541665
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.
542670 useEffect(() => {
543671 if (!hasResolvedFontPreference || !hasMeasuredSurface) {
544672 return;
545673 }
546674
675+ const thread = selectedThreadRef.current;
676+ const project = selectedThreadProjectBootstrapRef.current;
677+ const running = runningSessionRef.current;
678+ const termSnap = terminalBootstrapRef.current;
679+
547680 const bootstrapAction = resolveTerminalRouteBootstrap({
548− hasThread: selectedThread !== null,
549− hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
681+ hasThread: thread !== null,
682+ hasWorkspaceRoot: Boolean(project?.workspaceRoot),
550683 hasOpened: hasOpenedRef.current,
551684 requestedTerminalId,
552685 currentTerminalId: terminalId,
553− runningTerminalId: runningSession?.target.terminalId ?? null,
554− currentTerminalStatus: terminal.status,
555− hasCurrentTerminalHydration: terminal.summary !== null || terminal.buffer.length > 0,
686+ runningTerminalId: running?.target.terminalId ?? null,
687+ currentTerminalStatus: termSnap.status,
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,
556691 });
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) {
558702 return;
559703 }
560704
561705 if (bootstrapAction.kind === "redirect") {
562− router.replace(buildThreadTerminalRoutePath(selectedThread, bootstrapAction.terminalId));
706+ router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId));
563707 return;
564708 }
565709
566710 hasOpenedRef.current = true;
567711 try {
568− return attachTerminal() ?? undefined;
569− } catch {
712+ const detach = attachTerminalRef.current();
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) {
570724 hasOpenedRef.current = false;
725+ terminalDebugLog("bootstrap:attach-threw", {
726+ message: error instanceof Error ? error.message : String(error),
727+ });
571728 return;
572729 }
573730 }, [
574− attachTerminal,
731+ hasMeasuredSurface,
732+ hasResolvedFontPreference,
575733 requestedTerminalId,
576734 router,
577− runningSession,
578− selectedThread,
735+ selectedThread?.environmentId,
736+ selectedThread?.id,
579737 selectedThreadProject?.workspaceRoot,
580− terminal.buffer.length,
581− terminal.summary,
582− terminal.status,
583738 terminalId,
584− hasMeasuredSurface,
585− hasResolvedFontPreference,
586739 ]);
587740
588741 const writeInput = useCallback(
@@ -626,6 +779,11 @@ export function ThreadTerminalRouteScreen() { · 37 unmodified lines
626779
627780 const handleResize = useCallback(
628781 (size: { readonly cols: number; readonly rows: number }) => {
782+ terminalDebugLog("native:onResize", {
783+ cols: size.cols,
784+ rows: size.rows,
785+ terminalKey,
786+ });
629787 setHasMeasuredSurface(true);
630788 if (readyBufferReplayKey !== bufferReplayKey) {
631789 scheduleBufferReplayReady();
@@ -672,6 +830,7 @@ export function ThreadTerminalRouteScreen() { · 40 unmodified lines
672830 scheduleBufferReplayReady,
673831 selectedThread,
674832 terminalId,
833+ terminalKey,
675834 ],
676835 );
677836
@@ -681,7 +840,7 @@ export function ThreadTerminalRouteScreen() { · 3 unmodified lines
681840 return;
682841 }
683842
684− router.replace(buildThreadTerminalRoutePath(selectedThread, nextTerminalId));
843+ router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId));
685844 },
686845 [router, selectedThread, terminalId],
687846 );
@@ -692,12 +851,15 @@ export function ThreadTerminalRouteScreen() { · 4 unmodified lines
692851 }
693852
694853 router.replace(
695− buildThreadTerminalRoutePath(
854+ buildThreadTerminalNavigation(
696855 selectedThread,
697− nextTerminalId(terminalMenuSessions.map((session) => session.terminalId)),
856+ nextOpenTerminalId({
857+ listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
858+ activeRouteTerminalId: terminalId,
859+ }),
698860 ),
699861 );
700− }, [router, selectedThread, terminalMenuSessions]);
862+ }, [router, selectedThread, terminalId, terminalMenuSessions]);
701863
702864 const adjustFontSize = useCallback((delta: number) => {
703865 setTimeout(() => {
@@ -856,7 +1018,7 @@ export function ThreadTerminalRouteScreen() { · 152 unmodified lines
8561018 .filter(Boolean)
8571019 .join(" · ")}
8581020 >
859− <Stack.Toolbar.Label>{getTerminalLabel(session.terminalId)}</Stack.Toolbar.Label>
1021+ <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>
8601022 </Stack.Toolbar.MenuAction>
8611023 ))}
8621024 <Stack.Toolbar.MenuAction
apps/mobile/src/features/terminal/terminalBufferReplay.test.ts+2−2
@@ -12,7 +12,7 @@ describe("terminalBufferReplay", () => { · 11 unmodified lines
1212 ).toBe("env-1:thread-1:default:10");
1313 });
1414
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)", () => {
1616 const replayKey = getTerminalBufferReplayKey({
1717 terminalKey: "env-1:thread-1:default",
1818 fontSize: 10,
@@ -24,7 +24,7 @@ describe("terminalBufferReplay", () => { · 5 unmodified lines
2424 replayKey,
2525 readyReplayKey: null,
2626 }),
27− ).toBe("");
27+ ).toBe("fastfetch output");
2828 expect(
2929 getTerminalSurfaceReplayBuffer({
3030 buffer: "fastfetch output",
apps/mobile/src/features/terminal/terminalBufferReplay.ts+14−1
@@ -1,3 +1,5 @@
1+import { terminalDebugLog } from "./terminalDebugLog";
2+
13 export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180;
24
35 export function getTerminalBufferReplayKey(input: {
@@ -12,5 +14,16 @@ export function getTerminalSurfaceReplayBuffer(input: { · 8 unmodified lines
1214 readonly replayKey: string;
1315 readonly readyReplayKey: string | null;
1416 }): 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;
1629 }
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+ */
7+export 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+
15+export 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
33 import type { KnownTerminalSession } from "@t3tools/client-runtime";
44 import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
55
6−import { buildTerminalMenuSessions, resolveProjectScriptTerminalId } from "./terminalMenu";
6+import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
7+
8+import {
9+ buildTerminalMenuSessions,
10+ nextOpenTerminalId,
11+ nextTerminalId,
12+ resolveProjectScriptTerminalId,
13+} from "./terminalMenu";
714
815 function makeKnownSession(input: {
916 readonly terminalId: string;
@@ -29,6 +36,7 @@ function makeKnownSession(input: { · 19 unmodified lines
2936 exitCode: null,
3037 exitSignal: null,
3138 hasRunningSubprocess: false,
39+ label: getTerminalLabel(input.terminalId),
3240 updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z",
3341 }
3442 : null,
@@ -43,7 +51,7 @@ function makeKnownSession(input: { · 8 unmodified lines
4351 }
4452
4553 describe("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)", () => {
4755 expect(
4856 buildTerminalMenuSessions({
4957 knownSessions: [
@@ -63,18 +71,12 @@ describe("buildTerminalMenuSessions", () => { · 13 unmodified lines
6371 workspaceRoot: "/workspace/root",
6472 }),
6573 ).toEqual([
66− {
67− terminalId: DEFAULT_TERMINAL_ID,
68− cwd: "/workspace/root",
69− status: "closed",
70− hasRunningSubprocess: false,
71− updatedAt: null,
72− },
7374 {
7475 terminalId: "term-3",
7576 cwd: "/workspace/feature",
7677 status: "running",
7778 hasRunningSubprocess: false,
79+ displayLabel: "Terminal 3",
7880 updatedAt: "2026-04-15T20:05:00.000Z",
7981 },
8082 ]);
@@ -90,28 +92,58 @@ describe("buildTerminalMenuSessions", () => { · 9 unmodified lines
9092 cwd: "/workspace/exited",
9193 status: "exited",
9294 hasRunningSubprocess: false,
95+ displayLabel: "Terminal 4",
9396 updatedAt: "2026-04-15T20:07:00.000Z",
9497 },
9598 }),
9699 ).toEqual([
97− {
98− terminalId: DEFAULT_TERMINAL_ID,
99− cwd: "/workspace/root",
100− status: "closed",
101− hasRunningSubprocess: false,
102− updatedAt: null,
103− },
104100 {
105101 terminalId: "term-4",
106102 cwd: "/workspace/exited",
107103 status: "exited",
108104 hasRunningSubprocess: false,
105+ displayLabel: "Terminal 4",
109106 updatedAt: "2026-04-15T20:07:00.000Z",
110107 },
111108 ]);
112109 });
113110 });
114111
112+describe("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+
122+describe("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+
115147 describe("resolveProjectScriptTerminalId", () => {
116148 it("reuses the default shell when no terminal is running", () => {
117149 expect(
apps/mobile/src/features/terminal/terminalMenu.ts+28−39
@@ -1,11 +1,20 @@
11 import type { KnownTerminalSession } from "@t3tools/client-runtime";
22 import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts";
3+import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
4+
5+export {
6+ getTerminalLabel,
7+ nextTerminalId,
8+ resolveTerminalSessionLabel,
9+} from "@t3tools/shared/terminalLabels";
310
411 export interface TerminalMenuSession {
512 readonly terminalId: string;
613 readonly cwd: string | null;
714 readonly status: "starting" | "running" | "exited" | "error" | "closed";
815 readonly hasRunningSubprocess: boolean;
16+ /** Server-authoritative title with the same fallback rules as web. */
17+ readonly displayLabel: string;
918 readonly updatedAt: string | null;
1019 }
1120
@@ -23,19 +32,6 @@ export function basename(input: string | null): string | null { · 11 unmodified lines
2332 return segments[segments.length - 1] ?? normalized;
2433 }
2534
26−export 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−
3935 export function getTerminalStatusLabel(input: {
4036 readonly status: TerminalMenuSession["status"];
4137 readonly hasRunningSubprocess?: boolean;
@@ -56,14 +52,23 @@ export function getTerminalStatusLabel(input: { · 14 unmodified lines
5652 return "Not started";
5753 }
5854
59−export function nextTerminalId(existingTerminalIds: ReadonlyArray<string>): string {
60− const usedIds = new Set(existingTerminalIds);
61− let nextIndex = 2;
62− while (usedIds.has(`term-${nextIndex}`)) {
63− nextIndex += 1;
55+/**
56+ * Picks an id for "open another shell". Counts the terminal screen already mounted
57+ * (`activeRouteTerminalId`) as occupied so an empty session list on the primary route
58+ * still advances to `term-2` instead of `replace`-navigating to the same `default` tab.
59+ */
60+export 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);
6469 }
6570
66− return `term-${nextIndex}`;
71+ return nextTerminalId([...listed, routeId]);
6772 }
6873
6974 export function buildTerminalMenuSessions(input: {
@@ -73,14 +78,6 @@ export function buildTerminalMenuSessions(input: { · 3 unmodified lines
7378 }): ReadonlyArray<TerminalMenuSession> {
7479 const sessionsById = new Map<string, TerminalMenuSession>();
7580
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−
8481 for (const session of input.knownSessions) {
8582 if (
8683 session.state.status !== "running" &&
@@ -95,6 +92,7 @@ export function buildTerminalMenuSessions(input: { · 8 unmodified lines
9592 cwd: session.state.summary?.cwd ?? input.workspaceRoot,
9693 status: session.state.status,
9794 hasRunningSubprocess: session.state.hasRunningSubprocess,
95+ displayLabel: resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
9896 updatedAt: session.state.updatedAt,
9997 });
10098 }
@@ -103,18 +101,9 @@ export function buildTerminalMenuSessions(input: { · 2 unmodified lines
103101 sessionsById.set(input.currentSession.terminalId, input.currentSession);
104102 }
105103
106− return Array.from(sessionsById.values()).sort((left, right) => {
107− if (left.terminalId === DEFAULT_TERMINAL_ID) return -1;
108− if (right.terminalId === DEFAULT_TERMINAL_ID) return 1;
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− });
104+ return Array.from(sessionsById.values()).sort((left, right) =>
105+ left.terminalId.localeCompare(right.terminalId, undefined, { numeric: true }),
106+ );
118107 }
119108
120109 export function resolveProjectScriptTerminalId(input: {
apps/mobile/src/features/terminal/terminalRouteBootstrap.ts+1−0
@@ -6,6 +6,7 @@ export function resolveTerminalRouteBootstrap(input: { · 5 unmodified lines
66 readonly currentTerminalId: string;
77 readonly runningTerminalId: string | null;
88 readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed";
9+ /** True once the attach stream has populated scrollback (`buffer` non-empty), not merely metadata. */
910 readonly hasCurrentTerminalHydration: boolean;
1011 }):
1112 | { 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
1111 import { buildThreadReviewRoutePath } from "../../lib/routes";
1212 import {
1313 basename,
14− getTerminalLabel,
1514 getTerminalStatusLabel,
1615 projectScriptMenuIcon,
1716 projectScriptMenuLabel,
@@ -183,19 +182,16 @@ export function ThreadGitControls(props: { · 165 unmodified lines
183182 <Stack.Toolbar placement="right">
184183 <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground>
185184 {props.projectScripts.length > 0 ? (
186− <Stack.Toolbar.Menu icon="play" inline title="Project scripts">
187− <Stack.Toolbar.Label>Project scripts</Stack.Toolbar.Label>
188− {props.projectScripts.map((script) => (
189− <Stack.Toolbar.MenuAction
190− key={script.id}
191− icon={projectScriptMenuIcon(script.icon)}
192− onPress={() => void props.onRunProjectScript(script)}
193− subtitle={script.command}
194− >
195− <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label>
196− </Stack.Toolbar.MenuAction>
197− ))}
198− </Stack.Toolbar.Menu>
185+ props.projectScripts.map((script) => (
186+ <Stack.Toolbar.MenuAction
187+ key={script.id}
188+ icon={projectScriptMenuIcon(script.icon)}
189+ onPress={() => void props.onRunProjectScript(script)}
190+ subtitle={script.command}
191+ >
192+ <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label>
193+ </Stack.Toolbar.MenuAction>
194+ ))
199195 ) : (
200196 <Stack.Toolbar.MenuAction
201197 icon="play"
@@ -206,34 +202,31 @@ export function ThreadGitControls(props: { · 4 unmodified lines
206202 <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label>
207203 </Stack.Toolbar.MenuAction>
208204 )}
209− <Stack.Toolbar.Menu icon="terminal" inline title="Terminals">
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− ))}
205+ {props.terminalSessions.map((session) => (
229206 <Stack.Toolbar.MenuAction
230− icon="plus"
231− onPress={props.onOpenNewTerminal}
232− subtitle="Start another shell for this thread"
207+ key={session.terminalId}
208+ icon="terminal"
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(" · ")}
233219 >
234− <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label>
220+ <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label>
235221 </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>
237230 </Stack.Toolbar.Menu>
238231 <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up">
239232 <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
1212
1313 import { EmptyState } from "../../components/EmptyState";
1414 import { LoadingScreen } from "../../components/LoadingScreen";
15−import { buildThreadRoutePath, buildThreadTerminalRoutePath } from "../../lib/routes";
15+import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes";
1616 import { scopedThreadKey } from "../../lib/scopedEntities";
1717 import { connectionTone } from "../connection/connectionTone";
1818
@@ -27,13 +27,14 @@ import { useThreadSelection } from "../../state/use-thread-selection"; · 8 unmodified lines
2727 import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
2828 import {
2929 buildTerminalMenuSessions,
30− nextTerminalId,
30+ nextOpenTerminalId,
3131 resolveProjectScriptTerminalId,
3232 } from "../terminal/terminalMenu";
3333 import {
3434 resolvePreferredThreadWorktreePath,
3535 stagePendingTerminalLaunch,
3636 } from "../terminal/terminalLaunchContext";
37+import { terminalDebugLog } from "../terminal/terminalDebugLog";
3738 import { ThreadDetailScreen } from "./ThreadDetailScreen";
3839 import { ThreadGitControls } from "./ThreadGitControls";
3940 import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
@@ -145,27 +146,52 @@ export function ThreadRouteScreen() { · 105 unmodified lines
145146
146147 const handleOpenTerminal = useCallback(
147148 (nextTerminalId?: string | null) => {
149+ terminalDebugLog("terminal-menu:open-existing", {
150+ terminalId: nextTerminalId ?? null,
151+ hasThread: Boolean(selectedThread),
152+ hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
153+ });
154+
148155 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
149156 return;
150157 }
151158
152− void router.push(buildThreadTerminalRoutePath(selectedThread, nextTerminalId));
159+ void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId));
153160 },
154161 [router, selectedThread, selectedThreadProject?.workspaceRoot],
155162 );
156163
157164 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+
158171 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
159172 return;
160173 }
161174
162− const nextId = nextTerminalId(terminalMenuSessions.map((session) => session.terminalId));
163− void router.push(buildThreadTerminalRoutePath(selectedThread, nextId));
175+ const nextId = nextOpenTerminalId({
176+ listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
177+ });
178+ void router.push(buildThreadTerminalNavigation(selectedThread, nextId));
164179 }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);
165180
166181 const handleRunProjectScript = useCallback(
167182 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+
168190 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
191+ terminalDebugLog("project-script:abort", {
192+ scriptId: script.id,
193+ reason: "no-thread-or-workspace",
194+ });
169195 return;
170196 }
171197
@@ -200,8 +226,14 @@ export function ThreadRouteScreen() { · 28 unmodified lines
200226 initialInput: `${script.command}\r`,
201227 },
202228 });
229+ terminalDebugLog("project-script:staged", {
230+ scriptId: script.id,
231+ terminalId: targetTerminalId,
232+ cwd,
233+ worktreePath: preferredWorktreePath,
234+ });
203235
204− void router.push(buildThreadTerminalRoutePath(selectedThread, targetTerminalId));
236+ void router.push(buildThreadTerminalNavigation(selectedThread, targetTerminalId));
205237 },
206238 [
207239 router,
apps/mobile/src/lib/routes.ts+28−1
@@ -1,4 +1,4 @@
1−import type { Router } from "expo-router";
1+import type { Href, Router } from "expo-router";
22
33 import type { SelectedThreadRef } from "../state/remote-runtime-types";
44 import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime";
@@ -41,6 +41,33 @@ export function buildThreadTerminalRoutePath( · 36 unmodified lines
4141 return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`;
4242 }
4343
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+ */
49+export 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+
4471 export function dismissRoute(router: Router) {
4572 if (router.canGoBack()) {
4673 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
1818 import { pipe } from "effect/Function";
1919 import { Atom } from "effect/unstable/reactivity";
2020 import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection";
21+import { terminalDebugLog } from "../features/terminal/terminalDebugLog";
2122 import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage";
2223 import { appAtomRegistry } from "./atom-registry";
2324 import { type ConnectedEnvironmentSummary, type EnvironmentSession } from "./remote-runtime-types";
@@ -268,6 +269,9 @@ export async function connectSavedEnvironment( · 244 unmodified lines
268269 client,
269270 }),
270271 );
272+ terminalDebugLog("registry:terminal-metadata-subscribed", {
273+ environmentId: connection.environmentId,
274+ });
271275 notifyEnvironmentConnectionListeners();
272276
273277 try {
apps/mobile/src/state/use-terminal-session.ts+3−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 import type {
1414 EnvironmentId,
1515 TerminalAttachInput,
16+ TerminalAttachStreamEvent,
1617 TerminalMetadataStreamEvent,
1718 TerminalSessionSnapshot,
1819 } from "@t3tools/contracts";
@@ -43,12 +44,14 @@ export function attachTerminalSession(input: { · 24 unmodified lines
4344 readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"];
4445 readonly terminal: TerminalAttachInput;
4546 readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void;
47+ readonly onEvent?: (event: TerminalAttachStreamEvent) => void;
4648 }) {
4749 return terminalSessionManager.attach({
4850 environmentId: input.environmentId,
4951 client: input.client,
5052 terminal: input.terminal,
5153 ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}),
54+ ...(input.onEvent ? { onEvent: input.onEvent } : {}),
5255 });
5356 }
5457
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts+1−0
@@ -91,6 +91,7 @@ describe("ProjectSetupScriptRunner", () => { · 90 unmodified lines
9191 history: "",
9292 exitCode: null,
9393 exitSignal: null,
94+ label: "setup-setup",
9495 updatedAt: "2026-01-01T00:00:00.000Z",
9596 }),
9697 );
apps/server/src/server.test.ts+1−0
@@ -3929,6 +3929,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 3928 unmodified lines
39293929 history: "",
39303930 exitCode: null,
39313931 exitSignal: null,
3932+ label: "Primary",
39323933 updatedAt: new Date().toISOString(),
39333934 };
39343935
apps/server/src/terminal/Layers/Manager.test.ts+29−13
@@ -198,7 +198,10 @@ interface CreateManagerOptions { · 197 unmodified lines
198198 shellResolver?: () => string;
199199 platform?: NodeJS.Platform;
200200 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+ }>;
202205 subprocessPollIntervalMs?: number;
203206 processKillGraceMs?: number;
204207 maxRetainedInactiveSessions?: number;
@@ -234,8 +237,8 @@ const createManager = ( · 29 unmodified lines
234237 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
235238 ...(options.platform !== undefined ? { platform: options.platform } : {}),
236239 ...(options.env !== undefined ? { env: options.env } : {}),
237− ...(options.subprocessChecker !== undefined
238− ? { subprocessChecker: options.subprocessChecker }
240+ ...(options.subprocessInspector !== undefined
241+ ? { subprocessInspector: options.subprocessInspector }
239242 : {}),
240243 ...(options.subprocessPollIntervalMs !== undefined
241244 ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs }
@@ -275,7 +278,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 33 unmodified lines
275278 const third = yield* manager.open(openInput());
276279
277280 assert.equal(first.threadId, "thread-1");
278− assert.equal(first.terminalId, "default");
281+ assert.equal(first.terminalId, DEFAULT_TERMINAL_ID);
279282 assert.equal(second.threadId, "thread-1");
280283 assert.equal(third.threadId, "thread-1");
281284 expect(ptyAdapter.spawnInputs).toHaveLength(1);
@@ -445,7 +448,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 163 unmodified lines
445448 (event) =>
446449 event.type === "cleared" &&
447450 event.threadId === "thread-1" &&
448− event.terminalId === "default",
451+ event.terminalId === DEFAULT_TERMINAL_ID,
449452 ),
450453 ).toBe(true);
451454 }),
@@ -617,27 +620,40 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 165 unmodified lines
617620
618621 it.effect("emits subprocess activity events when child-process state changes", () =>
619622 Effect.gen(function* () {
620− let hasRunningSubprocess = false;
623+ let inspect: {
624+ readonly hasRunningSubprocess: boolean;
625+ readonly childCommand: string | null;
626+ } = { hasRunningSubprocess: false, childCommand: null };
621627 const { manager, getEvents } = yield* createManager(5, {
622− subprocessChecker: () => Effect.succeed(hasRunningSubprocess),
628+ subprocessInspector: () => Effect.succeed(inspect),
623629 subprocessPollIntervalMs: 20,
624630 });
625631
626632 yield* manager.open(openInput());
627633 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);
628634
629− hasRunningSubprocess = true;
635+ inspect = { hasRunningSubprocess: true, childCommand: "vim" };
630636 yield* waitFor(
631637 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+ ),
633644 ),
634645 "1200 millis",
635646 );
636647
637− hasRunningSubprocess = false;
648+ inspect = { hasRunningSubprocess: false, childCommand: null };
638649 yield* waitFor(
639650 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+ ),
641657 ),
642658 "1200 millis",
643659 );
@@ -648,9 +664,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( · 4 unmodified lines
648664 Effect.gen(function* () {
649665 let checks = 0;
650666 const { manager } = yield* createManager(5, {
651− subprocessChecker: () => {
667+ subprocessInspector: () => {
652668 checks += 1;
653− return Effect.succeed(false);
669+ return Effect.succeed({ hasRunningSubprocess: false, childCommand: null });
654670 },
655671 subprocessPollIntervalMs: 20,
656672 });
apps/server/src/terminal/Layers/Manager.ts+188−52
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 type TerminalSummary,
1313 } from "@t3tools/contracts";
1414 import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";
15+import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
1516 import {
1617 Effect,
1718 Encoding,
@@ -58,6 +59,7 @@ const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; · 40 unmodified lines
5859 const DEFAULT_OPEN_COLS = 120;
5960 const DEFAULT_OPEN_ROWS = 30;
6061 const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
62+const MAX_TERMINAL_LABEL_LENGTH = 128;
6163
6264 class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(
6365 "TerminalSubprocessCheckError",
@@ -78,8 +80,15 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess · 14 unmodified lines
7880 },
7981 ) {}
8082
81−interface TerminalSubprocessChecker {
82− (terminalPid: number): Effect.Effect<boolean, TerminalSubprocessCheckError>;
83+interface TerminalSubprocessInspectResult {
84+ readonly hasRunningSubprocess: boolean;
85+ readonly childCommand: string | null;
86+}
87+
88+interface TerminalSubprocessInspector {
89+ (
90+ terminalPid: number,
91+ ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>;
8392 }
8493
8594 interface ShellCandidate {
@@ -119,6 +128,8 @@ interface TerminalSessionState { · 33 unmodified lines
119128 unsubscribeData: (() => void) | null;
120129 unsubscribeExit: (() => void) | null;
121130 hasRunningSubprocess: boolean;
131+ /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */
132+ childCommandLabel: string | null;
122133 runtimeEnv: Record<string, string> | null;
123134 }
124135
@@ -154,6 +165,38 @@ interface TerminalManagerState { · 29 unmodified lines
154165 killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>;
155166 }
156167
168+function 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+
173+function 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+
190+function 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+
157200 function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
158201 return {
159202 threadId: session.threadId,
@@ -165,6 +208,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { · 5 unmodified lines
165208 history: session.history,
166209 exitCode: session.exitCode,
167210 exitSignal: session.exitSignal,
211+ label: terminalWireLabel(session),
168212 updatedAt: session.updatedAt,
169213 sequence: session.eventSequence,
170214 };
@@ -181,6 +225,7 @@ function summary(session: TerminalSessionState): TerminalSummary { · 10 unmodified lines
181225 exitCode: session.exitCode,
182226 exitSignal: session.exitSignal,
183227 hasRunningSubprocess: session.hasRunningSubprocess,
228+ label: terminalWireLabel(session),
184229 updatedAt: session.updatedAt,
185230 };
186231 }
@@ -426,13 +471,25 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { · 239 unmodified lines
426471 );
427472 }
428473
429−function checkWindowsSubprocessActivity(
474+function 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+
484+function windowsInspectSubprocess(
430485 terminalPid: number,
431−): Effect.Effect<boolean, TerminalSubprocessCheckError> {
486+ platform: NodeJS.Platform,
487+): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError> {
432488 const command = [
433− `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,
434− "if ($children) { exit 0 }",
435− "exit 1",
489+ `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`,
490+ "if ($null -eq $c) { exit 1 }",
491+ "Write-Output $c.Name",
492+ "exit 0",
436493 ].join("; ");
437494 return Effect.tryPromise({
438495 try: () =>
@@ -444,17 +501,33 @@ function checkWindowsSubprocessActivity( · 5 unmodified lines
444501 }),
445502 catch: (cause) =>
446503 new TerminalSubprocessCheckError({
447− message: "Failed to check Windows terminal subprocess activity.",
504+ message: "Failed to inspect Windows terminal subprocesses.",
448505 cause,
449506 terminalPid,
450507 command: "powershell",
451508 }),
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+ );
453525 }
454526
455−const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* (
527+const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* (
456528 terminalPid: number,
457−): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {
529+ platform: NodeJS.Platform,
530+): Effect.fn.Return<TerminalSubprocessInspectResult, TerminalSubprocessCheckError> {
458531 const runPgrep = Effect.tryPromise({
459532 try: () =>
460533 runProcess("pgrep", ["-P", String(terminalPid)], {
@@ -489,45 +562,94 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct · 28 unmodified lines
489562 }),
490563 });
491564
565+ let childPid: number | null = null;
566+
492567 const pgrepResult = yield* Effect.exit(runPgrep);
493568 if (pgrepResult._tag === "Success") {
494569 if (pgrepResult.value.code === 0) {
495− return pgrepResult.value.stdout.trim().length > 0;
496− }
497− if (pgrepResult.value.code === 1) {
498− return false;
570+ childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout);
571+ } else if (pgrepResult.value.code === 1) {
572+ return { hasRunningSubprocess: false, childCommand: null };
499573 }
500574 }
501575
502− const psResult = yield* Effect.exit(runPs);
503− if (psResult._tag === "Failure" || psResult.value.code !== 0) {
504− return false;
576+ if (childPid === null) {
577+ const psResult = yield* Effect.exit(runPs);
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+ }
505591 }
506592
507− for (const line of psResult.value.stdout.split(/\r?\n/g)) {
508− const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
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− }
593+ if (childPid === null) {
594+ return { hasRunningSubprocess: false, childCommand: null };
515595 }
516− return false;
517−});
518596
519−const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* (
520− terminalPid: number,
521−): Effect.fn.Return<boolean, TerminalSubprocessCheckError> {
522− if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
523− return false;
597+ const runComm = Effect.tryPromise({
598+ try: () =>
599+ runProcess("ps", ["-p", String(childPid), "-o", "comm="], {
600+ timeoutMs: 1_000,
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();
524612 }
525− if (process.platform === "win32") {
526− return yield* checkWindowsSubprocessActivity(terminalPid);
613+
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+ }
527630 }
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+ };
529637 });
530638
639+function 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+
531653 function capHistory(history: string, maxLines: number): string {
532654 if (history.length === 0) return history;
533655 const hasTrailingNewline = history.endsWith("\n");
@@ -775,7 +897,7 @@ interface TerminalManagerOptions { · 241 unmodified lines
775897 shellResolver?: () => string;
776898 platform?: NodeJS.Platform;
777899 env?: NodeJS.ProcessEnv;
778− subprocessChecker?: TerminalSubprocessChecker;
900+ subprocessInspector?: TerminalSubprocessInspector;
779901 subprocessPollIntervalMs?: number;
780902 processKillGraceMs?: number;
781903 maxRetainedInactiveSessions?: number;
@@ -801,7 +923,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 19 unmodified lines
801923 const platform = options.platform ?? process.platform;
802924 const baseEnv = options.env ?? process.env;
803925 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
804− const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;
926+ const subprocessInspector =
927+ options.subprocessInspector ?? defaultSubprocessInspectorForPlatform(platform);
805928 const subprocessPollIntervalMs =
806929 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
807930 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
@@ -1290,6 +1413,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 482 unmodified lines
12901413 session.process = null;
12911414 session.pid = null;
12921415 session.hasRunningSubprocess = false;
1416+ session.childCommandLabel = null;
12931417 session.status = "exited";
12941418 session.pendingHistoryControlSequence = "";
12951419 session.pendingProcessEvents = [];
@@ -1358,6 +1482,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 62 unmodified lines
13581482 session.process = null;
13591483 session.pid = null;
13601484 session.hasRunningSubprocess = false;
1485+ session.childCommandLabel = null;
13611486 session.status = "exited";
13621487 session.pendingHistoryControlSequence = "";
13631488 session.pendingProcessEvents = [];
@@ -1451,6 +1576,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 87 unmodified lines
14511576 session.exitCode = null;
14521577 session.exitSignal = null;
14531578 session.hasRunningSubprocess = false;
1579+ session.childCommandLabel = null;
14541580 session.pendingProcessEvents = [];
14551581 session.pendingProcessEventIndex = 0;
14561582 session.processEventDrainRunning = false;
@@ -1528,6 +1654,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 71 unmodified lines
15281654 session.unsubscribeData = null;
15291655 session.unsubscribeExit = null;
15301656 session.hasRunningSubprocess = false;
1657+ session.childCommandLabel = null;
15311658 session.pendingProcessEvents = [];
15321659 session.pendingProcessEventIndex = 0;
15331660 session.processEventDrainRunning = false;
@@ -1608,7 +1735,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 74 unmodified lines
16081735 session: TerminalSessionState & { pid: number },
16091736 ) {
16101737 const terminalPid = session.pid;
1611− const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe(
1738+ const inspectResult = yield* subprocessInspector(terminalPid).pipe(
16121739 Effect.map(Option.some),
16131740 Effect.catch((reason) =>
16141741 Effect.logWarning("failed to check terminal subprocess activity", {
@@ -1616,14 +1743,17 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 1 unmodified line
16161743 terminalId: session.terminalId,
16171744 terminalPid,
16181745 reason,
1619− }).pipe(Effect.as(Option.none<boolean>())),
1746+ }).pipe(Effect.as(Option.none<TerminalSubprocessInspectResult>())),
16201747 ),
16211748 );
16221749
1623− if (Option.isNone(hasRunningSubprocess)) {
1750+ if (Option.isNone(inspectResult)) {
16241751 return;
16251752 }
16261753
1754+ const next = inspectResult.value;
1755+ const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null;
1756+
16271757 const event = yield* modifyManagerState((state) => {
16281758 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
16291759 state.sessions.get(toSessionKey(session.threadId, session.terminalId)),
@@ -1632,12 +1762,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 2 unmodified lines
16321762 Option.isNone(liveSession) ||
16331763 liveSession.value.status !== "running" ||
16341764 liveSession.value.pid !== terminalPid ||
1635− liveSession.value.hasRunningSubprocess === hasRunningSubprocess.value
1765+ (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess &&
1766+ liveSession.value.childCommandLabel === nextChildLabel)
16361767 ) {
16371768 return [Option.none(), state] as const;
16381769 }
16391770
1640− liveSession.value.hasRunningSubprocess = hasRunningSubprocess.value;
1771+ liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess;
1772+ liveSession.value.childCommandLabel = nextChildLabel;
16411773 const eventStamp = advanceEventSequence(liveSession.value);
16421774
16431775 return [
@@ -1646,7 +1778,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 2 unmodified lines
16461778 threadId: liveSession.value.threadId,
16471779 terminalId: liveSession.value.terminalId,
16481780 sequence: eventStamp.sequence,
1649− hasRunningSubprocess: hasRunningSubprocess.value,
1781+ hasRunningSubprocess: next.hasRunningSubprocess,
1782+ label: terminalWireLabel(liveSession.value),
16501783 }),
16511784 state,
16521785 ] as const;
@@ -1711,7 +1844,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 58 unmodified lines
17111844 );
17121845
17131846 const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) {
1714− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1847+ const terminalId = input.terminalId;
17151848 yield* assertValidCwd(input.cwd);
17161849
17171850 const sessionKey = toSessionKey(input.threadId, terminalId);
@@ -1743,6 +1876,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 25 unmodified lines
17431876 unsubscribeData: null,
17441877 unsubscribeExit: null,
17451878 hasRunningSubprocess: false,
1879+ childCommandLabel: null,
17461880 runtimeEnv: normalizedRuntimeEnv(input.env),
17471881 };
17481882
@@ -1839,7 +1973,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 90 unmodified lines
18391973 withThreadLock(
18401974 input.threadId,
18411975 Effect.gen(function* () {
1842− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
1976+ const terminalId = input.terminalId;
18431977 const existing = yield* getSession(input.threadId, terminalId);
18441978
18451979 if (Option.isNone(existing)) {
@@ -1918,10 +2052,12 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 72 unmodified lines
19182052 let unsubscribe: (() => void) | null = null;
19192053
19202054 return Effect.gen(function* () {
1921− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
19222055 const bufferedEvents: TerminalEvent[] = [];
19232056 let deliverLive = false;
19242057
2058+ const initialSnapshot = yield* openOrAttachForStream(input);
2059+ const terminalId = initialSnapshot.terminalId;
2060+
19252061 unsubscribe = yield* subscribe((event) => {
19262062 if (event.threadId !== input.threadId || event.terminalId !== terminalId) {
19272063 return Effect.void;
@@ -1936,7 +2072,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 8 unmodified lines
19362072 return attachEvent ? listener(attachEvent) : Effect.void;
19372073 });
19382074
1939− const initialSnapshot = yield* openOrAttachForStream(input);
19402075 yield* listener({
19412076 type: "snapshot",
19422077 snapshot: initialSnapshot,
@@ -2044,7 +2179,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 101 unmodified lines
20442179 };
20452180
20462181 const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) {
2047− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
2182+ const terminalId = input.terminalId;
20482183 const session = yield* requireSession(input.threadId, terminalId);
20492184 const process = session.process;
20502185 if (!process || session.status !== "running") {
@@ -2058,7 +2193,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 7 unmodified lines
20582193 });
20592194
20602195 const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) {
2061− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
2196+ const terminalId = input.terminalId;
20622197 const session = yield* requireSession(input.threadId, terminalId);
20632198 const process = session.process;
20642199 if (!process || session.status !== "running") {
@@ -2077,7 +2212,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 12 unmodified lines
20772212 withThreadLock(
20782213 input.threadId,
20792214 Effect.gen(function* () {
2080− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
2215+ const terminalId = input.terminalId;
20812216 const session = yield* requireSession(input.threadId, terminalId);
20822217 session.history = "";
20832218 session.pendingHistoryControlSequence = "";
@@ -2100,7 +2235,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 16 unmodified lines
21002235 input.threadId,
21012236 Effect.gen(function* () {
21022237 yield* increment(terminalRestartsTotal, { scope: "thread" });
2103− const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
2238+ const terminalId = input.terminalId;
21042239 yield* assertValidCwd(input.cwd);
21052240
21062241 const sessionKey = toSessionKey(input.threadId, terminalId);
@@ -2131,6 +2266,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith · 24 unmodified lines
21312266 unsubscribeData: null,
21322267 unsubscribeExit: null,
21332268 hasRunningSubprocess: false,
2269+ childCommandLabel: null,
21342270 runtimeEnv: normalizedRuntimeEnv(input.env),
21352271 };
21362272 const createdSession = session;
apps/web/src/components/ChatView.browser.tsx+22−0
@@ -993,6 +993,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { · 992 unmodified lines
993993 history: "",
994994 exitCode: null,
995995 exitSignal: null,
996+ label: "Terminal 1",
996997 updatedAt: NOW_ISO,
997998 };
998999 }
@@ -1767,6 +1768,26 @@ describe("ChatView timeline estimator parity (full app)", () => { · 768 unmodified lines
17671768 const mounted = await mountChatView({
17681769 viewport: DEFAULT_VIEWPORT,
17691770 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+ },
17701791 });
17711792
17721793 try {
@@ -3550,6 +3571,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1777 unmodified lines
35503571 exitCode: null,
35513572 exitSignal: null,
35523573 hasRunningSubprocess: true,
3574+ label: "Terminal 1",
35533575 updatedAt: isoAt(1_200),
35543576 },
35553577 },
apps/web/src/components/ChatView.tsx+267−35
@@ -29,6 +29,7 @@ import { · 28 unmodified lines
2929 import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model";
3030 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
3131 import { truncate } from "@t3tools/shared/String";
32+import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
3233 import { Debouncer } from "@tanstack/react-pacer";
3334 import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
3435 import { useNavigate, useSearch } from "@tanstack/react-router";
@@ -100,7 +101,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" · 65 unmodified lines
100101 import PlanSidebar from "./PlanSidebar";
101102 import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
102103 import { ChevronDownIcon } from "lucide-react";
103−import { cn, randomUUID } from "~/lib/utils";
104+import { cn } from "~/lib/utils";
104105 import { toastManager } from "./ui/toast";
105106 import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
106107 import { type NewProjectScriptInput } from "./ProjectScriptsControl";
@@ -133,7 +134,7 @@ import { · 26 unmodified lines
133134 type TerminalContextSelection,
134135 } from "../lib/terminalContext";
135136 import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
136−import { useThreadRunningTerminalIds } from "../terminalSessionState";
137+import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState";
137138 import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
138139 import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
139140 import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
@@ -408,6 +409,45 @@ function useLocalDispatchState(input: { · 268 unmodified lines
408409 };
409410 }
410411
412+/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */
413+function 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+ */
435+function 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+
411451 interface PersistentThreadTerminalDrawerProps {
412452 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
413453 threadId: ThreadId;
@@ -444,11 +484,42 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 30 unmodified lines
444484 const terminalUiState = useTerminalUiStateStore((state) =>
445485 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),
446486 );
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+ );
447505 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);
448506 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);
449507 const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal);
450508 const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal);
451509 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]);
452523 const [localFocusRequestId, setLocalFocusRequestId] = useState(0);
453524 const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
454525 const effectiveWorktreePath = useMemo(() => {
@@ -494,14 +565,68 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 39 unmodified lines
494565 );
495566
496567 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);
498574 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+ ]);
500598
501599 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);
503606 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+ ]);
505630
506631 const activateTerminal = useCallback(
507632 (terminalId: string) => {
@@ -537,13 +662,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 29 unmodified lines
537662 storeCloseTerminal(threadRef, terminalId);
538663 bumpFocusRequestId();
539664 },
540− [
541− bumpFocusRequestId,
542− storeCloseTerminal,
543− terminalUiState.terminalIds.length,
544− threadId,
545− threadRef,
546− ],
665+ [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef],
547666 );
548667
549668 const handleAddTerminalContext = useCallback(
@@ -570,6 +689,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 20 unmodified lines
570689 runtimeEnv={runtimeEnv}
571690 visible={visible}
572691 height={terminalUiState.terminalHeight}
692+ // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.
573693 terminalIds={terminalUiState.terminalIds}
574694 activeTerminalId={terminalUiState.activeTerminalId}
575695 terminalGroups={terminalUiState.terminalGroups}
@@ -585,6 +705,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 9 unmodified lines
585705 onCloseTerminal={closeTerminal}
586706 onHeightChange={setTerminalHeight}
587707 onAddTerminalContext={handleAddTerminalContext}
708+ terminalLabelsById={terminalLabelsById}
588709 />
589710 </div>
590711 );
@@ -797,11 +918,52 @@ export default function ChatView(props: ChatViewProps) { · 206 unmodified lines
797918 environmentId: activeThread?.environmentId ?? null,
798919 threadId: activeThreadId,
799920 });
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);
800938 const activeThreadRef = useMemo(
801939 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
802940 [activeThread],
803941 );
804942 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+
805967 const existingOpenTerminalThreadKeys = useMemo(() => {
806968 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
807969 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
@@ -1581,22 +1743,93 @@ export default function ChatView(props: ChatViewProps) { · 773 unmodified lines
15811743 setTerminalOpen(!terminalUiState.terminalOpen);
15821744 }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]);
15831745 const splitTerminal = useCallback(() => {
1584− if (!activeThreadRef || hasReachedSplitLimit) return;
1585− const terminalId = `terminal-${randomUUID()}`;
1746+ if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) {
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);
15861758 storeSplitTerminal(activeThreadRef, terminalId);
15871759 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+ ]);
15891787 const createNewTerminal = useCallback(() => {
1590− if (!activeThreadRef) return;
1591− const terminalId = `terminal-${randomUUID()}`;
1788+ if (!activeThreadRef || !activeThreadId || !activeProject) {
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);
15921800 storeNewTerminal(activeThreadRef, terminalId);
15931801 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+ ]);
15951828 const closeTerminal = useCallback(
15961829 (terminalId: string) => {
15971830 const api = readEnvironmentApi(environmentId);
1598− if (!activeThreadId || !api) return;
1599− const isFinalTerminal = terminalUiState.terminalIds.length <= 1;
1831+ if (!activeThreadId || !api || !activeThreadRef) return;
1832+ const isFinalTerminal = activeServerOrderedTerminalIds.length <= 1;
16001833 const fallbackExitWrite = () =>
16011834 api.terminal
16021835 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })
@@ -1617,17 +1850,15 @@ export default function ChatView(props: ChatViewProps) { · 14 unmodified lines
16171850 } else {
16181851 void fallbackExitWrite();
16191852 }
1620− if (activeThreadRef) {
1621− storeCloseTerminal(activeThreadRef, terminalId);
1622− }
1853+ storeCloseTerminal(activeThreadRef, terminalId);
16231854 setTerminalFocusRequestId((value) => value + 1);
16241855 },
16251856 [
16261857 activeThreadId,
16271858 activeThreadRef,
1859+ activeServerOrderedTerminalIds,
16281860 environmentId,
16291861 storeCloseTerminal,
1630− terminalUiState.terminalIds.length,
16311862 ],
16321863 );
16331864 const runProjectScript = useCallback(
@@ -1652,14 +1883,11 @@ export default function ChatView(props: ChatViewProps) { · 18 unmodified lines
16521883 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
16531884 const baseTerminalId =
16541885 terminalUiState.activeTerminalId ||
1655− terminalUiState.terminalIds[0] ||
1886+ activeServerOrderedTerminalIds[0] ||
16561887 DEFAULT_THREAD_TERMINAL_ID;
16571888 const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);
16581889 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
16591890 const shouldCreateNewTerminal = wantsNewTerminal;
1660− const targetTerminalId = shouldCreateNewTerminal
1661− ? `terminal-${randomUUID()}`
1662− : baseTerminalId;
16631891 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;
16641892
16651893 setTerminalUiLaunchContext({
@@ -1671,11 +1899,6 @@ export default function ChatView(props: ChatViewProps) { · 5 unmodified lines
16711899 if (!activeThreadRef) {
16721900 return;
16731901 }
1674− if (shouldCreateNewTerminal) {
1675− storeNewTerminal(activeThreadRef, targetTerminalId);
1676− } else {
1677− storeSetActiveTerminal(activeThreadRef, targetTerminalId);
1678− }
16791902 setTerminalFocusRequestId((value) => value + 1);
16801903
16811904 const runtimeEnv = projectScriptRuntimeEnv({
@@ -1685,6 +1908,9 @@ export default function ChatView(props: ChatViewProps) { · 3 unmodified lines
16851908 worktreePath: targetWorktreePath,
16861909 ...(options?.env ? { extraEnv: options.env } : {}),
16871910 });
1911+ const targetTerminalId = shouldCreateNewTerminal
1912+ ? nextTerminalId(activeServerOrderedTerminalIds)
1913+ : baseTerminalId;
16881914 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
16891915 ? {
16901916 threadId: activeThreadId,
@@ -1703,6 +1929,12 @@ export default function ChatView(props: ChatViewProps) { · 12 unmodified lines
17031929 env: runtimeEnv,
17041930 };
17051931
1932+ if (shouldCreateNewTerminal) {
1933+ storeNewTerminal(activeThreadRef, targetTerminalId);
1934+ } else {
1935+ storeSetActiveTerminal(activeThreadRef, targetTerminalId);
1936+ }
1937+
17061938 try {
17071939 await api.terminal.open(openTerminalInput);
17081940 await api.terminal.write({
@@ -1729,9 +1961,9 @@ export default function ChatView(props: ChatViewProps) { · 20 unmodified lines
17291961 storeSetActiveTerminal,
17301962 setLastInvokedScriptByProjectId,
17311963 environmentId,
1964+ activeServerOrderedTerminalIds,
17321965 runningTerminalIds,
17331966 terminalUiState.activeTerminalId,
1734− terminalUiState.terminalIds,
17351967 ],
17361968 );
17371969
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+4−3
@@ -128,7 +128,7 @@ function createEnvironmentApi() { · 127 unmodified lines
128128 terminal: {
129129 open: vi.fn(async () => ({
130130 threadId: THREAD_ID,
131− terminalId: "default",
131+ terminalId: "term-1",
132132 cwd: "/repo/project",
133133 worktreePath: null,
134134 status: "running" as const,
@@ -136,6 +136,7 @@ function createEnvironmentApi() { · 1 unmodified line
136136 history: "",
137137 exitCode: null,
138138 exitSignal: null,
139+ label: "Terminal 1",
139140 updatedAt: "2026-04-07T00:00:00.000Z",
140141 })),
141142 write: vi.fn(async () => undefined),
@@ -168,7 +169,7 @@ async function mountTerminalViewport(props: { · 26 unmodified lines
168169 <TerminalViewport
169170 threadRef={props.threadRef}
170171 threadId={THREAD_ID}
171− terminalId="default"
172+ terminalId="term-1"
172173 terminalLabel="Terminal"
173174 cwd="/repo/project"
174175 onSessionExited={() => undefined}
@@ -188,7 +189,7 @@ async function mountTerminalViewport(props: { · 13 unmodified lines
188189 <TerminalViewport
189190 threadRef={nextProps.threadRef}
190191 threadId={THREAD_ID}
191− terminalId="default"
192+ terminalId="term-1"
192193 terminalLabel="Terminal"
193194 cwd="/repo/project"
194195 onSessionExited={() => undefined}
apps/web/src/components/ThreadTerminalDrawer.tsx+63−30
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 type TerminalSessionSnapshot,
88 type ThreadId,
99 } from "@t3tools/contracts";
10+import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
1011 import { Terminal, type ITheme } from "@xterm/xterm";
1112 import {
1213 type PointerEvent as ReactPointerEvent,
@@ -41,7 +42,6 @@ import { · 28 unmodified lines
4142 } from "../keybindings";
4243 import {
4344 DEFAULT_THREAD_TERMINAL_HEIGHT,
44− DEFAULT_THREAD_TERMINAL_ID,
4545 MAX_TERMINALS_PER_GROUP,
4646 type ThreadTerminalGroup,
4747 } from "../types";
@@ -763,6 +763,8 @@ interface ThreadTerminalDrawerProps { · 715 unmodified lines
763763 onHeightChange: (height: number) => void;
764764 onAddTerminalContext: (selection: TerminalContextSelection) => void;
765765 keybindings: ResolvedKeybindingsConfig;
766+ /** Prefer server-provided tab titles when present (e.g. active subprocess name). */
767+ terminalLabelsById?: ReadonlyMap<string, string>;
766768 }
767769
768770 interface TerminalActionButtonProps {
@@ -817,6 +819,7 @@ export default function ThreadTerminalDrawer({ · 48 unmodified lines
817819 onHeightChange,
818820 onAddTerminalContext,
819821 keybindings,
822+ terminalLabelsById,
820823 }: ThreadTerminalDrawerProps) {
821824 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
822825 const [resizeEpoch, setResizeEpoch] = useState(0);
@@ -831,15 +834,20 @@ export default function ThreadTerminalDrawer({ · 8 unmodified lines
831834 const didResizeDuringDragRef = useRef(false);
832835
833836 const normalizedTerminalIds = useMemo(() => {
834− const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
835− return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID];
837+ return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
836838 }, [terminalIds]);
837839
838− const resolvedActiveTerminalId = normalizedTerminalIds.includes(activeTerminalId)
839− ? activeTerminalId
840− : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
840+ const resolvedActiveTerminalId =
841+ normalizedTerminalIds.length === 0
842+ ? ""
843+ : normalizedTerminalIds.includes(activeTerminalId)
844+ ? activeTerminalId
845+ : (normalizedTerminalIds[0] ?? "");
841846
842847 const resolvedTerminalGroups = useMemo(() => {
848+ if (normalizedTerminalIds.length === 0) {
849+ return [];
850+ }
843851 const validTerminalIdSet = new Set(normalizedTerminalIds);
844852 const assignedTerminalIds = new Set<string>();
845853 const usedGroupIds = new Set<string>();
@@ -876,7 +884,7 @@ export default function ThreadTerminalDrawer({ · 30 unmodified lines
876884 const baseGroupId =
877885 terminalGroup.id.trim().length > 0
878886 ? terminalGroup.id.trim()
879− : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`;
887+ : `group-${nextTerminalIds[0] ?? normalizedTerminalIds[0] ?? ""}`;
880888 nextGroups.push({
881889 id: assignUniqueGroupId(baseGroupId),
882890 terminalIds: nextTerminalIds,
@@ -891,17 +899,17 @@ export default function ThreadTerminalDrawer({ · 8 unmodified lines
891899 });
892900 }
893901
894− if (nextGroups.length > 0) {
895− return nextGroups;
896− }
902+ const terminalOrderIndex = new Map(
903+ normalizedTerminalIds.map((id, index) => [id, index] as const),
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+ });
897910
898− return [
899− {
900− id: `group-${resolvedActiveTerminalId}`,
901− terminalIds: [resolvedActiveTerminalId],
902− },
903− ];
904− }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]);
911+ return nextGroups;
912+ }, [normalizedTerminalIds, terminalGroups]);
905913
906914 const resolvedActiveGroupIndex = useMemo(() => {
907915 const indexById = resolvedTerminalGroups.findIndex(
@@ -914,22 +922,22 @@ export default function ThreadTerminalDrawer({ · 6 unmodified lines
914922 return indexByTerminal >= 0 ? indexByTerminal : 0;
915923 }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]);
916924
917− const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [
918− resolvedActiveTerminalId,
919− ];
925+ const visibleTerminalIds =
926+ resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ??
927+ (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);
920928 const hasTerminalSidebar = normalizedTerminalIds.length > 1;
921929 const isSplitView = visibleTerminalIds.length > 1;
922930 const showGroupHeaders =
923931 resolvedTerminalGroups.length > 1 ||
924932 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
925933 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
926− const terminalLabelById = useMemo(
927− () =>
928− new Map(
929− normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]),
930− ),
931− [normalizedTerminalIds],
932− );
934+ const terminalLabelById = useMemo(() => {
935+ const next = new Map<string, string>();
936+ for (const terminalId of normalizedTerminalIds) {
937+ next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId));
938+ }
939+ return next;
940+ }, [normalizedTerminalIds, terminalLabelsById]);
933941 const splitTerminalActionLabel = hasReachedSplitLimit
934942 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`
935943 : splitShortcutLabel
@@ -1051,6 +1059,33 @@ export default function ThreadTerminalDrawer({ · 115 unmodified lines
10511059 };
10521060 }, [syncHeight]);
10531061
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+
10541089 return (
10551090 <aside
10561091 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
12161251 }`}
12171252 onClick={() => onActiveTerminalChange(groupActiveTerminalId)}
12181253 >
1219− {terminalGroup.terminalIds.length > 1
1220− ? `Split ${groupIndex + 1}`
1221− : `Terminal ${groupIndex + 1}`}
1254+ Group {groupIndex + 1}
12221255 </button>
12231256 )}
12241257
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+65−0
@@ -1,4 +1,5 @@
11 import { QueryClient } from "@tanstack/react-query";
2+import type { WsRpcClient } from "@t3tools/client-runtime";
23 import {
34 EnvironmentId,
45 ProjectId,
@@ -67,6 +68,70 @@ vi.mock("../../rpc/wsRpcClient", () => ({ · 62 unmodified lines
6768 createWsRpcClient: mockCreateWsRpcClient,
6869 }));
6970
71+vi.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+
70135 vi.mock("../../rpc/wsTransport", () => ({
71136 WsTransport: MockWsTransport,
72137 }));
apps/web/src/lib/terminalFocus.test.ts+18−1
@@ -11,7 +11,13 @@ class MockHTMLElement { · 10 unmodified lines
1111 };
1212
1313 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;
1521 }
1622 }
1723
@@ -53,4 +59,15 @@ describe("isTerminalFocused", () => { · 35 unmodified lines
5359
5460 expect(isTerminalFocused()).toBe(true);
5561 });
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+ });
5673 });
apps/web/src/lib/terminalFocus.ts+3−1
@@ -3,5 +3,7 @@ export function isTerminalFocused(): boolean { · 2 unmodified lines
33 if (!(activeElement instanceof HTMLElement)) return false;
44 if (!activeElement.isConnected) return false;
55 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;
79 }
apps/web/src/localApi.test.ts+1−0
@@ -341,6 +341,7 @@ describe("wsApi", () => { · 340 unmodified lines
341341 exitCode: null,
342342 exitSignal: null,
343343 hasRunningSubprocess: true,
344+ label: "terminal-1",
344345 updatedAt: "2026-02-24T00:00:00.000Z",
345346 },
346347 } satisfies TerminalMetadataStreamEvent;
apps/web/src/terminalUiStateStore.test.ts+51−31
@@ -20,7 +20,7 @@ describe("terminalUiStateStore actions", () => { · 19 unmodified lines
2020 });
2121 });
2222
23− it("returns a closed default terminal UI state for unknown threads", () => {
23+ it("returns an empty default terminal UI state for unknown threads", () => {
2424 const terminalUiState = selectThreadTerminalUiState(
2525 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
2626 THREAD_REF,
@@ -28,10 +28,10 @@ describe("terminalUiStateStore actions", () => { · 1 unmodified line
2828 expect(terminalUiState).toEqual({
2929 terminalOpen: false,
3030 terminalHeight: 280,
31− terminalIds: ["default"],
32− activeTerminalId: "default",
33− terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
34− activeTerminalGroupId: "group-default",
31+ terminalIds: [],
32+ activeTerminalId: "",
33+ terminalGroups: [],
34+ activeTerminalGroupId: "",
3535 });
3636 });
3737
@@ -45,10 +45,10 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines
4545 THREAD_REF,
4646 );
4747 expect(terminalUiState.terminalOpen).toBe(true);
48− expect(terminalUiState.terminalIds).toEqual(["default", "terminal-2"]);
48+ expect(terminalUiState.terminalIds).toEqual(["terminal-2"]);
4949 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
5050 expect(terminalUiState.terminalGroups).toEqual([
51− { id: "group-default", terminalIds: ["default", "terminal-2"] },
51+ { id: "group-terminal-2", terminalIds: ["terminal-2"] },
5252 ]);
5353 });
5454
@@ -58,19 +58,23 @@ describe("terminalUiStateStore actions", () => { · 3 unmodified lines
5858 store.splitTerminal(THREAD_REF, "terminal-3");
5959 store.splitTerminal(THREAD_REF, "terminal-4");
6060 store.splitTerminal(THREAD_REF, "terminal-5");
61+ store.splitTerminal(THREAD_REF, "terminal-6");
6162
6263 const terminalUiState = selectThreadTerminalUiState(
6364 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
6465 THREAD_REF,
6566 );
6667 expect(terminalUiState.terminalIds).toEqual([
67− "default",
6868 "terminal-2",
6969 "terminal-3",
7070 "terminal-4",
71+ "terminal-5",
7172 ]);
7273 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+ },
7478 ]);
7579 });
7680
@@ -81,11 +85,10 @@ describe("terminalUiStateStore actions", () => { · 4 unmodified lines
8185 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
8286 THREAD_REF,
8387 );
84− expect(terminalUiState.terminalIds).toEqual(["default", "terminal-2"]);
88+ expect(terminalUiState.terminalIds).toEqual(["terminal-2"]);
8589 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
8690 expect(terminalUiState.activeTerminalGroupId).toBe("group-terminal-2");
8791 expect(terminalUiState.terminalGroups).toEqual([
88− { id: "group-default", terminalIds: ["default"] },
8992 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
9093 ]);
9194 });
@@ -99,10 +102,9 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines
99102 THREAD_REF,
100103 );
101104 expect(terminalUiState.terminalOpen).toBe(true);
102− expect(terminalUiState.terminalIds).toEqual(["default", "setup-setup"]);
105+ expect(terminalUiState.terminalIds).toEqual(["setup-setup"]);
103106 expect(terminalUiState.activeTerminalId).toBe("setup-setup");
104107 expect(terminalUiState.terminalGroups).toEqual([
105− { id: "group-default", terminalIds: ["default"] },
106108 { id: "group-setup-setup", terminalIds: ["setup-setup"] },
107109 ]);
108110 });
@@ -123,28 +125,28 @@ describe("terminalUiStateStore actions", () => { · 14 unmodified lines
123125 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
124126 OTHER_THREAD_REF,
125127 ).terminalIds,
126− ).toEqual(["default", "env-b-terminal"]);
128+ ).toEqual(["env-b-terminal"]);
127129 });
128130
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", () => {
130132 const migrated = migratePersistedTerminalUiStateStoreState(
131133 {
132134 terminalStateByThreadKey: {
133135 [scopedThreadKey(THREAD_REF)]: {
134136 terminalOpen: true,
135137 terminalHeight: 320,
136− terminalIds: ["default"],
137− activeTerminalId: "default",
138− terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
139− activeTerminalGroupId: "group-default",
138+ terminalIds: ["term-1"],
139+ activeTerminalId: "term-1",
140+ terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
141+ activeTerminalGroupId: "group-term-1",
140142 },
141143 "legacy-thread-id": {
142144 terminalOpen: true,
143145 terminalHeight: 320,
144− terminalIds: ["default"],
145− activeTerminalId: "default",
146− terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
147− activeTerminalGroupId: "group-default",
146+ terminalIds: ["term-1"],
147+ activeTerminalId: "term-1",
148+ terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
149+ activeTerminalGroupId: "group-term-1",
148150 },
149151 },
150152 },
@@ -156,10 +158,10 @@ describe("terminalUiStateStore actions", () => { · 5 unmodified lines
156158 [scopedThreadKey(THREAD_REF)]: {
157159 terminalOpen: true,
158160 terminalHeight: 320,
159− terminalIds: ["default"],
160− activeTerminalId: "default",
161− terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
162− activeTerminalGroupId: "group-default",
161+ terminalIds: ["term-1"],
162+ activeTerminalId: "term-1",
163+ terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
164+ activeTerminalGroupId: "group-term-1",
163165 },
164166 },
165167 });
@@ -167,7 +169,8 @@ describe("terminalUiStateStore actions", () => { · 1 unmodified line
167169
168170 it("resets to default and clears persisted entry when closing the last terminal", () => {
169171 const store = useTerminalUiStateStore.getState();
170− store.closeTerminal(THREAD_REF, "default");
172+ store.newTerminal(THREAD_REF, "terminal-only");
173+ store.closeTerminal(THREAD_REF, "terminal-only");
171174
172175 expect(
173176 useTerminalUiStateStore.getState().terminalUiStateByThreadKey[scopedThreadKey(THREAD_REF)],
@@ -177,7 +180,7 @@ describe("terminalUiStateStore actions", () => { · 3 unmodified lines
177180 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
178181 THREAD_REF,
179182 ).terminalIds,
180− ).toEqual(["default"]);
183+ ).toEqual([]);
181184 });
182185
183186 it("keeps a valid active terminal after closing an active split terminal", () => {
@@ -191,9 +194,26 @@ describe("terminalUiStateStore actions", () => { · 7 unmodified lines
191194 THREAD_REF,
192195 );
193196 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");
195214 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"] },
197217 ]);
198218 });
199219
apps/web/src/terminalUiStateStore.ts+60−44
@@ -12,7 +12,6 @@ import { createJSONStorage, persist } from "zustand/middleware"; · 11 unmodified lines
1212 import { resolveStorage } from "./lib/storage";
1313 import {
1414 DEFAULT_THREAD_TERMINAL_HEIGHT,
15− DEFAULT_THREAD_TERMINAL_ID,
1615 MAX_TERMINALS_PER_GROUP,
1716 type ThreadTerminalGroup,
1817 } from "./types";
@@ -36,20 +35,22 @@ interface PersistedTerminalUiStateStoreState { · 17 unmodified lines
3635
3736 export function migratePersistedTerminalUiStateStoreState(
3837 persistedState: unknown,
39− version: number,
38+ _version: number,
4039 ): PersistedTerminalUiStateStoreState {
41− if (version <= 2 && persistedState && typeof persistedState === "object") {
42− const candidate = persistedState as PersistedTerminalUiStateStoreState;
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 };
40+ if (!persistedState || typeof persistedState !== "object") {
41+ return { terminalUiStateByThreadKey: {} };
5142 }
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 };
5354 }
5455
5556 function createTerminalUiStateStorage() {
@@ -57,8 +58,7 @@ function createTerminalUiStateStorage() { · 1 unmodified line
5758 }
5859
5960 function normalizeTerminalIds(terminalIds: string[]): string[] {
60− const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
61− return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
61+ return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
6262 }
6363
6464 function fallbackGroupId(terminalId: string): string {
@@ -91,6 +91,10 @@ function normalizeTerminalGroups( · 26 unmodified lines
9191 terminalGroups: ThreadTerminalGroup[],
9292 terminalIds: string[],
9393 ): ThreadTerminalGroup[] {
94+ if (terminalIds.length === 0) {
95+ return [];
96+ }
97+
9498 const validTerminalIdSet = new Set(terminalIds);
9599 const assignedTerminalIds = new Set<string>();
96100 const nextGroups: ThreadTerminalGroup[] = [];
@@ -109,7 +113,7 @@ function normalizeTerminalGroups( · 12 unmodified lines
109113 const baseGroupId =
110114 group.id.trim().length > 0
111115 ? group.id.trim()
112− : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
116+ : fallbackGroupId(groupTerminalIds[0] ?? terminalIds[0] ?? "");
113117 nextGroups.push({
114118 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
115119 terminalIds: groupTerminalIds,
@@ -124,15 +128,6 @@ function normalizeTerminalGroups( · 8 unmodified lines
124128 });
125129 }
126130
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−
136131 return nextGroups;
137132 }
138133
@@ -173,15 +168,10 @@ function threadTerminalUiStateEqual( · 34 unmodified lines
173168 const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({
174169 terminalOpen: false,
175170 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
176− terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
177− activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
178− terminalGroups: [
179− {
180− id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
181− terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
182− },
183− ],
184− activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
171+ terminalIds: [],
172+ activeTerminalId: "",
173+ terminalGroups: [],
174+ activeTerminalGroupId: "",
185175 });
186176
187177 function createDefaultThreadTerminalUiState(): ThreadTerminalUiState {
@@ -197,11 +187,10 @@ function getDefaultThreadTerminalUiState(): ThreadTerminalUiState { · 9 unmodified lines
197187 }
198188
199189 function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState {
200− const terminalIds = normalizeTerminalIds(state.terminalIds);
201− const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
190+ const nextTerminalIds = normalizeTerminalIds(state.terminalIds);
202191 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)
203192 ? state.activeTerminalId
204− : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
193+ : (nextTerminalIds[0] ?? "");
205194 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
206195 const activeGroupIdFromState = terminalGroups.some(
207196 (group) => group.id === state.activeTerminalGroupId,
@@ -221,10 +210,7 @@ function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTer · 13 unmodified lines
221210 activeTerminalId,
222211 terminalGroups,
223212 activeTerminalGroupId:
224− activeGroupIdFromState ??
225− activeGroupIdFromTerminal ??
226− terminalGroups[0]?.id ??
227− fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
213+ activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "",
228214 };
229215 return threadTerminalUiStateEqual(state, normalized) ? state : normalized;
230216 }
@@ -255,6 +241,7 @@ function upsertTerminalIntoGroups( · 24 unmodified lines
255241 mode: "split" | "new",
256242 ): ThreadTerminalUiState {
257243 const normalized = normalizeThreadTerminalUiState(state);
244+ const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode;
258245 if (!isValidTerminalId(terminalId)) {
259246 return normalized;
260247 }
@@ -275,7 +262,7 @@ function upsertTerminalIntoGroups( · 14 unmodified lines
275262 }
276263 }
277264
278− if (mode === "new") {
265+ if (effectiveMode === "new") {
279266 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
280267 const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);
281268 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
@@ -411,7 +398,7 @@ function closeThreadTerminal( · 129 unmodified lines
411398 normalized.activeTerminalId === terminalId
412399 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
413400 remainingTerminalIds[0] ??
414− DEFAULT_THREAD_TERMINAL_ID)
401+ "")
415402 : normalized.activeTerminalId;
416403
417404 const terminalGroups = normalized.terminalGroups
@@ -436,6 +423,32 @@ function closeThreadTerminal( · 18 unmodified lines
436423 });
437424 }
438425
426+function 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+
439452 export function selectThreadTerminalUiState(
440453 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,
441454 threadRef: ScopedThreadRef | null | undefined,
@@ -491,6 +504,7 @@ interface TerminalUiStateStoreState { · 49 unmodified lines
491504 ) => void;
492505 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
493506 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
507+ reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void;
494508 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;
495509 removeTerminalUiState: (threadRef: ScopedThreadRef) => void;
496510 removeOrphanedTerminalUiStates: (activeThreadKeys: Set<string>) => void;
@@ -553,6 +567,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( · 56 unmodified lines
553567 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),
554568 closeTerminal: (threadRef, terminalId) =>
555569 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)),
570+ reconcileTerminalIds: (threadRef, nextIds) =>
571+ updateTerminal(threadRef, (state) => reconcileThreadTerminalSessionIds(state, nextIds)),
556572 clearTerminalUiState: (threadRef) =>
557573 set((state) => {
558574 const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey(
@@ -600,7 +616,7 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( · 41 unmodified lines
600616 },
601617 {
602618 name: TERMINAL_UI_STATE_STORAGE_KEY,
603− version: 3,
619+ version: 4,
604620 storage: createJSONStorage(createTerminalUiStateStorage),
605621 migrate: migratePersistedTerminalUiStateStoreState,
606622 partialize: (state) => ({
apps/web/src/types.ts+1−1
@@ -22,7 +22,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; · 21 unmodified lines
2222
2323 export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default";
2424 export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
25−export const DEFAULT_THREAD_TERMINAL_ID = "default";
25+export const DEFAULT_THREAD_TERMINAL_ID = "term-1";
2626 export const MAX_TERMINALS_PER_GROUP = 4;
2727 export type ProjectScript = ContractProjectScript;
2828
packages/client-runtime/src/terminalSessionState.test.ts+26−4
@@ -28,7 +28,7 @@ function resetAtomRegistry() { · 27 unmodified lines
2828 const TARGET = {
2929 environmentId: EnvironmentId.make("env-local"),
3030 threadId: ThreadId.make("thread-1"),
31− terminalId: "default",
31+ terminalId: "term-1",
3232 } as const;
3333
3434 const BASE_SNAPSHOT: TerminalSessionSnapshot = {
@@ -41,6 +41,7 @@ const BASE_SNAPSHOT: TerminalSessionSnapshot = { · 6 unmodified lines
4141 history: "hello",
4242 exitCode: null,
4343 exitSignal: null,
44+ label: "Terminal 1",
4445 updatedAt: "2026-04-01T00:00:00.000Z",
4546 };
4647
@@ -161,7 +162,7 @@ describe("createTerminalSessionManager", () => { · 114 unmodified lines
161162 const otherTarget = {
162163 environmentId: EnvironmentId.make("env-remote"),
163164 threadId: ThreadId.make("thread-1"),
164− terminalId: "default",
165+ terminalId: "term-1",
165166 } as const;
166167
167168 for (const target of [TARGET, otherTarget]) {
@@ -181,7 +182,7 @@ describe("createTerminalSessionManager", () => { · 13 unmodified lines
181182 expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote");
182183 });
183184
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)", () => {
185186 const manager = createTerminalSessionManager({
186187 getRegistry: () => atomRegistry,
187188 });
@@ -190,6 +191,19 @@ describe("createTerminalSessionManager", () => { · 2 unmodified lines
190191 {
191192 type: "snapshot",
192193 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+ },
193207 {
194208 threadId: TARGET.threadId,
195209 terminalId: TARGET.terminalId,
@@ -201,6 +215,7 @@ describe("createTerminalSessionManager", () => { · 5 unmodified lines
201215 exitSignal: null,
202216 updatedAt: "2026-04-01T00:00:00.000Z",
203217 hasRunningSubprocess: false,
218+ label: "Terminal 1",
204219 },
205220 {
206221 threadId: TARGET.threadId,
@@ -213,6 +228,7 @@ describe("createTerminalSessionManager", () => { · 6 unmodified lines
213228 exitSignal: null,
214229 updatedAt: "2026-04-01T00:00:02.000Z",
215230 hasRunningSubprocess: false,
231+ label: "Terminal 2",
216232 },
217233 ],
218234 },
@@ -225,7 +241,7 @@ describe("createTerminalSessionManager", () => { · 6 unmodified lines
225241 threadId: TARGET.threadId,
226242 })
227243 .map((session) => session.target.terminalId),
228− ).toEqual(["term-2", "default"]);
244+ ).toEqual(["term-1", "term-2", "term-10"]);
229245 });
230246
231247 it("drops known sessions when an environment is invalidated", () => {
@@ -271,6 +287,7 @@ describe("createTerminalSessionManager", () => { · 39 unmodified lines
271287 exitSignal: null,
272288 updatedAt: BASE_SNAPSHOT.updatedAt,
273289 hasRunningSubprocess: false,
290+ label: "Terminal 1",
274291 },
275292 },
276293 ]);
@@ -384,6 +401,7 @@ describe("createTerminalSessionManager", () => { · 107 unmodified lines
384401 snapshot: {
385402 ...BASE_SNAPSHOT,
386403 terminalId: "term-2",
404+ label: "Terminal 2",
387405 updatedAt: "2026-04-01T00:00:02.000Z",
388406 },
389407 },
@@ -405,6 +423,7 @@ describe("createTerminalSessionManager", () => { · 15 unmodified lines
405423 exitSignal: null,
406424 updatedAt: "2026-04-01T00:00:05.000Z",
407425 hasRunningSubprocess: true,
426+ label: "Terminal 2",
408427 },
409428 ],
410429 },
@@ -452,6 +471,7 @@ describe("createTerminalSessionManager", () => { · 41 unmodified lines
452471 exitSignal: null,
453472 updatedAt: BASE_SNAPSHOT.updatedAt,
454473 hasRunningSubprocess: false,
474+ label: "Terminal 1",
455475 },
456476 },
457477 ]);
@@ -470,6 +490,7 @@ describe("createTerminalSessionManager", () => { · 12 unmodified lines
470490 exitSignal: null,
471491 updatedAt: "2026-04-01T00:00:05.000Z",
472492 hasRunningSubprocess: true,
493+ label: "Terminal 1",
473494 },
474495 },
475496 ]);
@@ -504,6 +525,7 @@ describe("createTerminalSessionManager", () => { · 28 unmodified lines
504525 exitSignal: null,
505526 updatedAt: BASE_SNAPSHOT.updatedAt,
506527 hasRunningSubprocess: true,
528+ label: "Terminal 1",
507529 },
508530 },
509531 ]);
packages/client-runtime/src/terminalSessionState.ts+6−15
@@ -5,7 +5,7 @@ import type { · 4 unmodified lines
55 TerminalSummary,
66 EnvironmentId,
77 } from "@t3tools/contracts";
8−import { DEFAULT_TERMINAL_ID, ThreadId, type TerminalAttachInput } from "@t3tools/contracts";
8+import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts";
99 import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
1010
1111 export interface TerminalSessionState {
@@ -77,7 +77,7 @@ export interface TerminalMetadataClient { · 65 unmodified lines
7777 export interface TerminalAttachClient {
7878 readonly terminal: {
7979 readonly attach: (
80− input: TerminalAttachInput & { readonly terminalId: string },
80+ input: TerminalAttachInput,
8181 listener: (event: TerminalAttachStreamEvent) => void,
8282 options?: { readonly onResubscribe?: () => void },
8383 ) => () => void;
@@ -268,14 +268,9 @@ function listKnownSessionsFromMetadata( · 184 unmodified lines
268268 target,
269269 state: combineSessionState(summary, getBuffer(target)),
270270 }))
271− .sort((left, right) => {
272− const leftUpdatedAt = left.state.updatedAt ? Date.parse(left.state.updatedAt) : 0;
273− const rightUpdatedAt = right.state.updatedAt ? Date.parse(right.state.updatedAt) : 0;
274− if (leftUpdatedAt !== rightUpdatedAt) {
275− return rightUpdatedAt - leftUpdatedAt;
276− }
277− return left.target.terminalId.localeCompare(right.target.terminalId);
278− });
271+ .sort((left, right) =>
272+ left.target.terminalId.localeCompare(right.target.terminalId, undefined, { numeric: true }),
273+ );
279274 }
280275
281276 export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) =>
@@ -569,12 +564,8 @@ export function createTerminalSessionManager(config: TerminalSessionManagerConfi · 287 unmodified lines
569564 readonly onEvent?: (event: TerminalAttachStreamEvent) => void;
570565 readonly options?: { readonly onResubscribe?: () => void };
571566 }): () => void {
572− const terminal = {
573− ...input.terminal,
574− terminalId: input.terminal.terminalId ?? DEFAULT_TERMINAL_ID,
575− };
576567 return input.client.terminal.attach(
577− terminal,
568+ input.terminal,
578569 (event) => {
579570 applyAttachEvent({ environmentId: input.environmentId }, event);
580571 input.onEvent?.(event);
packages/contracts/src/terminal.test.ts+43−9
@@ -31,6 +31,7 @@ describe("TerminalOpenInput", () => { · 30 unmodified lines
3131 expect(
3232 decodes(TerminalOpenInput, {
3333 threadId: "thread-1",
34+ terminalId: DEFAULT_TERMINAL_ID,
3435 cwd: "/tmp/project",
3536 cols: 120,
3637 rows: 40,
@@ -42,6 +43,7 @@ describe("TerminalOpenInput", () => { · 5 unmodified lines
4243 expect(
4344 decodes(TerminalOpenInput, {
4445 threadId: "thread-1",
46+ terminalId: DEFAULT_TERMINAL_ID,
4547 cwd: "/tmp/project",
4648 cols: 10,
4749 rows: 2,
@@ -49,19 +51,21 @@ describe("TerminalOpenInput", () => { · 1 unmodified line
4951 ).toBe(false);
5052 });
5153
52− it("defaults terminalId when missing", () => {
53− const parsed = decodeSync(TerminalOpenInput, {
54− threadId: "thread-1",
55− cwd: "/tmp/project",
56− cols: 100,
57− rows: 24,
58− });
59− expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
54+ it("requires terminalId — the client must always pick an id", () => {
55+ expect(
56+ decodes(TerminalOpenInput, {
57+ threadId: "thread-1",
58+ cwd: "/tmp/project",
59+ cols: 100,
60+ rows: 24,
61+ }),
62+ ).toBe(false);
6063 });
6164
6265 it("accepts optional env overrides", () => {
6366 const parsed = decodeSync(TerminalOpenInput, {
6467 threadId: "thread-1",
68+ terminalId: DEFAULT_TERMINAL_ID,
6569 cwd: "/tmp/project",
6670 worktreePath: "/tmp/project/.t3/worktrees/feature-a",
6771 cols: 100,
@@ -98,6 +102,7 @@ describe("TerminalWriteInput", () => { · 30 unmodified lines
98102 expect(
99103 decodes(TerminalWriteInput, {
100104 threadId: "thread-1",
105+ terminalId: DEFAULT_TERMINAL_ID,
101106 data: "echo hello\n",
102107 }),
103108 ).toBe(true);
@@ -107,10 +112,20 @@ describe("TerminalWriteInput", () => { · 3 unmodified lines
107112 expect(
108113 decodes(TerminalWriteInput, {
109114 threadId: "thread-1",
115+ terminalId: DEFAULT_TERMINAL_ID,
110116 data: "",
111117 }),
112118 ).toBe(false);
113119 });
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+ });
114129 });
115130
116131 describe("TerminalThreadInput", () => {
@@ -125,17 +140,33 @@ describe("TerminalResizeInput", () => { · 8 unmodified lines
125140 expect(
126141 decodes(TerminalResizeInput, {
127142 threadId: "thread-1",
143+ terminalId: DEFAULT_TERMINAL_ID,
128144 cols: 80,
129145 rows: 24,
130146 }),
131147 ).toBe(true);
132148 });
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+ });
133159 });
134160
135161 describe("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", () => {
137167 const parsed = decodeSync(TerminalClearInput, {
138168 threadId: "thread-1",
169+ terminalId: DEFAULT_TERMINAL_ID,
139170 });
140171 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
141172 });
@@ -165,6 +196,7 @@ describe("TerminalSessionSnapshot", () => { · 23 unmodified lines
165196 history: "hello\n",
166197 exitCode: null,
167198 exitSignal: null,
199+ label: "Primary",
168200 updatedAt: new Date().toISOString(),
169201 }),
170202 ).toBe(true);
@@ -212,6 +244,7 @@ describe("TerminalEvent", () => { · 41 unmodified lines
212244 threadId: "thread-1",
213245 terminalId: DEFAULT_TERMINAL_ID,
214246 hasRunningSubprocess: true,
247+ label: "vim",
215248 }),
216249 ).toBe(true);
217250 });
@@ -232,6 +265,7 @@ describe("TerminalEvent", () => { · 14 unmodified lines
232265 history: "",
233266 exitCode: null,
234267 exitSignal: null,
268+ label: "Primary",
235269 updatedAt: new Date().toISOString(),
236270 },
237271 }),
packages/contracts/src/terminal.ts+14−7
@@ -1,7 +1,12 @@
1−import { Effect, Schema } from "effect";
1+import { Schema } from "effect";
22 import { TrimmedNonEmptyString } from "./baseSchemas.ts";
33
4−export 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+ */
9+export const DEFAULT_TERMINAL_ID = "term-1";
510
611 const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
712 const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check(
@@ -19,18 +24,15 @@ const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSc · 11 unmodified lines
1924 Schema.isMaxProperties(128),
2025 );
2126
22−const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(
23− Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_ID)),
24−);
25−
2627 export const TerminalThreadInput = Schema.Struct({
2728 threadId: TrimmedNonEmptyStringSchema,
2829 });
2930 export type TerminalThreadInput = typeof TerminalThreadInput.Type;
3031
32+/** Terminal ids are ALWAYS chosen by the client and sent explicitly — no server-side allocation. */
3133 const TerminalSessionInput = Schema.Struct({
3234 ...TerminalThreadInput.fields,
33− terminalId: TerminalIdWithDefaultSchema,
35+ terminalId: TerminalIdSchema,
3436 });
3537 export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>;
3638
@@ -100,6 +102,8 @@ export const TerminalSessionSnapshot = Schema.Struct({ · 63 unmodified lines
100102 history: Schema.String,
101103 exitCode: Schema.NullOr(Schema.Int),
102104 exitSignal: Schema.NullOr(Schema.Int),
105+ /** Server-computed display title (idle shell vs subprocess command). */
106+ label: Schema.String.check(Schema.isMaxLength(128)),
103107 updatedAt: Schema.String,
104108 sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
105109 });
@@ -115,6 +119,8 @@ export const TerminalSummary = Schema.Struct({ · 9 unmodified lines
115119 exitCode: Schema.NullOr(Schema.Int),
116120 exitSignal: Schema.NullOr(Schema.Int),
117121 hasRunningSubprocess: Schema.Boolean,
122+ /** Server-computed display title (idle shell vs subprocess command). */
123+ label: Schema.String.check(Schema.isMaxLength(128)),
118124 updatedAt: Schema.String,
119125 });
120126 export type TerminalSummary = typeof TerminalSummary.Type;
@@ -193,6 +199,7 @@ const TerminalActivityEvent = Schema.Struct({ · 72 unmodified lines
193199 ...TerminalEventBaseSchema.fields,
194200 type: Schema.Literal("activity"),
195201 hasRunningSubprocess: Schema.Boolean,
202+ label: Schema.String.check(Schema.isMaxLength(128)),
196203 });
197204
198205 export const TerminalEvent = Schema.Union([
packages/shared/package.json+4−0
@@ -83,6 +83,10 @@ · 82 unmodified lines
8383 "./composerTrigger": {
8484 "types": "./src/composerTrigger.ts",
8585 "import": "./src/composerTrigger.ts"
86+ },
87+ "./terminalLabels": {
88+ "types": "./src/terminalLabels.ts",
89+ "import": "./src/terminalLabels.ts"
8690 }
8791 },
8892 "scripts": {
packages/shared/src/terminalLabels.test.ts+54−0
@@ -0,0 +1,54 @@
1+import { describe, expect, it } from "vitest";
2+
3+import type { TerminalSummary } from "@t3tools/contracts";
4+import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts";
5+
6+import { getTerminalLabel, nextTerminalId, resolveTerminalSessionLabel } from "./terminalLabels.ts";
7+
8+describe("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+
21+describe("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+
34+describe("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 @@
1+import type { TerminalSummary } from "@t3tools/contracts";
2+
3+/** Human-readable label for a terminal tab; matches mobile and web sidebars. */
4+export 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`. */
14+export 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+ */
32+export 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+}