@dfosco/storyboard-react 4.1.0 → 4.2.0-alpha.10

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,193 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+ import { readProp } from './widgetProps.js'
3
+ import { schemas } from './widgetProps.js'
4
+ import ResizeHandle from './ResizeHandle.jsx'
5
+ import styles from './ActionWidget.module.css'
6
+
7
+ const actionSchema = schemas['action']
8
+
9
+ /**
10
+ * ActionWidget — a canvas widget that runs a background agent.
11
+ *
12
+ * Displays a "Run" button. When clicked, spawns a headless tmux+copilot
13
+ * session via the /agent/spawn endpoint. Shows status indicators
14
+ * (running/done/error) and allows peeking into errored sessions.
15
+ */
16
+ export default function ActionWidget({ id, props, onUpdate, resizable }) {
17
+ const width = readProp(props, 'width', actionSchema)
18
+ const height = readProp(props, 'height', actionSchema)
19
+ const prompt = readProp(props, 'prompt', actionSchema) || ''
20
+ const label = readProp(props, 'label', actionSchema) || 'Run Agent'
21
+
22
+ const [status, setStatus] = useState('idle') // idle | running | done | error
23
+ const [message, setMessage] = useState(null)
24
+
25
+ // Listen for agent status updates via Vite HMR custom events
26
+ useEffect(() => {
27
+ if (!import.meta.hot) return
28
+
29
+ const handler = (data) => {
30
+ if (data.widgetId === id) {
31
+ setStatus(data.status)
32
+ setMessage(data.message || null)
33
+ }
34
+ }
35
+
36
+ import.meta.hot.on('storyboard:agent-status', handler)
37
+ return () => {
38
+ // Vite HMR doesn't support removeListener, but cleanup on unmount
39
+ }
40
+ }, [id])
41
+
42
+ // Poll for status on mount (in case we missed a WS event)
43
+ useEffect(() => {
44
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
45
+ const baseClean = base.endsWith('/') ? base : base + '/'
46
+
47
+ fetch(`${baseClean}_storyboard/canvas/agent/status?widgetId=${id}`)
48
+ .then((r) => r.json())
49
+ .then((data) => {
50
+ if (data.agentStatus?.status) {
51
+ setStatus(data.agentStatus.status)
52
+ setMessage(data.agentStatus.message || null)
53
+ }
54
+ })
55
+ .catch(() => {})
56
+ }, [id])
57
+
58
+ const handleRun = useCallback(async () => {
59
+ if (status === 'running') return
60
+
61
+ setStatus('running')
62
+ setMessage('Spawning agent...')
63
+
64
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
65
+ const baseClean = base.endsWith('/') ? base : base + '/'
66
+
67
+ try {
68
+ const res = await fetch(`${baseClean}_storyboard/canvas/agent/spawn`, {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({
72
+ canvasId: window.__storyboardCanvasBridgeState?.canvasId || 'unknown',
73
+ widgetId: id,
74
+ prompt,
75
+ autopilot: true,
76
+ }),
77
+ })
78
+
79
+ if (!res.ok) {
80
+ const data = await res.json().catch(() => ({}))
81
+ setStatus('error')
82
+ setMessage(data.error || 'Spawn failed')
83
+ }
84
+ } catch (err) {
85
+ setStatus('error')
86
+ setMessage(err.message || 'Connection failed')
87
+ }
88
+ }, [id, prompt, status])
89
+
90
+ const handlePeek = useCallback(async () => {
91
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
92
+ const baseClean = base.endsWith('/') ? base : base + '/'
93
+
94
+ try {
95
+ const res = await fetch(`${baseClean}_storyboard/canvas/agent/peek`, {
96
+ method: 'POST',
97
+ headers: { 'Content-Type': 'application/json' },
98
+ body: JSON.stringify({
99
+ widgetId: id,
100
+ canvasId: window.__storyboardCanvasBridgeState?.canvasId || 'unknown',
101
+ }),
102
+ })
103
+
104
+ if (res.ok) {
105
+ setMessage('Session opened — check the new terminal widget')
106
+ } else {
107
+ const data = await res.json().catch(() => ({}))
108
+ setMessage(data.error || 'Peek failed')
109
+ }
110
+ } catch (err) {
111
+ setMessage(err.message || 'Connection failed')
112
+ }
113
+ }, [id])
114
+
115
+ const handleDismiss = useCallback(() => {
116
+ setStatus('idle')
117
+ setMessage(null)
118
+ }, [])
119
+
120
+ const handleResize = useCallback((w, h) => {
121
+ onUpdate?.({ width: w, height: h })
122
+ }, [onUpdate])
123
+
124
+ const statusIcon = {
125
+ idle: '⚡',
126
+ running: '⏳',
127
+ done: '✓',
128
+ error: '!',
129
+ }
130
+
131
+ const statusClass = {
132
+ idle: styles.idle,
133
+ running: styles.running,
134
+ done: styles.done,
135
+ error: styles.error,
136
+ }
137
+
138
+ return (
139
+ <div
140
+ className={`${styles.container} ${statusClass[status] || ''}`}
141
+ style={{
142
+ ...(typeof width === 'number' ? { width: `${width}px` } : undefined),
143
+ ...(typeof height === 'number' ? { height: `${height}px` } : undefined),
144
+ }}
145
+ >
146
+ <div className={styles.header}>
147
+ <span className={styles.icon}>{statusIcon[status]}</span>
148
+ <span className={styles.label}>{label}</span>
149
+ </div>
150
+
151
+ {prompt && (
152
+ <div className={styles.prompt}>
153
+ {prompt.length > 100 ? prompt.slice(0, 100) + '…' : prompt}
154
+ </div>
155
+ )}
156
+
157
+ <div className={styles.actions}>
158
+ {(status === 'idle' || status === 'done') && (
159
+ <button className={styles.runButton} onClick={handleRun}>
160
+ {status === 'done' ? 'Run Again' : 'Run'}
161
+ </button>
162
+ )}
163
+
164
+ {status === 'running' && (
165
+ <div className={styles.spinner}>Running…</div>
166
+ )}
167
+
168
+ {status === 'error' && (
169
+ <div className={styles.errorActions}>
170
+ <button className={styles.peekButton} onClick={handlePeek}>
171
+ Peek Session
172
+ </button>
173
+ <button className={styles.dismissButton} onClick={handleDismiss}>
174
+ Dismiss
175
+ </button>
176
+ </div>
177
+ )}
178
+ </div>
179
+
180
+ {message && (
181
+ <div className={styles.message}>{message}</div>
182
+ )}
183
+
184
+ {resizable && (
185
+ <ResizeHandle
186
+ onResize={handleResize}
187
+ minWidth={200}
188
+ minHeight={120}
189
+ />
190
+ )}
191
+ </div>
192
+ )
193
+ }
@@ -0,0 +1,122 @@
1
+ .container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 8px;
5
+ padding: 16px;
6
+ border-radius: 8px;
7
+ background: var(--bgColor-default, #0d1117);
8
+ border: 1px solid var(--borderColor-default, #30363d);
9
+ color: var(--fgColor-default, #e6edf3);
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
11
+ font-size: 13px;
12
+ overflow: hidden;
13
+ }
14
+
15
+ .container.running {
16
+ border-color: var(--borderColor-accent-emphasis, #58a6ff);
17
+ }
18
+
19
+ .container.done {
20
+ border-color: var(--borderColor-success-emphasis, #3fb950);
21
+ }
22
+
23
+ .container.error {
24
+ border-color: var(--borderColor-danger-emphasis, #f85149);
25
+ }
26
+
27
+ .header {
28
+ display: flex;
29
+ align-items: center;
30
+ gap: 8px;
31
+ font-weight: 600;
32
+ font-size: 14px;
33
+ }
34
+
35
+ .icon {
36
+ font-size: 16px;
37
+ }
38
+
39
+ .label {
40
+ flex: 1;
41
+ overflow: hidden;
42
+ text-overflow: ellipsis;
43
+ white-space: nowrap;
44
+ }
45
+
46
+ .prompt {
47
+ color: var(--fgColor-muted, #8b949e);
48
+ font-size: 12px;
49
+ line-height: 1.4;
50
+ overflow: hidden;
51
+ display: -webkit-box;
52
+ -webkit-line-clamp: 3;
53
+ -webkit-box-orient: vertical;
54
+ }
55
+
56
+ .actions {
57
+ display: flex;
58
+ gap: 8px;
59
+ margin-top: auto;
60
+ }
61
+
62
+ .runButton {
63
+ padding: 6px 16px;
64
+ border-radius: 6px;
65
+ border: none;
66
+ background: var(--bgColor-accent-emphasis, #1f6feb);
67
+ color: #fff;
68
+ font-size: 13px;
69
+ font-weight: 500;
70
+ cursor: pointer;
71
+ transition: background 0.15s;
72
+ }
73
+
74
+ .runButton:hover {
75
+ background: var(--bgColor-accent-emphasis, #388bfd);
76
+ }
77
+
78
+ .spinner {
79
+ color: var(--fgColor-accent, #58a6ff);
80
+ font-size: 12px;
81
+ }
82
+
83
+ .errorActions {
84
+ display: flex;
85
+ gap: 8px;
86
+ }
87
+
88
+ .peekButton {
89
+ padding: 4px 12px;
90
+ border-radius: 6px;
91
+ border: 1px solid var(--borderColor-danger-emphasis, #f85149);
92
+ background: transparent;
93
+ color: var(--fgColor-danger, #f85149);
94
+ font-size: 12px;
95
+ cursor: pointer;
96
+ }
97
+
98
+ .peekButton:hover {
99
+ background: var(--bgColor-danger-muted, rgba(248, 81, 73, 0.1));
100
+ }
101
+
102
+ .dismissButton {
103
+ padding: 4px 12px;
104
+ border-radius: 6px;
105
+ border: 1px solid var(--borderColor-default, #30363d);
106
+ background: transparent;
107
+ color: var(--fgColor-muted, #8b949e);
108
+ font-size: 12px;
109
+ cursor: pointer;
110
+ }
111
+
112
+ .dismissButton:hover {
113
+ background: var(--bgColor-muted, #161b22);
114
+ }
115
+
116
+ .message {
117
+ color: var(--fgColor-muted, #8b949e);
118
+ font-size: 11px;
119
+ overflow: hidden;
120
+ text-overflow: ellipsis;
121
+ white-space: nowrap;
122
+ }
@@ -68,6 +68,7 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
68
68
  const content = readProp(props, 'content', markdownSchema)
69
69
  const width = readProp(props, 'width', markdownSchema)
70
70
  const height = props?.height
71
+ const collapsed = !!props?.collapsed
71
72
  const canEdit = typeof onUpdate === 'function'
72
73
  const [editing, setEditing] = useState(false)
73
74
  const editingActive = canEdit && editing
@@ -121,7 +122,14 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
121
122
  setEditHeight(blockRef.current.offsetHeight)
122
123
  }
123
124
  if (textareaRef.current) {
124
- textareaRef.current.focus()
125
+ // Place cursor at end and prevent scroll jump to top
126
+ const len = textareaRef.current.value.length
127
+ textareaRef.current.setSelectionRange(len, len)
128
+ textareaRef.current.focus({ preventScroll: true })
129
+ // Restore the block's scroll position (captured before React swapped the DOM)
130
+ if (blockRef.current) {
131
+ blockRef.current.scrollTop = blockRef.current.dataset.scrollTop || 0
132
+ }
125
133
  }
126
134
  } else {
127
135
  setEditHeight(null)
@@ -132,15 +140,19 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
132
140
  <WidgetWrapper>
133
141
  <div
134
142
  ref={blockRef}
135
- className={styles.block}
136
- style={{ width, ...(height ? { height, overflow: 'auto' } : {}), minHeight: editHeight || undefined }}
143
+ className={`${styles.block}${collapsed && !editingActive ? ` ${styles.blockCollapsed}` : ''}`}
144
+ style={{
145
+ width,
146
+ ...(height ? { height, overflow: 'auto' } : {}),
147
+ ...(editHeight ? { height: editHeight, display: 'flex', flexDirection: 'column' } : {}),
148
+ }}
137
149
  >
138
150
  {editingActive ? (
139
151
  <textarea
140
152
  ref={textareaRef}
141
153
  className={styles.editor}
142
154
  data-canvas-allow-text-selection
143
- style={{ minHeight: editHeight ? editHeight - 2 : undefined }}
155
+ style={{ flex: 1 }}
144
156
  value={content}
145
157
  onChange={handleContentChange}
146
158
  onBlur={() => setEditing(false)}
@@ -158,10 +170,19 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
158
170
  data-canvas-allow-text-selection={!canEdit ? '' : undefined}
159
171
  onClick={!canEdit ? (e) => e.stopPropagation() : undefined}
160
172
  onCopy={!canEdit ? handleReadOnlyCopy : undefined}
161
- onDoubleClick={canEdit ? () => setEditing(true) : undefined}
173
+ onDoubleClick={canEdit ? () => {
174
+ // Save scroll position before switching to editor
175
+ if (blockRef.current) blockRef.current.dataset.scrollTop = blockRef.current.scrollTop
176
+ setEditing(true)
177
+ } : undefined}
162
178
  role={canEdit ? 'button' : undefined}
163
179
  tabIndex={canEdit ? 0 : undefined}
164
- onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') setEditing(true) } : undefined}
180
+ onKeyDown={canEdit ? (e) => {
181
+ if (e.key === 'Enter') {
182
+ if (blockRef.current) blockRef.current.dataset.scrollTop = blockRef.current.scrollTop
183
+ setEditing(true)
184
+ }
185
+ } : undefined}
165
186
  dangerouslySetInnerHTML={{
166
187
  __html: renderedHtml || (canEdit
167
188
  ? '<p class="placeholder">Double-click to edit…</p>'
@@ -10,6 +10,16 @@
10
10
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
11
11
  }
12
12
 
13
+ .blockCollapsed {
14
+ max-height: 360px;
15
+ overflow: hidden;
16
+ }
17
+
18
+ .blockCollapsed .preview {
19
+ mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
20
+ -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
21
+ }
22
+
13
23
  .preview {
14
24
  padding: 16px 20px;
15
25
  font-size: 14px;
@@ -206,7 +216,7 @@
206
216
  width: 100%;
207
217
  height: 100%;
208
218
  box-sizing: border-box;
209
- min-height: 120px;
219
+
210
220
  padding: 16px 20px;
211
221
  border: none;
212
222
  outline: none;
@@ -12,6 +12,8 @@
12
12
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
13
13
  overflow: auto;
14
14
  position: relative;
15
+ display: flex;
16
+ flex-direction: column;
15
17
  }
16
18
 
17
19
  :global([data-sb-canvas-theme^='dark']) .sticky {
@@ -35,6 +37,7 @@
35
37
  word-break: break-word;
36
38
  cursor: text;
37
39
  min-height: 60px;
40
+ flex: 1;
38
41
  }
39
42
 
40
43
  :global([data-sb-canvas-theme^='dark']) .text {
@@ -0,0 +1,280 @@
1
+ import { useRef, useEffect, useCallback, useState } from 'react'
2
+ import { readProp } from './widgetProps.js'
3
+ import { schemas } from './widgetProps.js'
4
+ import { getTerminalConfig } from '@dfosco/storyboard-core'
5
+ import ResizeHandle from './ResizeHandle.jsx'
6
+ import styles from './TerminalWidget.module.css'
7
+ import overlayStyles from './embedOverlay.module.css'
8
+
9
+ const terminalSchema = schemas['terminal']
10
+
11
+ /**
12
+ * Lazy-load ghostty-web to avoid bundling WASM in prod.
13
+ */
14
+ let ghosttyPromise = null
15
+ function loadGhostty() {
16
+ if (!ghosttyPromise) {
17
+ ghosttyPromise = import('ghostty-web')
18
+ .then(async (mod) => {
19
+ if (mod.init) await mod.init()
20
+ return mod
21
+ })
22
+ .catch((err) => {
23
+ ghosttyPromise = null
24
+ console.warn('[TerminalWidget] ghostty-web not available:', err.message)
25
+ return null
26
+ })
27
+ }
28
+ return ghosttyPromise
29
+ }
30
+
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) {
37
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
38
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
39
+ const baseClean = base.endsWith('/') ? base : base + '/'
40
+ const canvasId = window.__storyboardCanvasBridgeState?.canvasId || 'unknown'
41
+ let url = `${protocol}//${location.host}${baseClean}_storyboard/terminal/${sessionId}?canvas=${encodeURIComponent(canvasId)}`
42
+ if (prettyName) url += `&name=${encodeURIComponent(prettyName)}`
43
+ return url
44
+ }
45
+
46
+ /**
47
+ * Calculate terminal cols/rows from pixel dimensions.
48
+ */
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 }
57
+ }
58
+
59
+ const DEFAULT_THEME = {
60
+ background: '#0d1117',
61
+ foreground: '#e6edf3',
62
+ cursor: '#e6edf3',
63
+ selectionBackground: '#264f78',
64
+ black: '#484f58',
65
+ red: '#ff7b72',
66
+ green: '#3fb950',
67
+ yellow: '#d29922',
68
+ blue: '#58a6ff',
69
+ magenta: '#bc8cff',
70
+ cyan: '#39d2c0',
71
+ white: '#b1bac4',
72
+ brightBlack: '#6e7681',
73
+ brightRed: '#ffa198',
74
+ brightGreen: '#56d364',
75
+ brightYellow: '#e3b341',
76
+ brightBlue: '#79c0ff',
77
+ brightMagenta: '#d2a8ff',
78
+ brightCyan: '#56d4dd',
79
+ brightWhite: '#f0f6fc',
80
+ }
81
+
82
+ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
83
+ const width = readProp(props, 'width', terminalSchema)
84
+ const height = readProp(props, 'height', terminalSchema)
85
+ const prettyName = props?.prettyName || null
86
+
87
+ const containerRef = useRef(null)
88
+ const termRef = useRef(null)
89
+ const terminalRef = useRef(null)
90
+ const wsRef = useRef(null)
91
+ const [ready, setReady] = useState(false)
92
+ const [error, setError] = useState(null)
93
+ const [sessionEnded, setSessionEnded] = useState(false)
94
+ const [connectAttempt, setConnectAttempt] = useState(0)
95
+
96
+ const handleResize = useCallback((w, h) => {
97
+ onUpdate?.({ width: w, height: h })
98
+ }, [onUpdate])
99
+
100
+ // Initialize terminal
101
+ useEffect(() => {
102
+ if (!containerRef.current) return
103
+
104
+ let disposed = false
105
+ let term = null
106
+ let ws = null
107
+
108
+ async function setup() {
109
+ try {
110
+ const ghostty = await loadGhostty()
111
+ if (disposed || !ghostty) return
112
+
113
+ const dims = calcDimensions(width, height)
114
+ const cfg = getTerminalConfig()
115
+
116
+ term = new ghostty.Terminal({
117
+ fontSize: cfg.fontSize ?? 13,
118
+ fontFamily: cfg.fontFamily ?? "'Ghostty', 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace",
119
+ cursorBlink: true,
120
+ cursorStyle: 'bar',
121
+ cols: dims.cols,
122
+ rows: dims.rows,
123
+ theme: { ...DEFAULT_THEME, ...cfg.theme },
124
+ })
125
+
126
+ term.open(containerRef.current)
127
+ termRef.current = term
128
+
129
+ // Connect WebSocket
130
+ const url = getWsUrl(id, prettyName)
131
+ ws = new WebSocket(url)
132
+ wsRef.current = ws
133
+
134
+ ws.onopen = () => {
135
+ if (disposed) return
136
+ setReady(true)
137
+ ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
138
+ }
139
+
140
+ ws.onmessage = (e) => {
141
+ if (disposed) return
142
+ const data = typeof e.data === 'string' ? e.data : null
143
+ // Intercept JSON control messages from the server
144
+ if (data && data.startsWith('{')) {
145
+ try {
146
+ 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
149
+ return
150
+ }
151
+ } catch {
152
+ // Not valid JSON — pass through as terminal data
153
+ }
154
+ }
155
+ term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data))
156
+ }
157
+
158
+ ws.onclose = () => {
159
+ if (disposed) return
160
+ setReady(false)
161
+ setSessionEnded(true)
162
+ }
163
+
164
+ ws.onerror = () => {
165
+ if (disposed) return
166
+ setReady(false)
167
+ setSessionEnded(true)
168
+ }
169
+
170
+ // Terminal input → WebSocket
171
+ term.onData((data) => {
172
+ if (ws.readyState === WebSocket.OPEN) {
173
+ ws.send(data)
174
+ }
175
+ })
176
+ } catch (err) {
177
+ if (!disposed) setError(err.message || 'Failed to load terminal')
178
+ }
179
+ }
180
+
181
+ setup()
182
+
183
+ return () => {
184
+ disposed = true
185
+ if (ws && ws.readyState <= WebSocket.OPEN) ws.close()
186
+ if (term) term.dispose()
187
+ termRef.current = null
188
+ wsRef.current = null
189
+ }
190
+ }, [id, connectAttempt])
191
+
192
+ // Resize terminal on dimension changes
193
+ useEffect(() => {
194
+ if (!termRef.current) return
195
+ const timer = setTimeout(() => {
196
+ const dims = calcDimensions(width, height)
197
+ termRef.current?.resize?.(dims.cols, dims.rows)
198
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
199
+ wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
200
+ }
201
+ }, 50)
202
+ return () => clearTimeout(timer)
203
+ }, [width, height])
204
+
205
+ const handleClick = useCallback(() => {
206
+ if (sessionEnded) return
207
+ termRef.current?.focus()
208
+ }, [sessionEnded])
209
+
210
+ const [waking, setWaking] = useState(false)
211
+
212
+ const handleStartSession = useCallback(() => {
213
+ setWaking(true)
214
+ setTimeout(() => {
215
+ setWaking(false)
216
+ setSessionEnded(false)
217
+ setError(null)
218
+ setConnectAttempt(c => c + 1)
219
+ }, 1500)
220
+ }, [])
221
+
222
+ // Show interact gate when session is ready but not interacting
223
+
224
+ const titleLabel = `terminal · ${prettyName || '...'}`
225
+
226
+ return (
227
+ <div className={styles.container}>
228
+ <div className={styles.titleBar}>{titleLabel}</div>
229
+ <div
230
+ ref={terminalRef}
231
+ className={styles.terminal}
232
+ style={{
233
+ ...(typeof width === 'number' ? { width: `${width}px` } : undefined),
234
+ ...(typeof height === 'number' ? { height: `${height}px` } : undefined),
235
+ }}
236
+ onClick={handleClick}
237
+ >
238
+ {error && !sessionEnded && (
239
+ <div className={styles.error}>
240
+ <span>⚠ {error}</span>
241
+ </div>
242
+ )}
243
+ <div ref={containerRef} className={styles.xtermContainer} />
244
+ {sessionEnded && (
245
+ <div
246
+ className={overlayStyles.interactOverlay}
247
+ style={{ backgroundColor: '#0d1117', flexDirection: 'column', gap: 0 }}
248
+ onClick={handleStartSession}
249
+ role="button"
250
+ tabIndex={0}
251
+ aria-label="Start terminal session"
252
+ onKeyDown={(e) => { if (e.key === 'Enter') handleStartSession() }}
253
+ >
254
+ {!waking && (
255
+ <div className={styles.buddyZzz}>
256
+ <span className={styles.z1}>z</span>
257
+ <span className={styles.z2}>z</span>
258
+ <span className={styles.z3}>z</span>
259
+ </div>
260
+ )}
261
+ <span className={overlayStyles.interactHint}>
262
+ {waking ? 'Waking up...' : 'Start terminal session'}
263
+ </span>
264
+ </div>
265
+ )}
266
+ {!ready && !error && !sessionEnded && (
267
+ <div className={styles.loading}>Connecting…</div>
268
+ )}
269
+ </div>
270
+ {resizable && (
271
+ <ResizeHandle
272
+ targetRef={terminalRef}
273
+ onResize={handleResize}
274
+ minWidth={300}
275
+ minHeight={200}
276
+ />
277
+ )}
278
+ </div>
279
+ )
280
+ }