@jhizzard/termdeck 1.2.0 → 1.4.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.
@@ -25,7 +25,14 @@
25
25
  // works if the endpoint 404s on an older server during a rolling
26
26
  // upgrade — Claude only, anchored binary match.
27
27
  agentAdapters: [{ name: 'claude', sessionType: 'claude-code', binary: 'claude', costBand: 'pay-per-token' }],
28
- focusedId: null
28
+ focusedId: null,
29
+ // Sprint 65 T1 — selected project-filter chip ('' = All). Hydrated from
30
+ // localStorage in init(); a TDZ on PROJECT_FILTER_KEY prevents reading
31
+ // it here in the state literal.
32
+ projectFilter: '',
33
+ // Sprint 65 T1 (c) — global terminal font size (xterm.js default 13).
34
+ // Hydrated from localStorage in init() (same TDZ reason as projectFilter).
35
+ fontSize: 13
29
36
  };
30
37
 
31
38
  // ===== API helpers =====
@@ -33,11 +40,25 @@
33
40
  const opts = { method, headers: { 'Content-Type': 'application/json' } };
34
41
  if (body) opts.body = JSON.stringify(body);
35
42
  const res = await fetch(`${API}${path}`, opts);
36
- return res.json();
43
+ // Success path: unchanged — return the parsed JSON body verbatim.
44
+ if (res.ok) return res.json();
45
+ // Sprint 65 T1 (1.3b) — non-2xx: many callers gate on `.error` and
46
+ // otherwise treat the body as success. A 4xx/5xx body without an
47
+ // `error` key (e.g. the dead-panel shape 410 {ok:false,code,message})
48
+ // would be misreported as delivered. annotateApiFailure() synthesizes a
49
+ // uniform `error` field and surfaces `_httpStatus` for precise callers.
50
+ let data = null;
51
+ try { data = await res.json(); } catch (err) { /* non-JSON error body */ }
52
+ return annotateApiFailure(data, res.status);
37
53
  }
38
54
 
39
55
  // ===== Initialize =====
40
56
  async function init() {
57
+ // Sprint 65 T1 — hydrate saved client prefs before any panel mounts: the
58
+ // project-filter chip (so a new tile filters right from frame 1) and the
59
+ // terminal font size (so restored panels open at the operator's size).
60
+ state.projectFilter = loadProjectFilter();
61
+ state.fontSize = loadFontSize();
41
62
  // Load config
42
63
  state.config = await api('GET', '/api/config');
43
64
  updateRagIndicator();
@@ -105,6 +126,14 @@
105
126
 
106
127
  updateEmptyState();
107
128
 
129
+ // Sprint 65 T1 — initial restore complete: subsequent createTerminalPanel
130
+ // calls are user-initiated launches, so arm the born-hidden chip guard.
131
+ _initialLoadComplete = true;
132
+ // Render chips + route the ORCH row now that the restored panels exist.
133
+ refreshDashboardChrome();
134
+ // Sprint 65 T1 (c) — sync the topbar font-size label to the saved size.
135
+ applyFontSizeToAll();
136
+
108
137
  // Rumen insights badge + briefing (no-op when server reports enabled:false)
109
138
  setupRumen();
110
139
 
@@ -433,7 +462,9 @@
433
462
  </div>
434
463
  `;
435
464
 
436
- document.getElementById('termGrid').appendChild(panel);
465
+ // Sprint 65 T1 (1.2) — orchestrator panels render in the pinned ORCH
466
+ // row above the grid; everything else goes in the grid.
467
+ placePanel(panel, meta);
437
468
 
438
469
  // Sprint 42 T4: drag/drop reorder. Inject identifier is the session
439
470
  // UUID, so DOM reorder is purely visual — Alt+1…9 (creation-order),
@@ -443,7 +474,7 @@
443
474
  // Create xterm.js instance
444
475
  const terminal = new Terminal({
445
476
  fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace",
446
- fontSize: 13,
477
+ fontSize: state.fontSize,
447
478
  lineHeight: 1.3,
448
479
  cursorBlink: true,
449
480
  cursorStyle: 'bar',
@@ -499,6 +530,11 @@
499
530
  refreshPanelIndices();
500
531
  renderSwitcher();
501
532
  break;
533
+ case 'panel_exited':
534
+ // Sprint 65 T1 (1.3) — T2 broadcasts this when a PTY exits.
535
+ // Primary path for auto-removing the dead tile from the grid.
536
+ handlePanelExited(msg.sessionId, msg.exitCode);
537
+ break;
502
538
  case 'status_broadcast':
503
539
  updateGlobalStats(msg.sessions);
504
540
  break;
@@ -592,6 +628,11 @@
592
628
  if (replyBtn) replyBtn.disabled = state.sessions.size < 2;
593
629
  refreshAllReplyFormsFor(id);
594
630
  refreshPanelIndices();
631
+ // Sprint 65 T1 — a user-launched panel must never be born hidden behind a
632
+ // stale chip filter (T3 20:10 / T4 20:11): switch the filter to its
633
+ // project first, then refresh chrome so the new tile is visible.
634
+ revealNewPanelIfFiltered(meta);
635
+ refreshDashboardChrome();
595
636
 
596
637
  // Handle window resize
597
638
  const resizeObserver = new ResizeObserver(() => {
@@ -611,6 +652,486 @@
611
652
  return { terminal, ws, fitAddon };
612
653
  }
613
654
 
655
+ // ===== Sprint 65: project-filter chips + ORCH-panel pin + tile lifecycle =====
656
+ // Brad's 2026-05-13 v2 spec (BACKLOG § D.5) — three dashboard-reliability
657
+ // surfaces sharing one client-side lane:
658
+ // 1.1 project-filter chips — a per-project visibility filter above the grid
659
+ // 1.2 ORCH pin — panels with meta.role==='orchestrator' get a dedicated
660
+ // always-visible row + gold/amber treatment, outside the chip filter
661
+ // 1.3 tile auto-removal — dead PTY panels leave the grid instead of
662
+ // lingering as "dead" tiles (Brad's "18 windows, 10 dead codex cli")
663
+ //
664
+ // Design note: TermDeck panels are persistent DOM + live xterm.js + a
665
+ // per-panel WebSocket. They are created once (createTerminalPanel) and
666
+ // never re-rendered per frame — status_broadcast only mutates meta. So the
667
+ // chip row and filter work by toggling classes on existing tiles, never by
668
+ // tearing panels down; ORCH routing is decided at panel-create time and
669
+ // re-checked cheaply on each broadcast.
670
+
671
+ const PROJECT_FILTER_KEY = 'termdeck.dashboard.projectFilter';
672
+ // Belt-and-suspenders thresholds for tile reconciliation (reconcileExitedPanels).
673
+ // ORPHAN_GRACE_MS > the tile-exit grace so the primary panel_exited path
674
+ // always wins when the WS frame is delivered.
675
+ const ORPHAN_GRACE_MS = 5000;
676
+ const STALE_EXITED_MS = 60000;
677
+ let _chromeRefreshScheduled = false;
678
+ // false during init()'s panel-restore loop, true afterward. Gates the
679
+ // born-hidden chip guard so a saved filter is honored on reload but a
680
+ // user-launched panel is always revealed.
681
+ let _initialLoadComplete = false;
682
+
683
+ // --- Pure helpers (no DOM, no globals — unit-tested in
684
+ // tests/dashboard-panels-client.test.js via the vm-extract pattern) ---
685
+
686
+ // Distinct non-null project tags across the given session-meta list, plus
687
+ // whether any panel carries no project at all. `metas` is an array of
688
+ // session.meta objects. Exited panels are excluded — chips count live work.
689
+ function discoverPanelProjects(metas) {
690
+ const projects = [];
691
+ const seen = Object.create(null);
692
+ let hasNullProject = false;
693
+ for (const m of (metas || [])) {
694
+ if (!m || m.status === 'exited') continue;
695
+ const p = m.project;
696
+ if (p === null || p === undefined || p === '') {
697
+ hasNullProject = true;
698
+ continue;
699
+ }
700
+ if (!seen[p]) {
701
+ seen[p] = true;
702
+ projects.push(p);
703
+ }
704
+ }
705
+ projects.sort();
706
+ return { projects: projects, hasNullProject: hasNullProject };
707
+ }
708
+
709
+ // Count of live (non-exited) panels for one chip. The "All" chip passes
710
+ // project==='' and counts every live panel; a project chip counts only its
711
+ // own. Orchestrator panels are counted under their project too — the ORCH
712
+ // pin is a placement, not an exclusion from the totals.
713
+ function countPanelsForProject(metas, project) {
714
+ let n = 0;
715
+ for (const m of (metas || [])) {
716
+ if (!m || m.status === 'exited') continue;
717
+ if (project === '' || project === null || project === undefined) { n++; continue; }
718
+ if (m.project === project) n++;
719
+ }
720
+ return n;
721
+ }
722
+
723
+ // Whether a grid tile with the given project should be visible under the
724
+ // current chip selection. The "All" selection ('') shows everything; a
725
+ // project selection shows only exact matches (null-project panels are
726
+ // hidden under any specific-project filter — they surface only under All).
727
+ function isPanelVisibleUnderFilter(panelProject, selectedFilter) {
728
+ if (selectedFilter === '' || selectedFilter === null || selectedFilter === undefined) return true;
729
+ return panelProject === selectedFilter;
730
+ }
731
+
732
+ // The chip row is only worth showing when there is real filtering to do:
733
+ // at least two project buckets, or one project plus some untagged panels.
734
+ function shouldShowChipRow(projects, hasNullProject) {
735
+ const n = (projects || []).length;
736
+ return n >= 2 || (n >= 1 && hasNullProject === true);
737
+ }
738
+
739
+ // Approach A (Brad's 2026-05-13 spec): orchestrator identity is the
740
+ // explicit meta.role flag, never inferred from cwd.
741
+ function isOrchestratorRole(role) {
742
+ return role === 'orchestrator';
743
+ }
744
+
745
+ // Belt-and-suspenders for missed panel_exited frames: panel ids the
746
+ // dashboard still has a tile for, but which no longer appear in the
747
+ // server's broadcast session list. Works whether or not T2 filters exited
748
+ // sessions out of status_broadcast.
749
+ function findOrphanedPanelIds(knownPanelIds, broadcastSessionIds) {
750
+ const live = Object.create(null);
751
+ for (const id of (broadcastSessionIds || [])) live[id] = true;
752
+ const orphaned = [];
753
+ for (const id of (knownPanelIds || [])) {
754
+ if (!live[id]) orphaned.push(id);
755
+ }
756
+ return orphaned;
757
+ }
758
+
759
+ // 1.3b — give a non-2xx API body a uniform failure signal. Many callers
760
+ // gate on `.error`; a 4xx/5xx body that lacks it (e.g. the Sprint 65
761
+ // dead-panel shape 410 {ok:false,code:'panel_exited',message}) would
762
+ // otherwise be misread as success. Pure: takes the parsed body + status,
763
+ // returns the body annotated with `error` + `_httpStatus`.
764
+ function annotateApiFailure(body, httpStatus) {
765
+ const out = (body && typeof body === 'object' && !Array.isArray(body)) ? body : {};
766
+ if (out.error === undefined || out.error === null) {
767
+ out.error = out.message || out.code || ('HTTP ' + httpStatus);
768
+ }
769
+ out._httpStatus = httpStatus;
770
+ return out;
771
+ }
772
+
773
+ // 1.1 born-hidden guard (T3 20:10 / T4 20:11) — a panel the operator just
774
+ // launched must never be hidden by a stale chip filter. Given the active
775
+ // filter and a newly-created panel, returns the filter value that keeps the
776
+ // panel visible, or null when no switch is needed. Pure.
777
+ function filterValueRevealingPanel(currentFilter, panelProject, isOrchPanel, initialLoadComplete) {
778
+ if (initialLoadComplete !== true) return null; // initial restore — honor the saved filter
779
+ if (isOrchPanel === true) return null; // ORCH panels bypass the chip filter
780
+ const proj = panelProject || '';
781
+ if (!currentFilter) return null; // "All" already shows everything
782
+ if (currentFilter === proj) return null; // already visible under this filter
783
+ return proj; // switch the filter to the new panel's project
784
+ }
785
+
786
+ // --- localStorage-backed filter persistence (origin-scoped, per-tab) ---
787
+ function loadProjectFilter() {
788
+ try {
789
+ const v = localStorage.getItem(PROJECT_FILTER_KEY);
790
+ return typeof v === 'string' ? v : '';
791
+ } catch (err) {
792
+ console.warn('[client] projectFilter load failed:', err);
793
+ return '';
794
+ }
795
+ }
796
+ function saveProjectFilter(value) {
797
+ try {
798
+ localStorage.setItem(PROJECT_FILTER_KEY, value || '');
799
+ } catch (err) {
800
+ console.warn('[client] projectFilter save failed:', err);
801
+ }
802
+ }
803
+
804
+ // session.meta for every panel currently mounted on THIS dashboard. Skips
805
+ // the _mounting placeholder createTerminalPanel reserves at function entry
806
+ // before the real entry is written.
807
+ function dashboardPanelMetas() {
808
+ const metas = [];
809
+ for (const entry of state.sessions.values()) {
810
+ if (!entry || entry._mounting || !entry.session || !entry.session.meta) continue;
811
+ metas.push(entry.session.meta);
812
+ }
813
+ return metas;
814
+ }
815
+
816
+ // 1.1 — (re)render the project-filter chip row from the live panel set.
817
+ // Chips are built with createElement (not innerHTML) so project names need
818
+ // no attribute escaping and cannot inject markup.
819
+ function renderProjectChips() {
820
+ const row = document.getElementById('project-chips');
821
+ if (!row) return;
822
+ const metas = dashboardPanelMetas();
823
+ const discovered = discoverPanelProjects(metas);
824
+ const projects = discovered.projects;
825
+
826
+ if (!shouldShowChipRow(projects, discovered.hasNullProject)) {
827
+ row.replaceChildren();
828
+ return;
829
+ }
830
+
831
+ // If the selected project no longer has any live panels, fall back to
832
+ // "All" so the grid never strands empty behind a dead chip.
833
+ if (state.projectFilter && projects.indexOf(state.projectFilter) === -1) {
834
+ state.projectFilter = '';
835
+ saveProjectFilter('');
836
+ }
837
+
838
+ const chips = [{ project: '', label: 'All' }];
839
+ for (const p of projects) chips.push({ project: p, label: p });
840
+
841
+ const frag = document.createDocumentFragment();
842
+ for (const c of chips) {
843
+ const btn = document.createElement('button');
844
+ btn.type = 'button';
845
+ btn.className = 'project-chip' + (((state.projectFilter || '') === c.project) ? ' active' : '');
846
+ btn.dataset.project = c.project;
847
+ const label = document.createElement('span');
848
+ label.className = 'project-chip-label';
849
+ label.textContent = c.label;
850
+ const count = document.createElement('span');
851
+ count.className = 'project-chip-count';
852
+ count.textContent = '(' + countPanelsForProject(metas, c.project) + ')';
853
+ btn.appendChild(label);
854
+ btn.appendChild(count);
855
+ frag.appendChild(btn);
856
+ }
857
+ row.replaceChildren(frag);
858
+ }
859
+
860
+ // 1.1 — apply the current chip selection to the grid by toggling
861
+ // .panel--filtered-out (display:none) on tiles. PTYs are never torn down —
862
+ // a filtered panel keeps running, just hidden. Orchestrator tiles live
863
+ // outside the grid and are never filtered.
864
+ function applyProjectFilter() {
865
+ for (const entry of state.sessions.values()) {
866
+ if (!entry || entry._mounting || !entry.el || !entry.session) continue;
867
+ if (isOrchestratorRole(entry.session.meta && entry.session.meta.role)) {
868
+ entry.el.classList.remove('panel--filtered-out');
869
+ continue;
870
+ }
871
+ const project = entry.session.meta ? entry.session.meta.project : null;
872
+ const visible = isPanelVisibleUnderFilter(project, state.projectFilter || '');
873
+ entry.el.classList.toggle('panel--filtered-out', !visible);
874
+ }
875
+ }
876
+
877
+ // 1.1 — chip click: single-select, persist, re-render + re-filter + refit.
878
+ function onProjectChipClick(e) {
879
+ const chip = e.target && e.target.closest ? e.target.closest('.project-chip') : null;
880
+ if (!chip) return;
881
+ const project = chip.getAttribute('data-project') || '';
882
+ state.projectFilter = project;
883
+ saveProjectFilter(project);
884
+ renderProjectChips();
885
+ applyProjectFilter();
886
+ requestAnimationFrame(function () { fitAll(); });
887
+ }
888
+
889
+ // 1.1 — born-hidden guard for a freshly-created panel: if an active chip
890
+ // filter would hide it, switch the filter to the panel's project so the
891
+ // operator sees what they just launched. No-op during the initial restore.
892
+ function revealNewPanelIfFiltered(meta) {
893
+ const next = filterValueRevealingPanel(
894
+ state.projectFilter,
895
+ meta && meta.project,
896
+ isOrchestratorRole(meta && meta.role),
897
+ _initialLoadComplete
898
+ );
899
+ if (next === null) return;
900
+ state.projectFilter = next;
901
+ saveProjectFilter(next);
902
+ }
903
+
904
+ // 1.2 — route a freshly-built panel into the ORCH pin row or the grid.
905
+ function placePanel(panel, meta) {
906
+ const orchRow = document.getElementById('orch-pin-row');
907
+ if (orchRow && isOrchestratorRole(meta && meta.role)) {
908
+ panel.classList.add('panel--role-orch');
909
+ orchRow.appendChild(panel);
910
+ } else {
911
+ document.getElementById('termGrid').appendChild(panel);
912
+ }
913
+ }
914
+
915
+ // 1.2 — defensive reconcile: keep every tile in the container its role
916
+ // dictates. meta.role is immutable post-spawn so this is a no-op after a
917
+ // panel's first placement; it only acts if a panel was somehow built
918
+ // before its role was known. Returns true if any tile moved.
919
+ function reconcileOrchRow() {
920
+ const orchRow = document.getElementById('orch-pin-row');
921
+ const grid = document.getElementById('termGrid');
922
+ if (!orchRow || !grid) return false;
923
+ let moved = false;
924
+ for (const entry of state.sessions.values()) {
925
+ if (!entry || entry._mounting || !entry.el || !entry.session) continue;
926
+ const isOrch = isOrchestratorRole(entry.session.meta && entry.session.meta.role);
927
+ const inOrchRow = entry.el.parentElement === orchRow;
928
+ if (isOrch && !inOrchRow) {
929
+ entry.el.classList.add('panel--role-orch');
930
+ entry.el.classList.remove('panel--filtered-out');
931
+ orchRow.appendChild(entry.el);
932
+ moved = true;
933
+ } else if (!isOrch && inOrchRow) {
934
+ entry.el.classList.remove('panel--role-orch');
935
+ grid.appendChild(entry.el);
936
+ moved = true;
937
+ }
938
+ }
939
+ return moved;
940
+ }
941
+
942
+ // Single entry point for keeping the dashboard chrome (ORCH row + chips +
943
+ // filter) in sync with the live panel set. Cheap — class toggles and a
944
+ // small chip-row rebuild.
945
+ function refreshDashboardChrome() {
946
+ const moved = reconcileOrchRow();
947
+ renderProjectChips();
948
+ applyProjectFilter();
949
+ if (moved) requestAnimationFrame(function () { fitAll(); });
950
+ }
951
+
952
+ // status_broadcast fires once per panel WebSocket; coalesce the resulting
953
+ // chrome refreshes into one per animation frame so an 18-panel dashboard
954
+ // doesn't rebuild the chip row 18× per 2s tick (T4's count-thrash concern).
955
+ function scheduleChromeRefresh() {
956
+ if (_chromeRefreshScheduled) return;
957
+ _chromeRefreshScheduled = true;
958
+ requestAnimationFrame(function () {
959
+ _chromeRefreshScheduled = false;
960
+ refreshDashboardChrome();
961
+ });
962
+ }
963
+
964
+ // --- 1.3: tile auto-removal on PTY exit ---
965
+
966
+ // Grace window before a dead tile is pulled, so the operator sees the
967
+ // final post-exit lines. Overridable via <body data-tile-exit-grace-ms>
968
+ // for deterministic tests.
969
+ function tileExitGraceMs() {
970
+ try {
971
+ const attr = document.body && document.body.getAttribute('data-tile-exit-grace-ms');
972
+ const n = attr != null ? parseInt(attr, 10) : NaN;
973
+ if (Number.isFinite(n) && n >= 0) return n;
974
+ } catch (err) {
975
+ console.warn('[client] tile-exit-grace read failed:', err);
976
+ }
977
+ return 3000;
978
+ }
979
+
980
+ // Tear down a panel's DOM + xterm + WebSocket and drop it from state.
981
+ // Idempotent: a second call for an already-removed id is a no-op. Shared
982
+ // by handlePanelExited (primary) and reconcileExitedPanels (fallback).
983
+ function removePanelTile(id) {
984
+ const entry = state.sessions.get(id);
985
+ if (!entry) return;
986
+ try { if (entry.terminal) entry.terminal.dispose(); } catch (err) { console.warn('[client] terminal dispose failed:', err); }
987
+ try { if (entry.ws) entry.ws.close(); } catch (err) { console.warn('[client] ws close failed:', err); }
988
+ try { if (entry.el) entry.el.remove(); } catch (err) { console.warn('[client] tile remove failed:', err); }
989
+ state.sessions.delete(id);
990
+ updateEmptyState();
991
+ renderSwitcher();
992
+ refreshAllReplyFormsFor(id);
993
+ refreshPanelIndices();
994
+ refreshDashboardChrome();
995
+ }
996
+
997
+ // 1.3 primary path — the server broadcast a panel_exited frame (T2 sub-task
998
+ // 2.4). Dim the tile for a grace window, then remove it. Guarded with
999
+ // _exitScheduled because panel_exited arrives on every panel's WebSocket.
1000
+ function handlePanelExited(sessionId, exitCode) {
1001
+ const entry = state.sessions.get(sessionId);
1002
+ if (!entry || !entry.el) return;
1003
+ if (entry._exitScheduled) return;
1004
+ entry._exitScheduled = true;
1005
+ entry.el.classList.add('panel--exiting');
1006
+ const statusEl = document.getElementById('status-' + sessionId);
1007
+ if (statusEl && exitCode !== undefined && exitCode !== null) {
1008
+ statusEl.textContent = 'Exited (' + exitCode + ')';
1009
+ }
1010
+ setTimeout(function () { removePanelTile(sessionId); }, tileExitGraceMs());
1011
+ }
1012
+
1013
+ // 1.3 belt-and-suspenders — covers a missed panel_exited frame. Two
1014
+ // independent checks so the dashboard cannot strand a dead tile forever
1015
+ // regardless of how T2 implements exited-session filtering:
1016
+ // (a) orphaned — a tile whose session id has dropped out of the
1017
+ // broadcast entirely (fires when T2 filters exited from the
1018
+ // broadcast); removed after ORPHAN_GRACE_MS.
1019
+ // (b) stale-exited — a tile still in the broadcast with status 'exited'
1020
+ // and lastActivity older than STALE_EXITED_MS (fires when T2 keeps
1021
+ // exited sessions in the broadcast).
1022
+ function reconcileExitedPanels(broadcastSessions) {
1023
+ const list = Array.isArray(broadcastSessions) ? broadcastSessions : [];
1024
+ const broadcastIds = [];
1025
+ const metaById = Object.create(null);
1026
+ for (const s of list) {
1027
+ if (s && s.id) { broadcastIds.push(s.id); metaById[s.id] = s.meta || {}; }
1028
+ }
1029
+ const knownIds = [];
1030
+ for (const [id, entry] of state.sessions) {
1031
+ if (entry && !entry._mounting && entry.el) knownIds.push(id);
1032
+ }
1033
+ const now = Date.now();
1034
+
1035
+ // (a) orphaned-from-broadcast
1036
+ const orphaned = findOrphanedPanelIds(knownIds, broadcastIds);
1037
+ const orphanSet = Object.create(null);
1038
+ for (const id of orphaned) {
1039
+ orphanSet[id] = true;
1040
+ const entry = state.sessions.get(id);
1041
+ if (!entry) continue;
1042
+ if (!entry._orphanedSince) entry._orphanedSince = now;
1043
+ if (now - entry._orphanedSince >= ORPHAN_GRACE_MS && !entry._exitScheduled) {
1044
+ removePanelTile(id);
1045
+ }
1046
+ }
1047
+ // clear the stamp for any panel that is back in the broadcast
1048
+ for (const id of knownIds) {
1049
+ const entry = state.sessions.get(id);
1050
+ if (entry && entry._orphanedSince && !orphanSet[id]) entry._orphanedSince = null;
1051
+ }
1052
+
1053
+ // (b) stale-exited still present in the broadcast
1054
+ for (const id of knownIds) {
1055
+ const meta = metaById[id];
1056
+ if (!meta || meta.status !== 'exited') continue;
1057
+ const entry = state.sessions.get(id);
1058
+ if (!entry || entry._exitScheduled) continue;
1059
+ const last = meta.lastActivity ? new Date(meta.lastActivity).getTime() : 0;
1060
+ if (last && now - last >= STALE_EXITED_MS) {
1061
+ removePanelTile(id);
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ // ===== Sprint 65 T1 (Joshua's 2026-05-16 ask c): terminal font size =====
1067
+ // A single global xterm.js font size, adjusted from the topbar A-/A+
1068
+ // stepper and persisted in localStorage. Applies to every panel (existing
1069
+ // + future) — operators running dense CLI output wanted smaller text. The
1070
+ // BACKLOG 2026-05-16 entry sanctions "per-panel OR global"; global is the
1071
+ // simpler, lower-risk option for this contingent sub-task.
1072
+
1073
+ const FONT_SIZE_KEY = 'termdeck.dashboard.fontSize';
1074
+
1075
+ // Clamp a requested font size to the supported range, coercing non-numbers
1076
+ // to the xterm.js default (13). Pure — unit-tested via the vm-extract path.
1077
+ function clampFontSize(n) {
1078
+ const v = Math.round(Number(n));
1079
+ if (!Number.isFinite(v)) return 13;
1080
+ if (v < 8) return 8;
1081
+ if (v > 22) return 22;
1082
+ return v;
1083
+ }
1084
+
1085
+ function loadFontSize() {
1086
+ try {
1087
+ const raw = localStorage.getItem(FONT_SIZE_KEY);
1088
+ return raw != null ? clampFontSize(raw) : 13;
1089
+ } catch (err) {
1090
+ console.warn('[client] fontSize load failed:', err);
1091
+ return 13;
1092
+ }
1093
+ }
1094
+ function saveFontSize(n) {
1095
+ try {
1096
+ localStorage.setItem(FONT_SIZE_KEY, String(n));
1097
+ } catch (err) {
1098
+ console.warn('[client] fontSize save failed:', err);
1099
+ }
1100
+ }
1101
+
1102
+ // Apply the global font size to every live terminal. Changing fontSize
1103
+ // resizes the character cell but NOT the container, so the per-panel
1104
+ // ResizeObserver does not fire — refit + push the new cols/rows explicitly.
1105
+ function applyFontSizeToAll() {
1106
+ for (const entry of state.sessions.values()) {
1107
+ if (!entry || entry._mounting || !entry.terminal) continue;
1108
+ try {
1109
+ entry.terminal.options.fontSize = state.fontSize;
1110
+ if (entry.fitAddon) entry.fitAddon.fit();
1111
+ if (entry.ws && entry.ws.readyState === WebSocket.OPEN) {
1112
+ entry.ws.send(JSON.stringify({
1113
+ type: 'resize',
1114
+ cols: entry.terminal.cols,
1115
+ rows: entry.terminal.rows,
1116
+ }));
1117
+ }
1118
+ } catch (err) {
1119
+ console.warn('[client] fontSize apply failed for a panel:', err);
1120
+ }
1121
+ }
1122
+ const label = document.getElementById('fontSizeLabel');
1123
+ if (label) label.textContent = String(state.fontSize);
1124
+ }
1125
+
1126
+ // Topbar A-/A+ stepper handler. delta is +1 or -1.
1127
+ function stepFontSize(delta) {
1128
+ const next = clampFontSize(state.fontSize + delta);
1129
+ if (next === state.fontSize) return;
1130
+ state.fontSize = next;
1131
+ saveFontSize(next);
1132
+ applyFontSizeToAll();
1133
+ }
1134
+
614
1135
  // ===== Control dashboard (T1.6) =====
615
1136
  async function enterControlMode() {
616
1137
  // Pre-warm command history for every open session so the feed is dense.
@@ -1536,6 +2057,11 @@
1536
2057
  const p = document.getElementById(`panel-${id}`);
1537
2058
  if (p) p.classList.add('exited');
1538
2059
  break;
2060
+ case 'panel_exited':
2061
+ // Sprint 65 T1 (1.3) — parity with the main WS handler so the
2062
+ // dead-tile removal still fires for reconnected panels.
2063
+ handlePanelExited(msg.sessionId, msg.exitCode);
2064
+ break;
1539
2065
  case 'status_broadcast':
1540
2066
  updateGlobalStats(msg.sessions);
1541
2067
  break;
@@ -1621,6 +2147,8 @@
1621
2147
  renderSwitcher();
1622
2148
  refreshAllReplyFormsFor(id);
1623
2149
  refreshPanelIndices();
2150
+ // Sprint 65 T1 — closing a panel changes chip counts / may empty a chip.
2151
+ refreshDashboardChrome();
1624
2152
  }
1625
2153
 
1626
2154
  function changeTheme(id, themeId) {
@@ -2954,6 +3482,10 @@
2954
3482
  document.getElementById('stat-thinking').textContent = thinking;
2955
3483
  document.getElementById('stat-idle').textContent = idle;
2956
3484
  renderSwitcher();
3485
+ // Sprint 65 T1 — reconcile dead tiles against this broadcast, then
3486
+ // refresh chips + ORCH-row chrome (coalesced to one rebuild per frame).
3487
+ reconcileExitedPanels(sessions);
3488
+ scheduleChromeRefresh();
2957
3489
  }
2958
3490
 
2959
3491
  function updateEmptyState() {
@@ -4246,6 +4778,17 @@
4246
4778
  btn.addEventListener('click', () => setLayout(btn.dataset.layout));
4247
4779
  });
4248
4780
 
4781
+ // Sprint 65 T1 (1.1) — project-filter chip clicks (delegated; chips are
4782
+ // rebuilt on every status_broadcast so a per-chip listener would go stale).
4783
+ const projectChipsRow = document.getElementById('project-chips');
4784
+ if (projectChipsRow) projectChipsRow.addEventListener('click', onProjectChipClick);
4785
+
4786
+ // Sprint 65 T1 (c) — topbar terminal font-size stepper.
4787
+ const fontDecBtn = document.getElementById('btn-font-dec');
4788
+ const fontIncBtn = document.getElementById('btn-font-inc');
4789
+ if (fontDecBtn) fontDecBtn.addEventListener('click', () => stepFontSize(-1));
4790
+ if (fontIncBtn) fontIncBtn.addEventListener('click', () => stepFontSize(1));
4791
+
4249
4792
  document.getElementById('promptLaunch').addEventListener('click', launchTerminal);
4250
4793
  document.getElementById('promptInput').addEventListener('keydown', (e) => {
4251
4794
  if (e.key === 'Enter') launchTerminal();
@@ -4362,11 +4905,15 @@
4362
4905
  document.getElementById('promptInput').focus();
4363
4906
  }
4364
4907
  }
4365
- // Ctrl+Shift+1-7 OR Cmd+Shift+1-7 → layout switch (Mac friendly)
4366
- if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key >= '1' && e.key <= '7') {
4908
+ // Ctrl+Shift+1-9,0 OR Cmd+Shift+1-9,0 → layout switch (Mac friendly).
4909
+ // Sprint 65 T1 (1.4) keys 1-9 map to indices 0-8, key 0 maps to index
4910
+ // 9. Keys 1-7 keep their pre-Sprint-65 layouts (muscle memory); 8/9/0 are
4911
+ // the new dense presets. Topbar buttons cover every preset incl. 2x5/5x2/3x4.
4912
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key >= '0' && e.key <= '9') {
4367
4913
  e.preventDefault();
4368
- const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2', 'orch'];
4369
- setLayout(layouts[parseInt(e.key) - 1]);
4914
+ const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2', 'orch', '1x2', '4x3', '4x4'];
4915
+ const idx = e.key === '0' ? 9 : parseInt(e.key, 10) - 1;
4916
+ if (layouts[idx]) setLayout(layouts[idx]);
4370
4917
  }
4371
4918
  // Ctrl+Shift+] / [ → cycle between terminals
4372
4919
  if (e.ctrlKey && e.shiftKey && (e.key === ']' || e.key === '[')) {