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