authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 20:30:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:30:13-07:00
logcc41482df0232454af0cc5ac2f1afd984e342134
treee074e63429b4a58ae42e3fa83a794a054a7b43ec
parent57b2418d4fbab56290ff4cb861dcd0a7d0f5ef63
signature Signed by PGP key B5690EEEBB952194

feat(auth): allow passive terminal observation (#9791)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

45 files changed, 2563 insertions(+), 321 deletions(-)

apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt+4
......@@ -30,6 +30,10 @@ class T3TerminalModule : Module() {
3030 view.focusRequest = focusRequest
3131 }
3232
33 Prop("readOnly") { view: T3TerminalView, readOnly: Boolean ->
34 view.readOnly = readOnly
35 }
36
3337 Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean ->
3438 view.autoFocus = autoFocus
3539 }
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt+17-4
......@@ -100,6 +100,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
100100 }
101101 }
102102
103 var readOnly: Boolean = false
104 set(value) {
105 field = value
106 inputView.isEnabled = !value
107 if (value) {
108 inputView.clearFocus()
109 hideKeyboard()
110 }
111 }
112
103113 var autoFocus: Boolean = true
104114 set(value) {
105115 field = value
......@@ -238,6 +248,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
238248 InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
239249 inputView.setPadding(0, 0, 0, 0)
240250 inputView.setOnEditorActionListener { _, actionId, event ->
251 if (readOnly) return@setOnEditorActionListener true
241252 val isKeyUp = event?.action == KeyEvent.ACTION_UP
242253 val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp
243254 val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER &&
......@@ -252,6 +263,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
252263 }
253264 }
254265 inputView.setOnKeyListener { _, keyCode, event ->
266 if (readOnly) return@setOnKeyListener true
255267 if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false
256268 when {
257269 keyCode == KeyEvent.KEYCODE_DEL -> {
......@@ -273,11 +285,11 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
273285 override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
274286
275287 override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
288 if (readOnly) return
276289 if (clearingInput || s == null || count <= 0) return
277290 val end = (start + count).coerceAtMost(s.length)
278 if (start >= end) return
279 val insertedText = s.subSequence(start, end).toString()
280 if (insertedText.isNotEmpty()) {
291 if (start < end) {
292 val insertedText = s.subSequence(start, end).toString()
281293 onInput(mapOf("data" to insertedText))
282294 }
283295 }
......@@ -390,12 +402,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
390402 }
391403
392404 private fun emitResponse(response: ByteArray) {
393 if (response.isNotEmpty()) {
405 if (!readOnly && response.isNotEmpty()) {
394406 onInput(mapOf("data" to String(response, Charsets.UTF_8)))
395407 }
396408 }
397409
398410 private fun requestKeyboardFocus() {
411 if (readOnly) return
399412 inputView.requestFocus()
400413 val inputMethodManager = context.getSystemService(
401414 Context.INPUT_METHOD_SERVICE
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift+4
......@@ -27,6 +27,10 @@ public class T3TerminalModule: Module {
2727 view.focusRequest = focusRequest
2828 }
2929
30 Prop("readOnly") { (view: T3TerminalView, readOnly: Bool) in
31 view.readOnly = readOnly
32 }
33
3034 Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in
3135 view.autoFocus = autoFocus
3236 }
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+11-3
......@@ -274,6 +274,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
274274 }
275275 }
276276
277 var readOnly = false {
278 didSet {
279 if readOnly {
280 inputField.resignFirstResponder()
281 }
282 }
283 }
284
277285 var autoFocus = true {
278286 didSet {
279287 guard oldValue != autoFocus else { return }
......@@ -610,7 +618,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
610618 guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return }
611619
612620 DispatchQueue.main.async {
613 view.onInput(["data": input])
621 view.emitInput(input)
614622 }
615623 }, userdata)
616624 }
......@@ -687,13 +695,13 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
687695 }
688696
689697 private func requestKeyboardFocus() {
690 guard window != nil else { return }
698 guard window != nil, !readOnly else { return }
691699 inputField.becomeFirstResponder()
692700 textInputModeDidChange()
693701 }
694702
695703 private func emitInput(_ data: String) {
696 guard !data.isEmpty else { return }
704 guard !readOnly, !data.isEmpty else { return }
697705 onInput(["data": data])
698706 }
699707
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+30-14
......@@ -23,6 +23,7 @@ import {
2323 type TerminalTheme,
2424} from "./terminalTheme";
2525import { terminalDebugLog } from "./terminalDebugLog";
26import { useTerminalSurfaceBuffer } from "./useTerminalSurfaceBuffer";
2627
2728interface TerminalInputEvent {
2829 readonly data: string;
......@@ -35,9 +36,10 @@ interface TerminalResizeEvent {
3536
3637interface TerminalSurfaceProps extends ViewProps {
3738 readonly terminalKey: string;
38 readonly buffer: string;
39 readonly buffer: string | null;
3940 readonly fontSize?: number;
4041 readonly isRunning: boolean;
42 readonly readOnly?: boolean;
4143 readonly autoFocus?: boolean;
4244 readonly keyboardFocusRequest?: number;
4345 readonly captureRequest?: number;
......@@ -47,6 +49,10 @@ interface TerminalSurfaceProps extends ViewProps {
4749 readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;
4850}
4951
52type ReadyTerminalSurfaceProps = Omit<TerminalSurfaceProps, "buffer"> & {
53 readonly buffer: string;
54};
55
5056function estimateGridSize(input: {
5157 readonly width: number;
5258 readonly height: number;
......@@ -60,14 +66,18 @@ function estimateGridSize(input: {
6066 };
6167}
6268
63const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {
69const FallbackTerminalSurface = memo(function FallbackTerminalSurface(
70 props: ReadyTerminalSurfaceProps,
71) {
6472 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
6573 const inputRef = useRef<TextInputInstance>(null);
6674 const { themeAppearance, themeId } = useAppearancePreferences();
6775 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
68 const statusLabel = props.isRunning
69 ? "Native terminal unavailable. Using text fallback."
70 : "Open terminal to start a shell.";
76 const statusLabel = props.readOnly
77 ? "Viewing terminal output."
78 : props.isRunning
79 ? "Native terminal unavailable. Using text fallback."
80 : "Open terminal to start a shell.";
7181
7282 const handleLayout = (event: LayoutChangeEvent) => {
7383 const { width, height } = event.nativeEvent.layout;
......@@ -75,14 +85,14 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
7585 };
7686
7787 useEffect(() => {
78 if ((props.keyboardFocusRequest ?? 0) > 0) {
88 if (!props.readOnly && (props.keyboardFocusRequest ?? 0) > 0) {
7989 inputRef.current?.blur();
8090 const focusFrame = requestAnimationFrame(() => inputRef.current?.focus());
8191 return () => cancelAnimationFrame(focusFrame);
8292 }
8393
8494 return undefined;
85 }, [props.keyboardFocusRequest]);
95 }, [props.keyboardFocusRequest, props.readOnly]);
8696
8797 return (
8898 <View
......@@ -135,7 +145,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
135145 autoCapitalize="none"
136146 autoCorrect={false}
137147 blurOnSubmit={false}
138 editable={props.isRunning}
148 editable={props.isRunning && !props.readOnly}
139149 placeholder="type and press return"
140150 placeholderTextColor={theme.mutedForeground}
141151 returnKeyType="send"
......@@ -155,9 +165,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
155165 }}
156166 />
157167 <Pressable
158 disabled={!props.isRunning}
168 disabled={!props.isRunning || props.readOnly}
159169 style={({ pressed }) => ({
160 opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1,
170 opacity: !props.isRunning || props.readOnly ? 0.35 : pressed ? 0.65 : 1,
161171 paddingHorizontal: 10,
162172 paddingVertical: 6,
163173 borderRadius: 8,
......@@ -175,6 +185,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
175185});
176186
177187export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
188 const buffer = useTerminalSurfaceBuffer(props);
189 return <ReadyTerminalSurface {...props} buffer={buffer} />;
190});
191
192const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerminalSurfaceProps) {
178193 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
179194 const { themeAppearance, themeId } = useAppearancePreferences();
180195 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
......@@ -194,7 +209,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
194209 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
195210 const handleNativeInput = useCallback(
196211 (event: NativeSyntheticEvent<TerminalInputEvent>) => {
197 if (!props.isRunning) {
212 if (!props.isRunning || props.readOnly) {
198213 return;
199214 }
200215 terminalDebugLog("native:onInput", {
......@@ -202,7 +217,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
202217 });
203218 onInput(event.nativeEvent.data);
204219 },
205 [onInput, props.isRunning],
220 [onInput, props.isRunning, props.readOnly],
206221 );
207222 const handleNativeResize = useCallback(
208223 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {
......@@ -219,9 +234,10 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
219234 <View style={props.style}>
220235 <NativeTerminalSurfaceView
221236 appearanceScheme={themeAppearance}
222 autoFocus={props.autoFocus ?? true}
237 autoFocus={!props.readOnly && (props.autoFocus ?? true)}
238 readOnly={props.readOnly ?? false}
223239 backgroundColor={theme.background}
224 focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0}
240 focusRequest={props.isRunning && !props.readOnly ? (props.keyboardFocusRequest ?? 0) : 0}
225241 foregroundColor={theme.foreground}
226242 mutedForegroundColor={theme.mutedForeground}
227243 terminalKey={props.terminalKey}
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+153-137
......@@ -1,4 +1,10 @@
1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
1import {
2 AuthTerminalReadScope,
3 AuthTerminalOperateScope,
4 DEFAULT_TERMINAL_ID,
5 EnvironmentId,
6 ThreadId,
7} from "@t3tools/contracts";
28import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
39import { SymbolView } from "../../components/AppSymbol";
410import { ScreenHeader } from "../../components/ScreenHeader";
......@@ -32,7 +38,10 @@ import { MaterialIconButton } from "../../components/MaterialIconButton";
3238import { environmentCatalog } from "../../connection/catalog";
3339import { useEnvironmentPresentation } from "../../state/presentation";
3440import { terminalEnvironment } from "../../state/terminal";
41import { environmentSession, readEnvironmentScope } from "../../state/session";
42import { useEnvironmentQuery } from "../../state/query";
3543import { useAtomCommand } from "../../state/use-atom-command";
44import { uuidv4 } from "../../lib/uuid";
3645import { useServerConfigs } from "../../state/entities";
3746import { useConnectionsReady } from "../../state/workspace";
3847import {
......@@ -79,10 +88,13 @@ import {
7988} from "./terminalInput";
8089import { createTerminalPasteSession } from "./terminalPaste";
8190import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
91import { useTerminalGridSync } from "./useTerminalGridSync";
92import { useTerminalLifecycle } from "./useTerminalLifecycle";
8293
8394function TerminalHeader(props: {
8495 readonly subtitle: string;
8596 readonly isEnvironmentReady: boolean;
97 readonly canOperateTerminal: boolean;
8698 readonly fontSize: number;
8799 readonly terminalId: string;
88100 readonly sessions: ReadonlyArray<TerminalMenuSession>;
......@@ -149,6 +161,7 @@ function TerminalHeader(props: {
149161 })),
150162 {
151163 id: "terminal-new",
164 disabled: !props.canOperateTerminal,
152165 title: "Open new terminal",
153166 icon: "plus",
154167 subtitle: `Start another shell in ${basename(props.workspaceRoot) ?? "this workspace"}`,
......@@ -260,6 +273,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
260273 ? EnvironmentId.make(routeEnvironmentIdRaw)
261274 : null;
262275 const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null;
276 const terminalSession = useEnvironmentQuery(
277 routeEnvironmentId === null ? null : environmentSession.sessionStateAtom(routeEnvironmentId),
278 );
279 const isAuthenticated =
280 terminalSession.error === null && terminalSession.data?.authenticated === true;
281 const canOperateTerminal =
282 isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalOperateScope) === true;
283 const canReadTerminal =
284 isAuthenticated && terminalSession.data?.scopes?.includes(AuthTerminalReadScope) === true;
263285 const environment = useEnvironmentPresentation(routeEnvironmentId);
264286 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
265287 const requestedTerminalId = firstRouteParam(params.terminalId);
......@@ -283,18 +305,25 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
283305 terminalId,
284306 })
285307 : null;
286 const knownSessions = useKnownTerminalSessions({
308 const {
309 sessions: knownSessions,
310 isPending: sessionsPending,
311 error: sessionsError,
312 } = useKnownTerminalSessions({
287313 environmentId: selectedThread?.environmentId ?? null,
288314 threadId: selectedThread?.id ?? null,
289315 });
290316 const runningSession = useMemo(
291 () => pickRunningTerminalSessionForBootstrap(knownSessions),
292 [knownSessions],
317 () =>
318 pickRunningTerminalSessionForBootstrap(knownSessions ?? []) ??
319 (canOperateTerminal ? null : (knownSessions?.[0] ?? null)),
320 [canOperateTerminal, knownSessions],
293321 );
294322 const activeKnownSession = useMemo(
295 () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null,
323 () => knownSessions?.find((session) => session.target.terminalId === terminalId) ?? null,
296324 [knownSessions, terminalId],
297325 );
326 const hasTerminalTarget = requestedTerminalId !== null || activeKnownSession !== null;
298327 const launchTarget = useMemo(
299328 () =>
300329 selectedThread
......@@ -418,13 +447,28 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
418447 terminalId,
419448 ],
420449 );
450 const observingTerminal =
451 !canOperateTerminal && canReadTerminal && selectedThread !== null && hasTerminalTarget;
421452 const terminal = useAttachedTerminalSession({
422453 environmentId: selectedThread?.environmentId ?? null,
423 terminal: terminalAttachInput,
454 terminal: canOperateTerminal
455 ? terminalAttachInput
456 : observingTerminal
457 ? { threadId: selectedThread.id, terminalId }
458 : null,
424459 });
425460 const terminalKey = selectedThread
426461 ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}`
427462 : terminalId;
463 useTerminalGridSync({
464 environmentId: selectedThread?.environmentId ?? null,
465 threadId: selectedThread?.id ?? null,
466 terminalId,
467 canOperate: canOperateTerminal,
468 terminal,
469 size: lastGridSize,
470 resize: resizeTerminal,
471 });
428472 const bufferReplayKey = useMemo(
429473 () => getTerminalBufferReplayKey({ terminalKey, fontSize }),
430474 [fontSize, terminalKey],
......@@ -439,64 +483,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
439483 });
440484 const isRunning = terminal.status === "running" || terminal.status === "starting";
441485
442 // When the process ends while this screen is attached (e.g. typing `exit`),
443 // close the session and leave the screen, mirroring the web drawer's
444 // onSessionExited flow. Only react to a running -> exited transition
445 // observed on this screen so already-exited sessions can still be opened
446 // (they restart on attach).
447 const runningTerminalKeyRef = useRef<string | null>(null);
448 const reopenedStaleTerminalKeyRef = useRef<string | null>(null);
449486 const pendingExitNavigationRef = useRef<string | null>(null);
450487
451 // Attach subscriptions are cached with an idle TTL, so revisiting a
452 // terminal whose session ended while unobserved reuses the stale stream
453 // without a new attach RPC — the server never respawns anything. Detect
454 // that (dead status with processed events, never seen running here) and
455 // issue an explicit open; its snapshot flows into the live subscription.
456 useEffect(() => {
457 if (isRunning) {
458 reopenedStaleTerminalKeyRef.current = null;
459 return;
460 }
461 if (
462 terminalAttachInput === null ||
463 !selectedThread ||
464 (terminal.status !== "closed" && terminal.status !== "exited") ||
465 terminal.version === 0 ||
466 runningTerminalKeyRef.current === terminalKey ||
467 reopenedStaleTerminalKeyRef.current === terminalKey
468 ) {
469 return;
470 }
471 reopenedStaleTerminalKeyRef.current = terminalKey;
472 void openTerminal({
473 environmentId: selectedThread.environmentId,
474 input: {
475 threadId: selectedThread.id,
476 terminalId,
477 cwd: terminalAttachInput.cwd,
478 worktreePath: terminalAttachInput.worktreePath,
479 cols: terminalAttachInput.cols,
480 rows: terminalAttachInput.rows,
481 ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}),
482 },
483 }).then((result) => {
484 // Release the guard on failure so a later render can retry the respawn.
485 if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) {
486 reopenedStaleTerminalKeyRef.current = null;
487 }
488 });
489 }, [
490 isRunning,
491 openTerminal,
492 selectedThread,
493 terminal.status,
494 terminal.version,
495 terminalAttachInput,
496 terminalId,
497 terminalKey,
498 ]);
499
500488 useEffect(() => {
501489 terminalDebugLog("surface:props", {
502490 terminalKey,
......@@ -596,7 +584,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
596584 height: state.height,
597585 isVisible: state.isVisible,
598586 }));
599 const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed;
587 const isAccessoryVisible = canOperateTerminal && keyboardState.isVisible && !isAccessoryDismissed;
600588 // Android's terminal owns an EditText; turning off autoFocus also clears its native focus.
601589 const terminalAutoFocus =
602590 Platform.OS === "android"
......@@ -624,7 +612,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
624612 const terminalMenuSessions = useMemo<ReadonlyArray<TerminalMenuSession>>(
625613 () =>
626614 buildTerminalMenuSessions({
627 knownSessions,
615 knownSessions: knownSessions ?? [],
628616 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,
629617 currentSession: {
630618 terminalId,
......@@ -707,8 +695,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
707695 useEffect(() => {
708696 const initialInput = pendingLaunch?.initialInput;
709697 if (
698 !canOperateTerminal ||
710699 !initialInput ||
711700 !selectedThread ||
701 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) ||
712702 terminal.version === 0 ||
713703 sentInitialInputKeyRef.current === launchTargetKey
714704 ) {
......@@ -730,6 +720,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
730720 terminal.version,
731721 terminalId,
732722 writeTerminal,
723 canOperateTerminal,
733724 ]);
734725
735726 useEffect(() => {
......@@ -795,7 +786,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
795786 /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */
796787 const writeInput = useCallback(
797788 async (data: string): Promise<boolean> => {
798 if (!selectedThread || !isRunning) {
789 if (
790 !selectedThread ||
791 !isRunning ||
792 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
793 ) {
799794 return false;
800795 }
801796
......@@ -820,11 +815,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
820815
821816 // Drop delayed clipboard reads whenever the route or attached pty changes.
822817 useEffect(() => {
823 pasteSession.reset(isRunning);
818 pasteSession.reset(canOperateTerminal && isRunning);
824819 return () => {
825820 pasteSession.reset(false);
826821 };
827 }, [isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]);
822 }, [canOperateTerminal, isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]);
828823
829824 const pasteFromClipboard = useCallback(async () => {
830825 await pasteSession.paste({
......@@ -896,31 +891,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
896891 }
897892
898893 setLastGridSize(size);
899 if (!selectedThread || !isRunning) {
900 return;
901 }
902
903 void resizeTerminal({
904 environmentId: selectedThread.environmentId,
905 input: {
906 threadId: selectedThread.id,
907 terminalId,
908 cols: size.cols,
909 rows: size.rows,
910 },
911 });
912894 },
913895 [
914 isRunning,
915896 lastGridSize.cols,
916897 lastGridSize.rows,
917898 bufferReplayKey,
918899 readyBufferReplayKey,
919900 routeEnvironmentId,
920901 routeThreadId,
921 resizeTerminal,
922902 scheduleBufferReplayReady,
923 selectedThread,
924903 terminalId,
925904 terminalKey,
926905 ],
......@@ -989,59 +968,54 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
989968 }
990969 }, [navigation, selectedThread, terminalId, terminalMenuSessions]);
991970
992 useEffect(() => {
993 // Detached (hidden surface or environment drop): forget the running
994 // marker so a reattach takes the stale-reopen path instead of misreading
995 // the dead snapshot as an exit observed on this screen. A pending exit
996 // navigation stays armed — it only clears once the session runs again —
997 // so refocusing a dead screen still leaves it.
998 if (terminalAttachInput === null) {
999 runningTerminalKeyRef.current = null;
1000 return;
1001 }
1002 if (isRunning) {
1003 runningTerminalKeyRef.current = terminalKey;
1004 // The session came back (e.g. respawned elsewhere) before the user
1005 // returned; a stale pending exit must not eject a live terminal.
1006 pendingExitNavigationRef.current = null;
1007 return;
1008 }
1009 // The web drawer treats both exited and closed as session end.
1010 const sessionEnded = terminal.status === "exited" || terminal.status === "closed";
1011 if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) {
1012 return;
1013 }
1014 runningTerminalKeyRef.current = null;
1015 // Mark this key handled so the stale-attach effect doesn't respawn the
1016 // session the user just ended.
1017 reopenedStaleTerminalKeyRef.current = terminalKey;
1018 if (selectedThread) {
1019 void closeTerminal({
971 useTerminalLifecycle({
972 terminalKey,
973 canOperate: canOperateTerminal,
974 observing: observingTerminal,
975 attached: terminalAttachInput !== null && selectedThread !== null,
976 terminal,
977 reopen: async () => {
978 if (
979 terminalAttachInput === null ||
980 selectedThread === null ||
981 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
982 )
983 return false;
984 const result = await openTerminal({
1020985 environmentId: selectedThread.environmentId,
1021986 input: {
1022987 threadId: selectedThread.id,
1023988 terminalId,
989 cwd: terminalAttachInput.cwd,
990 worktreePath: terminalAttachInput.worktreePath,
991 cols: terminalAttachInput.cols,
992 rows: terminalAttachInput.rows,
993 ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}),
1024994 },
1025995 });
1026 }
1027 if (navigation.isFocused()) {
1028 navigateAwayAfterExit();
1029 return;
1030 }
1031 // An unfocused screen can't navigate; leave when the user returns so
1032 // they never land on the dead session.
1033 pendingExitNavigationRef.current = terminalKey;
1034 }, [
1035 closeTerminal,
1036 isRunning,
1037 navigateAwayAfterExit,
1038 navigation,
1039 selectedThread,
1040 terminal.status,
1041 terminalAttachInput,
1042 terminalId,
1043 terminalKey,
1044 ]);
996 return result._tag === "Success";
997 },
998 onRunning: () => {
999 pendingExitNavigationRef.current = null;
1000 },
1001 onExit: () => {
1002 if (
1003 selectedThread === null ||
1004 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
1005 )
1006 return;
1007 void closeTerminal({
1008 environmentId: selectedThread.environmentId,
1009 input: { threadId: selectedThread.id, terminalId },
1010 });
1011 if (navigation.isFocused()) {
1012 navigateAwayAfterExit();
1013 return;
1014 }
1015 // Leave a background terminal screen when it is focused again.
1016 pendingExitNavigationRef.current = terminalKey;
1017 },
1018 });
10451019
10461020 useEffect(
10471021 () =>
......@@ -1056,7 +1030,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
10561030 );
10571031
10581032 const handleOpenNewTerminal = useCallback(() => {
1059 if (!selectedThread) {
1033 if (
1034 !selectedThread ||
1035 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
1036 ) {
10601037 return;
10611038 }
10621039
......@@ -1067,10 +1044,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
10671044 terminalId: nextOpenTerminalId({
10681045 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
10691046 activeRouteTerminalId: terminalId,
1047 ...(knownSessions === null ||
1048 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
1049 ? { uniqueSuffix: uuidv4() }
1050 : {}),
10701051 }),
10711052 }),
10721053 );
1073 }, [navigation, selectedThread, terminalId, terminalMenuSessions]);
1054 }, [knownSessions, navigation, selectedThread, terminalId, terminalMenuSessions]);
10741055
10751056 const handleDecreaseFontSize = useCallback(() => {
10761057 setTerminalFontSize(stepTerminalFontSize(fontSize, -1));
......@@ -1081,7 +1062,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
10811062 }, [fontSize, setTerminalFontSize]);
10821063
10831064 const handleClearTerminal = useCallback(() => {
1084 if (!selectedThread) {
1065 if (
1066 !selectedThread ||
1067 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
1068 ) {
10851069 return;
10861070 }
10871071
......@@ -1130,9 +1114,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
11301114 }, []);
11311115
11321116 const handleShowKeyboard = useCallback(() => {
1117 if (!canOperateTerminal) return;
11331118 setIsAccessoryDismissed(false);
11341119 setKeyboardFocusRequest((current) => current + 1);
1135 }, []);
1120 }, [canOperateTerminal]);
11361121 const handleRetryEnvironment = useCallback(() => {
11371122 if (routeEnvironmentId !== null) {
11381123 void retryEnvironment(routeEnvironmentId);
......@@ -1186,6 +1171,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
11861171 />
11871172 ) : null}
11881173 <TerminalHeader
1174 canOperateTerminal={canOperateTerminal}
11891175 subtitle={headerSubtitle}
11901176 isEnvironmentReady={isEnvironmentReady}
11911177 fontSize={fontSize}
......@@ -1232,6 +1218,34 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
12321218 resourceName="terminal"
12331219 onRetry={handleRetryEnvironment}
12341220 />
1221 ) : terminalSession.data === null && terminalSession.error === null ? (
1222 <EmptyState
1223 title="Checking terminal access"
1224 detail="Waiting for this connection's permissions."
1225 />
1226 ) : !canReadTerminal && !canOperateTerminal ? (
1227 <EmptyState
1228 title={
1229 terminalSession.error
1230 ? "Could not check terminal access"
1231 : "Terminal access unavailable"
1232 }
1233 detail={
1234 terminalSession.error ??
1235 "This connection does not have permission to view terminals."
1236 }
1237 />
1238 ) : !canOperateTerminal && !hasTerminalTarget && sessionsPending ? (
1239 <EmptyState title="Loading terminals" detail="Reading existing terminal sessions." />
1240 ) : !canOperateTerminal && !hasTerminalTarget && sessionsError !== null ? (
1241 <EmptyState title="Could not load terminals" detail={sessionsError} />
1242 ) : !canOperateTerminal && !hasTerminalTarget ? (
1243 <EmptyState
1244 title="No terminal sessions"
1245 detail="Existing terminals will appear here when another client opens one."
1246 />
1247 ) : !canOperateTerminal && terminal.error !== null ? (
1248 <EmptyState title="Terminal unavailable" detail={terminal.error} />
12351249 ) : (
12361250 <>
12371251 <View
......@@ -1249,8 +1263,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
12491263 }}
12501264 />
12511265 <TerminalSurface
1252 autoFocus={terminalAutoFocus}
1253 buffer={terminalSurfaceBuffer}
1266 autoFocus={canOperateTerminal && terminalAutoFocus}
1267 readOnly={!canOperateTerminal}
1268 buffer={terminal.version === 0 ? null : terminalSurfaceBuffer}
12541269 fontSize={fontSize}
12551270 isRunning={isRunning}
12561271 keyboardFocusRequest={keyboardFocusRequest}
......@@ -1279,6 +1294,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
12791294 <View className="flex-1" />
12801295 <MaterialIconButton
12811296 accessibilityLabel="Show keyboard"
1297 disabled={!canOperateTerminal}
12821298 icon="keyboard"
12831299 onPress={handleShowKeyboard}
12841300 />
......@@ -1345,7 +1361,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
13451361 </ComposerToolbarRow>
13461362 </View>
13471363 </KeyboardStickyView>
1348 ) : !keyboardState.isVisible && Platform.OS !== "android" ? (
1364 ) : canOperateTerminal && !keyboardState.isVisible && Platform.OS !== "android" ? (
13491365 <Pressable
13501366 accessibilityLabel="Show keyboard"
13511367 accessibilityRole="button"
apps/mobile/src/features/terminal/nativeTerminalModule.ts+1
......@@ -24,6 +24,7 @@ interface TerminalResizeEvent {
2424export interface NativeTerminalSurfaceProps extends ViewProps {
2525 readonly appearanceScheme?: "light" | "dark";
2626 readonly autoFocus?: boolean;
27 readonly readOnly?: boolean;
2728 readonly focusRequest?: number;
2829 readonly captureRequest?: number;
2930 readonly onCapture?: (event: NativeSyntheticEvent<{ readonly text: string }>) => void;
apps/mobile/src/features/terminal/terminalMenu.test.ts+23
......@@ -129,6 +129,19 @@ describe("buildTerminalMenuSessions", () => {
129129});
130130
131131describe("nextOpenTerminalId", () => {
132 it("allocates separate terminals on repeated visits without readable metadata", () => {
133 const first = nextOpenTerminalId({
134 listedTerminalIds: [],
135 uniqueSuffix: "783c91cc-a413-47c7-8312-c2a5a1f05e40",
136 });
137 const nextVisit = nextOpenTerminalId({
138 listedTerminalIds: [],
139 uniqueSuffix: "102315fc-ceef-45c4-b978-c4d4947d3c26",
140 });
141 expect(first).not.toBe(DEFAULT_TERMINAL_ID);
142 expect(nextVisit).not.toBe(first);
143 });
144
132145 it("matches nextTerminalId when not on a terminal route", () => {
133146 expect(nextOpenTerminalId({ listedTerminalIds: [] })).toBe(DEFAULT_TERMINAL_ID);
134147 expect(nextOpenTerminalId({ listedTerminalIds: [DEFAULT_TERMINAL_ID] })).toBe("term-2");
......@@ -208,6 +221,16 @@ describe("previousLiveTerminalId", () => {
208221});
209222
210223describe("resolveProjectScriptTerminalId", () => {
224 it("never targets an unseen default terminal when metadata is unavailable", () => {
225 const terminalId = resolveProjectScriptTerminalId({
226 existingTerminalIds: [],
227 hasRunningTerminal: false,
228 uniqueSuffix: "783c91cc-a413-47c7-8312-c2a5a1f05e40",
229 });
230 expect(terminalId).not.toBe(DEFAULT_TERMINAL_ID);
231 expect(getTerminalLabel(terminalId)).toBe("Terminal 1");
232 });
233
211234 it("reuses the default shell when no terminal is running", () => {
212235 expect(
213236 resolveProjectScriptTerminalId({
apps/mobile/src/features/terminal/terminalMenu.ts+6-4
......@@ -70,15 +70,16 @@ export function getTerminalStatusLabel(input: {
7070export function nextOpenTerminalId(input: {
7171 readonly listedTerminalIds: ReadonlyArray<string>;
7272 readonly activeRouteTerminalId?: string | null;
73 readonly uniqueSuffix?: string;
7374}): string {
7475 const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0);
7576 const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null;
7677
7778 if (!routeId || listed.includes(routeId)) {
78 return nextTerminalId(listed);
79 return nextTerminalId(listed, input.uniqueSuffix);
7980 }
8081
81 return nextTerminalId([...listed, routeId]);
82 return nextTerminalId([...listed, routeId], input.uniqueSuffix);
8283}
8384
8485export function buildTerminalMenuSessions(input: {
......@@ -146,12 +147,13 @@ export function previousLiveTerminalId(input: {
146147export function resolveProjectScriptTerminalId(input: {
147148 readonly existingTerminalIds: ReadonlyArray<string>;
148149 readonly hasRunningTerminal: boolean;
150 readonly uniqueSuffix?: string;
149151}): string {
150 if (!input.hasRunningTerminal) {
152 if (!input.hasRunningTerminal && input.uniqueSuffix === undefined) {
151153 return DEFAULT_TERMINAL_ID;
152154 }
153155
154 return nextTerminalId(input.existingTerminalIds);
156 return nextTerminalId(input.existingTerminalIds, input.uniqueSuffix);
155157}
156158
157159export function projectScriptMenuLabel(script: ProjectScript): string {
apps/mobile/src/features/terminal/useTerminalGridSync.test.ts created+183
......@@ -0,0 +1,183 @@
1import * as NodeModule from "node:module";
2import { EMPTY_TERMINAL_BUFFER_STATE } from "@t3tools/client-runtime/state/terminal";
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import { act, createElement, type ReactNode } from "react";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7import { useTerminalGridSync } from "./useTerminalGridSync";
8
9const access = vi.hoisted(() => ({ environments: new Set<string>() }));
10vi.mock("../../state/session", () => ({
11 readEnvironmentScope: (environmentId: string, scope: string) =>
12 scope === "terminal:operate" && access.environments.has(environmentId),
13}));
14
15// Mobile already depends on ReactDOM but does not install its browser type declarations.
16const { createRoot } = NodeModule.createRequire(import.meta.url)("react-dom/client") as {
17 createRoot(container: Element): {
18 render(children: ReactNode): void;
19 unmount(): void;
20 };
21};
22
23type Input = Parameters<typeof useTerminalGridSync>[0];
24let root: ReturnType<typeof createRoot>;
25const resize = vi.fn<Input["resize"]>();
26
27function GridProbe(input: Input) {
28 useTerminalGridSync(input);
29 return null;
30}
31
32function session({
33 generation = 1,
34 version = 1,
35 status = "running",
36}: {
37 readonly generation?: number;
38 readonly version?: number;
39 readonly status?: Input["terminal"]["status"];
40} = {}): Input["terminal"] {
41 return { output: { ...EMPTY_TERMINAL_BUFFER_STATE.output, generation }, version, status };
42}
43
44function input(): Input {
45 return {
46 environmentId: EnvironmentId.make("environment"),
47 threadId: ThreadId.make("thread"),
48 terminalId: "term-1",
49 canOperate: true,
50 terminal: session(),
51 size: { cols: 80, rows: 24 },
52 resize,
53 };
54}
55
56function render(value: Input) {
57 return act(() => root.render(createElement(GridProbe, value)));
58}
59
60beforeEach(() => {
61 resize.mockClear();
62 access.environments.clear();
63 access.environments.add("environment");
64 // The probe renders no DOM, but ReactDOM needs an event target to run real effects.
65 const document = {
66 nodeType: 9,
67 addEventListener() {},
68 removeEventListener() {},
69 };
70 const container = {
71 nodeType: 1,
72 tagName: "DIV",
73 namespaceURI: "http://www.w3.org/1999/xhtml",
74 ownerDocument: document,
75 addEventListener() {},
76 removeEventListener() {},
77 };
78 vi.stubGlobal("document", document);
79 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
80 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
81 root = createRoot(container as unknown as HTMLElement);
82});
83
84it("checks the current target grant before dispatching a delayed resize", async () => {
85 const current = input();
86 await render(current);
87 expect(resize).toHaveBeenCalledOnce();
88
89 access.environments.clear();
90 await render({ ...current, size: { cols: 120, rows: 40 } });
91 expect(resize).toHaveBeenCalledOnce();
92
93 access.environments.add("secondary");
94 await render({ ...current, size: { cols: 120, rows: 41 } });
95 expect(resize).toHaveBeenCalledOnce();
96
97 await render({ ...current, environmentId: EnvironmentId.make("secondary") });
98 expect(resize).toHaveBeenCalledTimes(2);
99 expect(resize).toHaveBeenLastCalledWith({
100 environmentId: "secondary",
101 input: { threadId: "thread", terminalId: "term-1", cols: 80, rows: 24 },
102 });
103});
104
105afterEach(async () => {
106 await act(() => root.unmount());
107 vi.unstubAllGlobals();
108});
109
110it("replays an observer's measured grid once its writable attachment snapshot arrives", async () => {
111 let current = { ...input(), canOperate: false };
112 await render(current);
113 current = { ...current, size: { cols: 120, rows: 40 } };
114 await render(current);
115 expect(resize).not.toHaveBeenCalled();
116
117 current = {
118 ...current,
119 canOperate: true,
120 terminal: session({ generation: 2, version: 0 }),
121 };
122 await render(current);
123 expect(resize).not.toHaveBeenCalled();
124
125 current = { ...current, terminal: session({ generation: 2 }) };
126 await render(current);
127 expect(resize).toHaveBeenCalledExactlyOnceWith({
128 environmentId: "environment",
129 input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 40 },
130 });
131
132 await render({ ...current, terminal: session({ generation: 2, version: 3 }) });
133 await render({ ...current, size: { ...current.size } });
134 expect(resize).toHaveBeenCalledOnce();
135
136 await render({ ...current, size: { cols: 120, rows: 41 } });
137 expect(resize).toHaveBeenCalledTimes(2);
138 expect(resize).toHaveBeenLastCalledWith({
139 environmentId: "environment",
140 input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 41 },
141 });
142});
143
144it("replays an unchanged grid after reconnecting with a new attachment generation", async () => {
145 const current = { ...input(), size: { cols: 120, rows: 40 } };
146 await render(current);
147 expect(resize).toHaveBeenCalledOnce();
148
149 await render({ ...current, terminal: session({ generation: 2, version: 0 }) });
150 expect(resize).toHaveBeenCalledOnce();
151
152 await render({ ...current, terminal: session({ generation: 2 }) });
153 expect(resize).toHaveBeenCalledTimes(2);
154 expect(resize).toHaveBeenLastCalledWith({
155 environmentId: "environment",
156 input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 40 },
157 });
158
159 // React may observe the next snapshot without rendering the empty seed first.
160 await render({ ...current, terminal: session({ generation: 3 }) });
161 expect(resize).toHaveBeenCalledTimes(3);
162});
163
164it.each([
165 { name: "permission is revoked", change: { canOperate: false } },
166 { name: "the attachment is pending", change: { terminal: session({ version: 0 }) } },
167 { name: "the process has exited", change: { terminal: session({ status: "exited" }) } },
168])("retains measurements without resizing while $name", async ({ change }) => {
169 const current = input();
170 await render(current);
171 expect(resize).toHaveBeenCalledOnce();
172
173 await render({ ...current, ...change, size: { cols: 120, rows: 40 } });
174 await render({ ...current, ...change, size: { cols: 120, rows: 41 } });
175 expect(resize).toHaveBeenCalledOnce();
176
177 await render({ ...current, size: { cols: 120, rows: 41 } });
178 expect(resize).toHaveBeenCalledTimes(2);
179 expect(resize).toHaveBeenLastCalledWith({
180 environmentId: "environment",
181 input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 41 },
182 });
183});
apps/mobile/src/features/terminal/useTerminalGridSync.ts created+52
......@@ -0,0 +1,52 @@
1import type { TerminalSessionState } from "@t3tools/client-runtime/state/terminal";
2import {
3 AuthTerminalOperateScope,
4 type EnvironmentId,
5 type TerminalResizeInput,
6 type ThreadId,
7} from "@t3tools/contracts";
8import { useEffect } from "react";
9
10import type { TerminalGridSize } from "./terminalUiState";
11import { readEnvironmentScope } from "../../state/session";
12
13/** Replay the measured grid when a writable attachment becomes ready or reconnects. */
14export function useTerminalGridSync({
15 environmentId,
16 threadId,
17 terminalId,
18 canOperate,
19 terminal,
20 size,
21 resize,
22}: {
23 readonly environmentId: EnvironmentId | null;
24 readonly threadId: ThreadId | null;
25 readonly terminalId: string;
26 readonly canOperate: boolean;
27 readonly terminal: Pick<TerminalSessionState, "output" | "status" | "version">;
28 readonly size: TerminalGridSize;
29 readonly resize: (target: {
30 readonly environmentId: EnvironmentId;
31 readonly input: TerminalResizeInput;
32 }) => void;
33}): void {
34 const generation =
35 canOperate && terminal.version > 0 && terminal.status === "running"
36 ? terminal.output.generation
37 : null;
38
39 useEffect(() => {
40 if (
41 generation === null ||
42 environmentId === null ||
43 threadId === null ||
44 !readEnvironmentScope(environmentId, AuthTerminalOperateScope)
45 )
46 return;
47 resize({
48 environmentId,
49 input: { threadId, terminalId, cols: size.cols, rows: size.rows },
50 });
51 }, [environmentId, generation, resize, size.cols, size.rows, terminalId, threadId]);
52}
apps/mobile/src/features/terminal/useTerminalLifecycle.test.ts created+177
......@@ -0,0 +1,177 @@
1import * as NodeModule from "node:module";
2import { act, createElement, type ReactNode } from "react";
3import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
4
5import { useTerminalLifecycle } from "./useTerminalLifecycle";
6
7const { createRoot } = NodeModule.createRequire(import.meta.url)("react-dom/client") as {
8 createRoot(container: Element): {
9 render(children: ReactNode): void;
10 unmount(): void;
11 };
12};
13
14type Input = Parameters<typeof useTerminalLifecycle>[0];
15let root: ReturnType<typeof createRoot>;
16const reopen = vi.fn<Input["reopen"]>();
17const onRunning = vi.fn<Input["onRunning"]>();
18const onExit = vi.fn<Input["onExit"]>();
19
20function LifecycleProbe(input: Input) {
21 useTerminalLifecycle(input);
22 return null;
23}
24
25function input(changes: Partial<Input> = {}): Input {
26 return {
27 terminalKey: "environment:thread:term-1",
28 canOperate: true,
29 observing: false,
30 attached: true,
31 terminal: { status: "running", version: 1 },
32 reopen,
33 onRunning,
34 onExit,
35 ...changes,
36 };
37}
38
39function render(value: Input) {
40 return act(() => root.render(createElement(LifecycleProbe, value)));
41}
42
43beforeEach(() => {
44 reopen.mockReset().mockResolvedValue(true);
45 onRunning.mockClear();
46 onExit.mockClear();
47 const document = {
48 nodeType: 9,
49 addEventListener() {},
50 removeEventListener() {},
51 };
52 const container = {
53 nodeType: 1,
54 tagName: "DIV",
55 namespaceURI: "http://www.w3.org/1999/xhtml",
56 ownerDocument: document,
57 addEventListener() {},
58 removeEventListener() {},
59 };
60 vi.stubGlobal("document", document);
61 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
62 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
63 root = createRoot(container as unknown as HTMLElement);
64});
65
66afterEach(async () => {
67 await act(() => root.unmount());
68 vi.unstubAllGlobals();
69});
70
71it.each(["running", "closed", "exited"] as const)(
72 "waits for the replacement attachment snapshot before handling %s",
73 async (status) => {
74 await render(input());
75 await render(input({ terminal: { status: "closed", version: 0 } }));
76 expect(onExit).not.toHaveBeenCalled();
77 expect(reopen).not.toHaveBeenCalled();
78
79 await render(input({ terminal: { status, version: 1 } }));
80 expect(onExit).toHaveBeenCalledTimes(status === "running" ? 0 : 1);
81 expect(reopen).not.toHaveBeenCalled();
82 },
83);
84
85it("retains an exited observer's history after gaining operate", async () => {
86 await render(input({ canOperate: false, terminal: { status: "exited", version: 1 } }));
87 await render(input({ terminal: { status: "closed", version: 0 } }));
88 await render(input({ terminal: { status: "exited", version: 1 } }));
89 expect(reopen).not.toHaveBeenCalled();
90 expect(onExit).not.toHaveBeenCalled();
91});
92
93it.each(["closed", "exited"] as const)(
94 "keeps a pending observer passive when its first writable snapshot is %s",
95 async (status) => {
96 await render(
97 input({
98 canOperate: false,
99 observing: true,
100 terminal: { status, version: 0 },
101 }),
102 );
103 await render(input({ terminal: { status: "closed", version: 0 } }));
104 await render(input({ terminal: { status, version: 1 } }));
105 expect(reopen).not.toHaveBeenCalled();
106 expect(onExit).not.toHaveBeenCalled();
107 expect(onRunning).not.toHaveBeenCalled();
108 },
109);
110
111it("reopens an explicit operator visit after permissions finish loading", async () => {
112 await render(
113 input({
114 canOperate: false,
115 observing: false,
116 terminal: { status: "closed", version: 0 },
117 }),
118 );
119 await render(input({ terminal: { status: "closed", version: 0 } }));
120 await render(input({ terminal: { status: "exited", version: 1 } }));
121 expect(reopen).toHaveBeenCalledOnce();
122 expect(onExit).not.toHaveBeenCalled();
123});
124
125it("does not respawn a terminal that exited after operate was revoked", async () => {
126 await render(input());
127 await render(input({ canOperate: false }));
128 await render(input({ canOperate: false, terminal: { status: "exited", version: 2 } }));
129 await render(input({ terminal: { status: "exited", version: 1 } }));
130 expect(reopen).not.toHaveBeenCalled();
131 expect(onExit).not.toHaveBeenCalled();
132});
133
134it("remembers revocation before the observer has received a snapshot", async () => {
135 await render(input());
136 await render(input({ canOperate: false, terminal: { status: "closed", version: 0 } }));
137 await render(input({ terminal: { status: "exited", version: 1 } }));
138 expect(reopen).not.toHaveBeenCalled();
139 expect(onExit).not.toHaveBeenCalled();
140});
141
142it("cleans up an actual exit after an observed running terminal becomes writable", async () => {
143 await render(input({ canOperate: false }));
144 await render(input());
145 await render(input({ terminal: { status: "exited", version: 2 } }));
146 await render(input({ terminal: { status: "closed", version: 3 } }));
147 expect(onExit).toHaveBeenCalledOnce();
148 expect(reopen).not.toHaveBeenCalled();
149});
150
151it("reopens an ended terminal on an explicit new route visit", async () => {
152 await render(input({ canOperate: false, terminal: { status: "exited", version: 1 } }));
153 await render(input({ terminalKey: "environment:thread:term-2" }));
154 await render(input({ terminal: { status: "exited", version: 1 } }));
155 expect(reopen).toHaveBeenCalledOnce();
156
157 await render(input({ terminal: { status: "exited", version: 2 } }));
158 expect(reopen).toHaveBeenCalledOnce();
159 await render(input({ terminal: { status: "running", version: 3 } }));
160 await render(input({ terminal: { status: "exited", version: 4 } }));
161 expect(onExit).toHaveBeenCalledOnce();
162});
163
164it("keeps a detached terminal from being mistaken for a live exit", async () => {
165 await render(input());
166 await render(input({ attached: false, terminal: { status: "closed", version: 0 } }));
167 await render(input({ terminal: { status: "exited", version: 1 } }));
168 expect(onExit).not.toHaveBeenCalled();
169 expect(reopen).toHaveBeenCalledOnce();
170});
171
172it("allows a later snapshot to retry an unsuccessful explicit reopen", async () => {
173 reopen.mockResolvedValueOnce(false);
174 await render(input({ terminal: { status: "exited", version: 1 } }));
175 await render(input({ terminal: { status: "exited", version: 2 } }));
176 expect(reopen).toHaveBeenCalledTimes(2);
177});
apps/mobile/src/features/terminal/useTerminalLifecycle.ts created+79
......@@ -0,0 +1,79 @@
1import type { TerminalSessionState } from "@t3tools/client-runtime/state/terminal";
2import { useEffect, useEffectEvent, useRef } from "react";
3
4/** Keep observed history passive while preserving explicit terminal visits and live exits. */
5export function useTerminalLifecycle({
6 terminalKey,
7 canOperate,
8 observing,
9 attached,
10 terminal,
11 reopen,
12 onRunning,
13 onExit,
14}: {
15 readonly terminalKey: string;
16 readonly canOperate: boolean;
17 readonly observing: boolean;
18 readonly attached: boolean;
19 readonly terminal: Pick<TerminalSessionState, "status" | "version">;
20 readonly reopen: () => Promise<boolean>;
21 readonly onRunning: () => void;
22 readonly onExit: () => void;
23}) {
24 const lifecycle = useRef({
25 key: terminalKey,
26 wasRunning: false,
27 observed: false,
28 reopened: false,
29 });
30 const reopenTerminal = useEffectEvent(reopen);
31 const handleRunning = useEffectEvent(onRunning);
32 const handleExit = useEffectEvent(onExit);
33
34 useEffect(() => {
35 if (lifecycle.current.key !== terminalKey) {
36 lifecycle.current = {
37 key: terminalKey,
38 wasRunning: false,
39 observed: false,
40 reopened: false,
41 };
42 }
43 const current = lifecycle.current;
44 if (!canOperate) {
45 // An observe request stays passive before its first snapshot arrives.
46 if (observing || terminal.version > 0 || current.wasRunning) current.observed = true;
47 current.wasRunning = false;
48 return;
49 }
50 if (!attached) {
51 current.wasRunning = false;
52 return;
53 }
54 // An attachment without its first snapshot has no known process status yet.
55 if (terminal.version === 0) return;
56
57 if (terminal.status === "running" || terminal.status === "starting") {
58 current.wasRunning = true;
59 current.reopened = false;
60 handleRunning();
61 return;
62 }
63 if (terminal.status !== "closed" && terminal.status !== "exited") return;
64
65 if (current.wasRunning) {
66 current.wasRunning = false;
67 current.reopened = true;
68 handleExit();
69 return;
70 }
71 // An explicit visit can reopen a cached ended session. Granting a viewer
72 // operate permission must leave the history they were reading intact.
73 if (current.observed || current.reopened) return;
74 current.reopened = true;
75 void reopenTerminal().then((succeeded) => {
76 if (!succeeded && lifecycle.current === current) current.reopened = false;
77 });
78 }, [attached, canOperate, observing, terminal.status, terminal.version, terminalKey]);
79}
apps/mobile/src/features/terminal/useTerminalSurfaceBuffer.test.ts created+100
......@@ -0,0 +1,100 @@
1import * as NodeModule from "node:module";
2import { act, createElement, useEffect, type ReactNode } from "react";
3import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
4
5import { useTerminalSurfaceBuffer } from "./useTerminalSurfaceBuffer";
6
7const { createRoot } = NodeModule.createRequire(import.meta.url)("react-dom/client") as {
8 createRoot(container: Element): {
9 render(children: ReactNode): void;
10 unmount(): void;
11 };
12};
13
14type Input = Parameters<typeof useTerminalSurfaceBuffer>[0] & { readonly readOnly: boolean };
15let root: ReturnType<typeof createRoot>;
16const displayed = vi.fn<(value: { buffer: string; readOnly: boolean }) => void>();
17
18function BufferProbe(input: Input) {
19 const buffer = useTerminalSurfaceBuffer(input);
20 useEffect(() => {
21 displayed({ buffer, readOnly: input.readOnly });
22 }, [buffer, input.readOnly]);
23 return null;
24}
25
26function render(changes: Partial<Input> = {}) {
27 return act(() =>
28 root.render(
29 createElement(BufferProbe, {
30 terminalKey: "environment:thread:term-1",
31 buffer: "host output",
32 readOnly: false,
33 ...changes,
34 }),
35 ),
36 );
37}
38
39beforeEach(() => {
40 displayed.mockClear();
41 const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} };
42 const container = {
43 nodeType: 1,
44 tagName: "DIV",
45 namespaceURI: "http://www.w3.org/1999/xhtml",
46 ownerDocument: document,
47 addEventListener() {},
48 removeEventListener() {},
49 };
50 vi.stubGlobal("document", document);
51 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
52 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
53 root = createRoot(container as unknown as HTMLElement);
54});
55
56afterEach(async () => {
57 await act(() => root.unmount());
58 vi.unstubAllGlobals();
59});
60
61it("preserves displayed history while permissions change before the next snapshot", async () => {
62 await render();
63 await render({ buffer: null, readOnly: true });
64 expect(displayed).toHaveBeenLastCalledWith({ buffer: "host output", readOnly: true });
65
66 await render({ buffer: "host output\nobserved output", readOnly: true });
67 await render({ buffer: null });
68 expect(displayed).toHaveBeenLastCalledWith({
69 buffer: "host output\nobserved output",
70 readOnly: false,
71 });
72 expect(displayed.mock.calls.every(([value]) => value.buffer.length > 0)).toBe(true);
73});
74
75it("still applies an intentional empty buffer", async () => {
76 await render();
77 await render({ buffer: null });
78 expect(displayed).toHaveBeenCalledTimes(1);
79 await render({ buffer: "" });
80 expect(displayed).toHaveBeenLastCalledWith({ buffer: "", readOnly: false });
81});
82
83it.each([
84 "secondary:thread:term-1",
85 "environment:another-thread:term-1",
86 "environment:thread:term-2",
87])("does not carry history into another terminal target (%s)", async (terminalKey) => {
88 await render();
89 await render({ terminalKey, buffer: null });
90 expect(displayed).toHaveBeenLastCalledWith({ buffer: "", readOnly: false });
91 await render({ terminalKey, buffer: "new target" });
92 expect(displayed).toHaveBeenLastCalledWith({ buffer: "new target", readOnly: false });
93});
94
95it("starts empty until its first snapshot arrives", async () => {
96 await render({ buffer: null });
97 expect(displayed).toHaveBeenLastCalledWith({ buffer: "", readOnly: false });
98 await render();
99 expect(displayed).toHaveBeenLastCalledWith({ buffer: "host output", readOnly: false });
100});
apps/mobile/src/features/terminal/useTerminalSurfaceBuffer.ts created+17
......@@ -0,0 +1,17 @@
1import { useState } from "react";
2
3/** A null buffer means the attachment is waiting for its first snapshot. */
4export function useTerminalSurfaceBuffer({
5 terminalKey,
6 buffer,
7}: {
8 readonly terminalKey: string;
9 readonly buffer: string | null;
10}): string {
11 const [snapshot, setSnapshot] = useState({ terminalKey, buffer: buffer ?? "" });
12 const currentBuffer = buffer ?? (snapshot.terminalKey === terminalKey ? snapshot.buffer : "");
13 if (snapshot.terminalKey !== terminalKey || snapshot.buffer !== currentBuffer) {
14 setSnapshot({ terminalKey, buffer: currentBuffer });
15 }
16 return currentBuffer;
17}
apps/mobile/src/features/threads/ThreadGitControls.tsx+6
......@@ -102,6 +102,7 @@ type ThreadGitControlsProps = ThreadGitMenuProps & {
102102 readonly onPress: () => void;
103103 };
104104 readonly canOpenTerminal: boolean;
105 readonly canOperateTerminal: boolean;
105106 readonly canOpenFiles: boolean;
106107 readonly projectScripts: ReadonlyArray<ProjectScript>;
107108 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
......@@ -287,6 +288,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
287288 items: [
288289 ...props.projectScripts.map((script) => ({
289290 description: script.command,
291 disabled: !props.canOperateTerminal,
290292 icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const },
291293 label: projectScriptMenuLabel(script),
292294 onPress: () => void props.onRunProjectScript(script),
......@@ -321,6 +323,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
321323 })),
322324 {
323325 description: "Start another shell for this thread",
326 disabled: !props.canOperateTerminal,
324327 icon: { name: "plus", type: "sfSymbol" },
325328 label: "Open new terminal",
326329 onPress: props.onOpenNewTerminal,
......@@ -417,6 +420,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
417420 model.runQuickAction,
418421 props.canOpenFiles,
419422 props.canOpenTerminal,
423 props.canOperateTerminal,
420424 props.gitStatus,
421425 props.onMergeBack,
422426 props.onOpenNewTerminal,
......@@ -474,6 +478,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
474478 <NativeHeaderToolbar.MenuAction
475479 key={script.id}
476480 icon={projectScriptMenuIcon(script.icon)}
481 disabled={!props.canOperateTerminal}
477482 onPress={() => void props.onRunProjectScript(script)}
478483 subtitle={script.command}
479484 >
......@@ -512,6 +517,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
512517 ))}
513518 <NativeHeaderToolbar.MenuAction
514519 icon="plus"
520 disabled={!props.canOperateTerminal}
515521 onPress={props.onOpenNewTerminal}
516522 subtitle="Start another shell for this thread"
517523 >
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+52-10
......@@ -16,12 +16,13 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
1616import * as Option from "effect/Option";
1717import {
1818 AuthOrchestrationOperateScope,
19 AuthTerminalOperateScope,
20 AuthTerminalReadScope,
1921 DEFAULT_SERVER_SETTINGS,
2022 EnvironmentId,
2123 ThreadId,
2224 type ProjectScript,
2325} from "@t3tools/contracts";
24import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
2526import {
2627 projectScriptCwd,
2728 projectScriptRuntimeEnv,
......@@ -48,6 +49,8 @@ import {
4849 useRemoteEnvironmentRuntime,
4950} from "../../state/use-remote-environment-registry";
5051import { useKnownTerminalSessions } from "../../state/use-terminal-session";
52import { uuidv4 } from "../../lib/uuid";
53import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
5154import { useSelectedThreadDetailState } from "../../state/use-thread-detail";
5255import { useThreadSelection } from "../../state/use-thread-selection";
5356import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
......@@ -119,7 +122,7 @@ function ThreadHeader(
119122 onPress: filesVisible ? toggleAuxiliaryPane : props.onOpenFilesInspector,
120123 });
121124 }
122 if (props.hasWorkspaceRoot) {
125 if (props.hasWorkspaceRoot && props.gitControls.canOpenTerminal) {
123126 actions.push({
124127 accessibilityLabel: "Open terminal",
125128 icon: "terminal",
......@@ -150,6 +153,7 @@ function ThreadHeader(
150153 props.onReturnToThread,
151154 props.hasThreadCwd,
152155 props.hasWorkspaceRoot,
156 props.gitControls.canOpenTerminal,
153157 ]);
154158
155159 return (
......@@ -339,6 +343,14 @@ function ThreadRouteContent(
339343 selectedThread?.environmentId ?? null,
340344 AuthOrchestrationOperateScope,
341345 );
346 const canReadTerminal = useEnvironmentScope(
347 selectedThread?.environmentId ?? null,
348 AuthTerminalReadScope,
349 );
350 const canOperateTerminal = useEnvironmentScope(
351 selectedThread?.environmentId ?? null,
352 AuthTerminalOperateScope,
353 );
342354 const selectedThreadDetailState = props.selectedThreadDetailState;
343355 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
344356 const { selectedThreadCwd } = useSelectedThreadWorktree();
......@@ -504,14 +516,14 @@ function ThreadRouteContent(
504516 })
505517 : null,
506518 );
507 const knownTerminalSessions = useKnownTerminalSessions({
519 const { sessions: knownTerminalSessions } = useKnownTerminalSessions({
508520 environmentId: selectedThread?.environmentId ?? null,
509521 threadId: selectedThread?.id ?? null,
510522 });
511523 const terminalMenuSessions = useMemo(
512524 () =>
513525 buildTerminalMenuSessions({
514 knownSessions: knownTerminalSessions,
526 knownSessions: knownTerminalSessions ?? [],
515527 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,
516528 }),
517529 [knownTerminalSessions, selectedThreadProject?.workspaceRoot],
......@@ -703,7 +715,12 @@ function ThreadRouteContent(
703715 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
704716 });
705717
706 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
718 if (
719 !selectedThread ||
720 !selectedThreadProject?.workspaceRoot ||
721 (!readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope) &&
722 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope))
723 ) {
707724 return;
708725 }
709726
......@@ -723,19 +740,33 @@ function ThreadRouteContent(
723740 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
724741 });
725742
726 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
743 if (
744 !selectedThread ||
745 !selectedThreadProject?.workspaceRoot ||
746 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
747 ) {
727748 return;
728749 }
729750
730751 const nextId = nextOpenTerminalId({
731752 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
753 ...(knownTerminalSessions === null ||
754 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
755 ? { uniqueSuffix: uuidv4() }
756 : {}),
732757 });
733758 void navigation.navigate("ThreadTerminal", {
734759 environmentId: String(selectedThread.environmentId),
735760 threadId: String(selectedThread.id),
736761 terminalId: nextId,
737762 });
738 }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);
763 }, [
764 knownTerminalSessions,
765 navigation,
766 selectedThread,
767 selectedThreadProject?.workspaceRoot,
768 terminalMenuSessions,
769 ]);
739770
740771 const handleRunProjectScript = useCallback(
741772 async (script: ProjectScript) => {
......@@ -746,16 +777,24 @@ function ThreadRouteContent(
746777 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
747778 });
748779
749 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
780 if (
781 !selectedThread ||
782 !selectedThreadProject?.workspaceRoot ||
783 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
784 ) {
750785 terminalDebugLog("project-script:abort", {
751786 scriptId: script.id,
752 reason: "no-thread-or-workspace",
787 reason: "no-thread-workspace-or-terminal-access",
753788 });
754789 return;
755790 }
756791
757792 const targetTerminalId = resolveProjectScriptTerminalId({
758793 existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
794 ...(knownTerminalSessions === null ||
795 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
796 ? { uniqueSuffix: uuidv4() }
797 : {}),
759798 hasRunningTerminal: terminalMenuSessions.some(
760799 (session) => session.status === "running" || session.status === "starting",
761800 ),
......@@ -804,6 +843,7 @@ function ThreadRouteContent(
804843 selectedThreadDetailWorktreePath,
805844 selectedThreadProject,
806845 terminalMenuSessions,
846 knownTerminalSessions,
807847 ],
808848 );
809849 const threadGitControlProps = {
......@@ -826,7 +866,9 @@ function ThreadRouteContent(
826866 currentBranch: selectedThread?.branch ?? null,
827867 gitStatus: gitStatus.data,
828868 gitOperationLabel: gitState.gitOperationLabel,
829 canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot),
869 canOpenTerminal:
870 Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal),
871 canOperateTerminal,
830872 canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
831873 projectScripts: selectedThreadProject
832874 ? resolveProjectScripts(
apps/mobile/src/state/use-terminal-session.ts+31-13
......@@ -6,11 +6,18 @@ import {
66 type KnownTerminalSession,
77 type TerminalSessionState,
88} from "@t3tools/client-runtime/state/terminal";
9import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts";
9import {
10 AuthTerminalReadScope,
11 AuthTerminalOperateScope,
12 ThreadId,
13 type EnvironmentId,
14 type TerminalAttachInput,
15} from "@t3tools/contracts";
1016import { useMemo } from "react";
1117
1218import { useEnvironmentQuery } from "./query";
1319import { terminalEnvironment } from "./terminal";
20import { useEnvironmentScope } from "./session";
1421
1522type LegacyTerminalSessionState = TerminalSessionState & { readonly buffer: string };
1623const EMPTY_LEGACY_TERMINAL_SESSION_STATE: LegacyTerminalSessionState = {
......@@ -22,16 +29,25 @@ export function useAttachedTerminalSession(input: {
2229 readonly environmentId: EnvironmentId | null;
2330 readonly terminal: TerminalAttachInput | null;
2431}): LegacyTerminalSessionState {
32 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
33 const canOperate = useEnvironmentScope(input.environmentId, AuthTerminalOperateScope);
2534 const attach = useEnvironmentQuery(
2635 input.environmentId !== null && input.terminal !== null
27 ? terminalEnvironment.attach({
28 environmentId: input.environmentId,
29 input: input.terminal,
30 })
36 ? canOperate
37 ? terminalEnvironment.attach({
38 environmentId: input.environmentId,
39 input: input.terminal,
40 })
41 : canRead
42 ? terminalEnvironment.observe({
43 environmentId: input.environmentId,
44 input: { threadId: input.terminal.threadId, terminalId: input.terminal.terminalId },
45 })
46 : null
3147 : null,
3248 );
3349 const metadata = useEnvironmentQuery(
34 input.environmentId === null
50 input.environmentId === null || !canRead
3551 ? null
3652 : terminalEnvironment.metadata({
3753 environmentId: input.environmentId,
......@@ -64,20 +80,21 @@ export function useAttachedTerminalSession(input: {
6480export function useKnownTerminalSessions(input: {
6581 readonly environmentId: EnvironmentId | null;
6682 readonly threadId: ThreadId | null;
67}): ReadonlyArray<KnownTerminalSession> {
83}) {
84 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
6885 const metadata = useEnvironmentQuery(
69 input.environmentId === null
86 input.environmentId === null || !canRead
7087 ? null
7188 : terminalEnvironment.metadata({
7289 environmentId: input.environmentId,
7390 input: null,
7491 }),
7592 );
76 return useMemo(() => {
77 if (input.environmentId === null) {
78 return [];
93 const sessions = useMemo((): ReadonlyArray<KnownTerminalSession> | null => {
94 if (input.environmentId === null || metadata.data === null || metadata.error !== null) {
95 return null;
7996 }
80 return (metadata.data ?? [])
97 return metadata.data
8198 .filter((summary) => input.threadId === null || summary.threadId === input.threadId)
8299 .map((summary) => ({
83100 target: {
......@@ -92,5 +109,6 @@ export function useKnownTerminalSessions(input: {
92109 numeric: true,
93110 }),
94111 );
95 }, [input.environmentId, input.threadId, metadata.data]);
112 }, [input.environmentId, input.threadId, metadata.data, metadata.error]);
113 return { sessions, isPending: metadata.isPending, error: metadata.error };
96114}
apps/server/src/auth/RpcAuthorization.test.ts+23
......@@ -13,6 +13,8 @@ import {
1313 AuthPreviewOperateScope,
1414 AuthRelayReadScope,
1515 AuthRelayWriteScope,
16 AuthTerminalReadScope,
17 AuthTerminalOperateScope,
1618 WS_METHODS,
1719 WsRpcGroup,
1820} from "@t3tools/contracts";
......@@ -162,6 +164,27 @@ describe("RPC authorization scopes", () => {
162164 }
163165 });
164166
167 it("separates passive terminal observation from operations that can change a shell", () => {
168 for (const method of [
169 WS_METHODS.terminalObserve,
170 WS_METHODS.subscribeTerminalEvents,
171 WS_METHODS.subscribeTerminalMetadata,
172 ]) {
173 expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalReadScope);
174 }
175 for (const method of [
176 WS_METHODS.terminalAttach,
177 WS_METHODS.terminalOpen,
178 WS_METHODS.terminalWrite,
179 WS_METHODS.terminalResize,
180 WS_METHODS.terminalClear,
181 WS_METHODS.terminalRestart,
182 WS_METHODS.terminalClose,
183 ]) {
184 expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalOperateScope);
185 }
186 });
187
165188 it("rejects unknown RPC method names", () => {
166189 for (const method of ["server.notRegistered", "toString", "constructor"]) {
167190 expect(() => requiredScopeForRpcMethod(method)).toThrow(
apps/server/src/auth/RpcAuthorization.ts+4-2
......@@ -20,6 +20,7 @@ import {
2020 AuthRelayWriteScope,
2121 AuthTerminalOperateScope,
2222 ORCHESTRATION_V2_WS_METHODS,
23 AuthTerminalReadScope,
2324 type AuthEnvironmentScope,
2425 EnvironmentAuthorizationError,
2526 RpcScopeAuthorization,
......@@ -164,13 +165,14 @@ export const RPC_REQUIRED_SCOPES = {
164165 [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope,
165166 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
166167 [WS_METHODS.terminalAttach]: AuthTerminalOperateScope,
168 [WS_METHODS.terminalObserve]: AuthTerminalReadScope,
167169 [WS_METHODS.terminalWrite]: AuthTerminalOperateScope,
168170 [WS_METHODS.terminalResize]: AuthTerminalOperateScope,
169171 [WS_METHODS.terminalClear]: AuthTerminalOperateScope,
170172 [WS_METHODS.terminalRestart]: AuthTerminalOperateScope,
171173 [WS_METHODS.terminalClose]: AuthTerminalOperateScope,
172 [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope,
173 [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope,
174 [WS_METHODS.subscribeTerminalEvents]: AuthTerminalReadScope,
175 [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope,
174176 [WS_METHODS.previewOpen]: AuthPreviewOperateScope,
175177 [WS_METHODS.previewNavigate]: AuthPreviewOperateScope,
176178 [WS_METHODS.previewResize]: AuthPreviewOperateScope,
apps/server/src/observability/RpcInstrumentation.ts+1
......@@ -161,6 +161,7 @@ const RPC_AGGREGATES = {
161161 [WS_METHODS.reviewGetDiffFileContents]: "review",
162162 [WS_METHODS.terminalOpen]: "terminal",
163163 [WS_METHODS.terminalAttach]: "terminal",
164 [WS_METHODS.terminalObserve]: "terminal",
164165 [WS_METHODS.terminalWrite]: "terminal",
165166 [WS_METHODS.terminalResize]: "terminal",
166167 [WS_METHODS.terminalClear]: "terminal",
apps/server/src/terminal/Manager.test.ts+98
......@@ -2693,6 +2693,104 @@ it.layer(
26932693 }),
26942694 );
26952695
2696 it.effect("observes terminal history and live output without changing the process", () =>
2697 Effect.gen(function* () {
2698 const { manager, ptyAdapter } = yield* createManager();
2699 const opened = yield* manager.open(openInput({ env: { OBSERVER_TEST: "original" } }));
2700 const process = ptyAdapter.processes[0]!;
2701 const historyReceived = yield* Deferred.make<void>();
2702 const unsubscribeHistory = yield* manager.subscribe((event) =>
2703 event.type === "output"
2704 ? Deferred.succeed(historyReceived, undefined).pipe(Effect.asVoid)
2705 : Effect.void,
2706 );
2707 process.emitData("existing history\n");
2708 yield* Deferred.await(historyReceived);
2709 unsubscribeHistory();
2710
2711 const observed = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]);
2712 const liveReceived = yield* Deferred.make<void>();
2713 const unsubscribe = yield* manager.observeStream(
2714 { threadId: opened.threadId, terminalId: opened.terminalId },
2715 (event) =>
2716 Ref.update(observed, (events) => [...events, event]).pipe(
2717 Effect.andThen(
2718 event.type === "output"
2719 ? Deferred.succeed(liveReceived, undefined).pipe(Effect.asVoid)
2720 : Effect.void,
2721 ),
2722 ),
2723 );
2724 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
2725 process.emitData("live output\n");
2726 yield* Deferred.await(liveReceived);
2727
2728 expect(yield* Ref.get(observed)).toMatchObject([
2729 {
2730 type: "snapshot",
2731 snapshot: {
2732 cwd: opened.cwd,
2733 worktreePath: opened.worktreePath,
2734 pid: opened.pid,
2735 history: "existing history\n",
2736 },
2737 },
2738 { type: "output", data: "live output\n" },
2739 ]);
2740 expect(ptyAdapter.spawnInputs).toHaveLength(1);
2741 expect(ptyAdapter.spawnInputs[0]?.env.OBSERVER_TEST).toBe("original");
2742 expect(process.resizeCalls).toEqual([]);
2743 expect(process.writes).toEqual([]);
2744 expect(process.killSignals).toEqual([]);
2745 }),
2746 );
2747
2748 it.effect("observes exited terminals without restarting and rejects missing sessions", () =>
2749 Effect.gen(function* () {
2750 const { manager, ptyAdapter } = yield* createManager();
2751 const missingEvents: TerminalAttachStreamEvent[] = [];
2752 const missing = yield* manager
2753 .observeStream({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }, (event) =>
2754 Effect.sync(() => {
2755 missingEvents.push(event);
2756 }),
2757 )
2758 .pipe(Effect.flip);
2759 expect(missing._tag).toBe("TerminalSessionLookupError");
2760 expect(ptyAdapter.spawnInputs).toEqual([]);
2761
2762 yield* manager.open(openInput());
2763 const process = ptyAdapter.processes[0]!;
2764 const exited = yield* Deferred.make<void>();
2765 const unsubscribeExit = yield* manager.subscribe((event) =>
2766 event.type === "exited"
2767 ? Deferred.succeed(exited, undefined).pipe(Effect.asVoid)
2768 : Effect.void,
2769 );
2770 process.emitExit({ exitCode: 7, signal: 0 });
2771 yield* Deferred.await(exited);
2772 unsubscribeExit();
2773
2774 const events: TerminalAttachStreamEvent[] = [];
2775 const unsubscribe = yield* manager.observeStream(
2776 { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID },
2777 (event) =>
2778 Effect.sync(() => {
2779 events.push(event);
2780 }),
2781 );
2782 unsubscribe();
2783 expect(events).toMatchObject([
2784 { type: "snapshot", snapshot: { status: "exited", exitCode: 7, pid: null } },
2785 ]);
2786 expect(ptyAdapter.spawnInputs).toHaveLength(1);
2787 expect(process.resizeCalls).toEqual([]);
2788 expect(process.writes).toEqual([]);
2789 expect(process.killSignals).toEqual([]);
2790 expect(missingEvents).toEqual([]);
2791 }),
2792 );
2793
26962794 it.effect("buffers attach output delivered during the initial snapshot callback", () =>
26972795 Effect.gen(function* () {
26982796 const { manager, ptyAdapter } = yield* createManager(5, {
apps/server/src/terminal/Manager.ts+27-2
......@@ -28,6 +28,7 @@ import {
2828 type TerminalEvent,
2929 type TerminalMetadataStreamEvent,
3030 type TerminalOpenInput,
31 type TerminalObserveInput,
3132 type TerminalResizeInput,
3233 type ResourceMonitorProcessTableEntry,
3334 type TerminalRestartInput,
......@@ -170,6 +171,12 @@ export class TerminalManager extends Context.Service<
170171 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
171172 ) => Effect.Effect<() => void, TerminalError>;
172173
174 /** Observe an existing session without starting or changing its process. */
175 readonly observeStream: (
176 input: TerminalObserveInput,
177 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
178 ) => Effect.Effect<() => void, TerminalError>;
179
173180 /**
174181 * Write input bytes to a terminal session.
175182 */
......@@ -2787,7 +2794,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
27872794 };
27882795 });
27892796
2790 const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => {
2797 const streamSession = (
2798 input: TerminalObserveInput,
2799 initial: Effect.Effect<TerminalSessionSnapshot, TerminalError>,
2800 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
2801 ) => {
27912802 let unsubscribe: (() => void) | null = null;
27922803
27932804 return Effect.gen(function* () {
......@@ -2808,7 +2819,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
28082819 return attachEvent ? listener(attachEvent) : Effect.void;
28092820 });
28102821
2811 const initialSnapshot = yield* openOrAttachForStream(input);
2822 const initialSnapshot = yield* initial;
28122823
28132824 yield* listener({
28142825 type: "snapshot",
......@@ -2844,6 +2855,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
28442855 );
28452856 };
28462857
2858 const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) =>
2859 streamSession(input, openOrAttachForStream(input), listener);
2860
2861 const observeStream: TerminalManager["Service"]["observeStream"] = (input, listener) =>
2862 streamSession(
2863 input,
2864 withThreadLock(
2865 input.threadId,
2866 requireSession(input.threadId, input.terminalId).pipe(Effect.map(snapshot)),
2867 ),
2868 listener,
2869 );
2870
28472871 const metadataEventFromTerminalEvent = (
28482872 event: TerminalEvent,
28492873 ): Effect.Effect<TerminalMetadataStreamEvent | null> => {
......@@ -3153,6 +3177,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
31533177 return TerminalManager.of({
31543178 open,
31553179 attachStream,
3180 observeStream,
31563181 write,
31573182 resize,
31583183 clear,
apps/server/src/ws.ts+8-1
......@@ -2840,13 +2840,20 @@ const layerWsRpc = (
28402840 Effect.acquireRelease(
28412841 terminalManager.attachStream(input, (event) => Queue.offer(queue, event)),
28422842 (unsubscribe) => Effect.sync(unsubscribe),
2843 ),
2843 ).pipe(Effect.catchCause((cause) => Queue.failCause(queue, cause))),
28442844 ),
28452845 [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input),
28462846 [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input),
28472847 [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input),
28482848 [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input),
28492849 [WS_METHODS.terminalClose]: (input) => terminalManager.close(input),
2850 [WS_METHODS.terminalObserve]: (input) =>
2851 Stream.callback<TerminalAttachStreamEvent, TerminalError>((queue) =>
2852 Effect.acquireRelease(
2853 terminalManager.observeStream(input, (event) => Queue.offer(queue, event)),
2854 (unsubscribe) => Effect.sync(unsubscribe),
2855 ).pipe(Effect.catchCause((cause) => Queue.failCause(queue, cause))),
2856 ),
28502857 [WS_METHODS.subscribeTerminalEvents]: (_input) =>
28512858 Stream.callback<TerminalEvent>((queue) =>
28522859 Effect.acquireRelease(
apps/web/src/components/ChatView.tsx+163-50
......@@ -52,6 +52,8 @@ import {
5252 AuthSettingsWriteScope,
5353 AuthSourceControlWriteScope,
5454 AuthPreviewOperateScope,
55 AuthTerminalReadScope,
56 AuthTerminalOperateScope,
5557 type AssistantCitation,
5658 type ChatFileAttachment,
5759 CommandId,
......@@ -980,6 +982,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
980982 keybindings,
981983 onAddTerminalContext,
982984}: PersistentThreadTerminalDrawerProps) {
985 const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope);
986 const hasTerminalWriteAccess = useCallback(
987 () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope),
988 [threadRef.environmentId],
989 );
983990 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
984991 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
985992 const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");
......@@ -1013,7 +1020,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
10131020 );
10141021 const drawerTerminalSessions = useMemo(
10151022 () =>
1016 knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)),
1023 knownTerminalSessions?.filter(
1024 (session) => !panelTerminalIds.has(session.target.terminalId),
1025 ) ?? [],
10171026 [knownTerminalSessions, panelTerminalIds],
10181027 );
10191028 const terminalLabelsById = useMemo(() => {
......@@ -1075,6 +1084,17 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
10751084 ],
10761085 [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds],
10771086 );
1087 const allocateTerminalId = useCallback(
1088 () =>
1089 nextTerminalId(
1090 allocatableTerminalIds,
1091 knownTerminalSessions === null ||
1092 !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope)
1093 ? randomUUID()
1094 : undefined,
1095 ),
1096 [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId],
1097 );
10781098 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);
10791099 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);
10801100 const storeSplitTerminalVertical = useTerminalUiStateStore(
......@@ -1141,10 +1161,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
11411161 );
11421162
11431163 const splitTerminal = useCallback(() => {
1144 if (!cwd) {
1164 if (!hasTerminalWriteAccess() || !cwd) {
11451165 return;
11461166 }
1147 const terminalId = nextTerminalId(allocatableTerminalIds);
1167 const terminalId = allocateTerminalId();
11481168 storeSplitTerminal(threadRef, terminalId);
11491169 bumpFocusRequestId();
11501170 void openTerminal({
......@@ -1158,7 +1178,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
11581178 },
11591179 });
11601180 }, [
1161 allocatableTerminalIds,
1181 allocateTerminalId,
11621182 bumpFocusRequestId,
11631183 cwd,
11641184 effectiveWorktreePath,
......@@ -1167,12 +1187,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
11671187 threadId,
11681188 threadRef,
11691189 openTerminal,
1190 hasTerminalWriteAccess,
11701191 ]);
11711192 const splitTerminalVertical = useCallback(() => {
1172 if (!cwd) {
1193 if (!hasTerminalWriteAccess() || !cwd) {
11731194 return;
11741195 }
1175 const terminalId = nextTerminalId(allocatableTerminalIds);
1196 const terminalId = allocateTerminalId();
11761197 storeSplitTerminalVertical(threadRef, terminalId);
11771198 bumpFocusRequestId();
11781199 void openTerminal({
......@@ -1186,11 +1207,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
11861207 },
11871208 });
11881209 }, [
1189 allocatableTerminalIds,
1210 allocateTerminalId,
11901211 bumpFocusRequestId,
11911212 cwd,
11921213 effectiveWorktreePath,
11931214 openTerminal,
1215 hasTerminalWriteAccess,
11941216 runtimeEnv,
11951217 storeSplitTerminalVertical,
11961218 threadId,
......@@ -1198,10 +1220,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
11981220 ]);
11991221
12001222 const createNewTerminal = useCallback(() => {
1201 if (!cwd) {
1223 if (!hasTerminalWriteAccess() || !cwd) {
12021224 return;
12031225 }
1204 const terminalId = nextTerminalId(allocatableTerminalIds);
1226 const terminalId = allocateTerminalId();
12051227 storeNewTerminal(threadRef, terminalId);
12061228 bumpFocusRequestId();
12071229 void openTerminal({
......@@ -1218,12 +1240,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
12181240 bumpFocusRequestId,
12191241 cwd,
12201242 effectiveWorktreePath,
1221 allocatableTerminalIds,
1243 allocateTerminalId,
12221244 runtimeEnv,
12231245 storeNewTerminal,
12241246 threadId,
12251247 threadRef,
12261248 openTerminal,
1249 hasTerminalWriteAccess,
12271250 ]);
12281251
12291252 const activateTerminal = useCallback(
......@@ -1236,6 +1259,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
12361259
12371260 const closeTerminal = useCallback(
12381261 (terminalId: string) => {
1262 if (!hasTerminalWriteAccess()) return;
12391263 const fallbackExitWrite = () =>
12401264 writeTerminal({
12411265 environmentId: threadRef.environmentId,
......@@ -1251,7 +1275,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
12511275 deleteHistory: true,
12521276 },
12531277 });
1254 if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) {
1278 if (
1279 closeResult._tag === "Failure" &&
1280 !isAtomCommandInterrupted(closeResult) &&
1281 hasTerminalWriteAccess()
1282 ) {
12551283 await fallbackExitWrite();
12561284 }
12571285 })();
......@@ -1265,6 +1293,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
12651293 threadId,
12661294 threadRef,
12671295 closeTerminalMutation,
1296 hasTerminalWriteAccess,
12681297 writeTerminal,
12691298 ],
12701299 );
......@@ -1303,7 +1332,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
13031332 visible={visible}
13041333 height={terminalUiState.terminalHeight}
13051334 // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.
1306 terminalIds={terminalUiState.terminalIds}
1335 terminalIds={canOperateTerminal ? terminalUiState.terminalIds : serverOrderedTerminalIds}
13071336 activeTerminalId={terminalUiState.activeTerminalId}
13081337 terminalGroups={terminalUiState.terminalGroups}
13091338 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}
......@@ -1379,7 +1408,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
13791408 });
13801409 const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
13811410 const activeSummary =
1382 knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId)
1411 knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId)
13831412 ?.state.summary ?? null;
13841413 const worktreePath =
13851414 launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath;
......@@ -1409,7 +1438,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
14091438 const labels = new Map<string, string>();
14101439 for (const terminalId of surface.terminalIds) {
14111440 const summary =
1412 knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state
1441 knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state
14131442 .summary ?? null;
14141443 labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary));
14151444 }
......@@ -1426,7 +1455,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
14261455 >();
14271456 for (const terminalId of surface.terminalIds) {
14281457 const summary =
1429 knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state
1458 knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state
14301459 .summary ?? null;
14311460 const terminalWorktreePath =
14321461 launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath;
......@@ -1544,6 +1573,12 @@ export default function ChatView(props: ChatViewProps) {
15441573 forceExpandedMobileComposer = false,
15451574 } = props;
15461575 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
1576 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
1577 const hasTerminalWriteAccess = useCallback(
1578 () => readEnvironmentScope(environmentId, AuthTerminalOperateScope),
1579 [environmentId],
1580 );
1581 const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope);
15471582 const draftId = routeKind === "draft" ? props.draftId : null;
15481583 const handleNewThread = useNewThreadHandler();
15491584 const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();
......@@ -2180,7 +2215,7 @@ export default function ChatView(props: ChatViewProps) {
21802215 threadId: activeThreadId,
21812216 });
21822217 const activeThreadKnownSessions = useMemo(() => {
2183 if (activeThreadId === null) {
2218 if (activeThreadId === null || activeThreadKnownSessionsRaw === null) {
21842219 return [];
21852220 }
21862221 return activeThreadKnownSessionsRaw.filter(
......@@ -2301,6 +2336,17 @@ export default function ChatView(props: ChatViewProps) {
23012336 () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])],
23022337 [activeKnownTerminalIds, panelTerminalIds],
23032338 );
2339 const canReuseTerminal = activeThreadKnownSessionsRaw !== null;
2340 const allocateTerminalId = useCallback(
2341 () =>
2342 nextTerminalId(
2343 allocatableActiveTerminalIds,
2344 canReuseTerminal && readEnvironmentScope(environmentId, AuthTerminalReadScope)
2345 ? undefined
2346 : randomUUID(),
2347 ),
2348 [allocatableActiveTerminalIds, canReuseTerminal, environmentId],
2349 );
23042350 const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable;
23052351 const rightPanelOpen = rightPanelState.isOpen;
23062352 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
......@@ -4696,7 +4742,13 @@ export default function ChatView(props: ChatViewProps) {
46964742 const toggleTerminalVisibility = useCallback(() => {
46974743 if (!activeThreadRef) return;
46984744 const nextOpen = !terminalUiState.terminalOpen;
4699 if (nextOpen && terminalUiState.terminalIds.length === 0) {
4745 if (
4746 nextOpen &&
4747 !readEnvironmentScope(environmentId, AuthTerminalReadScope) &&
4748 !hasTerminalWriteAccess()
4749 )
4750 return;
4751 if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) {
47004752 if (!activeThreadId || !activeProject) {
47014753 return;
47024754 }
......@@ -4704,7 +4756,7 @@ export default function ChatView(props: ChatViewProps) {
47044756 if (!cwdForOpen) {
47054757 return;
47064758 }
4707 const terminalId = nextTerminalId(allocatableActiveTerminalIds);
4759 const terminalId = allocateTerminalId();
47084760 storeEnsureTerminal(activeThreadRef, terminalId, { open: true });
47094761 void openTerminal({
47104762 environmentId,
......@@ -4727,10 +4779,11 @@ export default function ChatView(props: ChatViewProps) {
47274779 activeThreadId,
47284780 activeThreadRef,
47294781 activeThreadWorktreePath,
4730 allocatableActiveTerminalIds,
4782 allocateTerminalId,
47314783 environmentId,
47324784 gitCwd,
47334785 openTerminal,
4786 hasTerminalWriteAccess,
47344787 setTerminalOpen,
47354788 storeEnsureTerminal,
47364789 terminalUiState.terminalIds.length,
......@@ -4738,14 +4791,20 @@ export default function ChatView(props: ChatViewProps) {
47384791 ]);
47394792 const splitTerminal = useCallback(
47404793 (direction: "horizontal" | "vertical" = "horizontal") => {
4741 if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) {
4794 if (
4795 !hasTerminalWriteAccess() ||
4796 !activeThreadRef ||
4797 hasReachedSplitLimit ||
4798 !activeThreadId ||
4799 !activeProject
4800 ) {
47424801 return;
47434802 }
47444803 const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
47454804 if (!cwdForOpen) {
47464805 return;
47474806 }
4748 const terminalId = nextTerminalId(allocatableActiveTerminalIds);
4807 const terminalId = allocateTerminalId();
47494808 if (direction === "vertical") {
47504809 storeSplitTerminalVertical(activeThreadRef, terminalId);
47514810 } else {
......@@ -4769,9 +4828,10 @@ export default function ChatView(props: ChatViewProps) {
47694828 [
47704829 activeProject,
47714830 activeThreadId,
4772 allocatableActiveTerminalIds,
4831 allocateTerminalId,
47734832 activeThreadRef,
47744833 openTerminal,
4834 hasTerminalWriteAccess,
47754835 activeThreadWorktreePath,
47764836 environmentId,
47774837 gitCwd,
......@@ -4781,14 +4841,14 @@ export default function ChatView(props: ChatViewProps) {
47814841 ],
47824842 );
47834843 const createNewTerminal = useCallback(() => {
4784 if (!activeThreadRef || !activeThreadId || !activeProject) {
4844 if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) {
47854845 return;
47864846 }
47874847 const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
47884848 if (!cwdForOpen) {
47894849 return;
47904850 }
4791 const terminalId = nextTerminalId(allocatableActiveTerminalIds);
4851 const terminalId = allocateTerminalId();
47924852 storeNewTerminal(activeThreadRef, terminalId);
47934853 setTerminalFocusRequestId((value) => value + 1);
47944854 void openTerminal({
......@@ -4807,9 +4867,10 @@ export default function ChatView(props: ChatViewProps) {
48074867 }, [
48084868 activeProject,
48094869 activeThreadId,
4810 allocatableActiveTerminalIds,
4870 allocateTerminalId,
48114871 activeThreadRef,
48124872 openTerminal,
4873 hasTerminalWriteAccess,
48134874 activeThreadWorktreePath,
48144875 environmentId,
48154876 gitCwd,
......@@ -4817,7 +4878,7 @@ export default function ChatView(props: ChatViewProps) {
48174878 ]);
48184879 const closeTerminal = useCallback(
48194880 (terminalId: string) => {
4820 if (!activeThreadId || !activeThreadRef) return;
4881 if (!hasTerminalWriteAccess() || !activeThreadId || !activeThreadRef) return;
48214882 const fallbackExitWrite = () =>
48224883 writeTerminal({
48234884 environmentId,
......@@ -4832,7 +4893,11 @@ export default function ChatView(props: ChatViewProps) {
48324893 deleteHistory: true,
48334894 },
48344895 });
4835 if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) {
4896 if (
4897 closeResult._tag === "Failure" &&
4898 !isAtomCommandInterrupted(closeResult) &&
4899 hasTerminalWriteAccess()
4900 ) {
48364901 await fallbackExitWrite();
48374902 }
48384903 })();
......@@ -4843,6 +4908,7 @@ export default function ChatView(props: ChatViewProps) {
48434908 activeThreadId,
48444909 activeThreadRef,
48454910 closeTerminalMutation,
4911 hasTerminalWriteAccess,
48464912 environmentId,
48474913 storeCloseTerminal,
48484914 writeTerminal,
......@@ -4859,7 +4925,7 @@ export default function ChatView(props: ChatViewProps) {
48594925 rememberAsLastInvoked?: boolean;
48604926 },
48614927 ) => {
4862 if (!activeThreadId || !activeProject || !activeThread) return;
4928 if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return;
48634929 if (options?.rememberAsLastInvoked !== false) {
48644930 setLastInvokedScriptByProjectId((current) => {
48654931 if (current[activeProject.id] === script.id) return current;
......@@ -4871,7 +4937,10 @@ export default function ChatView(props: ChatViewProps) {
48714937 terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
48724938 const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);
48734939 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
4874 const shouldCreateNewTerminal = wantsNewTerminal;
4940 const shouldCreateNewTerminal =
4941 wantsNewTerminal ||
4942 !canReuseTerminal ||
4943 !readEnvironmentScope(environmentId, AuthTerminalReadScope);
48754944 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;
48764945
48774946 setTerminalUiLaunchContext({
......@@ -4892,9 +4961,7 @@ export default function ChatView(props: ChatViewProps) {
48924961 worktreePath: targetWorktreePath,
48934962 ...(options?.env ? { extraEnv: options.env } : {}),
48944963 });
4895 const targetTerminalId = shouldCreateNewTerminal
4896 ? nextTerminalId(allocatableActiveTerminalIds)
4897 : baseTerminalId;
4964 const targetTerminalId = shouldCreateNewTerminal ? allocateTerminalId() : baseTerminalId;
48984965 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
48994966 ? {
49004967 threadId: activeThreadId,
......@@ -4931,6 +4998,7 @@ export default function ChatView(props: ChatViewProps) {
49314998 return;
49324999 }
49335000
5001 if (!hasTerminalWriteAccess()) return;
49345002 const writeResult = await writeTerminal({
49355003 environmentId,
49365004 input: {
......@@ -4981,8 +5049,10 @@ export default function ChatView(props: ChatViewProps) {
49815049 environmentId,
49825050 openTerminal,
49835051 openPreview,
5052 hasTerminalWriteAccess,
49845053 activeKnownTerminalIds,
4985 allocatableActiveTerminalIds,
5054 canReuseTerminal,
5055 allocateTerminalId,
49865056 runningTerminalIds,
49875057 terminalUiState.activeTerminalId,
49885058 writeTerminal,
......@@ -5792,9 +5862,9 @@ export default function ChatView(props: ChatViewProps) {
57925862 previewPanelOpen,
57935863 ]);
57945864 const addTerminalSurface = useCallback(() => {
5795 if (!activeThreadRef || !activeThreadId || !activeProject) return;
5865 if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) return;
57965866 const cwd = gitCwd ?? activeProject.workspaceRoot;
5797 const terminalId = nextTerminalId(allocatableActiveTerminalIds);
5867 const terminalId = allocateTerminalId();
57985868 useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId);
57995869 setTerminalFocusRequestId((value) => value + 1);
58005870 void openTerminal({
......@@ -5815,13 +5885,15 @@ export default function ChatView(props: ChatViewProps) {
58155885 activeThreadId,
58165886 activeThreadRef,
58175887 activeThreadWorktreePath,
5818 allocatableActiveTerminalIds,
5888 allocateTerminalId,
58195889 gitCwd,
58205890 openTerminal,
5891 hasTerminalWriteAccess,
58215892 ]);
58225893 const splitPanelTerminal = useCallback(
58235894 (direction: "horizontal" | "vertical" = "horizontal") => {
58245895 if (
5896 !hasTerminalWriteAccess() ||
58255897 !activeThreadRef ||
58265898 !activeThreadId ||
58275899 !activeProject ||
......@@ -5830,7 +5902,7 @@ export default function ChatView(props: ChatViewProps) {
58305902 ) {
58315903 return;
58325904 }
5833 const terminalId = nextTerminalId(allocatableActiveTerminalIds);
5905 const terminalId = allocateTerminalId();
58345906 const cwd = gitCwd ?? activeProject.workspaceRoot;
58355907 useRightPanelStore
58365908 .getState()
......@@ -5856,9 +5928,10 @@ export default function ChatView(props: ChatViewProps) {
58565928 activeThreadId,
58575929 activeThreadRef,
58585930 activeThreadWorktreePath,
5859 allocatableActiveTerminalIds,
5931 allocateTerminalId,
58605932 gitCwd,
58615933 openTerminal,
5934 hasTerminalWriteAccess,
58625935 ],
58635936 );
58645937 const splitPanelTerminalVertical = useCallback(() => {
......@@ -5876,7 +5949,12 @@ export default function ChatView(props: ChatViewProps) {
58765949 );
58775950 const closePanelTerminal = useCallback(
58785951 (terminalId: string) => {
5879 if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return;
5952 if (
5953 !hasTerminalWriteAccess() ||
5954 !activeThreadRef ||
5955 activeRightPanelSurface?.kind !== "terminal"
5956 )
5957 return;
58805958 void closeTerminalMutation({
58815959 environmentId: activeThreadRef.environmentId,
58825960 input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },
......@@ -5887,25 +5965,37 @@ export default function ChatView(props: ChatViewProps) {
58875965 .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
58885966 setTerminalFocusRequestId((value) => value + 1);
58895967 },
5890 [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal],
5968 [
5969 hasTerminalWriteAccess,
5970 activeRightPanelSurface,
5971 activeThreadRef,
5972 closeTerminalMutation,
5973 storeCloseTerminal,
5974 ],
58915975 );
58925976 const requestCloseTerminal = useCallback(
58935977 (terminalId: string) => {
5978 if (!hasTerminalWriteAccess()) return;
58945979 const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
58955980 void confirmTerminalClose([label]).then((confirmed) => {
5896 if (confirmed) closeTerminal(terminalId);
5981 if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
5982 closeTerminal(terminalId);
5983 }
58975984 });
58985985 },
5899 [activeTerminalLabelsById, closeTerminal],
5986 [hasTerminalWriteAccess, activeTerminalLabelsById, closeTerminal, environmentId],
59005987 );
59015988 const requestClosePanelTerminal = useCallback(
59025989 (terminalId: string) => {
5990 if (!hasTerminalWriteAccess()) return;
59035991 const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
59045992 void confirmTerminalClose([label]).then((confirmed) => {
5905 if (confirmed) closePanelTerminal(terminalId);
5993 if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
5994 closePanelTerminal(terminalId);
5995 }
59065996 });
59075997 },
5908 [activeTerminalLabelsById, closePanelTerminal],
5998 [hasTerminalWriteAccess, activeTerminalLabelsById, closePanelTerminal, environmentId],
59095999 );
59106000 const activateRightPanelSurface = useCallback(
59116001 (surface: RightPanelSurface) => {
......@@ -5956,7 +6046,10 @@ export default function ChatView(props: ChatViewProps) {
59566046 threadRef: activeThreadRef,
59576047 });
59586048 }
5959 if (surface.kind === "terminal") {
6049 if (
6050 surface.kind === "terminal" &&
6051 readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope)
6052 ) {
59606053 for (const terminalId of surface.terminalIds) {
59616054 storeCloseTerminal(activeThreadRef, terminalId);
59626055 void closeTerminalMutation({
......@@ -6034,7 +6127,10 @@ export default function ChatView(props: ChatViewProps) {
60346127 closeAfterAgentBrowserConfirmation([surface], finishClose);
60356128 return;
60366129 }
6037 if (surface.kind !== "terminal") {
6130 if (
6131 surface.kind !== "terminal" ||
6132 !readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope)
6133 ) {
60386134 finishClose();
60396135 return;
60406136 }
......@@ -6047,7 +6143,9 @@ export default function ChatView(props: ChatViewProps) {
60476143 (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId),
60486144 );
60496145 void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => {
6050 if (confirmed) finishClose();
6146 if (confirmed) {
6147 finishClose();
6148 }
60516149 });
60526150 },
60536151 [
......@@ -7963,6 +8061,7 @@ export default function ChatView(props: ChatViewProps) {
79638061 }
79648062
79658063 if (command === "terminal.toggle") {
8064 if (!terminalUiState.terminalOpen && !canReadTerminal && !canOperateTerminal) return;
79668065 event.preventDefault();
79678066 event.stopPropagation();
79688067 toggleTerminalVisibility();
......@@ -7994,6 +8093,9 @@ export default function ChatView(props: ChatViewProps) {
79948093 }
79958094
79968095 if (command === "terminal.split") {
8096 // Without operate access the key keeps its native meaning, as it
8097 // does when nothing is open.
8098 if (!canOperateTerminal) return;
79978099 event.preventDefault();
79988100 event.stopPropagation();
79998101 if (terminalFocusOwner === "right-panel") {
......@@ -8008,6 +8110,9 @@ export default function ChatView(props: ChatViewProps) {
80088110 }
80098111
80108112 if (command === "terminal.splitVertical") {
8113 // Without operate access the key keeps its native meaning, as it
8114 // does when nothing is open.
8115 if (!canOperateTerminal) return;
80118116 event.preventDefault();
80128117 event.stopPropagation();
80138118 if (terminalFocusOwner === "right-panel") {
......@@ -8022,6 +8127,9 @@ export default function ChatView(props: ChatViewProps) {
80228127 }
80238128
80248129 if (command === "terminal.close") {
8130 // Without operate access the key keeps its native meaning, as it
8131 // does when nothing is open.
8132 if (!canOperateTerminal) return;
80258133 event.preventDefault();
80268134 event.stopPropagation();
80278135 if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
......@@ -8034,6 +8142,9 @@ export default function ChatView(props: ChatViewProps) {
80348142 }
80358143
80368144 if (command === "terminal.new") {
8145 // Without operate access the key keeps its native meaning, as it
8146 // does when nothing is open.
8147 if (!canOperateTerminal) return;
80378148 event.preventDefault();
80388149 event.stopPropagation();
80398150 if (terminalFocusOwner === "right-panel") {
......@@ -8132,7 +8243,7 @@ export default function ChatView(props: ChatViewProps) {
81328243 }
81338244
81348245 const scriptId = projectScriptIdFromCommand(command);
8135 if (!scriptId || !activeProject) return;
8246 if (!scriptId || !activeProject || !canOperateTerminal) return;
81368247 const script = activeProjectScripts.find((entry) => entry.id === scriptId);
81378248 if (!script) return;
81388249 event.preventDefault();
......@@ -8145,6 +8256,8 @@ export default function ChatView(props: ChatViewProps) {
81458256 activeProject,
81468257 activeRightPanelSurface,
81478258 activeProjectScripts,
8259 canReadTerminal,
8260 canOperateTerminal,
81488261 addTerminalSurface,
81498262 activeThreadRef,
81508263 activeThreadPinned,
......@@ -11769,7 +11882,7 @@ export default function ChatView(props: ChatViewProps) {
1176911882 onAddPullRequests={addPullRequestsSurface}
1177011883 onAddDevice={addDeviceSurface}
1177111884 browserAvailable={canOperatePreview && browserAvailable}
11772 terminalAvailable={activeProject !== null}
11885 terminalAvailable={activeProject !== null && canOperateTerminal}
1177311886 diffAvailable={isServerThread && isGitRepo}
1177411887 filesAvailable={activeProject !== null}
1177511888 pullRequestAvailable={pullRequestSurfaceAvailable}
......@@ -11827,7 +11940,7 @@ export default function ChatView(props: ChatViewProps) {
1182711940 onAddPullRequests={addPullRequestsSurface}
1182811941 onAddDevice={addDeviceSurface}
1182911942 browserAvailable={canOperatePreview && browserAvailable}
11830 terminalAvailable={activeProject !== null}
11943 terminalAvailable={activeProject !== null && canOperateTerminal}
1183111944 diffAvailable={isServerThread && isGitRepo}
1183211945 filesAvailable={activeProject !== null}
1183311946 pullRequestAvailable={pullRequestSurfaceAvailable}
apps/web/src/components/ProjectScriptsControl.tsx+12-5
......@@ -66,7 +66,7 @@ interface ProjectScriptsControlProps {
6666 /** Scripts declared in the project's checked-in t3.json, offered for import. */
6767 fileScripts?: ReadonlyArray<T3ProjectFileScript>;
6868 preferredScriptId?: string | null;
69 onRunScript: (script: ProjectScript) => void;
69 onRunScript?: ((script: ProjectScript) => void) | undefined;
7070 onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
7171 onUpdateScript: (
7272 scriptId: string,
......@@ -198,7 +198,8 @@ export default function ProjectScriptsControl({
198198 density={presentation === "menu" ? "touch" : "default"}
199199 key={script.id}
200200 className="group"
201 onClick={() => onRunScript(script)}
201 disabled={!onRunScript}
202 onClick={() => onRunScript?.(script)}
202203 >
203204 <ScriptIcon icon={script.icon} className="size-4" />
204205 <MenuItemLabel>
......@@ -255,7 +256,8 @@ export default function ProjectScriptsControl({
255256 {primaryScript && (
256257 <MenuItem
257258 density={presentation === "menu" ? "touch" : "default"}
258 onClick={() => onRunScript(primaryScript)}
259 disabled={!onRunScript}
260 onClick={() => onRunScript?.(primaryScript)}
259261 >
260262 <ScriptIcon icon={primaryScript.icon} className="size-4" />
261263 <MenuItemLabel>Run {primaryScript.name}</MenuItemLabel>
......@@ -308,7 +310,8 @@ export default function ProjectScriptsControl({
308310 // The tooltip wrapper replaces data-slot="button", so themed
309311 // toolbar styling needs its own hook.
310312 data-toolbar-control=""
311 onClick={() => onRunScript(primaryScript)}
313 aria-disabled={!onRunScript || undefined}
314 onClick={() => onRunScript?.(primaryScript)}
312315 />
313316 }
314317 >
......@@ -325,7 +328,11 @@ export default function ProjectScriptsControl({
325328 {primaryScript.name}
326329 </span>
327330 </TooltipTrigger>
328 <TooltipPopup side="top">Run {primaryScript.name}</TooltipPopup>
331 <TooltipPopup side="top">
332 {onRunScript
333 ? `Run ${primaryScript.name}`
334 : "Pair this client again with permission to run terminal commands."}
335 </TooltipPopup>
329336 </Tooltip>
330337 {isPanel ? (
331338 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
apps/web/src/components/ThreadTerminalDrawer.permissions.test.tsx created+153
......@@ -0,0 +1,153 @@
1import {
2 AuthTerminalOperateScope,
3 EnvironmentId,
4 ThreadId,
5 type AuthEnvironmentScope,
6} from "@t3tools/contracts";
7import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
8import { nextTerminalAttachSeedState } from "@t3tools/client-runtime/state/terminal";
9import { AsyncResult } from "effect/reactivity";
10import { act, create, type ReactTestRenderer } from "react-test-renderer";
11import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
12import type {
13 GhosttyTerminalSurface,
14 GhosttyTerminalSurfaceOptions,
15} from "~/terminal/ghostty/surface";
16
17const state = vi.hoisted(() => ({
18 allowed: true,
19 listeners: new Set<() => void>(),
20 resize: vi.fn(),
21 otherCommand: vi.fn(),
22 createSurface:
23 vi.fn<
24 (
25 mount: HTMLElement,
26 options: GhosttyTerminalSurfaceOptions,
27 ) => Promise<GhosttyTerminalSurface>
28 >(),
29}));
30
31vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
32vi.mock("../hooks/useSettings", () => ({
33 getClientSettings: () => DEFAULT_CLIENT_SETTINGS,
34 useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
35 select(DEFAULT_CLIENT_SETTINGS),
36}));
37vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => state.otherCommand }));
38vi.mock("../localApi", () => ({ readLocalApi: () => null }));
39vi.mock("../state/server", () => ({
40 serverEnvironment: { configValueAtom: () => "config" },
41}));
42vi.mock("../state/preview", () => ({ previewEnvironment: { open: "open" } }));
43vi.mock("../state/terminal", () => ({ terminalEnvironment: { resize: "resize", write: "write" } }));
44vi.mock("../state/use-atom-command", () => ({
45 useAtomCommand: (command: string) => (command === "resize" ? state.resize : state.otherCommand),
46}));
47vi.mock("../state/terminalSessions", () => ({
48 useAttachedTerminalSession: () => session,
49}));
50vi.mock("~/terminal/ghostty/surface", () => ({
51 GhosttyTerminalSurface: { create: state.createSurface },
52}));
53vi.mock("../state/session", async () => {
54 const { useSyncExternalStore } = await import("react");
55 const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) =>
56 id !== null &&
57 (scope !== AuthTerminalOperateScope || id !== threadRef.environmentId || state.allowed);
58 return {
59 readEnvironmentScope,
60 useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) =>
61 useSyncExternalStore(
62 (listener) => {
63 state.listeners.add(listener);
64 return () => state.listeners.delete(listener);
65 },
66 () => readEnvironmentScope(id, scope),
67 ),
68 };
69});
70
71import { TerminalViewport } from "./ThreadTerminalDrawer";
72
73const threadRef = {
74 environmentId: EnvironmentId.make("secondary-terminal"),
75 threadId: ThreadId.make("thread"),
76};
77const session = { ...nextTerminalAttachSeedState(), status: "running" as const, version: 1 };
78let renderer: ReactTestRenderer | undefined;
79
80beforeEach(() => {
81 state.allowed = true;
82 state.listeners.clear();
83 state.resize.mockReset().mockResolvedValue(AsyncResult.success(undefined));
84 state.otherCommand.mockReset().mockResolvedValue(AsyncResult.success(undefined));
85 // A surface can report its initial grid while asynchronous WASM setup is
86 // pending. Keep that unrelated setup pending while exercising its callback.
87 state.createSurface.mockReset().mockReturnValue(new Promise(() => undefined));
88 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
89 vi.stubGlobal("document", {
90 body: {},
91 documentElement: { classList: { contains: () => false } },
92 querySelector: () => null,
93 createElement: () => ({ getContext: () => null }),
94 });
95 vi.stubGlobal("getComputedStyle", () => ({
96 colorScheme: "light",
97 backgroundColor: "",
98 color: "",
99 getPropertyValue: () => "",
100 }));
101});
102
103afterEach(async () => {
104 await act(async () => renderer?.unmount());
105 renderer = undefined;
106 vi.unstubAllGlobals();
107});
108
109it("rechecks the target terminal grant when a retained surface callback reports a resize", async () => {
110 await act(async () => {
111 renderer = create(
112 <TerminalViewport
113 advancedTypography={false}
114 threadRef={threadRef}
115 threadId={threadRef.threadId}
116 terminalId="terminal-1"
117 terminalLabel="Terminal 1"
118 cwd="/repo"
119 onSessionExited={state.otherCommand}
120 focusRequestId={0}
121 autoFocus={false}
122 visible={true}
123 resizeEpoch={0}
124 drawerHeight={200}
125 keybindings={[]}
126 />,
127 { createNodeMock: () => ({ closest: () => null, contains: () => false }) },
128 );
129 });
130 expect(state.createSurface).toHaveBeenCalledOnce();
131 const onResize = state.createSurface.mock.calls[0]![1].onResize;
132 if (!onResize) throw new Error("The terminal did not register its resize callback.");
133
134 onResize(80, 24);
135 expect(state.resize).toHaveBeenCalledExactlyOnceWith({
136 environmentId: threadRef.environmentId,
137 input: { threadId: threadRef.threadId, terminalId: "terminal-1", cols: 80, rows: 24 },
138 });
139
140 // The connection updates before React commits its external-store update.
141 // Other environments retain access; only this terminal's target is revoked.
142 state.allowed = false;
143 onResize(120, 40);
144 expect(state.resize).toHaveBeenCalledTimes(1);
145
146 state.allowed = true;
147 onResize(100, 30);
148 expect(state.resize).toHaveBeenCalledTimes(2);
149 expect(state.resize).toHaveBeenLastCalledWith({
150 environmentId: threadRef.environmentId,
151 input: { threadId: threadRef.threadId, terminalId: "terminal-1", cols: 100, rows: 30 },
152 });
153});
apps/web/src/components/ThreadTerminalDrawer.test.ts+66-3
......@@ -1,9 +1,17 @@
11import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2import {
3 applyTerminalAttachStreamEvent,
4 combineTerminalSessionState,
5 EMPTY_TERMINAL_BUFFER_STATE,
6 INITIAL_TERMINAL_OUTPUT_CURSOR,
7 nextTerminalAttachSeedState,
8} from "@t3tools/client-runtime/state/terminal";
29
310import {
411 shouldClearTerminalSelectionAction,
512 shouldHandleTerminalExit,
613 terminalContextMenuItems,
14 synchronizeTerminalOutput,
715 terminalSelectionLineRange,
816 terminalSelectionMenuItems,
917 terminalThemeFromApp,
......@@ -138,8 +146,63 @@ describe("terminal selection actions", () => {
138146 });
139147
140148 it("handles an exit that lands while the terminal surface is still loading", () => {
141 expect(shouldHandleTerminalExit("exited", "running", false)).toBe(true);
142 expect(shouldHandleTerminalExit("exited", "exited", false)).toBe(false);
143 expect(shouldHandleTerminalExit("closed", "running", true)).toBe(false);
149 expect(shouldHandleTerminalExit("exited", "running", false, 1)).toBe(true);
150 expect(shouldHandleTerminalExit("exited", "exited", false, 1)).toBe(false);
151 expect(shouldHandleTerminalExit("closed", "running", true, 1)).toBe(false);
152 });
153
154 it.each(["closed", "exited"] as const)("ignores an unsynchronized %s seed", (status) => {
155 expect(shouldHandleTerminalExit(status, "running", false, 0)).toBe(false);
156 expect(shouldHandleTerminalExit(status, "running", false, 1)).toBe(true);
157 });
158});
159
160it("retains visible output and selection until a replacement subscription receives its snapshot", () => {
161 const terminal = { resetAndWrite: vi.fn(), write: vi.fn(), clearSelection: vi.fn() };
162 const snapshot = {
163 threadId: "thread",
164 terminalId: "term-1",
165 cwd: "/repo",
166 worktreePath: null,
167 status: "running" as const,
168 pid: 123,
169 history: "host output",
170 exitCode: null,
171 exitSignal: null,
172 label: "Terminal 1",
173 updatedAt: "2026-09-05T00:00:00.000Z",
174 };
175 const attached = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
176 type: "snapshot",
177 snapshot,
178 });
179 const cursor = synchronizeTerminalOutput(terminal, attached, INITIAL_TERMINAL_OUTPUT_CURSOR);
180 expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("host output");
181 terminal.resetAndWrite.mockClear();
182 terminal.clearSelection.mockClear();
183
184 const pending = combineTerminalSessionState(
185 { ...snapshot, hasRunningSubprocess: false },
186 EMPTY_TERMINAL_BUFFER_STATE,
187 );
188 expect(pending.status).toBe("running");
189 expect(synchronizeTerminalOutput(terminal, pending, cursor)).toBe(cursor);
190 expect(terminal.resetAndWrite).not.toHaveBeenCalled();
191 expect(terminal.write).not.toHaveBeenCalled();
192 expect(terminal.clearSelection).not.toHaveBeenCalled();
193
194 const observed = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
195 type: "snapshot",
196 snapshot: { ...snapshot, history: "host output\nobserved output" },
197 });
198 const observedCursor = synchronizeTerminalOutput(terminal, observed, cursor);
199 expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("host output\nobserved output");
200
201 const cleared = applyTerminalAttachStreamEvent(observed, {
202 type: "cleared",
203 threadId: "thread",
204 terminalId: "term-1",
144205 });
206 synchronizeTerminalOutput(terminal, cleared, observedCursor);
207 expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("");
145208});
apps/web/src/components/ThreadTerminalDrawer.tsx+135-37
......@@ -20,6 +20,8 @@ import {
2020} from "lucide-react";
2121import {
2222 AuthPreviewOperateScope,
23 AuthOrchestrationOperateScope,
24 AuthTerminalOperateScope,
2325 type ContextMenuItem,
2426 type ProviderInstanceId,
2527 type ResolvedKeybindingsConfig,
......@@ -83,6 +85,7 @@ import { serverEnvironment } from "../state/server";
8385import { previewEnvironment } from "../state/preview";
8486import { readEnvironmentScope } from "../state/session";
8587import { terminalEnvironment } from "../state/terminal";
88import { useEnvironmentScope } from "../state/session";
8689import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
8790import { useAtomCommand } from "../state/use-atom-command";
8891import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
......@@ -121,6 +124,18 @@ export function writeTerminalOutputUpdate(
121124 }
122125}
123126
127export function synchronizeTerminalOutput(
128 terminal: Pick<GhosttyTerminalSurface, "resetAndWrite" | "write" | "clearSelection">,
129 session: Pick<TerminalSessionState, "output" | "version">,
130 cursor: TerminalOutputCursor,
131): TerminalOutputCursor {
132 if (session.version === 0) return cursor;
133 const update = readTerminalOutputUpdate(session.output, cursor);
134 writeTerminalOutputUpdate(terminal, update);
135 terminal.clearSelection();
136 return update.cursor;
137}
138
124139function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor {
125140 if (typeof document === "undefined") return fallback;
126141
......@@ -272,6 +287,7 @@ export function terminalSelectionMenuItems(options?: {
272287export function terminalContextMenuItems(options: {
273288 hasSelection: boolean;
274289 canAddToChat?: boolean;
290 readOnly?: boolean;
275291}): ContextMenuItem<TerminalContextMenuAction>[] {
276292 const { hasSelection, canAddToChat = true } = options;
277293 return [
......@@ -279,7 +295,7 @@ export function terminalContextMenuItems(options: {
279295 ...item,
280296 disabled: !hasSelection,
281297 })),
282 { id: "paste", label: "Paste" },
298 { id: "paste", label: "Paste", ...(options.readOnly ? { disabled: true } : {}) },
283299 ];
284300}
285301
......@@ -302,9 +318,13 @@ export function shouldHandleTerminalExit(
302318 current: TerminalSessionState["status"],
303319 synchronized: TerminalSessionState["status"],
304320 alreadyHandled: boolean,
321 version: number,
305322): boolean {
306323 return (
307 (current === "closed" || current === "exited") && current !== synchronized && !alreadyHandled
324 version > 0 &&
325 (current === "closed" || current === "exited") &&
326 current !== synchronized &&
327 !alreadyHandled
308328 );
309329}
310330
......@@ -357,6 +377,15 @@ export function TerminalViewport({
357377 const terminalRef = useRef<GhosttyTerminalSurface | null>(null);
358378 const visibleRef = useRef(visible);
359379 const environmentId = threadRef.environmentId;
380 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
381 const hasTerminalWriteAccess = useEffectEvent(() =>
382 readEnvironmentScope(environmentId, AuthTerminalOperateScope),
383 );
384 const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
385 const canActivateTerminalLink = useEffectEvent(
386 (text: string) =>
387 isTerminalUrl(text) || readEnvironmentScope(environmentId, AuthOrchestrationOperateScope),
388 );
360389 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
361390 const openInPreferredEditor = useOpenInPreferredEditor(
362391 environmentId,
......@@ -381,7 +410,7 @@ export function TerminalViewport({
381410 const keybindingsRef = useRef(keybindings);
382411 const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);
383412 const handleSessionExited = useEffectEvent(() => {
384 onSessionExited();
413 if (hasTerminalWriteAccess()) onSessionExited();
385414 });
386415 const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {
387416 onAddTerminalContext?.(selection);
......@@ -414,29 +443,35 @@ export function TerminalViewport({
414443 ...(providerInstanceId ? { providerInstanceId } : {}),
415444 },
416445 });
446 const canResizeTerminal =
447 canOperateTerminal && terminalSession.version > 0 && terminalSession.status === "running";
448 const resizeSessionGeneration = canResizeTerminal ? terminalSession.output.generation : null;
417449 const writeTerminal = useEffectEvent((data: string) =>
418450 runTerminalWrite({
419451 environmentId,
420452 input: { threadId, terminalId, data },
421453 }),
422454 );
423 const resizeTerminal = useEffectEvent((cols: number, rows: number) =>
424 runTerminalResize({
455 const resizeTerminal = useEffectEvent((cols: number, rows: number) => {
456 if (!canResizeTerminal || !hasTerminalWriteAccess()) return;
457 return runTerminalResize({
425458 environmentId,
426459 input: { threadId, terminalId, cols, rows },
427 }),
428 );
460 });
461 });
429462 const terminalOutput = terminalSession.output;
430463 const terminalError = terminalSession.error;
431464 const terminalStatus = terminalSession.status;
432465 const outputCursorRef = useRef<TerminalOutputCursor>(INITIAL_TERMINAL_OUTPUT_CURSOR);
433466 const synchronizedStatusRef = useRef<TerminalSessionState["status"]>("closed");
434467 const synchronizeTerminalStatus = useEffectEvent(
435 (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => {
468 (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"], version: number) => {
436469 const synchronized = synchronizedStatusRef.current;
437 if (status === "running") {
470 if (version > 0 && status === "running") {
438471 hasHandledExitRef.current = false;
439 } else if (shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current)) {
472 } else if (
473 shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current, version)
474 ) {
440475 hasHandledExitRef.current = true;
441476 writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited");
442477 window.setTimeout(() => {
......@@ -445,7 +480,7 @@ export function TerminalViewport({
445480 }
446481 }, 0);
447482 }
448 synchronizedStatusRef.current = status;
483 if (version > 0) synchronizedStatusRef.current = status;
449484 },
450485 );
451486 const terminalVersion = terminalSession.version;
......@@ -467,6 +502,22 @@ export function TerminalViewport({
467502 keybindingsRef.current = keybindings;
468503 }, [keybindings]);
469504
505 useLayoutEffect(() => {
506 if (terminalRef.current) terminalRef.current.input.readOnly = !canOperateTerminal;
507 }, [canOperateTerminal]);
508
509 // A grant can change while the pointer remains over a link.
510 useEffect(() => {
511 terminalRef.current?.refreshLinkActivation();
512 }, [canOpenHostEditor]);
513
514 useEffect(() => {
515 if (resizeSessionGeneration === null) return;
516 // The first fit can finish before authorization or the attach snapshot.
517 // Replay its grid once this writable session exists, including reconnects.
518 terminalRef.current?.resendSize();
519 }, [resizeSessionGeneration]);
520
470521 useLayoutEffect(() => {
471522 visibleRef.current = visible;
472523 terminalRef.current?.setVisible(visible);
......@@ -503,6 +554,7 @@ export function TerminalViewport({
503554 onSelectionChange: () => handleSelectionChange(),
504555 beforeKey: (event) => handleBeforeKey(event),
505556 onLinkActivate: (text, event) => handleLinkActivate(text, event),
557 canActivateLink: (text) => canActivateTerminalLink(text),
506558 // The surface listens from construction, so a right-click can land
507559 // while `create` is still awaiting WASM — before the handler below it
508560 // exists. The ref is only assigned once that setup has run.
......@@ -521,6 +573,7 @@ export function TerminalViewport({
521573 terminal.setTheme(terminalThemeFromApp(mount));
522574 setupTerminal = terminal;
523575 terminalRef.current = terminal;
576 terminal.input.readOnly = !hasTerminalWriteAccess();
524577 // Client settings hydrate asynchronously; a font preference that landed
525578 // while the surface was loading found terminalRef null, so its setFont
526579 // was dropped. Re-apply whatever is current once the terminal exists.
......@@ -544,9 +597,13 @@ export function TerminalViewport({
544597 // (A session that is "closed" at mount is indistinguishable from one that
545598 // never started, so only "exited" triggers the message — as with xterm.)
546599 synchronizedStatusRef.current = "closed";
547 synchronizeTerminalStatus(terminal, latestSession.status);
600 synchronizeTerminalStatus(terminal, latestSession.status, latestSession.version);
548601 // Startup may finish after the user has returned to the composer.
549 if (visibleRef.current && mount.contains(document.activeElement)) {
602 if (
603 hasTerminalWriteAccess() &&
604 visibleRef.current &&
605 mount.contains(document.activeElement)
606 ) {
550607 terminal.focus();
551608 }
552609
......@@ -634,6 +691,7 @@ export function TerminalViewport({
634691 };
635692
636693 const pasteFromClipboard = async (requestId: number) => {
694 if (!hasTerminalWriteAccess()) return;
637695 const activeTerminal = terminalRef.current;
638696 if (!activeTerminal) return;
639697 try {
......@@ -667,6 +725,7 @@ export function TerminalViewport({
667725 terminalContextMenuItems({
668726 hasSelection: selectionAction !== null,
669727 canAddToChat: canAddSelectionToChat(),
728 readOnly: !hasTerminalWriteAccess(),
670729 }),
671730 { x: event.clientX, y: event.clientY },
672731 );
......@@ -732,6 +791,7 @@ export function TerminalViewport({
732791 };
733792
734793 const sendTerminalInput = async (data: string, fallbackError: string) => {
794 if (!hasTerminalWriteAccess()) return;
735795 const activeTerminal = terminalRef.current;
736796 if (!activeTerminal) return;
737797 const result = await writeTerminal(data);
......@@ -784,6 +844,7 @@ export function TerminalViewport({
784844 }
785845
786846 function handleLinkActivate(text: string, event: MouseEvent): void {
847 if (!canActivateTerminalLink(text)) return;
787848 const latestTerminal = terminalRef.current;
788849 if (!latestTerminal) return;
789850 if (isTerminalUrl(text)) {
......@@ -835,6 +896,7 @@ export function TerminalViewport({
835896 }
836897
837898 function handleData(data: string): void {
899 if (!hasTerminalWriteAccess()) return;
838900 void (async () => {
839901 const result = await writeTerminal(data);
840902 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
......@@ -924,7 +986,9 @@ export function TerminalViewport({
924986 cancelled = true;
925987 const hadFocus = mount.contains(document.activeElement);
926988 teardown?.();
927 if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true });
989 if (hasTerminalWriteAccess() && hadFocus && mount.isConnected) {
990 mount.focus({ preventScroll: true });
991 }
928992 };
929993 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
930994
......@@ -942,15 +1006,12 @@ export function TerminalViewport({
9421006 }
9431007
9441008 const previous = previousSessionRef.current;
945 synchronizeTerminalStatus(terminal, current.status);
1009 synchronizeTerminalStatus(terminal, current.status, current.version);
9461010 if (current.version === previous.version && current.output === previous.output) {
9471011 return;
9481012 }
9491013
950 const outputUpdate = readTerminalOutputUpdate(current.output, outputCursorRef.current);
951 writeTerminalOutputUpdate(terminal, outputUpdate);
952 outputCursorRef.current = outputUpdate.cursor;
953 terminal.clearSelection();
1014 outputCursorRef.current = synchronizeTerminalOutput(terminal, current, outputCursorRef.current);
9541015
9551016 if (current.error !== null && current.error !== previous.error) {
9561017 writeSystemMessage(terminal, current.error);
......@@ -960,11 +1021,11 @@ export function TerminalViewport({
9601021 }, [terminalOutput, terminalError, terminalStatus, terminalVersion]);
9611022
9621023 useEffect(() => {
963 if (!autoFocus || !visible) return;
1024 if (!autoFocus || !canOperateTerminal || !visible) return;
9641025 // Claim focus when requested, then hand it to the terminal once ready only
9651026 // if the user has not focused something else in the meantime.
9661027 (terminalRef.current ?? containerRef.current)?.focus();
967 }, [autoFocus, focusRequestId, visible]);
1028 }, [autoFocus, canOperateTerminal, focusRequestId, visible]);
9681029
9691030 useEffect(() => {
9701031 const terminal = terminalRef.current;
......@@ -1029,14 +1090,29 @@ interface TerminalActionButtonProps {
10291090 className: string;
10301091 onClick: () => void;
10311092 children: ReactNode;
1093 disabled?: boolean;
10321094}
10331095
1034function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) {
1096function TerminalActionButton({
1097 label,
1098 className,
1099 onClick,
1100 children,
1101 disabled,
1102}: TerminalActionButtonProps) {
10351103 return (
10361104 <Popover>
10371105 <PopoverTrigger
10381106 openOnHover
1039 render={<button type="button" className={className} onClick={onClick} aria-label={label} />}
1107 render={
1108 <button
1109 type="button"
1110 className={cn(className, disabled && "opacity-45 cursor-not-allowed")}
1111 onClick={disabled ? undefined : onClick}
1112 aria-label={label}
1113 aria-disabled={disabled}
1114 />
1115 }
10401116 >
10411117 {children}
10421118 </PopoverTrigger>
......@@ -1082,6 +1158,7 @@ export default function ThreadTerminalDrawer({
10821158 terminalLabelsById,
10831159 terminalLaunchLocationsById,
10841160}: ThreadTerminalDrawerProps) {
1161 const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope);
10851162 const isPanel = mode === "panel";
10861163 const [advancedTypography] = useLocalStorage(
10871164 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
......@@ -1273,24 +1350,28 @@ export default function ThreadTerminalDrawer({
12731350 ? `Close Terminal (${closeShortcutLabel})`
12741351 : "Close Terminal";
12751352 const onSplitTerminalAction = useCallback(() => {
1276 if (hasReachedSplitLimit) return;
1353 if (!canOperateTerminal || hasReachedSplitLimit) return;
12771354 onSplitTerminal();
1278 }, [hasReachedSplitLimit, onSplitTerminal]);
1355 }, [canOperateTerminal, hasReachedSplitLimit, onSplitTerminal]);
12791356 const onSplitTerminalVerticalAction = useCallback(() => {
1280 if (hasReachedSplitLimit) return;
1357 if (!canOperateTerminal || hasReachedSplitLimit) return;
12811358 onSplitTerminalVertical();
1282 }, [hasReachedSplitLimit, onSplitTerminalVertical]);
1359 }, [canOperateTerminal, hasReachedSplitLimit, onSplitTerminalVertical]);
12831360 const onNewTerminalAction = useCallback(() => {
1361 if (!canOperateTerminal) return;
12841362 onNewTerminal();
1285 }, [onNewTerminal]);
1363 }, [canOperateTerminal, onNewTerminal]);
12861364 const confirmCloseTerminal = useCallback(
12871365 (terminalId: string) => {
1366 if (!canOperateTerminal) return;
12881367 const label = terminalLabelById.get(terminalId) ?? getTerminalLabel(terminalId);
12891368 void confirmTerminalClose([label]).then((confirmed) => {
1290 if (confirmed) onCloseTerminal(terminalId);
1369 if (confirmed && readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) {
1370 onCloseTerminal(terminalId);
1371 }
12911372 });
12921373 },
1293 [onCloseTerminal, terminalLabelById],
1374 [canOperateTerminal, onCloseTerminal, terminalLabelById, threadRef.environmentId],
12941375 );
12951376
12961377 useEffect(() => {
......@@ -1417,7 +1498,12 @@ export default function ThreadTerminalDrawer({
14171498 ) : null}
14181499 <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">
14191500 <p>No terminal sessions for this thread yet.</p>
1420 <Button size="xs" variant="outline" onClick={onNewTerminalAction}>
1501 <Button
1502 size="xs"
1503 variant="outline"
1504 onClick={onNewTerminalAction}
1505 disabled={!canOperateTerminal}
1506 >
14211507 {newTerminalActionLabel}
14221508 </Button>
14231509 </div>
......@@ -1451,6 +1537,7 @@ export default function ThreadTerminalDrawer({
14511537 <div className="pointer-events-none absolute right-2 top-2 z-20">
14521538 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background shadow-xs">
14531539 <TerminalActionButton
1540 disabled={!canOperateTerminal}
14541541 className={`p-1 text-foreground/90 transition-colors ${
14551542 hasReachedSplitLimit
14561543 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
......@@ -1463,6 +1550,7 @@ export default function ThreadTerminalDrawer({
14631550 </TerminalActionButton>
14641551 <div className="h-4 w-px bg-border/80" />
14651552 <TerminalActionButton
1553 disabled={!canOperateTerminal}
14661554 className={`p-1 text-foreground/90 transition-colors ${
14671555 hasReachedSplitLimit
14681556 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
......@@ -1475,6 +1563,7 @@ export default function ThreadTerminalDrawer({
14751563 </TerminalActionButton>
14761564 <div className="h-4 w-px bg-border/80" />
14771565 <TerminalActionButton
1566 disabled={!canOperateTerminal}
14781567 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
14791568 onClick={onNewTerminalAction}
14801569 label={newTerminalActionLabel}
......@@ -1483,6 +1572,7 @@ export default function ThreadTerminalDrawer({
14831572 </TerminalActionButton>
14841573 <div className="h-4 w-px bg-border/80" />
14851574 <TerminalActionButton
1575 disabled={!canOperateTerminal}
14861576 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
14871577 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
14881578 label={closeTerminalActionLabel}
......@@ -1596,6 +1686,7 @@ export default function ThreadTerminalDrawer({
15961686 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
15971687 <div className="inline-flex h-full items-stretch">
15981688 <TerminalActionButton
1689 disabled={!canOperateTerminal}
15991690 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
16001691 hasReachedSplitLimit
16011692 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
......@@ -1607,6 +1698,7 @@ export default function ThreadTerminalDrawer({
16071698 <SquareSplitHorizontal className="size-3.25" />
16081699 </TerminalActionButton>
16091700 <TerminalActionButton
1701 disabled={!canOperateTerminal}
16101702 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
16111703 hasReachedSplitLimit
16121704 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
......@@ -1618,6 +1710,7 @@ export default function ThreadTerminalDrawer({
16181710 <SquareSplitVertical className="size-3.25" />
16191711 </TerminalActionButton>
16201712 <TerminalActionButton
1713 disabled={!canOperateTerminal}
16211714 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
16221715 onClick={onNewTerminalAction}
16231716 label={newTerminalActionLabel}
......@@ -1625,6 +1718,7 @@ export default function ThreadTerminalDrawer({
16251718 <Plus className="size-3.25" />
16261719 </TerminalActionButton>
16271720 <TerminalActionButton
1721 disabled={!canOperateTerminal}
16281722 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
16291723 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
16301724 label={closeTerminalActionLabel}
......@@ -1691,13 +1785,17 @@ export default function ThreadTerminalDrawer({
16911785 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
16921786 )}
16931787 >
1694 <PanelTabCloseButton
1695 label={closeTerminalLabel}
1696 onClick={() => confirmCloseTerminal(terminalId)}
1697 tooltip={closeTerminalLabel}
1698 >
1788 {canOperateTerminal ? (
1789 <PanelTabCloseButton
1790 label={closeTerminalLabel}
1791 onClick={() => confirmCloseTerminal(terminalId)}
1792 tooltip={closeTerminalLabel}
1793 >
1794 <TerminalSquare className="size-3 shrink-0" />
1795 </PanelTabCloseButton>
1796 ) : (
16991797 <TerminalSquare className="size-3 shrink-0" />
1700 </PanelTabCloseButton>
1798 )}
17011799 <button
17021800 type="button"
17031801 className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 text-left"
apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx created+470
......@@ -0,0 +1,470 @@
1import { RegistryContext } from "@effect/atom-react";
2import {
3 BearerConnectionTarget,
4 PrimaryConnectionTarget,
5} from "@t3tools/client-runtime/connection";
6import {
7 AuthTerminalOperateScope,
8 DEFAULT_SERVER_SETTINGS,
9 EnvironmentId,
10 ProviderDriverKind,
11 ProviderInstanceId,
12 type AuthEnvironmentScope,
13 type AuthSessionState,
14 type ResolvedKeybindingsConfig,
15 type ServerProvider,
16 type ServerSettings,
17 type TerminalCloseInput,
18 type TerminalOpenInput,
19 type TerminalWriteInput,
20} from "@t3tools/contracts";
21import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
22import * as Cause from "effect/Cause";
23import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
24import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer";
25import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
26
27type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>;
28type CommandResult = AsyncResult.AsyncResult<void, Error>;
29type Request<Input> = { environmentId: EnvironmentId; input: Input };
30type SetupConfig = {
31 cwd: string;
32 settings: ServerSettings;
33 environment: { platform: { os: "darwin" } };
34 keybindings: ResolvedKeybindingsConfig;
35};
36const state = vi.hoisted(() => ({
37 registry: null as AtomRegistry.AtomRegistry | null,
38 sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(),
39 providers: new Map<EnvironmentId, Atom.Writable<ReadonlyArray<ServerProvider>>>(),
40 configs: new Map<EnvironmentId, Atom.Atom<SetupConfig>>(),
41 open: vi.fn<(request: Request<TerminalOpenInput>) => Promise<CommandResult>>(),
42 write: vi.fn<(request: Request<TerminalWriteInput>) => Promise<CommandResult>>(),
43 close: vi.fn<(request: Request<TerminalCloseInput>) => Promise<CommandResult>>(),
44 refresh: vi.fn(),
45 pair: vi.fn(),
46 createProject: vi.fn(),
47 importThreads: vi.fn(),
48 complete: vi.fn<() => Promise<void>>(),
49 done: vi.fn(),
50}));
51
52vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
53vi.mock("@t3tools/client-runtime/state/session", () => ({
54 createEnvironmentSessionAtoms: () => ({
55 sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!,
56 }),
57}));
58vi.mock("../../rpc/atomRegistry", () => ({
59 get appAtomRegistry() {
60 return state.registry;
61 },
62}));
63vi.mock("../../state/environments", () => ({
64 useEnvironments: () => ({ environments: [remoteEnvironment] }),
65 usePrimaryEnvironment: () => primaryEnvironment,
66}));
67vi.mock("../../state/server", () => ({
68 serverEnvironment: {
69 providersValueAtom: (id: EnvironmentId) => state.providers.get(id)!,
70 configValueAtom: (id: EnvironmentId) => state.configs.get(id)!,
71 refreshProviders: "refresh",
72 },
73}));
74vi.mock("../../state/terminal", () => ({
75 terminalEnvironment: { open: "open", write: "write", close: "close" },
76}));
77vi.mock("../../state/use-atom-command", () => ({
78 useAtomCommand: (
79 command: "open" | "write" | "close" | "refresh" | "pair" | "createProject" | "importThreads",
80 ) => state[command],
81}));
82vi.mock("../../connection/onboarding", () => ({ connectPairing: "pair" }));
83vi.mock("../../state/agentSessions", () => ({
84 agentSessionImport: "importThreads",
85 agentSessionScan: vi.fn(),
86}));
87vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "createProject" } }));
88vi.mock("../../state/entities", () => ({ readProjects: () => [], useProjects: () => [] }));
89vi.mock("../../onboarding/useProjectScans", () => ({ useProjectScans: () => [] }));
90vi.mock("../ui/dialog", () => ({
91 Dialog: "div",
92 DialogPopup: "div",
93 DialogTitle: "div",
94 DialogHeader: "div",
95}));
96vi.mock("../ui/wizard", () => ({
97 WizardPanel: "div",
98 WizardPopup: "div",
99 WizardHeader: "div",
100 WizardSteps: ({ onStepChange }: { onStepChange: (step: number) => void }) => (
101 <button onClick={() => onStepChange(0)}>Back</button>
102 ),
103}));
104vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
105vi.mock("../../onboarding/firstRun", () => ({ useCompleteOnboarding: () => state.complete }));
106vi.mock("../../hooks/useTheme", () => ({ mountOnboardingTheme: () => undefined }));
107vi.mock("../../hooks/useLocalStorage", () => ({ useLocalStorage: () => [false, vi.fn()] }));
108vi.mock("../../hooks/useCopyToClipboard", () => ({
109 useCopyToClipboard: () => ({ copyToClipboard: vi.fn(), isCopied: false }),
110}));
111vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
112vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));
113vi.mock("@clerk/react", () => ({ useAuth: vi.fn() }));
114vi.mock("../../env", () => ({ isElectron: false }));
115vi.mock("../../providerInstances", () => ({ resolveDefaultProviderModelSelection: vi.fn() }));
116vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null }));
117vi.mock("../settings/CodexSetupSection", () => ({
118 CodexSetupSection: () => null,
119 AddManagedCodexAccountDialog: () => null,
120}));
121vi.mock("../settings/providerDriverMeta", () => ({
122 getDriverOption: (driver: string) => ({ label: driver }),
123}));
124vi.mock("../settings/providerStatus", () => ({
125 getProviderSummary: () => ({ headline: "Setup required" }),
126}));
127vi.mock("../cloud/CloudEnvironmentConnectList", () => ({
128 CloudEnvironmentConnectRows: () => null,
129}));
130vi.mock("../ThreadTerminalDrawer", () => ({
131 TerminalViewport: () => <div data-terminal-viewport />,
132}));
133vi.mock("../ui/button", () => ({ Button: "button" }));
134vi.mock("../ui/input", () => ({ Input: "input" }));
135vi.mock("../ui/checkbox", () => ({ Checkbox: "input" }));
136vi.mock("../ui/collapsible", () => ({
137 Collapsible: "div",
138 CollapsiblePanel: "div",
139 CollapsibleTrigger: "button",
140}));
141vi.mock("../ui/toast", () => ({
142 toastManager: { add: vi.fn(), update: vi.fn(), close: vi.fn() },
143}));
144
145import { WelcomeWizard } from "./WelcomeWizard";
146
147const primaryId = EnvironmentId.make("primary");
148const remoteId = EnvironmentId.make("paired-remote");
149const primaryEnvironment = {
150 environmentId: primaryId,
151 label: "This computer",
152 connection: { phase: "connected" },
153 entry: {
154 target: new PrimaryConnectionTarget({
155 environmentId: primaryId,
156 label: "This computer",
157 httpBaseUrl: "http://127.0.0.1:3773",
158 wsBaseUrl: "ws://127.0.0.1:3773",
159 }),
160 },
161} as const;
162const remoteEnvironment = {
163 environmentId: remoteId,
164 label: "Paired computer",
165 connection: { phase: "connected" },
166 entry: {
167 target: new BearerConnectionTarget({
168 environmentId: remoteId,
169 label: "Paired computer",
170 connectionId: "paired-remote",
171 }),
172 },
173} as const;
174const missingClaude: ServerProvider = {
175 instanceId: ProviderInstanceId.make("claude-work"),
176 driver: ProviderDriverKind.make("claudeAgent"),
177 enabled: true,
178 installed: false,
179 version: null,
180 status: "error",
181 auth: { status: "unknown" },
182 checkedAt: "2026-09-05T00:00:00.000Z",
183 models: [],
184 slashCommands: [],
185 skills: [],
186};
187const signedOutCodex: ServerProvider = {
188 ...missingClaude,
189 instanceId: ProviderInstanceId.make("codex-work"),
190 driver: ProviderDriverKind.make("codex"),
191 installed: true,
192 auth: { status: "unauthenticated" },
193};
194const session = (scopes: readonly AuthEnvironmentScope[]): AuthSessionState => ({
195 authenticated: true,
196 scopes,
197 auth: {
198 policy: "remote-reachable",
199 bootstrapMethods: ["one-time-token"],
200 sessionMethods: ["bearer-access-token"],
201 sessionCookieName: "t3_session",
202 },
203});
204function setAccess(environmentId: EnvironmentId, allowed: boolean) {
205 state.registry!.set(
206 state.sessions.get(environmentId)!,
207 AsyncResult.success(session(allowed ? [AuthTerminalOperateScope] : [])),
208 );
209}
210function deferred<T>() {
211 let resolve!: (value: T) => void;
212 const promise = new Promise<T>((complete) => {
213 resolve = complete;
214 });
215 return { promise, resolve };
216}
217function text(node: ReactTestInstance | string): string {
218 return typeof node === "string" ? node : node.children.map(text).join("");
219}
220let renderer: ReactTestRenderer | undefined;
221function button(label: string) {
222 return renderer!.root.findAllByType("button").find((node) => text(node) === label)!;
223}
224async function click(label: string) {
225 const node = button(label);
226 expect(node).toBeDefined();
227 expect(node.props.disabled).not.toBe(true);
228 await act(async () => node.props.onClick());
229}
230function hasViewport() {
231 return renderer!.root.findAllByProps({ "data-terminal-viewport": true }).length > 0;
232}
233async function enterRemoteAgents() {
234 await act(async () => {
235 renderer = create(
236 <RegistryContext.Provider value={state.registry!}>
237 <WelcomeWizard localAvailable onDone={state.done} />
238 </RegistryContext.Provider>,
239 );
240 });
241 await click("Continue");
242 expect(text(renderer!.root)).toContain("Paired computer");
243}
244
245beforeEach(() => {
246 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
247 vi.stubGlobal("document", { activeElement: null, body: {}, getElementById: () => null });
248 state.registry = AtomRegistry.make();
249 for (const id of [primaryId, remoteId]) {
250 state.sessions.set(
251 id,
252 Atom.make<SessionResult>(AsyncResult.success(session([AuthTerminalOperateScope]))).pipe(
253 Atom.keepAlive,
254 ),
255 );
256 state.providers.set(
257 id,
258 Atom.make<ReadonlyArray<ServerProvider>>([missingClaude, signedOutCodex]).pipe(
259 Atom.keepAlive,
260 ),
261 );
262 state.configs.set(
263 id,
264 Atom.make<SetupConfig>({
265 cwd: `/fixtures/${id}`,
266 settings: {
267 ...DEFAULT_SERVER_SETTINGS,
268 providerInstances: {
269 [signedOutCodex.instanceId]: {
270 driver: signedOutCodex.driver,
271 config: { binaryPath: "/opt/codex-work", setupMode: "existing" },
272 },
273 },
274 },
275 environment: { platform: { os: "darwin" } },
276 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
277 }),
278 );
279 }
280 state.open.mockReset().mockResolvedValue(AsyncResult.success(undefined));
281 state.write.mockReset().mockResolvedValue(AsyncResult.success(undefined));
282 state.close.mockReset().mockResolvedValue(AsyncResult.success(undefined));
283 state.refresh.mockReset().mockResolvedValue(AsyncResult.success(undefined));
284 state.pair.mockReset().mockResolvedValue(AsyncResult.success(remoteId));
285 state.complete.mockReset().mockResolvedValue(undefined);
286 state.done.mockReset();
287});
288
289afterEach(async () => {
290 await act(async () => renderer?.unmount());
291 renderer = undefined;
292 state.registry?.dispose();
293 state.sessions.clear();
294 state.providers.clear();
295 state.configs.clear();
296 vi.unstubAllGlobals();
297});
298
299describe("welcome agent terminal setup", () => {
300 it("disables both setup actions for the paired read-only connection and preserves local completion", async () => {
301 setAccess(remoteId, false);
302 await enterRemoteAgents();
303 expect(button("Install").props.disabled).toBe(true);
304 expect(button("Sign in").props.disabled).toBe(true);
305 expect(text(renderer!.root)).toContain("This connection cannot control terminals.");
306 await act(async () => {
307 button("Install").props.onClick();
308 button("Sign in").props.onClick();
309 });
310 expect(state.open).not.toHaveBeenCalled();
311 expect(state.write).not.toHaveBeenCalled();
312 expect(state.close).not.toHaveBeenCalled();
313 await click("Continue");
314 await click("Do not import projects");
315 expect(state.complete).toHaveBeenCalledOnce();
316 expect(state.done).toHaveBeenCalledExactlyOnceWith(undefined);
317 });
318
319 it("rejects a retained install action after revocation and allows a terminal-only regrant", async () => {
320 setAccess(primaryId, false);
321 await enterRemoteAgents();
322 const install = button("Install").props.onClick;
323 await act(async () => {
324 setAccess(remoteId, false);
325 install();
326 });
327 expect(state.open).not.toHaveBeenCalled();
328 expect(button("Install").props.disabled).toBe(true);
329 await act(async () => setAccess(remoteId, true));
330 expect(button("Install").props.disabled).toBe(false);
331 await act(async () => install());
332 expect(state.open).toHaveBeenCalledExactlyOnceWith({
333 environmentId: remoteId,
334 input: {
335 threadId: "onboarding-agent-setup",
336 terminalId: expect.any(String),
337 cwd: "/fixtures/paired-remote",
338 providerInstanceId: missingClaude.instanceId,
339 },
340 });
341 expect(state.write).toHaveBeenCalledExactlyOnceWith({
342 environmentId: remoteId,
343 input: {
344 threadId: "onboarding-agent-setup",
345 terminalId: state.open.mock.calls[0]![0].input.terminalId,
346 data: "curl -fsSL https://claude.ai/install.sh | bash",
347 },
348 });
349 expect(hasViewport()).toBe(true);
350 await click("Close");
351 expect(state.close).toHaveBeenCalledExactlyOnceWith({
352 environmentId: remoteId,
353 input: {
354 threadId: "onboarding-agent-setup",
355 terminalId: state.open.mock.calls[0]![0].input.terminalId,
356 deleteHistory: true,
357 },
358 });
359 });
360
361 it("pretypes the selected provider's sign-in command without executing it", async () => {
362 setAccess(primaryId, false);
363 await enterRemoteAgents();
364 await click("Sign in");
365 expect(state.open).toHaveBeenCalledExactlyOnceWith({
366 environmentId: remoteId,
367 input: {
368 threadId: "onboarding-agent-setup",
369 terminalId: expect.any(String),
370 cwd: "/fixtures/paired-remote",
371 providerInstanceId: signedOutCodex.instanceId,
372 },
373 });
374 expect(state.write).toHaveBeenCalledExactlyOnceWith({
375 environmentId: remoteId,
376 input: {
377 threadId: "onboarding-agent-setup",
378 terminalId: state.open.mock.calls[0]![0].input.terminalId,
379 data: "/opt/codex-work login",
380 },
381 });
382 expect(text(renderer!.root)).toContain("Review the command, then press Enter to run it.");
383 });
384
385 it("checks access when a queued retry starts and recovers after regrant", async () => {
386 state.open.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Could not open"))));
387 await enterRemoteAgents();
388 await click("Install");
389 const closing = deferred<CommandResult>();
390 state.close.mockReturnValueOnce(closing.promise);
391 await click("Retry");
392 expect(state.close).toHaveBeenCalledOnce();
393 expect(state.open).toHaveBeenCalledOnce();
394 await act(async () => {
395 setAccess(remoteId, false);
396 closing.resolve(AsyncResult.success(undefined));
397 });
398 expect(state.open).toHaveBeenCalledOnce();
399 expect(button("Retry").props.disabled).toBe(true);
400 expect(state.write).not.toHaveBeenCalled();
401 await act(async () => setAccess(remoteId, true));
402 await click("Retry");
403 expect(state.open).toHaveBeenCalledTimes(2);
404 expect(state.write).toHaveBeenCalledOnce();
405 expect(hasViewport()).toBe(true);
406 });
407
408 it("keeps an accepted terminal visible without pretyping when access is revoked during open", async () => {
409 const opening = deferred<CommandResult>();
410 state.open.mockReturnValueOnce(opening.promise);
411 await enterRemoteAgents();
412 await click("Install");
413 await act(async () => {
414 setAccess(remoteId, false);
415 opening.resolve(AsyncResult.success(undefined));
416 });
417 expect(state.write).not.toHaveBeenCalled();
418 expect(state.close).not.toHaveBeenCalled();
419 expect(hasViewport()).toBe(true);
420 expect(text(renderer!.root)).toContain("This connection cannot control terminals.");
421 await act(async () => setAccess(remoteId, true));
422 expect(state.open).toHaveBeenCalledOnce();
423 expect(state.write).not.toHaveBeenCalled();
424 expect(text(renderer!.root)).toContain(
425 "Run curl -fsSL https://claude.ai/install.sh | bash in this",
426 );
427 await act(async () => setAccess(remoteId, false));
428 await click("Close");
429 expect(hasViewport()).toBe(false);
430 expect(state.close).not.toHaveBeenCalled();
431 await click("Continue");
432 expect(text(renderer!.root)).toContain("Choose your projects");
433 });
434
435 it("settles accepted pretyping locally after revocation without closing the PTY", async () => {
436 const writing = deferred<CommandResult>();
437 state.write.mockReturnValueOnce(writing.promise);
438 await enterRemoteAgents();
439 await click("Sign in");
440 await act(async () => {
441 setAccess(remoteId, false);
442 writing.resolve(AsyncResult.success(undefined));
443 });
444 expect(state.open).toHaveBeenCalledOnce();
445 expect(state.write).toHaveBeenCalledOnce();
446 expect(hasViewport()).toBe(true);
447 await act(async () => setAccess(remoteId, true));
448 expect(text(renderer!.root)).toContain("Review the command, then press Enter to run it.");
449 expect(state.open).toHaveBeenCalledOnce();
450 expect(state.write).toHaveBeenCalledOnce();
451 await act(async () => setAccess(remoteId, false));
452 await click("Close");
453 expect(state.close).not.toHaveBeenCalled();
454 });
455
456 it("rechecks the original environment when cleanup runs after pending pretyping", async () => {
457 const writing = deferred<CommandResult>();
458 state.write.mockReturnValueOnce(writing.promise);
459 await enterRemoteAgents();
460 await click("Install");
461 await click("Back");
462 expect(state.close).not.toHaveBeenCalled();
463 await act(async () => {
464 setAccess(remoteId, false);
465 writing.resolve(AsyncResult.success(undefined));
466 });
467 expect(state.close).not.toHaveBeenCalled();
468 expect(text(renderer!.root)).toContain("Connect your computers");
469 });
470});
apps/web/src/components/onboarding/WelcomeWizard.tsx+51-11
......@@ -15,6 +15,7 @@ import {
1515 squashAtomCommandFailure,
1616} from "@t3tools/client-runtime/state/runtime";
1717import {
18 AuthTerminalOperateScope,
1819 CommandId,
1920 defaultInstanceIdForDriver,
2021 AuthOrchestrationOperateScope,
......@@ -61,7 +62,11 @@ import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment
6162import { useProjectScans } from "../../onboarding/useProjectScans";
6263import { projectEnvironment } from "../../state/projects";
6364import { serverEnvironment } from "../../state/server";
64import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
65import {
66 readEnvironmentScope,
67 useEnvironmentScope,
68 useEnvironmentsWithScope,
69} from "../../state/session";
6570import { terminalEnvironment } from "../../state/terminal";
6671import { useAtomCommand } from "../../state/use-atom-command";
6772import { connectPairing } from "../../connection/onboarding";
......@@ -712,6 +717,7 @@ function ConnectedAgentsStep({
712717 displayName: string;
713718 autoStart: boolean;
714719 } | null>(null);
720 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
715721
716722 // Re-probe on entry so freshly installed CLIs show up without a manual
717723 // refresh; harmless when nothing changed (single-flighted per environment).
......@@ -745,6 +751,11 @@ function ConnectedAgentsStep({
745751 return (
746752 <section>
747753 <h2 className="mb-2 text-sm font-medium">{machineLabel}</h2>
754 {!canOperateTerminal ? (
755 <p className="mb-2 text-sm text-muted-foreground">
756 This connection cannot control terminals.
757 </p>
758 ) : null}
748759 <div className="space-y-1.5">
749760 {primaryAgents.map(({ driver, provider, instanceId }) =>
750761 driver === "codex" && serverConfig !== null ? (
......@@ -759,7 +770,11 @@ function ConnectedAgentsStep({
759770 }
760771 terminalOpen={terminalSession?.driver === driver}
761772 onOpenTerminal={() => {
762 if (provider === undefined) return;
773 if (
774 provider === undefined ||
775 !readEnvironmentScope(environmentId, AuthTerminalOperateScope)
776 )
777 return;
763778 setTerminalSession({
764779 environmentId,
765780 driver,
......@@ -785,9 +800,14 @@ function ConnectedAgentsStep({
785800 driver={driver}
786801 provider={provider}
787802 terminalOpen={terminalSession?.driver === driver}
788 terminalAvailable={serverConfig !== null}
803 terminalAvailable={serverConfig !== null && canOperateTerminal}
789804 onOpenTerminal={() => {
790 if (provider === undefined || serverConfig === null) return;
805 if (
806 provider === undefined ||
807 serverConfig === null ||
808 !readEnvironmentScope(environmentId, AuthTerminalOperateScope)
809 )
810 return;
791811 setTerminalSession({
792812 environmentId,
793813 driver,
......@@ -864,6 +884,7 @@ function OnboardingCodexSetup({
864884 } | null;
865885 readonly onAutoStartConsumed: () => void;
866886}) {
887 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
867888 const update = useAtomCommand(serverEnvironment.updateSettings, "Codex setup settings");
868889 const instanceId =
869890 createdAccount?.instanceId ??
......@@ -911,7 +932,7 @@ function OnboardingCodexSetup({
911932 driver="codex"
912933 provider={provider}
913934 terminalOpen={terminalOpen}
914 terminalAvailable
935 terminalAvailable={canOperateTerminal}
915936 onOpenTerminal={onOpenTerminal}
916937 />
917938 ) : (
......@@ -1005,6 +1026,7 @@ function AgentInstallTerminal({
10051026 readonly onClose: () => void;
10061027}) {
10071028 const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session;
1029 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
10081030 // Same terminal typography preference the thread drawer honors.
10091031 const [advancedTypography] = useLocalStorage(
10101032 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
......@@ -1039,6 +1061,10 @@ function AgentInstallTerminal({
10391061
10401062 setupQueueRef.current = setupQueueRef.current.then(async () => {
10411063 if (activeSetupGenerationRef.current !== generation) return;
1064 if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
1065 setSetupState("openFailed");
1066 return;
1067 }
10421068 const opened = await openTerminal({
10431069 environmentId,
10441070 input: {
......@@ -1054,6 +1080,10 @@ function AgentInstallTerminal({
10541080 }
10551081
10561082 if (activeSetupGenerationRef.current !== generation) return;
1083 if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
1084 setSetupState("writeFailed");
1085 return;
1086 }
10571087
10581088 const wrote = await writeTerminal({
10591089 environmentId,
......@@ -1063,15 +1093,14 @@ function AgentInstallTerminal({
10631093 setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed");
10641094 });
10651095
1066 // Every exit path unmounts the drawer (Done, Continue/Skip, card switch,
1067 // session exit), so this cleanup is the single place the PTY dies —
1068 // nothing is left running behind the wizard. An interrupted install is
1069 // re-runnable from the card.
1096 // Every exit path unmounts the drawer. Close the PTY only while this
1097 // connection still has terminal access; revocation leaves it running.
10701098 return () => {
10711099 if (activeSetupGenerationRef.current === generation) {
10721100 activeSetupGenerationRef.current = null;
10731101 }
10741102 setupQueueRef.current = setupQueueRef.current.then(async () => {
1103 if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) return;
10751104 await closeTerminal({
10761105 environmentId,
10771106 input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true },
......@@ -1097,7 +1126,9 @@ function AgentInstallTerminal({
10971126 >
10981127 <div className="flex items-center justify-between border-b border-border/60 bg-background/60 px-3 py-1.5">
10991128 <span className="text-2xs font-medium text-muted-foreground">
1100 {setupState === "writeFailed" ? (
1129 {!canOperateTerminal ? (
1130 "This connection cannot control terminals."
1131 ) : setupState === "writeFailed" ? (
11011132 <>
11021133 Run <code className="rounded bg-muted px-1 font-mono">{command}</code> in this
11031134 terminal.
......@@ -1112,7 +1143,16 @@ function AgentInstallTerminal({
11121143 </span>
11131144 <div className="flex items-center gap-1">
11141145 {setupState === "openFailed" ? (
1115 <Button size="xs" variant="ghost" onClick={() => setSetupAttempt((value) => value + 1)}>
1146 <Button
1147 size="xs"
1148 variant="ghost"
1149 disabled={!canOperateTerminal}
1150 onClick={() => {
1151 if (readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
1152 setSetupAttempt((value) => value + 1);
1153 }
1154 }}
1155 >
11161156 Retry
11171157 </Button>
11181158 ) : null}
apps/web/src/components/settings/ConnectionsSettings.logic.test.ts+36
......@@ -6,8 +6,44 @@ import {
66 isQrShareableEndpoint,
77 isWslSettingsRowVisible,
88 selectQrEndpointOption,
9 togglePairingScopeSelection,
910} from "./ConnectionsSettings.logic";
1011
12describe("togglePairingScopeSelection", () => {
13 it.each([
14 {
15 label: "adds terminal:read when terminal:operate is selected",
16 current: ["orchestration:read"],
17 scope: "terminal:operate",
18 checked: true,
19 expected: ["orchestration:read", "terminal:operate", "terminal:read"],
20 },
21 {
22 label: "drops terminal:operate when terminal:read is cleared",
23 current: ["terminal:read", "terminal:operate", "relay:read"],
24 scope: "terminal:read",
25 checked: false,
26 expected: ["relay:read"],
27 },
28 {
29 label: "keeps terminal:read when terminal:operate is cleared",
30 current: ["terminal:read", "terminal:operate"],
31 scope: "terminal:operate",
32 checked: false,
33 expected: ["terminal:read"],
34 },
35 {
36 label: "toggles unrelated scopes on their own",
37 current: ["terminal:read"],
38 scope: "filesystem:read",
39 checked: true,
40 expected: ["terminal:read", "filesystem:read"],
41 },
42 ] as const)("$label", ({ current, scope, checked, expected }) => {
43 expect(togglePairingScopeSelection(current, scope, checked)).toEqual(expected);
44 });
45});
46
1147const baseWslState: DesktopWslState = {
1248 enabled: false,
1349 distro: null,
apps/web/src/components/settings/ConnectionsSettings.logic.ts+31-1
......@@ -1,4 +1,34 @@
1import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts";
1import {
2 type AdvertisedEndpoint,
3 type AuthGrantScope,
4 AuthTerminalOperateScope,
5 AuthTerminalReadScope,
6 type DesktopBridge,
7 type DesktopWslState,
8} from "@t3tools/contracts";
9
10/**
11 * Operating terminals without being able to list them leaves a client
12 * allocating a fresh shell for every script run it cannot see, so the pairing
13 * form keeps `terminal:read` alongside `terminal:operate`.
14 */
15export function togglePairingScopeSelection(
16 current: ReadonlyArray<AuthGrantScope>,
17 scope: AuthGrantScope,
18 checked: boolean,
19): ReadonlyArray<AuthGrantScope> {
20 const without = (scopes: ReadonlyArray<AuthGrantScope>) =>
21 scopes.filter((currentScope) => currentScope !== scope);
22 if (!checked) {
23 return scope === AuthTerminalReadScope
24 ? without(current).filter((currentScope) => currentScope !== AuthTerminalOperateScope)
25 : without(current);
26 }
27 const next = [...without(current), scope];
28 return scope === AuthTerminalOperateScope && !next.includes(AuthTerminalReadScope)
29 ? [...next, AuthTerminalReadScope]
30 : next;
31}
232
333/** A missing access list says nothing about whether other clients exist. */
434export function canRevokeOtherClients(
apps/web/src/components/settings/ConnectionsSettings.tsx+9-3
......@@ -37,6 +37,7 @@ import {
3737 AuthFilesystemWriteScope,
3838 AuthStandardClientScopes,
3939 AuthTerminalOperateScope,
40 AuthTerminalReadScope,
4041 type AuthClientSession,
4142 type AuthEnvironmentScope,
4243 type AuthGrantScope,
......@@ -75,6 +76,7 @@ import {
7576 isQrShareableEndpoint,
7677 isWslSettingsRowVisible,
7778 selectQrEndpointOption,
79 togglePairingScopeSelection,
7880} from "./ConnectionsSettings.logic";
7981import {
8082 SettingsPageContainer,
......@@ -269,6 +271,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
269271 title: "View diagnostics and usage",
270272 description: "Read process diagnostics, resource history, and usage totals.",
271273 },
274 {
275 scope: AuthTerminalReadScope,
276 title: "View terminals",
277 description: "Read existing terminal output and status.",
278 },
272279 {
273280 scope: AuthTerminalOperateScope,
274281 title: "Use terminals",
......@@ -1165,9 +1172,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
11651172 }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]);
11661173
11671174 const togglePairingScope = useCallback((scope: AuthGrantScope, checked: boolean) => {
1168 setPairingScopes((current) =>
1169 checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope),
1170 );
1175 setPairingScopes((current) => togglePairingScopeSelection(current, scope, checked));
11711176 }, []);
11721177
11731178 return (
......@@ -1240,6 +1245,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
12401245 AuthOrchestrationReadScope,
12411246 AuthFilesystemReadScope,
12421247 AuthDiagnosticsReadScope,
1248 AuthTerminalReadScope,
12431249 ].filter((scope) => delegatableScopes.includes(scope)),
12441250 )
12451251 }
apps/web/src/state/terminalSessionAvailability.test.ts created+104
......@@ -0,0 +1,104 @@
1import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts";
2import { act, createElement, useEffect } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6import { useKnownTerminalSessions } from "./terminalSessions";
7
8const state = vi.hoisted(() => ({
9 canRead: true,
10 data: null as ReadonlyArray<TerminalSummary> | null,
11 error: null as string | null,
12}));
13
14vi.mock("./session", () => ({ useEnvironmentScope: () => state.canRead }));
15vi.mock("./terminal", () => ({ terminalEnvironment: { metadata: () => ({}) } }));
16vi.mock("./query", () => ({
17 useEnvironmentQuery: (atom: unknown) =>
18 atom === null ? { data: null, error: null } : { data: state.data, error: state.error },
19}));
20
21let root: Root;
22let current: ReturnType<typeof useKnownTerminalSessions>;
23const target = {
24 environmentId: EnvironmentId.make("secondary"),
25 threadId: ThreadId.make("thread"),
26};
27const terminal: TerminalSummary = {
28 threadId: "thread",
29 terminalId: "term-1",
30 cwd: "/repo",
31 worktreePath: null,
32 status: "running",
33 pid: 123,
34 exitCode: null,
35 exitSignal: null,
36 hasRunningSubprocess: true,
37 label: "Busy process",
38 updatedAt: "2026-09-05T00:00:00.000Z",
39};
40
41function Probe() {
42 const sessions = useKnownTerminalSessions(target);
43 useEffect(() => {
44 current = sessions;
45 }, [sessions]);
46 return null;
47}
48
49function render() {
50 return act(() => root.render(createElement(Probe)));
51}
52
53beforeEach(() => {
54 state.canRead = true;
55 state.data = null;
56 state.error = null;
57 const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} };
58 const container = {
59 nodeType: 1,
60 tagName: "DIV",
61 namespaceURI: "http://www.w3.org/1999/xhtml",
62 ownerDocument: document,
63 addEventListener() {},
64 removeEventListener() {},
65 };
66 vi.stubGlobal("document", document);
67 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
68 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
69 root = createRoot(container as unknown as HTMLElement);
70});
71
72afterEach(async () => {
73 await act(() => root.unmount());
74 vi.unstubAllGlobals();
75});
76
77it("distinguishes missing metadata from an authoritative empty list across scope changes", async () => {
78 await render();
79 expect(current).toBeNull();
80
81 state.data = [terminal];
82 await render();
83 expect(current?.[0]).toMatchObject({
84 target: { environmentId: "secondary", terminalId: "term-1" },
85 state: { hasRunningSubprocess: true },
86 });
87
88 state.canRead = false;
89 await render();
90 expect(current).toBeNull();
91
92 state.canRead = true;
93 state.data = [];
94 await render();
95 expect(current).toEqual([]);
96});
97
98it("does not reuse cached terminal metadata after a subscription failure", async () => {
99 state.data = [terminal];
100 await render();
101 state.error = "Access denied";
102 await render();
103 expect(current).toBeNull();
104});
apps/web/src/state/terminalSessions.ts+26-10
......@@ -8,6 +8,8 @@ import {
88} from "@t3tools/client-runtime/state/terminal";
99import {
1010 ThreadId,
11 AuthTerminalReadScope,
12 AuthTerminalOperateScope,
1113 type EnvironmentId,
1214 type TerminalAttachInput,
1315 type TerminalSummary,
......@@ -16,6 +18,7 @@ import { useMemo } from "react";
1618
1719import { useEnvironmentQuery } from "./query";
1820import { terminalEnvironment } from "./terminal";
21import { useEnvironmentScope } from "./session";
1922
2023const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<ReadonlyArray<KnownTerminalSession>>([]);
2124
......@@ -125,16 +128,25 @@ export function useAttachedTerminalSession(input: {
125128 readonly environmentId: EnvironmentId | null;
126129 readonly terminal: TerminalAttachInput | null;
127130}): TerminalSessionState {
131 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
132 const canOperate = useEnvironmentScope(input.environmentId, AuthTerminalOperateScope);
128133 const attach = useEnvironmentQuery(
129134 input.environmentId !== null && input.terminal !== null
130 ? terminalEnvironment.attach({
131 environmentId: input.environmentId,
132 input: input.terminal,
133 })
135 ? canOperate
136 ? terminalEnvironment.attach({
137 environmentId: input.environmentId,
138 input: input.terminal,
139 })
140 : canRead
141 ? terminalEnvironment.observe({
142 environmentId: input.environmentId,
143 input: { threadId: input.terminal.threadId, terminalId: input.terminal.terminalId },
144 })
145 : null
134146 : null,
135147 );
136148 const metadata = useEnvironmentQuery(
137 input.environmentId === null
149 input.environmentId === null || !canRead
138150 ? null
139151 : terminalEnvironment.metadata({
140152 environmentId: input.environmentId,
......@@ -160,9 +172,10 @@ export function useAttachedTerminalSession(input: {
160172export function useKnownTerminalSessions(input: {
161173 readonly environmentId: EnvironmentId | null;
162174 readonly threadId: ThreadId | null;
163}): ReadonlyArray<KnownTerminalSession> {
175}): ReadonlyArray<KnownTerminalSession> | null {
176 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
164177 const metadata = useEnvironmentQuery(
165 input.environmentId === null
178 input.environmentId === null || !canRead
166179 ? null
167180 : terminalEnvironment.metadata({
168181 environmentId: input.environmentId,
......@@ -170,8 +183,11 @@ export function useKnownTerminalSessions(input: {
170183 }),
171184 );
172185 return useMemo(
173 () => selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId),
174 [input.environmentId, input.threadId, metadata.data],
186 () =>
187 metadata.data === null || metadata.error !== null
188 ? null
189 : selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId),
190 [input.environmentId, input.threadId, metadata.data, metadata.error],
175191 );
176192}
177193
......@@ -180,5 +196,5 @@ export function useThreadRunningTerminalIds(input: {
180196 readonly threadId: ThreadId | null;
181197}): ReadonlyArray<string> {
182198 const sessions = useKnownTerminalSessions(input);
183 return useMemo(() => selectRunningSubprocessTerminalIds(sessions), [sessions]);
199 return useMemo(() => selectRunningSubprocessTerminalIds(sessions ?? []), [sessions]);
184200}
apps/web/src/terminal/ghostty/surface.test.ts+115-2
......@@ -167,7 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => {
167167 resize() {
168168 for (const callback of resizeCallbacks) callback();
169169 },
170 pointer(type: string, clientX: number, buttons: number, shiftKey = false, button = 0) {
170 pointer(
171 type: string,
172 clientX: number,
173 buttons: number,
174 modifiers: boolean | Partial<Pick<MouseEvent, "ctrlKey" | "metaKey" | "shiftKey">> = {},
175 button = 0,
176 ) {
171177 canvas.dispatchEvent(
172178 Object.assign(new Event(type, { cancelable: true }), {
173179 clientX,
......@@ -175,7 +181,7 @@ describe("GhosttyTerminalSurface visibility", () => {
175181 pointerId: 1,
176182 button,
177183 buttons,
178 shiftKey,
184 ...(typeof modifiers === "boolean" ? { shiftKey: modifiers } : modifiers),
179185 }),
180186 );
181187 },
......@@ -210,6 +216,113 @@ describe("GhosttyTerminalSurface visibility", () => {
210216 vi.restoreAllMocks();
211217 });
212218
219 it.each([
220 { platform: "Linux x86_64", modifiers: { ctrlKey: true, metaKey: false } },
221 { platform: "MacIntel", modifiers: { ctrlKey: false, metaKey: true } },
222 ])(
223 "gates path links and preserves selection and URLs ($platform)",
224 async ({ platform, modifiers }) => {
225 vi.stubGlobal("navigator", { platform });
226 const harness = createHarness();
227 vi.spyOn(Event.prototype, "timeStamp", "get").mockImplementation(() => Date.now());
228 let canOpenPaths = false;
229 const openLink = vi.fn();
230 const surface = await harness.create({
231 canActivateLink: (text) => text.startsWith("https://") || canOpenPaths,
232 onLinkActivate: openLink,
233 });
234 surface.write("/repo/file.ts");
235 harness.flushFrame();
236 harness.pointer("pointermove", 5, 0, modifiers);
237 expect(surface.canvas.style.cursor).toBe("");
238 harness.pointer("pointerdown", 5, 1, modifiers);
239 harness.pointer("pointerup", 5, 0, modifiers);
240 expect(openLink).not.toHaveBeenCalled();
241
242 vi.advanceTimersByTime(501);
243 harness.pointer("pointerdown", 5, 1);
244 harness.pointer("pointermove", 37, 1);
245 harness.pointer("pointerup", 37, 0);
246 expect(surface.getSelection()).toBe("/repo");
247
248 vi.advanceTimersByTime(501);
249 harness.pointer("pointermove", 5, 0, modifiers);
250 canOpenPaths = true;
251 surface.refreshLinkActivation();
252 expect(surface.canvas.style.cursor).toBe("pointer");
253 harness.pointer("pointerdown", 5, 1, modifiers);
254 harness.pointer("pointerup", 5, 0, modifiers);
255 expect(openLink).toHaveBeenCalledExactlyOnceWith("/repo/file.ts", expect.any(Event));
256
257 vi.advanceTimersByTime(501);
258 harness.pointer("pointerdown", 5, 1, modifiers);
259 canOpenPaths = false;
260 surface.refreshLinkActivation();
261 expect(surface.canvas.style.cursor).toBe("");
262 harness.pointer("pointerup", 5, 0, modifiers);
263 expect(openLink).toHaveBeenCalledOnce();
264
265 vi.advanceTimersByTime(501);
266 surface.resetAndWrite("https://t3.codes");
267 harness.flushFrame();
268 harness.pointer("pointermove", 5, 0, modifiers);
269 expect(surface.canvas.style.cursor).toBe("pointer");
270 harness.pointer("pointerdown", 5, 1, modifiers);
271 harness.pointer("pointerup", 5, 0, modifiers);
272 expect(openLink).toHaveBeenLastCalledWith("https://t3.codes", expect.any(Event));
273 },
274 );
275
276 it("resends an unchanged grid when authorization and attachment become ready", async () => {
277 const harness = createHarness();
278 let canOperate = false;
279 let attached = false;
280 const resizePty = vi.fn<(cols: number, rows: number) => void>();
281 const surface = await harness.create({
282 onResize: (cols, rows) => {
283 if (canOperate && attached) resizePty(cols, rows);
284 },
285 });
286 vi.advanceTimersByTime(150);
287 expect(resizePty).not.toHaveBeenCalled();
288
289 canOperate = true;
290 surface.resendSize();
291 vi.advanceTimersByTime(150);
292 expect(resizePty).not.toHaveBeenCalled();
293
294 attached = true;
295 surface.resendSize();
296 vi.advanceTimersByTime(150);
297 expect(resizePty).toHaveBeenCalledExactlyOnceWith(20, 6);
298 surface.fit();
299 vi.advanceTimersByTime(150);
300 expect(resizePty).toHaveBeenCalledOnce();
301
302 canOperate = false;
303 harness.mount.clientWidth = 248;
304 surface.fit();
305 vi.advanceTimersByTime(150);
306 expect(resizePty).toHaveBeenCalledOnce();
307 });
308
309 it("replays the current grid on reveal when a hidden host becomes ready", async () => {
310 const harness = createHarness();
311 const onResize = vi.fn<(cols: number, rows: number) => void>();
312 const surface = await harness.create({ onResize });
313 vi.advanceTimersByTime(150);
314 onResize.mockClear();
315
316 surface.setVisible(false);
317 surface.resendSize();
318 vi.advanceTimersByTime(150);
319 expect(onResize).not.toHaveBeenCalled();
320
321 surface.setVisible(true);
322 vi.advanceTimersByTime(150);
323 expect(onResize).toHaveBeenCalledExactlyOnceWith(20, 6);
324 });
325
213326 it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => {
214327 const harness = createHarness();
215328 const surface = await harness.create();
apps/web/src/terminal/ghostty/surface.ts+18
......@@ -547,6 +547,7 @@ export interface GhosttyTerminalSurfaceOptions {
547547 readonly onSelectionChange: () => void;
548548 readonly beforeKey: (event: KeyboardEvent) => boolean;
549549 readonly onLinkActivate: (text: string, event: MouseEvent) => void;
550 readonly canActivateLink?: (text: string) => boolean;
550551 /**
551552 * A right-click the running application did not claim through mouse
552553 * reporting. The host owns the menu, so it also owns preventing the browser
......@@ -793,6 +794,12 @@ export class GhosttyTerminalSurface {
793794 this.requestRender();
794795 }
795796
797 /** Re-evaluate link feedback after the host's available actions change. */
798 refreshLinkActivation(): void {
799 if (this.disposed) return;
800 this.refreshHoveredLink();
801 }
802
796803 async setFont(font: GhosttyTerminalFont): Promise<void> {
797804 if (this.disposed) return;
798805 const fontSize = terminalFontSize(font.size);
......@@ -855,6 +862,12 @@ export class GhosttyTerminalSurface {
855862 this.applyFontMetrics();
856863 };
857864
865 /** Replay the measured grid after the host becomes ready to resize its PTY. */
866 resendSize(): void {
867 this.resizeNotified = false;
868 this.fit();
869 }
870
858871 fit(): boolean {
859872 if (this.disposed || !this.visible) return false;
860873 const width = this.mount.clientWidth;
......@@ -1926,6 +1939,11 @@ export class GhosttyTerminalSurface {
19261939 }
19271940
19281941 private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null {
1942 const link = this.findLinkAt(clientX, clientY);
1943 return link && this.options.canActivateLink?.(link.text) !== false ? link : null;
1944 }
1945
1946 private findLinkAt(clientX: number, clientY: number): TerminalLinkWithRange | null {
19291947 if (!this.snapshot) return null;
19301948 const cell = terminalGridCellAt({
19311949 bounds: this.canvas.getBoundingClientRect(),
packages/client-runtime/src/rpc/client.ts+2-1
......@@ -66,7 +66,8 @@ export type EnvironmentSubscriptionRpcTag =
6666 | typeof WS_METHODS.subscribeVcsStatus
6767 | typeof WS_METHODS.subscribeWorktreeSetup
6868 | typeof WS_METHODS.subscribeProjectClones
69 | typeof WS_METHODS.terminalAttach;
69 | typeof WS_METHODS.terminalAttach
70 | typeof WS_METHODS.terminalObserve;
7071
7172export type EnvironmentStreamCommandRpcTag =
7273 | typeof WS_METHODS.chatGptHandoffSubscribe
packages/client-runtime/src/state/terminal.ts+9
......@@ -37,6 +37,15 @@ export function createTerminalEnvironmentAtoms<R, E>(
3737 }) => JSON.stringify([environmentId, input.threadId, input.terminalId ?? null]);
3838 const lifecycleConcurrency = { mode: "serial" as const, key: terminalThreadKey };
3939 return {
40 observe: createEnvironmentSubscriptionAtomFamily(runtime, {
41 label: "environment-data:terminal:observe",
42 subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.terminalObserve>) =>
43 Stream.suspend(() =>
44 subscribe(WS_METHODS.terminalObserve, input).pipe(
45 Stream.scan(nextTerminalAttachSeedState, applyTerminalAttachStreamEvent),
46 ),
47 ),
48 }),
4049 attach: createEnvironmentSubscriptionAtomFamily(runtime, {
4150 label: "environment-data:terminal:attach",
4251 subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.terminalAttach>) =>
packages/contracts/src/auth.ts+3
......@@ -85,6 +85,7 @@ export const AuthProvidersManageScope = "providers:manage" as const;
8585export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
8686export const AuthPreviewOperateScope = "preview:operate" as const;
8787export const AuthDiagnosticsReadScope = "diagnostics:read" as const;
88export const AuthTerminalReadScope = "terminal:read" as const;
8889export const AuthTerminalOperateScope = "terminal:operate" as const;
8990export const AuthSourceControlWriteScope = "source-control:write" as const;
9091export const AuthFilesystemReadScope = "filesystem:read" as const;
......@@ -103,6 +104,7 @@ export const AuthEnvironmentScope = Schema.Literals([
103104 AuthEnvironmentMaintainScope,
104105 AuthPreviewOperateScope,
105106 AuthDiagnosticsReadScope,
107 AuthTerminalReadScope,
106108 AuthTerminalOperateScope,
107109 AuthFilesystemReadScope,
108110 AuthFilesystemWriteScope,
......@@ -132,6 +134,7 @@ export const AuthStandardClientScopes = [
132134 AuthEnvironmentMaintainScope,
133135 AuthPreviewOperateScope,
134136 AuthDiagnosticsReadScope,
137 AuthTerminalReadScope,
135138 AuthTerminalOperateScope,
136139 AuthSourceControlWriteScope,
137140 AuthFilesystemReadScope,
packages/contracts/src/rpc.ts+10
......@@ -218,6 +218,7 @@ import {
218218} from "./project.ts";
219219import {
220220 TerminalAttachInput,
221 TerminalObserveInput,
221222 TerminalAttachStreamEvent,
222223 TerminalClearInput,
223224 TerminalCloseInput,
......@@ -406,6 +407,7 @@ export const WS_METHODS = {
406407 // Terminal methods
407408 terminalOpen: "terminal.open",
408409 terminalAttach: "terminal.attach",
410 terminalObserve: "terminal.observe",
409411 terminalWrite: "terminal.write",
410412 terminalResize: "terminal.resize",
411413 terminalClear: "terminal.clear",
......@@ -1351,6 +1353,13 @@ const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, {
13511353 stream: true,
13521354});
13531355
1356const WsTerminalObserveRpc = Rpc.make(WS_METHODS.terminalObserve, {
1357 payload: TerminalObserveInput,
1358 success: TerminalAttachStreamEvent,
1359 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
1360 stream: true,
1361});
1362
13541363const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {
13551364 payload: TerminalWriteInput,
13561365 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
......@@ -1866,6 +1875,7 @@ export const WsRpcGroup = RpcGroup.make(
18661875 WsReviewGetDiffFileContentsRpc,
18671876 WsTerminalOpenRpc,
18681877 WsTerminalAttachRpc,
1878 WsTerminalObserveRpc,
18691879 WsTerminalWriteRpc,
18701880 WsTerminalResizeRpc,
18711881 WsTerminalClearRpc,
packages/contracts/src/terminal.ts+3
......@@ -60,6 +60,9 @@ export const TerminalAttachInput = Schema.Struct({
6060});
6161export type TerminalAttachInput = typeof TerminalAttachInput.Type;
6262
63export const TerminalObserveInput = TerminalSessionInput;
64export type TerminalObserveInput = Schema.Codec.Encoded<typeof TerminalObserveInput>;
65
6366export const TerminalWriteInput = Schema.Struct({
6467 ...TerminalSessionInput.fields,
6568 data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)),
packages/shared/src/terminalLabels.test.ts+24-1
......@@ -1,10 +1,13 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import type { TerminalSummary } from "@t3tools/contracts";
4import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts";
4import { DEFAULT_TERMINAL_ID, TerminalOpenInput } from "@t3tools/contracts";
5import * as Schema from "effect/Schema";
56
67import { getTerminalLabel, nextTerminalId, resolveTerminalSessionLabel } from "./terminalLabels.ts";
78
9const decodeTerminalOpen = Schema.decodeUnknownSync(TerminalOpenInput);
10
811describe("getTerminalLabel", () => {
912 it("uses the numeric suffix for term-* ids", () => {
1013 expect(getTerminalLabel(DEFAULT_TERMINAL_ID)).toBe("Terminal 1");
......@@ -16,6 +19,10 @@ describe("getTerminalLabel", () => {
1619 it("falls back to the raw id for unknown shapes", () => {
1720 expect(getTerminalLabel("custom-session")).toBe("custom-session");
1821 });
22
23 it("keeps a numbered label for terminals allocated without host metadata", () => {
24 expect(getTerminalLabel("term-2-783c91cc-a413-47c7-8312-c2a5a1f05e40")).toBe("Terminal 2");
25 });
1926});
2027
2128describe("resolveTerminalSessionLabel", () => {
......@@ -50,4 +57,20 @@ describe("nextTerminalId", () => {
5057 expect(nextTerminalId(["", " ", DEFAULT_TERMINAL_ID])).toBe("term-2");
5158 expect(nextTerminalId(["", " "])).toBe("term-1");
5259 });
60
61 it("avoids unseen sessions when metadata is unavailable and resumes normal numbering", () => {
62 const first = nextTerminalId([], "783c91cc-a413-47c7-8312-c2a5a1f05e40");
63 const second = nextTerminalId([first], "102315fc-ceef-45c4-b978-c4d4947d3c26");
64 expect(first).not.toBe(DEFAULT_TERMINAL_ID);
65 expect(second).not.toBe(first);
66 expect(getTerminalLabel(second)).toBe("Terminal 2");
67 expect(nextTerminalId([first, second])).toBe("term-3");
68 expect(
69 decodeTerminalOpen({
70 threadId: "thread",
71 terminalId: second,
72 cwd: "/workspace",
73 }),
74 ).toMatchObject({ terminalId: second });
75 });
5376});
packages/shared/src/terminalLabels.ts+16-7
......@@ -1,8 +1,14 @@
11import type { TerminalSummary } from "@t3tools/contracts";
22
3function terminalNumber(terminalId: string): string | undefined {
4 return /^term(?:inal)?-(\d+)(?:-[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12})?$/i.exec(
5 terminalId,
6 )?.[1];
7}
8
39/** Human-readable label for a terminal tab; matches mobile and web sidebars. */
410export function getTerminalLabel(terminalId: string): string {
5 const numericSuffix = /^term(?:inal)?-(\d+)$/i.exec(terminalId)?.[1];
11 const numericSuffix = terminalNumber(terminalId);
612 if (numericSuffix) {
713 return `Terminal ${numericSuffix}`;
814 }
......@@ -26,15 +32,18 @@ export function resolveTerminalSessionLabel(
2632 * Client-side terminal id allocator. Ids are ALWAYS chosen by the client and sent explicitly
2733 * on every `terminal.open` / `terminal.attach` call — the server never allocates.
2834 *
29 * Returns the lowest unused `term-N` id (starting at `term-1`), skipping any ids already in
30 * `existingTerminalIds`.
35 * Returns the lowest unused `term-N` number. When metadata is unavailable,
36 * callers append a UUID so an unseen host session cannot be reused accidentally.
3137 */
32export function nextTerminalId(existingTerminalIds: ReadonlyArray<string>): string {
33 const usedIds = new Set(existingTerminalIds.filter((id) => id.trim().length > 0));
38export function nextTerminalId(
39 existingTerminalIds: ReadonlyArray<string>,
40 uniqueSuffix?: string,
41): string {
42 const usedNumbers = new Set(existingTerminalIds.map(terminalNumber));
3443 let nextIndex = 1;
35 while (usedIds.has(`term-${nextIndex}`)) {
44 while (usedNumbers.has(String(nextIndex))) {
3645 nextIndex += 1;
3746 }
3847
39 return `term-${nextIndex}`;
48 return `term-${nextIndex}${uniqueSuffix === undefined ? "" : `-${uniqueSuffix}`}`;
4049}