sigrank 0.12.0 → 0.12.1

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/README.md CHANGED
@@ -67,7 +67,7 @@ sigrank
67
67
  ## Commands
68
68
 
69
69
  ```
70
- ⊙ SigRank CLI v0.11.2
70
+ ⊙ SigRank CLI v0.12.1
71
71
 
72
72
  Default (no args)
73
73
  sigrank unified dashboard: cascade + token pillars + board
@@ -258,7 +258,7 @@ All adapters are token-only (no message content, no cost fields, no credentials)
258
258
  | Var | Default | Description |
259
259
  |---|---|---|
260
260
  | `SIGRANK_API_BASE` | `https://signalaf.com` | Override the board host |
261
- | `SIGRANK_FETCH_TIMEOUT_MS` | `10000` | Board API fetch timeout |
261
+ | `SIGRANK_FETCH_TIMEOUT` | `10000` | Board API fetch timeout (ms) |
262
262
 
263
263
  ---
264
264
 
package/cli.mjs CHANGED
@@ -396,6 +396,16 @@ async function runMe({ platform = 'claude', compare = false } = {}) {
396
396
 
397
397
  // ── COMPARE command ───────────────────────────────────────────────────────────
398
398
  // Side-by-side: ccusage (JSON) vs tokenpull vs token-dashboard (SQLite)
399
+ //
400
+ // NOTE (P3 2026-06-27): These sync verifier readers (ccusagePillars / tokscalePillars /
401
+ // tokenDashPillars / appPillars) are INTENTIONALLY separate from tokenpull.mjs
402
+ // `freshVerifierPillars()`. The compare command uses cached/file-based data sources
403
+ // (tokscale_report.json, direct db read with windowed+model-filtered queries) for a
404
+ // quick side-by-side, while freshVerifierPillars runs all three live (bunx tokscale,
405
+ // scan+read tokendash) for the dashboard. Merging them would change compare's data
406
+ // source and lose the windowed tokendash breakdown. ccusagePillars IS functionally
407
+ // identical to _freshCcusage and could be consolidated in a future pass if compare
408
+ // switches to live data — but that's a behavior change, not a refactor.
399
409
 
400
410
  function ccusagePillars(platform = 'claude') {
401
411
  // ccusage <platform> daily --json → sum by window
@@ -831,7 +841,7 @@ async function runSigRank() {
831
841
  writeln(` ${gold('⊙ SigRank')} ${bold('Operator Dashboard')}`)
832
842
  writeln(` ${dim('reading local data…')}`)
833
843
 
834
- const { tokenpullAny } = await import('./tokenpull.mjs')
844
+ const { tokenpullAny, freshVerifierPillars } = await import('./tokenpull.mjs')
835
845
 
836
846
  // Local sources first (fast) — board fetch with 5s timeout runs in parallel
837
847
  const boardPromise = Promise.race([
@@ -839,24 +849,16 @@ async function runSigRank() {
839
849
  new Promise(r => setTimeout(() => r(null), 5000)),
840
850
  ])
841
851
 
842
- const [platformResults, tdPillars] = await Promise.all([
852
+ // FIX A1-CLI (2026-06-27): verifiers (ccusage/tokscale/tokendash) are fetched
853
+ // ON-DEMAND for ACTIVE platforms only — not synchronously for all 15 platforms
854
+ // upfront (which blocked Dashboard paint up to ~40s, same class of bug as TUI
855
+ // FIX A1). The sync ccusagePillars()/tokscalePillars()/tokenDashPillars() loop
856
+ // over ALL_PLATFORMS is replaced by parallel freshVerifierPillars() calls after
857
+ // we know which platforms actually have local data.
858
+ const [platformResults] = await Promise.all([
843
859
  Promise.allSettled(ALL_PLATFORMS.map(p => tokenpullAny(p))),
844
- Promise.resolve(tokenDashPillars()),
845
860
  ])
846
861
 
847
- // per-platform verifiers: ccusage + tokscale both support multiple platforms
848
- // build map: platform → { cc, ts }
849
- const verifierMap = {}
850
- for (const platform of ALL_PLATFORMS) {
851
- verifierMap[platform] = {
852
- cc: ccusagePillars(platform),
853
- ts: tokscalePillars(platform),
854
- }
855
- }
856
- // keep top-level claude refs for combined section
857
- const ccPillars = verifierMap['claude']?.cc ?? null
858
- const tsPillars = verifierMap['claude']?.ts ?? null
859
-
860
862
  const boardData = await boardPromise
861
863
 
862
864
  // filter to platforms with actual data
@@ -872,6 +874,21 @@ async function runSigRank() {
872
874
  active.push(d)
873
875
  }
874
876
 
877
+ // Fetch verifiers on-demand for active platforms only (parallel, never throws).
878
+ // tdPillars (token-dashboard) is claude-only and pulled from the claude fresh result.
879
+ const verifierMap = {}
880
+ let tdPillars = null
881
+ if (active.length > 0) {
882
+ const fresh = await Promise.all(
883
+ active.map(d => freshVerifierPillars(d.platform).catch(() => ({ ccusage: null, tokscale: null, tokendash: null })))
884
+ )
885
+ for (let i = 0; i < active.length; i++) {
886
+ const f = fresh[i]
887
+ verifierMap[active[i].platform] = { cc: f.ccusage, ts: f.tokscale }
888
+ if (active[i].platform === 'claude' && f.tokendash) tdPillars = f.tokendash
889
+ }
890
+ }
891
+
875
892
  // clear the 3 loading lines (blank + header + "reading…")
876
893
  write(CURSOR_UP(3) + ERASE_LINE + CURSOR_UP(1) + ERASE_LINE + CURSOR_UP(1) + ERASE_LINE)
877
894
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/tools.mjs CHANGED
@@ -22,6 +22,12 @@ import path from 'node:path'
22
22
 
23
23
  // ── Verifier readers (sync, on-device, token-only) ────────────────────────────
24
24
  // These mirror the implementations in cli.mjs / tui.mjs without the circular import.
25
+ //
26
+ // NOTE (P3 2026-06-27): Intentionally separate from tokenpull.mjs `freshVerifierPillars()`.
27
+ // The MCP `tokenpull_compare` tool uses these file-based/cached readers (tokscale_report.json,
28
+ // direct db read) for a quick comparison, while freshVerifierPillars runs all sources live
29
+ // (bunx tokscale, scan+read tokendash) for the TUI/CLI dashboard. Different data sources =
30
+ // different behavior; do NOT merge without understanding the trade-off.
25
31
 
26
32
  function _ccusagePillars(platform = 'claude') {
27
33
  try {
package/tui.mjs CHANGED
@@ -221,70 +221,13 @@ function sparkline(values) {
221
221
  }).join('')
222
222
  }
223
223
 
224
- // ── Data sources (same as cli.mjs) ──────────────────────────────────────────
225
- function ccusagePillars(platform = 'claude') {
226
- try {
227
- const cmd = platform === 'claude' ? 'ccusage claude daily --json' : `ccusage ${platform} daily --json`
228
- const raw = execSync(cmd, { timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
229
- const data = JSON.parse(raw)
230
- const rows = data.daily ?? data
231
- const now = Date.now()
232
- const cutoff = { '7d': 7, '30d': 30, '90d': 90 }
233
- const result = {}
234
- for (const [win, days] of Object.entries(cutoff)) {
235
- const since = new Date(now - days * 86400000)
236
- let i = 0, o = 0, cw = 0, cr = 0
237
- for (const row of rows) {
238
- if (new Date(row.date ?? row.day ?? '1970') >= since) {
239
- i += row.inputTokens ?? row.input_tokens ?? 0
240
- o += row.outputTokens ?? row.output_tokens ?? 0
241
- cw += row.cacheCreationTokens ?? row.cache_create_tokens ?? 0
242
- cr += row.cacheReadTokens ?? row.cache_read_tokens ?? 0
243
- }
244
- }
245
- result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
246
- }
247
- let i = 0, o = 0, cw = 0, cr = 0
248
- for (const row of rows) {
249
- i += row.inputTokens ?? 0
250
- o += row.outputTokens ?? 0
251
- cw += row.cacheCreationTokens ?? 0
252
- cr += row.cacheReadTokens ?? 0
253
- }
254
- result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
255
- return result
256
- } catch { return null }
257
- }
258
-
259
- function tokscalePillars(platform = 'claude') {
260
- const p = path.join(os.homedir(), 'tokscale_report.json')
261
- if (!existsSync(p)) return null
262
- try {
263
- const data = JSON.parse(readFileSync(p, 'utf8'))
264
- const rows = (data.entries ?? []).filter(e =>
265
- e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' && (e.input > 0 || e.output > 0)
266
- )
267
- if (!rows.length) return null
268
- const acc = rows.reduce((a, e) => ({
269
- input: a.input + (e.input ?? 0), output: a.output + (e.output ?? 0),
270
- cacheCreate: a.cacheCreate + (e.cacheWrite ?? 0), cacheRead: a.cacheRead + (e.cacheRead ?? 0),
271
- }), { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 })
272
- return { all: acc }
273
- } catch { return null }
274
- }
275
-
276
- function tokenDashPillars() {
277
- const dbPath = path.join(os.homedir(), '.claude', 'token-dashboard.db')
278
- if (!existsSync(dbPath)) return null
279
- try {
280
- const raw = execSync(
281
- `sqlite3 "${dbPath}" "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages"`,
282
- { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }
283
- ).toString().trim()
284
- const [i, o, cw, cr] = raw.split('|').map(Number)
285
- return { all: { input: i || 0, output: o || 0, cacheCreate: cw || 0, cacheRead: cr || 0 } }
286
- } catch { return null }
287
- }
224
+ // ── Data sources ─────────────────────────────────────────────────────────────
225
+ // (0.12.1 FIX 4) The local verifier readers (ccusage / tokscale / token-dashboard)
226
+ // moved to `freshVerifierPillars()` in tokenpull.mjs — FRESH on-demand pulls used by
227
+ // the Compare tab (#9). The old in-tui copies were dead after A1 removed the Dashboard
228
+ // verifierMap loop and #9 routed Compare through the fresh helper; removed here.
229
+ // (tools.mjs keeps its own `_ccusagePillars`/`_tokscalePillars`/`_tokenDashPillars` —
230
+ // still used by the `tokenpull_compare` MCP tool.)
288
231
 
289
232
  async function loadDashboardData() {
290
233
  const { tokenpullAny } = await import('./tokenpull.mjs')
@@ -474,7 +417,40 @@ function yieldSpark(d) {
474
417
  }
475
418
 
476
419
  // ── TAB 1: DASHBOARD ─────────────────────────────────────────────────────────
477
- function renderDashboard(data, status = '') {
420
+
421
+ // SCROLL-VIEW (2026-06-27): section-scoped scroll for the cascade table.
422
+ // The cascade section gets a real scroll viewport — up/down arrows scroll just
423
+ // the cascade rows while the rest of the dashboard (header, token composition,
424
+ // board, footer) stays pinned. This replaces the misleading "+N more — resize /
425
+ // scroll" text (A3) which implied scroll existed but didn't (alt-screen locked
426
+ // frame = no terminal scroll). The scroll is IN-SECTION only, on the Dashboard tab.
427
+
428
+ const DASH_WINS = ['7d', '30d', '90d', 'all']
429
+
430
+ // Count how many cascade rows the active platforms would produce (every window
431
+ // with data across all active platforms). This is the "total wanted" count.
432
+ function cascadeScrollableCount(active) {
433
+ return active.reduce((n, d) =>
434
+ n + DASH_WINS.filter(wk => {
435
+ const wd = d.windows?.find(x => x.window === wk)
436
+ return wd && (wd.pillars.input + wd.pillars.output) > 0
437
+ }).length, 0)
438
+ }
439
+
440
+ // Compute the max cascade rows that fit in the current terminal height budget.
441
+ // Mirrors the calculation in renderDashboard (budget - used - sectionsBelow).
442
+ function maxCascadeRowsFor(active) {
443
+ const budget = H() - 4 // 2 tab bar + 2 footer
444
+ const platformCount = active.length
445
+ const barLines = 4 + platformCount + (platformCount > 1 ? 1 : 0)
446
+ const boardLines = 17
447
+ const sectionsBelow = barLines + boardLines
448
+ // used before cascade = header(2) + blank(1) + title(1) + blank(1) + col-header(1) + separator(1) = 7
449
+ const usedBeforeCascade = 7
450
+ return Math.max(8, budget - usedBeforeCascade - sectionsBelow)
451
+ }
452
+
453
+ function renderDashboard(data, status = '', scrollOffset = 0) {
478
454
  const w = W()
479
455
  const budget = H() - 4 // 2 tab bar + 2 footer
480
456
  const WINS = ['7d', '30d', '90d', 'all']
@@ -554,35 +530,51 @@ function renderDashboard(data, status = '') {
554
530
  const sectionsBelow = barLines + boardLines
555
531
  const maxCascadeRows = Math.max(8, budget - used - sectionsBelow)
556
532
 
557
- // A3 (2026-06-27): count the cascade rows we WANT to show (every window with data across all
558
- // active platforms) so we can tell the user how many were dropped by the height budget, instead
559
- // of silently truncating them. (Fit logic below is unchanged — this is observation only.)
560
- const totalCascadeRowsWanted = active.reduce((n, d) =>
561
- n + WINS.filter(wk => {
562
- const wd = d.windows?.find(x => x.window === wk)
563
- return wd && (wd.pillars.input + wd.pillars.output) > 0
564
- }).length, 0)
565
-
533
+ // SCROLL-VIEW (2026-06-27): Build the full list of cascade rows first, then
534
+ // slice by [scrollOffset, scrollOffset + maxCascadeRows]. This gives a real
535
+ // in-section scroll viewport — up/down arrows scroll just the cascade rows
536
+ // while the rest of the dashboard stays pinned. Replaces the A3 "+N more —
537
+ // resize / scroll" text which implied scroll existed but didn't.
566
538
  let firstWin = {}
567
- let cascadeRowCount = 0
539
+ const allCascadeRows = [] // flat list: { platform, winKey, pillars, estimated }
568
540
  for (const d of active) {
569
541
  firstWin[d.platform] = WINS.find(wk => {
570
542
  const wd = d.windows?.find(w => w.window === wk)
571
543
  return wd && (wd.pillars.input + wd.pillars.output) > 0
572
544
  })
573
545
  for (const wk of WINS) {
574
- if (cascadeRowCount >= maxCascadeRows || used >= budget - sectionsBelow) break
575
546
  const wd = d.windows?.find(x => x.window === wk)
576
547
  if (!wd || (wd.pillars.input + wd.pillars.output) === 0) continue
577
- renderRow(d.platform, (s) => wk === firstWin[d.platform] ? cyan(s) : dim(s), wk, wd.pillars, d.estimated)
578
- cascadeRowCount++
548
+ allCascadeRows.push({ platform: d.platform, winKey: wk, pillars: wd.pillars, estimated: d.estimated })
579
549
  }
580
- emit()
581
550
  }
582
551
 
583
- // A3: if any cascade rows were dropped (height budget / row cap), say so instead of hiding them.
584
- const cascadeDropped = totalCascadeRowsWanted - cascadeRowCount
585
- if (cascadeDropped > 0) emit(` ${dim(` +${cascadeDropped} more — resize / scroll`)}`)
552
+ const totalWanted = allCascadeRows.length
553
+ const canScroll = totalWanted > maxCascadeRows
554
+ // Clamp scroll offset to valid range [0, totalWanted - maxCascadeRows]
555
+ const maxOffset = Math.max(0, totalWanted - maxCascadeRows)
556
+ const effectiveOffset = Math.min(scrollOffset, maxOffset)
557
+ const visibleRows = allCascadeRows.slice(effectiveOffset, effectiveOffset + maxCascadeRows)
558
+
559
+ // ▲ indicator: rows above the current viewport
560
+ if (canScroll && effectiveOffset > 0) {
561
+ emit(` ${dim(` ▲ ${effectiveOffset} row${effectiveOffset > 1 ? 's' : ''} above`)}`)
562
+ }
563
+
564
+ // Render the visible slice — group by platform (emit a blank line between platforms)
565
+ let lastPlatform = null
566
+ for (const row of visibleRows) {
567
+ if (lastPlatform !== null && row.platform !== lastPlatform) emit()
568
+ renderRow(row.platform, (s) => row.winKey === firstWin[row.platform] ? cyan(s) : dim(s), row.winKey, row.pillars, row.estimated)
569
+ lastPlatform = row.platform
570
+ }
571
+ if (visibleRows.length > 0) emit()
572
+
573
+ // ▼ indicator: rows below the current viewport
574
+ const rowsBelow = totalWanted - effectiveOffset - visibleRows.length
575
+ if (canScroll && rowsBelow > 0) {
576
+ emit(` ${dim(` ▼ ${rowsBelow} row${rowsBelow > 1 ? 's' : ''} below · ↑↓ scroll`)}${effectiveOffset > 0 ? dim(` · ${effectiveOffset}/${maxOffset}`) : ''}`)
577
+ }
586
578
 
587
579
  // combined
588
580
  if (active.length > 1 && used < budget - sectionsBelow) {
@@ -763,7 +755,9 @@ function renderTrends(data, subIdx = 0) {
763
755
  emit()
764
756
  }
765
757
  // A3: surface the dropped count instead of hiding it.
766
- if (trendsDropped > 0) emit(` ${dim(` +${trendsDropped} more — resize / scroll`)}`)
758
+ // SCROLL-VIEW (2026-06-27): changed "resize / scroll" → "resize to see more"
759
+ // because these tabs don't have a real scroll viewport (only Dashboard cascade does).
760
+ if (trendsDropped > 0) emit(` ${dim(` +${trendsDropped} more — resize to see more`)}`)
767
761
  } else { // Field
768
762
  emit(` ${dim(' Field-wide trends — calculating…')}`)
769
763
  emit(` ${dim(' (needs per-window board history; materializes once live ingest lands)')}`)
@@ -939,8 +933,10 @@ function renderSubmissions(boardData, window = '30d', filterCodename = null, hig
939
933
  shown++
940
934
  }
941
935
  // A3-style: if the height budget dropped rows, say so instead of silently truncating.
936
+ // SCROLL-VIEW (2026-06-27): changed "resize / scroll" → "resize to see more"
937
+ // (Board tab doesn't have a real scroll viewport — only Dashboard cascade does).
942
938
  const dropped = sorted.length - shown
943
- if (dropped > 0 && used < budget) emit(` ${dim(` +${dropped} more — resize / scroll`)}`)
939
+ if (dropped > 0 && used < budget) emit(` ${dim(` +${dropped} more — resize to see more`)}`)
944
940
  }
945
941
 
946
942
  // ── TAB 3: BOARD ─────────────────────────────────────────────────────────────
@@ -1360,6 +1356,7 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1360
1356
  let submitMsg = '' // [S] in-place submit result, shown in the read-tab footer
1361
1357
  let submitPreview = false // FIX I1: [S] opens a preview grid before sending (see→confirm→send)
1362
1358
  let boardYouOnly = false // FIX I2: [Y] toggles the Board to "just me" (hybrid model)
1359
+ let cascadeScroll = 0 // SCROLL-VIEW (2026-06-27): cascade-section scroll offset (Dashboard only)
1363
1360
 
1364
1361
  // ── Redraw (buffered: renders into memory, then paints as a locked frame)
1365
1362
  const redraw = async () => {
@@ -1386,9 +1383,13 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1386
1383
  if (!dashData) {
1387
1384
  writeln(`\n ${dim('loading dashboard…')}`)
1388
1385
  } else {
1389
- renderDashboard(dashData, status)
1386
+ renderDashboard(dashData, status, cascadeScroll)
1390
1387
  }
1391
- setFooter(readFooter(`${hint}${submitHint}`))
1388
+ // SCROLL-VIEW: show ↑↓ hint only when there are cascade rows to scroll
1389
+ const scrollHint = dashData && Array.isArray(dashData.active)
1390
+ ? cascadeScrollableCount(dashData.active) > maxCascadeRowsFor(dashData.active) ? ` ${dim('↑↓ scroll cascade')}` : ''
1391
+ : ''
1392
+ setFooter(readFooter(`${hint}${scrollHint}${submitHint}`))
1392
1393
  } else if (activeTab === 1) { // Trends
1393
1394
  if (!dashData) {
1394
1395
  writeln(`\n ${dim('loading trends…')}`)
@@ -1631,6 +1632,33 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1631
1632
  return
1632
1633
  }
1633
1634
 
1635
+ // SCROLL-VIEW (2026-06-27): ↑/↓ (or j/k) scroll the cascade section on the
1636
+ // Dashboard tab ONLY. The rest of the dashboard stays pinned. Only active
1637
+ // when there are more cascade rows than fit in the viewport.
1638
+ if (activeTab === 0 && dashData && Array.isArray(dashData.active)) {
1639
+ const total = cascadeScrollableCount(dashData.active)
1640
+ const maxRows = maxCascadeRowsFor(dashData.active)
1641
+ if (total > maxRows) {
1642
+ const maxOff = total - maxRows
1643
+ if (key === '\x1b[A' || k === 'k') { // ↑ / k → scroll up
1644
+ cascadeScroll = Math.max(0, cascadeScroll - 1)
1645
+ await redraw(); return
1646
+ }
1647
+ if (key === '\x1b[B' || k === 'j') { // ↓ / j → scroll down
1648
+ cascadeScroll = Math.min(maxOff, cascadeScroll + 1)
1649
+ await redraw(); return
1650
+ }
1651
+ if (key === '\x1b[5~') { // PageUp → scroll up by viewport
1652
+ cascadeScroll = Math.max(0, cascadeScroll - maxRows)
1653
+ await redraw(); return
1654
+ }
1655
+ if (key === '\x1b[6~') { // PageDown → scroll down by viewport
1656
+ cascadeScroll = Math.min(maxOff, cascadeScroll + maxRows)
1657
+ await redraw(); return
1658
+ }
1659
+ }
1660
+ }
1661
+
1634
1662
  // tab switching (6 tabs: 0=Dashboard 1=Trends 2=Compare 3=Board 4=Watch 5=Connect)
1635
1663
  let switched = false
1636
1664
  if (key === '\x1b[C') { activeTab = Math.min(5, activeTab + 1); switched = true }
@@ -1643,6 +1671,9 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1643
1671
  if (k === '6') { activeTab = 5; switched = true } // Connect = sign in / switch device
1644
1672
  if (k === 'c' && activeTab !== 5) { activeTab = 5; switched = true } // [C] → Connect from any read tab
1645
1673
 
1674
+ // SCROLL-VIEW: reset cascade scroll when leaving the Dashboard tab
1675
+ if (switched && activeTab !== 0) cascadeScroll = 0
1676
+
1646
1677
  // #9 (2026-06-27): Compare loads its FRESH verifier pull ON-DEMAND when the tab opens
1647
1678
  // (NOT on the Dashboard load path — that pull is a 5–60s scan). Paint the "pulling fresh
1648
1679
  // sources…" spinner first (compareData null → redraw shows it), then run the pull + re-render.
@@ -1714,6 +1745,7 @@ export async function runTui({ platform: initPlatform = 'claude', window: win =
1714
1745
 
1715
1746
  if (k === 'r') {
1716
1747
  status = 'refreshing…'
1748
+ cascadeScroll = 0 // SCROLL-VIEW: reset scroll on refresh
1717
1749
  // #9: on the Compare tab, [R] re-runs the FRESH verifier pull for the current platform
1718
1750
  // (loadAll no longer touches Compare). Clearing compareData shows the spinner first.
1719
1751
  if (activeTab === 2) {