@dfosco/storyboard-react 4.2.0-beta.17 → 4.2.0-beta.19

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 (79) hide show
  1. package/package.json +7 -3
  2. package/src/BranchBar/BranchBar.jsx +3 -1
  3. package/src/BranchBar/BranchBar.module.css +2 -2
  4. package/src/BranchBar/useBranches.js +20 -6
  5. package/src/BranchBar/useBranches.test.js +68 -0
  6. package/src/CommandPalette/CommandPalette.jsx +250 -61
  7. package/src/CommandPalette/command-palette.css +12 -0
  8. package/src/Icon.jsx +46 -11
  9. package/src/Viewfinder.jsx +53 -133
  10. package/src/Viewfinder.module.css +20 -91
  11. package/src/Workspace.jsx +7 -0
  12. package/src/canvas/CanvasPage.jsx +601 -62
  13. package/src/canvas/CanvasPage.module.css +15 -2
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
  15. package/src/canvas/ConnectorLayer.jsx +120 -152
  16. package/src/canvas/ConnectorLayer.module.css +69 -0
  17. package/src/canvas/canvasApi.js +68 -2
  18. package/src/canvas/connectorGeometry.js +132 -0
  19. package/src/canvas/hotPoolDevLogs.js +25 -0
  20. package/src/canvas/useMarqueeSelect.js +30 -4
  21. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  22. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  23. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  25. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  26. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  27. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  28. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  29. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  30. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  31. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  32. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  33. package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
  34. package/src/canvas/widgets/ImageWidget.jsx +129 -8
  35. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  36. package/src/canvas/widgets/LinkPreview.jsx +93 -44
  37. package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
  38. package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
  39. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  40. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  41. package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
  42. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  43. package/src/canvas/widgets/StoryWidget.jsx +65 -11
  44. package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
  45. package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
  46. package/src/canvas/widgets/TerminalWidget.jsx +301 -124
  47. package/src/canvas/widgets/TerminalWidget.module.css +121 -12
  48. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  49. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  50. package/src/canvas/widgets/WidgetChrome.jsx +67 -152
  51. package/src/canvas/widgets/WidgetChrome.module.css +20 -1
  52. package/src/canvas/widgets/expandUtils.js +385 -16
  53. package/src/canvas/widgets/expandUtils.test.js +155 -0
  54. package/src/canvas/widgets/index.js +6 -2
  55. package/src/canvas/widgets/tilePool.js +23 -0
  56. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  57. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  58. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  59. package/src/canvas/widgets/tiles/leaf.png +0 -0
  60. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  61. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  62. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  63. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  64. package/src/canvas/widgets/widgetConfig.js +37 -4
  65. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  66. package/src/canvas/widgets/widgetProps.js +1 -0
  67. package/src/context.jsx +47 -19
  68. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  69. package/src/index.js +4 -2
  70. package/src/story/ComponentSetPage.jsx +186 -0
  71. package/src/story/ComponentSetPage.module.css +121 -0
  72. package/src/story/StoryPage.jsx +32 -2
  73. package/src/vite/data-plugin.js +79 -35
  74. package/src/canvas/widgets/ActionWidget.jsx +0 -200
  75. package/src/canvas/widgets/ActionWidget.module.css +0 -122
  76. package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
  77. package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
  78. package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
  79. package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
@@ -1,13 +1,10 @@
1
1
  import { useRef, useEffect, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react'
2
- import { createPortal } from 'react-dom'
3
2
  import { readProp } from './widgetProps.js'
4
3
  import { schemas } from './widgetProps.js'
5
- import { getTerminalConfig } from '@dfosco/storyboard-core'
6
- import { ScreenNormalIcon } from '@primer/octicons-react'
4
+ import { getTerminalConfig, getTerminalDimensions, getStoryData } from '@dfosco/storyboard-core'
7
5
  import { useOverride } from '../../hooks/useOverride.js'
8
- import { getSplitPaneLabel, findConnectedSplitTarget, buildSecondaryIframeUrl as buildSplitUrl, getPaneOrder } from './expandUtils.js'
9
- import SplitScreenTopBar from './SplitScreenTopBar.jsx'
10
- import ResizeHandle from './ResizeHandle.jsx'
6
+ import { getSplitPaneLabel, findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
7
+ import ExpandedPane from './ExpandedPane.jsx'
11
8
  import styles from './TerminalWidget.module.css'
12
9
  import overlayStyles from './embedOverlay.module.css'
13
10
 
@@ -30,6 +27,10 @@ function loadGhostty() {
30
27
  return ghosttyPromise
31
28
  }
32
29
 
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
+
33
34
  function getWsUrl(sessionId, prettyName, startupCommand) {
34
35
  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
35
36
  const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
@@ -47,12 +48,37 @@ function calcDimensions(widthPx, heightPx, fontSize = 13) {
47
48
  const scale = fontSize / 13
48
49
  const cellWidth = 7.8 * scale
49
50
  const cellHeight = 17 * scale
50
- const padding = 24
51
- const cols = Math.max(10, Math.floor((widthPx - padding) / cellWidth))
52
- const rows = Math.max(4, Math.floor((heightPx - padding) / cellHeight))
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))
53
56
  return { cols, rows }
54
57
  }
55
58
 
59
+ /**
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.
62
+ */
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
+
56
82
  const EMBED_TYPES = new Set(['prototype', 'story'])
57
83
 
58
84
  function findConnectedEmbed(widgetId) {
@@ -83,7 +109,7 @@ function buildEmbedUrl(widget) {
83
109
  const storyId = widget.props?.storyId
84
110
  const exportName = widget.props?.exportName
85
111
  if (!storyId) return null
86
- const storyData = typeof window !== 'undefined' && window.__storyboardStoryIndex?.[storyId]
112
+ const storyData = getStoryData(storyId)
87
113
  if (storyData?._route) {
88
114
  const route = exportName ? `${storyData._route}?export=${exportName}` : storyData._route
89
115
  return `${baseClean}${route}`
@@ -116,40 +142,67 @@ const DEFAULT_THEME = {
116
142
  brightWhite: '#f0f6fc',
117
143
  }
118
144
 
119
- export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizable }, ref) {
145
+ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizable, multiSelected }, ref) {
120
146
  const cfg = getTerminalConfig()
121
147
  const fontSize = cfg.fontSize ?? 13
122
- const width = props?.width ?? cfg.defaultWidth ?? readProp(props, 'width', terminalSchema)
123
- const height = props?.height ?? cfg.defaultHeight ?? readProp(props, 'height', terminalSchema)
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
124
155
  const prettyName = props?.prettyName || null
125
156
  const startupCommand = props?.startupCommand || null
126
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)
168
+
127
169
  const containerRef = useRef(null)
128
170
  const termRef = useRef(null)
129
171
  const terminalRef = useRef(null)
130
172
  const wsRef = useRef(null)
131
173
 
132
174
  const [ready, setReady] = useState(false)
175
+ const [revealed, setRevealed] = useState(false)
133
176
  const [error, setError] = useState(null)
134
177
  const [sessionEnded, setSessionEnded] = useState(false)
135
178
  const [connectAttempt, setConnectAttempt] = useState(0)
136
179
  const [interactive, setInteractive] = useState(false)
137
180
  const [expandedOverride, setExpandedOverride, clearExpandedOverride] = useOverride(`_terminal_expanded_${id}`)
138
- const expanded = expandedOverride === 'true'
139
- const setExpanded = useCallback((val) => {
140
- if (val) setExpandedOverride('true')
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)
141
185
  else clearExpandedOverride()
142
186
  }, [setExpandedOverride, clearExpandedOverride])
143
187
  const [waking, setWaking] = useState(false)
188
+ const [resourceLimited, setResourceLimited] = useState(null)
144
189
  const [showDragHint, setShowDragHint] = useState(false)
145
190
  const expandContainerRef = useRef(null)
146
191
  const dragHintTimer = useRef(null)
147
192
 
148
193
  useImperativeHandle(ref, () => ({
149
194
  handleAction(actionId) {
150
- if (actionId === 'expand' || actionId === 'split-screen') setExpanded(true)
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
151
204
  },
152
- }), [setExpanded])
205
+ }), [setExpanded, props, onUpdate])
153
206
 
154
207
  // Exit interactive on click outside
155
208
  useEffect(() => {
@@ -165,9 +218,10 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
165
218
  return () => document.removeEventListener('pointerdown', handlePointerDown)
166
219
  }, [interactive, id])
167
220
 
168
- const handleResize = useCallback((w, h) => {
169
- onUpdate?.({ width: w, height: h })
170
- }, [onUpdate])
221
+ // Exit interactive when terminal becomes part of a multi-selection
222
+ useEffect(() => {
223
+ if (multiSelected && interactive) setInteractive(false)
224
+ }, [multiSelected])
171
225
 
172
226
  // Connect terminal + WebSocket
173
227
  useEffect(() => {
@@ -202,6 +256,29 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
202
256
  term.open(containerRef.current)
203
257
  termRef.current = term
204
258
 
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
+
205
282
  // SGR mouse wheel for tmux scroll in alternate screen
206
283
  term.attachCustomWheelEventHandler((e) => {
207
284
  if (!(term.wasmTerm?.isAlternateScreen?.() ?? false)) return false
@@ -223,7 +300,6 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
223
300
  ws.onopen = () => {
224
301
  if (disposed) return
225
302
  setReady(true)
226
- setInteractive(true)
227
303
  ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
228
304
  }
229
305
 
@@ -233,6 +309,11 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
233
309
  if (data && data.startsWith('{')) {
234
310
  try {
235
311
  const msg = JSON.parse(data)
312
+ if (msg.type === 'resource-limited') {
313
+ setResourceLimited(msg)
314
+ setSessionEnded(true)
315
+ return
316
+ }
236
317
  if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') return
237
318
  } catch { /* not JSON */ }
238
319
  }
@@ -254,6 +335,9 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
254
335
  term.onData((data) => {
255
336
  if (ws.readyState === WebSocket.OPEN) ws.send(data)
256
337
  })
338
+
339
+ // Register in global registry for split-screen access
340
+ terminalRegistry.set(id, { term, ws })
257
341
  } catch (err) {
258
342
  if (!disposed) setError(err.message || 'Failed to load terminal')
259
343
  }
@@ -263,6 +347,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
263
347
 
264
348
  return () => {
265
349
  disposed = true
350
+ terminalRegistry.delete(id)
266
351
  if (ws && ws.readyState <= WebSocket.OPEN) ws.close()
267
352
  if (term) term.dispose()
268
353
  termRef.current = null
@@ -276,6 +361,20 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
276
361
  const timer = setTimeout(() => {
277
362
  const dims = calcDimensions(width, height, fontSize)
278
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)
279
378
  if (wsRef.current?.readyState === WebSocket.OPEN) {
280
379
  wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
281
380
  }
@@ -283,21 +382,32 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
283
382
  return () => clearTimeout(timer)
284
383
  }, [width, height])
285
384
 
286
- // Resize for expand
385
+ // Reveal mask — hide terminal for 750ms after ready to mask startup flash
287
386
  useEffect(() => {
288
- if (!expanded || !termRef.current || !expandContainerRef.current) return
387
+ if (!ready) {
388
+ setRevealed(false)
389
+ return
390
+ }
289
391
  const timer = setTimeout(() => {
290
- const el = expandContainerRef.current
291
- if (!el) return
292
- const dims = calcDimensions(el.clientWidth, el.clientHeight - 40, fontSize)
293
- termRef.current?.resize?.(dims.cols, dims.rows)
294
- if (wsRef.current?.readyState === WebSocket.OPEN) {
295
- wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
296
- }
392
+ setRevealed(true)
297
393
  setInteractive(true)
298
- termRef.current?.focus?.()
299
- }, 100)
394
+ }, 750)
300
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
+ }
301
411
  }, [expanded])
302
412
 
303
413
  // Restore size on collapse
@@ -313,20 +423,16 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
313
423
  return () => clearTimeout(timer)
314
424
  }, [expanded, width, height])
315
425
 
316
- // Reparent terminal DOM between inline and expand
426
+ // Focus terminal on expand
317
427
  useEffect(() => {
318
- const xtermEl = containerRef.current
319
- if (!xtermEl) return
320
428
  if (expanded && expandContainerRef.current) {
321
- expandContainerRef.current.appendChild(xtermEl)
322
- } else if (!expanded && terminalRef.current) {
323
- terminalRef.current.appendChild(xtermEl)
429
+ fitTerminalToElement(id, expandContainerRef.current)
324
430
  }
325
431
  }, [expanded])
326
432
 
327
433
  const handleClick = useCallback(() => {
328
- if (sessionEnded) return
329
- if (ready) {
434
+ if (sessionEnded || multiSelected) return
435
+ if (revealed) {
330
436
  setInteractive(true)
331
437
  const scrollEl = terminalRef.current?.closest('[class*="canvasScroll"]')
332
438
  const scrollTop = scrollEl?.scrollTop
@@ -337,7 +443,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
337
443
  scrollEl.scrollLeft = scrollLeft
338
444
  }
339
445
  }
340
- }, [sessionEnded, ready])
446
+ }, [sessionEnded, multiSelected, revealed])
341
447
 
342
448
  const handleTerminalPointerDown = useCallback((e) => {
343
449
  if (!interactive) return
@@ -367,37 +473,50 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
367
473
  setTimeout(() => {
368
474
  setWaking(false)
369
475
  setSessionEnded(false)
476
+ setResourceLimited(null)
370
477
  setError(null)
371
478
  setConnectAttempt(c => c + 1)
372
479
  }, 1500)
373
480
  }, [])
374
481
 
375
- const titleLabel = `Terminal · ${prettyName || '…'}`
376
- const connectedEmbed = expanded ? findConnectedSplitTarget(id) : null
377
- const embedUrl = expanded ? buildSplitUrl(connectedEmbed) : null
378
- const hasSplit = Boolean(connectedEmbed)
379
- const [activePane, setActivePane] = useState('left')
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])
380
508
 
381
- const paneOrder = useMemo(
382
- () => (hasSplit ? getPaneOrder(id, connectedEmbed) : { primaryIsLeft: true }),
383
- [hasSplit, id, connectedEmbed],
384
- )
385
- const primaryWidget = useMemo(() => ({ type: 'terminal', props: { prettyName } }), [prettyName])
386
- const primaryLabel = useMemo(() => getSplitPaneLabel(primaryWidget), [primaryWidget])
387
- const secondaryLabel = useMemo(() => getSplitPaneLabel(connectedEmbed), [connectedEmbed])
388
- const leftLabel = paneOrder.primaryIsLeft ? primaryLabel : secondaryLabel
389
- const rightLabel = paneOrder.primaryIsLeft ? secondaryLabel : primaryLabel
390
509
 
391
510
  return (
392
511
  <>
393
512
  <div className={styles.container}>
394
- <div className={styles.titleBar}>{titleLabel}</div>
513
+ <div className={`tc-drag-handle ${styles.titleBar}`}>{titleLabel}</div>
395
514
  <div
396
515
  ref={terminalRef}
397
516
  className={styles.terminal}
398
517
  style={{
399
- ...(typeof width === 'number' ? { width: `${width}px` } : undefined),
400
- ...(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),
401
520
  }}
402
521
  onClick={handleClick}
403
522
  onPointerDown={handleTerminalPointerDown}
@@ -413,32 +532,71 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
413
532
  <span>⚠ {error}</span>
414
533
  </div>
415
534
  )}
416
- <div ref={containerRef} className={styles.xtermContainer} />
535
+ <div ref={containerRef} className={styles.xtermContainer} style={{ opacity: revealed ? 1 : 0 }} />
417
536
 
418
537
  {/* Live but not interactive */}
419
- {ready && !interactive && !sessionEnded && (
538
+ {revealed && !interactive && !sessionEnded && (
420
539
  <div
421
540
  className={overlayStyles.interactOverlay}
422
541
  style={{ backgroundColor: 'transparent' }}
423
542
  onClick={(e) => {
424
- if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
543
+ if (multiSelected || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
425
544
  setInteractive(true)
426
545
  termRef.current?.focus({ preventScroll: true })
427
546
  }}
428
547
  role="button"
429
548
  tabIndex={0}
430
- onKeyDown={(e) => { if (e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
549
+ onKeyDown={(e) => { if (!multiSelected && e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
431
550
  aria-label="Click to interact"
432
551
  >
433
- <span className={overlayStyles.interactHint}>Click to interact</span>
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>
434
592
  </div>
435
593
  )}
436
594
 
437
- {/* Session ended */}
438
- {sessionEnded && (
595
+ {/* Session ended — normal (zzz) */}
596
+ {sessionEnded && !resourceLimited && (
439
597
  <div
440
598
  className={overlayStyles.interactOverlay}
441
- style={{ backgroundColor: '#0d1117', flexDirection: 'column', gap: 0 }}
599
+ style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: 0 }}
442
600
  onClick={handleStartSession}
443
601
  role="button"
444
602
  tabIndex={0}
@@ -458,67 +616,86 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
458
616
  </div>
459
617
  )}
460
618
 
461
- {/* Connecting */}
462
- {!ready && !error && !sessionEnded && (
463
- <div className={styles.loading}>Connecting…</div>
619
+ {/* Connecting / reveal mask */}
620
+ {!revealed && !error && !sessionEnded && (
621
+ <div className={styles.loading}>
622
+ <div className={styles.spinner} />
623
+ </div>
464
624
  )}
465
625
  </div>
466
- {resizable && (
467
- <ResizeHandle
468
- targetRef={terminalRef}
469
- onResize={handleResize}
470
- minWidth={300}
471
- minHeight={200}
472
- />
473
- )}
474
626
  </div>
475
- {createPortal(
476
- <div
477
- className={styles.expandBackdrop}
478
- style={expanded ? undefined : { display: 'none' }}
479
- onPointerDown={(e) => e.stopPropagation()}
480
- onKeyDown={(e) => { if (e.key === 'Escape') setExpanded(false) }}
481
- onWheel={(e) => e.stopPropagation()}
482
- >
483
- {hasSplit ? (
484
- <SplitScreenTopBar
485
- leftLabel={leftLabel}
486
- rightLabel={rightLabel}
487
- activePane={activePane}
488
- onClose={() => setExpanded(false)}
489
- />
490
- ) : (
491
- <div className={styles.expandTopBar}>
492
- <span className={styles.expandTitle}>{titleLabel}</span>
493
- <button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus><ScreenNormalIcon size={16} /></button>
494
- </div>
495
- )}
496
- <div className={`${styles.expandBody}${hasSplit ? ` ${styles.expandSplit}` : ''}`}>
497
- {hasSplit ? (
498
- <>
499
- {paneOrder.primaryIsLeft ? (
500
- <>
501
- <div ref={expandContainerRef} className={styles.expandTerminal} onPointerDown={() => setActivePane('left')} />
502
- <div className={styles.expandEmbed} onPointerDown={() => setActivePane('right')}>
503
- {embedUrl ? <iframe src={embedUrl} className={styles.expandIframe} title="Connected embed" /> : null}
504
- </div>
505
- </>
506
- ) : (
507
- <>
508
- <div className={styles.expandEmbed} onPointerDown={() => setActivePane('left')}>
509
- {embedUrl ? <iframe src={embedUrl} className={styles.expandIframe} title="Connected embed" /> : null}
510
- </div>
511
- <div ref={expandContainerRef} className={styles.expandTerminal} onPointerDown={() => setActivePane('right')} />
512
- </>
513
- )}
514
- </>
515
- ) : (
516
- <div ref={expandContainerRef} className={styles.expandTerminal} />
517
- )}
518
- </div>
519
- </div>,
520
- document.body
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
+ />
521
636
  )}
522
637
  </>
523
638
  )
524
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
+ />
700
+ )
701
+ }