@dfosco/storyboard 0.5.0-beta.46 → 0.5.0-beta.48

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.46",
3
+ "version": "0.5.0-beta.48",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -963,6 +963,10 @@ function BranchNav({ basePath }) {
963
963
  const { branches, currentBranch, branchBasePath } = useBranches(basePath)
964
964
  const [switching, setSwitching] = useState(null)
965
965
 
966
+ // Branch switcher is meaningful only in deployed environments where users
967
+ // need to jump between branch deploys. In local dev each worktree is its own
968
+ // process; switching is handled via `sb dev` / `sb code`.
969
+ if (isLocalDev) return null
966
970
  if (!branches || branches.length === 0) return null
967
971
 
968
972
  const branchNames = branches.map(b => b.branch)
@@ -116,37 +116,81 @@ const PromptWidget = forwardRef(function PromptWidget({ id, props, onUpdate, res
116
116
  const onUpdateRef = useRef(onUpdate)
117
117
  useEffect(() => { onUpdateRef.current = onUpdate }, [onUpdate])
118
118
 
119
+ // Apply a status payload (from WS event or polled config) to local + persisted state.
120
+ const applyStatus = useCallback((data) => {
121
+ if (!data || !data.status) return
122
+ if (data.status === 'done' || data.status === 'completed') {
123
+ setExecStatus('done')
124
+ onUpdateRef.current?.({ status: 'done' })
125
+ } else if (data.status === 'error') {
126
+ setExecStatus('error')
127
+ setExecError(data.message || 'Unknown error')
128
+ onUpdateRef.current?.({ status: 'error', errorMessage: data.message || 'Unknown error' })
129
+ } else if (data.status === 'cancelled') {
130
+ setExecStatus('idle')
131
+ onUpdateRef.current?.({ status: 'idle', sessionId: '', errorMessage: '' })
132
+ } else if (data.status === 'working' || data.status === 'running' || data.status === 'pending') {
133
+ setExecStatus('pending')
134
+ onUpdateRef.current?.({ status: 'pending' })
135
+ }
136
+ }, [])
137
+
119
138
  useEffect(() => {
120
139
  if (!import.meta.hot) return
121
-
122
140
  const handler = (data) => {
123
141
  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
- }
142
+ applyStatus(data)
141
143
  }
142
-
143
144
  import.meta.hot.on('storyboard:agent-status', handler)
144
145
  return () => {
145
146
  if (typeof import.meta.hot.off === 'function') {
146
147
  import.meta.hot.off('storyboard:agent-status', handler)
147
148
  }
148
149
  }
149
- }, [id])
150
+ }, [id, applyStatus])
151
+
152
+ // Reconcile with the server-side persisted agentStatus.
153
+ // The agent always writes its final status to .storyboard/terminals/{id}.json
154
+ // via POST /agent/signal, but the live `storyboard:agent-status` WS event
155
+ // can be missed (tab in background, HMR reconnect, page navigation). On
156
+ // mount and while we believe the agent is still pending, poll the persisted
157
+ // status as a safety net so the widget can never get stuck.
158
+ useEffect(() => {
159
+ if (execStatus !== 'pending' && execStatus !== 'idle') return
160
+
161
+ let cancelled = false
162
+ let timer = null
163
+
164
+ async function pollOnce() {
165
+ try {
166
+ const url = `${getBase()}/_storyboard/canvas/agent/status?widgetId=${encodeURIComponent(id)}`
167
+ const res = await fetch(url)
168
+ if (!res.ok) return
169
+ const json = await res.json().catch(() => null)
170
+ const persisted = json?.agentStatus
171
+ if (cancelled || !persisted?.status) return
172
+ // Only apply terminal states or transitions we don't already reflect.
173
+ if (persisted.status === 'done' || persisted.status === 'error' || persisted.status === 'cancelled') {
174
+ applyStatus(persisted)
175
+ } else if (execStatus === 'idle' && (persisted.status === 'running' || persisted.status === 'working' || persisted.status === 'pending')) {
176
+ applyStatus(persisted)
177
+ }
178
+ } catch { /* offline — try again next tick */ }
179
+ }
180
+
181
+ // Reconcile immediately on mount / when we enter pending.
182
+ pollOnce()
183
+ // And keep polling at a low cadence while pending, in case the WS event
184
+ // is missed entirely.
185
+ if (execStatus === 'pending') {
186
+ timer = setInterval(pollOnce, 5000)
187
+ }
188
+
189
+ return () => {
190
+ cancelled = true
191
+ if (timer) clearInterval(timer)
192
+ }
193
+ }, [id, execStatus, applyStatus])
150
194
 
151
195
  useImperativeHandle(ref, () => ({
152
196
  handleAction(action) {
@@ -1394,6 +1394,41 @@ export default function storyboardDataPlugin() {
1394
1394
  }
1395
1395
  },
1396
1396
 
1397
+ // Guard against known invalid named imports from @primer/react.
1398
+ // The most common offender is `Octicon`, which lives in
1399
+ // `@primer/octicons-react`, not `@primer/react`. When a consumer
1400
+ // writes `import { Octicon } from '@primer/react'`, Vite happily
1401
+ // pre-bundles the dep and only fails at runtime with a cryptic
1402
+ // "does not provide an export named 'Octicon'" error from inside
1403
+ // `node_modules/.vite/deps/@primer_react.js`. Catch it here at
1404
+ // transform time with a clear, actionable error pointing to the
1405
+ // correct package.
1406
+ transform(code, id) {
1407
+ if (!/\.(jsx?|tsx?|mjs|cjs)(\?|$)/.test(id)) return null
1408
+ if (id.includes('/node_modules/')) return null
1409
+ if (!code.includes('@primer/react')) return null
1410
+ const re = /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]@primer\/react['"]/g
1411
+ let match
1412
+ while ((match = re.exec(code)) !== null) {
1413
+ const names = match[1]
1414
+ .split(',')
1415
+ .map(s => s.replace(/\s+as\s+\w+/, '').trim())
1416
+ .filter(Boolean)
1417
+ if (names.includes('Octicon')) {
1418
+ const before = code.slice(0, match.index)
1419
+ const line = before.split('\n').length
1420
+ const rel = id.replace(root + '/', '')
1421
+ throw new Error(
1422
+ `[storyboard] Invalid import in ${rel}:${line} — \`Octicon\` is not exported by \`@primer/react\`.\n` +
1423
+ ` Import the icon you need directly from \`@primer/octicons-react\` instead, e.g.:\n` +
1424
+ ` import { GearIcon } from '@primer/octicons-react'\n` +
1425
+ ` See AGENTS.md: "Use Primer Octicons from @primer/octicons-react for icons".`
1426
+ )
1427
+ }
1428
+ }
1429
+ return null
1430
+ },
1431
+
1397
1432
  handleHotUpdate(ctx) {
1398
1433
  const normalized = ctx.file.replace(/\\/g, '/')
1399
1434
  if (!/\.canvas\.jsonl$/.test(normalized)) return