@dfosco/storyboard 0.5.0-beta.32 → 0.5.0-beta.34

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.32",
3
+ "version": "0.5.0-beta.34",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -167,12 +167,25 @@ export class HotPool {
167
167
  this.#log('■ STOPPED — all sessions killed')
168
168
  }
169
169
 
170
+ /**
171
+ * Non-mutating probe: would the next acquire() return a session, and would it
172
+ * be webgl-ready? Used by callers that want to render in webgl-ready mode
173
+ * without claiming a slot (the actual claim happens later when the WS
174
+ * connects). Returns { ready, webglReady }.
175
+ */
176
+ peek() {
177
+ if (!this.#enabled || this.#queue.length === 0) return { ready: false, webglReady: false }
178
+ const idx = this.#queue.findIndex(s => s.state === 'ready')
179
+ if (idx === -1) return { ready: false, webglReady: false }
180
+ const webglReady = this.#webglReadySlots > 0 && idx < this.#webglReadySlots
181
+ return { ready: true, webglReady }
182
+ }
183
+
170
184
  acquire() {
171
185
  if (!this.#enabled || this.#queue.length === 0) {
172
186
  this.#log(`→ ACQUIRE — pool ${!this.#enabled ? 'disabled' : 'empty'}, returning null`)
173
187
  return null
174
188
  }
175
-
176
189
  const idx = this.#queue.findIndex(s => s.state === 'ready')
177
190
  if (idx === -1) {
178
191
  this.#log(`→ ACQUIRE — ${this.#queue.length} in queue but none ready, returning null`)
@@ -731,6 +744,13 @@ export class HotPoolManager {
731
744
  return pool.acquire()
732
745
  }
733
746
 
747
+ /** Non-mutating probe: { ready, webglReady } for the specified pool. */
748
+ peek(poolId) {
749
+ const pool = this.#pools.get(poolId)
750
+ if (!pool) return { ready: false, webglReady: false }
751
+ return pool.peek()
752
+ }
753
+
734
754
  /** Consume a session (transfer ownership out of pool permanently). */
735
755
  consume(poolId, sessionId) {
736
756
  this.#pools.get(poolId)?.consume(sessionId)
@@ -143,6 +143,7 @@ function findCanvasFiles(root) {
143
143
  try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
144
144
  for (const entry of entries) {
145
145
  if (ignore.has(entry.name)) continue
146
+ if (entry.name.startsWith('~')) continue
146
147
  const fullPath = path.join(dir, entry.name)
147
148
  const relPath = rel ? `${rel}/${entry.name}` : entry.name
148
149
  if (entry.isDirectory()) {
@@ -775,18 +776,19 @@ export function createCanvasHandler(ctx) {
775
776
  }
776
777
 
777
778
  /**
778
- * Try to acquire a warm session from the hot pool.
779
+ * Non-mutating probe of a hot pool returns webgl-readiness without
780
+ * claiming a slot. Use this when the canvas API just needs to tell the
781
+ * client whether to render in webgl-ready mode; the actual session claim
782
+ * happens later in terminal-server when the WebSocket connects.
779
783
  * @param {Object|null} hotPool — HotPoolManager instance
780
- * @param {string} poolId — pool to acquire from
784
+ * @param {string} poolId — pool to peek at
781
785
  * @param {string} [mode] — 'auto' (default), 'hot', or 'cold'
782
- * @returns {Object|null} acquired session or null
786
+ * @returns {{ ready: boolean, webglReady: boolean }}
783
787
  */
784
- function acquireFromPool(hotPool, poolId, mode) {
785
- if (!hotPool || mode === 'cold') return null
786
- const effectiveMode = mode || 'auto'
787
- if (effectiveMode === 'cold') return null
788
- if (!hotPool.has(poolId)) return null
789
- return hotPool.acquire(poolId) || null
788
+ function peekPool(hotPool, poolId, mode) {
789
+ if (!hotPool || mode === 'cold') return { ready: false, webglReady: false }
790
+ if (!hotPool.has(poolId)) return { ready: false, webglReady: false }
791
+ return hotPool.peek(poolId)
790
792
  }
791
793
 
792
794
  /**
@@ -1117,12 +1119,17 @@ export function createCanvasHandler(ctx) {
1117
1119
 
1118
1120
  await prepareTerminalWidget({ type, props, widgetId, canvasName: name, req })
1119
1121
 
1120
- // Hot pool acquisition for terminal/agent widgets
1121
- let hotSession = null
1122
+ // Hot pool readiness probe for terminal/agent widgets — non-mutating.
1123
+ // The actual session claim happens later in terminal-server when the
1124
+ // WS connects. Probing here only tells the client whether to render
1125
+ // in webgl-ready mode immediately. (Previously this called
1126
+ // acquireFromPool, which leaked a #acquired slot per widget creation
1127
+ // because no consume/release ever fired against this acquisition.)
1128
+ let hotProbe = { ready: false, webglReady: false }
1122
1129
  if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
1123
1130
  const poolId = resolvePoolId(type, props)
1124
- hotSession = acquireFromPool(hotPool, poolId, pool)
1125
- if (!hotSession && pool === 'hot') {
1131
+ hotProbe = peekPool(hotPool, poolId, pool)
1132
+ if (!hotProbe.ready && pool === 'hot') {
1126
1133
  sendJson(res, 409, { error: `No warm sessions available in pool "${poolId}"` })
1127
1134
  return
1128
1135
  }
@@ -1137,7 +1144,7 @@ export function createCanvasHandler(ctx) {
1137
1144
  })
1138
1145
 
1139
1146
  const response = { success: true, widget }
1140
- if (hotSession) response.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null, webglReady: !!hotSession.webglReady }
1147
+ if (hotProbe.ready) response.hotSession = { id: null, tmuxName: null, webglReady: hotProbe.webglReady }
1141
1148
  sendJson(res, 201, response)
1142
1149
  pushCanvasUpdate(name, filePath, __viteWs)
1143
1150
  } catch (err) {
@@ -1869,11 +1876,11 @@ export function createCanvasHandler(ctx) {
1869
1876
  const widgetId = generateWidgetId(type)
1870
1877
  await prepareTerminalWidget({ type, props, widgetId, canvasName: name, req })
1871
1878
 
1872
- let hotSession = null
1879
+ let hotProbe = { ready: false, webglReady: false }
1873
1880
  if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
1874
1881
  const poolId = resolvePoolId(type, props)
1875
- hotSession = acquireFromPool(hotPool, poolId, pool)
1876
- if (!hotSession && pool === 'hot') throw new Error(`No warm sessions available in pool "${poolId}"`)
1882
+ hotProbe = peekPool(hotPool, poolId, pool)
1883
+ if (!hotProbe.ready && pool === 'hot') throw new Error(`No warm sessions available in pool "${poolId}"`)
1877
1884
  }
1878
1885
 
1879
1886
  const widget = stampBounds({ id: widgetId, type, position, props })
@@ -1886,7 +1893,7 @@ export function createCanvasHandler(ctx) {
1886
1893
  if (ref) refs[ref] = widgetId
1887
1894
 
1888
1895
  const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget }
1889
- if (hotSession) result.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null, webglReady: !!hotSession.webglReady }
1896
+ if (hotProbe.ready) result.hotSession = { id: null, tmuxName: null, webglReady: hotProbe.webglReady }
1890
1897
  results.push(result)
1891
1898
  break
1892
1899
  }
@@ -2567,10 +2567,25 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2567
2567
  const y = widget.position?.y ?? 0
2568
2568
  const w = widget.props?.width ?? fallback.width
2569
2569
  const h = widget.props?.height ?? fallback.height
2570
- const scale = (zoomRef.current || 100) / 100
2570
+
2571
+ // Zoom in (to at least 100%) so the agent is comfortably visible.
2572
+ const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
2573
+ const targetZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.max(zoomRef.current || 100, 100)))
2574
+ const newScale = targetZoom / 100
2575
+ if (targetZoom !== zoomRef.current) {
2576
+ zoomRef.current = targetZoom
2577
+ const zoomEl = zoomElRef.current
2578
+ if (zoomEl) {
2579
+ zoomEl.style.transform = `scale(${newScale})`
2580
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
2581
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
2582
+ }
2583
+ setZoom(targetZoom)
2584
+ }
2585
+
2571
2586
  el.scrollTo({
2572
- left: (x + w / 2) * scale - el.clientWidth / 2,
2573
- top: (y + h / 2) * scale - el.clientHeight / 2,
2587
+ left: (x + w / 2) * newScale - el.clientWidth / 2,
2588
+ top: (y + h / 2) * newScale - el.clientHeight / 2,
2574
2589
  behavior: 'smooth',
2575
2590
  })
2576
2591
  }
@@ -52,9 +52,9 @@ function parseDataFile(filePath) {
52
52
  // Handle .canvas.jsonl files
53
53
  const canvasJsonlMatch = base.match(/^(.+)\.canvas\.jsonl$/)
54
54
  if (canvasJsonlMatch) {
55
- if (canvasJsonlMatch[1].startsWith('_')) return null
55
+ if (canvasJsonlMatch[1].startsWith('_') || canvasJsonlMatch[1].startsWith('~')) return null
56
56
  const normalized = filePath.replace(/\\/g, '/')
57
- if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
57
+ if (normalized.split('/').some(seg => seg.startsWith('_') || seg.startsWith('~'))) return null
58
58
 
59
59
  const baseName = canvasJsonlMatch[1]
60
60
  let name = baseName