great-cto 2.95.0 → 2.97.0

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.
@@ -46,6 +46,18 @@
46
46
  --text2: #8a9a92;
47
47
  --text3: #8b958c;
48
48
 
49
+
50
+ /* Focus ring — a separate token from --accent on purpose.
51
+ #00d97e measures 9.68:1 on the dark background and 1.87:1 on white, against
52
+ a 3.0 minimum for a non-text indicator. One colour cannot serve both themes,
53
+ and using the brand colour for focus made the light theme's ring effectively
54
+ invisible. Dark keeps the brand green; light gets #047857 (5.48:1). */
55
+ --focus-ring: #00d97e;
56
+ --rail-ok-bg: rgba(0, 217, 126, 0.12);
57
+ --rail-fail-bg: rgba(255, 84, 102, 0.12);
58
+ --surface-drawer: #0f1411;
59
+ --drawer-shadow: 0 16px 32px rgba(0, 0, 0, 0.5);
60
+
49
61
  --accent: #00d97e;
50
62
  --accent-2: #34e69b;
51
63
  --accent-soft: rgba(0, 217, 126, 0.10);
@@ -86,6 +98,7 @@
86
98
  --text: #0d1a14;
87
99
  --text2: #56655e;
88
100
  --text3: #55625b;
101
+ --focus-ring: #047857;
89
102
  --accent: #00d97e;
90
103
  --accent-2: #0a7d4d;
91
104
  --accent-soft: rgba(0, 217, 126, 0.12);
@@ -590,6 +603,24 @@ button { font-family: inherit; cursor: pointer; }
590
603
  transition: transform 0.2s ease;
591
604
  }
592
605
  .side.open { transform: translateX(0); }
606
+ /* A document is not a task.
607
+ *
608
+ * 480px is right for a task: a dozen short property rows read fine in a
609
+ * column. A markdown document is prose and tables — ARCH docs, ADRs, plans —
610
+ * and at 480px a table wraps into unreadable confetti and a paragraph becomes
611
+ * six words a line. Opening one at half the window is the difference between
612
+ * reading it here and giving up and opening an editor, which is the failure
613
+ * this panel exists to prevent.
614
+ *
615
+ * `vw` rather than `%`: the panel is absolutely positioned inside the main
616
+ * column, so 50% is half of what is left after the sidebar — 41% of the window
617
+ * on a 1280px laptop, which is not the half screen this is meant to be.
618
+ *
619
+ * `clamp` rather than a flat 50vw: never narrower than the task width on a
620
+ * laptop, and never so wide on an ultrawide that the line length itself becomes
621
+ * the problem. */
622
+ .side.wide { width: clamp(480px, 50vw, 1100px); }
623
+ @media (max-width: 900px) { .side, .side.wide { width: 100%; } }
593
624
  .side-top {
594
625
  display: flex; align-items: center; gap: 10px;
595
626
  padding: 14px 18px;
@@ -1653,10 +1684,12 @@ button { font-family: inherit; cursor: pointer; }
1653
1684
  --agent-failing: var(--status-blocked);
1654
1685
  --agent-retired: var(--text3);
1655
1686
  --agent-unused: var(--text3);
1656
- --rail-ok-bg: rgba(0, 217, 126, 0.12);
1657
- --rail-fail-bg: rgba(255, 84, 102, 0.12);
1658
- --surface-drawer: #0f1411;
1659
- --drawer-shadow: 0 16px 32px rgba(0, 0, 0, 0.5);
1687
+ /* The four theme-specific values that used to sit here have moved to the
1688
+ theme blocks at the top of this file. A bare `:root` this far down the
1689
+ stylesheet has the same specificity as `[data-theme="light"]` and comes
1690
+ after it, so it won — the LIGHT theme was being served the dark drawer
1691
+ surface (#0f1411) and the dark rail tints. The review read this as the dark
1692
+ theme missing tokens; it was the other way round, and the cascade is why. */
1660
1693
  }
1661
1694
 
1662
1695
  /* Fleet summary — uses existing .metric-card; nothing new */
@@ -1671,7 +1704,7 @@ button { font-family: inherit; cursor: pointer; }
1671
1704
  padding: 8px 12px; border-radius: 6px;
1672
1705
  }
1673
1706
  .fleet-search input[type="search"]:focus {
1674
- outline: 2px solid var(--accent); outline-offset: 1px;
1707
+ outline: 2px solid var(--focus-ring); outline-offset: 1px;
1675
1708
  border-color: transparent;
1676
1709
  }
1677
1710
  .fleet-search .search-hint { font-size: 11px; color: var(--text3); font-family: var(--mono); }
@@ -1696,7 +1729,7 @@ button { font-family: inherit; cursor: pointer; }
1696
1729
  transition: background-color 0.15s, color 0.15s, border-color 0.15s;
1697
1730
  }
1698
1731
  .facet-chip:hover { color: var(--text); border-color: var(--border-strong); }
1699
- .facet-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1732
+ .facet-chip:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1700
1733
  .facet-chip[aria-checked="true"] {
1701
1734
  background: var(--accent-soft); color: var(--accent);
1702
1735
  border-color: var(--accent-strong);
@@ -1735,7 +1768,7 @@ button { font-family: inherit; cursor: pointer; }
1735
1768
  transition: background-color 0.15s, border-color 0.15s;
1736
1769
  }
1737
1770
  .agent-row:hover { background: var(--bg-muted); border-color: var(--border); }
1738
- .agent-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1771
+ .agent-row:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1739
1772
  .agent-row[aria-current="true"] {
1740
1773
  background: var(--accent-soft);
1741
1774
  border-color: var(--accent-strong);
@@ -1824,7 +1857,7 @@ button { font-family: inherit; cursor: pointer; }
1824
1857
  display: flex; align-items: center; justify-content: center;
1825
1858
  }
1826
1859
  .agent-drawer-close:hover { color: var(--text); border-color: var(--border-strong); }
1827
- .agent-drawer-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1860
+ .agent-drawer-close:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1828
1861
  .agent-drawer-body {
1829
1862
  flex: 1; overflow-y: auto; padding: 16px 20px;
1830
1863
  display: flex; flex-direction: column; gap: 16px;
@@ -1866,7 +1899,7 @@ button { font-family: inherit; cursor: pointer; }
1866
1899
  padding: 6px 12px; border-radius: 6px;
1867
1900
  }
1868
1901
  .agent-actions button:hover { border-color: var(--border-strong); }
1869
- .agent-actions button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1902
+ .agent-actions button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1870
1903
  .agent-actions button.danger { color: var(--agent-failing); }
1871
1904
  .agent-actions button.danger:hover { border-color: var(--agent-failing); }
1872
1905
  .agent-actions button[disabled] { opacity: 0.4; cursor: not-allowed; }
@@ -2180,31 +2213,26 @@ button { font-family: inherit; cursor: pointer; }
2180
2213
  <span>Inbox</span>
2181
2214
  <span class="count" id="nav-inbox-count">0</span>
2182
2215
  </div>
2183
- <div class="nav-item" data-tab="kanban" onclick="switchTab('kanban', this)">
2216
+ <div class="nav-item" data-tab="kanban" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('kanban', this)">
2184
2217
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
2185
2218
  <span>Tasks</span>
2186
2219
  <span class="count" id="nav-task-count">0</span>
2187
2220
  </div>
2188
- <div class="nav-item" data-tab="dashboard" onclick="switchTab('dashboard', this)">
2221
+ <div class="nav-item" data-tab="dashboard" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('dashboard', this)">
2189
2222
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18 M7 14l3-3 4 4 5-5"/></svg>
2190
2223
  <span>Metrics</span>
2191
2224
  </div>
2192
- <div class="nav-item" data-tab="memory" onclick="switchTab('memory', this)">
2193
- <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
2194
- <span>Memory</span>
2195
- <span class="count" id="nav-memory-count">0</span>
2196
- </div>
2197
- <div class="nav-item" data-tab="sessions" onclick="switchTab('sessions', this)">
2198
- <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
2199
- <span>Sessions</span>
2200
- <span class="count" id="nav-sessions-count">0</span>
2225
+ <div class="nav-item" data-tab="docs" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('docs', this)">
2226
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
2227
+ <span>Docs</span>
2228
+ <span class="count" id="nav-docs-count">0</span>
2201
2229
  </div>
2202
- <div class="nav-item" data-tab="logs" onclick="switchTab('logs', this)">
2230
+ <div class="nav-item" data-tab="logs" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('logs', this)">
2203
2231
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
2204
- <span>Logs</span>
2232
+ <span>Activity</span>
2205
2233
  <span class="count" id="nav-logs-count">0</span>
2206
2234
  </div>
2207
- <div class="nav-item" data-tab="notifications" onclick="switchTab('notifications', this); loadNotifHistory()">
2235
+ <div class="nav-item" data-tab="notifications" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('notifications', this); loadNotifHistory()">
2208
2236
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9 M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
2209
2237
  <span>Notifications</span>
2210
2238
  <span id="nav-notifications-badge" class="share-badge off" style="margin-left:auto;">
@@ -2256,6 +2284,9 @@ button { font-family: inherit; cursor: pointer; }
2256
2284
  <div class="inbox-page">
2257
2285
  <div class="inbox-greet" id="inbox-greet">Good morning. Here's what needs your decision.</div>
2258
2286
  <div class="inbox-summary" id="inbox-summary"></div>
2287
+ <!-- Top rung of the evidence ladder: are the bytes that were reviewed
2288
+ still the bytes in the tree? Hidden while they match. -->
2289
+ <div id="inbox-receipt" style="display:none;margin:6px 0 2px;font-size:12px"></div>
2259
2290
 
2260
2291
  <!-- Resume — pick up where you left off -->
2261
2292
  <div class="resume-card" id="resume-card" style="display:none">
@@ -2304,6 +2335,17 @@ button { font-family: inherit; cursor: pointer; }
2304
2335
  </div>
2305
2336
  <div id="inbox-gates"></div>
2306
2337
  </div>
2338
+ <!-- Gates that did NOT wait for you. Placed directly under the decisions
2339
+ you still owe, because "what proceeded without me" is the question a
2340
+ tiered gate creates and nothing on this board answered. -->
2341
+ <div class="inbox-section" id="inbox-standdown-section" style="display:none">
2342
+ <div class="inbox-section-head">
2343
+ <span class="dot dot-amber"></span>
2344
+ <h3>Proceeded without you</h3>
2345
+ <span class="inbox-count" id="inbox-standdown-count">0</span>
2346
+ </div>
2347
+ <div id="inbox-standdown"></div>
2348
+ </div>
2307
2349
  <div class="inbox-section" id="inbox-p0-section">
2308
2350
  <div class="inbox-section-head">
2309
2351
  <span class="dot dot-red"></span>
@@ -2379,6 +2421,14 @@ button { font-family: inherit; cursor: pointer; }
2379
2421
  <h2>Session logs</h2>
2380
2422
  <span class="logs-hint">Written by <code>/save</code> · Restored by <code>/resume</code></span>
2381
2423
  </div>
2424
+ <!-- Sessions used to be its own sidebar entry. Both answered "what did
2425
+ the agents do" — this panel from written summaries, that one from raw
2426
+ transcripts — so a reader had to choose which to open before knowing
2427
+ which held the answer. One entry, two depths. -->
2428
+ <div style="display:flex;gap:8px;margin:0 0 12px">
2429
+ <button id="act-tab-logs" class="facet-chip active" onclick="setActivityView('logs')">Summaries</button>
2430
+ <button id="act-tab-sessions" class="facet-chip" onclick="setActivityView('sessions')">Transcripts</button>
2431
+ </div>
2382
2432
  <div class="logs-layout">
2383
2433
  <div class="logs-list" id="logs-list"><div class="empty">Loading…</div></div>
2384
2434
  <div class="logs-detail" id="logs-detail"><div class="empty">Select a session to view</div></div>
@@ -2482,6 +2532,11 @@ button { font-family: inherit; cursor: pointer; }
2482
2532
  <div id="sessions-body" class="muted">Loading…</div>
2483
2533
  </div>
2484
2534
 
2535
+ <div class="panel" id="panel-docs">
2536
+ <h1>Docs</h1>
2537
+ <div id="docs-body" class="muted">loading…</div>
2538
+ </div>
2539
+
2485
2540
  <div class="panel" id="panel-memory">
2486
2541
  <div class="memory-page">
2487
2542
  <div class="memory-side">
@@ -2713,7 +2768,12 @@ function greetByHour() {
2713
2768
  function renderInbox(d) {
2714
2769
  inboxData = d || {};
2715
2770
  const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
2716
- document.getElementById('nav-inbox-count').textContent = s.gates + s.p0 + s.blocked;
2771
+ // `needs_you` counts distinct objects. Summing the three categories double-counted
2772
+ // anything that matched two of them — a P0 that is also blocked read as two things
2773
+ // needing attention when it was one. Older payloads have no `needs_you`, so the sum
2774
+ // stays as the fallback rather than rendering nothing.
2775
+ document.getElementById('nav-inbox-count').textContent =
2776
+ Number.isFinite(s.needs_you) ? s.needs_you : s.gates + s.p0 + s.blocked;
2717
2777
  // Same rule as the all-clear card: "nothing urgent" is a claim about data we
2718
2778
  // may not have. When a read failed, say that instead of reassuring the user.
2719
2779
  const greetTail = anyDegraded()
@@ -2730,6 +2790,8 @@ function renderInbox(d) {
2730
2790
  renderInboxList('inbox-p0', d?.p0_open || [], 'inbox-p0-count', {});
2731
2791
  renderInboxList('inbox-blocked', d?.blocked || [], 'inbox-blocked-count', {});
2732
2792
  renderInboxList('inbox-stale', d?.stale_in_progress || [], 'inbox-stale-count', {});
2793
+ refreshStandDowns();
2794
+ refreshReceipt();
2733
2795
  // Idle focus: collapse the empty sections into one all-clear card when nothing needs a decision.
2734
2796
  const attention = (d?.pending_gates || []).length + (d?.p0_open || []).length
2735
2797
  + (d?.blocked || []).length + (d?.stale_in_progress || []).length;
@@ -2744,6 +2806,150 @@ function renderInbox(d) {
2744
2806
  'Some project data could not be read — counts below are incomplete.');
2745
2807
  }
2746
2808
 
2809
+ /**
2810
+ * Does the last approval still describe what is in the tree?
2811
+ *
2812
+ * The reviewing agents already record a hash-bound receipt of the bytes they
2813
+ * read. Nothing on this board looked at it, so the top rung of the evidence
2814
+ * ladder was being written and never read.
2815
+ *
2816
+ * Five readings, and only three of them speak:
2817
+ * differs reviewed files changed after the approval — the actionable one
2818
+ * unreadable we could not tell. Shown, because "cannot check" must never
2819
+ * render as "checked and fine"
2820
+ * extended new files appeared; nothing reviewed was altered — worth a
2821
+ * quiet line, since the addition was never reviewed either
2822
+ * matches silent. A clean state does not need a banner.
2823
+ * no-receipt silent. Nothing has been approved yet; saying so would nag
2824
+ * about a stage that has not happened.
2825
+ */
2826
+ async function refreshReceipt() {
2827
+ const el = document.getElementById('inbox-receipt');
2828
+ if (!el) return;
2829
+ const d = await api(`/api/receipt${pqs()}`);
2830
+ if (!d || d.state === 'matches' || d.state === 'no-receipt') { el.style.display = 'none'; return; }
2831
+
2832
+ const stamp = d.agent ? `${esc(d.agent)}${d.ts ? ' · ' + relTime(d.ts) : ''}` : '';
2833
+ const body = d.state === 'differs'
2834
+ ? `<b>${(d.changed || []).length + (d.removed || []).length} reviewed file(s) changed since ${stamp} approved</b>
2835
+ — the approval no longer describes what is in the tree.
2836
+ <span class="muted">${esc((d.changed || []).slice(0, 4).join(', '))}${(d.changed || []).length > 4 ? ' …' : ''}</span>`
2837
+ : d.state === 'unreadable'
2838
+ ? `<b>Could not check the review receipt</b> — <span class="muted">${esc(d.why || '')}</span>. This is not the same as a clean tree.`
2839
+ : `${(d.added || []).length} file(s) added since ${stamp} approved; nothing reviewed was altered.`;
2840
+
2841
+ const colour = d.state === 'matches' ? 'var(--text3)' : 'var(--status-gate)';
2842
+ el.style.display = '';
2843
+ el.innerHTML = `<span style="color:${colour}">${body}</span>`;
2844
+ }
2845
+
2846
+ /**
2847
+ * Is this document still true? — which is not the question `modified` answers.
2848
+ *
2849
+ * Three states, and the two that are not `fresh` look different from each other:
2850
+ *
2851
+ * stale the author's own `stale_after` has passed, or its date is older
2852
+ * than the threshold — say so, loudly, it is the actionable one
2853
+ * unknown nothing to judge it by, OR the file could not be read. Both are
2854
+ * shown, because a document nobody could judge must never render the
2855
+ * same as one that passed. That equivalence is why stale_after exists.
2856
+ * fresh nothing — a clean row should stay quiet
2857
+ *
2858
+ * Absent freshness data (an older board payload) also renders nothing, since
2859
+ * inventing a verdict from a missing field is the defect itself.
2860
+ */
2861
+ function freshnessBadge(doc) {
2862
+ const v = doc?.freshness;
2863
+ if (!v || v === 'fresh') return '';
2864
+ const unreadable = doc.freshnessBasis === 'unreadable';
2865
+ const colour = v === 'stale' ? 'var(--status-gate)' : 'var(--text3)';
2866
+ const label = v === 'stale' ? 'stale' : (unreadable ? 'could not read' : 'unknown');
2867
+ return `<span title="${esc(doc.freshnessWhy || '')}" style="font-family:var(--mono);font-size:10px;margin-left:6px;padding:0 4px;border:1px solid var(--border);border-radius:3px;color:${colour}">${esc(label)}</span>`;
2868
+ }
2869
+
2870
+ // Will this gate wait for you, or did the pipeline already go past it?
2871
+ //
2872
+ // `null` until fetched, and it stays null on any failure — every render path
2873
+ // treats "no tier information" as "this gate waits", which is the reading that
2874
+ // keeps a human in the loop when we do not know.
2875
+ let gateTiers = null;
2876
+ async function refreshGateTiers() {
2877
+ const d = await api(`/api/gate-tiers${pqs()}`);
2878
+ gateTiers = d && d.agents ? d : null;
2879
+ }
2880
+
2881
+ /**
2882
+ * The tier line for a gate card, or '' when it would say nothing true.
2883
+ *
2884
+ * Three readings, not two:
2885
+ * - tiering off for this project, or the agent unmeasured → nothing shown, the
2886
+ * gate waits, which is what the buttons already imply
2887
+ * - `gated` → nothing shown for the same reason
2888
+ * - `notify` / `notify-thin` → said out loud, because the pipeline did NOT
2889
+ * wait for this decision and the Approve button alone implies it did
2890
+ */
2891
+ function gateTierNote(t) {
2892
+ if (!gateTiers?.enabled || !gateTiers?.measured) return '';
2893
+ const info = gateTiers.agents?.[t.agent];
2894
+ if (!info || info.tier === 'gated') return '';
2895
+ const thin = info.tier === 'notify-thin';
2896
+ const colour = thin ? 'var(--status-gate)' : 'var(--text3)';
2897
+ // `notify-thin` is sound statistics over unmeasured coverage — one eval passed
2898
+ // conclusively, which says nothing about the rest of what this agent is for.
2899
+ // It is the reading most worth a second look, so it reads differently.
2900
+ const caveat = thin ? ' — one eval, coverage unmeasured' : '';
2901
+ return `<div style="margin-top:3px;font-size:11px;color:${colour}">
2902
+ <span style="font-family:var(--mono);border:1px solid var(--border);border-radius:3px;padding:0 4px">${esc(info.tier)}</span>
2903
+ the pipeline did not wait for this${esc(caveat)} · <span title="${esc(info.why || '')}">${esc((info.why || '').slice(0, 90))}</span>
2904
+ </div>`;
2905
+ }
2906
+
2907
+ // Gates that announced instead of waiting.
2908
+ //
2909
+ // Three states, not two. `none` hides the section — nothing has proceeded
2910
+ // unattended, which is a real and common answer. `unreadable` SHOWS it and says
2911
+ // so: a ledger that exists and could not be read must never render the same as
2912
+ // an empty one, because the whole reason this record exists is that a
2913
+ // stand-down nobody can see is indistinguishable from a gate nobody configured.
2914
+ async function refreshStandDowns() {
2915
+ const section = document.getElementById('inbox-standdown-section');
2916
+ const root = document.getElementById('inbox-standdown');
2917
+ const cnt = document.getElementById('inbox-standdown-count');
2918
+ if (!section || !root) return;
2919
+
2920
+ const d = await api(`/api/stand-downs${pqs()}`);
2921
+ if (!d) { section.style.display = 'none'; return; }
2922
+
2923
+ if (d.state === 'unreadable') {
2924
+ section.style.display = '';
2925
+ if (cnt) cnt.textContent = '?';
2926
+ root.innerHTML = `<div class="inbox-row"><span class="ttl">The stand-down ledger could not be read
2927
+ <span class="meta">${esc(d.why || '')} — gates may have proceeded unattended without appearing here</span></span></div>`;
2928
+ return;
2929
+ }
2930
+ if (d.state !== 'some' || !d.rows.length) { section.style.display = 'none'; root.innerHTML = ''; return; }
2931
+
2932
+ section.style.display = '';
2933
+ if (cnt) cnt.textContent = d.rows.length;
2934
+ // Newest first — the ledger appends, but a reader wants the most recent thing
2935
+ // that happened without them.
2936
+ root.innerHTML = d.rows.slice().reverse().map(r => {
2937
+ // `notify-thin` means the gate stood down on ONE eval: sound statistics,
2938
+ // unmeasured coverage. That is the row most worth a second look, so it is
2939
+ // the row that looks different.
2940
+ const thin = r.tier === 'notify-thin';
2941
+ const tierTag = `<span style="font-family:var(--mono);font-size:10px;padding:1px 5px;border-radius:3px;border:1px solid var(--border);color:${thin ? 'var(--status-gate)' : 'var(--text3)'}">${esc(r.tier || 'unknown')}</span>`;
2942
+ return `
2943
+ <div class="inbox-row">
2944
+ <span class="id">gate:${esc(r.gate || '?')}</span>
2945
+ <span class="ttl">${esc(r.agent || 'unknown agent')} proceeded without asking
2946
+ <span class="ttl-desc">${esc(r.evidence || '')}</span>
2947
+ <span class="meta">${tierTag} · <span title="${esc(r.ts || '')}">${r.ts ? relTime(r.ts) : 'no timestamp'}</span></span>
2948
+ </span>
2949
+ </div>`;
2950
+ }).join('');
2951
+ }
2952
+
2747
2953
  function renderInboxList(rootId, items, countId, opts = {}) {
2748
2954
  const root = document.getElementById(rootId);
2749
2955
  const cnt = document.getElementById(countId);
@@ -2753,9 +2959,17 @@ function renderInboxList(rootId, items, countId, opts = {}) {
2753
2959
  if (section) section.style.display = items.length ? '' : 'none';
2754
2960
  if (!items.length) { root.innerHTML = ''; return; }
2755
2961
  root.innerHTML = items.map(t => {
2962
+ // A task now appears in one section only, so the states it ALSO matched are
2963
+ // shown here rather than lost. Deduplicating the rows must not deduplicate
2964
+ // the facts: a gate that is also blocked is still blocked, and hiding that
2965
+ // to fix a double count would trade one wrong reading for another.
2966
+ const alsoTag = (t.also || []).length
2967
+ ? `<span title="also matched: ${esc((t.also || []).join(', '))}" style="color:var(--text3)">also ${esc((t.also || []).join(' + '))}</span>`
2968
+ : '';
2756
2969
  const meta = [
2757
2970
  t.agent ? `agent: ${esc(t.agent)}` : '',
2758
2971
  t.priority != null ? PRIORITY_LABEL[t.priority] : '',
2972
+ alsoTag,
2759
2973
  // Gates (the showApprove list) get the exact date+time the decision has been
2760
2974
  // waiting since; other rows keep the compact relative stamp.
2761
2975
  t.updated_at
@@ -2771,16 +2985,21 @@ function renderInboxList(rootId, items, countId, opts = {}) {
2771
2985
  </div>`
2772
2986
  : `<div class="actions"><span style="font-family:var(--mono);font-size:11px;color:var(--text3)">${t.status || ''}</span></div>`;
2773
2987
  const desc = t.description ? `<span class="ttl-desc">${esc(t.description.slice(0, 140))}${t.description.length > 140 ? '…' : ''}</span>` : '';
2988
+ // Only gate rows can stand down, so only they carry the note.
2989
+ const tierNote = opts.showApprove ? gateTierNote(t) : '';
2774
2990
  return `
2775
2991
  <div class="inbox-row" onclick='openSide(${JSON.stringify(t).replace(/'/g, "&apos;")})'>
2776
2992
  <span class="id">${esc(t.id || '')}</span>
2777
- <span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span></span>
2993
+ <span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
2778
2994
  ${actions}
2779
2995
  </div>`;
2780
2996
  }).join('');
2781
2997
  }
2782
2998
 
2783
2999
  async function refreshInbox() {
3000
+ // Tiers first: the gate rows are rendered from this same call, and a row drawn
3001
+ // before the tiers arrive would silently claim every gate is waiting.
3002
+ await refreshGateTiers();
2784
3003
  const d = await api(`/api/inbox${pqs()}`);
2785
3004
  if (d) renderInbox(d);
2786
3005
  refreshPipeline();
@@ -2842,6 +3061,33 @@ async function refreshResume() {
2842
3061
  if (d) renderResume(d);
2843
3062
  }
2844
3063
 
3064
+ /**
3065
+ * What a verdict says, in the width of a column.
3066
+ *
3067
+ * Two shapes reach here: the legacy `agent: free text` line, and the versioned
3068
+ * JSON record the verdict helper writes today. Only the first has anything
3069
+ * readable after the colon — for the second, splitting on `': '` yields the
3070
+ * whole record, which is how a JSON blob ended up rendered as a summary.
3071
+ */
3072
+ function summariseVerdict(v, raw) {
3073
+ let meta = null;
3074
+ const t = String(raw || '').trim();
3075
+ if (t.startsWith('{')) { try { meta = JSON.parse(t).meta || null; } catch { meta = null; } }
3076
+
3077
+ if (meta) {
3078
+ // `scope` is what the run was about; the rest are the artefacts it produced.
3079
+ const what = meta.scope || meta.feature || meta.task || null;
3080
+ const artefact = Object.values(meta).find(x => typeof x === 'string' && x.endsWith('.md'));
3081
+ const tail = what || (artefact ? artefact.split('/').pop() : null);
3082
+ return tail ? `${v.verdict || 'verdict'} · ${tail}` : (v.verdict || 'verdict');
3083
+ }
3084
+
3085
+ const i = t.indexOf(': ');
3086
+ if (i !== -1) return t.slice(i + 2);
3087
+ // Not JSON and not `agent: text` — a bare line, which is still readable.
3088
+ return t || (v.verdict || 'verdict');
3089
+ }
3090
+
2845
3091
  function renderResume(d) {
2846
3092
  const card = document.getElementById('resume-card');
2847
3093
  if (!card) return;
@@ -2868,8 +3114,16 @@ function renderResume(d) {
2868
3114
  const vu = (v.verdict || '').toUpperCase();
2869
3115
  const cls = ['APPROVED','DONE','PASS'].includes(vu) ? 'ok' :
2870
3116
  ['BLOCKED','FAIL','REJECTED'].includes(vu) ? 'fail' : '';
3117
+ // A verdict is written in one of two shapes: the legacy `AGENT: text` line,
3118
+ // and the versioned JSON record the helper writes today. This column split
3119
+ // on `': '` and printed whatever followed — which for the JSON shape is the
3120
+ // whole record, so the column that should read "DONE · board-review" showed
3121
+ // `{"v":1,"ts":"2026-08-09T06:19:37Z","agent":"design-advis…`.
3122
+ //
3123
+ // The structured fields are already parsed by the time they arrive; the raw
3124
+ // line is a fallback for the legacy shape, not the primary source.
2871
3125
  const raw = v.raw || '';
2872
- const after = raw.indexOf(': ') !== -1 ? raw.slice(raw.indexOf(': ') + 2) : raw;
3126
+ const after = summariseVerdict(v, raw);
2873
3127
  window.__resumeVerdicts = verdicts;
2874
3128
  return `<div class="resume-item" onclick="showVerdictDetail(${i})" role="button" tabindex="0">
2875
3129
  <span class="ri-tag ${cls}">${esc(v.agent || 'agent')}</span>
@@ -3302,6 +3556,234 @@ function mdToHtml(md) {
3302
3556
  });
3303
3557
  }
3304
3558
 
3559
+ // Activity — one entry, two depths.
3560
+ //
3561
+ // Sessions and Logs were separate sidebar items answering the same question,
3562
+ // "what did the agents do", from written summaries and from raw transcripts. A
3563
+ // reader had to choose which to open before knowing which one held the answer.
3564
+ function setActivityView(view) {
3565
+ const logsPanel = document.getElementById('logs-list')?.closest('.panel') || document.getElementById('panel-logs');
3566
+ const sessHost = document.getElementById('sessions-body');
3567
+ const logsHost = document.getElementById('logs-list')?.parentElement;
3568
+ const inPanel = document.getElementById('activity-sessions-host');
3569
+
3570
+ document.getElementById('act-tab-logs')?.classList.toggle('active', view === 'logs');
3571
+ document.getElementById('act-tab-sessions')?.classList.toggle('active', view === 'sessions');
3572
+
3573
+ // The transcript view is rendered by the Sessions panel's own code; it is moved
3574
+ // into this panel rather than reimplemented, so the two cannot drift apart.
3575
+ let host = inPanel;
3576
+ if (!host) {
3577
+ host = document.createElement('div');
3578
+ host.id = 'activity-sessions-host';
3579
+ logsPanel?.appendChild(host);
3580
+ }
3581
+ if (view === 'sessions') {
3582
+ if (logsHost) logsHost.style.display = 'none';
3583
+ host.style.display = '';
3584
+ if (sessHost && sessHost.parentElement !== host) host.appendChild(sessHost);
3585
+ loadSessions();
3586
+ } else {
3587
+ host.style.display = 'none';
3588
+ if (logsHost) logsHost.style.display = '';
3589
+ }
3590
+ }
3591
+
3592
+ // ── Docs ────────────────────────────────────────────────────────────────────
3593
+ //
3594
+ // A project accumulates documentation nobody can find: this repository has
3595
+ // twenty architecture documents, eleven ADRs and thirty-one plans, and the only
3596
+ // way to read one was to know its filename.
3597
+ //
3598
+ // The two diagrams are computed on request rather than stored. docs/ARCHITECTURE.md
3599
+ // is hand-drawn, three months old, and says "34 agents" where there are
3600
+ // sixty-nine — a drawn picture goes stale silently, a derived one cannot.
3601
+ let _docsCache = null;
3602
+
3603
+ /**
3604
+ * The sidebar badge, without rendering the panel.
3605
+ *
3606
+ * It was written only inside `loadDocs`, which runs when the tab is opened — so
3607
+ * a project with a hundred and seventy-five documents showed `0` until you
3608
+ * clicked, and `0` is the one number that reads as "there is nothing here".
3609
+ * A dash says "not counted yet", which is what it actually means.
3610
+ */
3611
+ async function refreshDocsCount() {
3612
+ const el = document.getElementById('nav-docs-count');
3613
+ if (!el) return;
3614
+ let d;
3615
+ try { d = await api(`/api/docs${pqs()}`); } catch { d = null; }
3616
+ el.textContent = (d && !d.error && typeof d.total === 'number') ? d.total : '—';
3617
+ }
3618
+
3619
+ async function loadDocs() {
3620
+ const el = document.getElementById('docs-body');
3621
+ el.innerHTML = '<span class="muted">loading…</span>';
3622
+ let d;
3623
+ try {
3624
+ // pqs() carries the selected project — without it this panel would repeat
3625
+ // the defect that made sixteen endpoints show the server's own directory.
3626
+ d = await api(`/api/docs${pqs()}`);
3627
+ } catch (e) {
3628
+ // "Could not read" must never render as "there is nothing here" — the
3629
+ // failure this board keeps making in other forms.
3630
+ el.innerHTML = `<div class="card"><b>Could not read the project's documents.</b>
3631
+ <div class="muted" style="margin-top:6px">${String(e && e.message || e)}</div></div>`;
3632
+ return;
3633
+ }
3634
+ _docsCache = d;
3635
+ document.getElementById('nav-docs-count').textContent = d.total || 0;
3636
+
3637
+ const pipeline = d.pipeline
3638
+ ? `<div class="card"><b>Pipeline</b> <span class="muted">— how a feature moves, drawn from shared/pipeline.toml</span>
3639
+ ${renderDeclaredPipeline(d.pipeline)}
3640
+ <details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
3641
+ <pre style="white-space:pre-wrap;font-size:11px">${esc(d.pipeline)}</pre></details></div>`
3642
+ : '';
3643
+
3644
+ const map = d.map ? `<div class="card"><b>Components</b>
3645
+ <span class="muted">— derived ${ago(d.map.generatedAt)}, so it cannot disagree with the code</span>
3646
+ <div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:10px">
3647
+ ${d.map.nodes.map(n => `<div style="border:1px solid var(--border,#ddd);border-radius:6px;padding:8px 10px;min-width:120px">
3648
+ <div><b>${esc(n.label)}</b> <span class="muted">${n.count}</span></div>
3649
+ <div class="muted" style="font-size:11px">${esc(n.what || '')}</div></div>`).join('')}
3650
+ </div>
3651
+ ${d.map.edges.length ? `<div class="muted" style="margin-top:8px;font-size:11px">
3652
+ ${d.map.edges.map(e => `${esc(e.from)}→${esc(e.to)} (${e.count})`).join(' · ')}</div>` : ''}
3653
+ <details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
3654
+ <pre style="white-space:pre-wrap;font-size:11px">${esc(d.map.mermaid)}</pre></details></div>` : '';
3655
+
3656
+ const groups = (d.groups || []).map(g => `<div class="card">
3657
+ <b>${esc(g.label)}</b> <span class="muted">${g.docs.length} — ${esc(g.why || '')}</span>
3658
+ <div style="margin-top:8px">${g.docs.map(doc => `
3659
+ <div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
3660
+ <a href="#" onclick="openDoc('${esc(doc.path)}');return false">${esc(doc.title)}</a>
3661
+ <span class="muted" style="font-size:11px"> · ${esc(doc.path)} · ${ago(doc.modified)}</span>
3662
+ ${freshnessBadge(doc)}
3663
+ </div>`).join('')}</div>
3664
+ </div>`).join('');
3665
+
3666
+ // Memory used to be its own tab listing PROJECT.md, CODEBASE.md and lessons —
3667
+ // the same files this panel's "This project" group already showed. One section
3668
+ // for documentation, as asked; what Memory knew that a directory walk does not
3669
+ // is folded in rather than dropped.
3670
+ const mem = await loadContextLayers();
3671
+
3672
+ // The document itself opens in the side panel now, so this panel no longer
3673
+ // needs a place to append one.
3674
+ el.innerHTML = pipeline + map + mem + groups;
3675
+ }
3676
+
3677
+ /**
3678
+ * The context an agent reads, and the patterns learned across projects.
3679
+ *
3680
+ * A directory walk finds files that exist. This list is the CANONICAL set of
3681
+ * layers, so a layer that is absent is information — "this project has recorded
3682
+ * no lessons yet" is a fact about the project, not an empty row to hide. Three
3683
+ * of them live in ~/.great_cto and are shared by every project, which is why
3684
+ * they never appeared in a per-project walk at all.
3685
+ */
3686
+ async function loadContextLayers() {
3687
+ let m;
3688
+ try { m = await api(`/api/memory${pqs()}`); } catch { m = null; }
3689
+ if (!m || m.error) {
3690
+ return `<div class="card"><b>Agent context</b>
3691
+ <div class="muted" style="margin-top:6px">could not be read — ${esc((m && m.error) || degradedFor('/api/memory') || 'no response')}</div></div>`;
3692
+ }
3693
+
3694
+ const layers = (m.layers || []).map(l => {
3695
+ const global = String(l.path || '').includes('/.great_cto/') && !String(l.path || '').includes(projectRootHint());
3696
+ const where = global ? ' <span class="muted" style="font-size:11px">shared by every project</span>' : '';
3697
+ return l.exists
3698
+ ? `<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
3699
+ <a href="#" onclick="openDoc('${esc(l.path)}');return false">${esc(l.id)}</a>${where}
3700
+ <span class="muted" style="font-size:11px"> · ${esc(l.path)}</span></div>`
3701
+ : `<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)" class="muted">
3702
+ ${esc(l.id)} — not written yet${where}</div>`;
3703
+ }).join('');
3704
+
3705
+ const pats = (m.patterns || []).map(p =>
3706
+ `<div style="padding:4px 0" class="muted">${esc(p.id)} — ${esc(p.title || '')}</div>`).join('');
3707
+
3708
+ return `<div class="card">
3709
+ <b>Agent context</b> <span class="muted">${(m.layers || []).filter(l => l.exists).length} of ${(m.layers || []).length} written — what an agent reads before it starts</span>
3710
+ <div style="margin-top:8px">${layers}</div>
3711
+ </div>` + (pats ? `<div class="card">
3712
+ <b>Learned patterns</b> <span class="muted">${(m.patterns || []).length} — failures seen often enough to be written down</span>
3713
+ <div style="margin-top:8px">${pats}</div></div>` : '');
3714
+ }
3715
+
3716
+ /** The selected project's root, for telling a shared layer from a local one. */
3717
+ function projectRootHint() {
3718
+ const crumb = document.querySelector('.crumb-project, #crumb-project');
3719
+ return (crumb && crumb.textContent || '').trim() || '\u0000';
3720
+ }
3721
+
3722
+ /** The pipeline as boxes and gates — no diagram library, so it always renders. */
3723
+ // Named `renderDeclaredPipeline`, not `renderPipeline`, because there is already
3724
+ // a `renderPipeline(stages)` above that draws the LIVE rail. Both were declared
3725
+ // at the top level of this one <script>, so the later declaration won and every
3726
+ // call carrying an array of stages — refreshPipeline() and the SSE handler —
3727
+ // reached this mermaid parser instead. It returned a string nobody used and
3728
+ // never touched `#pipeline-track`, so the rail stayed the empty div in the
3729
+ // markup and the status stayed the hardcoded `idle` beside it.
3730
+ //
3731
+ // The first section of the first screen therefore read "idle" whatever the
3732
+ // pipeline was doing: not an empty state, but a confident wrong answer, which is
3733
+ // the failure this project spends its time removing.
3734
+ function renderDeclaredPipeline(mermaid) {
3735
+ const edges = [];
3736
+ for (const line of String(mermaid).split('\n')) {
3737
+ const m = line.match(/^\s*(\w+)\s*-->(?:\|([^|]*)\|)?\s*(\w+)/);
3738
+ if (m) edges.push({ from: m[1], gate: (m[2] || '').trim(), to: m[3], join: false });
3739
+ const j = line.match(/^\s*(\w+)\s*-\.->\|join\|\s*(\w+)/);
3740
+ if (j) edges.push({ from: j[1], to: j[2], join: true });
3741
+ }
3742
+ if (!edges.length) return '<div class="muted">no transitions declared</div>';
3743
+ const name = (id) => id.replace(/_/g, '-');
3744
+ return `<div style="margin-top:10px;font-size:12px">${edges.map(e => `
3745
+ <div style="display:flex;align-items:center;gap:8px;padding:3px 0">
3746
+ <span style="border:1px solid var(--border,#ddd);border-radius:4px;padding:2px 8px">${esc(name(e.from))}</span>
3747
+ <span class="muted">${e.join ? '⋯ join ⋯' : (e.gate ? `→ ${esc(e.gate)} →` : '→')}</span>
3748
+ <span style="border:1px solid var(--border,#ddd);border-radius:4px;padding:2px 8px">${esc(name(e.to))}</span>
3749
+ </div>`).join('')}</div>`;
3750
+ }
3751
+
3752
+ /**
3753
+ * Open a document in the side panel — the same drawer a task, a verdict and a
3754
+ * decision open in.
3755
+ *
3756
+ * It used to append the rendered markdown to the BOTTOM of the Docs panel and
3757
+ * scroll there, so reading a document meant losing the list you were browsing,
3758
+ * and closing it meant scrolling back up. Every other section of this board
3759
+ * answers a click by sliding the detail in from the right; this one invented its
3760
+ * own mechanic, and a reader has to learn two.
3761
+ */
3762
+ async function openDoc(p) {
3763
+ const panel = document.getElementById('side-panel');
3764
+ const body = document.getElementById('side-body');
3765
+ const idEl = document.getElementById('side-id');
3766
+ const tl = document.getElementById('side-timeline');
3767
+ if (!panel || !body) return;
3768
+
3769
+ idEl.textContent = p.split('/').pop();
3770
+ body.innerHTML = '<div style="padding:18px" class="muted">loading…</div>';
3771
+ if (tl) tl.innerHTML = '';
3772
+ panel.classList.add('open', 'wide');
3773
+
3774
+ try {
3775
+ const q = pqs();
3776
+ const d = await api(`/api/doc?path=${encodeURIComponent(p)}${q ? '&' + q.slice(1) : ''}`);
3777
+ if (!d || d.error) throw new Error(d?.error || 'doc not found');
3778
+ idEl.textContent = d.name || p;
3779
+ body.innerHTML = `<div class="muted" style="font-size:11px;margin-bottom:10px">${esc(d.path)}</div>
3780
+ <div class="side-desc md">${mdToHtml(d.content)}</div>`;
3781
+ } catch (e) {
3782
+ body.innerHTML = `<div class="side-desc"><b>Could not open ${esc(p)}</b>
3783
+ <div class="muted" style="margin-top:6px">${esc(String(e && e.message || e))}</div></div>`;
3784
+ }
3785
+ }
3786
+
3305
3787
  // ── Sessions ────────────────────────────────────────────────────────────────
3306
3788
  //
3307
3789
  // Every other panel answers "what happened". This one answers "how", which is
@@ -3402,10 +3884,28 @@ async function searchTranscripts() {
3402
3884
 
3403
3885
  async function loadMemory() {
3404
3886
  const d = await api(`/api/memory${pqs()}`);
3405
- memoryData = d || { layers: [], patterns: [] };
3887
+ // `d || {layers: []}` collapsed a failed read into "this project has no
3888
+ // memory" — the two look identical to a reader and mean opposite things.
3889
+ if (!d || d._neterr || d.error) {
3890
+ memoryData = { layers: [], patterns: [] };
3891
+ renderMemorySidebar();
3892
+ const c = document.getElementById('nav-memory-count'); if (c) c.textContent = '—';
3893
+ renderDegradedBanner('degraded-memory',
3894
+ (d && (d.error || (d._neterr && 'board not reachable — restart it'))) || 'no response',
3895
+ 'Memory could not be read');
3896
+ return;
3897
+ }
3898
+ renderDegradedBanner('degraded-memory', degradedFor('/api/memory'), 'Memory could not be read');
3899
+ memoryData = d;
3406
3900
  renderMemorySidebar();
3407
- document.getElementById('nav-memory-count').textContent =
3408
- (memoryData.layers || []).filter(l => l.exists).length + (memoryData.patterns || []).length;
3901
+ // Memory folded into Docs, so this badge no longer exists. It was still
3902
+ // written unconditionally, and the TypeError left the rest of this function —
3903
+ // including the auto-select below — unreached on every page load.
3904
+ const memCount = document.getElementById('nav-memory-count');
3905
+ if (memCount) {
3906
+ memCount.textContent =
3907
+ (memoryData.layers || []).filter(l => l.exists).length + (memoryData.patterns || []).length;
3908
+ }
3409
3909
  // Auto-select first existing layer
3410
3910
  if (!activeMemKey) {
3411
3911
  const first = (memoryData.layers || []).find(l => l.exists);
@@ -3628,6 +4128,7 @@ async function switchProject(slug) {
3628
4128
  try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
3629
4129
  try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
3630
4130
  loadMemory();
4131
+ refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
3631
4132
  refreshCost();
3632
4133
  refreshPipeline();
3633
4134
  refreshLogs(); // populate the Logs nav badge (was lazy — stuck at 0 until the tab was opened)
@@ -3816,6 +4317,7 @@ async function init() {
3816
4317
  try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
3817
4318
  try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
3818
4319
  loadMemory();
4320
+ refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
3819
4321
  refreshCost();
3820
4322
  refreshPipeline();
3821
4323
  refreshLogs(); // populate the Logs nav badge (was lazy — stuck at 0 until the tab was opened)
@@ -3875,7 +4377,15 @@ function api(url, opts) {
3875
4377
  if (d) { try { BOARD_DEGRADED[key] = decodeURIComponent(d); } catch { BOARD_DEGRADED[key] = d; } }
3876
4378
  else delete BOARD_DEGRADED[key];
3877
4379
  return r.json();
3878
- }).catch(() => null);
4380
+ }).catch(() => {
4381
+ // A request that never completed is the one case the degradation mechanism
4382
+ // could not see: the header lives on a response, and there is no response.
4383
+ // So the board went quiet exactly when it had lost contact with itself —
4384
+ // and worse, a key deleted by an earlier success stayed deleted, leaving
4385
+ // every panel looking healthy while nothing could be read at all.
4386
+ BOARD_DEGRADED[key] = 'the board is not reachable — is the server still running?';
4387
+ return null;
4388
+ });
3879
4389
  }
3880
4390
 
3881
4391
  function connectSSE() {
@@ -4647,7 +5157,10 @@ function openSide(task) {
4647
5157
  ${acceptanceSec}
4648
5158
  ${notesSec}
4649
5159
  ${task.close_reason ? `<div class="result-box"><div class="side-section-head">Close reason</div>${esc(task.close_reason)}</div>` : ''}`;
4650
- document.getElementById('side-panel').classList.add('open');
5160
+ const sidePanel = document.getElementById('side-panel');
5161
+ sidePanel.classList.add('open');
5162
+ // A task after a document: back to the narrower column the rows were sized for.
5163
+ sidePanel.classList.remove('wide');
4651
5164
  // Clear and load timeline
4652
5165
  const tlEl = document.getElementById('side-timeline');
4653
5166
  if (tlEl) tlEl.innerHTML = '';
@@ -4682,6 +5195,7 @@ async function openDocInSide(rel) {
4682
5195
  const d = await api(`/api/doc?path=${encodeURIComponent(rel)}${qs}`);
4683
5196
  if (!d || d.error) { showToast('✗ ' + esc(d?.error || 'doc not found'), 'error', 3500); return; }
4684
5197
  document.getElementById('side-id').textContent = d.name;
5198
+ document.getElementById('side-panel').classList.add('wide');
4685
5199
  document.getElementById('side-body').innerHTML = `
4686
5200
  <button class="gate-btn" onclick="openSide(_sideTask)" style="margin-bottom:12px">← back to task</button>
4687
5201
  <div class="side-desc md">${mdRender(d.content)}</div>`;
@@ -4694,6 +5208,11 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSide();
4694
5208
 
4695
5209
  /* ── Tabs ───────────────────────────────────────────────────────────────── */
4696
5210
  function switchTab(id, el) {
5211
+ // `agents` has no sidebar entry by design: it is a drill-in from Dashboard.
5212
+ // Without this the call fell through to code that expects a nav element and
5213
+ // left the sidebar with nothing selected.
5214
+ if (!el) el = document.querySelector(`.nav-item[data-tab="${id}"]`)
5215
+ || document.querySelector('.nav-item[data-tab="dashboard"]');
4697
5216
  currentTab = id;
4698
5217
  // sidebar nav
4699
5218
  document.querySelectorAll('.nav-item[data-tab]').forEach(t => {
@@ -4708,15 +5227,24 @@ function switchTab(id, el) {
4708
5227
  // crumb — `labels` used to be referenced here but is only ever a function-local in
4709
5228
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
4710
5229
  // loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
4711
- const TAB_LABELS = { dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', memory: 'Memory', sessions: 'Sessions', logs: 'Logs', notifications: 'Notifications', metrics: 'Metrics' };
5230
+ const TAB_LABELS = { docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
4712
5231
  const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
4713
5232
  if (id === 'dashboard' || id === 'agents') {
4714
- api(`/api/metrics${pqs()}`).then(m => { if (m) { metrics = m; renderDashboard(m); } });
5233
+ // pqsd(), not pqs(): this call omitted `days` entirely, so the server applied
5234
+ // its 30-day default and overwrote whatever window the period chips had
5235
+ // selected. Pick "7d", switch tabs, come back — the chip still read 7d and
5236
+ // the numbers were thirty days old, which is the period selector appearing
5237
+ // not to work at all.
5238
+ //
5239
+ // Through the sequence guard too, so a tab switch cannot land after a newer
5240
+ // period request and clobber it.
5241
+ _refreshMetricsRaced(`/api/metrics${pqsd()}`).then(m => { if (m) { metrics = m; renderDashboard(m); } });
4715
5242
  if (id === 'dashboard') refreshCost();
4716
5243
  if (id === 'agents') refreshAgentsInstalled();
4717
5244
  }
4718
5245
  if (id === 'inbox') refreshInbox();
4719
- if (id === 'memory') loadMemory();
5246
+ if (id === 'logs') setActivityView('logs');
5247
+ if (id === 'docs') loadDocs();
4720
5248
  if (id === 'sessions') loadSessions();
4721
5249
  if (id === 'logs') refreshLogs();
4722
5250
  if (id === 'notifications') loadNotifications();
@@ -4732,6 +5260,12 @@ async function refreshLogs() {
4732
5260
  const countEl = document.getElementById('nav-logs-count'); if (countEl) countEl.textContent = '0';
4733
5261
  return;
4734
5262
  }
5263
+ // The server already says WHY a read came back short — /api/logs has emitted
5264
+ // X-Board-Degraded since the tasks fix, and until now the UI threw it away.
5265
+ // An unreadable logs directory rendered as a project that had never run.
5266
+ renderDegradedBanner('degraded-logs', degradedFor('/api/logs'),
5267
+ 'Session logs could not be read');
5268
+
4735
5269
  allLogs = data.logs || [];
4736
5270
  const countEl = document.getElementById('nav-logs-count');
4737
5271
  if (countEl) countEl.textContent = allLogs.length;