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

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 (48) hide show
  1. package/package.json +5 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  3. package/src/BranchBar/BranchBar.jsx +17 -5
  4. package/src/BranchBar/BranchBar.module.css +11 -2
  5. package/src/CommandPalette/CommandPalette.jsx +267 -164
  6. package/src/CommandPalette/command-palette.css +130 -78
  7. package/src/Icon.jsx +112 -48
  8. package/src/Viewfinder.jsx +511 -61
  9. package/src/Viewfinder.module.css +414 -2
  10. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  11. package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
  12. package/src/canvas/CanvasPage.jsx +157 -174
  13. package/src/canvas/CanvasPage.module.css +0 -15
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
  15. package/src/canvas/ConnectorLayer.jsx +5 -5
  16. package/src/canvas/PageSelector.test.jsx +15 -6
  17. package/src/canvas/useCanvas.js +1 -1
  18. package/src/canvas/widgets/ActionWidget.jsx +200 -0
  19. package/src/canvas/widgets/ActionWidget.module.css +122 -0
  20. package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
  21. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  22. package/src/canvas/widgets/ImageWidget.jsx +1 -1
  23. package/src/canvas/widgets/LinkPreview.jsx +64 -5
  24. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  25. package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
  26. package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
  27. package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
  28. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  29. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  30. package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
  31. package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
  32. package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
  33. package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
  34. package/src/canvas/widgets/StoryWidget.jsx +7 -4
  35. package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
  36. package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
  37. package/src/canvas/widgets/TerminalWidget.jsx +299 -49
  38. package/src/canvas/widgets/TerminalWidget.module.css +155 -1
  39. package/src/canvas/widgets/WidgetChrome.jsx +19 -14
  40. package/src/canvas/widgets/WidgetChrome.module.css +10 -0
  41. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  42. package/src/canvas/widgets/expandUtils.js +188 -0
  43. package/src/canvas/widgets/index.js +5 -0
  44. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  45. package/src/canvas/widgets/widgetConfig.js +19 -1
  46. package/src/hooks/useConfig.js +14 -0
  47. package/src/index.js +4 -0
  48. package/src/vite/data-plugin.js +264 -14
@@ -1,55 +1,98 @@
1
- import { useRef, useEffect, useCallback, useState } from 'react'
1
+ import { useRef, useEffect, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { readProp } from './widgetProps.js'
3
4
  import { schemas } from './widgetProps.js'
4
5
  import { getTerminalConfig } from '@dfosco/storyboard-core'
6
+ import { ScreenNormalIcon } from '@primer/octicons-react'
7
+ import { useOverride } from '../../hooks/useOverride.js'
8
+ import { getSplitPaneLabel, findConnectedSplitTarget, buildSecondaryIframeUrl as buildSplitUrl, getPaneOrder } from './expandUtils.js'
9
+ import SplitScreenTopBar from './SplitScreenTopBar.jsx'
5
10
  import ResizeHandle from './ResizeHandle.jsx'
6
11
  import styles from './TerminalWidget.module.css'
7
12
  import overlayStyles from './embedOverlay.module.css'
8
13
 
9
14
  const terminalSchema = schemas['terminal']
10
15
 
11
- /**
12
- * Lazy-load ghostty-web to avoid bundling WASM in prod.
13
- */
14
16
  let ghosttyPromise = null
15
17
  function loadGhostty() {
16
18
  if (!ghosttyPromise) {
17
- ghosttyPromise = import('ghostty-web').then(async (mod) => {
18
- if (mod.init) await mod.init()
19
- return mod
20
- })
19
+ ghosttyPromise = import('ghostty-web')
20
+ .then(async (mod) => {
21
+ if (mod.init) await mod.init()
22
+ return mod
23
+ })
24
+ .catch((err) => {
25
+ ghosttyPromise = null
26
+ console.warn('[TerminalWidget] ghostty-web not available:', err.message)
27
+ return null
28
+ })
21
29
  }
22
30
  return ghosttyPromise
23
31
  }
24
32
 
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) {
33
+ function getWsUrl(sessionId, prettyName, startupCommand) {
31
34
  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
32
35
  const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
33
36
  const baseClean = base.endsWith('/') ? base : base + '/'
34
37
  const canvasId = window.__storyboardCanvasBridgeState?.canvasId || 'unknown'
35
38
  let url = `${protocol}//${location.host}${baseClean}_storyboard/terminal/${sessionId}?canvas=${encodeURIComponent(canvasId)}`
36
39
  if (prettyName) url += `&name=${encodeURIComponent(prettyName)}`
40
+ if (startupCommand) url += `&startupCommand=${encodeURIComponent(startupCommand)}`
37
41
  return url
38
42
  }
39
43
 
40
- /**
41
- * Calculate terminal cols/rows from pixel dimensions.
42
- */
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
44
+ function calcDimensions(widthPx, heightPx, fontSize = 13) {
45
+ // Cell dimensions scale proportionally with font size.
46
+ // Base measurements at 13px: ~7.8px wide, ~17px tall.
47
+ const scale = fontSize / 13
48
+ const cellWidth = 7.8 * scale
49
+ const cellHeight = 17 * scale
50
+ const padding = 24
48
51
  const cols = Math.max(10, Math.floor((widthPx - padding) / cellWidth))
49
52
  const rows = Math.max(4, Math.floor((heightPx - padding) / cellHeight))
50
53
  return { cols, rows }
51
54
  }
52
55
 
56
+ const EMBED_TYPES = new Set(['prototype', 'story'])
57
+
58
+ function findConnectedEmbed(widgetId) {
59
+ const bridge = window.__storyboardCanvasBridgeState
60
+ if (!bridge?.connectors || !bridge?.widgets) return null
61
+ const connectedIds = new Set()
62
+ for (const c of bridge.connectors) {
63
+ if (c.start?.widgetId === widgetId) connectedIds.add(c.end?.widgetId)
64
+ if (c.end?.widgetId === widgetId) connectedIds.add(c.start?.widgetId)
65
+ }
66
+ for (const w of bridge.widgets) {
67
+ if (connectedIds.has(w.id) && EMBED_TYPES.has(w.type)) return w
68
+ }
69
+ return null
70
+ }
71
+
72
+ function buildEmbedUrl(widget) {
73
+ if (!widget) return null
74
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
75
+ const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
76
+ if (widget.type === 'prototype') {
77
+ const src = widget.props?.src
78
+ if (!src) return null
79
+ if (/^https?:\/\//.test(src)) return src
80
+ return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar`
81
+ }
82
+ if (widget.type === 'story') {
83
+ const storyId = widget.props?.storyId
84
+ const exportName = widget.props?.exportName
85
+ if (!storyId) return null
86
+ const storyData = typeof window !== 'undefined' && window.__storyboardStoryIndex?.[storyId]
87
+ if (storyData?._route) {
88
+ const route = exportName ? `${storyData._route}?export=${exportName}` : storyData._route
89
+ return `${baseClean}${route}`
90
+ }
91
+ return null
92
+ }
93
+ return null
94
+ }
95
+
53
96
  const DEFAULT_THEME = {
54
97
  background: '#0d1117',
55
98
  foreground: '#e6edf3',
@@ -73,25 +116,60 @@ const DEFAULT_THEME = {
73
116
  brightWhite: '#f0f6fc',
74
117
  }
75
118
 
76
- export default function TerminalWidget({ id, props, onUpdate, resizable }) {
77
- const width = readProp(props, 'width', terminalSchema)
78
- const height = readProp(props, 'height', terminalSchema)
119
+ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizable }, ref) {
120
+ const cfg = getTerminalConfig()
121
+ 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)
79
124
  const prettyName = props?.prettyName || null
125
+ const startupCommand = props?.startupCommand || null
80
126
 
81
127
  const containerRef = useRef(null)
82
128
  const termRef = useRef(null)
83
129
  const terminalRef = useRef(null)
84
130
  const wsRef = useRef(null)
131
+
85
132
  const [ready, setReady] = useState(false)
86
133
  const [error, setError] = useState(null)
87
134
  const [sessionEnded, setSessionEnded] = useState(false)
88
135
  const [connectAttempt, setConnectAttempt] = useState(0)
136
+ const [interactive, setInteractive] = useState(false)
137
+ const [expandedOverride, setExpandedOverride, clearExpandedOverride] = useOverride(`_terminal_expanded_${id}`)
138
+ const expanded = expandedOverride === 'true'
139
+ const setExpanded = useCallback((val) => {
140
+ if (val) setExpandedOverride('true')
141
+ else clearExpandedOverride()
142
+ }, [setExpandedOverride, clearExpandedOverride])
143
+ const [waking, setWaking] = useState(false)
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' || actionId === 'split-screen') setExpanded(true)
151
+ },
152
+ }), [setExpanded])
153
+
154
+ // Exit interactive on click outside
155
+ useEffect(() => {
156
+ if (!interactive) return
157
+ function handlePointerDown(e) {
158
+ if (terminalRef.current && !terminalRef.current.contains(e.target)) {
159
+ const chromeEl = e.target.closest(`[data-widget-id="${id}"]`)
160
+ if (chromeEl) return
161
+ setInteractive(false)
162
+ }
163
+ }
164
+ document.addEventListener('pointerdown', handlePointerDown)
165
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
166
+ }, [interactive, id])
89
167
 
90
168
  const handleResize = useCallback((w, h) => {
91
169
  onUpdate?.({ width: w, height: h })
92
170
  }, [onUpdate])
93
171
 
94
- // Initialize terminal
172
+ // Connect terminal + WebSocket
95
173
  useEffect(() => {
96
174
  if (!containerRef.current) return
97
175
 
@@ -103,8 +181,12 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
103
181
  try {
104
182
  const ghostty = await loadGhostty()
105
183
  if (disposed) return
184
+ if (!ghostty) {
185
+ setError('ghostty-web not installed — add it to your dependencies to enable terminal widgets')
186
+ return
187
+ }
106
188
 
107
- const dims = calcDimensions(width, height)
189
+ const dims = calcDimensions(width, height, fontSize)
108
190
  const cfg = getTerminalConfig()
109
191
 
110
192
  term = new ghostty.Terminal({
@@ -120,31 +202,39 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
120
202
  term.open(containerRef.current)
121
203
  termRef.current = term
122
204
 
123
- // Connect WebSocket
124
- const url = getWsUrl(id, prettyName)
205
+ // SGR mouse wheel for tmux scroll in alternate screen
206
+ term.attachCustomWheelEventHandler((e) => {
207
+ if (!(term.wasmTerm?.isAlternateScreen?.() ?? false)) return false
208
+ const sock = wsRef.current
209
+ if (!sock || sock.readyState !== WebSocket.OPEN) return true
210
+ const btn = e.deltaY < 0 ? 64 : 65
211
+ const lines = Math.max(1, Math.min(5, Math.ceil(Math.abs(e.deltaY) / 33)))
212
+ for (let i = 0; i < lines; i++) {
213
+ sock.send(`\x1b[<${btn};1;1M`)
214
+ sock.send(`\x1b[<${btn};1;1m`)
215
+ }
216
+ return true
217
+ })
218
+
219
+ const url = getWsUrl(id, prettyName, startupCommand)
125
220
  ws = new WebSocket(url)
126
221
  wsRef.current = ws
127
222
 
128
223
  ws.onopen = () => {
129
224
  if (disposed) return
130
225
  setReady(true)
226
+ setInteractive(true)
131
227
  ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
132
228
  }
133
229
 
134
230
  ws.onmessage = (e) => {
135
231
  if (disposed) return
136
232
  const data = typeof e.data === 'string' ? e.data : null
137
- // Intercept JSON control messages from the server
138
233
  if (data && data.startsWith('{')) {
139
234
  try {
140
235
  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
143
- return
144
- }
145
- } catch {
146
- // Not valid JSON — pass through as terminal data
147
- }
236
+ if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') return
237
+ } catch { /* not JSON */ }
148
238
  }
149
239
  term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data))
150
240
  }
@@ -161,11 +251,8 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
161
251
  setSessionEnded(true)
162
252
  }
163
253
 
164
- // Terminal input → WebSocket
165
254
  term.onData((data) => {
166
- if (ws.readyState === WebSocket.OPEN) {
167
- ws.send(data)
168
- }
255
+ if (ws.readyState === WebSocket.OPEN) ws.send(data)
169
256
  })
170
257
  } catch (err) {
171
258
  if (!disposed) setError(err.message || 'Failed to load terminal')
@@ -187,7 +274,7 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
187
274
  useEffect(() => {
188
275
  if (!termRef.current) return
189
276
  const timer = setTimeout(() => {
190
- const dims = calcDimensions(width, height)
277
+ const dims = calcDimensions(width, height, fontSize)
191
278
  termRef.current?.resize?.(dims.cols, dims.rows)
192
279
  if (wsRef.current?.readyState === WebSocket.OPEN) {
193
280
  wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
@@ -196,12 +283,84 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
196
283
  return () => clearTimeout(timer)
197
284
  }, [width, height])
198
285
 
286
+ // Resize for expand
287
+ useEffect(() => {
288
+ if (!expanded || !termRef.current || !expandContainerRef.current) return
289
+ 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
+ }
297
+ setInteractive(true)
298
+ termRef.current?.focus?.()
299
+ }, 100)
300
+ return () => clearTimeout(timer)
301
+ }, [expanded])
302
+
303
+ // Restore size on collapse
304
+ useEffect(() => {
305
+ if (expanded || !termRef.current) return
306
+ const timer = setTimeout(() => {
307
+ const dims = calcDimensions(width, height, fontSize)
308
+ termRef.current?.resize?.(dims.cols, dims.rows)
309
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
310
+ wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
311
+ }
312
+ }, 100)
313
+ return () => clearTimeout(timer)
314
+ }, [expanded, width, height])
315
+
316
+ // Reparent terminal DOM between inline and expand
317
+ useEffect(() => {
318
+ const xtermEl = containerRef.current
319
+ if (!xtermEl) return
320
+ if (expanded && expandContainerRef.current) {
321
+ expandContainerRef.current.appendChild(xtermEl)
322
+ } else if (!expanded && terminalRef.current) {
323
+ terminalRef.current.appendChild(xtermEl)
324
+ }
325
+ }, [expanded])
326
+
199
327
  const handleClick = useCallback(() => {
200
328
  if (sessionEnded) return
201
- termRef.current?.focus()
202
- }, [sessionEnded])
329
+ if (ready) {
330
+ setInteractive(true)
331
+ const scrollEl = terminalRef.current?.closest('[class*="canvasScroll"]')
332
+ const scrollTop = scrollEl?.scrollTop
333
+ const scrollLeft = scrollEl?.scrollLeft
334
+ termRef.current?.focus({ preventScroll: true })
335
+ if (scrollEl && (scrollEl.scrollTop !== scrollTop || scrollEl.scrollLeft !== scrollLeft)) {
336
+ scrollEl.scrollTop = scrollTop
337
+ scrollEl.scrollLeft = scrollLeft
338
+ }
339
+ }
340
+ }, [sessionEnded, ready])
203
341
 
204
- const [waking, setWaking] = useState(false)
342
+ const handleTerminalPointerDown = useCallback((e) => {
343
+ if (!interactive) return
344
+ if (e.target.closest('.tc-drag-handle')) return
345
+ e.stopPropagation()
346
+ const startX = e.clientX
347
+ const startY = e.clientY
348
+ let moved = false
349
+ function onMove(me) {
350
+ if (!moved && (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5)) {
351
+ moved = true
352
+ setShowDragHint(true)
353
+ clearTimeout(dragHintTimer.current)
354
+ dragHintTimer.current = setTimeout(() => setShowDragHint(false), 2000)
355
+ }
356
+ }
357
+ function onUp() {
358
+ document.removeEventListener('pointermove', onMove)
359
+ document.removeEventListener('pointerup', onUp)
360
+ }
361
+ document.addEventListener('pointermove', onMove)
362
+ document.addEventListener('pointerup', onUp)
363
+ }, [interactive])
205
364
 
206
365
  const handleStartSession = useCallback(() => {
207
366
  setWaking(true)
@@ -213,11 +372,24 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
213
372
  }, 1500)
214
373
  }, [])
215
374
 
216
- // Show interact gate when session is ready but not interacting
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')
217
380
 
218
- const titleLabel = `terminal · ${prettyName || '...'}`
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
219
390
 
220
391
  return (
392
+ <>
221
393
  <div className={styles.container}>
222
394
  <div className={styles.titleBar}>{titleLabel}</div>
223
395
  <div
@@ -228,13 +400,41 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
228
400
  ...(typeof height === 'number' ? { height: `${height}px` } : undefined),
229
401
  }}
230
402
  onClick={handleClick}
403
+ onPointerDown={handleTerminalPointerDown}
404
+ onKeyDown={interactive ? (e) => e.stopPropagation() : undefined}
231
405
  >
406
+ {showDragHint && (
407
+ <div className={styles.dragHint}>
408
+ <span className={styles.dragHintArrow}>←</span> Drag here to move widget
409
+ </div>
410
+ )}
232
411
  {error && !sessionEnded && (
233
412
  <div className={styles.error}>
234
413
  <span>⚠ {error}</span>
235
414
  </div>
236
415
  )}
237
416
  <div ref={containerRef} className={styles.xtermContainer} />
417
+
418
+ {/* Live but not interactive */}
419
+ {ready && !interactive && !sessionEnded && (
420
+ <div
421
+ className={overlayStyles.interactOverlay}
422
+ style={{ backgroundColor: 'transparent' }}
423
+ onClick={(e) => {
424
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
425
+ setInteractive(true)
426
+ termRef.current?.focus({ preventScroll: true })
427
+ }}
428
+ role="button"
429
+ tabIndex={0}
430
+ onKeyDown={(e) => { if (e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
431
+ aria-label="Click to interact"
432
+ >
433
+ <span className={overlayStyles.interactHint}>Click to interact</span>
434
+ </div>
435
+ )}
436
+
437
+ {/* Session ended */}
238
438
  {sessionEnded && (
239
439
  <div
240
440
  className={overlayStyles.interactOverlay}
@@ -253,10 +453,12 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
253
453
  </div>
254
454
  )}
255
455
  <span className={overlayStyles.interactHint}>
256
- {waking ? 'Waking up...' : 'Start terminal session'}
456
+ {waking ? 'Waking up...' : connectAttempt > 0 ? 'Continue terminal session' : 'Start terminal session'}
257
457
  </span>
258
458
  </div>
259
459
  )}
460
+
461
+ {/* Connecting */}
260
462
  {!ready && !error && !sessionEnded && (
261
463
  <div className={styles.loading}>Connecting…</div>
262
464
  )}
@@ -270,5 +472,53 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
270
472
  />
271
473
  )}
272
474
  </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
521
+ )}
522
+ </>
273
523
  )
274
- }
524
+ })
@@ -33,7 +33,8 @@
33
33
  background: #0d1117;
34
34
  border: 1px solid var(--borderColor-default, #30363d);
35
35
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
36
- padding: 8px;
36
+ padding: var(--base-size-8, 8px);
37
+ padding-bottom: var(--base-size-16, 16px);
37
38
  }
38
39
 
39
40
  .xtermContainer {
@@ -49,6 +50,22 @@
49
50
  padding: 12px;
50
51
  }
51
52
 
53
+ /* Hide the native caret on ghostty-web's helper textarea —
54
+ without this it renders a visible blinking cursor at (0,0)
55
+ and triggers scrollIntoView on focus (scroll-to-top bug). */
56
+ .xtermContainer :global(.xterm-helper-textarea),
57
+ .xtermContainer textarea {
58
+ caret-color: transparent !important;
59
+ opacity: 0 !important;
60
+ position: absolute !important;
61
+ top: 0 !important;
62
+ left: 0 !important;
63
+ width: 1px !important;
64
+ height: 1px !important;
65
+ overflow: hidden !important;
66
+ pointer-events: none !important;
67
+ }
68
+
52
69
  .xtermContainer :global(.xterm-viewport) {
53
70
  overflow-y: auto;
54
71
  }
@@ -156,3 +173,140 @@
156
173
  transform: translateY(-24px);
157
174
  }
158
175
  }
176
+
177
+ /* Fullscreen expand */
178
+
179
+ /* Drag hint tooltip — appears when user tries to drag the terminal body */
180
+ .dragHint {
181
+ position: absolute;
182
+ bottom: -32px;
183
+ right: 0;
184
+ z-index: 10;
185
+ display: flex;
186
+ align-items: center;
187
+ gap: 4px;
188
+ padding: 4px 10px;
189
+ border-radius: 6px;
190
+ background: var(--bgColor-inverse, #1f2328);
191
+ color: var(--fgColor-onInverse, #ffffff);
192
+ font-size: 12px;
193
+ font-weight: 500;
194
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
195
+ white-space: nowrap;
196
+ pointer-events: none;
197
+ animation: dragHintIn 150ms ease;
198
+ }
199
+
200
+ .dragHintArrow {
201
+ font-size: 14px;
202
+ opacity: 0.7;
203
+ }
204
+
205
+ @keyframes dragHintIn {
206
+ from { opacity: 0; transform: translateY(-4px); }
207
+ to { opacity: 1; transform: translateY(0); }
208
+ }
209
+
210
+ .expandBackdrop {
211
+ position: fixed;
212
+ inset: 0;
213
+ z-index: 100000;
214
+ background: #0d1117;
215
+ display: flex;
216
+ flex-direction: column;
217
+ animation: expandFadeIn 0.15s ease;
218
+ }
219
+
220
+ @keyframes expandFadeIn {
221
+ from { opacity: 0; }
222
+ to { opacity: 1; }
223
+ }
224
+
225
+ .expandTopBar {
226
+ display: flex;
227
+ align-items: center;
228
+ height: 40px;
229
+ padding: 0 12px;
230
+ background: #161b22;
231
+ border-bottom: 1px solid #30363d;
232
+ flex-shrink: 0;
233
+ }
234
+
235
+ .expandTitle {
236
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
237
+ font-size: 12px;
238
+ font-weight: 500;
239
+ color: #e6edf3;
240
+ }
241
+
242
+ .expandEmbedLabel {
243
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
244
+ font-size: 12px;
245
+ color: #8b949e;
246
+ margin-left: auto;
247
+ margin-right: 12px;
248
+ overflow: hidden;
249
+ text-overflow: ellipsis;
250
+ white-space: nowrap;
251
+ }
252
+
253
+ .expandClose {
254
+ all: unset;
255
+ cursor: pointer;
256
+ margin-left: auto;
257
+ width: 28px;
258
+ height: 28px;
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: center;
262
+ border-radius: 6px;
263
+ color: #8b949e;
264
+ font-size: 14px;
265
+ transition: background 100ms, color 100ms;
266
+ }
267
+
268
+ .expandClose:hover {
269
+ background: #30363d;
270
+ color: #e6edf3;
271
+ }
272
+
273
+ .expandEmbedLabel + .expandClose {
274
+ margin-left: 0;
275
+ }
276
+
277
+ .expandBody {
278
+ flex: 1;
279
+ min-height: 0;
280
+ display: flex;
281
+ }
282
+
283
+ .expandTerminal {
284
+ flex: 1;
285
+ min-width: 0;
286
+ overflow: hidden;
287
+ background: #0d1117;
288
+ }
289
+
290
+ .expandTerminal :global(.xterm) {
291
+ height: 100%;
292
+ }
293
+
294
+ .expandSplit .expandTerminal {
295
+ flex: 1;
296
+ border-right: 1px solid #30363d;
297
+ }
298
+
299
+ .expandSplit .expandEmbed {
300
+ flex: 1;
301
+ min-width: 0;
302
+ }
303
+
304
+ .expandEmbed {
305
+ display: flex;
306
+ }
307
+
308
+ .expandIframe {
309
+ border: none;
310
+ width: 100%;
311
+ height: 100%;
312
+ }