@dfosco/storyboard-react 4.2.4 → 4.2.6

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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * StorySetWidget — renders all exports from a story in a single iframe grid.
3
+ *
4
+ * Instead of N iframes (one per export), this widget loads one iframe pointing
5
+ * to the isolate-set endpoint. Each export renders in a grid cell inside
6
+ * that single page. The user can select a cell (via label click) which updates
7
+ * `props.selected` — visible to connected agents.
8
+ *
9
+ * User-facing label: "Component Set"
10
+ *
11
+ * Props: { storyId, layout, selected, width, height }
12
+ */
13
+ import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
14
+ import { getStoryData } from '@dfosco/storyboard-core'
15
+ import Icon from '../../Icon.jsx'
16
+ import WidgetWrapper from './WidgetWrapper.jsx'
17
+ import ResizeHandle from './ResizeHandle.jsx'
18
+ import { useIframeDevLogs } from './iframeDevLogs.js'
19
+ import styles from './StorySetWidget.module.css'
20
+ import overlayStyles from './embedOverlay.module.css'
21
+
22
+ function GridIcon({ size = 16 }) {
23
+ return <Icon name="iconoir/view-grid" size={size} />
24
+ }
25
+
26
+ function resolveStorySetUrl(storyId, layout, selected) {
27
+ const story = getStoryData(storyId)
28
+ if (!story?._storyModule) return ''
29
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
30
+ const params = new URLSearchParams()
31
+ params.set('module', story._storyModule)
32
+ if (layout) params.set('layout', layout)
33
+ if (selected) params.set('selected', selected)
34
+ return `${base}/_storyboard/canvas/isolate-set?${params}`
35
+ }
36
+
37
+ export default forwardRef(function StorySetWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
38
+ const storyId = props?.storyId || ''
39
+ const layout = props?.layout || 'horizontal'
40
+ const selected = props?.selected || ''
41
+ const width = props?.width
42
+ const height = props?.height
43
+
44
+ const containerRef = useRef(null)
45
+ const iframeRef = useRef(null)
46
+ const [interactive, setInteractive] = useState(false)
47
+ const [storyIndexKey, setStoryIndexKey] = useState(0)
48
+
49
+ // Re-resolve when story index is live-patched
50
+ useEffect(() => {
51
+ const handler = () => setStoryIndexKey((k) => k + 1)
52
+ document.addEventListener('storyboard:story-index-changed', handler)
53
+ return () => document.removeEventListener('storyboard:story-index-changed', handler)
54
+ }, [])
55
+
56
+ const enterInteractive = useCallback(() => setInteractive(true), [])
57
+
58
+ // Exit interactive mode when clicking outside
59
+ useEffect(() => {
60
+ if (!interactive) return
61
+ function handlePointerDown(e) {
62
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
63
+ const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
64
+ if (chromeEl) return
65
+ setInteractive(false)
66
+ }
67
+ }
68
+ document.addEventListener('pointerdown', handlePointerDown)
69
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
70
+ }, [interactive, widgetId])
71
+
72
+ // Listen for selection messages from the embedded grid
73
+ useEffect(() => {
74
+ function handleMessage(e) {
75
+ if (e.source !== iframeRef.current?.contentWindow) return
76
+ if (e.data?.type === 'storyboard:component-set:select') {
77
+ const newSelected = e.data.exportName || ''
78
+ if (newSelected !== selected) {
79
+ onUpdate?.({ selected: newSelected })
80
+ }
81
+ } else if (e.data?.type === 'storyboard:component-set:resize') {
82
+ // Auto-size widget to fit the grid content (+ header height)
83
+ const headerH = 32
84
+ const newW = Math.max(200, Math.ceil(e.data.width))
85
+ const newH = Math.max(60, Math.ceil(e.data.height) + headerH)
86
+ if (newW !== width || newH !== height) {
87
+ onUpdate?.({ width: newW, height: newH })
88
+ }
89
+ }
90
+ }
91
+ window.addEventListener('message', handleMessage)
92
+ return () => window.removeEventListener('message', handleMessage)
93
+ }, [selected, width, height, onUpdate])
94
+
95
+ const handleResize = useCallback((w, h) => {
96
+ onUpdate?.({ width: w, height: h })
97
+ }, [onUpdate])
98
+
99
+ useImperativeHandle(ref, () => ({
100
+ handleAction(actionId) {
101
+ if (actionId === 'flip-layout') {
102
+ const next = layout === 'horizontal' ? 'vertical' : 'horizontal'
103
+ onUpdate?.({ layout: next })
104
+ return true
105
+ } else if (actionId === 'open-external') {
106
+ const story = getStoryData(storyId)
107
+ if (story?._route) {
108
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
109
+ window.open(`${base}${story._route}`, '_blank', 'noopener')
110
+ }
111
+ return true
112
+ }
113
+ },
114
+ }), [storyId, layout, onUpdate])
115
+
116
+ const iframeSrc = useMemo(
117
+ () => resolveStorySetUrl(storyId, layout, selected),
118
+ // storyIndexKey forces re-evaluation when HMR mutates the story index
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps
120
+ [storyId, layout, selected, storyIndexKey],
121
+ )
122
+
123
+ useIframeDevLogs({
124
+ widget: 'StorySetWidget',
125
+ loaded: interactive && Boolean(iframeSrc),
126
+ src: iframeSrc,
127
+ })
128
+
129
+ const displayName = storyId || 'Component Set'
130
+
131
+ if (!storyId) {
132
+ return (
133
+ <WidgetWrapper>
134
+ <div className={styles.container} ref={containerRef}>
135
+ <div className={styles.error}>
136
+ <span className={styles.errorIcon}><GridIcon size={20} /></span>
137
+ <span className={styles.errorText}>Missing story ID</span>
138
+ </div>
139
+ </div>
140
+ </WidgetWrapper>
141
+ )
142
+ }
143
+
144
+ if (!iframeSrc) {
145
+ return (
146
+ <WidgetWrapper>
147
+ <div className={styles.container} ref={containerRef}>
148
+ <div className={styles.error}>
149
+ <span className={styles.errorIcon}><GridIcon size={20} /></span>
150
+ <span className={styles.errorText}>Story &ldquo;{storyId}&rdquo; not found or has no route</span>
151
+ </div>
152
+ </div>
153
+ </WidgetWrapper>
154
+ )
155
+ }
156
+
157
+ const sizeStyle = {}
158
+ if (typeof width === 'number') sizeStyle.width = `${width}px`
159
+ if (typeof height === 'number') sizeStyle.height = `${height}px`
160
+
161
+ return (
162
+ <WidgetWrapper>
163
+ <div ref={containerRef} className={styles.container} style={sizeStyle}>
164
+ <div className={styles.header}>
165
+ <span className={styles.headerIcon}><GridIcon size={16} /></span>
166
+ <span className={styles.headerTitle}>{displayName}</span>
167
+ {selected && (
168
+ <span className={styles.headerSelected}>· {selected}</span>
169
+ )}
170
+ <span className={styles.headerLayout} title={`Layout: ${layout}`}>
171
+ {layout === 'horizontal' ? '⇔' : '⇕'}
172
+ </span>
173
+ </div>
174
+ <div className={styles.content}>
175
+ <iframe
176
+ ref={iframeRef}
177
+ src={iframeSrc}
178
+ className={styles.iframe}
179
+ title={`${displayName} component set`}
180
+ onLoad={(e) => e.target.blur()}
181
+ />
182
+ </div>
183
+ {!interactive && (
184
+ <div
185
+ className={overlayStyles.interactOverlay}
186
+ onClick={(e) => {
187
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
188
+ enterInteractive()
189
+ }}
190
+ role="button"
191
+ tabIndex={0}
192
+ onKeyDown={(e) => {
193
+ if (e.key === 'Enter' || e.key === ' ') {
194
+ e.preventDefault()
195
+ e.stopPropagation()
196
+ enterInteractive()
197
+ }
198
+ }}
199
+ aria-label="Click to interact"
200
+ >
201
+ <span className={overlayStyles.interactHint}>Click to interact</span>
202
+ </div>
203
+ )}
204
+ </div>
205
+ {resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
206
+ </WidgetWrapper>
207
+ )
208
+ })
@@ -0,0 +1,89 @@
1
+ /* StorySetWidget — canvas widget chrome */
2
+
3
+ .container {
4
+ position: relative;
5
+ overflow: hidden;
6
+ min-width: 200px;
7
+ min-height: 120px;
8
+ background: var(--bgColor-default, #ffffff);
9
+ border: 3px solid var(--borderColor-default, #d0d7de);
10
+ border-radius: 12px;
11
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
12
+ width: 100%;
13
+ height: 100%;
14
+ }
15
+
16
+ .header {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 6px;
20
+ padding: 10px 10px;
21
+ font-size: 12px;
22
+ font-weight: 500;
23
+ color: var(--fgColor-muted, #656d76);
24
+ background: var(--bgColor-muted, #f6f8fa);
25
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
26
+ white-space: nowrap;
27
+ overflow: hidden;
28
+ text-overflow: ellipsis;
29
+ user-select: none;
30
+ }
31
+
32
+ .headerIcon {
33
+ display: inline-flex;
34
+ flex-shrink: 0;
35
+ }
36
+
37
+ .headerTitle {
38
+ overflow: hidden;
39
+ text-overflow: ellipsis;
40
+ }
41
+
42
+ .headerSelected {
43
+ color: var(--fgColor-accent, #0969da);
44
+ font-weight: 600;
45
+ flex-shrink: 0;
46
+ }
47
+
48
+ .headerLayout {
49
+ margin-left: auto;
50
+ font-size: 14px;
51
+ opacity: 0.5;
52
+ flex-shrink: 0;
53
+ }
54
+
55
+ .content {
56
+ position: relative;
57
+ width: 100%;
58
+ height: calc(100% - 37px);
59
+ }
60
+
61
+ .iframe {
62
+ position: absolute;
63
+ inset: 0;
64
+ display: block;
65
+ width: 100%;
66
+ height: 100%;
67
+ border: none;
68
+ z-index: 1;
69
+ }
70
+
71
+ .error {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 8px;
75
+ padding: 16px;
76
+ color: var(--fgColor-danger, #cf222e);
77
+ font-family: system-ui, -apple-system, sans-serif;
78
+ font-size: 13px;
79
+ line-height: 1.5;
80
+ }
81
+
82
+ .errorIcon {
83
+ font-size: 20px;
84
+ flex-shrink: 0;
85
+ }
86
+
87
+ .errorText {
88
+ word-break: break-word;
89
+ }
@@ -26,13 +26,12 @@ function ComponentIcon({ size = 36 }) {
26
26
 
27
27
  function resolveStoryUrl(storyId, exportName) {
28
28
  const story = getStoryData(storyId)
29
- if (!story?._route) return ''
29
+ if (!story?._storyModule) return ''
30
30
  const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
31
31
  const params = new URLSearchParams()
32
+ params.set('module', story._storyModule)
32
33
  if (exportName) params.set('export', exportName)
33
- params.set('_sb_embed', '')
34
- params.set('_sb_hide_branch_bar', '')
35
- return `${base}${story._route}?${params}`
34
+ return `${base}/_storyboard/canvas/isolate?${params}`
36
35
  }
37
36
 
38
37
  const _storySourcesCache = {}
@@ -5,6 +5,8 @@ import { getTerminalConfig, getTerminalDimensions } from '@dfosco/storyboard-cor
5
5
  import { useOverride } from '../../hooks/useOverride.js'
6
6
  import { getSplitPaneLabel, findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
7
7
  import ExpandedPane from './ExpandedPane.jsx'
8
+ import { useWebGLSlot, Priority } from '../WebGLContextPool.jsx'
9
+ import FrozenTerminalOverlay from './FrozenTerminalOverlay.jsx'
8
10
  import styles from './TerminalWidget.module.css'
9
11
  import overlayStyles from './embedOverlay.module.css'
10
12
 
@@ -106,17 +108,16 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
106
108
  const cfg = getTerminalConfig()
107
109
  const fontSize = cfg.fontSize ?? 13
108
110
  const agentId = props?.agentId || null
111
+ // Config dimensions are authoritative — always use them as the base
109
112
  const dims = getTerminalDimensions(agentId, {
110
113
  width: readProp(props, 'width', terminalSchema),
111
114
  height: readProp(props, 'height', terminalSchema),
112
115
  })
113
- const rawWidth = props?.width ?? dims.width
114
- const rawHeight = props?.height ?? dims.height
116
+ const width = dims.width
117
+ const height = dims.height
115
118
  const prettyName = props?.prettyName || null
116
119
  const startupCommand = props?.startupCommand || null
117
120
 
118
- const width = rawWidth
119
- const height = rawHeight
120
121
  // Snapped dimensions computed from ghostty's actual cell metrics (set after open)
121
122
  const [snappedHeight, setSnappedHeight] = useState(null)
122
123
  const [snappedWidth, setSnappedWidth] = useState(null)
@@ -145,6 +146,25 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
145
146
  const expandContainerRef = useRef(null)
146
147
  const dragHintTimer = useRef(null)
147
148
 
149
+ // ── WebGL context pool integration ──
150
+ // webglReady: PINNED (bypass cap, guaranteed live — no frozen flash)
151
+ // All others: VISIBLE (auto-requests a live slot — no manual click needed)
152
+ const initialPriority = props?.webglReady ? Priority.PINNED : Priority.VISIBLE
153
+ const { isLive, generation, setPriority } = useWebGLSlot(id, initialPriority)
154
+
155
+ // Update pool priority based on widget state
156
+ useEffect(() => {
157
+ if (expanded || interactive) {
158
+ setPriority(Priority.PINNED)
159
+ }
160
+ // Priority for VISIBLE/NEAR/OFFSCREEN is set by CanvasPage via usePoolVisibilityUpdater
161
+ }, [expanded, interactive, setPriority])
162
+
163
+ // Request activation when user clicks a frozen terminal
164
+ const handleFrozenActivate = useCallback(() => {
165
+ setPriority(Priority.PINNED)
166
+ }, [setPriority])
167
+
148
168
  useImperativeHandle(ref, () => ({
149
169
  handleAction(actionId) {
150
170
  if (actionId === 'expand') { setExpanded('single'); return true }
@@ -179,8 +199,9 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
179
199
  if (multiSelected && interactive) setInteractive(false)
180
200
  }, [multiSelected])
181
201
 
182
- // Connect terminal + WebSocket
202
+ // Connect terminal + WebSocket (only when pool grants a live slot)
183
203
  useEffect(() => {
204
+ if (!isLive) return
184
205
  if (!containerRef.current) return
185
206
 
186
207
  let disposed = false
@@ -308,8 +329,10 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
308
329
  if (term) term.dispose()
309
330
  termRef.current = null
310
331
  wsRef.current = null
332
+ setReady(false)
333
+ setRevealed(false)
311
334
  }
312
- }, [id, connectAttempt])
335
+ }, [id, isLive, generation, connectAttempt])
313
336
 
314
337
  // Resize terminal on dimension changes
315
338
  useEffect(() => {
@@ -472,112 +495,135 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
472
495
  ref={terminalRef}
473
496
  className={styles.terminal}
474
497
  style={{
475
- ...(typeof (snappedWidth ?? width) === 'number' ? { width: `${snappedWidth ?? width}px` } : undefined),
476
- ...(typeof (snappedHeight ?? height) === 'number' ? { height: `${snappedHeight ?? height}px` } : undefined),
498
+ ...(typeof (isLive ? (snappedWidth ?? width) : width) === 'number'
499
+ ? { width: `${isLive ? (snappedWidth ?? width) : width}px` }
500
+ : undefined),
501
+ ...(typeof (isLive ? (snappedHeight ?? height) : height) === 'number'
502
+ ? { height: `${isLive ? (snappedHeight ?? height) : height}px` }
503
+ : undefined),
477
504
  }}
478
505
  onClick={handleClick}
479
506
  onPointerDown={handleTerminalPointerDown}
480
507
  onKeyDown={interactive ? (e) => e.stopPropagation() : undefined}
481
508
  >
482
- {showDragHint && (
483
- <div className={styles.dragHint}>
484
- <span className={styles.dragHintArrow}>←</span> Drag here to move widget
485
- </div>
486
- )}
487
- {error && !sessionEnded && (
488
- <div className={styles.error}>
489
- <span>⚠ {error}</span>
490
- </div>
491
- )}
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>
509
+ {/* ── Frozen state: WebGL context released, show snapshot ── */}
510
+ {!isLive && (
511
+ <FrozenTerminalOverlay
512
+ widgetId={id}
513
+ onActivate={handleFrozenActivate}
514
+ />
511
515
  )}
512
516
 
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.
517
+ {/* ── Live state: ghostty WebGL terminal ── */}
518
+ {isLive && (
519
+ <>
520
+ {showDragHint && (
521
+ <div className={styles.dragHint}>
522
+ <span className={styles.dragHintArrow}>←</span> Drag here to move widget
523
+ </div>
524
+ )}
525
+ {error && !sessionEnded && (
526
+ <div className={styles.error}>
527
+ <span>⚠ {error}</span>
528
+ </div>
529
+ )}
530
+ <div ref={containerRef} className={styles.xtermContainer} style={{ opacity: revealed ? 1 : 0 }} />
531
+
532
+ {/* Live but not interactive */}
533
+ {revealed && !interactive && !sessionEnded && (
534
+ <div
535
+ className={overlayStyles.interactOverlay}
536
+ style={{ backgroundColor: 'transparent' }}
537
+ onClick={(e) => {
538
+ if (multiSelected || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
539
+ setInteractive(true)
540
+ termRef.current?.focus({ preventScroll: true })
541
+ }}
542
+ role="button"
543
+ tabIndex={0}
544
+ onKeyDown={(e) => { if (!multiSelected && e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
545
+ aria-label="Click to interact"
546
+ >
547
+ {!multiSelected && <span className={overlayStyles.interactHint}>Click to interact</span>}
548
+ </div>
549
+ )}
550
+
551
+ {/* Session ended — resource limited */}
552
+ {sessionEnded && resourceLimited && (
553
+ <div
554
+ className={overlayStyles.interactOverlay}
555
+ style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: '8px', padding: '24px' }}
556
+ >
557
+ <span className={styles.resourceIcon}>⚠</span>
558
+ <span className={styles.resourceTitle}>No terminal devices available</span>
559
+ <span className={styles.resourceMessage}>
560
+ Too many terminal sessions are open.
561
+ {resourceLimited.counts && (
562
+ <span className={styles.resourceCounts}>
563
+ {resourceLimited.counts.live} live · {resourceLimited.counts.background} background · {resourceLimited.counts.archived} archived
564
+ </span>
565
+ )}
543
566
  </span>
544
- )}
545
- <button className={styles.resourceBtnSecondary} onClick={handleStartSession}>
546
- Retry
547
- </button>
548
- </div>
549
- </div>
550
- )}
567
+ <div className={styles.resourceActions}>
568
+ {!resourceLimited.cleanupResult && (resourceLimited.counts?.background > 0 || resourceLimited.counts?.archived > 0) && (
569
+ <button className={styles.resourceBtn} onClick={handleCleanupAndRetry}>
570
+ Close background sessions
571
+ </button>
572
+ )}
573
+ {resourceLimited.cleanupResult === 'nothing-to-clean' && (
574
+ <span className={styles.resourceMuted}>
575
+ All background sessions already cleaned. Close some live terminals to free resources.
576
+ </span>
577
+ )}
578
+ {resourceLimited.cleanupResult === 'failed' && (
579
+ <span className={styles.resourceMuted}>
580
+ Cleanup failed — could not reach dev server.
581
+ </span>
582
+ )}
583
+ <button className={styles.resourceBtnSecondary} onClick={handleStartSession}>
584
+ Retry
585
+ </button>
586
+ </div>
587
+ </div>
588
+ )}
551
589
 
552
- {/* Session ended — normal (zzz) */}
553
- {sessionEnded && !resourceLimited && (
554
- <div
555
- className={overlayStyles.interactOverlay}
556
- style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: 0 }}
557
- onClick={handleStartSession}
558
- role="button"
559
- tabIndex={0}
560
- aria-label="Start terminal session"
561
- onKeyDown={(e) => { if (e.key === 'Enter') handleStartSession() }}
562
- >
563
- {!waking && (
564
- <div className={styles.buddyZzz}>
565
- <span className={styles.z1}>z</span>
566
- <span className={styles.z2}>z</span>
567
- <span className={styles.z3}>z</span>
590
+ {/* Session ended — normal */}
591
+ {sessionEnded && !resourceLimited && (
592
+ <div
593
+ className={overlayStyles.interactOverlay}
594
+ style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: 0 }}
595
+ onClick={handleStartSession}
596
+ role="button"
597
+ tabIndex={0}
598
+ aria-label="Start terminal session"
599
+ onKeyDown={(e) => { if (e.key === 'Enter') handleStartSession() }}
600
+ >
601
+ {!waking && (
602
+ <>
603
+ <div className={styles.buddyZzz}>
604
+ <span className={styles.z1}>z</span>
605
+ <span className={styles.z2}>z</span>
606
+ <span className={styles.z3}>z</span>
607
+ </div>
608
+ <span className={styles.sessionEndedBadge}>Session ended</span>
609
+ <span className={styles.sessionEndedAction}>Click to start</span>
610
+ </>
611
+ )}
612
+ {waking && (
613
+ <span className={overlayStyles.interactHint} style={{ opacity: 1 }}>
614
+ Waking up...
615
+ </span>
616
+ )}
568
617
  </div>
569
618
  )}
570
- <span className={overlayStyles.interactHint}>
571
- {waking ? 'Waking up...' : connectAttempt > 0 ? 'Continue terminal session' : 'Start terminal session'}
572
- </span>
573
- </div>
574
- )}
575
619
 
576
- {/* Connecting / reveal mask */}
577
- {!revealed && !error && !sessionEnded && (
578
- <div className={styles.loading}>
579
- <div className={styles.spinner} />
580
- </div>
620
+ {/* Connecting / reveal mask */}
621
+ {!revealed && !error && !sessionEnded && (
622
+ <div className={styles.loading}>
623
+ <div className={styles.spinner} />
624
+ </div>
625
+ )}
626
+ </>
581
627
  )}
582
628
  </div>
583
629
  </div>
@@ -204,6 +204,29 @@
204
204
  }
205
205
  }
206
206
 
207
+ /* ── Session-ended badge + action ── */
208
+
209
+ .sessionEndedBadge {
210
+ display: inline-flex;
211
+ align-items: center;
212
+ background: rgba(110, 118, 129, 0.25);
213
+ color: #8b949e;
214
+ font-size: 12px;
215
+ padding: 5px 14px;
216
+ border-radius: 999px;
217
+ letter-spacing: 0.01em;
218
+ pointer-events: none;
219
+ margin-bottom: 12px;
220
+ }
221
+
222
+ .sessionEndedAction {
223
+ color: #e6edf3;
224
+ font-size: 14px;
225
+ font-weight: 600;
226
+ opacity: 0.7;
227
+ pointer-events: none;
228
+ }
229
+
207
230
  /* ── Resource-limited overlay ── */
208
231
 
209
232
  .resourceIcon {