@opengeni/react 0.46.1 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -14
- package/dist/artifacts-document.d.ts +3 -0
- package/dist/artifacts-document.js +17 -0
- package/dist/artifacts-document.js.map +1 -0
- package/dist/artifacts-presentation.d.ts +3 -0
- package/dist/artifacts-presentation.js +17 -0
- package/dist/artifacts-presentation.js.map +1 -0
- package/dist/artifacts-spreadsheet.d.ts +3 -0
- package/dist/artifacts-spreadsheet.js +17 -0
- package/dist/artifacts-spreadsheet.js.map +1 -0
- package/dist/artifacts.d.ts +2 -0
- package/dist/artifacts.js +170 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/chunk-6BBWWOOU.js +82 -0
- package/dist/chunk-6BBWWOOU.js.map +1 -0
- package/dist/{chunk-HFO4ERGQ.js → chunk-724BW4TW.js} +586 -35
- package/dist/chunk-724BW4TW.js.map +1 -0
- package/dist/chunk-7I2KB3OK.js +3895 -0
- package/dist/chunk-7I2KB3OK.js.map +1 -0
- package/dist/chunk-7WCLAEFV.js +2828 -0
- package/dist/chunk-7WCLAEFV.js.map +1 -0
- package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
- package/dist/chunk-FK6VG4LL.js.map +1 -0
- package/dist/{chunk-SRFUT2ZU.js → chunk-FWS3KC3Z.js} +120 -64
- package/dist/chunk-FWS3KC3Z.js.map +1 -0
- package/dist/{chunk-EKZH6IDG.js → chunk-HDBG7XGO.js} +62 -1
- package/dist/chunk-HDBG7XGO.js.map +1 -0
- package/dist/chunk-HUIKIDM5.js +2148 -0
- package/dist/chunk-HUIKIDM5.js.map +1 -0
- package/dist/{chunk-LGVMUIRV.js → chunk-JAGDUCZQ.js} +1053 -390
- package/dist/chunk-JAGDUCZQ.js.map +1 -0
- package/dist/{chunk-LWR4MXSS.js → chunk-JR3QFY6B.js} +420 -35
- package/dist/chunk-JR3QFY6B.js.map +1 -0
- package/dist/chunk-JVYQRRC4.js +53 -0
- package/dist/chunk-JVYQRRC4.js.map +1 -0
- package/dist/chunk-KCGXPP5V.js +157 -0
- package/dist/chunk-KCGXPP5V.js.map +1 -0
- package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
- package/dist/chunk-KZ73RL76.js.map +1 -0
- package/dist/chunk-PEI6RANI.js +3234 -0
- package/dist/chunk-PEI6RANI.js.map +1 -0
- package/dist/{chunk-3PB7MIT6.js → chunk-ROIRTM37.js} +220 -72
- package/dist/chunk-ROIRTM37.js.map +1 -0
- package/dist/chunk-VZTQ73XT.js +86 -0
- package/dist/chunk-VZTQ73XT.js.map +1 -0
- package/dist/{chunk-VCBFUSUZ.js → chunk-WSK7G5LE.js} +72 -4
- package/dist/chunk-WSK7G5LE.js.map +1 -0
- package/dist/{chunk-FGZUCXZF.js → chunk-WYA65Y3F.js} +16 -16
- package/dist/chunk-WYA65Y3F.js.map +1 -0
- package/dist/{chunk-6EDV6YFE.js → chunk-X4S56Y2H.js} +147 -35
- package/dist/chunk-X4S56Y2H.js.map +1 -0
- package/dist/client.d.ts +7 -1
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +7 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/commands/index.d.ts +3 -3
- package/dist/commands/registry.d.ts +1 -1
- package/dist/commands/types.d.ts +1 -1
- package/dist/components/approval-surface.d.ts +2 -2
- package/dist/components/artifacts/artifact-surface.d.ts +18 -0
- package/dist/components/artifacts/document-editor.d.ts +172 -0
- package/dist/components/artifacts/editable-artifact-ui.d.ts +13 -0
- package/dist/components/artifacts/editable-artifact-workbench.d.ts +49 -0
- package/dist/components/artifacts/editable-document.d.ts +24 -0
- package/dist/components/artifacts/editable-presentation.d.ts +24 -0
- package/dist/components/artifacts/editable-spreadsheet.d.ts +32 -0
- package/dist/components/artifacts/index.d.ts +8 -0
- package/dist/components/artifacts/presentation-editor.d.ts +297 -0
- package/dist/components/artifacts/spreadsheet-canvas.d.ts +120 -0
- package/dist/components/artifacts/spreadsheet-grid.d.ts +113 -0
- package/dist/components/browser-viewer.d.ts +29 -0
- package/dist/components/chat-composer.d.ts +13 -8
- package/dist/components/code-editor.d.ts +25 -3
- package/dist/components/command-palette.d.ts +2 -2
- package/dist/components/composer-transcription-control.d.ts +4 -2
- package/dist/components/composer.d.ts +29 -15
- package/dist/components/computer-viewer.d.ts +23 -0
- package/dist/components/copy-button.d.ts +2 -2
- package/dist/components/desktop-viewer.d.ts +25 -21
- package/dist/components/diff-view.d.ts +1 -1
- package/dist/components/enrollment-consent.d.ts +1 -1
- package/dist/components/enrollment-device-flow.d.ts +1 -1
- package/dist/components/file-browser.d.ts +2 -2
- package/dist/components/fleet-tile.d.ts +1 -1
- package/dist/components/generated-video-player.d.ts +10 -0
- package/dist/components/human-input-form.d.ts +1 -1
- package/dist/components/human-input-surface.d.ts +2 -2
- package/dist/components/interactive-workbench-viewers.d.ts +2 -0
- package/dist/components/machine-card.d.ts +2 -2
- package/dist/components/machine-dock-bar.d.ts +3 -3
- package/dist/components/machine-health-pill.d.ts +2 -2
- package/dist/components/machine-input-display.d.ts +1 -1
- package/dist/components/machine-metrics.d.ts +2 -2
- package/dist/components/machine-status-pill.d.ts +4 -4
- package/dist/components/machines/health.d.ts +1 -1
- package/dist/components/machines/machine-detail.d.ts +5 -3
- package/dist/components/machines/metric-history-chart.d.ts +1 -1
- package/dist/components/machines/metric-sparkline.d.ts +2 -2
- package/dist/components/machines/series.d.ts +2 -2
- package/dist/components/machines-dashboard.d.ts +5 -5
- package/dist/components/markdown.d.ts +1 -1
- package/dist/components/message-timeline.d.ts +22 -5
- package/dist/components/model-picker.d.ts +2 -2
- package/dist/components/model-policy-picker.d.ts +7 -7
- package/dist/components/pierre-diff.d.ts +1 -1
- package/dist/components/pierre-file.d.ts +1 -1
- package/dist/components/queue-draft-policy.d.ts +1 -1
- package/dist/components/queue-surface-implementation.d.ts +3 -3
- package/dist/components/queue-surface-state.d.ts +4 -4
- package/dist/components/queue-surface.d.ts +5 -5
- package/dist/components/sandbox-files.d.ts +5 -5
- package/dist/components/sandbox-terminal.d.ts +50 -4
- package/dist/components/sandbox-workspace.d.ts +30 -10
- package/dist/components/session-chrome.d.ts +4 -4
- package/dist/components/session-status.d.ts +2 -2
- package/dist/components/timeline-annotation-selection.d.ts +9 -0
- package/dist/components/timeline-annotations-dialog.d.ts +16 -0
- package/dist/components/timeline-annotations.d.ts +13 -0
- package/dist/components/tooltip.d.ts +4 -4
- package/dist/components/user-message-body.d.ts +2 -2
- package/dist/components/workbench-changes.d.ts +2 -2
- package/dist/components/workspace-dock.d.ts +3 -1
- package/dist/composer.d.ts +11 -11
- package/dist/composer.js +7 -7
- package/dist/hooks/internal.d.ts +7 -1
- package/dist/hooks/use-attached-browsers.d.ts +21 -0
- package/dist/hooks/use-available-models.d.ts +2 -2
- package/dist/hooks/use-billing-usage.d.ts +1 -1
- package/dist/hooks/use-browser-frame-stream.d.ts +28 -0
- package/dist/hooks/use-browser-identities.d.ts +26 -0
- package/dist/hooks/use-browser-session.d.ts +28 -0
- package/dist/hooks/use-browser-sessions.d.ts +32 -0
- package/dist/hooks/use-codex-accounts.d.ts +3 -3
- package/dist/hooks/use-composer.d.ts +36 -9
- package/dist/hooks/use-computer-frame-stream.d.ts +24 -0
- package/dist/hooks/use-computer-session.d.ts +28 -0
- package/dist/hooks/use-computer-sessions.d.ts +28 -0
- package/dist/hooks/use-desktop-stream.d.ts +1 -1
- package/dist/hooks/use-environments.d.ts +11 -5
- package/dist/hooks/use-file-attachments.d.ts +1 -1
- package/dist/hooks/use-goal.d.ts +2 -2
- package/dist/hooks/use-human-input.d.ts +2 -2
- package/dist/hooks/use-last-started-turn-policy.d.ts +2 -2
- package/dist/hooks/use-machine-chip.d.ts +2 -2
- package/dist/hooks/use-machines.d.ts +10 -3
- package/dist/hooks/use-packs.d.ts +7 -2
- package/dist/hooks/use-rigs.d.ts +1 -1
- package/dist/hooks/use-sandbox-files.d.ts +25 -4
- package/dist/hooks/use-sandbox-git.d.ts +13 -5
- package/dist/hooks/use-sandbox-terminal.d.ts +1 -1
- package/dist/hooks/use-scheduled-tasks.d.ts +1 -1
- package/dist/hooks/use-session-capabilities.d.ts +5 -4
- package/dist/hooks/use-session-control.d.ts +1 -1
- package/dist/hooks/use-session-events.d.ts +2 -2
- package/dist/hooks/use-session-lineage.d.ts +1 -1
- package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
- package/dist/hooks/use-session.d.ts +2 -2
- package/dist/hooks/use-slash-commands.d.ts +1 -1
- package/dist/hooks/use-terminal-stream.d.ts +8 -2
- package/dist/hooks/use-transcription.d.ts +2 -2
- package/dist/hooks/use-turn-queue.d.ts +2 -2
- package/dist/hooks/use-video-artifact-playback.d.ts +9 -0
- package/dist/hooks/use-voice-input.d.ts +6 -4
- package/dist/hooks/use-workspace-capture.d.ts +1 -1
- package/dist/hooks/use-workspace-edit.d.ts +1 -1
- package/dist/hooks/use-workspace-sessions.d.ts +1 -1
- package/dist/hooks/use-workspaces.d.ts +1 -1
- package/dist/index.d.ts +158 -154
- package/dist/index.js +1718 -908
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +23 -0
- package/dist/interaction.js +36 -0
- package/dist/interaction.js.map +1 -0
- package/dist/interactive-workbench-viewers-KHNOHWQE.js +13 -0
- package/dist/interactive-workbench-viewers-KHNOHWQE.js.map +1 -0
- package/dist/lib/composer-responsive-context.d.ts +9 -0
- package/dist/lib/relay-wire.d.ts +4 -0
- package/dist/lib/sandbox-liveness.d.ts +9 -0
- package/dist/lib/terminal-capability.d.ts +5 -0
- package/dist/lib/use-portal-token-style.d.ts +14 -3
- package/dist/lib/xterm-theme.d.ts +1 -1
- package/dist/machines.d.ts +30 -30
- package/dist/machines.js +3 -3
- package/dist/provider.d.ts +4 -4
- package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-2KVJTYXH.js} +117 -64
- package/dist/queue-surface-implementation-2KVJTYXH.js.map +1 -0
- package/dist/realtime/dropdown-menu.d.ts +8 -8
- package/dist/realtime/realtime-control.d.ts +15 -6
- package/dist/realtime.d.ts +4 -4
- package/dist/realtime.js +30 -33
- package/dist/realtime.js.map +1 -1
- package/dist/session-context.d.ts +19 -1
- package/dist/session-ui.d.ts +14 -14
- package/dist/session-ui.js +6 -5
- package/dist/session.d.ts +28 -28
- package/dist/session.js +5 -5
- package/dist/timeline/activity-rail.d.ts +6 -3
- package/dist/timeline/disclosure-context.d.ts +1 -1
- package/dist/timeline/fleet-decision-projection.d.ts +1 -1
- package/dist/timeline/fleet-decision-row.d.ts +3 -3
- package/dist/timeline/index.d.ts +19 -19
- package/dist/timeline/parsers.d.ts +6 -3
- package/dist/timeline/projection.d.ts +3 -3
- package/dist/timeline/registry.d.ts +10 -1
- package/dist/timeline/screenshot-lightbox.d.ts +2 -2
- package/dist/timeline/shared.d.ts +21 -10
- package/dist/timeline/tool-diff.d.ts +2 -2
- package/dist/timeline/tool-renderers.d.ts +1 -1
- package/dist/timeline/turn-summary.d.ts +3 -3
- package/dist/timeline/types.d.ts +42 -2
- package/dist/timeline-annotation-selection-3L7LHGG2.js +187 -0
- package/dist/timeline-annotation-selection-3L7LHGG2.js.map +1 -0
- package/dist/timeline-annotations-dialog-U7EVLR75.js +196 -0
- package/dist/timeline-annotations-dialog-U7EVLR75.js.map +1 -0
- package/dist/voice-recording-store.d.ts +8 -2
- package/package.json +52 -7
- package/src/artifacts-document.ts +31 -0
- package/src/artifacts-presentation.ts +36 -0
- package/src/artifacts-spreadsheet.ts +21 -0
- package/src/artifacts.ts +86 -0
- package/src/client.ts +90 -1
- package/src/clipboard.ts +2 -0
- package/src/components/artifacts/artifact-surface.tsx +93 -0
- package/src/components/artifacts/document-editor.tsx +2255 -0
- package/src/components/artifacts/editable-artifact-ui.tsx +89 -0
- package/src/components/artifacts/editable-artifact-workbench.tsx +203 -0
- package/src/components/artifacts/editable-document.tsx +657 -0
- package/src/components/artifacts/editable-presentation.tsx +1020 -0
- package/src/components/artifacts/editable-spreadsheet.tsx +733 -0
- package/src/components/artifacts/index.ts +99 -0
- package/src/components/artifacts/presentation-editor.tsx +3148 -0
- package/src/components/artifacts/spreadsheet-canvas.ts +799 -0
- package/src/components/artifacts/spreadsheet-grid.tsx +2129 -0
- package/src/components/browser-viewer.tsx +1592 -0
- package/src/components/chat-composer.tsx +25 -2
- package/src/components/code-editor.tsx +90 -31
- package/src/components/command-palette.tsx +1 -1
- package/src/components/composer-transcription-control.tsx +51 -22
- package/src/components/composer.tsx +49 -23
- package/src/components/computer-viewer.tsx +1139 -0
- package/src/components/copy-button.tsx +1 -1
- package/src/components/desktop-viewer.tsx +103 -75
- package/src/components/file-browser.tsx +39 -17
- package/src/components/generated-video-player.tsx +89 -0
- package/src/components/human-input-form.tsx +2 -2
- package/src/components/interactive-workbench-viewers.ts +2 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machine-input-display.ts +3 -0
- package/src/components/machines/machine-detail.tsx +39 -15
- package/src/components/machines-dashboard.tsx +7 -6
- package/src/components/message-timeline.tsx +327 -37
- package/src/components/model-picker.tsx +2 -2
- package/src/components/model-policy-picker.tsx +16 -15
- package/src/components/queue-surface-implementation.tsx +135 -75
- package/src/components/queue-surface-state.tsx +2 -2
- package/src/components/sandbox-files.tsx +142 -62
- package/src/components/sandbox-terminal.tsx +119 -35
- package/src/components/sandbox-workspace.tsx +394 -228
- package/src/components/session-chrome.tsx +67 -27
- package/src/components/timeline-annotation-selection.tsx +221 -0
- package/src/components/timeline-annotations-dialog.tsx +213 -0
- package/src/components/timeline-annotations.tsx +84 -0
- package/src/components/tooltip.tsx +13 -16
- package/src/components/user-message-body.tsx +1 -1
- package/src/components/workbench-changes.tsx +192 -35
- package/src/components/workspace-dock.tsx +13 -5
- package/src/composer.ts +3 -0
- package/src/hooks/internal.ts +132 -38
- package/src/hooks/use-attached-browsers.ts +152 -0
- package/src/hooks/use-browser-frame-stream.ts +316 -0
- package/src/hooks/use-browser-identities.ts +225 -0
- package/src/hooks/use-browser-session.ts +476 -0
- package/src/hooks/use-browser-sessions.ts +281 -0
- package/src/hooks/use-codex-accounts.ts +4 -4
- package/src/hooks/use-composer.ts +769 -24
- package/src/hooks/use-computer-frame-stream.ts +311 -0
- package/src/hooks/use-computer-session.ts +442 -0
- package/src/hooks/use-computer-sessions.ts +245 -0
- package/src/hooks/use-environments.ts +24 -5
- package/src/hooks/use-file-attachments.ts +70 -32
- package/src/hooks/use-goal.ts +17 -5
- package/src/hooks/use-machine-chip.ts +5 -3
- package/src/hooks/use-machines.ts +79 -3
- package/src/hooks/use-packs.ts +51 -0
- package/src/hooks/use-sandbox-files.ts +382 -88
- package/src/hooks/use-sandbox-git.ts +208 -46
- package/src/hooks/use-sandbox-terminal.ts +3 -2
- package/src/hooks/use-session-capabilities.ts +94 -70
- package/src/hooks/use-session-events.ts +18 -4
- package/src/hooks/use-terminal-stream.ts +102 -23
- package/src/hooks/use-transcription.ts +4 -14
- package/src/hooks/use-turn-queue.ts +17 -5
- package/src/hooks/use-video-artifact-playback.ts +28 -0
- package/src/hooks/use-voice-input.ts +163 -19
- package/src/hooks/use-windowed-sections.ts +9 -7
- package/src/hooks/use-workspace-edit.ts +2 -1
- package/src/index.ts +21 -6
- package/src/interaction.ts +71 -0
- package/src/lib/composer-responsive-context.ts +15 -0
- package/src/lib/relay-wire.ts +4 -0
- package/src/lib/sandbox-liveness.ts +11 -0
- package/src/lib/terminal-capability.ts +10 -0
- package/src/lib/use-portal-token-style.ts +60 -15
- package/src/provider.tsx +52 -7
- package/src/realtime/dropdown-menu.tsx +15 -19
- package/src/realtime/realtime-control.tsx +16 -5
- package/src/session-context.ts +90 -0
- package/src/timeline/activity-rail.tsx +31 -6
- package/src/timeline/index.ts +4 -1
- package/src/timeline/parsers.ts +46 -20
- package/src/timeline/projection.ts +233 -13
- package/src/timeline/registry.ts +19 -0
- package/src/timeline/screenshot-lightbox.tsx +30 -9
- package/src/timeline/shared.tsx +59 -11
- package/src/timeline/tool-renderers.tsx +421 -24
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +58 -2
- package/src/voice-recording-store.ts +15 -0
- package/styles/compiled.css +5298 -0
- package/styles/compiled.d.ts +2 -0
- package/styles/effective-tokens.css +27 -0
- package/styles/index.css +18 -0
- package/styles/responsive.css +208 -0
- package/styles/responsive.d.ts +2 -0
- package/styles/tokens.css +50 -17
- package/dist/chunk-3PB7MIT6.js.map +0 -1
- package/dist/chunk-6EDV6YFE.js.map +0 -1
- package/dist/chunk-EB67J347.js.map +0 -1
- package/dist/chunk-EKZH6IDG.js.map +0 -1
- package/dist/chunk-FGZUCXZF.js.map +0 -1
- package/dist/chunk-HFO4ERGQ.js.map +0 -1
- package/dist/chunk-JB5725BI.js +0 -48
- package/dist/chunk-JB5725BI.js.map +0 -1
- package/dist/chunk-LGVMUIRV.js.map +0 -1
- package/dist/chunk-LWR4MXSS.js.map +0 -1
- package/dist/chunk-SRFUT2ZU.js.map +0 -1
- package/dist/chunk-U3CSCMWA.js.map +0 -1
- package/dist/chunk-VCBFUSUZ.js.map +0 -1
- package/dist/fleet-decision-projection-SLNZMS42.js +0 -241
- package/dist/fleet-decision-projection-SLNZMS42.js.map +0 -1
- package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
|
@@ -7,9 +7,13 @@ import {
|
|
|
7
7
|
} from "@opengeni/sdk";
|
|
8
8
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
9
9
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
10
|
+
import { sandboxAcceptsLiveIo } from "../lib/sandbox-liveness";
|
|
11
|
+
import { terminalCanAcquirePty } from "../lib/terminal-capability";
|
|
12
|
+
import { usePageLiveActivity } from "./internal";
|
|
10
13
|
|
|
11
|
-
// "on-demand" is the boxless-benign resting state: the lease is
|
|
12
|
-
// asked to warm a box (no viewer/consent action, no
|
|
14
|
+
// "on-demand" is the boxless-benign resting state: the lease is not currently
|
|
15
|
+
// holder-backed and NOTHING asked to warm a box (no viewer/consent action, no
|
|
16
|
+
// files-tab warm). Under lazy
|
|
13
17
|
// provisioning a chat-only turn never creates a box, so this is the correct calm
|
|
14
18
|
// terminal state — NOT an error. It is distinct from "cold", which now means "a
|
|
15
19
|
// warm-up is genuinely in flight" (a viewer attach was requested) and therefore
|
|
@@ -83,7 +87,8 @@ export type UseSessionCapabilitiesResult = {
|
|
|
83
87
|
error: Error | null;
|
|
84
88
|
/**
|
|
85
89
|
* 409 from the desktop attach: the un-redacted (or shared) plane needs explicit
|
|
86
|
-
* acknowledgment before a viewer holder is granted.
|
|
90
|
+
* acknowledgment before a viewer holder is granted. The workbench uses this
|
|
91
|
+
* to complete acknowledgment automatically while Desktop is opening.
|
|
87
92
|
*/
|
|
88
93
|
acknowledgmentRequired: "unredacted" | "shared" | null;
|
|
89
94
|
/** 429 from the desktop attach: the per-session viewer cap is reached. */
|
|
@@ -111,24 +116,10 @@ function desktopAttachable(cell: SessionCapabilities["DesktopStream"]): boolean
|
|
|
111
116
|
|
|
112
117
|
/**
|
|
113
118
|
* Whether warming the box for the interactive terminal (pty-ws) is worth it.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* what flips `sse-events` → `pty-ws` by warming the box and minting the ttyd
|
|
118
|
-
* tunnel URL, so we attach whenever the terminal is not hard-unavailable.
|
|
119
|
+
* A real-PTY descriptor is attachable when already routed as `pty-ws`, or while
|
|
120
|
+
* transiently cold. A firehose-only backend and policy/OS/backend degradation
|
|
121
|
+
* cannot become interactive, so attaching there would only waste a holder.
|
|
119
122
|
*/
|
|
120
|
-
function terminalAttachable(cell: SessionCapabilities["Terminal"]): boolean {
|
|
121
|
-
if (cell.transport === null) {
|
|
122
|
-
// No terminal at all unless the only blocker is a transient cold state.
|
|
123
|
-
return (
|
|
124
|
-
cell.reason === "lease_cold" || cell.reason === "not_provisioned" || cell.reason === null
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
// Already pty-ws (warm) is fine to (re)attach; sse-events is the cold state the
|
|
128
|
-
// attach upgrades. Either way it's attachable.
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
123
|
/** Read `stream.url.rotated` payloads off the live event log, newest last. */
|
|
133
124
|
function rotationsFrom(events: SessionEvent[]): StreamUrlRotatedPayload[] {
|
|
134
125
|
const out: StreamUrlRotatedPayload[] = [];
|
|
@@ -148,8 +139,8 @@ function rotationsFrom(events: SessionEvent[]): StreamUrlRotatedPayload[] {
|
|
|
148
139
|
*
|
|
149
140
|
* Degradation is a value, never a crash: an unsupported surface comes back
|
|
150
141
|
* `available:false`/`transport:null` + a `reason`; the components render the
|
|
151
|
-
* reason-aware empty state. 409 (
|
|
152
|
-
* typed signals, not thrown.
|
|
142
|
+
* reason-aware empty state. 409 (acknowledgment) and 429 (viewer cap) are
|
|
143
|
+
* surfaced as typed signals, not thrown.
|
|
153
144
|
*/
|
|
154
145
|
export function useSessionCapabilities(
|
|
155
146
|
sessionId: string | null | undefined,
|
|
@@ -157,6 +148,8 @@ export function useSessionCapabilities(
|
|
|
157
148
|
): UseSessionCapabilitiesResult {
|
|
158
149
|
const { client, workspaceId } = useOpenGeni(options);
|
|
159
150
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
151
|
+
const pageLive = usePageLiveActivity();
|
|
152
|
+
const lifecycleEnabled = enabled && pageLive;
|
|
160
153
|
const attachDesktop = options.attachDesktop ?? false;
|
|
161
154
|
const attachTerminal = options.attachTerminal ?? false;
|
|
162
155
|
const attachFiles = options.attachFiles ?? false;
|
|
@@ -201,21 +194,28 @@ export function useSessionCapabilities(
|
|
|
201
194
|
epochRef.current = 0;
|
|
202
195
|
viewerIdRef.current = null;
|
|
203
196
|
}
|
|
204
|
-
if (!
|
|
197
|
+
if (!lifecycleEnabled || !sessionId) {
|
|
205
198
|
setState("idle");
|
|
206
199
|
setCapabilities(null);
|
|
200
|
+
viewerIdRef.current = null;
|
|
201
|
+
setViewerId(null);
|
|
207
202
|
return;
|
|
208
203
|
}
|
|
204
|
+
// The preceding effect cleanup released any old holder before this fresh
|
|
205
|
+
// lifecycle starts. Never project that stale id while re-negotiating or if
|
|
206
|
+
// the replacement grant degrades.
|
|
207
|
+
viewerIdRef.current = null;
|
|
208
|
+
setViewerId(null);
|
|
209
209
|
let cancelled = false;
|
|
210
210
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
211
|
-
let heartbeatTimer: ReturnType<typeof
|
|
211
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
212
212
|
let localViewerId: string | null = null;
|
|
213
213
|
const requestAbort = new AbortController();
|
|
214
214
|
const startedAt = Date.now();
|
|
215
215
|
|
|
216
216
|
const clearTimers = () => {
|
|
217
217
|
if (pollTimer !== null) clearTimeout(pollTimer);
|
|
218
|
-
if (heartbeatTimer !== null)
|
|
218
|
+
if (heartbeatTimer !== null) clearTimeout(heartbeatTimer);
|
|
219
219
|
pollTimer = null;
|
|
220
220
|
heartbeatTimer = null;
|
|
221
221
|
};
|
|
@@ -231,27 +231,35 @@ export function useSessionCapabilities(
|
|
|
231
231
|
if (!localViewerId || heartbeatTimer !== null) return;
|
|
232
232
|
const intervalMs =
|
|
233
233
|
caps.viewerHeartbeatIntervalMs > 0 ? caps.viewerHeartbeatIntervalMs : 30_000;
|
|
234
|
-
|
|
234
|
+
const schedule = () => {
|
|
235
235
|
if (cancelled || !localViewerId) return;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
236
|
+
heartbeatTimer = setTimeout(() => {
|
|
237
|
+
heartbeatTimer = null;
|
|
238
|
+
if (cancelled || !localViewerId) return;
|
|
239
|
+
void client
|
|
240
|
+
.heartbeatViewer(workspaceId, sessionId, localViewerId, {
|
|
241
|
+
leaseEpoch: epochRef.current,
|
|
242
|
+
})
|
|
243
|
+
.then((res) => {
|
|
244
|
+
// alive:false ⇒ the holder was reaped or the epoch moved under us;
|
|
245
|
+
// re-negotiate to re-acquire against the new owner.
|
|
246
|
+
if (!res.alive && !cancelled) {
|
|
247
|
+
renegotiate();
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
.catch((cause) => {
|
|
251
|
+
if (cancelled) return;
|
|
252
|
+
if (
|
|
253
|
+
cause instanceof OpenGeniApiError &&
|
|
254
|
+
(cause.status === 409 || cause.status === 410)
|
|
255
|
+
) {
|
|
256
|
+
renegotiate();
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
.finally(schedule);
|
|
260
|
+
}, intervalMs);
|
|
261
|
+
};
|
|
262
|
+
schedule();
|
|
255
263
|
};
|
|
256
264
|
|
|
257
265
|
const pollUntilWarm = () => {
|
|
@@ -262,7 +270,7 @@ export function useSessionCapabilities(
|
|
|
262
270
|
.then((caps) => {
|
|
263
271
|
if (cancelled) return;
|
|
264
272
|
settle(caps);
|
|
265
|
-
if (caps.liveness
|
|
273
|
+
if (sandboxAcceptsLiveIo(caps.liveness)) {
|
|
266
274
|
setState("ready");
|
|
267
275
|
return;
|
|
268
276
|
}
|
|
@@ -297,8 +305,8 @@ export function useSessionCapabilities(
|
|
|
297
305
|
// attach serves BOTH live planes — the desktop pixel stream AND the
|
|
298
306
|
// interactive terminal (ttyd pty-ws) ride the same warm box and the same
|
|
299
307
|
// holder; the response folds the minted address for each requested plane.
|
|
300
|
-
// The
|
|
301
|
-
// rather than throwing — the tabs degrade gracefully.
|
|
308
|
+
// The acknowledgment requirement (409) and viewer cap (429) surface as
|
|
309
|
+
// typed signals rather than throwing — the tabs degrade gracefully.
|
|
302
310
|
//
|
|
303
311
|
// KEY: the handshake (getStreamCapabilities) NEVER spins up a cold box, so
|
|
304
312
|
// on a cold/warming lease the desktop cell comes back transport:null and
|
|
@@ -310,7 +318,7 @@ export function useSessionCapabilities(
|
|
|
310
318
|
// already live OR cold-but-feasible — and let POST /viewers warm the box and
|
|
311
319
|
// mint the URLs. Only a genuinely-unsupported reason suppresses the attach.
|
|
312
320
|
const wantDesktopAttach = attachDesktop && desktopAttachable(caps.DesktopStream);
|
|
313
|
-
const wantTerminalAttach = attachTerminal &&
|
|
321
|
+
const wantTerminalAttach = attachTerminal && terminalCanAcquirePty(caps.Terminal);
|
|
314
322
|
// The Files attach warms the box for a file WRITE (wake-on-edit intent). It
|
|
315
323
|
// folds no live URL (files are stateless HTTP) — the attach is purely a
|
|
316
324
|
// liveness refcount. Unlike the previous "keep the box warm while the Files
|
|
@@ -328,6 +336,8 @@ export function useSessionCapabilities(
|
|
|
328
336
|
// pty-ws terminal cell WITHOUT tripping the desktop consent 409.
|
|
329
337
|
const holder = await client.attachViewer(workspaceId, sessionId, {
|
|
330
338
|
desktop: wantDesktopAttach,
|
|
339
|
+
terminal: wantTerminalAttach,
|
|
340
|
+
files: wantFilesAttach,
|
|
331
341
|
});
|
|
332
342
|
if (cancelled) {
|
|
333
343
|
// The attach crossed an identity/unmount boundary after the server
|
|
@@ -337,6 +347,18 @@ export function useSessionCapabilities(
|
|
|
337
347
|
void client.detachViewer(workspaceId, sessionId, holder.viewerId).catch(() => {});
|
|
338
348
|
return;
|
|
339
349
|
}
|
|
350
|
+
if (
|
|
351
|
+
(wantDesktopAttach &&
|
|
352
|
+
(!holder.dataPlaneUrl || !holder.transport || !holder.client)) ||
|
|
353
|
+
(wantTerminalAttach && (!holder.terminalUrl || !holder.terminalTransport))
|
|
354
|
+
) {
|
|
355
|
+
// Nullable plane cells are valid graceful-degradation values at
|
|
356
|
+
// the raw API boundary, but an explicit UI grant must not become
|
|
357
|
+
// a successful holder that waits forever. Release it and surface
|
|
358
|
+
// a retryable error instead of retaining stale credentials.
|
|
359
|
+
await client.detachViewer(workspaceId, sessionId, holder.viewerId).catch(() => {});
|
|
360
|
+
throw new Error("live plane unavailable");
|
|
361
|
+
}
|
|
340
362
|
localViewerId = holder.viewerId;
|
|
341
363
|
viewerIdRef.current = holder.viewerId;
|
|
342
364
|
setViewerId(holder.viewerId);
|
|
@@ -356,24 +378,24 @@ export function useSessionCapabilities(
|
|
|
356
378
|
...prev.DesktopStream,
|
|
357
379
|
transport: holder.transport ?? prev.DesktopStream.transport,
|
|
358
380
|
client: holder.client ?? prev.DesktopStream.client,
|
|
359
|
-
url: holder.dataPlaneUrl
|
|
360
|
-
token: holder.streamToken
|
|
361
|
-
expiresAt: holder.streamExpiresAt
|
|
381
|
+
url: holder.dataPlaneUrl,
|
|
382
|
+
token: holder.streamToken,
|
|
383
|
+
expiresAt: holder.streamExpiresAt,
|
|
362
384
|
resolution: holder.resolution ?? prev.DesktopStream.resolution,
|
|
363
385
|
}
|
|
364
386
|
: prev.DesktopStream,
|
|
365
|
-
Terminal:
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
387
|
+
Terminal: wantTerminalAttach
|
|
388
|
+
? {
|
|
389
|
+
...prev.Terminal,
|
|
390
|
+
transport: holder.terminalTransport ?? prev.Terminal.transport,
|
|
391
|
+
url: holder.terminalUrl,
|
|
392
|
+
token: holder.terminalToken,
|
|
393
|
+
expiresAt: holder.terminalExpiresAt,
|
|
394
|
+
// A live pty-ws means the box is pty-capable for real.
|
|
395
|
+
ptyCapable: holder.terminalTransport ? true : prev.Terminal.ptyCapable,
|
|
396
|
+
reason: holder.terminalTransport ? null : prev.Terminal.reason,
|
|
397
|
+
}
|
|
398
|
+
: prev.Terminal,
|
|
377
399
|
}
|
|
378
400
|
: prev,
|
|
379
401
|
);
|
|
@@ -382,8 +404,8 @@ export function useSessionCapabilities(
|
|
|
382
404
|
if (cause instanceof OpenGeniApiError) {
|
|
383
405
|
if (cause.status === 409) {
|
|
384
406
|
// The un-redacted/shared consent gate is a DESKTOP requirement. A
|
|
385
|
-
// terminal-only warm attach needs no
|
|
386
|
-
// a
|
|
407
|
+
// terminal-only warm attach needs no acknowledgment, so a 409 there
|
|
408
|
+
// is not a desktop-open signal — the terminal stays on the read-only
|
|
387
409
|
// firehose. Only raise the consent requirement when the desktop was
|
|
388
410
|
// the (or a) reason we attached.
|
|
389
411
|
if (wantDesktopAttach) {
|
|
@@ -397,6 +419,8 @@ export function useSessionCapabilities(
|
|
|
397
419
|
setState("error");
|
|
398
420
|
setError(cause);
|
|
399
421
|
return;
|
|
422
|
+
} else {
|
|
423
|
+
throw cause;
|
|
400
424
|
}
|
|
401
425
|
// 409/429 are recoverable: structured surfaces still negotiated;
|
|
402
426
|
// keep going to set ready/cold below.
|
|
@@ -407,16 +431,16 @@ export function useSessionCapabilities(
|
|
|
407
431
|
}
|
|
408
432
|
|
|
409
433
|
// Whether ANYTHING asked to warm a box this negotiation. Only then is a
|
|
410
|
-
//
|
|
434
|
+
// non-warm lease "warming in flight" — worth polling, and worth surfacing a
|
|
411
435
|
// stall as an error. With no warm-up requested a cold lease is the benign
|
|
412
436
|
// boxless resting state (below), never an error.
|
|
413
437
|
const warmUpRequested = wantDesktopAttach || wantTerminalAttach || wantFilesAttach;
|
|
414
|
-
if (caps.liveness
|
|
438
|
+
if (sandboxAcceptsLiveIo(caps.liveness) || localViewerId) {
|
|
415
439
|
setState("ready");
|
|
416
440
|
startHeartbeat(caps);
|
|
417
441
|
} else if (warmUpRequested) {
|
|
418
442
|
// A warm-up is genuinely in flight (a viewer attach was requested) but the
|
|
419
|
-
// box isn't warm yet — poll until it
|
|
443
|
+
// box isn't holder-confirmed warm yet — poll until it is, with the deadline so a
|
|
420
444
|
// real stall surfaces as an error.
|
|
421
445
|
setState("cold");
|
|
422
446
|
pollUntilWarm();
|
|
@@ -455,7 +479,7 @@ export function useSessionCapabilities(
|
|
|
455
479
|
client,
|
|
456
480
|
workspaceId,
|
|
457
481
|
sessionId,
|
|
458
|
-
|
|
482
|
+
lifecycleEnabled,
|
|
459
483
|
attachDesktop,
|
|
460
484
|
attachTerminal,
|
|
461
485
|
attachFiles,
|
|
@@ -4,6 +4,7 @@ import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../sessi
|
|
|
4
4
|
import { buildTimeline, groupTimeline, sessionStatusFromEvents } from "../timeline/projection";
|
|
5
5
|
import type { TimelineItem } from "../timeline/types";
|
|
6
6
|
import type { EmbeddedSessionClientLike } from "../client";
|
|
7
|
+
import { usePageLiveActivity } from "./internal";
|
|
7
8
|
|
|
8
9
|
export type SessionEventsConnectionState = StreamConnectionState | "idle" | "ended" | "error";
|
|
9
10
|
|
|
@@ -135,6 +136,8 @@ export function useSessionEvents(
|
|
|
135
136
|
): UseSessionEventsResult {
|
|
136
137
|
const { client, workspaceId, reconcileSession } = useEmbeddedSession(options);
|
|
137
138
|
const enabled = options.enabled ?? true;
|
|
139
|
+
const pageLive = usePageLiveActivity();
|
|
140
|
+
const streamEnabled = enabled && pageLive;
|
|
138
141
|
const after = options.after ?? 0;
|
|
139
142
|
const replay = options.replay ?? "windowed";
|
|
140
143
|
const fullReplay = replay === "full" || after !== 0;
|
|
@@ -224,7 +227,7 @@ export function useSessionEvents(
|
|
|
224
227
|
loadingOlderRef.current = false;
|
|
225
228
|
setLoadingOlder(false);
|
|
226
229
|
}
|
|
227
|
-
if (!sessionId || !
|
|
230
|
+
if (!sessionId || !streamEnabled) {
|
|
228
231
|
setConnectionState("idle");
|
|
229
232
|
return;
|
|
230
233
|
}
|
|
@@ -338,7 +341,18 @@ export function useSessionEvents(
|
|
|
338
341
|
const stream = client.streamEvents(workspaceId, sessionId, {
|
|
339
342
|
after: streamResumeSequenceRef.current,
|
|
340
343
|
signal: controller.signal,
|
|
341
|
-
|
|
344
|
+
onOpen: () => {
|
|
345
|
+
// Reconciliation repairs projections, but it must not block reading
|
|
346
|
+
// the already-open SSE body. Otherwise live events accumulate in the
|
|
347
|
+
// browser and appear as one delayed burst after every reconciler ends.
|
|
348
|
+
void Promise.resolve()
|
|
349
|
+
.then(() => reconcileSession(sessionId))
|
|
350
|
+
.catch((cause) => {
|
|
351
|
+
if (isCurrent()) {
|
|
352
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
},
|
|
342
356
|
onStateChange: (state) => {
|
|
343
357
|
if (isCurrent()) {
|
|
344
358
|
setConnectionState(state);
|
|
@@ -399,7 +413,7 @@ export function useSessionEvents(
|
|
|
399
413
|
workspaceId,
|
|
400
414
|
sessionId,
|
|
401
415
|
after,
|
|
402
|
-
|
|
416
|
+
streamEnabled,
|
|
403
417
|
fullReplay,
|
|
404
418
|
streamKey,
|
|
405
419
|
streamEpoch,
|
|
@@ -1175,7 +1189,7 @@ async function loadPreviousPage(
|
|
|
1175
1189
|
compact: true,
|
|
1176
1190
|
// Monitoring summaries omit correlation fields once a bounded durable
|
|
1177
1191
|
// payload exceeds their preview threshold. The browser timeline needs the
|
|
1178
|
-
//
|
|
1192
|
+
// exact stored payload so persisted tool outputs still match their
|
|
1179
1193
|
// calls and expose truthful truncation telemetry after a reload.
|
|
1180
1194
|
payloadMode: "full",
|
|
1181
1195
|
});
|
|
@@ -17,7 +17,7 @@ export type UseTerminalStreamOptions = {
|
|
|
17
17
|
* The stream connects ONLY when `transport === "pty-ws"` and `url` is set; on a
|
|
18
18
|
* cold box (`transport === "sse-events"` / no url) it stays idle and the caller
|
|
19
19
|
* falls back to the Channel-A read-only firehose. */
|
|
20
|
-
capability: Pick<TerminalCapability, "transport" | "url" | "token"> | null;
|
|
20
|
+
capability: Pick<TerminalCapability, "transport" | "url" | "token" | "expiresAt"> | null;
|
|
21
21
|
/** Called for each OUTPUT payload from ttyd (write verbatim into xterm). */
|
|
22
22
|
onOutput?: ((data: string) => void) | undefined;
|
|
23
23
|
/** Called when ttyd sends a SET_WINDOW_TITLE frame. */
|
|
@@ -31,7 +31,8 @@ export type UseTerminalStreamResult = {
|
|
|
31
31
|
/** True once the ttyd socket is open (and the auth frame has been sent). */
|
|
32
32
|
connected: boolean;
|
|
33
33
|
status: TerminalStreamStatus;
|
|
34
|
-
/** Pipe a keystroke/paste to
|
|
34
|
+
/** Pipe a keystroke/paste to PTY stdin. Input during CONNECTING is bounded and
|
|
35
|
+
* replayed only after ttyd authentication succeeds. */
|
|
35
36
|
write: (data: string) => void;
|
|
36
37
|
/** Tell ttyd the PTY window changed size (on xterm fit/resize). */
|
|
37
38
|
resize: (cols: number, rows: number) => void;
|
|
@@ -39,6 +40,27 @@ export type UseTerminalStreamResult = {
|
|
|
39
40
|
disconnect: () => void;
|
|
40
41
|
};
|
|
41
42
|
|
|
43
|
+
// Keystrokes entered during the websocket handshake are held until ttyd has
|
|
44
|
+
// accepted its auth/resize frames. Bound the queue by UTF-16 code units (a hard
|
|
45
|
+
// <=128 KiB string payload in current JS engines) so a paste cannot turn a slow
|
|
46
|
+
// handshake into unbounded renderer memory. Overflow rejects the whole pending
|
|
47
|
+
// input rather than ever executing a truncated shell command.
|
|
48
|
+
export const MAX_PENDING_TERMINAL_INPUT_CODE_UNITS = 64 * 1024;
|
|
49
|
+
const TERMINAL_CONNECT_EXPIRY_SKEW_MS = 5_000;
|
|
50
|
+
|
|
51
|
+
/** A bearer must remain valid long enough to finish a new websocket handshake.
|
|
52
|
+
* Existing open sockets are unaffected: this is evaluated only when the
|
|
53
|
+
* credential identity changes and the connection effect attempts a new socket. */
|
|
54
|
+
export function terminalStreamCredentialUsable(
|
|
55
|
+
capability: Pick<TerminalCapability, "transport" | "url" | "expiresAt"> | null | undefined,
|
|
56
|
+
nowMs = Date.now(),
|
|
57
|
+
): boolean {
|
|
58
|
+
if (capability?.transport !== "pty-ws" || !capability.url) return false;
|
|
59
|
+
if (!capability.expiresAt) return true;
|
|
60
|
+
const expiresAt = Date.parse(capability.expiresAt);
|
|
61
|
+
return Number.isFinite(expiresAt) && expiresAt > nowMs + TERMINAL_CONNECT_EXPIRY_SKEW_MS;
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
/** Decode an inbound ttyd frame's payload (everything after the 1-char command).
|
|
43
65
|
* ttyd may send either a text frame (string) or a binary frame (ArrayBuffer);
|
|
44
66
|
* for binary we slice off the first byte (the command) and utf-8 decode the rest. */
|
|
@@ -52,6 +74,14 @@ function decodeFrame(data: string | ArrayBuffer): { command: string; payload: st
|
|
|
52
74
|
return { command, payload };
|
|
53
75
|
}
|
|
54
76
|
|
|
77
|
+
function closeSocket(socket: WebSocket | null): void {
|
|
78
|
+
try {
|
|
79
|
+
socket?.close();
|
|
80
|
+
} catch {
|
|
81
|
+
// Already closed or rejected by the host WebSocket implementation.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
/**
|
|
56
86
|
* Drive a ttyd PTY-over-websocket connection from a `pty-ws` Terminal capability,
|
|
57
87
|
* symmetric with `use-desktop-stream` (the noVNC-over-tunnel hook). The scoped
|
|
@@ -73,6 +103,9 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
|
|
|
73
103
|
const { capability, onOutput, onTitle, initialCols, initialRows } = options;
|
|
74
104
|
const [status, setStatus] = useState<TerminalStreamStatus>("closed");
|
|
75
105
|
const wsRef = useRef<WebSocket | null>(null);
|
|
106
|
+
const pendingInputRef = useRef("");
|
|
107
|
+
const socketFailedRef = useRef(false);
|
|
108
|
+
const socketAuthenticatedRef = useRef(false);
|
|
76
109
|
// Latest size, so a resize() before the socket opens is replayed on open, and a
|
|
77
110
|
// reconnect seeds the right geometry.
|
|
78
111
|
const sizeRef = useRef<{ cols: number; rows: number }>({
|
|
@@ -89,12 +122,35 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
|
|
|
89
122
|
const transport = capability?.transport ?? null;
|
|
90
123
|
const url = capability?.url ?? null;
|
|
91
124
|
const token = capability?.token ?? null;
|
|
125
|
+
const expiresAt = capability?.expiresAt ?? null;
|
|
92
126
|
|
|
93
127
|
useEffect(() => {
|
|
94
128
|
// SSR / no WebSocket / not a live pty-ws cell: stay closed; the caller falls
|
|
95
129
|
// back to the Channel-A read-only firehose.
|
|
96
130
|
if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
|
|
97
|
-
if (transport !== "pty-ws"
|
|
131
|
+
if (transport !== "pty-ws") {
|
|
132
|
+
// A transport downgrade is a real semantic boundary. Do not replay input
|
|
133
|
+
// captured for a PTY if this surface later changes back from firehose mode.
|
|
134
|
+
pendingInputRef.current = "";
|
|
135
|
+
socketAuthenticatedRef.current = false;
|
|
136
|
+
setStatus("closed");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// A changed/cleared credential is a new connection attempt boundary. It may
|
|
140
|
+
// receive input while its exact grant is pending even if the prior socket
|
|
141
|
+
// failed; the unchanged failed identity remains fenced because this effect
|
|
142
|
+
// does not re-run for a status-only render.
|
|
143
|
+
socketFailedRef.current = false;
|
|
144
|
+
socketAuthenticatedRef.current = false;
|
|
145
|
+
if (!url) {
|
|
146
|
+
// Exact grants arrive asynchronously. Preserve the bounded first input
|
|
147
|
+
// while the descriptor remains pty-ws, but never open without a credential.
|
|
148
|
+
setStatus("closed");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!terminalStreamCredentialUsable({ transport, url, expiresAt })) {
|
|
152
|
+
// A descriptor from an older rolling server may still carry a bearer that
|
|
153
|
+
// aged out before this surface mounted. Wait for the exact fresh grant.
|
|
98
154
|
setStatus("closed");
|
|
99
155
|
return;
|
|
100
156
|
}
|
|
@@ -107,6 +163,7 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
|
|
|
107
163
|
// it. The scoped token is already in the tunnel `url`.
|
|
108
164
|
socket = new WebSocket(terminalSocketUrl({ url }), TTYD_SUBPROTOCOL);
|
|
109
165
|
} catch {
|
|
166
|
+
socketFailedRef.current = true;
|
|
110
167
|
setStatus("error");
|
|
111
168
|
return;
|
|
112
169
|
}
|
|
@@ -121,8 +178,18 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
|
|
|
121
178
|
try {
|
|
122
179
|
socket.send(ttydAuthFrame({ columns: sizeRef.current.cols, rows: sizeRef.current.rows }));
|
|
123
180
|
socket.send(ttydResizeFrame(sizeRef.current.cols, sizeRef.current.rows));
|
|
181
|
+
if (pendingInputRef.current.length > 0) {
|
|
182
|
+
// One websocket frame makes the pre-open queue all-or-nothing from this
|
|
183
|
+
// client's perspective; never execute a prefix of a buffered command.
|
|
184
|
+
socket.send(ttydInputFrame(pendingInputRef.current));
|
|
185
|
+
pendingInputRef.current = "";
|
|
186
|
+
}
|
|
187
|
+
socketAuthenticatedRef.current = true;
|
|
124
188
|
} catch {
|
|
125
|
-
|
|
189
|
+
socketFailedRef.current = true;
|
|
190
|
+
setStatus("error");
|
|
191
|
+
closeSocket(socket);
|
|
192
|
+
return;
|
|
126
193
|
}
|
|
127
194
|
setStatus("open");
|
|
128
195
|
};
|
|
@@ -144,46 +211,60 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
|
|
|
144
211
|
};
|
|
145
212
|
|
|
146
213
|
socket.onerror = () => {
|
|
147
|
-
if (!disposed)
|
|
214
|
+
if (!disposed) {
|
|
215
|
+
socketFailedRef.current = true;
|
|
216
|
+
setStatus("error");
|
|
217
|
+
}
|
|
148
218
|
};
|
|
149
219
|
socket.onclose = () => {
|
|
150
|
-
if (
|
|
220
|
+
if (wsRef.current === socket) wsRef.current = null;
|
|
221
|
+
socketAuthenticatedRef.current = false;
|
|
222
|
+
if (!disposed) setStatus(socketFailedRef.current ? "error" : "closed");
|
|
151
223
|
};
|
|
152
224
|
|
|
153
225
|
return () => {
|
|
154
226
|
disposed = true;
|
|
155
227
|
wsRef.current = null;
|
|
228
|
+
socketAuthenticatedRef.current = false;
|
|
156
229
|
// Drop handlers so an in-flight close/error doesn't mutate state post-unmount.
|
|
157
230
|
socket.onopen = null;
|
|
158
231
|
socket.onmessage = null;
|
|
159
232
|
socket.onerror = null;
|
|
160
233
|
socket.onclose = null;
|
|
161
|
-
|
|
162
|
-
socket.close();
|
|
163
|
-
} catch {
|
|
164
|
-
// ignore teardown errors
|
|
165
|
-
}
|
|
234
|
+
closeSocket(socket);
|
|
166
235
|
};
|
|
167
236
|
// A url/token change (rotation) re-runs this effect → close old, open new.
|
|
168
237
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
169
|
-
}, [transport, url, token]);
|
|
238
|
+
}, [transport, url, token, expiresAt]);
|
|
170
239
|
|
|
171
240
|
return useMemo<UseTerminalStreamResult>(() => {
|
|
172
241
|
const write = (data: string) => {
|
|
173
242
|
const ws = wsRef.current;
|
|
174
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
243
|
+
if (ws && ws.readyState === WebSocket.OPEN && socketAuthenticatedRef.current) {
|
|
175
244
|
try {
|
|
176
245
|
ws.send(ttydInputFrame(data));
|
|
177
246
|
} catch {
|
|
178
|
-
|
|
247
|
+
socketFailedRef.current = true;
|
|
248
|
+
setStatus("error");
|
|
249
|
+
closeSocket(ws);
|
|
250
|
+
}
|
|
251
|
+
} else if (transport === "pty-ws" && !socketFailedRef.current) {
|
|
252
|
+
const nextSize = pendingInputRef.current.length + data.length;
|
|
253
|
+
if (nextSize > MAX_PENDING_TERMINAL_INPUT_CODE_UNITS) {
|
|
254
|
+
pendingInputRef.current = "";
|
|
255
|
+
socketFailedRef.current = true;
|
|
256
|
+
setStatus("error");
|
|
257
|
+
closeSocket(ws);
|
|
258
|
+
return;
|
|
179
259
|
}
|
|
260
|
+
pendingInputRef.current += data;
|
|
180
261
|
}
|
|
181
262
|
};
|
|
182
263
|
const resize = (cols: number, rows: number) => {
|
|
183
264
|
if (cols <= 0 || rows <= 0) return;
|
|
184
265
|
sizeRef.current = { cols, rows };
|
|
185
266
|
const ws = wsRef.current;
|
|
186
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
267
|
+
if (ws && ws.readyState === WebSocket.OPEN && socketAuthenticatedRef.current) {
|
|
187
268
|
try {
|
|
188
269
|
ws.send(ttydResizeFrame(cols, rows));
|
|
189
270
|
} catch {
|
|
@@ -194,14 +275,12 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
|
|
|
194
275
|
const disconnect = () => {
|
|
195
276
|
const ws = wsRef.current;
|
|
196
277
|
wsRef.current = null;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
}
|
|
278
|
+
pendingInputRef.current = "";
|
|
279
|
+
socketFailedRef.current = false;
|
|
280
|
+
socketAuthenticatedRef.current = false;
|
|
281
|
+
setStatus("closed");
|
|
282
|
+
closeSocket(ws);
|
|
204
283
|
};
|
|
205
284
|
return { connected: status === "open", status, write, resize, disconnect };
|
|
206
|
-
}, [status]);
|
|
285
|
+
}, [status, transport]);
|
|
207
286
|
}
|
|
@@ -214,7 +214,7 @@ export type UseTranscriptionOptions = {
|
|
|
214
214
|
disabled?: boolean | undefined;
|
|
215
215
|
createLocalSessionId?: (() => string) | undefined;
|
|
216
216
|
lifecycleTimeouts?: Partial<TranscriptionLifecycleTimeouts> | undefined;
|
|
217
|
-
/** Receives
|
|
217
|
+
/** Receives the exact diagnostic emitted by the transcription transport. */
|
|
218
218
|
onDiagnostic?: ((diagnostic: TranscriptionDiagnostic) => void) | undefined;
|
|
219
219
|
};
|
|
220
220
|
|
|
@@ -277,7 +277,7 @@ export function useTranscription({
|
|
|
277
277
|
(diagnostic: unknown) => {
|
|
278
278
|
if (!onDiagnostic) return;
|
|
279
279
|
try {
|
|
280
|
-
onDiagnostic(
|
|
280
|
+
onDiagnostic(normalizeTranscriptionDiagnostic(diagnostic));
|
|
281
281
|
} catch {
|
|
282
282
|
// Observability must never break the local privacy or UI lifecycle.
|
|
283
283
|
}
|
|
@@ -625,7 +625,7 @@ export function normalizeTranscriptionErrorCode(code: unknown): TranscriptionErr
|
|
|
625
625
|
}
|
|
626
626
|
}
|
|
627
627
|
|
|
628
|
-
export function
|
|
628
|
+
export function normalizeTranscriptionDiagnostic(diagnostic: unknown): TranscriptionDiagnostic {
|
|
629
629
|
const source = isRecord(diagnostic) ? diagnostic : {};
|
|
630
630
|
const operation =
|
|
631
631
|
source.operation === "start" ||
|
|
@@ -635,20 +635,10 @@ export function sanitizeTranscriptionDiagnostic(diagnostic: unknown): Transcript
|
|
|
635
635
|
? source.operation
|
|
636
636
|
: "session";
|
|
637
637
|
const rawDetail = typeof source.detail === "string" ? source.detail : "No diagnostic detail.";
|
|
638
|
-
const detail = rawDetail
|
|
639
|
-
.replace(/[\u0000-\u001f\u007f]+/gu, " ")
|
|
640
|
-
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/giu, "Bearer [REDACTED]")
|
|
641
|
-
.replace(/\bsk-[A-Za-z0-9_-]{4,}\b/gu, "[REDACTED]")
|
|
642
|
-
.replace(
|
|
643
|
-
/\b(api[-_ ]?key|authorization|access[-_ ]?token|refresh[-_ ]?token|token|secret)\b\s*[:=]\s*[^\s,;]+/giu,
|
|
644
|
-
"$1=[REDACTED]",
|
|
645
|
-
)
|
|
646
|
-
.trim()
|
|
647
|
-
.slice(0, 512);
|
|
648
638
|
return {
|
|
649
639
|
operation,
|
|
650
640
|
code: normalizeTranscriptionErrorCode(source.code),
|
|
651
|
-
detail:
|
|
641
|
+
detail: rawDetail,
|
|
652
642
|
};
|
|
653
643
|
}
|
|
654
644
|
|