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