@dfosco/storyboard-react 4.2.0-beta.1 → 4.2.0-beta.18
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/package.json +5 -4
- package/src/AuthModal/AuthModal.jsx +6 -2
- package/src/BranchBar/BranchBar.jsx +20 -6
- package/src/BranchBar/BranchBar.module.css +13 -4
- package/src/BranchBar/useBranches.js +20 -6
- package/src/BranchBar/useBranches.test.js +68 -0
- package/src/CommandPalette/CommandPalette.jsx +478 -186
- package/src/CommandPalette/command-palette.css +142 -78
- package/src/Icon.jsx +157 -58
- package/src/Viewfinder.jsx +561 -191
- package/src/Viewfinder.module.css +434 -93
- package/src/Workspace.jsx +7 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +738 -216
- package/src/canvas/CanvasPage.module.css +13 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
- package/src/canvas/ConnectorLayer.jsx +121 -153
- package/src/canvas/ConnectorLayer.module.css +69 -0
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/canvasApi.js +68 -2
- package/src/canvas/connectorGeometry.js +132 -0
- package/src/canvas/hotPoolDevLogs.js +25 -0
- package/src/canvas/useCanvas.js +1 -1
- package/src/canvas/useMarqueeSelect.js +30 -4
- package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
- package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
- package/src/canvas/widgets/ComponentWidget.jsx +1 -0
- package/src/canvas/widgets/CropOverlay.jsx +219 -0
- package/src/canvas/widgets/CropOverlay.module.css +118 -0
- package/src/canvas/widgets/ExpandedPane.jsx +472 -0
- package/src/canvas/widgets/ExpandedPane.module.css +179 -0
- package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +130 -9
- package/src/canvas/widgets/ImageWidget.module.css +30 -0
- package/src/canvas/widgets/LinkPreview.jsx +112 -4
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
- package/src/canvas/widgets/PromptWidget.jsx +414 -0
- package/src/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/ResizeHandle.jsx +17 -6
- package/src/canvas/widgets/StoryWidget.jsx +72 -15
- package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
- package/src/canvas/widgets/TerminalWidget.jsx +496 -69
- package/src/canvas/widgets/TerminalWidget.module.css +271 -8
- package/src/canvas/widgets/TilesWidget.jsx +302 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +73 -153
- package/src/canvas/widgets/WidgetChrome.module.css +30 -1
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +557 -0
- package/src/canvas/widgets/expandUtils.test.js +155 -0
- package/src/canvas/widgets/index.js +9 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/tilePool.js +23 -0
- package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/canvas/widgets/tiles/leaf.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/canvas/widgets/tiles/solid-a.png +0 -0
- package/src/canvas/widgets/tiles/solid-b.png +0 -0
- package/src/canvas/widgets/widgetConfig.js +55 -4
- package/src/canvas/widgets/widgetIcons.jsx +190 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +47 -19
- package/src/hooks/useConfig.js +14 -0
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/index.js +8 -2
- package/src/story/ComponentSetPage.jsx +186 -0
- package/src/story/ComponentSetPage.module.css +121 -0
- package/src/story/StoryPage.jsx +32 -2
- package/src/vite/data-plugin.js +324 -30
|
@@ -1,53 +1,122 @@
|
|
|
1
|
-
import { useRef, useEffect, useCallback, useState } from 'react'
|
|
1
|
+
import { useRef, useEffect, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react'
|
|
2
2
|
import { readProp } from './widgetProps.js'
|
|
3
3
|
import { schemas } from './widgetProps.js'
|
|
4
|
-
import { getTerminalConfig } from '@dfosco/storyboard-core'
|
|
5
|
-
import
|
|
4
|
+
import { getTerminalConfig, getTerminalDimensions, getStoryData } from '@dfosco/storyboard-core'
|
|
5
|
+
import { useOverride } from '../../hooks/useOverride.js'
|
|
6
|
+
import { getSplitPaneLabel, findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
|
|
7
|
+
import ExpandedPane from './ExpandedPane.jsx'
|
|
6
8
|
import styles from './TerminalWidget.module.css'
|
|
7
9
|
import overlayStyles from './embedOverlay.module.css'
|
|
8
10
|
|
|
9
11
|
const terminalSchema = schemas['terminal']
|
|
10
12
|
|
|
11
|
-
/**
|
|
12
|
-
* Lazy-load ghostty-web to avoid bundling WASM in prod.
|
|
13
|
-
*/
|
|
14
13
|
let ghosttyPromise = null
|
|
15
14
|
function loadGhostty() {
|
|
16
15
|
if (!ghosttyPromise) {
|
|
17
|
-
ghosttyPromise = import('ghostty-web')
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
ghosttyPromise = import('ghostty-web')
|
|
17
|
+
.then(async (mod) => {
|
|
18
|
+
if (mod.init) await mod.init()
|
|
19
|
+
return mod
|
|
20
|
+
})
|
|
21
|
+
.catch((err) => {
|
|
22
|
+
ghosttyPromise = null
|
|
23
|
+
console.warn('[TerminalWidget] ghostty-web not available:', err.message)
|
|
24
|
+
return null
|
|
25
|
+
})
|
|
21
26
|
}
|
|
22
27
|
return ghosttyPromise
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
function getWsUrl(sessionId, prettyName) {
|
|
30
|
+
// Global registry so split-screen can look up any terminal's ghostty + WS by widget ID
|
|
31
|
+
const terminalRegistry = new Map()
|
|
32
|
+
if (typeof window !== 'undefined') window.__storyboardTerminalRegistry = terminalRegistry
|
|
33
|
+
|
|
34
|
+
function getWsUrl(sessionId, prettyName, startupCommand) {
|
|
31
35
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
32
36
|
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
33
37
|
const baseClean = base.endsWith('/') ? base : base + '/'
|
|
34
38
|
const canvasId = window.__storyboardCanvasBridgeState?.canvasId || 'unknown'
|
|
35
39
|
let url = `${protocol}//${location.host}${baseClean}_storyboard/terminal/${sessionId}?canvas=${encodeURIComponent(canvasId)}`
|
|
36
40
|
if (prettyName) url += `&name=${encodeURIComponent(prettyName)}`
|
|
41
|
+
if (startupCommand) url += `&startupCommand=${encodeURIComponent(startupCommand)}`
|
|
37
42
|
return url
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
function calcDimensions(widthPx, heightPx, fontSize = 13) {
|
|
46
|
+
// Cell dimensions scale proportionally with font size.
|
|
47
|
+
// Base measurements at 13px: ~7.8px wide, ~17px tall.
|
|
48
|
+
const scale = fontSize / 13
|
|
49
|
+
const cellWidth = 7.8 * scale
|
|
50
|
+
const cellHeight = 17 * scale
|
|
51
|
+
// .terminal has 16px padding + 1px border on each side (box-sizing: border-box)
|
|
52
|
+
const hPad = 34 // (16+1) * 2
|
|
53
|
+
const vPad = 34
|
|
54
|
+
const cols = Math.max(10, Math.floor((widthPx - hPad) / cellWidth))
|
|
55
|
+
const rows = Math.max(4, Math.floor((heightPx - vPad) / cellHeight))
|
|
56
|
+
return { cols, rows }
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
/**
|
|
41
|
-
*
|
|
60
|
+
* Fit a terminal (by widget ID) to a container element using real cell metrics.
|
|
61
|
+
* Works for both the primary and secondary terminal in split-screen.
|
|
42
62
|
*/
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
63
|
+
function fitTerminalToElement(widgetId, containerEl) {
|
|
64
|
+
const entry = terminalRegistry.get(widgetId)
|
|
65
|
+
if (!entry || !containerEl) return
|
|
66
|
+
const { term, ws } = entry
|
|
67
|
+
const cw = term.renderer?.charWidth
|
|
68
|
+
const ch = term.renderer?.charHeight
|
|
69
|
+
if (!cw || !ch) return
|
|
70
|
+
const w = containerEl.clientWidth
|
|
71
|
+
const h = containerEl.clientHeight
|
|
72
|
+
// Skip if container hasn't laid out yet (flex: 1 may not have resolved)
|
|
73
|
+
if (w < 50 || h < 50) return
|
|
74
|
+
const cols = Math.max(10, Math.floor(w / cw))
|
|
75
|
+
const rows = Math.max(4, Math.floor(h / ch))
|
|
76
|
+
term.resize?.(cols, rows)
|
|
77
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
78
|
+
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const EMBED_TYPES = new Set(['prototype', 'story'])
|
|
83
|
+
|
|
84
|
+
function findConnectedEmbed(widgetId) {
|
|
85
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
86
|
+
if (!bridge?.connectors || !bridge?.widgets) return null
|
|
87
|
+
const connectedIds = new Set()
|
|
88
|
+
for (const c of bridge.connectors) {
|
|
89
|
+
if (c.start?.widgetId === widgetId) connectedIds.add(c.end?.widgetId)
|
|
90
|
+
if (c.end?.widgetId === widgetId) connectedIds.add(c.start?.widgetId)
|
|
91
|
+
}
|
|
92
|
+
for (const w of bridge.widgets) {
|
|
93
|
+
if (connectedIds.has(w.id) && EMBED_TYPES.has(w.type)) return w
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildEmbedUrl(widget) {
|
|
99
|
+
if (!widget) return null
|
|
100
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
101
|
+
const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
|
|
102
|
+
if (widget.type === 'prototype') {
|
|
103
|
+
const src = widget.props?.src
|
|
104
|
+
if (!src) return null
|
|
105
|
+
if (/^https?:\/\//.test(src)) return src
|
|
106
|
+
return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar`
|
|
107
|
+
}
|
|
108
|
+
if (widget.type === 'story') {
|
|
109
|
+
const storyId = widget.props?.storyId
|
|
110
|
+
const exportName = widget.props?.exportName
|
|
111
|
+
if (!storyId) return null
|
|
112
|
+
const storyData = getStoryData(storyId)
|
|
113
|
+
if (storyData?._route) {
|
|
114
|
+
const route = exportName ? `${storyData._route}?export=${exportName}` : storyData._route
|
|
115
|
+
return `${baseClean}${route}`
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
51
120
|
}
|
|
52
121
|
|
|
53
122
|
const DEFAULT_THEME = {
|
|
@@ -73,25 +142,88 @@ const DEFAULT_THEME = {
|
|
|
73
142
|
brightWhite: '#f0f6fc',
|
|
74
143
|
}
|
|
75
144
|
|
|
76
|
-
export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
145
|
+
export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizable, multiSelected }, ref) {
|
|
146
|
+
const cfg = getTerminalConfig()
|
|
147
|
+
const fontSize = cfg.fontSize ?? 13
|
|
148
|
+
const agentId = props?.agentId || null
|
|
149
|
+
const dims = getTerminalDimensions(agentId, {
|
|
150
|
+
width: readProp(props, 'width', terminalSchema),
|
|
151
|
+
height: readProp(props, 'height', terminalSchema),
|
|
152
|
+
})
|
|
153
|
+
const rawWidth = props?.width ?? dims.width
|
|
154
|
+
const rawHeight = props?.height ?? dims.height
|
|
79
155
|
const prettyName = props?.prettyName || null
|
|
156
|
+
const startupCommand = props?.startupCommand || null
|
|
157
|
+
|
|
158
|
+
// Snap dimensions to cell grid so the terminal fills its container exactly
|
|
159
|
+
const { cols, rows } = useMemo(
|
|
160
|
+
() => calcDimensions(rawWidth, rawHeight, fontSize),
|
|
161
|
+
[rawWidth, rawHeight, fontSize],
|
|
162
|
+
)
|
|
163
|
+
const width = rawWidth
|
|
164
|
+
const height = rawHeight
|
|
165
|
+
// Snapped dimensions computed from ghostty's actual cell metrics (set after open)
|
|
166
|
+
const [snappedHeight, setSnappedHeight] = useState(null)
|
|
167
|
+
const [snappedWidth, setSnappedWidth] = useState(null)
|
|
80
168
|
|
|
81
169
|
const containerRef = useRef(null)
|
|
82
170
|
const termRef = useRef(null)
|
|
83
171
|
const terminalRef = useRef(null)
|
|
84
172
|
const wsRef = useRef(null)
|
|
173
|
+
|
|
85
174
|
const [ready, setReady] = useState(false)
|
|
175
|
+
const [revealed, setRevealed] = useState(false)
|
|
86
176
|
const [error, setError] = useState(null)
|
|
87
177
|
const [sessionEnded, setSessionEnded] = useState(false)
|
|
88
178
|
const [connectAttempt, setConnectAttempt] = useState(0)
|
|
179
|
+
const [interactive, setInteractive] = useState(false)
|
|
180
|
+
const [expandedOverride, setExpandedOverride, clearExpandedOverride] = useOverride(`_terminal_expanded_${id}`)
|
|
181
|
+
const expandMode = expandedOverride === 'single' || expandedOverride === 'split' ? expandedOverride : expandedOverride === 'true' ? 'split' : null
|
|
182
|
+
const expanded = expandMode !== null
|
|
183
|
+
const setExpanded = useCallback((mode) => {
|
|
184
|
+
if (mode) setExpandedOverride(mode)
|
|
185
|
+
else clearExpandedOverride()
|
|
186
|
+
}, [setExpandedOverride, clearExpandedOverride])
|
|
187
|
+
const [waking, setWaking] = useState(false)
|
|
188
|
+
const [resourceLimited, setResourceLimited] = useState(null)
|
|
189
|
+
const [showDragHint, setShowDragHint] = useState(false)
|
|
190
|
+
const expandContainerRef = useRef(null)
|
|
191
|
+
const dragHintTimer = useRef(null)
|
|
192
|
+
|
|
193
|
+
useImperativeHandle(ref, () => ({
|
|
194
|
+
handleAction(actionId) {
|
|
195
|
+
if (actionId === 'expand') { setExpanded('single'); return true }
|
|
196
|
+
if (actionId === 'expand-single') { setExpanded('single'); return true }
|
|
197
|
+
if (actionId === 'split-screen') { setExpanded('split'); return true }
|
|
198
|
+
if (actionId === 'toggle-private') {
|
|
199
|
+
const isPrivate = !!props?.private
|
|
200
|
+
onUpdate?.({ private: !isPrivate })
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
return false
|
|
204
|
+
},
|
|
205
|
+
}), [setExpanded, props, onUpdate])
|
|
89
206
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
207
|
+
// Exit interactive on click outside
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (!interactive) return
|
|
210
|
+
function handlePointerDown(e) {
|
|
211
|
+
if (terminalRef.current && !terminalRef.current.contains(e.target)) {
|
|
212
|
+
const chromeEl = e.target.closest(`[data-widget-id="${id}"]`)
|
|
213
|
+
if (chromeEl) return
|
|
214
|
+
setInteractive(false)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
218
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
219
|
+
}, [interactive, id])
|
|
93
220
|
|
|
94
|
-
//
|
|
221
|
+
// Exit interactive when terminal becomes part of a multi-selection
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (multiSelected && interactive) setInteractive(false)
|
|
224
|
+
}, [multiSelected])
|
|
225
|
+
|
|
226
|
+
// Connect terminal + WebSocket
|
|
95
227
|
useEffect(() => {
|
|
96
228
|
if (!containerRef.current) return
|
|
97
229
|
|
|
@@ -103,8 +235,12 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
103
235
|
try {
|
|
104
236
|
const ghostty = await loadGhostty()
|
|
105
237
|
if (disposed) return
|
|
238
|
+
if (!ghostty) {
|
|
239
|
+
setError('ghostty-web not installed — add it to your dependencies to enable terminal widgets')
|
|
240
|
+
return
|
|
241
|
+
}
|
|
106
242
|
|
|
107
|
-
const dims = calcDimensions(width, height)
|
|
243
|
+
const dims = calcDimensions(width, height, fontSize)
|
|
108
244
|
const cfg = getTerminalConfig()
|
|
109
245
|
|
|
110
246
|
term = new ghostty.Terminal({
|
|
@@ -120,8 +256,44 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
120
256
|
term.open(containerRef.current)
|
|
121
257
|
termRef.current = term
|
|
122
258
|
|
|
123
|
-
//
|
|
124
|
-
|
|
259
|
+
// Expose ghostty's actual computed cell metrics as CSS variables
|
|
260
|
+
// Set on .terminal (terminalRef) so they cascade into .xtermContainer
|
|
261
|
+
const cw = term.renderer?.charWidth
|
|
262
|
+
const ch = term.renderer?.charHeight
|
|
263
|
+
const wrap = terminalRef.current
|
|
264
|
+
if (wrap && cw) wrap.style.setProperty('--term-char-width', `${cw}px`)
|
|
265
|
+
if (wrap && ch) wrap.style.setProperty('--term-char-height', `${ch}px`)
|
|
266
|
+
if (wrap) {
|
|
267
|
+
wrap.style.setProperty('--term-cols', dims.cols)
|
|
268
|
+
wrap.style.setProperty('--term-rows', dims.rows)
|
|
269
|
+
wrap.style.setProperty('--term-font-size', `${cfg.fontSize ?? 13}px`)
|
|
270
|
+
const theme = { ...DEFAULT_THEME, ...cfg.theme }
|
|
271
|
+
wrap.style.setProperty('--term-bg', theme.background)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Snap container to exact cell grid using real metrics
|
|
275
|
+
// .terminal has 16px padding + 1px border on each side = 34px chrome per axis
|
|
276
|
+
if (!disposed) {
|
|
277
|
+
const pad = 34
|
|
278
|
+
if (ch) setSnappedHeight(Math.round(dims.rows * ch) + pad)
|
|
279
|
+
if (cw) setSnappedWidth(Math.round(dims.cols * cw) + pad)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// SGR mouse wheel for tmux scroll in alternate screen
|
|
283
|
+
term.attachCustomWheelEventHandler((e) => {
|
|
284
|
+
if (!(term.wasmTerm?.isAlternateScreen?.() ?? false)) return false
|
|
285
|
+
const sock = wsRef.current
|
|
286
|
+
if (!sock || sock.readyState !== WebSocket.OPEN) return true
|
|
287
|
+
const btn = e.deltaY < 0 ? 64 : 65
|
|
288
|
+
const lines = Math.max(1, Math.min(5, Math.ceil(Math.abs(e.deltaY) / 33)))
|
|
289
|
+
for (let i = 0; i < lines; i++) {
|
|
290
|
+
sock.send(`\x1b[<${btn};1;1M`)
|
|
291
|
+
sock.send(`\x1b[<${btn};1;1m`)
|
|
292
|
+
}
|
|
293
|
+
return true
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const url = getWsUrl(id, prettyName, startupCommand)
|
|
125
297
|
ws = new WebSocket(url)
|
|
126
298
|
wsRef.current = ws
|
|
127
299
|
|
|
@@ -134,17 +306,16 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
134
306
|
ws.onmessage = (e) => {
|
|
135
307
|
if (disposed) return
|
|
136
308
|
const data = typeof e.data === 'string' ? e.data : null
|
|
137
|
-
// Intercept JSON control messages from the server
|
|
138
309
|
if (data && data.startsWith('{')) {
|
|
139
310
|
try {
|
|
140
311
|
const msg = JSON.parse(data)
|
|
141
|
-
if (msg.type === '
|
|
142
|
-
|
|
312
|
+
if (msg.type === 'resource-limited') {
|
|
313
|
+
setResourceLimited(msg)
|
|
314
|
+
setSessionEnded(true)
|
|
143
315
|
return
|
|
144
316
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
317
|
+
if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') return
|
|
318
|
+
} catch { /* not JSON */ }
|
|
148
319
|
}
|
|
149
320
|
term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data))
|
|
150
321
|
}
|
|
@@ -161,12 +332,12 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
161
332
|
setSessionEnded(true)
|
|
162
333
|
}
|
|
163
334
|
|
|
164
|
-
// Terminal input → WebSocket
|
|
165
335
|
term.onData((data) => {
|
|
166
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
167
|
-
ws.send(data)
|
|
168
|
-
}
|
|
336
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(data)
|
|
169
337
|
})
|
|
338
|
+
|
|
339
|
+
// Register in global registry for split-screen access
|
|
340
|
+
terminalRegistry.set(id, { term, ws })
|
|
170
341
|
} catch (err) {
|
|
171
342
|
if (!disposed) setError(err.message || 'Failed to load terminal')
|
|
172
343
|
}
|
|
@@ -176,6 +347,7 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
176
347
|
|
|
177
348
|
return () => {
|
|
178
349
|
disposed = true
|
|
350
|
+
terminalRegistry.delete(id)
|
|
179
351
|
if (ws && ws.readyState <= WebSocket.OPEN) ws.close()
|
|
180
352
|
if (term) term.dispose()
|
|
181
353
|
termRef.current = null
|
|
@@ -187,8 +359,22 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
187
359
|
useEffect(() => {
|
|
188
360
|
if (!termRef.current) return
|
|
189
361
|
const timer = setTimeout(() => {
|
|
190
|
-
const dims = calcDimensions(width, height)
|
|
362
|
+
const dims = calcDimensions(width, height, fontSize)
|
|
191
363
|
termRef.current?.resize?.(dims.cols, dims.rows)
|
|
364
|
+
// Update CSS variables after resize
|
|
365
|
+
const wrap = terminalRef.current
|
|
366
|
+
const cw = termRef.current?.renderer?.charWidth
|
|
367
|
+
const ch = termRef.current?.renderer?.charHeight
|
|
368
|
+
if (wrap && cw) wrap.style.setProperty('--term-char-width', `${cw}px`)
|
|
369
|
+
if (wrap && ch) wrap.style.setProperty('--term-char-height', `${ch}px`)
|
|
370
|
+
if (wrap) {
|
|
371
|
+
wrap.style.setProperty('--term-cols', dims.cols)
|
|
372
|
+
wrap.style.setProperty('--term-rows', dims.rows)
|
|
373
|
+
}
|
|
374
|
+
// Re-snap to cell grid
|
|
375
|
+
const pad = 34
|
|
376
|
+
if (ch) setSnappedHeight(Math.round(dims.rows * ch) + pad)
|
|
377
|
+
if (cw) setSnappedWidth(Math.round(dims.cols * cw) + pad)
|
|
192
378
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
193
379
|
wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
194
380
|
}
|
|
@@ -196,49 +382,221 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
196
382
|
return () => clearTimeout(timer)
|
|
197
383
|
}, [width, height])
|
|
198
384
|
|
|
385
|
+
// Reveal mask — hide terminal for 750ms after ready to mask startup flash
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (!ready) {
|
|
388
|
+
setRevealed(false)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
const timer = setTimeout(() => {
|
|
392
|
+
setRevealed(true)
|
|
393
|
+
setInteractive(true)
|
|
394
|
+
}, 750)
|
|
395
|
+
return () => clearTimeout(timer)
|
|
396
|
+
}, [ready])
|
|
397
|
+
|
|
398
|
+
const isAgent = id.startsWith('agent-')
|
|
399
|
+
const typeLabel = isAgent ? 'Agent' : 'Terminal'
|
|
400
|
+
const titleLabel = `${typeLabel} · ${prettyName || '…'}`
|
|
401
|
+
|
|
402
|
+
// Reparent terminal DOM between inline and expand container
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
const xtermEl = containerRef.current
|
|
405
|
+
if (!xtermEl) return
|
|
406
|
+
if (expanded && expandContainerRef.current) {
|
|
407
|
+
expandContainerRef.current.appendChild(xtermEl)
|
|
408
|
+
} else if (!expanded && terminalRef.current) {
|
|
409
|
+
terminalRef.current.appendChild(xtermEl)
|
|
410
|
+
}
|
|
411
|
+
}, [expanded])
|
|
412
|
+
|
|
413
|
+
// Restore size on collapse
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
if (expanded || !termRef.current) return
|
|
416
|
+
const timer = setTimeout(() => {
|
|
417
|
+
const dims = calcDimensions(width, height, fontSize)
|
|
418
|
+
termRef.current?.resize?.(dims.cols, dims.rows)
|
|
419
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
420
|
+
wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
421
|
+
}
|
|
422
|
+
}, 100)
|
|
423
|
+
return () => clearTimeout(timer)
|
|
424
|
+
}, [expanded, width, height])
|
|
425
|
+
|
|
426
|
+
// Focus terminal on expand
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
if (expanded && expandContainerRef.current) {
|
|
429
|
+
fitTerminalToElement(id, expandContainerRef.current)
|
|
430
|
+
}
|
|
431
|
+
}, [expanded])
|
|
432
|
+
|
|
199
433
|
const handleClick = useCallback(() => {
|
|
200
|
-
if (sessionEnded) return
|
|
201
|
-
|
|
202
|
-
|
|
434
|
+
if (sessionEnded || multiSelected) return
|
|
435
|
+
if (revealed) {
|
|
436
|
+
setInteractive(true)
|
|
437
|
+
const scrollEl = terminalRef.current?.closest('[class*="canvasScroll"]')
|
|
438
|
+
const scrollTop = scrollEl?.scrollTop
|
|
439
|
+
const scrollLeft = scrollEl?.scrollLeft
|
|
440
|
+
termRef.current?.focus({ preventScroll: true })
|
|
441
|
+
if (scrollEl && (scrollEl.scrollTop !== scrollTop || scrollEl.scrollLeft !== scrollLeft)) {
|
|
442
|
+
scrollEl.scrollTop = scrollTop
|
|
443
|
+
scrollEl.scrollLeft = scrollLeft
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}, [sessionEnded, multiSelected, revealed])
|
|
203
447
|
|
|
204
|
-
const
|
|
448
|
+
const handleTerminalPointerDown = useCallback((e) => {
|
|
449
|
+
if (!interactive) return
|
|
450
|
+
if (e.target.closest('.tc-drag-handle')) return
|
|
451
|
+
e.stopPropagation()
|
|
452
|
+
const startX = e.clientX
|
|
453
|
+
const startY = e.clientY
|
|
454
|
+
let moved = false
|
|
455
|
+
function onMove(me) {
|
|
456
|
+
if (!moved && (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5)) {
|
|
457
|
+
moved = true
|
|
458
|
+
setShowDragHint(true)
|
|
459
|
+
clearTimeout(dragHintTimer.current)
|
|
460
|
+
dragHintTimer.current = setTimeout(() => setShowDragHint(false), 2000)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function onUp() {
|
|
464
|
+
document.removeEventListener('pointermove', onMove)
|
|
465
|
+
document.removeEventListener('pointerup', onUp)
|
|
466
|
+
}
|
|
467
|
+
document.addEventListener('pointermove', onMove)
|
|
468
|
+
document.addEventListener('pointerup', onUp)
|
|
469
|
+
}, [interactive])
|
|
205
470
|
|
|
206
471
|
const handleStartSession = useCallback(() => {
|
|
207
472
|
setWaking(true)
|
|
208
473
|
setTimeout(() => {
|
|
209
474
|
setWaking(false)
|
|
210
475
|
setSessionEnded(false)
|
|
476
|
+
setResourceLimited(null)
|
|
211
477
|
setError(null)
|
|
212
478
|
setConnectAttempt(c => c + 1)
|
|
213
479
|
}, 1500)
|
|
214
480
|
}, [])
|
|
215
481
|
|
|
216
|
-
|
|
482
|
+
const handleCleanupAndRetry = useCallback(async () => {
|
|
483
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
484
|
+
const baseClean = base.endsWith('/') ? base : base + '/'
|
|
485
|
+
try {
|
|
486
|
+
const res = await fetch(`${baseClean}_storyboard/terminal/sessions/cleanup`, {
|
|
487
|
+
method: 'POST',
|
|
488
|
+
headers: { 'Content-Type': 'application/json' },
|
|
489
|
+
body: JSON.stringify({ statuses: ['archived', 'background'] }),
|
|
490
|
+
})
|
|
491
|
+
if (res.ok) {
|
|
492
|
+
const data = await res.json()
|
|
493
|
+
if (data.removed > 0) {
|
|
494
|
+
handleStartSession()
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
// Nothing was removed — update counts from server
|
|
498
|
+
setResourceLimited(prev => prev ? {
|
|
499
|
+
...prev,
|
|
500
|
+
cleanupResult: 'nothing-to-clean',
|
|
501
|
+
counts: data.remaining || prev.counts,
|
|
502
|
+
} : prev)
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
} catch { /* ignore fetch errors */ }
|
|
506
|
+
setResourceLimited(prev => prev ? { ...prev, cleanupResult: 'failed' } : prev)
|
|
507
|
+
}, [handleStartSession])
|
|
217
508
|
|
|
218
|
-
const titleLabel = `terminal · ${prettyName || '...'}`
|
|
219
509
|
|
|
220
510
|
return (
|
|
511
|
+
<>
|
|
221
512
|
<div className={styles.container}>
|
|
222
|
-
<div className={styles.titleBar}>{titleLabel}</div>
|
|
513
|
+
<div className={`tc-drag-handle ${styles.titleBar}`}>{titleLabel}</div>
|
|
223
514
|
<div
|
|
224
515
|
ref={terminalRef}
|
|
225
516
|
className={styles.terminal}
|
|
226
517
|
style={{
|
|
227
|
-
...(typeof width === 'number' ? { width: `${width}px` } : undefined),
|
|
228
|
-
...(typeof height === 'number' ? { height: `${height}px` } : undefined),
|
|
518
|
+
...(typeof (snappedWidth ?? width) === 'number' ? { width: `${snappedWidth ?? width}px` } : undefined),
|
|
519
|
+
...(typeof (snappedHeight ?? height) === 'number' ? { height: `${snappedHeight ?? height}px` } : undefined),
|
|
229
520
|
}}
|
|
230
521
|
onClick={handleClick}
|
|
522
|
+
onPointerDown={handleTerminalPointerDown}
|
|
523
|
+
onKeyDown={interactive ? (e) => e.stopPropagation() : undefined}
|
|
231
524
|
>
|
|
525
|
+
{showDragHint && (
|
|
526
|
+
<div className={styles.dragHint}>
|
|
527
|
+
<span className={styles.dragHintArrow}>←</span> Drag here to move widget
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
232
530
|
{error && !sessionEnded && (
|
|
233
531
|
<div className={styles.error}>
|
|
234
532
|
<span>⚠ {error}</span>
|
|
235
533
|
</div>
|
|
236
534
|
)}
|
|
237
|
-
<div ref={containerRef} className={styles.xtermContainer} />
|
|
238
|
-
|
|
535
|
+
<div ref={containerRef} className={styles.xtermContainer} style={{ opacity: revealed ? 1 : 0 }} />
|
|
536
|
+
|
|
537
|
+
{/* Live but not interactive */}
|
|
538
|
+
{revealed && !interactive && !sessionEnded && (
|
|
239
539
|
<div
|
|
240
540
|
className={overlayStyles.interactOverlay}
|
|
241
|
-
style={{ backgroundColor: '
|
|
541
|
+
style={{ backgroundColor: 'transparent' }}
|
|
542
|
+
onClick={(e) => {
|
|
543
|
+
if (multiSelected || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
544
|
+
setInteractive(true)
|
|
545
|
+
termRef.current?.focus({ preventScroll: true })
|
|
546
|
+
}}
|
|
547
|
+
role="button"
|
|
548
|
+
tabIndex={0}
|
|
549
|
+
onKeyDown={(e) => { if (!multiSelected && e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
|
|
550
|
+
aria-label="Click to interact"
|
|
551
|
+
>
|
|
552
|
+
{!multiSelected && <span className={overlayStyles.interactHint}>Click to interact</span>}
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
|
|
556
|
+
{/* Session ended — resource limited */}
|
|
557
|
+
{sessionEnded && resourceLimited && (
|
|
558
|
+
<div
|
|
559
|
+
className={overlayStyles.interactOverlay}
|
|
560
|
+
style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: '8px', padding: '24px' }}
|
|
561
|
+
>
|
|
562
|
+
<span className={styles.resourceIcon}>⚠</span>
|
|
563
|
+
<span className={styles.resourceTitle}>No terminal devices available</span>
|
|
564
|
+
<span className={styles.resourceMessage}>
|
|
565
|
+
Too many terminal sessions are open.
|
|
566
|
+
{resourceLimited.counts && (
|
|
567
|
+
<span className={styles.resourceCounts}>
|
|
568
|
+
{resourceLimited.counts.live} live · {resourceLimited.counts.background} background · {resourceLimited.counts.archived} archived
|
|
569
|
+
</span>
|
|
570
|
+
)}
|
|
571
|
+
</span>
|
|
572
|
+
<div className={styles.resourceActions}>
|
|
573
|
+
{!resourceLimited.cleanupResult && (resourceLimited.counts?.background > 0 || resourceLimited.counts?.archived > 0) && (
|
|
574
|
+
<button className={styles.resourceBtn} onClick={handleCleanupAndRetry}>
|
|
575
|
+
Close background sessions
|
|
576
|
+
</button>
|
|
577
|
+
)}
|
|
578
|
+
{resourceLimited.cleanupResult === 'nothing-to-clean' && (
|
|
579
|
+
<span className={styles.resourceMuted}>
|
|
580
|
+
All background sessions already cleaned. Close some live terminals to free resources.
|
|
581
|
+
</span>
|
|
582
|
+
)}
|
|
583
|
+
{resourceLimited.cleanupResult === 'failed' && (
|
|
584
|
+
<span className={styles.resourceMuted}>
|
|
585
|
+
Cleanup failed — could not reach dev server.
|
|
586
|
+
</span>
|
|
587
|
+
)}
|
|
588
|
+
<button className={styles.resourceBtnSecondary} onClick={handleStartSession}>
|
|
589
|
+
Retry
|
|
590
|
+
</button>
|
|
591
|
+
</div>
|
|
592
|
+
</div>
|
|
593
|
+
)}
|
|
594
|
+
|
|
595
|
+
{/* Session ended — normal (zzz) */}
|
|
596
|
+
{sessionEnded && !resourceLimited && (
|
|
597
|
+
<div
|
|
598
|
+
className={overlayStyles.interactOverlay}
|
|
599
|
+
style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: 0 }}
|
|
242
600
|
onClick={handleStartSession}
|
|
243
601
|
role="button"
|
|
244
602
|
tabIndex={0}
|
|
@@ -253,22 +611,91 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
253
611
|
</div>
|
|
254
612
|
)}
|
|
255
613
|
<span className={overlayStyles.interactHint}>
|
|
256
|
-
{waking ? 'Waking up...' : 'Start terminal session'}
|
|
614
|
+
{waking ? 'Waking up...' : connectAttempt > 0 ? 'Continue terminal session' : 'Start terminal session'}
|
|
257
615
|
</span>
|
|
258
616
|
</div>
|
|
259
617
|
)}
|
|
260
|
-
|
|
261
|
-
|
|
618
|
+
|
|
619
|
+
{/* Connecting / reveal mask */}
|
|
620
|
+
{!revealed && !error && !sessionEnded && (
|
|
621
|
+
<div className={styles.loading}>
|
|
622
|
+
<div className={styles.spinner} />
|
|
623
|
+
</div>
|
|
262
624
|
)}
|
|
263
625
|
</div>
|
|
264
|
-
{resizable && (
|
|
265
|
-
<ResizeHandle
|
|
266
|
-
targetRef={terminalRef}
|
|
267
|
-
onResize={handleResize}
|
|
268
|
-
minWidth={300}
|
|
269
|
-
minHeight={200}
|
|
270
|
-
/>
|
|
271
|
-
)}
|
|
272
626
|
</div>
|
|
627
|
+
{expanded && (
|
|
628
|
+
<TerminalExpandPane
|
|
629
|
+
widgetId={id}
|
|
630
|
+
expandContainerRef={expandContainerRef}
|
|
631
|
+
prettyName={prettyName}
|
|
632
|
+
isAgent={isAgent}
|
|
633
|
+
splitMode={expandMode === 'split'}
|
|
634
|
+
onClose={() => setExpanded(null)}
|
|
635
|
+
/>
|
|
636
|
+
)}
|
|
637
|
+
</>
|
|
638
|
+
)
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Builds pane configs and renders ExpandedPane for an expanded terminal widget.
|
|
643
|
+
* Primary pane is an external pane that receives the xterm container + handles fit.
|
|
644
|
+
*/
|
|
645
|
+
function TerminalExpandPane({ widgetId, expandContainerRef, prettyName, isAgent, splitMode, onClose }) {
|
|
646
|
+
const connectedWidgets = useMemo(
|
|
647
|
+
() => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
|
|
648
|
+
[widgetId, splitMode],
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
const primaryWidget = useMemo(() => {
|
|
652
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
653
|
+
return bridge?.widgets?.find((w) => w.id === widgetId) || {
|
|
654
|
+
id: widgetId,
|
|
655
|
+
type: isAgent ? 'agent' : 'terminal',
|
|
656
|
+
position: { x: 0, y: 0 },
|
|
657
|
+
props: { prettyName },
|
|
658
|
+
}
|
|
659
|
+
}, [widgetId, isAgent, prettyName])
|
|
660
|
+
|
|
661
|
+
// Custom pane builder for the primary widget (external pane with terminal fit)
|
|
662
|
+
const buildPaneFn = useCallback((widget) => {
|
|
663
|
+
if (widget.id === widgetId) {
|
|
664
|
+
return {
|
|
665
|
+
id: widgetId,
|
|
666
|
+
label: getSplitPaneLabel({ type: isAgent ? 'agent' : 'terminal', props: { prettyName } }),
|
|
667
|
+
widgetType: isAgent ? 'agent' : 'terminal',
|
|
668
|
+
kind: 'external',
|
|
669
|
+
attach: (container) => {
|
|
670
|
+
expandContainerRef.current = container
|
|
671
|
+
const t1 = setTimeout(() => fitTerminalToElement(widgetId, container), 150)
|
|
672
|
+
const t2 = setTimeout(() => fitTerminalToElement(widgetId, container), 400)
|
|
673
|
+
return () => {
|
|
674
|
+
clearTimeout(t1)
|
|
675
|
+
clearTimeout(t2)
|
|
676
|
+
expandContainerRef.current = null
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
onResize: () => {
|
|
680
|
+
if (expandContainerRef.current) {
|
|
681
|
+
fitTerminalToElement(widgetId, expandContainerRef.current)
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return buildPaneForWidget(widget)
|
|
687
|
+
}, [widgetId, prettyName, isAgent, expandContainerRef])
|
|
688
|
+
|
|
689
|
+
const layout = useMemo(
|
|
690
|
+
() => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
|
|
691
|
+
[primaryWidget, connectedWidgets, buildPaneFn],
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
return (
|
|
695
|
+
<ExpandedPane
|
|
696
|
+
initialLayout={layout}
|
|
697
|
+
variant="full"
|
|
698
|
+
onClose={onClose}
|
|
699
|
+
/>
|
|
273
700
|
)
|
|
274
701
|
}
|