@dfosco/storyboard-react 4.2.0-beta.2 → 4.2.0-beta.20

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.
Files changed (85) hide show
  1. package/package.json +9 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +478 -186
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +561 -191
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
  15. package/src/canvas/CanvasPage.jsx +738 -216
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -153
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/connectorGeometry.js +132 -0
  23. package/src/canvas/hotPoolDevLogs.js +25 -0
  24. package/src/canvas/useCanvas.js +1 -1
  25. package/src/canvas/useMarqueeSelect.js +30 -4
  26. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  27. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  28. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  29. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  30. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  31. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  32. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  33. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  34. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  35. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  38. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  39. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  40. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  41. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  42. package/src/canvas/widgets/LinkPreview.jsx +112 -4
  43. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  44. package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
  45. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  46. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  47. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  48. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
  49. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  50. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  51. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  52. package/src/canvas/widgets/StoryWidget.jsx +72 -15
  53. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  54. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  55. package/src/canvas/widgets/TerminalWidget.jsx +496 -69
  56. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  57. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  58. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  59. package/src/canvas/widgets/WidgetChrome.jsx +73 -153
  60. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  61. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  62. package/src/canvas/widgets/expandUtils.js +557 -0
  63. package/src/canvas/widgets/expandUtils.test.js +155 -0
  64. package/src/canvas/widgets/index.js +9 -0
  65. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  66. package/src/canvas/widgets/tilePool.js +23 -0
  67. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  68. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  70. package/src/canvas/widgets/tiles/leaf.png +0 -0
  71. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  73. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  75. package/src/canvas/widgets/widgetConfig.js +55 -4
  76. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  77. package/src/canvas/widgets/widgetProps.js +1 -0
  78. package/src/context.jsx +47 -19
  79. package/src/hooks/useConfig.js +14 -0
  80. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  81. package/src/index.js +8 -2
  82. package/src/story/ComponentSetPage.jsx +186 -0
  83. package/src/story/ComponentSetPage.module.css +121 -0
  84. package/src/story/StoryPage.jsx +32 -2
  85. 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 ResizeHandle from './ResizeHandle.jsx'
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').then(async (mod) => {
18
- if (mod.init) await mod.init()
19
- return mod
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
- * Build the WebSocket URL for the terminal backend.
27
- * Includes the base path (e.g. /branch--4.2.0/) so the proxy routes correctly.
28
- * Passes canvasId as a query parameter for session scoping.
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
- * Calculate terminal cols/rows from pixel dimensions.
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 calcDimensions(widthPx, heightPx) {
44
- // Approximate character cell size for 13px monospace
45
- const cellWidth = 7.8
46
- const cellHeight = 17
47
- const padding = 24 // 12px each side
48
- const cols = Math.max(10, Math.floor((widthPx - padding) / cellWidth))
49
- const rows = Math.max(4, Math.floor((heightPx - padding) / cellHeight))
50
- return { cols, rows }
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 width = readProp(props, 'width', terminalSchema)
78
- const height = readProp(props, 'height', terminalSchema)
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
- const handleResize = useCallback((w, h) => {
91
- onUpdate?.({ width: w, height: h })
92
- }, [onUpdate])
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
- // Initialize terminal
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
- // Connect WebSocket
124
- const url = getWsUrl(id, prettyName)
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 === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') {
142
- // Control message — don't render to terminal
312
+ if (msg.type === 'resource-limited') {
313
+ setResourceLimited(msg)
314
+ setSessionEnded(true)
143
315
  return
144
316
  }
145
- } catch {
146
- // Not valid JSON pass through as terminal data
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
- termRef.current?.focus()
202
- }, [sessionEnded])
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 [waking, setWaking] = useState(false)
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
- // Show interact gate when session is ready but not interacting
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
- {sessionEnded && (
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: '#0d1117', flexDirection: 'column', gap: 0 }}
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
- {!ready && !error && !sessionEnded && (
261
- <div className={styles.loading}>Connecting…</div>
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
  }