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() {...@@ -30,6 +30,10 @@ class T3TerminalModule : Module() {
30 view.focusRequest = focusRequest30 view.focusRequest = focusRequest
31 }31 }
3232
33 Prop("readOnly") { view: T3TerminalView, readOnly: Boolean ->
34 view.readOnly = readOnly
35 }
36
33 Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean ->37 Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean ->
34 view.autoFocus = autoFocus38 view.autoFocus = autoFocus
35 }39 }
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...@@ -100,6 +100,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
100 }100 }
101 }101 }
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
103 var autoFocus: Boolean = true113 var autoFocus: Boolean = true
104 set(value) {114 set(value) {
105 field = value115 field = value
...@@ -238,6 +248,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex...@@ -238,6 +248,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
238 InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS248 InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
239 inputView.setPadding(0, 0, 0, 0)249 inputView.setPadding(0, 0, 0, 0)
240 inputView.setOnEditorActionListener { _, actionId, event ->250 inputView.setOnEditorActionListener { _, actionId, event ->
251 if (readOnly) return@setOnEditorActionListener true
241 val isKeyUp = event?.action == KeyEvent.ACTION_UP252 val isKeyUp = event?.action == KeyEvent.ACTION_UP
242 val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp253 val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp
243 val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER &&254 val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER &&
...@@ -252,6 +263,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex...@@ -252,6 +263,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
252 }263 }
253 }264 }
254 inputView.setOnKeyListener { _, keyCode, event ->265 inputView.setOnKeyListener { _, keyCode, event ->
266 if (readOnly) return@setOnKeyListener true
255 if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false267 if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false
256 when {268 when {
257 keyCode == KeyEvent.KEYCODE_DEL -> {269 keyCode == KeyEvent.KEYCODE_DEL -> {
...@@ -273,11 +285,11 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex...@@ -273,11 +285,11 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
273 override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit285 override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
274286
275 override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {287 override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
288 if (readOnly) return
276 if (clearingInput || s == null || count <= 0) return289 if (clearingInput || s == null || count <= 0) return
277 val end = (start + count).coerceAtMost(s.length)290 val end = (start + count).coerceAtMost(s.length)
278 if (start >= end) return291 if (start < end) {
279 val insertedText = s.subSequence(start, end).toString()292 val insertedText = s.subSequence(start, end).toString()
280 if (insertedText.isNotEmpty()) {
281 onInput(mapOf("data" to insertedText))293 onInput(mapOf("data" to insertedText))
282 }294 }
283 }295 }
...@@ -390,12 +402,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex...@@ -390,12 +402,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
390 }402 }
391403
392 private fun emitResponse(response: ByteArray) {404 private fun emitResponse(response: ByteArray) {
393 if (response.isNotEmpty()) {405 if (!readOnly && response.isNotEmpty()) {
394 onInput(mapOf("data" to String(response, Charsets.UTF_8)))406 onInput(mapOf("data" to String(response, Charsets.UTF_8)))
395 }407 }
396 }408 }
397409
398 private fun requestKeyboardFocus() {410 private fun requestKeyboardFocus() {
411 if (readOnly) return
399 inputView.requestFocus()412 inputView.requestFocus()
400 val inputMethodManager = context.getSystemService(413 val inputMethodManager = context.getSystemService(
401 Context.INPUT_METHOD_SERVICE414 Context.INPUT_METHOD_SERVICE
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift+4
...@@ -27,6 +27,10 @@ public class T3TerminalModule: Module {...@@ -27,6 +27,10 @@ public class T3TerminalModule: Module {
27 view.focusRequest = focusRequest27 view.focusRequest = focusRequest
28 }28 }
2929
30 Prop("readOnly") { (view: T3TerminalView, readOnly: Bool) in
31 view.readOnly = readOnly
32 }
33
30 Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in34 Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in
31 view.autoFocus = autoFocus35 view.autoFocus = autoFocus
32 }36 }
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+11-3
...@@ -274,6 +274,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {...@@ -274,6 +274,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
274 }274 }
275 }275 }
276276
277 var readOnly = false {
278 didSet {
279 if readOnly {
280 inputField.resignFirstResponder()
281 }
282 }
283 }
284
277 var autoFocus = true {285 var autoFocus = true {
278 didSet {286 didSet {
279 guard oldValue != autoFocus else { return }287 guard oldValue != autoFocus else { return }
...@@ -610,7 +618,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {...@@ -610,7 +618,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
610 guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return }618 guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return }
611619
612 DispatchQueue.main.async {620 DispatchQueue.main.async {
613 view.onInput(["data": input])621 view.emitInput(input)
614 }622 }
615 }, userdata)623 }, userdata)
616 }624 }
...@@ -687,13 +695,13 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {...@@ -687,13 +695,13 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
687 }695 }
688696
689 private func requestKeyboardFocus() {697 private func requestKeyboardFocus() {
690 guard window != nil else { return }698 guard window != nil, !readOnly else { return }
691 inputField.becomeFirstResponder()699 inputField.becomeFirstResponder()
692 textInputModeDidChange()700 textInputModeDidChange()
693 }701 }
694702
695 private func emitInput(_ data: String) {703 private func emitInput(_ data: String) {
696 guard !data.isEmpty else { return }704 guard !readOnly, !data.isEmpty else { return }
697 onInput(["data": data])705 onInput(["data": data])
698 }706 }
699707
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+30-14
...@@ -23,6 +23,7 @@ import {...@@ -23,6 +23,7 @@ import {
23 type TerminalTheme,23 type TerminalTheme,
24} from "./terminalTheme";24} from "./terminalTheme";
25import { terminalDebugLog } from "./terminalDebugLog";25import { terminalDebugLog } from "./terminalDebugLog";
26import { useTerminalSurfaceBuffer } from "./useTerminalSurfaceBuffer";
2627
27interface TerminalInputEvent {28interface TerminalInputEvent {
28 readonly data: string;29 readonly data: string;
...@@ -35,9 +36,10 @@ interface TerminalResizeEvent {...@@ -35,9 +36,10 @@ interface TerminalResizeEvent {
3536
36interface TerminalSurfaceProps extends ViewProps {37interface TerminalSurfaceProps extends ViewProps {
37 readonly terminalKey: string;38 readonly terminalKey: string;
38 readonly buffer: string;39 readonly buffer: string | null;
39 readonly fontSize?: number;40 readonly fontSize?: number;
40 readonly isRunning: boolean;41 readonly isRunning: boolean;
42 readonly readOnly?: boolean;
41 readonly autoFocus?: boolean;43 readonly autoFocus?: boolean;
42 readonly keyboardFocusRequest?: number;44 readonly keyboardFocusRequest?: number;
43 readonly captureRequest?: number;45 readonly captureRequest?: number;
...@@ -47,6 +49,10 @@ interface TerminalSurfaceProps extends ViewProps {...@@ -47,6 +49,10 @@ interface TerminalSurfaceProps extends ViewProps {
47 readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;49 readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;
48}50}
4951
52type ReadyTerminalSurfaceProps = Omit<TerminalSurfaceProps, "buffer"> & {
53 readonly buffer: string;
54};
55
50function estimateGridSize(input: {56function estimateGridSize(input: {
51 readonly width: number;57 readonly width: number;
52 readonly height: number;58 readonly height: number;
...@@ -60,14 +66,18 @@ function estimateGridSize(input: {...@@ -60,14 +66,18 @@ function estimateGridSize(input: {
60 };66 };
61}67}
6268
63const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {69const FallbackTerminalSurface = memo(function FallbackTerminalSurface(
70 props: ReadyTerminalSurfaceProps,
71) {
64 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;72 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
65 const inputRef = useRef<TextInputInstance>(null);73 const inputRef = useRef<TextInputInstance>(null);
66 const { themeAppearance, themeId } = useAppearancePreferences();74 const { themeAppearance, themeId } = useAppearancePreferences();
67 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);75 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
68 const statusLabel = props.isRunning76 const statusLabel = props.readOnly
69 ? "Native terminal unavailable. Using text fallback."77 ? "Viewing terminal output."
70 : "Open terminal to start a shell.";78 : props.isRunning
79 ? "Native terminal unavailable. Using text fallback."
80 : "Open terminal to start a shell.";
7181
72 const handleLayout = (event: LayoutChangeEvent) => {82 const handleLayout = (event: LayoutChangeEvent) => {
73 const { width, height } = event.nativeEvent.layout;83 const { width, height } = event.nativeEvent.layout;
...@@ -75,14 +85,14 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter...@@ -75,14 +85,14 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
75 };85 };
7686
77 useEffect(() => {87 useEffect(() => {
78 if ((props.keyboardFocusRequest ?? 0) > 0) {88 if (!props.readOnly && (props.keyboardFocusRequest ?? 0) > 0) {
79 inputRef.current?.blur();89 inputRef.current?.blur();
80 const focusFrame = requestAnimationFrame(() => inputRef.current?.focus());90 const focusFrame = requestAnimationFrame(() => inputRef.current?.focus());
81 return () => cancelAnimationFrame(focusFrame);91 return () => cancelAnimationFrame(focusFrame);
82 }92 }
8393
84 return undefined;94 return undefined;
85 }, [props.keyboardFocusRequest]);95 }, [props.keyboardFocusRequest, props.readOnly]);
8696
87 return (97 return (
88 <View98 <View
...@@ -135,7 +145,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter...@@ -135,7 +145,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
135 autoCapitalize="none"145 autoCapitalize="none"
136 autoCorrect={false}146 autoCorrect={false}
137 blurOnSubmit={false}147 blurOnSubmit={false}
138 editable={props.isRunning}148 editable={props.isRunning && !props.readOnly}
139 placeholder="type and press return"149 placeholder="type and press return"
140 placeholderTextColor={theme.mutedForeground}150 placeholderTextColor={theme.mutedForeground}
141 returnKeyType="send"151 returnKeyType="send"
...@@ -155,9 +165,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter...@@ -155,9 +165,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
155 }}165 }}
156 />166 />
157 <Pressable167 <Pressable
158 disabled={!props.isRunning}168 disabled={!props.isRunning || props.readOnly}
159 style={({ pressed }) => ({169 style={({ pressed }) => ({
160 opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1,170 opacity: !props.isRunning || props.readOnly ? 0.35 : pressed ? 0.65 : 1,
161 paddingHorizontal: 10,171 paddingHorizontal: 10,
162 paddingVertical: 6,172 paddingVertical: 6,
163 borderRadius: 8,173 borderRadius: 8,
...@@ -175,6 +185,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter...@@ -175,6 +185,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
175});185});
176186
177export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {187export 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) {
178 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;193 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
179 const { themeAppearance, themeId } = useAppearancePreferences();194 const { themeAppearance, themeId } = useAppearancePreferences();
180 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);195 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
...@@ -194,7 +209,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf...@@ -194,7 +209,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
194 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);209 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
195 const handleNativeInput = useCallback(210 const handleNativeInput = useCallback(
196 (event: NativeSyntheticEvent<TerminalInputEvent>) => {211 (event: NativeSyntheticEvent<TerminalInputEvent>) => {
197 if (!props.isRunning) {212 if (!props.isRunning || props.readOnly) {
198 return;213 return;
199 }214 }
200 terminalDebugLog("native:onInput", {215 terminalDebugLog("native:onInput", {
...@@ -202,7 +217,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf...@@ -202,7 +217,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
202 });217 });
203 onInput(event.nativeEvent.data);218 onInput(event.nativeEvent.data);
204 },219 },
205 [onInput, props.isRunning],220 [onInput, props.isRunning, props.readOnly],
206 );221 );
207 const handleNativeResize = useCallback(222 const handleNativeResize = useCallback(
208 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {223 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {
...@@ -219,9 +234,10 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf...@@ -219,9 +234,10 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
219 <View style={props.style}>234 <View style={props.style}>
220 <NativeTerminalSurfaceView235 <NativeTerminalSurfaceView
221 appearanceScheme={themeAppearance}236 appearanceScheme={themeAppearance}
222 autoFocus={props.autoFocus ?? true}237 autoFocus={!props.readOnly && (props.autoFocus ?? true)}
238 readOnly={props.readOnly ?? false}
223 backgroundColor={theme.background}239 backgroundColor={theme.background}
224 focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0}240 focusRequest={props.isRunning && !props.readOnly ? (props.keyboardFocusRequest ?? 0) : 0}
225 foregroundColor={theme.foreground}241 foregroundColor={theme.foreground}
226 mutedForegroundColor={theme.mutedForeground}242 mutedForegroundColor={theme.mutedForeground}
227 terminalKey={props.terminalKey}243 terminalKey={props.terminalKey}
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+153-137
...@@ -1,4 +1,10 @@...@@ -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";
2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";8import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
3import { SymbolView } from "../../components/AppSymbol";9import { SymbolView } from "../../components/AppSymbol";
4import { ScreenHeader } from "../../components/ScreenHeader";10import { ScreenHeader } from "../../components/ScreenHeader";
...@@ -32,7 +38,10 @@ import { MaterialIconButton } from "../../components/MaterialIconButton";...@@ -32,7 +38,10 @@ import { MaterialIconButton } from "../../components/MaterialIconButton";
32import { environmentCatalog } from "../../connection/catalog";38import { environmentCatalog } from "../../connection/catalog";
33import { useEnvironmentPresentation } from "../../state/presentation";39import { useEnvironmentPresentation } from "../../state/presentation";
34import { terminalEnvironment } from "../../state/terminal";40import { terminalEnvironment } from "../../state/terminal";
41import { environmentSession, readEnvironmentScope } from "../../state/session";
42import { useEnvironmentQuery } from "../../state/query";
35import { useAtomCommand } from "../../state/use-atom-command";43import { useAtomCommand } from "../../state/use-atom-command";
44import { uuidv4 } from "../../lib/uuid";
36import { useServerConfigs } from "../../state/entities";45import { useServerConfigs } from "../../state/entities";
37import { useConnectionsReady } from "../../state/workspace";46import { useConnectionsReady } from "../../state/workspace";
38import {47import {
...@@ -79,10 +88,13 @@ import {...@@ -79,10 +88,13 @@ import {
79} from "./terminalInput";88} from "./terminalInput";
80import { createTerminalPasteSession } from "./terminalPaste";89import { createTerminalPasteSession } from "./terminalPaste";
81import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";90import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
91import { useTerminalGridSync } from "./useTerminalGridSync";
92import { useTerminalLifecycle } from "./useTerminalLifecycle";
8293
83function TerminalHeader(props: {94function TerminalHeader(props: {
84 readonly subtitle: string;95 readonly subtitle: string;
85 readonly isEnvironmentReady: boolean;96 readonly isEnvironmentReady: boolean;
97 readonly canOperateTerminal: boolean;
86 readonly fontSize: number;98 readonly fontSize: number;
87 readonly terminalId: string;99 readonly terminalId: string;
88 readonly sessions: ReadonlyArray<TerminalMenuSession>;100 readonly sessions: ReadonlyArray<TerminalMenuSession>;
...@@ -149,6 +161,7 @@ function TerminalHeader(props: {...@@ -149,6 +161,7 @@ function TerminalHeader(props: {
149 })),161 })),
150 {162 {
151 id: "terminal-new",163 id: "terminal-new",
164 disabled: !props.canOperateTerminal,
152 title: "Open new terminal",165 title: "Open new terminal",
153 icon: "plus",166 icon: "plus",
154 subtitle: `Start another shell in ${basename(props.workspaceRoot) ?? "this workspace"}`,167 subtitle: `Start another shell in ${basename(props.workspaceRoot) ?? "this workspace"}`,
...@@ -260,6 +273,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -260,6 +273,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
260 ? EnvironmentId.make(routeEnvironmentIdRaw)273 ? EnvironmentId.make(routeEnvironmentIdRaw)
261 : null;274 : null;
262 const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null;275 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;
263 const environment = useEnvironmentPresentation(routeEnvironmentId);285 const environment = useEnvironmentPresentation(routeEnvironmentId);
264 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";286 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
265 const requestedTerminalId = firstRouteParam(params.terminalId);287 const requestedTerminalId = firstRouteParam(params.terminalId);
...@@ -283,18 +305,25 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -283,18 +305,25 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
283 terminalId,305 terminalId,
284 })306 })
285 : null;307 : null;
286 const knownSessions = useKnownTerminalSessions({308 const {
309 sessions: knownSessions,
310 isPending: sessionsPending,
311 error: sessionsError,
312 } = useKnownTerminalSessions({
287 environmentId: selectedThread?.environmentId ?? null,313 environmentId: selectedThread?.environmentId ?? null,
288 threadId: selectedThread?.id ?? null,314 threadId: selectedThread?.id ?? null,
289 });315 });
290 const runningSession = useMemo(316 const runningSession = useMemo(
291 () => pickRunningTerminalSessionForBootstrap(knownSessions),317 () =>
292 [knownSessions],318 pickRunningTerminalSessionForBootstrap(knownSessions ?? []) ??
319 (canOperateTerminal ? null : (knownSessions?.[0] ?? null)),
320 [canOperateTerminal, knownSessions],
293 );321 );
294 const activeKnownSession = useMemo(322 const activeKnownSession = useMemo(
295 () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null,323 () => knownSessions?.find((session) => session.target.terminalId === terminalId) ?? null,
296 [knownSessions, terminalId],324 [knownSessions, terminalId],
297 );325 );
326 const hasTerminalTarget = requestedTerminalId !== null || activeKnownSession !== null;
298 const launchTarget = useMemo(327 const launchTarget = useMemo(
299 () =>328 () =>
300 selectedThread329 selectedThread
...@@ -418,13 +447,28 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -418,13 +447,28 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
418 terminalId,447 terminalId,
419 ],448 ],
420 );449 );
450 const observingTerminal =
451 !canOperateTerminal && canReadTerminal && selectedThread !== null && hasTerminalTarget;
421 const terminal = useAttachedTerminalSession({452 const terminal = useAttachedTerminalSession({
422 environmentId: selectedThread?.environmentId ?? null,453 environmentId: selectedThread?.environmentId ?? null,
423 terminal: terminalAttachInput,454 terminal: canOperateTerminal
455 ? terminalAttachInput
456 : observingTerminal
457 ? { threadId: selectedThread.id, terminalId }
458 : null,
424 });459 });
425 const terminalKey = selectedThread460 const terminalKey = selectedThread
426 ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}`461 ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}`
427 : terminalId;462 : 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 });
428 const bufferReplayKey = useMemo(472 const bufferReplayKey = useMemo(
429 () => getTerminalBufferReplayKey({ terminalKey, fontSize }),473 () => getTerminalBufferReplayKey({ terminalKey, fontSize }),
430 [fontSize, terminalKey],474 [fontSize, terminalKey],
...@@ -439,64 +483,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -439,64 +483,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
439 });483 });
440 const isRunning = terminal.status === "running" || terminal.status === "starting";484 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);
449 const pendingExitNavigationRef = useRef<string | null>(null);486 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
500 useEffect(() => {488 useEffect(() => {
501 terminalDebugLog("surface:props", {489 terminalDebugLog("surface:props", {
502 terminalKey,490 terminalKey,
...@@ -596,7 +584,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -596,7 +584,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
596 height: state.height,584 height: state.height,
597 isVisible: state.isVisible,585 isVisible: state.isVisible,
598 }));586 }));
599 const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed;587 const isAccessoryVisible = canOperateTerminal && keyboardState.isVisible && !isAccessoryDismissed;
600 // Android's terminal owns an EditText; turning off autoFocus also clears its native focus.588 // Android's terminal owns an EditText; turning off autoFocus also clears its native focus.
601 const terminalAutoFocus =589 const terminalAutoFocus =
602 Platform.OS === "android"590 Platform.OS === "android"
...@@ -624,7 +612,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -624,7 +612,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
624 const terminalMenuSessions = useMemo<ReadonlyArray<TerminalMenuSession>>(612 const terminalMenuSessions = useMemo<ReadonlyArray<TerminalMenuSession>>(
625 () =>613 () =>
626 buildTerminalMenuSessions({614 buildTerminalMenuSessions({
627 knownSessions,615 knownSessions: knownSessions ?? [],
628 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,616 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,
629 currentSession: {617 currentSession: {
630 terminalId,618 terminalId,
...@@ -707,8 +695,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -707,8 +695,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
707 useEffect(() => {695 useEffect(() => {
708 const initialInput = pendingLaunch?.initialInput;696 const initialInput = pendingLaunch?.initialInput;
709 if (697 if (
698 !canOperateTerminal ||
710 !initialInput ||699 !initialInput ||
711 !selectedThread ||700 !selectedThread ||
701 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) ||
712 terminal.version === 0 ||702 terminal.version === 0 ||
713 sentInitialInputKeyRef.current === launchTargetKey703 sentInitialInputKeyRef.current === launchTargetKey
714 ) {704 ) {
...@@ -730,6 +720,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -730,6 +720,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
730 terminal.version,720 terminal.version,
731 terminalId,721 terminalId,
732 writeTerminal,722 writeTerminal,
723 canOperateTerminal,
733 ]);724 ]);
734725
735 useEffect(() => {726 useEffect(() => {
...@@ -795,7 +786,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -795,7 +786,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
795 /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */786 /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */
796 const writeInput = useCallback(787 const writeInput = useCallback(
797 async (data: string): Promise<boolean> => {788 async (data: string): Promise<boolean> => {
798 if (!selectedThread || !isRunning) {789 if (
790 !selectedThread ||
791 !isRunning ||
792 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
793 ) {
799 return false;794 return false;
800 }795 }
801796
...@@ -820,11 +815,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -820,11 +815,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
820815
821 // Drop delayed clipboard reads whenever the route or attached pty changes.816 // Drop delayed clipboard reads whenever the route or attached pty changes.
822 useEffect(() => {817 useEffect(() => {
823 pasteSession.reset(isRunning);818 pasteSession.reset(canOperateTerminal && isRunning);
824 return () => {819 return () => {
825 pasteSession.reset(false);820 pasteSession.reset(false);
826 };821 };
827 }, [isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]);822 }, [canOperateTerminal, isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]);
828823
829 const pasteFromClipboard = useCallback(async () => {824 const pasteFromClipboard = useCallback(async () => {
830 await pasteSession.paste({825 await pasteSession.paste({
...@@ -896,31 +891,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -896,31 +891,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
896 }891 }
897892
898 setLastGridSize(size);893 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 });
912 },894 },
913 [895 [
914 isRunning,
915 lastGridSize.cols,896 lastGridSize.cols,
916 lastGridSize.rows,897 lastGridSize.rows,
917 bufferReplayKey,898 bufferReplayKey,
918 readyBufferReplayKey,899 readyBufferReplayKey,
919 routeEnvironmentId,900 routeEnvironmentId,
920 routeThreadId,901 routeThreadId,
921 resizeTerminal,
922 scheduleBufferReplayReady,902 scheduleBufferReplayReady,
923 selectedThread,
924 terminalId,903 terminalId,
925 terminalKey,904 terminalKey,
926 ],905 ],
...@@ -989,59 +968,54 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -989,59 +968,54 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
989 }968 }
990 }, [navigation, selectedThread, terminalId, terminalMenuSessions]);969 }, [navigation, selectedThread, terminalId, terminalMenuSessions]);
991970
992 useEffect(() => {971 useTerminalLifecycle({
993 // Detached (hidden surface or environment drop): forget the running972 terminalKey,
994 // marker so a reattach takes the stale-reopen path instead of misreading973 canOperate: canOperateTerminal,
995 // the dead snapshot as an exit observed on this screen. A pending exit974 observing: observingTerminal,
996 // navigation stays armed — it only clears once the session runs again —975 attached: terminalAttachInput !== null && selectedThread !== null,
997 // so refocusing a dead screen still leaves it.976 terminal,
998 if (terminalAttachInput === null) {977 reopen: async () => {
999 runningTerminalKeyRef.current = null;978 if (
1000 return;979 terminalAttachInput === null ||
1001 }980 selectedThread === null ||
1002 if (isRunning) {981 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
1003 runningTerminalKeyRef.current = terminalKey;982 )
1004 // The session came back (e.g. respawned elsewhere) before the user983 return false;
1005 // returned; a stale pending exit must not eject a live terminal.984 const result = await openTerminal({
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({
1020 environmentId: selectedThread.environmentId,985 environmentId: selectedThread.environmentId,
1021 input: {986 input: {
1022 threadId: selectedThread.id,987 threadId: selectedThread.id,
1023 terminalId,988 terminalId,
989 cwd: terminalAttachInput.cwd,
990 worktreePath: terminalAttachInput.worktreePath,
991 cols: terminalAttachInput.cols,
992 rows: terminalAttachInput.rows,
993 ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}),
1024 },994 },
1025 });995 });
1026 }996 return result._tag === "Success";
1027 if (navigation.isFocused()) {997 },
1028 navigateAwayAfterExit();998 onRunning: () => {
1029 return;999 pendingExitNavigationRef.current = null;
1030 }1000 },
1031 // An unfocused screen can't navigate; leave when the user returns so1001 onExit: () => {
1032 // they never land on the dead session.1002 if (
1033 pendingExitNavigationRef.current = terminalKey;1003 selectedThread === null ||
1034 }, [1004 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
1035 closeTerminal,1005 )
1036 isRunning,1006 return;
1037 navigateAwayAfterExit,1007 void closeTerminal({
1038 navigation,1008 environmentId: selectedThread.environmentId,
1039 selectedThread,1009 input: { threadId: selectedThread.id, terminalId },
1040 terminal.status,1010 });
1041 terminalAttachInput,1011 if (navigation.isFocused()) {
1042 terminalId,1012 navigateAwayAfterExit();
1043 terminalKey,1013 return;
1044 ]);1014 }
1015 // Leave a background terminal screen when it is focused again.
1016 pendingExitNavigationRef.current = terminalKey;
1017 },
1018 });
10451019
1046 useEffect(1020 useEffect(
1047 () =>1021 () =>
...@@ -1056,7 +1030,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1056,7 +1030,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1056 );1030 );
10571031
1058 const handleOpenNewTerminal = useCallback(() => {1032 const handleOpenNewTerminal = useCallback(() => {
1059 if (!selectedThread) {1033 if (
1034 !selectedThread ||
1035 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
1036 ) {
1060 return;1037 return;
1061 }1038 }
10621039
...@@ -1067,10 +1044,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1067,10 +1044,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1067 terminalId: nextOpenTerminalId({1044 terminalId: nextOpenTerminalId({
1068 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),1045 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
1069 activeRouteTerminalId: terminalId,1046 activeRouteTerminalId: terminalId,
1047 ...(knownSessions === null ||
1048 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
1049 ? { uniqueSuffix: uuidv4() }
1050 : {}),
1070 }),1051 }),
1071 }),1052 }),
1072 );1053 );
1073 }, [navigation, selectedThread, terminalId, terminalMenuSessions]);1054 }, [knownSessions, navigation, selectedThread, terminalId, terminalMenuSessions]);
10741055
1075 const handleDecreaseFontSize = useCallback(() => {1056 const handleDecreaseFontSize = useCallback(() => {
1076 setTerminalFontSize(stepTerminalFontSize(fontSize, -1));1057 setTerminalFontSize(stepTerminalFontSize(fontSize, -1));
...@@ -1081,7 +1062,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1081,7 +1062,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1081 }, [fontSize, setTerminalFontSize]);1062 }, [fontSize, setTerminalFontSize]);
10821063
1083 const handleClearTerminal = useCallback(() => {1064 const handleClearTerminal = useCallback(() => {
1084 if (!selectedThread) {1065 if (
1066 !selectedThread ||
1067 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
1068 ) {
1085 return;1069 return;
1086 }1070 }
10871071
...@@ -1130,9 +1114,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1130,9 +1114,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1130 }, []);1114 }, []);
11311115
1132 const handleShowKeyboard = useCallback(() => {1116 const handleShowKeyboard = useCallback(() => {
1117 if (!canOperateTerminal) return;
1133 setIsAccessoryDismissed(false);1118 setIsAccessoryDismissed(false);
1134 setKeyboardFocusRequest((current) => current + 1);1119 setKeyboardFocusRequest((current) => current + 1);
1135 }, []);1120 }, [canOperateTerminal]);
1136 const handleRetryEnvironment = useCallback(() => {1121 const handleRetryEnvironment = useCallback(() => {
1137 if (routeEnvironmentId !== null) {1122 if (routeEnvironmentId !== null) {
1138 void retryEnvironment(routeEnvironmentId);1123 void retryEnvironment(routeEnvironmentId);
...@@ -1186,6 +1171,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1186,6 +1171,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1186 />1171 />
1187 ) : null}1172 ) : null}
1188 <TerminalHeader1173 <TerminalHeader
1174 canOperateTerminal={canOperateTerminal}
1189 subtitle={headerSubtitle}1175 subtitle={headerSubtitle}
1190 isEnvironmentReady={isEnvironmentReady}1176 isEnvironmentReady={isEnvironmentReady}
1191 fontSize={fontSize}1177 fontSize={fontSize}
...@@ -1232,6 +1218,34 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1232,6 +1218,34 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1232 resourceName="terminal"1218 resourceName="terminal"
1233 onRetry={handleRetryEnvironment}1219 onRetry={handleRetryEnvironment}
1234 />1220 />
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} />
1235 ) : (1249 ) : (
1236 <>1250 <>
1237 <View1251 <View
...@@ -1249,8 +1263,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1249,8 +1263,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1249 }}1263 }}
1250 />1264 />
1251 <TerminalSurface1265 <TerminalSurface
1252 autoFocus={terminalAutoFocus}1266 autoFocus={canOperateTerminal && terminalAutoFocus}
1253 buffer={terminalSurfaceBuffer}1267 readOnly={!canOperateTerminal}
1268 buffer={terminal.version === 0 ? null : terminalSurfaceBuffer}
1254 fontSize={fontSize}1269 fontSize={fontSize}
1255 isRunning={isRunning}1270 isRunning={isRunning}
1256 keyboardFocusRequest={keyboardFocusRequest}1271 keyboardFocusRequest={keyboardFocusRequest}
...@@ -1279,6 +1294,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1279,6 +1294,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1279 <View className="flex-1" />1294 <View className="flex-1" />
1280 <MaterialIconButton1295 <MaterialIconButton
1281 accessibilityLabel="Show keyboard"1296 accessibilityLabel="Show keyboard"
1297 disabled={!canOperateTerminal}
1282 icon="keyboard"1298 icon="keyboard"
1283 onPress={handleShowKeyboard}1299 onPress={handleShowKeyboard}
1284 />1300 />
...@@ -1345,7 +1361,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1345,7 +1361,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1345 </ComposerToolbarRow>1361 </ComposerToolbarRow>
1346 </View>1362 </View>
1347 </KeyboardStickyView>1363 </KeyboardStickyView>
1348 ) : !keyboardState.isVisible && Platform.OS !== "android" ? (1364 ) : canOperateTerminal && !keyboardState.isVisible && Platform.OS !== "android" ? (
1349 <Pressable1365 <Pressable
1350 accessibilityLabel="Show keyboard"1366 accessibilityLabel="Show keyboard"
1351 accessibilityRole="button"1367 accessibilityRole="button"
apps/mobile/src/features/terminal/nativeTerminalModule.ts+1
...@@ -24,6 +24,7 @@ interface TerminalResizeEvent {...@@ -24,6 +24,7 @@ interface TerminalResizeEvent {
24export interface NativeTerminalSurfaceProps extends ViewProps {24export interface NativeTerminalSurfaceProps extends ViewProps {
25 readonly appearanceScheme?: "light" | "dark";25 readonly appearanceScheme?: "light" | "dark";
26 readonly autoFocus?: boolean;26 readonly autoFocus?: boolean;
27 readonly readOnly?: boolean;
27 readonly focusRequest?: number;28 readonly focusRequest?: number;
28 readonly captureRequest?: number;29 readonly captureRequest?: number;
29 readonly onCapture?: (event: NativeSyntheticEvent<{ readonly text: string }>) => void;30 readonly onCapture?: (event: NativeSyntheticEvent<{ readonly text: string }>) => void;
apps/mobile/src/features/terminal/terminalMenu.test.ts+23
...@@ -129,6 +129,19 @@ describe("buildTerminalMenuSessions", () => {...@@ -129,6 +129,19 @@ describe("buildTerminalMenuSessions", () => {
129});129});
130130
131describe("nextOpenTerminalId", () => {131describe("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
132 it("matches nextTerminalId when not on a terminal route", () => {145 it("matches nextTerminalId when not on a terminal route", () => {
133 expect(nextOpenTerminalId({ listedTerminalIds: [] })).toBe(DEFAULT_TERMINAL_ID);146 expect(nextOpenTerminalId({ listedTerminalIds: [] })).toBe(DEFAULT_TERMINAL_ID);
134 expect(nextOpenTerminalId({ listedTerminalIds: [DEFAULT_TERMINAL_ID] })).toBe("term-2");147 expect(nextOpenTerminalId({ listedTerminalIds: [DEFAULT_TERMINAL_ID] })).toBe("term-2");
...@@ -208,6 +221,16 @@ describe("previousLiveTerminalId", () => {...@@ -208,6 +221,16 @@ describe("previousLiveTerminalId", () => {
208});221});
209222
210describe("resolveProjectScriptTerminalId", () => {223describe("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
211 it("reuses the default shell when no terminal is running", () => {234 it("reuses the default shell when no terminal is running", () => {
212 expect(235 expect(
213 resolveProjectScriptTerminalId({236 resolveProjectScriptTerminalId({
apps/mobile/src/features/terminal/terminalMenu.ts+6-4
...@@ -70,15 +70,16 @@ export function getTerminalStatusLabel(input: {...@@ -70,15 +70,16 @@ export function getTerminalStatusLabel(input: {
70export function nextOpenTerminalId(input: {70export function nextOpenTerminalId(input: {
71 readonly listedTerminalIds: ReadonlyArray<string>;71 readonly listedTerminalIds: ReadonlyArray<string>;
72 readonly activeRouteTerminalId?: string | null;72 readonly activeRouteTerminalId?: string | null;
73 readonly uniqueSuffix?: string;
73}): string {74}): string {
74 const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0);75 const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0);
75 const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null;76 const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null;
7677
77 if (!routeId || listed.includes(routeId)) {78 if (!routeId || listed.includes(routeId)) {
78 return nextTerminalId(listed);79 return nextTerminalId(listed, input.uniqueSuffix);
79 }80 }
8081
81 return nextTerminalId([...listed, routeId]);82 return nextTerminalId([...listed, routeId], input.uniqueSuffix);
82}83}
8384
84export function buildTerminalMenuSessions(input: {85export function buildTerminalMenuSessions(input: {
...@@ -146,12 +147,13 @@ export function previousLiveTerminalId(input: {...@@ -146,12 +147,13 @@ export function previousLiveTerminalId(input: {
146export function resolveProjectScriptTerminalId(input: {147export function resolveProjectScriptTerminalId(input: {
147 readonly existingTerminalIds: ReadonlyArray<string>;148 readonly existingTerminalIds: ReadonlyArray<string>;
148 readonly hasRunningTerminal: boolean;149 readonly hasRunningTerminal: boolean;
150 readonly uniqueSuffix?: string;
149}): string {151}): string {
150 if (!input.hasRunningTerminal) {152 if (!input.hasRunningTerminal && input.uniqueSuffix === undefined) {
151 return DEFAULT_TERMINAL_ID;153 return DEFAULT_TERMINAL_ID;
152 }154 }
153155
154 return nextTerminalId(input.existingTerminalIds);156 return nextTerminalId(input.existingTerminalIds, input.uniqueSuffix);
155}157}
156158
157export function projectScriptMenuLabel(script: ProjectScript): string {159export 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 & {...@@ -102,6 +102,7 @@ type ThreadGitControlsProps = ThreadGitMenuProps & {
102 readonly onPress: () => void;102 readonly onPress: () => void;
103 };103 };
104 readonly canOpenTerminal: boolean;104 readonly canOpenTerminal: boolean;
105 readonly canOperateTerminal: boolean;
105 readonly canOpenFiles: boolean;106 readonly canOpenFiles: boolean;
106 readonly projectScripts: ReadonlyArray<ProjectScript>;107 readonly projectScripts: ReadonlyArray<ProjectScript>;
107 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;108 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
...@@ -287,6 +288,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit...@@ -287,6 +288,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
287 items: [288 items: [
288 ...props.projectScripts.map((script) => ({289 ...props.projectScripts.map((script) => ({
289 description: script.command,290 description: script.command,
291 disabled: !props.canOperateTerminal,
290 icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const },292 icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const },
291 label: projectScriptMenuLabel(script),293 label: projectScriptMenuLabel(script),
292 onPress: () => void props.onRunProjectScript(script),294 onPress: () => void props.onRunProjectScript(script),
...@@ -321,6 +323,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit...@@ -321,6 +323,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
321 })),323 })),
322 {324 {
323 description: "Start another shell for this thread",325 description: "Start another shell for this thread",
326 disabled: !props.canOperateTerminal,
324 icon: { name: "plus", type: "sfSymbol" },327 icon: { name: "plus", type: "sfSymbol" },
325 label: "Open new terminal",328 label: "Open new terminal",
326 onPress: props.onOpenNewTerminal,329 onPress: props.onOpenNewTerminal,
...@@ -417,6 +420,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit...@@ -417,6 +420,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
417 model.runQuickAction,420 model.runQuickAction,
418 props.canOpenFiles,421 props.canOpenFiles,
419 props.canOpenTerminal,422 props.canOpenTerminal,
423 props.canOperateTerminal,
420 props.gitStatus,424 props.gitStatus,
421 props.onMergeBack,425 props.onMergeBack,
422 props.onOpenNewTerminal,426 props.onOpenNewTerminal,
...@@ -474,6 +478,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {...@@ -474,6 +478,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
474 <NativeHeaderToolbar.MenuAction478 <NativeHeaderToolbar.MenuAction
475 key={script.id}479 key={script.id}
476 icon={projectScriptMenuIcon(script.icon)}480 icon={projectScriptMenuIcon(script.icon)}
481 disabled={!props.canOperateTerminal}
477 onPress={() => void props.onRunProjectScript(script)}482 onPress={() => void props.onRunProjectScript(script)}
478 subtitle={script.command}483 subtitle={script.command}
479 >484 >
...@@ -512,6 +517,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {...@@ -512,6 +517,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
512 ))}517 ))}
513 <NativeHeaderToolbar.MenuAction518 <NativeHeaderToolbar.MenuAction
514 icon="plus"519 icon="plus"
520 disabled={!props.canOperateTerminal}
515 onPress={props.onOpenNewTerminal}521 onPress={props.onOpenNewTerminal}
516 subtitle="Start another shell for this thread"522 subtitle="Start another shell for this thread"
517 >523 >
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+52-10
...@@ -16,12 +16,13 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro...@@ -16,12 +16,13 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
16import * as Option from "effect/Option";16import * as Option from "effect/Option";
17import {17import {
18 AuthOrchestrationOperateScope,18 AuthOrchestrationOperateScope,
19 AuthTerminalOperateScope,
20 AuthTerminalReadScope,
19 DEFAULT_SERVER_SETTINGS,21 DEFAULT_SERVER_SETTINGS,
20 EnvironmentId,22 EnvironmentId,
21 ThreadId,23 ThreadId,
22 type ProjectScript,24 type ProjectScript,
23} from "@t3tools/contracts";25} from "@t3tools/contracts";
24import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
25import {26import {
26 projectScriptCwd,27 projectScriptCwd,
27 projectScriptRuntimeEnv,28 projectScriptRuntimeEnv,
...@@ -48,6 +49,8 @@ import {...@@ -48,6 +49,8 @@ import {
48 useRemoteEnvironmentRuntime,49 useRemoteEnvironmentRuntime,
49} from "../../state/use-remote-environment-registry";50} from "../../state/use-remote-environment-registry";
50import { useKnownTerminalSessions } from "../../state/use-terminal-session";51import { useKnownTerminalSessions } from "../../state/use-terminal-session";
52import { uuidv4 } from "../../lib/uuid";
53import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
51import { useSelectedThreadDetailState } from "../../state/use-thread-detail";54import { useSelectedThreadDetailState } from "../../state/use-thread-detail";
52import { useThreadSelection } from "../../state/use-thread-selection";55import { useThreadSelection } from "../../state/use-thread-selection";
53import { GitActionProgressOverlay } from "./GitActionProgressOverlay";56import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
...@@ -119,7 +122,7 @@ function ThreadHeader(...@@ -119,7 +122,7 @@ function ThreadHeader(
119 onPress: filesVisible ? toggleAuxiliaryPane : props.onOpenFilesInspector,122 onPress: filesVisible ? toggleAuxiliaryPane : props.onOpenFilesInspector,
120 });123 });
121 }124 }
122 if (props.hasWorkspaceRoot) {125 if (props.hasWorkspaceRoot && props.gitControls.canOpenTerminal) {
123 actions.push({126 actions.push({
124 accessibilityLabel: "Open terminal",127 accessibilityLabel: "Open terminal",
125 icon: "terminal",128 icon: "terminal",
...@@ -150,6 +153,7 @@ function ThreadHeader(...@@ -150,6 +153,7 @@ function ThreadHeader(
150 props.onReturnToThread,153 props.onReturnToThread,
151 props.hasThreadCwd,154 props.hasThreadCwd,
152 props.hasWorkspaceRoot,155 props.hasWorkspaceRoot,
156 props.gitControls.canOpenTerminal,
153 ]);157 ]);
154158
155 return (159 return (
...@@ -339,6 +343,14 @@ function ThreadRouteContent(...@@ -339,6 +343,14 @@ function ThreadRouteContent(
339 selectedThread?.environmentId ?? null,343 selectedThread?.environmentId ?? null,
340 AuthOrchestrationOperateScope,344 AuthOrchestrationOperateScope,
341 );345 );
346 const canReadTerminal = useEnvironmentScope(
347 selectedThread?.environmentId ?? null,
348 AuthTerminalReadScope,
349 );
350 const canOperateTerminal = useEnvironmentScope(
351 selectedThread?.environmentId ?? null,
352 AuthTerminalOperateScope,
353 );
342 const selectedThreadDetailState = props.selectedThreadDetailState;354 const selectedThreadDetailState = props.selectedThreadDetailState;
343 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);355 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
344 const { selectedThreadCwd } = useSelectedThreadWorktree();356 const { selectedThreadCwd } = useSelectedThreadWorktree();
...@@ -504,14 +516,14 @@ function ThreadRouteContent(...@@ -504,14 +516,14 @@ function ThreadRouteContent(
504 })516 })
505 : null,517 : null,
506 );518 );
507 const knownTerminalSessions = useKnownTerminalSessions({519 const { sessions: knownTerminalSessions } = useKnownTerminalSessions({
508 environmentId: selectedThread?.environmentId ?? null,520 environmentId: selectedThread?.environmentId ?? null,
509 threadId: selectedThread?.id ?? null,521 threadId: selectedThread?.id ?? null,
510 });522 });
511 const terminalMenuSessions = useMemo(523 const terminalMenuSessions = useMemo(
512 () =>524 () =>
513 buildTerminalMenuSessions({525 buildTerminalMenuSessions({
514 knownSessions: knownTerminalSessions,526 knownSessions: knownTerminalSessions ?? [],
515 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,527 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,
516 }),528 }),
517 [knownTerminalSessions, selectedThreadProject?.workspaceRoot],529 [knownTerminalSessions, selectedThreadProject?.workspaceRoot],
...@@ -703,7 +715,12 @@ function ThreadRouteContent(...@@ -703,7 +715,12 @@ function ThreadRouteContent(
703 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),715 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
704 });716 });
705717
706 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {718 if (
719 !selectedThread ||
720 !selectedThreadProject?.workspaceRoot ||
721 (!readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope) &&
722 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope))
723 ) {
707 return;724 return;
708 }725 }
709726
...@@ -723,19 +740,33 @@ function ThreadRouteContent(...@@ -723,19 +740,33 @@ function ThreadRouteContent(
723 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),740 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
724 });741 });
725742
726 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {743 if (
744 !selectedThread ||
745 !selectedThreadProject?.workspaceRoot ||
746 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
747 ) {
727 return;748 return;
728 }749 }
729750
730 const nextId = nextOpenTerminalId({751 const nextId = nextOpenTerminalId({
731 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),752 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
753 ...(knownTerminalSessions === null ||
754 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
755 ? { uniqueSuffix: uuidv4() }
756 : {}),
732 });757 });
733 void navigation.navigate("ThreadTerminal", {758 void navigation.navigate("ThreadTerminal", {
734 environmentId: String(selectedThread.environmentId),759 environmentId: String(selectedThread.environmentId),
735 threadId: String(selectedThread.id),760 threadId: String(selectedThread.id),
736 terminalId: nextId,761 terminalId: nextId,
737 });762 });
738 }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]);763 }, [
764 knownTerminalSessions,
765 navigation,
766 selectedThread,
767 selectedThreadProject?.workspaceRoot,
768 terminalMenuSessions,
769 ]);
739770
740 const handleRunProjectScript = useCallback(771 const handleRunProjectScript = useCallback(
741 async (script: ProjectScript) => {772 async (script: ProjectScript) => {
...@@ -746,16 +777,24 @@ function ThreadRouteContent(...@@ -746,16 +777,24 @@ function ThreadRouteContent(
746 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),777 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
747 });778 });
748779
749 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {780 if (
781 !selectedThread ||
782 !selectedThreadProject?.workspaceRoot ||
783 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
784 ) {
750 terminalDebugLog("project-script:abort", {785 terminalDebugLog("project-script:abort", {
751 scriptId: script.id,786 scriptId: script.id,
752 reason: "no-thread-or-workspace",787 reason: "no-thread-workspace-or-terminal-access",
753 });788 });
754 return;789 return;
755 }790 }
756791
757 const targetTerminalId = resolveProjectScriptTerminalId({792 const targetTerminalId = resolveProjectScriptTerminalId({
758 existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),793 existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
794 ...(knownTerminalSessions === null ||
795 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
796 ? { uniqueSuffix: uuidv4() }
797 : {}),
759 hasRunningTerminal: terminalMenuSessions.some(798 hasRunningTerminal: terminalMenuSessions.some(
760 (session) => session.status === "running" || session.status === "starting",799 (session) => session.status === "running" || session.status === "starting",
761 ),800 ),
...@@ -804,6 +843,7 @@ function ThreadRouteContent(...@@ -804,6 +843,7 @@ function ThreadRouteContent(
804 selectedThreadDetailWorktreePath,843 selectedThreadDetailWorktreePath,
805 selectedThreadProject,844 selectedThreadProject,
806 terminalMenuSessions,845 terminalMenuSessions,
846 knownTerminalSessions,
807 ],847 ],
808 );848 );
809 const threadGitControlProps = {849 const threadGitControlProps = {
...@@ -826,7 +866,9 @@ function ThreadRouteContent(...@@ -826,7 +866,9 @@ function ThreadRouteContent(
826 currentBranch: selectedThread?.branch ?? null,866 currentBranch: selectedThread?.branch ?? null,
827 gitStatus: gitStatus.data,867 gitStatus: gitStatus.data,
828 gitOperationLabel: gitState.gitOperationLabel,868 gitOperationLabel: gitState.gitOperationLabel,
829 canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot),869 canOpenTerminal:
870 Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal),
871 canOperateTerminal,
830 canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),872 canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
831 projectScripts: selectedThreadProject873 projectScripts: selectedThreadProject
832 ? resolveProjectScripts(874 ? resolveProjectScripts(
apps/mobile/src/state/use-terminal-session.ts+31-13
...@@ -6,11 +6,18 @@ import {...@@ -6,11 +6,18 @@ import {
6 type KnownTerminalSession,6 type KnownTerminalSession,
7 type TerminalSessionState,7 type TerminalSessionState,
8} from "@t3tools/client-runtime/state/terminal";8} 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";
10import { useMemo } from "react";16import { useMemo } from "react";
1117
12import { useEnvironmentQuery } from "./query";18import { useEnvironmentQuery } from "./query";
13import { terminalEnvironment } from "./terminal";19import { terminalEnvironment } from "./terminal";
20import { useEnvironmentScope } from "./session";
1421
15type LegacyTerminalSessionState = TerminalSessionState & { readonly buffer: string };22type LegacyTerminalSessionState = TerminalSessionState & { readonly buffer: string };
16const EMPTY_LEGACY_TERMINAL_SESSION_STATE: LegacyTerminalSessionState = {23const EMPTY_LEGACY_TERMINAL_SESSION_STATE: LegacyTerminalSessionState = {
...@@ -22,16 +29,25 @@ export function useAttachedTerminalSession(input: {...@@ -22,16 +29,25 @@ export function useAttachedTerminalSession(input: {
22 readonly environmentId: EnvironmentId | null;29 readonly environmentId: EnvironmentId | null;
23 readonly terminal: TerminalAttachInput | null;30 readonly terminal: TerminalAttachInput | null;
24}): LegacyTerminalSessionState {31}): LegacyTerminalSessionState {
32 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
33 const canOperate = useEnvironmentScope(input.environmentId, AuthTerminalOperateScope);
25 const attach = useEnvironmentQuery(34 const attach = useEnvironmentQuery(
26 input.environmentId !== null && input.terminal !== null35 input.environmentId !== null && input.terminal !== null
27 ? terminalEnvironment.attach({36 ? canOperate
28 environmentId: input.environmentId,37 ? terminalEnvironment.attach({
29 input: input.terminal,38 environmentId: input.environmentId,
30 })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
31 : null,47 : null,
32 );48 );
33 const metadata = useEnvironmentQuery(49 const metadata = useEnvironmentQuery(
34 input.environmentId === null50 input.environmentId === null || !canRead
35 ? null51 ? null
36 : terminalEnvironment.metadata({52 : terminalEnvironment.metadata({
37 environmentId: input.environmentId,53 environmentId: input.environmentId,
...@@ -64,20 +80,21 @@ export function useAttachedTerminalSession(input: {...@@ -64,20 +80,21 @@ export function useAttachedTerminalSession(input: {
64export function useKnownTerminalSessions(input: {80export function useKnownTerminalSessions(input: {
65 readonly environmentId: EnvironmentId | null;81 readonly environmentId: EnvironmentId | null;
66 readonly threadId: ThreadId | null;82 readonly threadId: ThreadId | null;
67}): ReadonlyArray<KnownTerminalSession> {83}) {
84 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
68 const metadata = useEnvironmentQuery(85 const metadata = useEnvironmentQuery(
69 input.environmentId === null86 input.environmentId === null || !canRead
70 ? null87 ? null
71 : terminalEnvironment.metadata({88 : terminalEnvironment.metadata({
72 environmentId: input.environmentId,89 environmentId: input.environmentId,
73 input: null,90 input: null,
74 }),91 }),
75 );92 );
76 return useMemo(() => {93 const sessions = useMemo((): ReadonlyArray<KnownTerminalSession> | null => {
77 if (input.environmentId === null) {94 if (input.environmentId === null || metadata.data === null || metadata.error !== null) {
78 return [];95 return null;
79 }96 }
80 return (metadata.data ?? [])97 return metadata.data
81 .filter((summary) => input.threadId === null || summary.threadId === input.threadId)98 .filter((summary) => input.threadId === null || summary.threadId === input.threadId)
82 .map((summary) => ({99 .map((summary) => ({
83 target: {100 target: {
...@@ -92,5 +109,6 @@ export function useKnownTerminalSessions(input: {...@@ -92,5 +109,6 @@ export function useKnownTerminalSessions(input: {
92 numeric: true,109 numeric: true,
93 }),110 }),
94 );111 );
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 };
96}114}
apps/server/src/auth/RpcAuthorization.test.ts+23
...@@ -13,6 +13,8 @@ import {...@@ -13,6 +13,8 @@ import {
13 AuthPreviewOperateScope,13 AuthPreviewOperateScope,
14 AuthRelayReadScope,14 AuthRelayReadScope,
15 AuthRelayWriteScope,15 AuthRelayWriteScope,
16 AuthTerminalReadScope,
17 AuthTerminalOperateScope,
16 WS_METHODS,18 WS_METHODS,
17 WsRpcGroup,19 WsRpcGroup,
18} from "@t3tools/contracts";20} from "@t3tools/contracts";
...@@ -162,6 +164,27 @@ describe("RPC authorization scopes", () => {...@@ -162,6 +164,27 @@ describe("RPC authorization scopes", () => {
162 }164 }
163 });165 });
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
165 it("rejects unknown RPC method names", () => {188 it("rejects unknown RPC method names", () => {
166 for (const method of ["server.notRegistered", "toString", "constructor"]) {189 for (const method of ["server.notRegistered", "toString", "constructor"]) {
167 expect(() => requiredScopeForRpcMethod(method)).toThrow(190 expect(() => requiredScopeForRpcMethod(method)).toThrow(
apps/server/src/auth/RpcAuthorization.ts+4-2
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 AuthRelayWriteScope,20 AuthRelayWriteScope,
21 AuthTerminalOperateScope,21 AuthTerminalOperateScope,
22 ORCHESTRATION_V2_WS_METHODS,22 ORCHESTRATION_V2_WS_METHODS,
23 AuthTerminalReadScope,
23 type AuthEnvironmentScope,24 type AuthEnvironmentScope,
24 EnvironmentAuthorizationError,25 EnvironmentAuthorizationError,
25 RpcScopeAuthorization,26 RpcScopeAuthorization,
...@@ -164,13 +165,14 @@ export const RPC_REQUIRED_SCOPES = {...@@ -164,13 +165,14 @@ export const RPC_REQUIRED_SCOPES = {
164 [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope,165 [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope,
165 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,166 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
166 [WS_METHODS.terminalAttach]: AuthTerminalOperateScope,167 [WS_METHODS.terminalAttach]: AuthTerminalOperateScope,
168 [WS_METHODS.terminalObserve]: AuthTerminalReadScope,
167 [WS_METHODS.terminalWrite]: AuthTerminalOperateScope,169 [WS_METHODS.terminalWrite]: AuthTerminalOperateScope,
168 [WS_METHODS.terminalResize]: AuthTerminalOperateScope,170 [WS_METHODS.terminalResize]: AuthTerminalOperateScope,
169 [WS_METHODS.terminalClear]: AuthTerminalOperateScope,171 [WS_METHODS.terminalClear]: AuthTerminalOperateScope,
170 [WS_METHODS.terminalRestart]: AuthTerminalOperateScope,172 [WS_METHODS.terminalRestart]: AuthTerminalOperateScope,
171 [WS_METHODS.terminalClose]: AuthTerminalOperateScope,173 [WS_METHODS.terminalClose]: AuthTerminalOperateScope,
172 [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope,174 [WS_METHODS.subscribeTerminalEvents]: AuthTerminalReadScope,
173 [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope,175 [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope,
174 [WS_METHODS.previewOpen]: AuthPreviewOperateScope,176 [WS_METHODS.previewOpen]: AuthPreviewOperateScope,
175 [WS_METHODS.previewNavigate]: AuthPreviewOperateScope,177 [WS_METHODS.previewNavigate]: AuthPreviewOperateScope,
176 [WS_METHODS.previewResize]: AuthPreviewOperateScope,178 [WS_METHODS.previewResize]: AuthPreviewOperateScope,
apps/server/src/observability/RpcInstrumentation.ts+1
...@@ -161,6 +161,7 @@ const RPC_AGGREGATES = {...@@ -161,6 +161,7 @@ const RPC_AGGREGATES = {
161 [WS_METHODS.reviewGetDiffFileContents]: "review",161 [WS_METHODS.reviewGetDiffFileContents]: "review",
162 [WS_METHODS.terminalOpen]: "terminal",162 [WS_METHODS.terminalOpen]: "terminal",
163 [WS_METHODS.terminalAttach]: "terminal",163 [WS_METHODS.terminalAttach]: "terminal",
164 [WS_METHODS.terminalObserve]: "terminal",
164 [WS_METHODS.terminalWrite]: "terminal",165 [WS_METHODS.terminalWrite]: "terminal",
165 [WS_METHODS.terminalResize]: "terminal",166 [WS_METHODS.terminalResize]: "terminal",
166 [WS_METHODS.terminalClear]: "terminal",167 [WS_METHODS.terminalClear]: "terminal",
apps/server/src/terminal/Manager.test.ts+98
...@@ -2693,6 +2693,104 @@ it.layer(...@@ -2693,6 +2693,104 @@ it.layer(
2693 }),2693 }),
2694 );2694 );
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
2696 it.effect("buffers attach output delivered during the initial snapshot callback", () =>2794 it.effect("buffers attach output delivered during the initial snapshot callback", () =>
2697 Effect.gen(function* () {2795 Effect.gen(function* () {
2698 const { manager, ptyAdapter } = yield* createManager(5, {2796 const { manager, ptyAdapter } = yield* createManager(5, {
apps/server/src/terminal/Manager.ts+27-2
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 type TerminalEvent,28 type TerminalEvent,
29 type TerminalMetadataStreamEvent,29 type TerminalMetadataStreamEvent,
30 type TerminalOpenInput,30 type TerminalOpenInput,
31 type TerminalObserveInput,
31 type TerminalResizeInput,32 type TerminalResizeInput,
32 type ResourceMonitorProcessTableEntry,33 type ResourceMonitorProcessTableEntry,
33 type TerminalRestartInput,34 type TerminalRestartInput,
...@@ -170,6 +171,12 @@ export class TerminalManager extends Context.Service<...@@ -170,6 +171,12 @@ export class TerminalManager extends Context.Service<
170 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,171 listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>,
171 ) => Effect.Effect<() => void, TerminalError>;172 ) => 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
173 /**180 /**
174 * Write input bytes to a terminal session.181 * Write input bytes to a terminal session.
175 */182 */
...@@ -2787,7 +2794,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2787,7 +2794,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2787 };2794 };
2788 });2795 });
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 ) => {
2791 let unsubscribe: (() => void) | null = null;2802 let unsubscribe: (() => void) | null = null;
27922803
2793 return Effect.gen(function* () {2804 return Effect.gen(function* () {
...@@ -2808,7 +2819,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2808,7 +2819,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2808 return attachEvent ? listener(attachEvent) : Effect.void;2819 return attachEvent ? listener(attachEvent) : Effect.void;
2809 });2820 });
28102821
2811 const initialSnapshot = yield* openOrAttachForStream(input);2822 const initialSnapshot = yield* initial;
28122823
2813 yield* listener({2824 yield* listener({
2814 type: "snapshot",2825 type: "snapshot",
...@@ -2844,6 +2855,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2844,6 +2855,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2844 );2855 );
2845 };2856 };
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
2847 const metadataEventFromTerminalEvent = (2871 const metadataEventFromTerminalEvent = (
2848 event: TerminalEvent,2872 event: TerminalEvent,
2849 ): Effect.Effect<TerminalMetadataStreamEvent | null> => {2873 ): Effect.Effect<TerminalMetadataStreamEvent | null> => {
...@@ -3153,6 +3177,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -3153,6 +3177,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
3153 return TerminalManager.of({3177 return TerminalManager.of({
3154 open,3178 open,
3155 attachStream,3179 attachStream,
3180 observeStream,
3156 write,3181 write,
3157 resize,3182 resize,
3158 clear,3183 clear,
apps/server/src/ws.ts+8-1
...@@ -2840,13 +2840,20 @@ const layerWsRpc = (...@@ -2840,13 +2840,20 @@ const layerWsRpc = (
2840 Effect.acquireRelease(2840 Effect.acquireRelease(
2841 terminalManager.attachStream(input, (event) => Queue.offer(queue, event)),2841 terminalManager.attachStream(input, (event) => Queue.offer(queue, event)),
2842 (unsubscribe) => Effect.sync(unsubscribe),2842 (unsubscribe) => Effect.sync(unsubscribe),
2843 ),2843 ).pipe(Effect.catchCause((cause) => Queue.failCause(queue, cause))),
2844 ),2844 ),
2845 [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input),2845 [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input),
2846 [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input),2846 [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input),
2847 [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input),2847 [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input),
2848 [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input),2848 [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input),
2849 [WS_METHODS.terminalClose]: (input) => terminalManager.close(input),2849 [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 ),
2850 [WS_METHODS.subscribeTerminalEvents]: (_input) =>2857 [WS_METHODS.subscribeTerminalEvents]: (_input) =>
2851 Stream.callback<TerminalEvent>((queue) =>2858 Stream.callback<TerminalEvent>((queue) =>
2852 Effect.acquireRelease(2859 Effect.acquireRelease(
apps/web/src/components/ChatView.tsx+163-50
...@@ -52,6 +52,8 @@ import {...@@ -52,6 +52,8 @@ import {
52 AuthSettingsWriteScope,52 AuthSettingsWriteScope,
53 AuthSourceControlWriteScope,53 AuthSourceControlWriteScope,
54 AuthPreviewOperateScope,54 AuthPreviewOperateScope,
55 AuthTerminalReadScope,
56 AuthTerminalOperateScope,
55 type AssistantCitation,57 type AssistantCitation,
56 type ChatFileAttachment,58 type ChatFileAttachment,
57 CommandId,59 CommandId,
...@@ -980,6 +982,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -980,6 +982,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
980 keybindings,982 keybindings,
981 onAddTerminalContext,983 onAddTerminalContext,
982}: PersistentThreadTerminalDrawerProps) {984}: PersistentThreadTerminalDrawerProps) {
985 const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope);
986 const hasTerminalWriteAccess = useCallback(
987 () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope),
988 [threadRef.environmentId],
989 );
983 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");990 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
984 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");991 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
985 const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");992 const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");
...@@ -1013,7 +1020,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1013,7 +1020,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1013 );1020 );
1014 const drawerTerminalSessions = useMemo(1021 const drawerTerminalSessions = useMemo(
1015 () =>1022 () =>
1016 knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)),1023 knownTerminalSessions?.filter(
1024 (session) => !panelTerminalIds.has(session.target.terminalId),
1025 ) ?? [],
1017 [knownTerminalSessions, panelTerminalIds],1026 [knownTerminalSessions, panelTerminalIds],
1018 );1027 );
1019 const terminalLabelsById = useMemo(() => {1028 const terminalLabelsById = useMemo(() => {
...@@ -1075,6 +1084,17 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1075,6 +1084,17 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1075 ],1084 ],
1076 [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds],1085 [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds],
1077 );1086 );
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 );
1078 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);1098 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);
1079 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);1099 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);
1080 const storeSplitTerminalVertical = useTerminalUiStateStore(1100 const storeSplitTerminalVertical = useTerminalUiStateStore(
...@@ -1141,10 +1161,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1141,10 +1161,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1141 );1161 );
11421162
1143 const splitTerminal = useCallback(() => {1163 const splitTerminal = useCallback(() => {
1144 if (!cwd) {1164 if (!hasTerminalWriteAccess() || !cwd) {
1145 return;1165 return;
1146 }1166 }
1147 const terminalId = nextTerminalId(allocatableTerminalIds);1167 const terminalId = allocateTerminalId();
1148 storeSplitTerminal(threadRef, terminalId);1168 storeSplitTerminal(threadRef, terminalId);
1149 bumpFocusRequestId();1169 bumpFocusRequestId();
1150 void openTerminal({1170 void openTerminal({
...@@ -1158,7 +1178,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1158,7 +1178,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1158 },1178 },
1159 });1179 });
1160 }, [1180 }, [
1161 allocatableTerminalIds,1181 allocateTerminalId,
1162 bumpFocusRequestId,1182 bumpFocusRequestId,
1163 cwd,1183 cwd,
1164 effectiveWorktreePath,1184 effectiveWorktreePath,
...@@ -1167,12 +1187,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1167,12 +1187,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1167 threadId,1187 threadId,
1168 threadRef,1188 threadRef,
1169 openTerminal,1189 openTerminal,
1190 hasTerminalWriteAccess,
1170 ]);1191 ]);
1171 const splitTerminalVertical = useCallback(() => {1192 const splitTerminalVertical = useCallback(() => {
1172 if (!cwd) {1193 if (!hasTerminalWriteAccess() || !cwd) {
1173 return;1194 return;
1174 }1195 }
1175 const terminalId = nextTerminalId(allocatableTerminalIds);1196 const terminalId = allocateTerminalId();
1176 storeSplitTerminalVertical(threadRef, terminalId);1197 storeSplitTerminalVertical(threadRef, terminalId);
1177 bumpFocusRequestId();1198 bumpFocusRequestId();
1178 void openTerminal({1199 void openTerminal({
...@@ -1186,11 +1207,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1186,11 +1207,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1186 },1207 },
1187 });1208 });
1188 }, [1209 }, [
1189 allocatableTerminalIds,1210 allocateTerminalId,
1190 bumpFocusRequestId,1211 bumpFocusRequestId,
1191 cwd,1212 cwd,
1192 effectiveWorktreePath,1213 effectiveWorktreePath,
1193 openTerminal,1214 openTerminal,
1215 hasTerminalWriteAccess,
1194 runtimeEnv,1216 runtimeEnv,
1195 storeSplitTerminalVertical,1217 storeSplitTerminalVertical,
1196 threadId,1218 threadId,
...@@ -1198,10 +1220,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1198,10 +1220,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1198 ]);1220 ]);
11991221
1200 const createNewTerminal = useCallback(() => {1222 const createNewTerminal = useCallback(() => {
1201 if (!cwd) {1223 if (!hasTerminalWriteAccess() || !cwd) {
1202 return;1224 return;
1203 }1225 }
1204 const terminalId = nextTerminalId(allocatableTerminalIds);1226 const terminalId = allocateTerminalId();
1205 storeNewTerminal(threadRef, terminalId);1227 storeNewTerminal(threadRef, terminalId);
1206 bumpFocusRequestId();1228 bumpFocusRequestId();
1207 void openTerminal({1229 void openTerminal({
...@@ -1218,12 +1240,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1218,12 +1240,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1218 bumpFocusRequestId,1240 bumpFocusRequestId,
1219 cwd,1241 cwd,
1220 effectiveWorktreePath,1242 effectiveWorktreePath,
1221 allocatableTerminalIds,1243 allocateTerminalId,
1222 runtimeEnv,1244 runtimeEnv,
1223 storeNewTerminal,1245 storeNewTerminal,
1224 threadId,1246 threadId,
1225 threadRef,1247 threadRef,
1226 openTerminal,1248 openTerminal,
1249 hasTerminalWriteAccess,
1227 ]);1250 ]);
12281251
1229 const activateTerminal = useCallback(1252 const activateTerminal = useCallback(
...@@ -1236,6 +1259,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1236,6 +1259,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
12361259
1237 const closeTerminal = useCallback(1260 const closeTerminal = useCallback(
1238 (terminalId: string) => {1261 (terminalId: string) => {
1262 if (!hasTerminalWriteAccess()) return;
1239 const fallbackExitWrite = () =>1263 const fallbackExitWrite = () =>
1240 writeTerminal({1264 writeTerminal({
1241 environmentId: threadRef.environmentId,1265 environmentId: threadRef.environmentId,
...@@ -1251,7 +1275,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1251,7 +1275,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1251 deleteHistory: true,1275 deleteHistory: true,
1252 },1276 },
1253 });1277 });
1254 if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) {1278 if (
1279 closeResult._tag === "Failure" &&
1280 !isAtomCommandInterrupted(closeResult) &&
1281 hasTerminalWriteAccess()
1282 ) {
1255 await fallbackExitWrite();1283 await fallbackExitWrite();
1256 }1284 }
1257 })();1285 })();
...@@ -1265,6 +1293,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1265,6 +1293,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1265 threadId,1293 threadId,
1266 threadRef,1294 threadRef,
1267 closeTerminalMutation,1295 closeTerminalMutation,
1296 hasTerminalWriteAccess,
1268 writeTerminal,1297 writeTerminal,
1269 ],1298 ],
1270 );1299 );
...@@ -1303,7 +1332,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -1303,7 +1332,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
1303 visible={visible}1332 visible={visible}
1304 height={terminalUiState.terminalHeight}1333 height={terminalUiState.terminalHeight}
1305 // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.1334 // 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}
1307 activeTerminalId={terminalUiState.activeTerminalId}1336 activeTerminalId={terminalUiState.activeTerminalId}
1308 terminalGroups={terminalUiState.terminalGroups}1337 terminalGroups={terminalUiState.terminalGroups}
1309 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}1338 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}
...@@ -1379,7 +1408,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane...@@ -1379,7 +1408,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
1379 });1408 });
1380 const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;1409 const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
1381 const activeSummary =1410 const activeSummary =
1382 knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId)1411 knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId)
1383 ?.state.summary ?? null;1412 ?.state.summary ?? null;
1384 const worktreePath =1413 const worktreePath =
1385 launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath;1414 launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath;
...@@ -1409,7 +1438,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane...@@ -1409,7 +1438,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
1409 const labels = new Map<string, string>();1438 const labels = new Map<string, string>();
1410 for (const terminalId of surface.terminalIds) {1439 for (const terminalId of surface.terminalIds) {
1411 const summary =1440 const summary =
1412 knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state1441 knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state
1413 .summary ?? null;1442 .summary ?? null;
1414 labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary));1443 labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary));
1415 }1444 }
...@@ -1426,7 +1455,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane...@@ -1426,7 +1455,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
1426 >();1455 >();
1427 for (const terminalId of surface.terminalIds) {1456 for (const terminalId of surface.terminalIds) {
1428 const summary =1457 const summary =
1429 knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state1458 knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state
1430 .summary ?? null;1459 .summary ?? null;
1431 const terminalWorktreePath =1460 const terminalWorktreePath =
1432 launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath;1461 launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath;
...@@ -1544,6 +1573,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -1544,6 +1573,12 @@ export default function ChatView(props: ChatViewProps) {
1544 forceExpandedMobileComposer = false,1573 forceExpandedMobileComposer = false,
1545 } = props;1574 } = props;
1546 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);1575 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);
1547 const draftId = routeKind === "draft" ? props.draftId : null;1582 const draftId = routeKind === "draft" ? props.draftId : null;
1548 const handleNewThread = useNewThreadHandler();1583 const handleNewThread = useNewThreadHandler();
1549 const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();1584 const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();
...@@ -2180,7 +2215,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2180,7 +2215,7 @@ export default function ChatView(props: ChatViewProps) {
2180 threadId: activeThreadId,2215 threadId: activeThreadId,
2181 });2216 });
2182 const activeThreadKnownSessions = useMemo(() => {2217 const activeThreadKnownSessions = useMemo(() => {
2183 if (activeThreadId === null) {2218 if (activeThreadId === null || activeThreadKnownSessionsRaw === null) {
2184 return [];2219 return [];
2185 }2220 }
2186 return activeThreadKnownSessionsRaw.filter(2221 return activeThreadKnownSessionsRaw.filter(
...@@ -2301,6 +2336,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -2301,6 +2336,17 @@ export default function ChatView(props: ChatViewProps) {
2301 () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])],2336 () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])],
2302 [activeKnownTerminalIds, panelTerminalIds],2337 [activeKnownTerminalIds, panelTerminalIds],
2303 );2338 );
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 );
2304 const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable;2350 const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable;
2305 const rightPanelOpen = rightPanelState.isOpen;2351 const rightPanelOpen = rightPanelState.isOpen;
2306 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =2352 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
...@@ -4696,7 +4742,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -4696,7 +4742,13 @@ export default function ChatView(props: ChatViewProps) {
4696 const toggleTerminalVisibility = useCallback(() => {4742 const toggleTerminalVisibility = useCallback(() => {
4697 if (!activeThreadRef) return;4743 if (!activeThreadRef) return;
4698 const nextOpen = !terminalUiState.terminalOpen;4744 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) {
4700 if (!activeThreadId || !activeProject) {4752 if (!activeThreadId || !activeProject) {
4701 return;4753 return;
4702 }4754 }
...@@ -4704,7 +4756,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4704,7 +4756,7 @@ export default function ChatView(props: ChatViewProps) {
4704 if (!cwdForOpen) {4756 if (!cwdForOpen) {
4705 return;4757 return;
4706 }4758 }
4707 const terminalId = nextTerminalId(allocatableActiveTerminalIds);4759 const terminalId = allocateTerminalId();
4708 storeEnsureTerminal(activeThreadRef, terminalId, { open: true });4760 storeEnsureTerminal(activeThreadRef, terminalId, { open: true });
4709 void openTerminal({4761 void openTerminal({
4710 environmentId,4762 environmentId,
...@@ -4727,10 +4779,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -4727,10 +4779,11 @@ export default function ChatView(props: ChatViewProps) {
4727 activeThreadId,4779 activeThreadId,
4728 activeThreadRef,4780 activeThreadRef,
4729 activeThreadWorktreePath,4781 activeThreadWorktreePath,
4730 allocatableActiveTerminalIds,4782 allocateTerminalId,
4731 environmentId,4783 environmentId,
4732 gitCwd,4784 gitCwd,
4733 openTerminal,4785 openTerminal,
4786 hasTerminalWriteAccess,
4734 setTerminalOpen,4787 setTerminalOpen,
4735 storeEnsureTerminal,4788 storeEnsureTerminal,
4736 terminalUiState.terminalIds.length,4789 terminalUiState.terminalIds.length,
...@@ -4738,14 +4791,20 @@ export default function ChatView(props: ChatViewProps) {...@@ -4738,14 +4791,20 @@ export default function ChatView(props: ChatViewProps) {
4738 ]);4791 ]);
4739 const splitTerminal = useCallback(4792 const splitTerminal = useCallback(
4740 (direction: "horizontal" | "vertical" = "horizontal") => {4793 (direction: "horizontal" | "vertical" = "horizontal") => {
4741 if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) {4794 if (
4795 !hasTerminalWriteAccess() ||
4796 !activeThreadRef ||
4797 hasReachedSplitLimit ||
4798 !activeThreadId ||
4799 !activeProject
4800 ) {
4742 return;4801 return;
4743 }4802 }
4744 const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;4803 const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
4745 if (!cwdForOpen) {4804 if (!cwdForOpen) {
4746 return;4805 return;
4747 }4806 }
4748 const terminalId = nextTerminalId(allocatableActiveTerminalIds);4807 const terminalId = allocateTerminalId();
4749 if (direction === "vertical") {4808 if (direction === "vertical") {
4750 storeSplitTerminalVertical(activeThreadRef, terminalId);4809 storeSplitTerminalVertical(activeThreadRef, terminalId);
4751 } else {4810 } else {
...@@ -4769,9 +4828,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -4769,9 +4828,10 @@ export default function ChatView(props: ChatViewProps) {
4769 [4828 [
4770 activeProject,4829 activeProject,
4771 activeThreadId,4830 activeThreadId,
4772 allocatableActiveTerminalIds,4831 allocateTerminalId,
4773 activeThreadRef,4832 activeThreadRef,
4774 openTerminal,4833 openTerminal,
4834 hasTerminalWriteAccess,
4775 activeThreadWorktreePath,4835 activeThreadWorktreePath,
4776 environmentId,4836 environmentId,
4777 gitCwd,4837 gitCwd,
...@@ -4781,14 +4841,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -4781,14 +4841,14 @@ export default function ChatView(props: ChatViewProps) {
4781 ],4841 ],
4782 );4842 );
4783 const createNewTerminal = useCallback(() => {4843 const createNewTerminal = useCallback(() => {
4784 if (!activeThreadRef || !activeThreadId || !activeProject) {4844 if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) {
4785 return;4845 return;
4786 }4846 }
4787 const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;4847 const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
4788 if (!cwdForOpen) {4848 if (!cwdForOpen) {
4789 return;4849 return;
4790 }4850 }
4791 const terminalId = nextTerminalId(allocatableActiveTerminalIds);4851 const terminalId = allocateTerminalId();
4792 storeNewTerminal(activeThreadRef, terminalId);4852 storeNewTerminal(activeThreadRef, terminalId);
4793 setTerminalFocusRequestId((value) => value + 1);4853 setTerminalFocusRequestId((value) => value + 1);
4794 void openTerminal({4854 void openTerminal({
...@@ -4807,9 +4867,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -4807,9 +4867,10 @@ export default function ChatView(props: ChatViewProps) {
4807 }, [4867 }, [
4808 activeProject,4868 activeProject,
4809 activeThreadId,4869 activeThreadId,
4810 allocatableActiveTerminalIds,4870 allocateTerminalId,
4811 activeThreadRef,4871 activeThreadRef,
4812 openTerminal,4872 openTerminal,
4873 hasTerminalWriteAccess,
4813 activeThreadWorktreePath,4874 activeThreadWorktreePath,
4814 environmentId,4875 environmentId,
4815 gitCwd,4876 gitCwd,
...@@ -4817,7 +4878,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4817,7 +4878,7 @@ export default function ChatView(props: ChatViewProps) {
4817 ]);4878 ]);
4818 const closeTerminal = useCallback(4879 const closeTerminal = useCallback(
4819 (terminalId: string) => {4880 (terminalId: string) => {
4820 if (!activeThreadId || !activeThreadRef) return;4881 if (!hasTerminalWriteAccess() || !activeThreadId || !activeThreadRef) return;
4821 const fallbackExitWrite = () =>4882 const fallbackExitWrite = () =>
4822 writeTerminal({4883 writeTerminal({
4823 environmentId,4884 environmentId,
...@@ -4832,7 +4893,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -4832,7 +4893,11 @@ export default function ChatView(props: ChatViewProps) {
4832 deleteHistory: true,4893 deleteHistory: true,
4833 },4894 },
4834 });4895 });
4835 if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) {4896 if (
4897 closeResult._tag === "Failure" &&
4898 !isAtomCommandInterrupted(closeResult) &&
4899 hasTerminalWriteAccess()
4900 ) {
4836 await fallbackExitWrite();4901 await fallbackExitWrite();
4837 }4902 }
4838 })();4903 })();
...@@ -4843,6 +4908,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4843,6 +4908,7 @@ export default function ChatView(props: ChatViewProps) {
4843 activeThreadId,4908 activeThreadId,
4844 activeThreadRef,4909 activeThreadRef,
4845 closeTerminalMutation,4910 closeTerminalMutation,
4911 hasTerminalWriteAccess,
4846 environmentId,4912 environmentId,
4847 storeCloseTerminal,4913 storeCloseTerminal,
4848 writeTerminal,4914 writeTerminal,
...@@ -4859,7 +4925,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4859,7 +4925,7 @@ export default function ChatView(props: ChatViewProps) {
4859 rememberAsLastInvoked?: boolean;4925 rememberAsLastInvoked?: boolean;
4860 },4926 },
4861 ) => {4927 ) => {
4862 if (!activeThreadId || !activeProject || !activeThread) return;4928 if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return;
4863 if (options?.rememberAsLastInvoked !== false) {4929 if (options?.rememberAsLastInvoked !== false) {
4864 setLastInvokedScriptByProjectId((current) => {4930 setLastInvokedScriptByProjectId((current) => {
4865 if (current[activeProject.id] === script.id) return current;4931 if (current[activeProject.id] === script.id) return current;
...@@ -4871,7 +4937,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -4871,7 +4937,10 @@ export default function ChatView(props: ChatViewProps) {
4871 terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;4937 terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
4872 const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);4938 const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);
4873 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;4939 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
4874 const shouldCreateNewTerminal = wantsNewTerminal;4940 const shouldCreateNewTerminal =
4941 wantsNewTerminal ||
4942 !canReuseTerminal ||
4943 !readEnvironmentScope(environmentId, AuthTerminalReadScope);
4875 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;4944 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;
48764945
4877 setTerminalUiLaunchContext({4946 setTerminalUiLaunchContext({
...@@ -4892,9 +4961,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4892,9 +4961,7 @@ export default function ChatView(props: ChatViewProps) {
4892 worktreePath: targetWorktreePath,4961 worktreePath: targetWorktreePath,
4893 ...(options?.env ? { extraEnv: options.env } : {}),4962 ...(options?.env ? { extraEnv: options.env } : {}),
4894 });4963 });
4895 const targetTerminalId = shouldCreateNewTerminal4964 const targetTerminalId = shouldCreateNewTerminal ? allocateTerminalId() : baseTerminalId;
4896 ? nextTerminalId(allocatableActiveTerminalIds)
4897 : baseTerminalId;
4898 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal4965 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
4899 ? {4966 ? {
4900 threadId: activeThreadId,4967 threadId: activeThreadId,
...@@ -4931,6 +4998,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4931,6 +4998,7 @@ export default function ChatView(props: ChatViewProps) {
4931 return;4998 return;
4932 }4999 }
49335000
5001 if (!hasTerminalWriteAccess()) return;
4934 const writeResult = await writeTerminal({5002 const writeResult = await writeTerminal({
4935 environmentId,5003 environmentId,
4936 input: {5004 input: {
...@@ -4981,8 +5049,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -4981,8 +5049,10 @@ export default function ChatView(props: ChatViewProps) {
4981 environmentId,5049 environmentId,
4982 openTerminal,5050 openTerminal,
4983 openPreview,5051 openPreview,
5052 hasTerminalWriteAccess,
4984 activeKnownTerminalIds,5053 activeKnownTerminalIds,
4985 allocatableActiveTerminalIds,5054 canReuseTerminal,
5055 allocateTerminalId,
4986 runningTerminalIds,5056 runningTerminalIds,
4987 terminalUiState.activeTerminalId,5057 terminalUiState.activeTerminalId,
4988 writeTerminal,5058 writeTerminal,
...@@ -5792,9 +5862,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -5792,9 +5862,9 @@ export default function ChatView(props: ChatViewProps) {
5792 previewPanelOpen,5862 previewPanelOpen,
5793 ]);5863 ]);
5794 const addTerminalSurface = useCallback(() => {5864 const addTerminalSurface = useCallback(() => {
5795 if (!activeThreadRef || !activeThreadId || !activeProject) return;5865 if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) return;
5796 const cwd = gitCwd ?? activeProject.workspaceRoot;5866 const cwd = gitCwd ?? activeProject.workspaceRoot;
5797 const terminalId = nextTerminalId(allocatableActiveTerminalIds);5867 const terminalId = allocateTerminalId();
5798 useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId);5868 useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId);
5799 setTerminalFocusRequestId((value) => value + 1);5869 setTerminalFocusRequestId((value) => value + 1);
5800 void openTerminal({5870 void openTerminal({
...@@ -5815,13 +5885,15 @@ export default function ChatView(props: ChatViewProps) {...@@ -5815,13 +5885,15 @@ export default function ChatView(props: ChatViewProps) {
5815 activeThreadId,5885 activeThreadId,
5816 activeThreadRef,5886 activeThreadRef,
5817 activeThreadWorktreePath,5887 activeThreadWorktreePath,
5818 allocatableActiveTerminalIds,5888 allocateTerminalId,
5819 gitCwd,5889 gitCwd,
5820 openTerminal,5890 openTerminal,
5891 hasTerminalWriteAccess,
5821 ]);5892 ]);
5822 const splitPanelTerminal = useCallback(5893 const splitPanelTerminal = useCallback(
5823 (direction: "horizontal" | "vertical" = "horizontal") => {5894 (direction: "horizontal" | "vertical" = "horizontal") => {
5824 if (5895 if (
5896 !hasTerminalWriteAccess() ||
5825 !activeThreadRef ||5897 !activeThreadRef ||
5826 !activeThreadId ||5898 !activeThreadId ||
5827 !activeProject ||5899 !activeProject ||
...@@ -5830,7 +5902,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5830,7 +5902,7 @@ export default function ChatView(props: ChatViewProps) {
5830 ) {5902 ) {
5831 return;5903 return;
5832 }5904 }
5833 const terminalId = nextTerminalId(allocatableActiveTerminalIds);5905 const terminalId = allocateTerminalId();
5834 const cwd = gitCwd ?? activeProject.workspaceRoot;5906 const cwd = gitCwd ?? activeProject.workspaceRoot;
5835 useRightPanelStore5907 useRightPanelStore
5836 .getState()5908 .getState()
...@@ -5856,9 +5928,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -5856,9 +5928,10 @@ export default function ChatView(props: ChatViewProps) {
5856 activeThreadId,5928 activeThreadId,
5857 activeThreadRef,5929 activeThreadRef,
5858 activeThreadWorktreePath,5930 activeThreadWorktreePath,
5859 allocatableActiveTerminalIds,5931 allocateTerminalId,
5860 gitCwd,5932 gitCwd,
5861 openTerminal,5933 openTerminal,
5934 hasTerminalWriteAccess,
5862 ],5935 ],
5863 );5936 );
5864 const splitPanelTerminalVertical = useCallback(() => {5937 const splitPanelTerminalVertical = useCallback(() => {
...@@ -5876,7 +5949,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -5876,7 +5949,12 @@ export default function ChatView(props: ChatViewProps) {
5876 );5949 );
5877 const closePanelTerminal = useCallback(5950 const closePanelTerminal = useCallback(
5878 (terminalId: string) => {5951 (terminalId: string) => {
5879 if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return;5952 if (
5953 !hasTerminalWriteAccess() ||
5954 !activeThreadRef ||
5955 activeRightPanelSurface?.kind !== "terminal"
5956 )
5957 return;
5880 void closeTerminalMutation({5958 void closeTerminalMutation({
5881 environmentId: activeThreadRef.environmentId,5959 environmentId: activeThreadRef.environmentId,
5882 input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },5960 input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },
...@@ -5887,25 +5965,37 @@ export default function ChatView(props: ChatViewProps) {...@@ -5887,25 +5965,37 @@ export default function ChatView(props: ChatViewProps) {
5887 .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);5965 .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
5888 setTerminalFocusRequestId((value) => value + 1);5966 setTerminalFocusRequestId((value) => value + 1);
5889 },5967 },
5890 [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal],5968 [
5969 hasTerminalWriteAccess,
5970 activeRightPanelSurface,
5971 activeThreadRef,
5972 closeTerminalMutation,
5973 storeCloseTerminal,
5974 ],
5891 );5975 );
5892 const requestCloseTerminal = useCallback(5976 const requestCloseTerminal = useCallback(
5893 (terminalId: string) => {5977 (terminalId: string) => {
5978 if (!hasTerminalWriteAccess()) return;
5894 const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);5979 const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
5895 void confirmTerminalClose([label]).then((confirmed) => {5980 void confirmTerminalClose([label]).then((confirmed) => {
5896 if (confirmed) closeTerminal(terminalId);5981 if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
5982 closeTerminal(terminalId);
5983 }
5897 });5984 });
5898 },5985 },
5899 [activeTerminalLabelsById, closeTerminal],5986 [hasTerminalWriteAccess, activeTerminalLabelsById, closeTerminal, environmentId],
5900 );5987 );
5901 const requestClosePanelTerminal = useCallback(5988 const requestClosePanelTerminal = useCallback(
5902 (terminalId: string) => {5989 (terminalId: string) => {
5990 if (!hasTerminalWriteAccess()) return;
5903 const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);5991 const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
5904 void confirmTerminalClose([label]).then((confirmed) => {5992 void confirmTerminalClose([label]).then((confirmed) => {
5905 if (confirmed) closePanelTerminal(terminalId);5993 if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
5994 closePanelTerminal(terminalId);
5995 }
5906 });5996 });
5907 },5997 },
5908 [activeTerminalLabelsById, closePanelTerminal],5998 [hasTerminalWriteAccess, activeTerminalLabelsById, closePanelTerminal, environmentId],
5909 );5999 );
5910 const activateRightPanelSurface = useCallback(6000 const activateRightPanelSurface = useCallback(
5911 (surface: RightPanelSurface) => {6001 (surface: RightPanelSurface) => {
...@@ -5956,7 +6046,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -5956,7 +6046,10 @@ export default function ChatView(props: ChatViewProps) {
5956 threadRef: activeThreadRef,6046 threadRef: activeThreadRef,
5957 });6047 });
5958 }6048 }
5959 if (surface.kind === "terminal") {6049 if (
6050 surface.kind === "terminal" &&
6051 readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope)
6052 ) {
5960 for (const terminalId of surface.terminalIds) {6053 for (const terminalId of surface.terminalIds) {
5961 storeCloseTerminal(activeThreadRef, terminalId);6054 storeCloseTerminal(activeThreadRef, terminalId);
5962 void closeTerminalMutation({6055 void closeTerminalMutation({
...@@ -6034,7 +6127,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -6034,7 +6127,10 @@ export default function ChatView(props: ChatViewProps) {
6034 closeAfterAgentBrowserConfirmation([surface], finishClose);6127 closeAfterAgentBrowserConfirmation([surface], finishClose);
6035 return;6128 return;
6036 }6129 }
6037 if (surface.kind !== "terminal") {6130 if (
6131 surface.kind !== "terminal" ||
6132 !readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope)
6133 ) {
6038 finishClose();6134 finishClose();
6039 return;6135 return;
6040 }6136 }
...@@ -6047,7 +6143,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -6047,7 +6143,9 @@ export default function ChatView(props: ChatViewProps) {
6047 (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId),6143 (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId),
6048 );6144 );
6049 void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => {6145 void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => {
6050 if (confirmed) finishClose();6146 if (confirmed) {
6147 finishClose();
6148 }
6051 });6149 });
6052 },6150 },
6053 [6151 [
...@@ -7963,6 +8061,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7963,6 +8061,7 @@ export default function ChatView(props: ChatViewProps) {
7963 }8061 }
79648062
7965 if (command === "terminal.toggle") {8063 if (command === "terminal.toggle") {
8064 if (!terminalUiState.terminalOpen && !canReadTerminal && !canOperateTerminal) return;
7966 event.preventDefault();8065 event.preventDefault();
7967 event.stopPropagation();8066 event.stopPropagation();
7968 toggleTerminalVisibility();8067 toggleTerminalVisibility();
...@@ -7994,6 +8093,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -7994,6 +8093,9 @@ export default function ChatView(props: ChatViewProps) {
7994 }8093 }
79958094
7996 if (command === "terminal.split") {8095 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;
7997 event.preventDefault();8099 event.preventDefault();
7998 event.stopPropagation();8100 event.stopPropagation();
7999 if (terminalFocusOwner === "right-panel") {8101 if (terminalFocusOwner === "right-panel") {
...@@ -8008,6 +8110,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -8008,6 +8110,9 @@ export default function ChatView(props: ChatViewProps) {
8008 }8110 }
80098111
8010 if (command === "terminal.splitVertical") {8112 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;
8011 event.preventDefault();8116 event.preventDefault();
8012 event.stopPropagation();8117 event.stopPropagation();
8013 if (terminalFocusOwner === "right-panel") {8118 if (terminalFocusOwner === "right-panel") {
...@@ -8022,6 +8127,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -8022,6 +8127,9 @@ export default function ChatView(props: ChatViewProps) {
8022 }8127 }
80238128
8024 if (command === "terminal.close") {8129 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;
8025 event.preventDefault();8133 event.preventDefault();
8026 event.stopPropagation();8134 event.stopPropagation();
8027 if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {8135 if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
...@@ -8034,6 +8142,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -8034,6 +8142,9 @@ export default function ChatView(props: ChatViewProps) {
8034 }8142 }
80358143
8036 if (command === "terminal.new") {8144 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;
8037 event.preventDefault();8148 event.preventDefault();
8038 event.stopPropagation();8149 event.stopPropagation();
8039 if (terminalFocusOwner === "right-panel") {8150 if (terminalFocusOwner === "right-panel") {
...@@ -8132,7 +8243,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8132,7 +8243,7 @@ export default function ChatView(props: ChatViewProps) {
8132 }8243 }
81338244
8134 const scriptId = projectScriptIdFromCommand(command);8245 const scriptId = projectScriptIdFromCommand(command);
8135 if (!scriptId || !activeProject) return;8246 if (!scriptId || !activeProject || !canOperateTerminal) return;
8136 const script = activeProjectScripts.find((entry) => entry.id === scriptId);8247 const script = activeProjectScripts.find((entry) => entry.id === scriptId);
8137 if (!script) return;8248 if (!script) return;
8138 event.preventDefault();8249 event.preventDefault();
...@@ -8145,6 +8256,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -8145,6 +8256,8 @@ export default function ChatView(props: ChatViewProps) {
8145 activeProject,8256 activeProject,
8146 activeRightPanelSurface,8257 activeRightPanelSurface,
8147 activeProjectScripts,8258 activeProjectScripts,
8259 canReadTerminal,
8260 canOperateTerminal,
8148 addTerminalSurface,8261 addTerminalSurface,
8149 activeThreadRef,8262 activeThreadRef,
8150 activeThreadPinned,8263 activeThreadPinned,
...@@ -11769,7 +11882,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11769,7 +11882,7 @@ export default function ChatView(props: ChatViewProps) {
11769 onAddPullRequests={addPullRequestsSurface}11882 onAddPullRequests={addPullRequestsSurface}
11770 onAddDevice={addDeviceSurface}11883 onAddDevice={addDeviceSurface}
11771 browserAvailable={canOperatePreview && browserAvailable}11884 browserAvailable={canOperatePreview && browserAvailable}
11772 terminalAvailable={activeProject !== null}11885 terminalAvailable={activeProject !== null && canOperateTerminal}
11773 diffAvailable={isServerThread && isGitRepo}11886 diffAvailable={isServerThread && isGitRepo}
11774 filesAvailable={activeProject !== null}11887 filesAvailable={activeProject !== null}
11775 pullRequestAvailable={pullRequestSurfaceAvailable}11888 pullRequestAvailable={pullRequestSurfaceAvailable}
...@@ -11827,7 +11940,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11827,7 +11940,7 @@ export default function ChatView(props: ChatViewProps) {
11827 onAddPullRequests={addPullRequestsSurface}11940 onAddPullRequests={addPullRequestsSurface}
11828 onAddDevice={addDeviceSurface}11941 onAddDevice={addDeviceSurface}
11829 browserAvailable={canOperatePreview && browserAvailable}11942 browserAvailable={canOperatePreview && browserAvailable}
11830 terminalAvailable={activeProject !== null}11943 terminalAvailable={activeProject !== null && canOperateTerminal}
11831 diffAvailable={isServerThread && isGitRepo}11944 diffAvailable={isServerThread && isGitRepo}
11832 filesAvailable={activeProject !== null}11945 filesAvailable={activeProject !== null}
11833 pullRequestAvailable={pullRequestSurfaceAvailable}11946 pullRequestAvailable={pullRequestSurfaceAvailable}
apps/web/src/components/ProjectScriptsControl.tsx+12-5
...@@ -66,7 +66,7 @@ interface ProjectScriptsControlProps {...@@ -66,7 +66,7 @@ interface ProjectScriptsControlProps {
66 /** Scripts declared in the project's checked-in t3.json, offered for import. */66 /** Scripts declared in the project's checked-in t3.json, offered for import. */
67 fileScripts?: ReadonlyArray<T3ProjectFileScript>;67 fileScripts?: ReadonlyArray<T3ProjectFileScript>;
68 preferredScriptId?: string | null;68 preferredScriptId?: string | null;
69 onRunScript: (script: ProjectScript) => void;69 onRunScript?: ((script: ProjectScript) => void) | undefined;
70 onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;70 onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
71 onUpdateScript: (71 onUpdateScript: (
72 scriptId: string,72 scriptId: string,
...@@ -198,7 +198,8 @@ export default function ProjectScriptsControl({...@@ -198,7 +198,8 @@ export default function ProjectScriptsControl({
198 density={presentation === "menu" ? "touch" : "default"}198 density={presentation === "menu" ? "touch" : "default"}
199 key={script.id}199 key={script.id}
200 className="group"200 className="group"
201 onClick={() => onRunScript(script)}201 disabled={!onRunScript}
202 onClick={() => onRunScript?.(script)}
202 >203 >
203 <ScriptIcon icon={script.icon} className="size-4" />204 <ScriptIcon icon={script.icon} className="size-4" />
204 <MenuItemLabel>205 <MenuItemLabel>
...@@ -255,7 +256,8 @@ export default function ProjectScriptsControl({...@@ -255,7 +256,8 @@ export default function ProjectScriptsControl({
255 {primaryScript && (256 {primaryScript && (
256 <MenuItem257 <MenuItem
257 density={presentation === "menu" ? "touch" : "default"}258 density={presentation === "menu" ? "touch" : "default"}
258 onClick={() => onRunScript(primaryScript)}259 disabled={!onRunScript}
260 onClick={() => onRunScript?.(primaryScript)}
259 >261 >
260 <ScriptIcon icon={primaryScript.icon} className="size-4" />262 <ScriptIcon icon={primaryScript.icon} className="size-4" />
261 <MenuItemLabel>Run {primaryScript.name}</MenuItemLabel>263 <MenuItemLabel>Run {primaryScript.name}</MenuItemLabel>
...@@ -308,7 +310,8 @@ export default function ProjectScriptsControl({...@@ -308,7 +310,8 @@ export default function ProjectScriptsControl({
308 // The tooltip wrapper replaces data-slot="button", so themed310 // The tooltip wrapper replaces data-slot="button", so themed
309 // toolbar styling needs its own hook.311 // toolbar styling needs its own hook.
310 data-toolbar-control=""312 data-toolbar-control=""
311 onClick={() => onRunScript(primaryScript)}313 aria-disabled={!onRunScript || undefined}
314 onClick={() => onRunScript?.(primaryScript)}
312 />315 />
313 }316 }
314 >317 >
...@@ -325,7 +328,11 @@ export default function ProjectScriptsControl({...@@ -325,7 +328,11 @@ export default function ProjectScriptsControl({
325 {primaryScript.name}328 {primaryScript.name}
326 </span>329 </span>
327 </TooltipTrigger>330 </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>
329 </Tooltip>336 </Tooltip>
330 {isPanel ? (337 {isPanel ? (
331 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />338 <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 @@...@@ -1,9 +1,17 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";1import { 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
3import {10import {
4 shouldClearTerminalSelectionAction,11 shouldClearTerminalSelectionAction,
5 shouldHandleTerminalExit,12 shouldHandleTerminalExit,
6 terminalContextMenuItems,13 terminalContextMenuItems,
14 synchronizeTerminalOutput,
7 terminalSelectionLineRange,15 terminalSelectionLineRange,
8 terminalSelectionMenuItems,16 terminalSelectionMenuItems,
9 terminalThemeFromApp,17 terminalThemeFromApp,
...@@ -138,8 +146,63 @@ describe("terminal selection actions", () => {...@@ -138,8 +146,63 @@ describe("terminal selection actions", () => {
138 });146 });
139147
140 it("handles an exit that lands while the terminal surface is still loading", () => {148 it("handles an exit that lands while the terminal surface is still loading", () => {
141 expect(shouldHandleTerminalExit("exited", "running", false)).toBe(true);149 expect(shouldHandleTerminalExit("exited", "running", false, 1)).toBe(true);
142 expect(shouldHandleTerminalExit("exited", "exited", false)).toBe(false);150 expect(shouldHandleTerminalExit("exited", "exited", false, 1)).toBe(false);
143 expect(shouldHandleTerminalExit("closed", "running", true)).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",
144 });205 });
206 synchronizeTerminalOutput(terminal, cleared, observedCursor);
207 expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("");
145});208});
apps/web/src/components/ThreadTerminalDrawer.tsx+135-37
...@@ -20,6 +20,8 @@ import {...@@ -20,6 +20,8 @@ import {
20} from "lucide-react";20} from "lucide-react";
21import {21import {
22 AuthPreviewOperateScope,22 AuthPreviewOperateScope,
23 AuthOrchestrationOperateScope,
24 AuthTerminalOperateScope,
23 type ContextMenuItem,25 type ContextMenuItem,
24 type ProviderInstanceId,26 type ProviderInstanceId,
25 type ResolvedKeybindingsConfig,27 type ResolvedKeybindingsConfig,
...@@ -83,6 +85,7 @@ import { serverEnvironment } from "../state/server";...@@ -83,6 +85,7 @@ import { serverEnvironment } from "../state/server";
83import { previewEnvironment } from "../state/preview";85import { previewEnvironment } from "../state/preview";
84import { readEnvironmentScope } from "../state/session";86import { readEnvironmentScope } from "../state/session";
85import { terminalEnvironment } from "../state/terminal";87import { terminalEnvironment } from "../state/terminal";
88import { useEnvironmentScope } from "../state/session";
86import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";89import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
87import { useAtomCommand } from "../state/use-atom-command";90import { useAtomCommand } from "../state/use-atom-command";
88import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";91import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
...@@ -121,6 +124,18 @@ export function writeTerminalOutputUpdate(...@@ -121,6 +124,18 @@ export function writeTerminalOutputUpdate(
121 }124 }
122}125}
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
124function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor {139function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor {
125 if (typeof document === "undefined") return fallback;140 if (typeof document === "undefined") return fallback;
126141
...@@ -272,6 +287,7 @@ export function terminalSelectionMenuItems(options?: {...@@ -272,6 +287,7 @@ export function terminalSelectionMenuItems(options?: {
272export function terminalContextMenuItems(options: {287export function terminalContextMenuItems(options: {
273 hasSelection: boolean;288 hasSelection: boolean;
274 canAddToChat?: boolean;289 canAddToChat?: boolean;
290 readOnly?: boolean;
275}): ContextMenuItem<TerminalContextMenuAction>[] {291}): ContextMenuItem<TerminalContextMenuAction>[] {
276 const { hasSelection, canAddToChat = true } = options;292 const { hasSelection, canAddToChat = true } = options;
277 return [293 return [
...@@ -279,7 +295,7 @@ export function terminalContextMenuItems(options: {...@@ -279,7 +295,7 @@ export function terminalContextMenuItems(options: {
279 ...item,295 ...item,
280 disabled: !hasSelection,296 disabled: !hasSelection,
281 })),297 })),
282 { id: "paste", label: "Paste" },298 { id: "paste", label: "Paste", ...(options.readOnly ? { disabled: true } : {}) },
283 ];299 ];
284}300}
285301
...@@ -302,9 +318,13 @@ export function shouldHandleTerminalExit(...@@ -302,9 +318,13 @@ export function shouldHandleTerminalExit(
302 current: TerminalSessionState["status"],318 current: TerminalSessionState["status"],
303 synchronized: TerminalSessionState["status"],319 synchronized: TerminalSessionState["status"],
304 alreadyHandled: boolean,320 alreadyHandled: boolean,
321 version: number,
305): boolean {322): boolean {
306 return (323 return (
307 (current === "closed" || current === "exited") && current !== synchronized && !alreadyHandled324 version > 0 &&
325 (current === "closed" || current === "exited") &&
326 current !== synchronized &&
327 !alreadyHandled
308 );328 );
309}329}
310330
...@@ -357,6 +377,15 @@ export function TerminalViewport({...@@ -357,6 +377,15 @@ export function TerminalViewport({
357 const terminalRef = useRef<GhosttyTerminalSurface | null>(null);377 const terminalRef = useRef<GhosttyTerminalSurface | null>(null);
358 const visibleRef = useRef(visible);378 const visibleRef = useRef(visible);
359 const environmentId = threadRef.environmentId;379 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 );
360 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));389 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
361 const openInPreferredEditor = useOpenInPreferredEditor(390 const openInPreferredEditor = useOpenInPreferredEditor(
362 environmentId,391 environmentId,
...@@ -381,7 +410,7 @@ export function TerminalViewport({...@@ -381,7 +410,7 @@ export function TerminalViewport({
381 const keybindingsRef = useRef(keybindings);410 const keybindingsRef = useRef(keybindings);
382 const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);411 const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);
383 const handleSessionExited = useEffectEvent(() => {412 const handleSessionExited = useEffectEvent(() => {
384 onSessionExited();413 if (hasTerminalWriteAccess()) onSessionExited();
385 });414 });
386 const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {415 const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {
387 onAddTerminalContext?.(selection);416 onAddTerminalContext?.(selection);
...@@ -414,29 +443,35 @@ export function TerminalViewport({...@@ -414,29 +443,35 @@ export function TerminalViewport({
414 ...(providerInstanceId ? { providerInstanceId } : {}),443 ...(providerInstanceId ? { providerInstanceId } : {}),
415 },444 },
416 });445 });
446 const canResizeTerminal =
447 canOperateTerminal && terminalSession.version > 0 && terminalSession.status === "running";
448 const resizeSessionGeneration = canResizeTerminal ? terminalSession.output.generation : null;
417 const writeTerminal = useEffectEvent((data: string) =>449 const writeTerminal = useEffectEvent((data: string) =>
418 runTerminalWrite({450 runTerminalWrite({
419 environmentId,451 environmentId,
420 input: { threadId, terminalId, data },452 input: { threadId, terminalId, data },
421 }),453 }),
422 );454 );
423 const resizeTerminal = useEffectEvent((cols: number, rows: number) =>455 const resizeTerminal = useEffectEvent((cols: number, rows: number) => {
424 runTerminalResize({456 if (!canResizeTerminal || !hasTerminalWriteAccess()) return;
457 return runTerminalResize({
425 environmentId,458 environmentId,
426 input: { threadId, terminalId, cols, rows },459 input: { threadId, terminalId, cols, rows },
427 }),460 });
428 );461 });
429 const terminalOutput = terminalSession.output;462 const terminalOutput = terminalSession.output;
430 const terminalError = terminalSession.error;463 const terminalError = terminalSession.error;
431 const terminalStatus = terminalSession.status;464 const terminalStatus = terminalSession.status;
432 const outputCursorRef = useRef<TerminalOutputCursor>(INITIAL_TERMINAL_OUTPUT_CURSOR);465 const outputCursorRef = useRef<TerminalOutputCursor>(INITIAL_TERMINAL_OUTPUT_CURSOR);
433 const synchronizedStatusRef = useRef<TerminalSessionState["status"]>("closed");466 const synchronizedStatusRef = useRef<TerminalSessionState["status"]>("closed");
434 const synchronizeTerminalStatus = useEffectEvent(467 const synchronizeTerminalStatus = useEffectEvent(
435 (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => {468 (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"], version: number) => {
436 const synchronized = synchronizedStatusRef.current;469 const synchronized = synchronizedStatusRef.current;
437 if (status === "running") {470 if (version > 0 && status === "running") {
438 hasHandledExitRef.current = false;471 hasHandledExitRef.current = false;
439 } else if (shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current)) {472 } else if (
473 shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current, version)
474 ) {
440 hasHandledExitRef.current = true;475 hasHandledExitRef.current = true;
441 writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited");476 writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited");
442 window.setTimeout(() => {477 window.setTimeout(() => {
...@@ -445,7 +480,7 @@ export function TerminalViewport({...@@ -445,7 +480,7 @@ export function TerminalViewport({
445 }480 }
446 }, 0);481 }, 0);
447 }482 }
448 synchronizedStatusRef.current = status;483 if (version > 0) synchronizedStatusRef.current = status;
449 },484 },
450 );485 );
451 const terminalVersion = terminalSession.version;486 const terminalVersion = terminalSession.version;
...@@ -467,6 +502,22 @@ export function TerminalViewport({...@@ -467,6 +502,22 @@ export function TerminalViewport({
467 keybindingsRef.current = keybindings;502 keybindingsRef.current = keybindings;
468 }, [keybindings]);503 }, [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
470 useLayoutEffect(() => {521 useLayoutEffect(() => {
471 visibleRef.current = visible;522 visibleRef.current = visible;
472 terminalRef.current?.setVisible(visible);523 terminalRef.current?.setVisible(visible);
...@@ -503,6 +554,7 @@ export function TerminalViewport({...@@ -503,6 +554,7 @@ export function TerminalViewport({
503 onSelectionChange: () => handleSelectionChange(),554 onSelectionChange: () => handleSelectionChange(),
504 beforeKey: (event) => handleBeforeKey(event),555 beforeKey: (event) => handleBeforeKey(event),
505 onLinkActivate: (text, event) => handleLinkActivate(text, event),556 onLinkActivate: (text, event) => handleLinkActivate(text, event),
557 canActivateLink: (text) => canActivateTerminalLink(text),
506 // The surface listens from construction, so a right-click can land558 // The surface listens from construction, so a right-click can land
507 // while `create` is still awaiting WASM — before the handler below it559 // while `create` is still awaiting WASM — before the handler below it
508 // exists. The ref is only assigned once that setup has run.560 // exists. The ref is only assigned once that setup has run.
...@@ -521,6 +573,7 @@ export function TerminalViewport({...@@ -521,6 +573,7 @@ export function TerminalViewport({
521 terminal.setTheme(terminalThemeFromApp(mount));573 terminal.setTheme(terminalThemeFromApp(mount));
522 setupTerminal = terminal;574 setupTerminal = terminal;
523 terminalRef.current = terminal;575 terminalRef.current = terminal;
576 terminal.input.readOnly = !hasTerminalWriteAccess();
524 // Client settings hydrate asynchronously; a font preference that landed577 // Client settings hydrate asynchronously; a font preference that landed
525 // while the surface was loading found terminalRef null, so its setFont578 // while the surface was loading found terminalRef null, so its setFont
526 // was dropped. Re-apply whatever is current once the terminal exists.579 // was dropped. Re-apply whatever is current once the terminal exists.
...@@ -544,9 +597,13 @@ export function TerminalViewport({...@@ -544,9 +597,13 @@ export function TerminalViewport({
544 // (A session that is "closed" at mount is indistinguishable from one that597 // (A session that is "closed" at mount is indistinguishable from one that
545 // never started, so only "exited" triggers the message — as with xterm.)598 // never started, so only "exited" triggers the message — as with xterm.)
546 synchronizedStatusRef.current = "closed";599 synchronizedStatusRef.current = "closed";
547 synchronizeTerminalStatus(terminal, latestSession.status);600 synchronizeTerminalStatus(terminal, latestSession.status, latestSession.version);
548 // Startup may finish after the user has returned to the composer.601 // 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 ) {
550 terminal.focus();607 terminal.focus();
551 }608 }
552609
...@@ -634,6 +691,7 @@ export function TerminalViewport({...@@ -634,6 +691,7 @@ export function TerminalViewport({
634 };691 };
635692
636 const pasteFromClipboard = async (requestId: number) => {693 const pasteFromClipboard = async (requestId: number) => {
694 if (!hasTerminalWriteAccess()) return;
637 const activeTerminal = terminalRef.current;695 const activeTerminal = terminalRef.current;
638 if (!activeTerminal) return;696 if (!activeTerminal) return;
639 try {697 try {
...@@ -667,6 +725,7 @@ export function TerminalViewport({...@@ -667,6 +725,7 @@ export function TerminalViewport({
667 terminalContextMenuItems({725 terminalContextMenuItems({
668 hasSelection: selectionAction !== null,726 hasSelection: selectionAction !== null,
669 canAddToChat: canAddSelectionToChat(),727 canAddToChat: canAddSelectionToChat(),
728 readOnly: !hasTerminalWriteAccess(),
670 }),729 }),
671 { x: event.clientX, y: event.clientY },730 { x: event.clientX, y: event.clientY },
672 );731 );
...@@ -732,6 +791,7 @@ export function TerminalViewport({...@@ -732,6 +791,7 @@ export function TerminalViewport({
732 };791 };
733792
734 const sendTerminalInput = async (data: string, fallbackError: string) => {793 const sendTerminalInput = async (data: string, fallbackError: string) => {
794 if (!hasTerminalWriteAccess()) return;
735 const activeTerminal = terminalRef.current;795 const activeTerminal = terminalRef.current;
736 if (!activeTerminal) return;796 if (!activeTerminal) return;
737 const result = await writeTerminal(data);797 const result = await writeTerminal(data);
...@@ -784,6 +844,7 @@ export function TerminalViewport({...@@ -784,6 +844,7 @@ export function TerminalViewport({
784 }844 }
785845
786 function handleLinkActivate(text: string, event: MouseEvent): void {846 function handleLinkActivate(text: string, event: MouseEvent): void {
847 if (!canActivateTerminalLink(text)) return;
787 const latestTerminal = terminalRef.current;848 const latestTerminal = terminalRef.current;
788 if (!latestTerminal) return;849 if (!latestTerminal) return;
789 if (isTerminalUrl(text)) {850 if (isTerminalUrl(text)) {
...@@ -835,6 +896,7 @@ export function TerminalViewport({...@@ -835,6 +896,7 @@ export function TerminalViewport({
835 }896 }
836897
837 function handleData(data: string): void {898 function handleData(data: string): void {
899 if (!hasTerminalWriteAccess()) return;
838 void (async () => {900 void (async () => {
839 const result = await writeTerminal(data);901 const result = await writeTerminal(data);
840 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;902 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
...@@ -924,7 +986,9 @@ export function TerminalViewport({...@@ -924,7 +986,9 @@ export function TerminalViewport({
924 cancelled = true;986 cancelled = true;
925 const hadFocus = mount.contains(document.activeElement);987 const hadFocus = mount.contains(document.activeElement);
926 teardown?.();988 teardown?.();
927 if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true });989 if (hasTerminalWriteAccess() && hadFocus && mount.isConnected) {
990 mount.focus({ preventScroll: true });
991 }
928 };992 };
929 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);993 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
930994
...@@ -942,15 +1006,12 @@ export function TerminalViewport({...@@ -942,15 +1006,12 @@ export function TerminalViewport({
942 }1006 }
9431007
944 const previous = previousSessionRef.current;1008 const previous = previousSessionRef.current;
945 synchronizeTerminalStatus(terminal, current.status);1009 synchronizeTerminalStatus(terminal, current.status, current.version);
946 if (current.version === previous.version && current.output === previous.output) {1010 if (current.version === previous.version && current.output === previous.output) {
947 return;1011 return;
948 }1012 }
9491013
950 const outputUpdate = readTerminalOutputUpdate(current.output, outputCursorRef.current);1014 outputCursorRef.current = synchronizeTerminalOutput(terminal, current, outputCursorRef.current);
951 writeTerminalOutputUpdate(terminal, outputUpdate);
952 outputCursorRef.current = outputUpdate.cursor;
953 terminal.clearSelection();
9541015
955 if (current.error !== null && current.error !== previous.error) {1016 if (current.error !== null && current.error !== previous.error) {
956 writeSystemMessage(terminal, current.error);1017 writeSystemMessage(terminal, current.error);
...@@ -960,11 +1021,11 @@ export function TerminalViewport({...@@ -960,11 +1021,11 @@ export function TerminalViewport({
960 }, [terminalOutput, terminalError, terminalStatus, terminalVersion]);1021 }, [terminalOutput, terminalError, terminalStatus, terminalVersion]);
9611022
962 useEffect(() => {1023 useEffect(() => {
963 if (!autoFocus || !visible) return;1024 if (!autoFocus || !canOperateTerminal || !visible) return;
964 // Claim focus when requested, then hand it to the terminal once ready only1025 // Claim focus when requested, then hand it to the terminal once ready only
965 // if the user has not focused something else in the meantime.1026 // if the user has not focused something else in the meantime.
966 (terminalRef.current ?? containerRef.current)?.focus();1027 (terminalRef.current ?? containerRef.current)?.focus();
967 }, [autoFocus, focusRequestId, visible]);1028 }, [autoFocus, canOperateTerminal, focusRequestId, visible]);
9681029
969 useEffect(() => {1030 useEffect(() => {
970 const terminal = terminalRef.current;1031 const terminal = terminalRef.current;
...@@ -1029,14 +1090,29 @@ interface TerminalActionButtonProps {...@@ -1029,14 +1090,29 @@ interface TerminalActionButtonProps {
1029 className: string;1090 className: string;
1030 onClick: () => void;1091 onClick: () => void;
1031 children: ReactNode;1092 children: ReactNode;
1093 disabled?: boolean;
1032}1094}
10331095
1034function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) {1096function TerminalActionButton({
1097 label,
1098 className,
1099 onClick,
1100 children,
1101 disabled,
1102}: TerminalActionButtonProps) {
1035 return (1103 return (
1036 <Popover>1104 <Popover>
1037 <PopoverTrigger1105 <PopoverTrigger
1038 openOnHover1106 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 }
1040 >1116 >
1041 {children}1117 {children}
1042 </PopoverTrigger>1118 </PopoverTrigger>
...@@ -1082,6 +1158,7 @@ export default function ThreadTerminalDrawer({...@@ -1082,6 +1158,7 @@ export default function ThreadTerminalDrawer({
1082 terminalLabelsById,1158 terminalLabelsById,
1083 terminalLaunchLocationsById,1159 terminalLaunchLocationsById,
1084}: ThreadTerminalDrawerProps) {1160}: ThreadTerminalDrawerProps) {
1161 const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope);
1085 const isPanel = mode === "panel";1162 const isPanel = mode === "panel";
1086 const [advancedTypography] = useLocalStorage(1163 const [advancedTypography] = useLocalStorage(
1087 TYPOGRAPHY_ADVANCED_STORAGE_KEY,1164 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
...@@ -1273,24 +1350,28 @@ export default function ThreadTerminalDrawer({...@@ -1273,24 +1350,28 @@ export default function ThreadTerminalDrawer({
1273 ? `Close Terminal (${closeShortcutLabel})`1350 ? `Close Terminal (${closeShortcutLabel})`
1274 : "Close Terminal";1351 : "Close Terminal";
1275 const onSplitTerminalAction = useCallback(() => {1352 const onSplitTerminalAction = useCallback(() => {
1276 if (hasReachedSplitLimit) return;1353 if (!canOperateTerminal || hasReachedSplitLimit) return;
1277 onSplitTerminal();1354 onSplitTerminal();
1278 }, [hasReachedSplitLimit, onSplitTerminal]);1355 }, [canOperateTerminal, hasReachedSplitLimit, onSplitTerminal]);
1279 const onSplitTerminalVerticalAction = useCallback(() => {1356 const onSplitTerminalVerticalAction = useCallback(() => {
1280 if (hasReachedSplitLimit) return;1357 if (!canOperateTerminal || hasReachedSplitLimit) return;
1281 onSplitTerminalVertical();1358 onSplitTerminalVertical();
1282 }, [hasReachedSplitLimit, onSplitTerminalVertical]);1359 }, [canOperateTerminal, hasReachedSplitLimit, onSplitTerminalVertical]);
1283 const onNewTerminalAction = useCallback(() => {1360 const onNewTerminalAction = useCallback(() => {
1361 if (!canOperateTerminal) return;
1284 onNewTerminal();1362 onNewTerminal();
1285 }, [onNewTerminal]);1363 }, [canOperateTerminal, onNewTerminal]);
1286 const confirmCloseTerminal = useCallback(1364 const confirmCloseTerminal = useCallback(
1287 (terminalId: string) => {1365 (terminalId: string) => {
1366 if (!canOperateTerminal) return;
1288 const label = terminalLabelById.get(terminalId) ?? getTerminalLabel(terminalId);1367 const label = terminalLabelById.get(terminalId) ?? getTerminalLabel(terminalId);
1289 void confirmTerminalClose([label]).then((confirmed) => {1368 void confirmTerminalClose([label]).then((confirmed) => {
1290 if (confirmed) onCloseTerminal(terminalId);1369 if (confirmed && readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) {
1370 onCloseTerminal(terminalId);
1371 }
1291 });1372 });
1292 },1373 },
1293 [onCloseTerminal, terminalLabelById],1374 [canOperateTerminal, onCloseTerminal, terminalLabelById, threadRef.environmentId],
1294 );1375 );
12951376
1296 useEffect(() => {1377 useEffect(() => {
...@@ -1417,7 +1498,12 @@ export default function ThreadTerminalDrawer({...@@ -1417,7 +1498,12 @@ export default function ThreadTerminalDrawer({
1417 ) : null}1498 ) : null}
1418 <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">1499 <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">
1419 <p>No terminal sessions for this thread yet.</p>1500 <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 >
1421 {newTerminalActionLabel}1507 {newTerminalActionLabel}
1422 </Button>1508 </Button>
1423 </div>1509 </div>
...@@ -1451,6 +1537,7 @@ export default function ThreadTerminalDrawer({...@@ -1451,6 +1537,7 @@ export default function ThreadTerminalDrawer({
1451 <div className="pointer-events-none absolute right-2 top-2 z-20">1537 <div className="pointer-events-none absolute right-2 top-2 z-20">
1452 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background shadow-xs">1538 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background shadow-xs">
1453 <TerminalActionButton1539 <TerminalActionButton
1540 disabled={!canOperateTerminal}
1454 className={`p-1 text-foreground/90 transition-colors ${1541 className={`p-1 text-foreground/90 transition-colors ${
1455 hasReachedSplitLimit1542 hasReachedSplitLimit
1456 ? "cursor-not-allowed opacity-64 hover:bg-transparent"1543 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
...@@ -1463,6 +1550,7 @@ export default function ThreadTerminalDrawer({...@@ -1463,6 +1550,7 @@ export default function ThreadTerminalDrawer({
1463 </TerminalActionButton>1550 </TerminalActionButton>
1464 <div className="h-4 w-px bg-border/80" />1551 <div className="h-4 w-px bg-border/80" />
1465 <TerminalActionButton1552 <TerminalActionButton
1553 disabled={!canOperateTerminal}
1466 className={`p-1 text-foreground/90 transition-colors ${1554 className={`p-1 text-foreground/90 transition-colors ${
1467 hasReachedSplitLimit1555 hasReachedSplitLimit
1468 ? "cursor-not-allowed opacity-64 hover:bg-transparent"1556 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
...@@ -1475,6 +1563,7 @@ export default function ThreadTerminalDrawer({...@@ -1475,6 +1563,7 @@ export default function ThreadTerminalDrawer({
1475 </TerminalActionButton>1563 </TerminalActionButton>
1476 <div className="h-4 w-px bg-border/80" />1564 <div className="h-4 w-px bg-border/80" />
1477 <TerminalActionButton1565 <TerminalActionButton
1566 disabled={!canOperateTerminal}
1478 className="p-1 text-foreground/90 transition-colors hover:bg-accent"1567 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1479 onClick={onNewTerminalAction}1568 onClick={onNewTerminalAction}
1480 label={newTerminalActionLabel}1569 label={newTerminalActionLabel}
...@@ -1483,6 +1572,7 @@ export default function ThreadTerminalDrawer({...@@ -1483,6 +1572,7 @@ export default function ThreadTerminalDrawer({
1483 </TerminalActionButton>1572 </TerminalActionButton>
1484 <div className="h-4 w-px bg-border/80" />1573 <div className="h-4 w-px bg-border/80" />
1485 <TerminalActionButton1574 <TerminalActionButton
1575 disabled={!canOperateTerminal}
1486 className="p-1 text-foreground/90 transition-colors hover:bg-accent"1576 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1487 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}1577 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
1488 label={closeTerminalActionLabel}1578 label={closeTerminalActionLabel}
...@@ -1596,6 +1686,7 @@ export default function ThreadTerminalDrawer({...@@ -1596,6 +1686,7 @@ export default function ThreadTerminalDrawer({
1596 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">1686 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
1597 <div className="inline-flex h-full items-stretch">1687 <div className="inline-flex h-full items-stretch">
1598 <TerminalActionButton1688 <TerminalActionButton
1689 disabled={!canOperateTerminal}
1599 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${1690 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
1600 hasReachedSplitLimit1691 hasReachedSplitLimit
1601 ? "cursor-not-allowed opacity-64 hover:bg-transparent"1692 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
...@@ -1607,6 +1698,7 @@ export default function ThreadTerminalDrawer({...@@ -1607,6 +1698,7 @@ export default function ThreadTerminalDrawer({
1607 <SquareSplitHorizontal className="size-3.25" />1698 <SquareSplitHorizontal className="size-3.25" />
1608 </TerminalActionButton>1699 </TerminalActionButton>
1609 <TerminalActionButton1700 <TerminalActionButton
1701 disabled={!canOperateTerminal}
1610 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${1702 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
1611 hasReachedSplitLimit1703 hasReachedSplitLimit
1612 ? "cursor-not-allowed opacity-64 hover:bg-transparent"1704 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
...@@ -1618,6 +1710,7 @@ export default function ThreadTerminalDrawer({...@@ -1618,6 +1710,7 @@ export default function ThreadTerminalDrawer({
1618 <SquareSplitVertical className="size-3.25" />1710 <SquareSplitVertical className="size-3.25" />
1619 </TerminalActionButton>1711 </TerminalActionButton>
1620 <TerminalActionButton1712 <TerminalActionButton
1713 disabled={!canOperateTerminal}
1621 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"1714 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
1622 onClick={onNewTerminalAction}1715 onClick={onNewTerminalAction}
1623 label={newTerminalActionLabel}1716 label={newTerminalActionLabel}
...@@ -1625,6 +1718,7 @@ export default function ThreadTerminalDrawer({...@@ -1625,6 +1718,7 @@ export default function ThreadTerminalDrawer({
1625 <Plus className="size-3.25" />1718 <Plus className="size-3.25" />
1626 </TerminalActionButton>1719 </TerminalActionButton>
1627 <TerminalActionButton1720 <TerminalActionButton
1721 disabled={!canOperateTerminal}
1628 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"1722 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
1629 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}1723 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
1630 label={closeTerminalActionLabel}1724 label={closeTerminalActionLabel}
...@@ -1691,13 +1785,17 @@ export default function ThreadTerminalDrawer({...@@ -1691,13 +1785,17 @@ export default function ThreadTerminalDrawer({
1691 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",1785 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
1692 )}1786 )}
1693 >1787 >
1694 <PanelTabCloseButton1788 {canOperateTerminal ? (
1695 label={closeTerminalLabel}1789 <PanelTabCloseButton
1696 onClick={() => confirmCloseTerminal(terminalId)}1790 label={closeTerminalLabel}
1697 tooltip={closeTerminalLabel}1791 onClick={() => confirmCloseTerminal(terminalId)}
1698 >1792 tooltip={closeTerminalLabel}
1793 >
1794 <TerminalSquare className="size-3 shrink-0" />
1795 </PanelTabCloseButton>
1796 ) : (
1699 <TerminalSquare className="size-3 shrink-0" />1797 <TerminalSquare className="size-3 shrink-0" />
1700 </PanelTabCloseButton>1798 )}
1701 <button1799 <button
1702 type="button"1800 type="button"
1703 className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 text-left"1801 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 {...@@ -15,6 +15,7 @@ import {
15 squashAtomCommandFailure,15 squashAtomCommandFailure,
16} from "@t3tools/client-runtime/state/runtime";16} from "@t3tools/client-runtime/state/runtime";
17import {17import {
18 AuthTerminalOperateScope,
18 CommandId,19 CommandId,
19 defaultInstanceIdForDriver,20 defaultInstanceIdForDriver,
20 AuthOrchestrationOperateScope,21 AuthOrchestrationOperateScope,
...@@ -61,7 +62,11 @@ import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment...@@ -61,7 +62,11 @@ import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment
61import { useProjectScans } from "../../onboarding/useProjectScans";62import { useProjectScans } from "../../onboarding/useProjectScans";
62import { projectEnvironment } from "../../state/projects";63import { projectEnvironment } from "../../state/projects";
63import { serverEnvironment } from "../../state/server";64import { serverEnvironment } from "../../state/server";
64import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";65import {
66 readEnvironmentScope,
67 useEnvironmentScope,
68 useEnvironmentsWithScope,
69} from "../../state/session";
65import { terminalEnvironment } from "../../state/terminal";70import { terminalEnvironment } from "../../state/terminal";
66import { useAtomCommand } from "../../state/use-atom-command";71import { useAtomCommand } from "../../state/use-atom-command";
67import { connectPairing } from "../../connection/onboarding";72import { connectPairing } from "../../connection/onboarding";
...@@ -712,6 +717,7 @@ function ConnectedAgentsStep({...@@ -712,6 +717,7 @@ function ConnectedAgentsStep({
712 displayName: string;717 displayName: string;
713 autoStart: boolean;718 autoStart: boolean;
714 } | null>(null);719 } | null>(null);
720 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
715721
716 // Re-probe on entry so freshly installed CLIs show up without a manual722 // Re-probe on entry so freshly installed CLIs show up without a manual
717 // refresh; harmless when nothing changed (single-flighted per environment).723 // refresh; harmless when nothing changed (single-flighted per environment).
...@@ -745,6 +751,11 @@ function ConnectedAgentsStep({...@@ -745,6 +751,11 @@ function ConnectedAgentsStep({
745 return (751 return (
746 <section>752 <section>
747 <h2 className="mb-2 text-sm font-medium">{machineLabel}</h2>753 <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}
748 <div className="space-y-1.5">759 <div className="space-y-1.5">
749 {primaryAgents.map(({ driver, provider, instanceId }) =>760 {primaryAgents.map(({ driver, provider, instanceId }) =>
750 driver === "codex" && serverConfig !== null ? (761 driver === "codex" && serverConfig !== null ? (
...@@ -759,7 +770,11 @@ function ConnectedAgentsStep({...@@ -759,7 +770,11 @@ function ConnectedAgentsStep({
759 }770 }
760 terminalOpen={terminalSession?.driver === driver}771 terminalOpen={terminalSession?.driver === driver}
761 onOpenTerminal={() => {772 onOpenTerminal={() => {
762 if (provider === undefined) return;773 if (
774 provider === undefined ||
775 !readEnvironmentScope(environmentId, AuthTerminalOperateScope)
776 )
777 return;
763 setTerminalSession({778 setTerminalSession({
764 environmentId,779 environmentId,
765 driver,780 driver,
...@@ -785,9 +800,14 @@ function ConnectedAgentsStep({...@@ -785,9 +800,14 @@ function ConnectedAgentsStep({
785 driver={driver}800 driver={driver}
786 provider={provider}801 provider={provider}
787 terminalOpen={terminalSession?.driver === driver}802 terminalOpen={terminalSession?.driver === driver}
788 terminalAvailable={serverConfig !== null}803 terminalAvailable={serverConfig !== null && canOperateTerminal}
789 onOpenTerminal={() => {804 onOpenTerminal={() => {
790 if (provider === undefined || serverConfig === null) return;805 if (
806 provider === undefined ||
807 serverConfig === null ||
808 !readEnvironmentScope(environmentId, AuthTerminalOperateScope)
809 )
810 return;
791 setTerminalSession({811 setTerminalSession({
792 environmentId,812 environmentId,
793 driver,813 driver,
...@@ -864,6 +884,7 @@ function OnboardingCodexSetup({...@@ -864,6 +884,7 @@ function OnboardingCodexSetup({
864 } | null;884 } | null;
865 readonly onAutoStartConsumed: () => void;885 readonly onAutoStartConsumed: () => void;
866}) {886}) {
887 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
867 const update = useAtomCommand(serverEnvironment.updateSettings, "Codex setup settings");888 const update = useAtomCommand(serverEnvironment.updateSettings, "Codex setup settings");
868 const instanceId =889 const instanceId =
869 createdAccount?.instanceId ??890 createdAccount?.instanceId ??
...@@ -911,7 +932,7 @@ function OnboardingCodexSetup({...@@ -911,7 +932,7 @@ function OnboardingCodexSetup({
911 driver="codex"932 driver="codex"
912 provider={provider}933 provider={provider}
913 terminalOpen={terminalOpen}934 terminalOpen={terminalOpen}
914 terminalAvailable935 terminalAvailable={canOperateTerminal}
915 onOpenTerminal={onOpenTerminal}936 onOpenTerminal={onOpenTerminal}
916 />937 />
917 ) : (938 ) : (
...@@ -1005,6 +1026,7 @@ function AgentInstallTerminal({...@@ -1005,6 +1026,7 @@ function AgentInstallTerminal({
1005 readonly onClose: () => void;1026 readonly onClose: () => void;
1006}) {1027}) {
1007 const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session;1028 const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session;
1029 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
1008 // Same terminal typography preference the thread drawer honors.1030 // Same terminal typography preference the thread drawer honors.
1009 const [advancedTypography] = useLocalStorage(1031 const [advancedTypography] = useLocalStorage(
1010 TYPOGRAPHY_ADVANCED_STORAGE_KEY,1032 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
...@@ -1039,6 +1061,10 @@ function AgentInstallTerminal({...@@ -1039,6 +1061,10 @@ function AgentInstallTerminal({
10391061
1040 setupQueueRef.current = setupQueueRef.current.then(async () => {1062 setupQueueRef.current = setupQueueRef.current.then(async () => {
1041 if (activeSetupGenerationRef.current !== generation) return;1063 if (activeSetupGenerationRef.current !== generation) return;
1064 if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
1065 setSetupState("openFailed");
1066 return;
1067 }
1042 const opened = await openTerminal({1068 const opened = await openTerminal({
1043 environmentId,1069 environmentId,
1044 input: {1070 input: {
...@@ -1054,6 +1080,10 @@ function AgentInstallTerminal({...@@ -1054,6 +1080,10 @@ function AgentInstallTerminal({
1054 }1080 }
10551081
1056 if (activeSetupGenerationRef.current !== generation) return;1082 if (activeSetupGenerationRef.current !== generation) return;
1083 if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
1084 setSetupState("writeFailed");
1085 return;
1086 }
10571087
1058 const wrote = await writeTerminal({1088 const wrote = await writeTerminal({
1059 environmentId,1089 environmentId,
...@@ -1063,15 +1093,14 @@ function AgentInstallTerminal({...@@ -1063,15 +1093,14 @@ function AgentInstallTerminal({
1063 setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed");1093 setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed");
1064 });1094 });
10651095
1066 // Every exit path unmounts the drawer (Done, Continue/Skip, card switch,1096 // Every exit path unmounts the drawer. Close the PTY only while this
1067 // session exit), so this cleanup is the single place the PTY dies —1097 // connection still has terminal access; revocation leaves it running.
1068 // nothing is left running behind the wizard. An interrupted install is
1069 // re-runnable from the card.
1070 return () => {1098 return () => {
1071 if (activeSetupGenerationRef.current === generation) {1099 if (activeSetupGenerationRef.current === generation) {
1072 activeSetupGenerationRef.current = null;1100 activeSetupGenerationRef.current = null;
1073 }1101 }
1074 setupQueueRef.current = setupQueueRef.current.then(async () => {1102 setupQueueRef.current = setupQueueRef.current.then(async () => {
1103 if (!readEnvironmentScope(environmentId, AuthTerminalOperateScope)) return;
1075 await closeTerminal({1104 await closeTerminal({
1076 environmentId,1105 environmentId,
1077 input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true },1106 input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true },
...@@ -1097,7 +1126,9 @@ function AgentInstallTerminal({...@@ -1097,7 +1126,9 @@ function AgentInstallTerminal({
1097 >1126 >
1098 <div className="flex items-center justify-between border-b border-border/60 bg-background/60 px-3 py-1.5">1127 <div className="flex items-center justify-between border-b border-border/60 bg-background/60 px-3 py-1.5">
1099 <span className="text-2xs font-medium text-muted-foreground">1128 <span className="text-2xs font-medium text-muted-foreground">
1100 {setupState === "writeFailed" ? (1129 {!canOperateTerminal ? (
1130 "This connection cannot control terminals."
1131 ) : setupState === "writeFailed" ? (
1101 <>1132 <>
1102 Run <code className="rounded bg-muted px-1 font-mono">{command}</code> in this1133 Run <code className="rounded bg-muted px-1 font-mono">{command}</code> in this
1103 terminal.1134 terminal.
...@@ -1112,7 +1143,16 @@ function AgentInstallTerminal({...@@ -1112,7 +1143,16 @@ function AgentInstallTerminal({
1112 </span>1143 </span>
1113 <div className="flex items-center gap-1">1144 <div className="flex items-center gap-1">
1114 {setupState === "openFailed" ? (1145 {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 >
1116 Retry1156 Retry
1117 </Button>1157 </Button>
1118 ) : null}1158 ) : null}
apps/web/src/components/settings/ConnectionsSettings.logic.test.ts+36
...@@ -6,8 +6,44 @@ import {...@@ -6,8 +6,44 @@ import {
6 isQrShareableEndpoint,6 isQrShareableEndpoint,
7 isWslSettingsRowVisible,7 isWslSettingsRowVisible,
8 selectQrEndpointOption,8 selectQrEndpointOption,
9 togglePairingScopeSelection,
9} from "./ConnectionsSettings.logic";10} 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
11const baseWslState: DesktopWslState = {47const baseWslState: DesktopWslState = {
12 enabled: false,48 enabled: false,
13 distro: null,49 distro: null,
apps/web/src/components/settings/ConnectionsSettings.logic.ts+31-1
...@@ -1,4 +1,34 @@...@@ -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
3/** A missing access list says nothing about whether other clients exist. */33/** A missing access list says nothing about whether other clients exist. */
4export function canRevokeOtherClients(34export function canRevokeOtherClients(
apps/web/src/components/settings/ConnectionsSettings.tsx+9-3
...@@ -37,6 +37,7 @@ import {...@@ -37,6 +37,7 @@ import {
37 AuthFilesystemWriteScope,37 AuthFilesystemWriteScope,
38 AuthStandardClientScopes,38 AuthStandardClientScopes,
39 AuthTerminalOperateScope,39 AuthTerminalOperateScope,
40 AuthTerminalReadScope,
40 type AuthClientSession,41 type AuthClientSession,
41 type AuthEnvironmentScope,42 type AuthEnvironmentScope,
42 type AuthGrantScope,43 type AuthGrantScope,
...@@ -75,6 +76,7 @@ import {...@@ -75,6 +76,7 @@ import {
75 isQrShareableEndpoint,76 isQrShareableEndpoint,
76 isWslSettingsRowVisible,77 isWslSettingsRowVisible,
77 selectQrEndpointOption,78 selectQrEndpointOption,
79 togglePairingScopeSelection,
78} from "./ConnectionsSettings.logic";80} from "./ConnectionsSettings.logic";
79import {81import {
80 SettingsPageContainer,82 SettingsPageContainer,
...@@ -269,6 +271,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{...@@ -269,6 +271,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
269 title: "View diagnostics and usage",271 title: "View diagnostics and usage",
270 description: "Read process diagnostics, resource history, and usage totals.",272 description: "Read process diagnostics, resource history, and usage totals.",
271 },273 },
274 {
275 scope: AuthTerminalReadScope,
276 title: "View terminals",
277 description: "Read existing terminal output and status.",
278 },
272 {279 {
273 scope: AuthTerminalOperateScope,280 scope: AuthTerminalOperateScope,
274 title: "Use terminals",281 title: "Use terminals",
...@@ -1165,9 +1172,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1165,9 +1172,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1165 }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]);1172 }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]);
11661173
1167 const togglePairingScope = useCallback((scope: AuthGrantScope, checked: boolean) => {1174 const togglePairingScope = useCallback((scope: AuthGrantScope, checked: boolean) => {
1168 setPairingScopes((current) =>1175 setPairingScopes((current) => togglePairingScopeSelection(current, scope, checked));
1169 checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope),
1170 );
1171 }, []);1176 }, []);
11721177
1173 return (1178 return (
...@@ -1240,6 +1245,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1240,6 +1245,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1240 AuthOrchestrationReadScope,1245 AuthOrchestrationReadScope,
1241 AuthFilesystemReadScope,1246 AuthFilesystemReadScope,
1242 AuthDiagnosticsReadScope,1247 AuthDiagnosticsReadScope,
1248 AuthTerminalReadScope,
1243 ].filter((scope) => delegatableScopes.includes(scope)),1249 ].filter((scope) => delegatableScopes.includes(scope)),
1244 )1250 )
1245 }1251 }
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 {...@@ -8,6 +8,8 @@ import {
8} from "@t3tools/client-runtime/state/terminal";8} from "@t3tools/client-runtime/state/terminal";
9import {9import {
10 ThreadId,10 ThreadId,
11 AuthTerminalReadScope,
12 AuthTerminalOperateScope,
11 type EnvironmentId,13 type EnvironmentId,
12 type TerminalAttachInput,14 type TerminalAttachInput,
13 type TerminalSummary,15 type TerminalSummary,
...@@ -16,6 +18,7 @@ import { useMemo } from "react";...@@ -16,6 +18,7 @@ import { useMemo } from "react";
1618
17import { useEnvironmentQuery } from "./query";19import { useEnvironmentQuery } from "./query";
18import { terminalEnvironment } from "./terminal";20import { terminalEnvironment } from "./terminal";
21import { useEnvironmentScope } from "./session";
1922
20const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<ReadonlyArray<KnownTerminalSession>>([]);23const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<ReadonlyArray<KnownTerminalSession>>([]);
2124
...@@ -125,16 +128,25 @@ export function useAttachedTerminalSession(input: {...@@ -125,16 +128,25 @@ export function useAttachedTerminalSession(input: {
125 readonly environmentId: EnvironmentId | null;128 readonly environmentId: EnvironmentId | null;
126 readonly terminal: TerminalAttachInput | null;129 readonly terminal: TerminalAttachInput | null;
127}): TerminalSessionState {130}): TerminalSessionState {
131 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
132 const canOperate = useEnvironmentScope(input.environmentId, AuthTerminalOperateScope);
128 const attach = useEnvironmentQuery(133 const attach = useEnvironmentQuery(
129 input.environmentId !== null && input.terminal !== null134 input.environmentId !== null && input.terminal !== null
130 ? terminalEnvironment.attach({135 ? canOperate
131 environmentId: input.environmentId,136 ? terminalEnvironment.attach({
132 input: input.terminal,137 environmentId: input.environmentId,
133 })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
134 : null,146 : null,
135 );147 );
136 const metadata = useEnvironmentQuery(148 const metadata = useEnvironmentQuery(
137 input.environmentId === null149 input.environmentId === null || !canRead
138 ? null150 ? null
139 : terminalEnvironment.metadata({151 : terminalEnvironment.metadata({
140 environmentId: input.environmentId,152 environmentId: input.environmentId,
...@@ -160,9 +172,10 @@ export function useAttachedTerminalSession(input: {...@@ -160,9 +172,10 @@ export function useAttachedTerminalSession(input: {
160export function useKnownTerminalSessions(input: {172export function useKnownTerminalSessions(input: {
161 readonly environmentId: EnvironmentId | null;173 readonly environmentId: EnvironmentId | null;
162 readonly threadId: ThreadId | null;174 readonly threadId: ThreadId | null;
163}): ReadonlyArray<KnownTerminalSession> {175}): ReadonlyArray<KnownTerminalSession> | null {
176 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
164 const metadata = useEnvironmentQuery(177 const metadata = useEnvironmentQuery(
165 input.environmentId === null178 input.environmentId === null || !canRead
166 ? null179 ? null
167 : terminalEnvironment.metadata({180 : terminalEnvironment.metadata({
168 environmentId: input.environmentId,181 environmentId: input.environmentId,
...@@ -170,8 +183,11 @@ export function useKnownTerminalSessions(input: {...@@ -170,8 +183,11 @@ export function useKnownTerminalSessions(input: {
170 }),183 }),
171 );184 );
172 return useMemo(185 return useMemo(
173 () => selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId),186 () =>
174 [input.environmentId, input.threadId, metadata.data],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],
175 );191 );
176}192}
177193
...@@ -180,5 +196,5 @@ export function useThreadRunningTerminalIds(input: {...@@ -180,5 +196,5 @@ export function useThreadRunningTerminalIds(input: {
180 readonly threadId: ThreadId | null;196 readonly threadId: ThreadId | null;
181}): ReadonlyArray<string> {197}): ReadonlyArray<string> {
182 const sessions = useKnownTerminalSessions(input);198 const sessions = useKnownTerminalSessions(input);
183 return useMemo(() => selectRunningSubprocessTerminalIds(sessions), [sessions]);199 return useMemo(() => selectRunningSubprocessTerminalIds(sessions ?? []), [sessions]);
184}200}
apps/web/src/terminal/ghostty/surface.test.ts+115-2
...@@ -167,7 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -167,7 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => {
167 resize() {167 resize() {
168 for (const callback of resizeCallbacks) callback();168 for (const callback of resizeCallbacks) callback();
169 },169 },
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 ) {
171 canvas.dispatchEvent(177 canvas.dispatchEvent(
172 Object.assign(new Event(type, { cancelable: true }), {178 Object.assign(new Event(type, { cancelable: true }), {
173 clientX,179 clientX,
...@@ -175,7 +181,7 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -175,7 +181,7 @@ describe("GhosttyTerminalSurface visibility", () => {
175 pointerId: 1,181 pointerId: 1,
176 button,182 button,
177 buttons,183 buttons,
178 shiftKey,184 ...(typeof modifiers === "boolean" ? { shiftKey: modifiers } : modifiers),
179 }),185 }),
180 );186 );
181 },187 },
...@@ -210,6 +216,113 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -210,6 +216,113 @@ describe("GhosttyTerminalSurface visibility", () => {
210 vi.restoreAllMocks();216 vi.restoreAllMocks();
211 });217 });
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
213 it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => {326 it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => {
214 const harness = createHarness();327 const harness = createHarness();
215 const surface = await harness.create();328 const surface = await harness.create();
apps/web/src/terminal/ghostty/surface.ts+18
...@@ -547,6 +547,7 @@ export interface GhosttyTerminalSurfaceOptions {...@@ -547,6 +547,7 @@ export interface GhosttyTerminalSurfaceOptions {
547 readonly onSelectionChange: () => void;547 readonly onSelectionChange: () => void;
548 readonly beforeKey: (event: KeyboardEvent) => boolean;548 readonly beforeKey: (event: KeyboardEvent) => boolean;
549 readonly onLinkActivate: (text: string, event: MouseEvent) => void;549 readonly onLinkActivate: (text: string, event: MouseEvent) => void;
550 readonly canActivateLink?: (text: string) => boolean;
550 /**551 /**
551 * A right-click the running application did not claim through mouse552 * A right-click the running application did not claim through mouse
552 * reporting. The host owns the menu, so it also owns preventing the browser553 * reporting. The host owns the menu, so it also owns preventing the browser
...@@ -793,6 +794,12 @@ export class GhosttyTerminalSurface {...@@ -793,6 +794,12 @@ export class GhosttyTerminalSurface {
793 this.requestRender();794 this.requestRender();
794 }795 }
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
796 async setFont(font: GhosttyTerminalFont): Promise<void> {803 async setFont(font: GhosttyTerminalFont): Promise<void> {
797 if (this.disposed) return;804 if (this.disposed) return;
798 const fontSize = terminalFontSize(font.size);805 const fontSize = terminalFontSize(font.size);
...@@ -855,6 +862,12 @@ export class GhosttyTerminalSurface {...@@ -855,6 +862,12 @@ export class GhosttyTerminalSurface {
855 this.applyFontMetrics();862 this.applyFontMetrics();
856 };863 };
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
858 fit(): boolean {871 fit(): boolean {
859 if (this.disposed || !this.visible) return false;872 if (this.disposed || !this.visible) return false;
860 const width = this.mount.clientWidth;873 const width = this.mount.clientWidth;
...@@ -1926,6 +1939,11 @@ export class GhosttyTerminalSurface {...@@ -1926,6 +1939,11 @@ export class GhosttyTerminalSurface {
1926 }1939 }
19271940
1928 private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null {1941 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 {
1929 if (!this.snapshot) return null;1947 if (!this.snapshot) return null;
1930 const cell = terminalGridCellAt({1948 const cell = terminalGridCellAt({
1931 bounds: this.canvas.getBoundingClientRect(),1949 bounds: this.canvas.getBoundingClientRect(),
packages/client-runtime/src/rpc/client.ts+2-1
...@@ -66,7 +66,8 @@ export type EnvironmentSubscriptionRpcTag =...@@ -66,7 +66,8 @@ export type EnvironmentSubscriptionRpcTag =
66 | typeof WS_METHODS.subscribeVcsStatus66 | typeof WS_METHODS.subscribeVcsStatus
67 | typeof WS_METHODS.subscribeWorktreeSetup67 | typeof WS_METHODS.subscribeWorktreeSetup
68 | typeof WS_METHODS.subscribeProjectClones68 | typeof WS_METHODS.subscribeProjectClones
69 | typeof WS_METHODS.terminalAttach;69 | typeof WS_METHODS.terminalAttach
70 | typeof WS_METHODS.terminalObserve;
7071
71export type EnvironmentStreamCommandRpcTag =72export type EnvironmentStreamCommandRpcTag =
72 | typeof WS_METHODS.chatGptHandoffSubscribe73 | typeof WS_METHODS.chatGptHandoffSubscribe
packages/client-runtime/src/state/terminal.ts+9
...@@ -37,6 +37,15 @@ export function createTerminalEnvironmentAtoms<R, E>(...@@ -37,6 +37,15 @@ export function createTerminalEnvironmentAtoms<R, E>(
37 }) => JSON.stringify([environmentId, input.threadId, input.terminalId ?? null]);37 }) => JSON.stringify([environmentId, input.threadId, input.terminalId ?? null]);
38 const lifecycleConcurrency = { mode: "serial" as const, key: terminalThreadKey };38 const lifecycleConcurrency = { mode: "serial" as const, key: terminalThreadKey };
39 return {39 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 }),
40 attach: createEnvironmentSubscriptionAtomFamily(runtime, {49 attach: createEnvironmentSubscriptionAtomFamily(runtime, {
41 label: "environment-data:terminal:attach",50 label: "environment-data:terminal:attach",
42 subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.terminalAttach>) =>51 subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.terminalAttach>) =>
packages/contracts/src/auth.ts+3
...@@ -85,6 +85,7 @@ export const AuthProvidersManageScope = "providers:manage" as const;...@@ -85,6 +85,7 @@ export const AuthProvidersManageScope = "providers:manage" as const;
85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
86export const AuthPreviewOperateScope = "preview:operate" as const;86export const AuthPreviewOperateScope = "preview:operate" as const;
87export const AuthDiagnosticsReadScope = "diagnostics:read" as const;87export const AuthDiagnosticsReadScope = "diagnostics:read" as const;
88export const AuthTerminalReadScope = "terminal:read" as const;
88export const AuthTerminalOperateScope = "terminal:operate" as const;89export const AuthTerminalOperateScope = "terminal:operate" as const;
89export const AuthSourceControlWriteScope = "source-control:write" as const;90export const AuthSourceControlWriteScope = "source-control:write" as const;
90export const AuthFilesystemReadScope = "filesystem:read" as const;91export const AuthFilesystemReadScope = "filesystem:read" as const;
...@@ -103,6 +104,7 @@ export const AuthEnvironmentScope = Schema.Literals([...@@ -103,6 +104,7 @@ export const AuthEnvironmentScope = Schema.Literals([
103 AuthEnvironmentMaintainScope,104 AuthEnvironmentMaintainScope,
104 AuthPreviewOperateScope,105 AuthPreviewOperateScope,
105 AuthDiagnosticsReadScope,106 AuthDiagnosticsReadScope,
107 AuthTerminalReadScope,
106 AuthTerminalOperateScope,108 AuthTerminalOperateScope,
107 AuthFilesystemReadScope,109 AuthFilesystemReadScope,
108 AuthFilesystemWriteScope,110 AuthFilesystemWriteScope,
...@@ -132,6 +134,7 @@ export const AuthStandardClientScopes = [...@@ -132,6 +134,7 @@ export const AuthStandardClientScopes = [
132 AuthEnvironmentMaintainScope,134 AuthEnvironmentMaintainScope,
133 AuthPreviewOperateScope,135 AuthPreviewOperateScope,
134 AuthDiagnosticsReadScope,136 AuthDiagnosticsReadScope,
137 AuthTerminalReadScope,
135 AuthTerminalOperateScope,138 AuthTerminalOperateScope,
136 AuthSourceControlWriteScope,139 AuthSourceControlWriteScope,
137 AuthFilesystemReadScope,140 AuthFilesystemReadScope,
packages/contracts/src/rpc.ts+10
...@@ -218,6 +218,7 @@ import {...@@ -218,6 +218,7 @@ import {
218} from "./project.ts";218} from "./project.ts";
219import {219import {
220 TerminalAttachInput,220 TerminalAttachInput,
221 TerminalObserveInput,
221 TerminalAttachStreamEvent,222 TerminalAttachStreamEvent,
222 TerminalClearInput,223 TerminalClearInput,
223 TerminalCloseInput,224 TerminalCloseInput,
...@@ -406,6 +407,7 @@ export const WS_METHODS = {...@@ -406,6 +407,7 @@ export const WS_METHODS = {
406 // Terminal methods407 // Terminal methods
407 terminalOpen: "terminal.open",408 terminalOpen: "terminal.open",
408 terminalAttach: "terminal.attach",409 terminalAttach: "terminal.attach",
410 terminalObserve: "terminal.observe",
409 terminalWrite: "terminal.write",411 terminalWrite: "terminal.write",
410 terminalResize: "terminal.resize",412 terminalResize: "terminal.resize",
411 terminalClear: "terminal.clear",413 terminalClear: "terminal.clear",
...@@ -1351,6 +1353,13 @@ const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, {...@@ -1351,6 +1353,13 @@ const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, {
1351 stream: true,1353 stream: true,
1352});1354});
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
1354const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {1363const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {
1355 payload: TerminalWriteInput,1364 payload: TerminalWriteInput,
1356 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),1365 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
...@@ -1866,6 +1875,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1866,6 +1875,7 @@ export const WsRpcGroup = RpcGroup.make(
1866 WsReviewGetDiffFileContentsRpc,1875 WsReviewGetDiffFileContentsRpc,
1867 WsTerminalOpenRpc,1876 WsTerminalOpenRpc,
1868 WsTerminalAttachRpc,1877 WsTerminalAttachRpc,
1878 WsTerminalObserveRpc,
1869 WsTerminalWriteRpc,1879 WsTerminalWriteRpc,
1870 WsTerminalResizeRpc,1880 WsTerminalResizeRpc,
1871 WsTerminalClearRpc,1881 WsTerminalClearRpc,
packages/contracts/src/terminal.ts+3
...@@ -60,6 +60,9 @@ export const TerminalAttachInput = Schema.Struct({...@@ -60,6 +60,9 @@ export const TerminalAttachInput = Schema.Struct({
60});60});
61export type TerminalAttachInput = typeof TerminalAttachInput.Type;61export type TerminalAttachInput = typeof TerminalAttachInput.Type;
6262
63export const TerminalObserveInput = TerminalSessionInput;
64export type TerminalObserveInput = Schema.Codec.Encoded<typeof TerminalObserveInput>;
65
63export const TerminalWriteInput = Schema.Struct({66export const TerminalWriteInput = Schema.Struct({
64 ...TerminalSessionInput.fields,67 ...TerminalSessionInput.fields,
65 data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)),68 data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)),
packages/shared/src/terminalLabels.test.ts+24-1
...@@ -1,10 +1,13 @@...@@ -1,10 +1,13 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import type { TerminalSummary } from "@t3tools/contracts";3import 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
6import { getTerminalLabel, nextTerminalId, resolveTerminalSessionLabel } from "./terminalLabels.ts";7import { getTerminalLabel, nextTerminalId, resolveTerminalSessionLabel } from "./terminalLabels.ts";
78
9const decodeTerminalOpen = Schema.decodeUnknownSync(TerminalOpenInput);
10
8describe("getTerminalLabel", () => {11describe("getTerminalLabel", () => {
9 it("uses the numeric suffix for term-* ids", () => {12 it("uses the numeric suffix for term-* ids", () => {
10 expect(getTerminalLabel(DEFAULT_TERMINAL_ID)).toBe("Terminal 1");13 expect(getTerminalLabel(DEFAULT_TERMINAL_ID)).toBe("Terminal 1");
...@@ -16,6 +19,10 @@ describe("getTerminalLabel", () => {...@@ -16,6 +19,10 @@ describe("getTerminalLabel", () => {
16 it("falls back to the raw id for unknown shapes", () => {19 it("falls back to the raw id for unknown shapes", () => {
17 expect(getTerminalLabel("custom-session")).toBe("custom-session");20 expect(getTerminalLabel("custom-session")).toBe("custom-session");
18 });21 });
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 });
19});26});
2027
21describe("resolveTerminalSessionLabel", () => {28describe("resolveTerminalSessionLabel", () => {
...@@ -50,4 +57,20 @@ describe("nextTerminalId", () => {...@@ -50,4 +57,20 @@ describe("nextTerminalId", () => {
50 expect(nextTerminalId(["", " ", DEFAULT_TERMINAL_ID])).toBe("term-2");57 expect(nextTerminalId(["", " ", DEFAULT_TERMINAL_ID])).toBe("term-2");
51 expect(nextTerminalId(["", " "])).toBe("term-1");58 expect(nextTerminalId(["", " "])).toBe("term-1");
52 });59 });
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 });
53});76});
packages/shared/src/terminalLabels.ts+16-7
...@@ -1,8 +1,14 @@...@@ -1,8 +1,14 @@
1import type { TerminalSummary } from "@t3tools/contracts";1import 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
3/** Human-readable label for a terminal tab; matches mobile and web sidebars. */9/** Human-readable label for a terminal tab; matches mobile and web sidebars. */
4export function getTerminalLabel(terminalId: string): string {10export function getTerminalLabel(terminalId: string): string {
5 const numericSuffix = /^term(?:inal)?-(\d+)$/i.exec(terminalId)?.[1];11 const numericSuffix = terminalNumber(terminalId);
6 if (numericSuffix) {12 if (numericSuffix) {
7 return `Terminal ${numericSuffix}`;13 return `Terminal ${numericSuffix}`;
8 }14 }
...@@ -26,15 +32,18 @@ export function resolveTerminalSessionLabel(...@@ -26,15 +32,18 @@ export function resolveTerminalSessionLabel(
26 * Client-side terminal id allocator. Ids are ALWAYS chosen by the client and sent explicitly32 * Client-side terminal id allocator. Ids are ALWAYS chosen by the client and sent explicitly
27 * on every `terminal.open` / `terminal.attach` call — the server never allocates.33 * on every `terminal.open` / `terminal.attach` call — the server never allocates.
28 *34 *
29 * Returns the lowest unused `term-N` id (starting at `term-1`), skipping any ids already in35 * Returns the lowest unused `term-N` number. When metadata is unavailable,
30 * `existingTerminalIds`.36 * callers append a UUID so an unseen host session cannot be reused accidentally.
31 */37 */
32export function nextTerminalId(existingTerminalIds: ReadonlyArray<string>): string {38export function nextTerminalId(
33 const usedIds = new Set(existingTerminalIds.filter((id) => id.trim().length > 0));39 existingTerminalIds: ReadonlyArray<string>,
40 uniqueSuffix?: string,
41): string {
42 const usedNumbers = new Set(existingTerminalIds.map(terminalNumber));
34 let nextIndex = 1;43 let nextIndex = 1;
35 while (usedIds.has(`term-${nextIndex}`)) {44 while (usedNumbers.has(String(nextIndex))) {
36 nextIndex += 1;45 nextIndex += 1;
37 }46 }
3847
39 return `term-${nextIndex}`;48 return `term-${nextIndex}${uniqueSuffix === undefined ? "" : `-${uniqueSuffix}`}`;
40}49}