@dfosco/storyboard-react 4.1.0 → 4.2.0-alpha.5
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.
- package/package.json +10 -4
- package/src/CommandPalette/CommandPalette.jsx +69 -5
- package/src/CommandPalette/command-palette.css +5 -0
- package/src/canvas/CanvasPage.jsx +333 -10
- package/src/canvas/CanvasPage.module.css +26 -4
- package/src/canvas/ConnectorLayer.jsx +252 -0
- package/src/canvas/ConnectorLayer.module.css +60 -0
- package/src/canvas/PageSelector.jsx +376 -37
- package/src/canvas/PageSelector.module.css +93 -6
- package/src/canvas/canvasApi.js +35 -0
- package/src/canvas/widgets/ActionWidget.jsx +193 -0
- package/src/canvas/widgets/ActionWidget.module.css +122 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +27 -6
- package/src/canvas/widgets/MarkdownBlock.module.css +11 -1
- package/src/canvas/widgets/StickyNote.module.css +3 -0
- package/src/canvas/widgets/TerminalWidget.jsx +280 -0
- package/src/canvas/widgets/TerminalWidget.module.css +158 -0
- package/src/canvas/widgets/WidgetChrome.jsx +86 -1
- package/src/canvas/widgets/WidgetChrome.module.css +82 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/widgetConfig.js +78 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +15 -0
|
@@ -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
|
-
|
|
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={{
|
|
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={{
|
|
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 ? () =>
|
|
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) => {
|
|
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
|
-
|
|
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(/* @vite-ignore */ '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
|
+
}
|