myrlin-workbook 0.9.3 → 0.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myrlin-workbook",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -7740,13 +7740,8 @@ class CWMApp {
7740
7740
 
7741
7741
  list.innerHTML = html;
7742
7742
 
7743
- // Fire off async cost fetches for visible sessions (best-effort, non-blocking)
7744
- const visibleSessionIds = (this.state.allSessions || this.state.sessions)
7745
- .filter(s => s.status === 'running' || s.status === 'idle')
7746
- .map(s => s.id);
7747
- if (visibleSessionIds.length > 0) {
7748
- this._fetchSessionCostsAsync(visibleSessionIds);
7749
- }
7743
+ // Fetch all session costs in a single batch request (non-blocking)
7744
+ this._fetchSessionCostsAsync();
7750
7745
 
7751
7746
 
7752
7747
  this.els.workspaceCount.textContent = `${workspaces.length} project${workspaces.length !== 1 ? 's' : ''}`;
@@ -14745,30 +14740,34 @@ class CWMApp {
14745
14740
  return null;
14746
14741
  }
14747
14742
 
14748
- _fetchSessionCostsAsync(sessionIds) {
14743
+ /**
14744
+ * Fetch costs for all sessions in a single batch request instead of N+1
14745
+ * individual requests. Results are cached for 5 minutes. Only re-renders
14746
+ * the sidebar once after all costs are received.
14747
+ */
14748
+ _fetchSessionCostsAsync() {
14749
14749
  if (!this._costCache) this._costCache = {};
14750
- if (!this._costFetchInFlight) this._costFetchInFlight = new Set();
14751
-
14752
- sessionIds.forEach(sid => {
14753
- // Don't re-fetch if already in flight or recently cached
14754
- if (this._costFetchInFlight.has(sid)) return;
14755
- const entry = this._costCache[sid];
14756
- if (entry && (Date.now() - entry.ts < 300000)) return;
14757
-
14758
- this._costFetchInFlight.add(sid);
14759
- this.api('GET', `/api/sessions/${sid}/cost`).then(data => {
14760
- this._costFetchInFlight.delete(sid);
14761
- if (data && (data.totalCost !== undefined || data.cost !== undefined)) {
14762
- const cost = data.totalCost ?? (data.cost && typeof data.cost === 'object' ? data.cost.total : data.cost) ?? null;
14763
- this._costCache[sid] = { cost, ts: Date.now() };
14764
- // Trigger a soft re-render of workspaces to show updated cost badges
14765
- this.renderWorkspaces();
14766
- }
14767
- }).catch(() => {
14768
- this._costFetchInFlight.delete(sid);
14769
- // Cache a null so we don't keep retrying for 5 minutes
14770
- this._costCache[sid] = { cost: null, ts: Date.now() };
14771
- });
14750
+ // Skip if a batch fetch is already in flight or cache is fresh
14751
+ if (this._costBatchInFlight) return;
14752
+ if (this._costBatchTs && (Date.now() - this._costBatchTs < 300000)) return;
14753
+
14754
+ this._costBatchInFlight = true;
14755
+ this.api('GET', '/api/cost/batch').then(data => {
14756
+ this._costBatchInFlight = false;
14757
+ this._costBatchTs = Date.now();
14758
+ if (data && data.costs) {
14759
+ let changed = false;
14760
+ for (const [sid, entry] of Object.entries(data.costs)) {
14761
+ const prev = this._costCache[sid];
14762
+ if (!prev || prev.cost !== entry.cost) changed = true;
14763
+ this._costCache[sid] = { cost: entry.cost, ts: Date.now() };
14764
+ }
14765
+ // Only re-render if any cost value actually changed
14766
+ if (changed) this.renderWorkspaces();
14767
+ }
14768
+ }).catch(() => {
14769
+ this._costBatchInFlight = false;
14770
+ this._costBatchTs = Date.now();
14772
14771
  });
14773
14772
  }
14774
14773
 
@@ -400,6 +400,13 @@ class TerminalPane {
400
400
  const xtermTextarea = container.querySelector('.xterm-helper-textarea');
401
401
  if (xtermTextarea) {
402
402
  xtermTextarea.addEventListener('beforeinput', (e) => {
403
+ // Block paste events - we handle Ctrl+V/Cmd+V ourselves via
404
+ // pasteFromClipboard() to avoid xterm.js onData firing twice
405
+ if (e.inputType === 'insertFromPaste') {
406
+ e.preventDefault();
407
+ return;
408
+ }
409
+
403
410
  if (e.inputType === 'insertReplacementText') {
404
411
  e.preventDefault();
405
412
  const replacement = e.data || (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
@@ -417,7 +424,13 @@ class TerminalPane {
417
424
  const backspaces = '\x7f'.repeat(deleteCount) || '\b'.repeat(deleteCount);
418
425
  this.ws.send(JSON.stringify({ type: 'input', data: backspaces + replacement }));
419
426
  }
420
- });
427
+ }, { capture: true });
428
+
429
+ // Also block native paste event as a fallback
430
+ xtermTextarea.addEventListener('paste', (e) => {
431
+ e.preventDefault();
432
+ e.stopPropagation();
433
+ }, { capture: true });
421
434
  }
422
435
 
423
436
  this.term.onData((data) => {
package/src/web/server.js CHANGED
@@ -2444,6 +2444,57 @@ app.get('/api/sessions/:id/cost', requireAuth, (req, res) => {
2444
2444
  }
2445
2445
  });
2446
2446
 
2447
+ /**
2448
+ * GET /api/cost/batch
2449
+ * Returns cost totals for all sessions in a single response, avoiding N+1 requests.
2450
+ * Each entry includes sessionId, totalCost, and lastActive for sidebar badge rendering.
2451
+ * Uses the same cache as per-session cost endpoints.
2452
+ */
2453
+ app.get('/api/cost/batch', requireAuth, (req, res) => {
2454
+ try {
2455
+ const store = getStore();
2456
+ const allWorkspaces = store.getAllWorkspacesList();
2457
+ const costs = {};
2458
+
2459
+ for (const workspace of allWorkspaces) {
2460
+ const sessions = store.getWorkspaceSessions(workspace.id);
2461
+ for (const session of sessions) {
2462
+ const resumeSessionId = session.resumeSessionId;
2463
+ if (!resumeSessionId) continue;
2464
+ const jsonlPath = findJsonlFile(resumeSessionId);
2465
+ if (!jsonlPath) continue;
2466
+
2467
+ try {
2468
+ const stat = fs.statSync(jsonlPath);
2469
+ if (stat.size >= 500 * 1024 * 1024) continue;
2470
+ const mtimeMs = stat.mtimeMs;
2471
+ const cached = _costCache.get(resumeSessionId);
2472
+ const now = Date.now();
2473
+ let costData;
2474
+
2475
+ if (cached && cached.mtimeMs === mtimeMs && (now - cached.timestamp) < COST_CACHE_TTL) {
2476
+ costData = cached.result;
2477
+ } else {
2478
+ costData = calculateSessionCost(jsonlPath);
2479
+ const result = { sessionId: session.id, resumeSessionId, ...costData };
2480
+ _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
2481
+ costData = result;
2482
+ }
2483
+
2484
+ costs[session.id] = {
2485
+ cost: costData.cost ? costData.cost.total : 0,
2486
+ lastActive: costData.lastMessage || session.lastActive || null,
2487
+ };
2488
+ } catch (_) {}
2489
+ }
2490
+ }
2491
+
2492
+ return res.json({ costs });
2493
+ } catch (err) {
2494
+ return res.status(500).json({ error: 'Batch cost failed: ' + err.message });
2495
+ }
2496
+ });
2497
+
2447
2498
  /**
2448
2499
  * GET /api/quota-overview
2449
2500
  * Returns all sessions ranked by context window size (heaviness).