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