@dfosco/storyboard 0.5.0-beta.47 → 0.5.0-beta.49

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.5.0-beta.47",
3
+ "version": "0.5.0-beta.49",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -3015,9 +3015,17 @@ export function Default() {
3015
3015
  }
3016
3016
 
3017
3017
  try {
3018
- const { readTerminalConfig, initTerminalConfig } = await import('./terminal-config.js')
3018
+ const { readTerminalConfig, readTerminalConfigById, initTerminalConfig } = await import('./terminal-config.js')
3019
3019
  initTerminalConfig(root)
3020
- const config = readTerminalConfig({ branch, canvasId, widgetId })
3020
+ let config = readTerminalConfig({ branch, canvasId, widgetId })
3021
+ // Fallback: when canvasId/branch are missing, mismatched, or stale
3022
+ // (e.g. the persisted canvasId only captured the parent folder),
3023
+ // recover via the widget-id-named symlink that writeTerminalConfig
3024
+ // creates next to the hashed config.
3025
+ if (!config?.agentStatus) {
3026
+ const byId = readTerminalConfigById(widgetId)
3027
+ if (byId?.agentStatus) config = byId
3028
+ }
3021
3029
  sendJson(res, 200, { agentStatus: config?.agentStatus || null })
3022
3030
  } catch (err) {
3023
3031
  sendJson(res, 500, { error: `Failed to read agent status: ${err.message}` })
@@ -9,6 +9,27 @@ function getBase() {
9
9
  return (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
10
10
  }
11
11
 
12
+ /**
13
+ * Resolve the **full** canvas-page id for the currently-mounted canvas.
14
+ * Prefer the live bridge state (set by CanvasPage), which always carries the
15
+ * complete nested id (e.g. `dfosco-explorations/adaptive-threat-view`). Fall
16
+ * back to URL parsing when the bridge isn't ready yet — and crucially, take
17
+ * **every** path segment after `/canvas/`, not just the first one, so nested
18
+ * canvas ids round-trip correctly.
19
+ */
20
+ function resolveCanvasId() {
21
+ if (typeof window !== 'undefined') {
22
+ const fromBridge = window.__storyboardCanvasBridgeState?.canvasId
23
+ if (fromBridge) return fromBridge
24
+ const pathParts = window.location.pathname.split('/').filter(Boolean)
25
+ const canvasIdx = pathParts.indexOf('canvas')
26
+ if (canvasIdx >= 0 && canvasIdx < pathParts.length - 1) {
27
+ return pathParts.slice(canvasIdx + 1).join('/')
28
+ }
29
+ }
30
+ return 'default'
31
+ }
32
+
12
33
  async function spawnPromptAgent({ canvasId, widgetId, prompt }) {
13
34
  const res = await fetch(`${getBase()}/_storyboard/canvas/prompt/spawn`, {
14
35
  method: 'POST',
@@ -116,37 +137,84 @@ const PromptWidget = forwardRef(function PromptWidget({ id, props, onUpdate, res
116
137
  const onUpdateRef = useRef(onUpdate)
117
138
  useEffect(() => { onUpdateRef.current = onUpdate }, [onUpdate])
118
139
 
140
+ // Apply a status payload (from WS event or polled config) to local + persisted state.
141
+ const applyStatus = useCallback((data) => {
142
+ if (!data || !data.status) return
143
+ if (data.status === 'done' || data.status === 'completed') {
144
+ setExecStatus('done')
145
+ onUpdateRef.current?.({ status: 'done' })
146
+ } else if (data.status === 'error') {
147
+ setExecStatus('error')
148
+ setExecError(data.message || 'Unknown error')
149
+ onUpdateRef.current?.({ status: 'error', errorMessage: data.message || 'Unknown error' })
150
+ } else if (data.status === 'cancelled') {
151
+ setExecStatus('idle')
152
+ onUpdateRef.current?.({ status: 'idle', sessionId: '', errorMessage: '' })
153
+ } else if (data.status === 'working' || data.status === 'running' || data.status === 'pending') {
154
+ setExecStatus('pending')
155
+ onUpdateRef.current?.({ status: 'pending' })
156
+ }
157
+ }, [])
158
+
119
159
  useEffect(() => {
120
160
  if (!import.meta.hot) return
121
-
122
161
  const handler = (data) => {
123
162
  if (data.widgetId !== id) return
124
- if (data.status === 'done' || data.status === 'completed') {
125
- setExecStatus('done')
126
- onUpdateRef.current?.({ status: 'done' })
127
- } else if (data.status === 'error') {
128
- setExecStatus('error')
129
- setExecError(data.message || 'Unknown error')
130
- onUpdateRef.current?.({ status: 'error', errorMessage: data.message || 'Unknown error' })
131
- } else if (data.status === 'cancelled') {
132
- setExecStatus('idle')
133
- onUpdateRef.current?.({ status: 'idle', sessionId: '', errorMessage: '' })
134
- } else if (data.status === 'working') {
135
- setExecStatus('pending')
136
- onUpdateRef.current?.({ status: 'pending' })
137
- } else if (data.status === 'running' || data.status === 'pending') {
138
- setExecStatus('pending')
139
- onUpdateRef.current?.({ status: 'pending' })
140
- }
163
+ applyStatus(data)
141
164
  }
142
-
143
165
  import.meta.hot.on('storyboard:agent-status', handler)
144
166
  return () => {
145
167
  if (typeof import.meta.hot.off === 'function') {
146
168
  import.meta.hot.off('storyboard:agent-status', handler)
147
169
  }
148
170
  }
149
- }, [id])
171
+ }, [id, applyStatus])
172
+
173
+ // Reconcile with the server-side persisted agentStatus.
174
+ // The agent always writes its final status to .storyboard/terminals/{id}.json
175
+ // via POST /agent/signal, but the live `storyboard:agent-status` WS event
176
+ // can be missed (tab in background, HMR reconnect, page navigation). On
177
+ // mount and while we believe the agent is still pending, poll the persisted
178
+ // status as a safety net so the widget can never get stuck.
179
+ useEffect(() => {
180
+ if (execStatus !== 'pending' && execStatus !== 'idle') return
181
+
182
+ let cancelled = false
183
+ let timer = null
184
+
185
+ async function pollOnce() {
186
+ try {
187
+ const params = new URLSearchParams({ widgetId: id })
188
+ const cid = resolveCanvasId()
189
+ if (cid && cid !== 'default') params.set('canvasId', cid)
190
+ const url = `${getBase()}/_storyboard/canvas/agent/status?${params}`
191
+ const res = await fetch(url)
192
+ if (!res.ok) return
193
+ const json = await res.json().catch(() => null)
194
+ const persisted = json?.agentStatus
195
+ if (cancelled || !persisted?.status) return
196
+ // Only apply terminal states or transitions we don't already reflect.
197
+ if (persisted.status === 'done' || persisted.status === 'error' || persisted.status === 'cancelled') {
198
+ applyStatus(persisted)
199
+ } else if (execStatus === 'idle' && (persisted.status === 'running' || persisted.status === 'working' || persisted.status === 'pending')) {
200
+ applyStatus(persisted)
201
+ }
202
+ } catch { /* offline — try again next tick */ }
203
+ }
204
+
205
+ // Reconcile immediately on mount / when we enter pending.
206
+ pollOnce()
207
+ // And keep polling at a low cadence while pending, in case the WS event
208
+ // is missed entirely.
209
+ if (execStatus === 'pending') {
210
+ timer = setInterval(pollOnce, 5000)
211
+ }
212
+
213
+ return () => {
214
+ cancelled = true
215
+ if (timer) clearInterval(timer)
216
+ }
217
+ }, [id, execStatus, applyStatus])
150
218
 
151
219
  useImperativeHandle(ref, () => ({
152
220
  handleAction(action) {
@@ -169,9 +237,7 @@ const PromptWidget = forwardRef(function PromptWidget({ id, props, onUpdate, res
169
237
  setExecStatus('pending')
170
238
  setExecError('')
171
239
 
172
- const pathParts = window.location.pathname.split('/')
173
- const canvasIdx = pathParts.indexOf('canvas')
174
- const canvasId = canvasIdx >= 0 ? pathParts[canvasIdx + 1] : 'default'
240
+ const canvasId = resolveCanvasId()
175
241
 
176
242
  onUpdate?.({ text: draftText, status: 'pending' })
177
243
 
@@ -271,9 +337,7 @@ const PromptWidget = forwardRef(function PromptWidget({ id, props, onUpdate, res
271
337
  term.open(termContainerRef.current)
272
338
  termRef.current = term
273
339
 
274
- const pathParts = window.location.pathname.split('/')
275
- const canvasIdx = pathParts.indexOf('canvas')
276
- const canvasId = canvasIdx >= 0 ? pathParts[canvasIdx + 1] : 'default'
340
+ const canvasId = resolveCanvasId()
277
341
 
278
342
  const url = getWsUrl(id, canvasId, true)
279
343
  ws = new WebSocket(url)