@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +10 -11
  2. package/src/AuthModal/AuthModal.jsx +6 -8
  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 +480 -187
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +562 -207
  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 +11 -7
  15. package/src/canvas/CanvasPage.jsx +739 -219
  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 -165
  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/canvasReloadGuard.test.js +1 -1
  23. package/src/canvas/connectorGeometry.js +132 -0
  24. package/src/canvas/hotPoolDevLogs.js +25 -0
  25. package/src/canvas/useCanvas.js +1 -1
  26. package/src/canvas/useMarqueeSelect.js +30 -4
  27. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  28. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  29. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  30. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  31. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  32. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  33. package/src/canvas/widgets/ExpandedPane.jsx +474 -0
  34. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  35. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  38. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  39. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  40. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  41. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  42. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  43. package/src/canvas/widgets/LinkPreview.jsx +113 -5
  44. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  45. package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
  46. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  47. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  48. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  49. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
  50. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  51. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  52. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  53. package/src/canvas/widgets/StoryWidget.jsx +73 -15
  54. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  55. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  56. package/src/canvas/widgets/TerminalWidget.jsx +445 -67
  57. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  58. package/src/canvas/widgets/TilesWidget.jsx +300 -0
  59. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  60. package/src/canvas/widgets/WidgetChrome.jsx +74 -153
  61. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  62. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  63. package/src/canvas/widgets/expandUtils.js +560 -0
  64. package/src/canvas/widgets/expandUtils.test.js +155 -0
  65. package/src/canvas/widgets/index.js +9 -0
  66. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  67. package/src/canvas/widgets/tilePool.js +23 -0
  68. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  70. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  71. package/src/canvas/widgets/tiles/leaf.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  73. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  75. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  76. package/src/canvas/widgets/widgetConfig.js +55 -4
  77. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  78. package/src/canvas/widgets/widgetProps.js +1 -0
  79. package/src/context.jsx +48 -20
  80. package/src/hooks/useConfig.js +14 -0
  81. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  82. package/src/hooks/useSceneData.js +1 -0
  83. package/src/hooks/useThemeState.test.js +1 -1
  84. package/src/index.js +8 -2
  85. package/src/story/ComponentSetPage.jsx +186 -0
  86. package/src/story/ComponentSetPage.module.css +121 -0
  87. package/src/story/StoryPage.jsx +32 -2
  88. package/src/vite/data-plugin.js +407 -67
  89. 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 ResizeHandle from './ResizeHandle.jsx'
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(/* @vite-ignore */ 'ghostty-web')
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
- * Build the WebSocket URL for the terminal backend.
33
- * Includes the base path (e.g. /branch--4.2.0/) so the proxy routes correctly.
34
- * Passes canvasId as a query parameter for session scoping.
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
- * 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.
48
62
  */
49
- function calcDimensions(widthPx, heightPx) {
50
- // Approximate character cell size for 13px monospace
51
- const cellWidth = 7.8
52
- const cellHeight = 17
53
- const padding = 24 // 12px each side
54
- const cols = Math.max(10, Math.floor((widthPx - padding) / cellWidth))
55
- const rows = Math.max(4, Math.floor((heightPx - padding) / cellHeight))
56
- 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
+ }
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, resizable }) {
83
- const width = readProp(props, 'width', terminalSchema)
84
- const height = readProp(props, 'height', terminalSchema)
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
- const handleResize = useCallback((w, h) => {
97
- onUpdate?.({ width: w, height: h })
98
- }, [onUpdate])
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
- // Initialize terminal
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 || !ghostty) return
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
- // Connect WebSocket
130
- const url = getWsUrl(id, prettyName)
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 === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') {
148
- // Control message — don't render to terminal
268
+ if (msg.type === 'resource-limited') {
269
+ setResourceLimited(msg)
270
+ setSessionEnded(true)
149
271
  return
150
272
  }
151
- } catch {
152
- // Not valid JSON pass through as terminal data
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
- termRef.current?.focus()
208
- }, [sessionEnded])
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 [waking, setWaking] = useState(false)
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
- // Show interact gate when session is ready but not interacting
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
- {sessionEnded && (
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
- {!ready && !error && !sessionEnded && (
267
- <div className={styles.loading}>Connecting…</div>
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
  }