myrlin-workbook 0.9.4 → 0.9.6

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.4",
3
+ "version": "0.9.6",
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": {
@@ -1820,7 +1820,7 @@ class CWMApp {
1820
1820
  this.showApp();
1821
1821
  this.initDragAndDrop();
1822
1822
  this.initTerminalResize();
1823
- this.initTerminalGroups();
1823
+ await this.initTerminalGroups();
1824
1824
  this.initTerminalPaneSwipe();
1825
1825
  this.initNotesEditor();
1826
1826
  this.initAIInsights();
@@ -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' : ''}`;
@@ -11526,15 +11521,16 @@ class CWMApp {
11526
11521
  PHASE 4: TERMINAL TAB GROUPS
11527
11522
  ═══════════════════════════════════════════════════════════ */
11528
11523
 
11529
- initTerminalGroups() {
11524
+ async initTerminalGroups() {
11530
11525
  // Load layout from server
11531
11526
  this._tabGroups = [];
11532
11527
  this._tabFolders = []; // Tab group folders: { id, name, color, collapsed }
11533
11528
  this._activeGroupId = null;
11534
11529
  this._layoutSaveTimer = null;
11530
+ this._layoutRestored = false;
11535
11531
 
11536
- // Load saved layout
11537
- this.loadTerminalLayout();
11532
+ // Load saved layout (must complete before SSE or other init touches panes)
11533
+ await this.loadTerminalLayout();
11538
11534
  }
11539
11535
 
11540
11536
  async loadTerminalLayout() {
@@ -11562,11 +11558,12 @@ class CWMApp {
11562
11558
  const group = this._tabGroups.find(g => g.id === this._activeGroupId);
11563
11559
  if (group && group.panes && group.panes.length > 0) {
11564
11560
  group.panes.forEach(p => {
11565
- if (p.sessionId) {
11561
+ if (p.sessionId && !this.terminalPanes[p.slot]) {
11566
11562
  this.openTerminalInPane(p.slot, p.sessionId, p.sessionName || 'Terminal', p.spawnOpts || {});
11567
11563
  }
11568
11564
  });
11569
11565
  }
11566
+ this._layoutRestored = true;
11570
11567
  }
11571
11568
 
11572
11569
  /**
@@ -11964,11 +11961,11 @@ class CWMApp {
11964
11961
  }
11965
11962
  });
11966
11963
  } else {
11967
- // No cache create fresh connections (first time opening this group)
11964
+ // No cache, create fresh connections (first time opening this group)
11968
11965
  const group = this._tabGroups.find(g => g.id === groupId);
11969
11966
  if (group && group.panes) {
11970
11967
  group.panes.forEach(p => {
11971
- if (p.sessionId) {
11968
+ if (p.sessionId && !this.terminalPanes[p.slot]) {
11972
11969
  this.openTerminalInPane(p.slot, p.sessionId, p.sessionName || 'Terminal', p.spawnOpts || {});
11973
11970
  }
11974
11971
  });
@@ -14745,30 +14742,34 @@ class CWMApp {
14745
14742
  return null;
14746
14743
  }
14747
14744
 
14748
- _fetchSessionCostsAsync(sessionIds) {
14745
+ /**
14746
+ * Fetch costs for all sessions in a single batch request instead of N+1
14747
+ * individual requests. Results are cached for 5 minutes. Only re-renders
14748
+ * the sidebar once after all costs are received.
14749
+ */
14750
+ _fetchSessionCostsAsync() {
14749
14751
  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
- });
14752
+ // Skip if a batch fetch is already in flight or cache is fresh
14753
+ if (this._costBatchInFlight) return;
14754
+ if (this._costBatchTs && (Date.now() - this._costBatchTs < 300000)) return;
14755
+
14756
+ this._costBatchInFlight = true;
14757
+ this.api('GET', '/api/cost/batch').then(data => {
14758
+ this._costBatchInFlight = false;
14759
+ this._costBatchTs = Date.now();
14760
+ if (data && data.costs) {
14761
+ let changed = false;
14762
+ for (const [sid, entry] of Object.entries(data.costs)) {
14763
+ const prev = this._costCache[sid];
14764
+ if (!prev || prev.cost !== entry.cost) changed = true;
14765
+ this._costCache[sid] = { cost: entry.cost, ts: Date.now() };
14766
+ }
14767
+ // Only re-render if any cost value actually changed
14768
+ if (changed) this.renderWorkspaces();
14769
+ }
14770
+ }).catch(() => {
14771
+ this._costBatchInFlight = false;
14772
+ this._costBatchTs = Date.now();
14772
14773
  });
14773
14774
  }
14774
14775
 
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).