pilotswarm 0.5.26 → 0.5.28

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.
Files changed (71) hide show
  1. package/package.json +1 -1
  2. package/ui/core/src/controller.js +66 -6
  3. package/ui/core/src/selectors.js +265 -13
  4. package/ui/react/src/web-app.js +527 -117
  5. package/web/dist/assets/{abnfDiagram-VRR7QNED-C4qRR7va.js → abnfDiagram-VRR7QNED-DLV45wH4.js} +1 -1
  6. package/web/dist/assets/{arc-BQw2V2pL.js → arc-CE4xAYdE.js} +1 -1
  7. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DMdWnLGG.js → architectureDiagram-ZJ3FMSHR-DfzR8-9F.js} +1 -1
  8. package/web/dist/assets/{blockDiagram-677ZJIJ3-DZnALw8j.js → blockDiagram-677ZJIJ3-C2pZdqvH.js} +1 -1
  9. package/web/dist/assets/{c4Diagram-LMCZKHZV-BGGZ42iX.js → c4Diagram-LMCZKHZV-CKRspTan.js} +1 -1
  10. package/web/dist/assets/channel-D7p-E4El.js +1 -0
  11. package/web/dist/assets/{chunk-2Q5K7J3B-Dastiegd.js → chunk-2Q5K7J3B-CNjLrfHT.js} +1 -1
  12. package/web/dist/assets/{chunk-32BRIVSS-B4XexRzf.js → chunk-32BRIVSS-Bv5EwyaJ.js} +1 -1
  13. package/web/dist/assets/{chunk-5VM5RSS4-D66MNGCN.js → chunk-5VM5RSS4-NDBesEbJ.js} +1 -1
  14. package/web/dist/assets/{chunk-EX3LRPZG-DeqPS4Xm.js → chunk-EX3LRPZG-DHQht9ty.js} +1 -1
  15. package/web/dist/assets/{chunk-JWPE2WC7-DIMWiXpM.js → chunk-JWPE2WC7-r0OQpJRF.js} +1 -1
  16. package/web/dist/assets/{chunk-MOJQB5TN-vTyL0qgx.js → chunk-MOJQB5TN-fcwIDgtl.js} +1 -1
  17. package/web/dist/assets/{chunk-RYQCIY6F-C7U9MKGw.js → chunk-RYQCIY6F-x7XQlDO1.js} +1 -1
  18. package/web/dist/assets/{chunk-V7JOEXUC-BzPwQ7n3.js → chunk-V7JOEXUC-U8ZNVy1m.js} +1 -1
  19. package/web/dist/assets/{chunk-VR4S4FIN-CmDsyK3b.js → chunk-VR4S4FIN-DLEdPLcN.js} +1 -1
  20. package/web/dist/assets/{chunk-XXDRQBXY-ZJn6BaGL.js → chunk-XXDRQBXY-TeyAtB-i.js} +1 -1
  21. package/web/dist/assets/classDiagram-OUVF2IWQ-yFZ-1yMb.js +1 -0
  22. package/web/dist/assets/classDiagram-v2-EOCWNBFH-yFZ-1yMb.js +1 -0
  23. package/web/dist/assets/{cose-bilkent-JH36ORCC-CAA8JZZM.js → cose-bilkent-JH36ORCC-B3bAl1-Z.js} +1 -1
  24. package/web/dist/assets/{cynefin-VYW2F7L2-B6pX-lUD.js → cynefin-VYW2F7L2-DexP8nG-.js} +1 -1
  25. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-sKTHU_UH.js → cynefinDiagram-TSTJHNR4-Doa2XnSs.js} +1 -1
  26. package/web/dist/assets/{dagre-VKFMJZFB-EuakJ578.js → dagre-VKFMJZFB-DZMBKQcJ.js} +1 -1
  27. package/web/dist/assets/{diagram-FQU43EPY-DR3M_pic.js → diagram-FQU43EPY-BkzibmSq.js} +1 -1
  28. package/web/dist/assets/{diagram-G47NLZAW-BlS6yM0u.js → diagram-G47NLZAW-CihyHR3a.js} +1 -1
  29. package/web/dist/assets/{diagram-NH7WQ7WH-CQNXe6dc.js → diagram-NH7WQ7WH-OMDUUxwW.js} +1 -1
  30. package/web/dist/assets/{diagram-OA4YK3LP-BizBeSQo.js → diagram-OA4YK3LP-Lc0imJba.js} +1 -1
  31. package/web/dist/assets/{diagram-WEI45ONY-C-_KM6Gg.js → diagram-WEI45ONY-Dlcy8hPe.js} +1 -1
  32. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-BapWEdic.js → ebnfDiagram-CCIWWBDH-Dh3u17Dg.js} +1 -1
  33. package/web/dist/assets/{erDiagram-Q63AITRT-Btijn1Xj.js → erDiagram-Q63AITRT-C0OhlUC2.js} +1 -1
  34. package/web/dist/assets/{flowDiagram-23GEKE2U-D8zK8HkH.js → flowDiagram-23GEKE2U-CADKxT-x.js} +1 -1
  35. package/web/dist/assets/{ganttDiagram-NO4QXBWP-DyjkOIMW.js → ganttDiagram-NO4QXBWP-iVVdxWB7.js} +1 -1
  36. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-3r9_QeYN.js → gitGraphDiagram-IHSO6WYX-BwDUec_a.js} +1 -1
  37. package/web/dist/assets/index-BGWTu1KK.css +1 -0
  38. package/web/dist/assets/{index-CIt9PdC-.js → index-Djfdr7ts.js} +2 -2
  39. package/web/dist/assets/{infoDiagram-FWYZ7A6U-Ccd8ACUz.js → infoDiagram-FWYZ7A6U-CGPUif0s.js} +1 -1
  40. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-DPRUC8V2.js → ishikawaDiagram-FXEZZL3T-QYnrl-KA.js} +1 -1
  41. package/web/dist/assets/{journeyDiagram-5HDEW3XC-CkvayMsL.js → journeyDiagram-5HDEW3XC-CeKixxL0.js} +1 -1
  42. package/web/dist/assets/{kanban-definition-HUTT4EX6-CAK9Z-ha.js → kanban-definition-HUTT4EX6-CeYokFBS.js} +1 -1
  43. package/web/dist/assets/{linear-B-_ZjN2w.js → linear-BbA6_gnz.js} +1 -1
  44. package/web/dist/assets/{mermaid.core-BN9c--2r.js → mermaid.core--xGRoQTa.js} +4 -4
  45. package/web/dist/assets/{mindmap-definition-LN4V7U3C-DuD_AHRU.js → mindmap-definition-LN4V7U3C-D53u8h0z.js} +1 -1
  46. package/web/dist/assets/papaparse.min-BU0o7jXn.js +10 -0
  47. package/web/dist/assets/{pegDiagram-2B236MQR-Cc1v58KA.js → pegDiagram-2B236MQR-B7QSyG-3.js} +1 -1
  48. package/web/dist/assets/{pieDiagram-ENE6RG2P-Bc32--ur.js → pieDiagram-ENE6RG2P-XtSGL7GG.js} +1 -1
  49. package/web/dist/assets/pilotswarm-CtO819QH.js +94 -0
  50. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-DpPTc2EP.js → quadrantDiagram-ABIIQ3AL-LZoYSbTH.js} +1 -1
  51. package/web/dist/assets/{railroadDiagram-RFXS5EU6-DXMzwejw.js → railroadDiagram-RFXS5EU6-B5FREx2U.js} +1 -1
  52. package/web/dist/assets/{requirementDiagram-TGXJPOKE-B3ImAxw-.js → requirementDiagram-TGXJPOKE-1VRFkBQX.js} +1 -1
  53. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-hmVMffE2.js → sankeyDiagram-HTMAVEWB-J456gWvu.js} +1 -1
  54. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-D53fk6uF.js → sequenceDiagram-DBY2YBRQ-CaCcw6KO.js} +1 -1
  55. package/web/dist/assets/{sizeCapture-X5ZJPWSS-4U-I5d2g.js → sizeCapture-X5ZJPWSS-DClsP3_O.js} +1 -1
  56. package/web/dist/assets/{stateDiagram-2N3HPSRC-0Y5TgWhW.js → stateDiagram-2N3HPSRC-D6VyrKhd.js} +1 -1
  57. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-D7eKFhHb.js +1 -0
  58. package/web/dist/assets/{swimlanes-5IMT3BWC-Umhy4qUu.js → swimlanes-5IMT3BWC-q678iAuo.js} +2 -2
  59. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DlVHQBQx.js +8 -0
  60. package/web/dist/assets/{timeline-definition-FHXFAJF6-D5hOg3Qu.js → timeline-definition-FHXFAJF6-WnPK_zhe.js} +1 -1
  61. package/web/dist/assets/{vennDiagram-L72KCM5P-QVIcE3UD.js → vennDiagram-L72KCM5P-WpumuLld.js} +1 -1
  62. package/web/dist/assets/{wardleyDiagram-EHGQE667-BEj1M2cB.js → wardleyDiagram-EHGQE667-CBrYLUm_.js} +1 -1
  63. package/web/dist/assets/{xychartDiagram-FW5EYKEG-xojRIXIf.js → xychartDiagram-FW5EYKEG-DbX1rnDW.js} +1 -1
  64. package/web/dist/index.html +3 -3
  65. package/web/dist/assets/channel-CB157dQ5.js +0 -1
  66. package/web/dist/assets/classDiagram-OUVF2IWQ-B6JwBYlv.js +0 -1
  67. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B6JwBYlv.js +0 -1
  68. package/web/dist/assets/index-CT2_MRcC.css +0 -1
  69. package/web/dist/assets/pilotswarm-BgzdDy09.js +0 -93
  70. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DqIcGfAY.js +0 -1
  71. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DA9txLi6.js +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.26",
3
+ "version": "0.5.28",
4
4
  "description": "PilotSwarm application package: terminal UI, browser portal + Web API server, and MCP server \u2014 one install, three bins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -935,7 +935,12 @@ function movePromptCursorVertically(prompt, cursor, direction) {
935
935
  return getPromptCursorIndex(prompt, targetLine, position.column);
936
936
  }
937
937
 
938
- const AUTO_HISTORY_EVENT_SOFT_CAP = 3_000;
938
+ // Automatic scroll-up expansion stops here; past it the user must ask for more
939
+ // explicitly. Exported so a surface can SHOW that control — the portal had no
940
+ // way to trigger EXPAND_HISTORY at all, so on a busy session (tens of thousands
941
+ // of events) scrolling up simply died with a status line telling the user to
942
+ // press a key that only exists in the TUI.
943
+ export const AUTO_HISTORY_EVENT_SOFT_CAP = 3_000;
939
944
  const INSPECTOR_BOTTOM_ANCHORED_TABS = new Set(["logs", "sequence"]);
940
945
  const FILE_PREVIEW_CHAR_LIMIT = 200_000;
941
946
  const MARKDOWN_FILE_EXTENSIONS = new Set([
@@ -1070,6 +1075,19 @@ function normalizePreviewPayload(filename, rawContent, contentType = "", metadat
1070
1075
 
1071
1076
  /** How long a fetched artifact list is trusted before a refetch. */
1072
1077
  const FILES_LIST_TTL_MS = 10_000;
1078
+ // How long keyboard navigation must rest on a session before it is fetched.
1079
+ // Long enough that scrolling past a row costs nothing, short enough that
1080
+ // landing on one feels immediate.
1081
+ const SESSION_NAV_SETTLE_MS = 140;
1082
+ // Ceiling on the window a session re-opens with, in events (5 history pages).
1083
+ //
1084
+ // `loadedEventLimit` is sticky: scrolling back through a long session escalates
1085
+ // it 300 -> 1000 -> 3000 -> 10000, and ensureSessionHistory then re-requested
1086
+ // that expanded size on EVERY switch-in, for the life of the tab. So one trip
1087
+ // through the history of a busy session made it permanently expensive to open —
1088
+ // 10k events re-fetched, re-derived and re-laid-out each time. Paging back
1089
+ // still expands freely while you are in the session; re-entering starts bounded.
1090
+ const HISTORY_REENTRY_MAX_EVENTS = 1_500;
1073
1091
 
1074
1092
  export class PilotSwarmUiController {
1075
1093
  constructor({ store, transport }) {
@@ -2292,9 +2310,12 @@ export class PilotSwarmUiController {
2292
2310
  async ensureSessionHistory(sessionId, { force = false } = {}) {
2293
2311
  if (!sessionId) return null;
2294
2312
  const existingHistory = this.getState().history.bySessionId.get(sessionId);
2295
- const requestedLimit = Math.max(
2296
- DEFAULT_HISTORY_EVENT_LIMIT,
2297
- Number(existingHistory?.loadedEventLimit ?? DEFAULT_HISTORY_EVENT_LIMIT) || DEFAULT_HISTORY_EVENT_LIMIT,
2313
+ const requestedLimit = Math.min(
2314
+ HISTORY_REENTRY_MAX_EVENTS,
2315
+ Math.max(
2316
+ DEFAULT_HISTORY_EVENT_LIMIT,
2317
+ Number(existingHistory?.loadedEventLimit ?? DEFAULT_HISTORY_EVENT_LIMIT) || DEFAULT_HISTORY_EVENT_LIMIT,
2318
+ ),
2298
2319
  );
2299
2320
  if (!force && existingHistory?.events) {
2300
2321
  return existingHistory;
@@ -6140,7 +6161,36 @@ export class PilotSwarmUiController {
6140
6161
  const currentIndex = Math.max(0, rows.findIndex((row) => row.sessionId === currentId));
6141
6162
  const nextIndex = Math.max(0, Math.min(rows.length - 1, currentIndex + delta));
6142
6163
  const nextId = rows[nextIndex].sessionId;
6143
- await this.loadSession(nextId);
6164
+ this.navigateToSession(nextId);
6165
+ }
6166
+
6167
+ /**
6168
+ * Move the selection now; fetch the session once the movement settles.
6169
+ *
6170
+ * `loadSession` force-refetches history, syncs detail, re-attaches the live
6171
+ * event stream and refreshes the inspector — four round trips. Driving that
6172
+ * from every keypress meant scrolling a list of sessions fired a request
6173
+ * pair per row, and against a remote deployment the UI ran behind the
6174
+ * cursor by however long the network took. The highlight is local state and
6175
+ * moves immediately; only the fetching waits for the user to stop.
6176
+ *
6177
+ * A CLICK still loads immediately — that is a deliberate choice of one
6178
+ * session, not a scroll through many.
6179
+ */
6180
+ navigateToSession(sessionId) {
6181
+ if (!sessionId) return;
6182
+ if (this.getState().sessions.activeSessionId !== sessionId) {
6183
+ this.dispatch({ type: "sessions/selected", sessionId });
6184
+ // Same per-session reset loadSession does, so the sequence tab does
6185
+ // not show another session's turn while the fetch is pending.
6186
+ this.dispatch({ type: "ui/sequenceExpandedTurns", turns: [] });
6187
+ this.dispatch({ type: "ui/sequenceSelectedTurn", turn: null });
6188
+ }
6189
+ if (this.sessionNavSettleTimer) clearTimeout(this.sessionNavSettleTimer);
6190
+ this.sessionNavSettleTimer = setTimeout(() => {
6191
+ this.sessionNavSettleTimer = null;
6192
+ this.loadSession(sessionId).catch(() => {});
6193
+ }, SESSION_NAV_SETTLE_MS);
6144
6194
  }
6145
6195
 
6146
6196
  getSessionPageSize() {
@@ -6283,7 +6333,17 @@ export class PilotSwarmUiController {
6283
6333
 
6284
6334
  await this.maybeAutoExpandActiveHistory(requestedScrollOffset, {
6285
6335
  pages: AUTO_HISTORY_SCROLL_PAGE_COUNT,
6286
- force: Boolean(options.force),
6336
+ // Always bypass the offset gate. Every caller of this method fires
6337
+ // only when the DOM scroller is already AT the top — a direct
6338
+ // measurement. The gate is a second, weaker guess that compares a
6339
+ // DOM-derived offset against `selectChatLines` metrics, i.e. the
6340
+ // TERMINAL wrapped-line count, which the rich renderer does not
6341
+ // use. On a session whose messages are long (tables, code blocks)
6342
+ // the terminal line count balloons far past the rich DOM's scroll
6343
+ // extent, so the gate could never open and older history was
6344
+ // unreachable — while a session of short messages worked fine.
6345
+ // The arm/fire handshake above still debounces the wheel path.
6346
+ force: true,
6287
6347
  });
6288
6348
  }
6289
6349
 
@@ -509,7 +509,7 @@ function getCollapseBadge(sessionId, entry, totalDescendantCounts, visibleDescen
509
509
  return { text: `[+${badgeCount}]`, color: "cyan" };
510
510
  }
511
511
 
512
- function formatCronTimestampForClient(value) {
512
+ export function formatCronTimestampForClient(value) {
513
513
  if (!value) return "scheduled";
514
514
  const date = value instanceof Date ? value : new Date(value);
515
515
  if (Number.isNaN(date.getTime())) return "scheduled";
@@ -771,6 +771,14 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
771
771
  badgeRuns: [],
772
772
  selectedMetaRuns: detailRuns,
773
773
  detailRuns,
774
+ // Total descendants, for renderers that want the real number rather
775
+ // than the collapse badge (which counts only HIDDEN children, so it is
776
+ // null whenever the subtree is expanded).
777
+ // NOTE: `detailRuns` above computes its own childCount by indexing this
778
+ // Map with brackets, which always yields undefined — so its "N children"
779
+ // segment has never rendered. Left alone deliberately: fixing it would
780
+ // change TUI and mobile output.
781
+ childCount: totalDescendantCounts?.get?.(session?.sessionId) || 0,
774
782
  // Structured row description for renderers that draw real chrome
775
783
  // instead of terminal runs (the portal's rich session list): status
776
784
  // becomes a dot, depth becomes indentation, the owner/child/cron
@@ -840,6 +848,23 @@ function collectFilterExceptionIds(sessions) {
840
848
  return ids;
841
849
  }
842
850
 
851
+ // Per-row memo, keyed by the `flat` list so it survives selection changes but
852
+ // resets whenever the tree itself is rebuilt.
853
+ //
854
+ // WHY: moving the selection re-runs this selector (once from moveSession, once
855
+ // from the portal's useMemo) and used to rebuild EVERY row — including
856
+ // flattenRunsText over each row's runs — to change one boolean on two of them.
857
+ // At fleet scale that is the cost of a keypress. Now only rows whose inputs
858
+ // actually moved are rebuilt, and the rest keep their previous object
859
+ // identity, which also lets the renderer skip them.
860
+ const sessionRowMemo = new WeakMap();
861
+
862
+ function sameRowDeps(a, b) {
863
+ if (!a || a.length !== b.length) return false;
864
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
865
+ return true;
866
+ }
867
+
843
868
  export function selectSessionRows(state) {
844
869
  const totalDescendantCounts = getTotalDescendantCounts(state.sessions.byId);
845
870
  const visibleDescendantCounts = getVisibleDescendantCounts(state.sessions.flat, state.sessions.byId);
@@ -850,25 +875,52 @@ export function selectSessionRows(state) {
850
875
  const selectedSet = new Set(Array.isArray(state.sessions?.selectedIds) ? state.sessions.selectedIds : []);
851
876
  const filterExceptionIds = collectFilterExceptionIds(state.sessions);
852
877
 
878
+ let memo = sessionRowMemo.get(state.sessions.flat);
879
+ if (!memo) {
880
+ memo = new Map();
881
+ sessionRowMemo.set(state.sessions.flat, memo);
882
+ }
883
+
853
884
  return state.sessions.flat.map((entry) => {
854
885
  const session = state.sessions.byId[entry.sessionId];
886
+ const active = entry.sessionId === state.sessions.activeSessionId;
887
+ const pinned = pinnedSet.has(entry.sessionId);
888
+ const selected = selectedSet.has(entry.sessionId);
889
+ // Every input the row below is derived from. Identity comparison is
890
+ // enough: the reducer replaces these objects rather than mutating them.
891
+ const deps = [
892
+ entry, session, active, pinned, selected, query, ownerFilter, auth,
893
+ state.connection?.mode, state.sessions.selectMode,
894
+ totalDescendantCounts, visibleDescendantCounts,
895
+ // The root system row renders as the branding title, and this
896
+ // selector is called with two different state shapes (the portal
897
+ // builds a synthetic one). Without this the cache could hand a row
898
+ // built under one branding to a caller using another, and the root
899
+ // session's name would flip between them.
900
+ state.branding?.title,
901
+ ];
902
+ const cached = memo.get(entry.sessionId);
903
+ if (cached && sameRowDeps(cached.deps, deps)) return cached.row;
904
+
855
905
  const rowView = buildSessionRowView(entry, session, state, totalDescendantCounts, visibleDescendantCounts);
856
- return {
906
+ const row = {
857
907
  sessionId: entry.sessionId,
858
908
  text: flattenRunsText(rowView.runs),
859
909
  ...rowView,
860
910
  depth: entry.depth,
861
911
  status: session?.status,
862
912
  statusColor: sessionStatusColor(session, state.connection?.mode || "local"),
863
- active: entry.sessionId === state.sessions.activeSessionId,
913
+ active,
864
914
  isSystem: Boolean(session?.isSystem),
865
915
  isGroup: Boolean(session?.isGroup),
866
916
  hasChildren: entry.hasChildren,
867
917
  collapsed: entry.collapsed,
868
- pinned: pinnedSet.has(entry.sessionId),
869
- selected: selectedSet.has(entry.sessionId),
918
+ pinned,
919
+ selected,
870
920
  canPin: canPinSessionRow(session),
871
921
  };
922
+ memo.set(entry.sessionId, { deps, row });
923
+ return row;
872
924
  }).filter((row) => {
873
925
  if (filterExceptionIds?.has(row.sessionId)) return true;
874
926
  const session = state.sessions.byId[row.sessionId];
@@ -2356,10 +2408,75 @@ export function selectLiveActivityLines(state, options = {}) {
2356
2408
  return [fitRuns(runs, maxWidth)];
2357
2409
  }
2358
2410
 
2411
+ // Recent selectChatLines results, compared by the IDENTITY of every input.
2412
+ //
2413
+ // This wraps the entire transcript to terminal lines, and it runs from
2414
+ // scrollPane / scrollPaneTo / the visual-offset helpers — i.e. on every scroll
2415
+ // action, to answer nothing more than "how many lines are there". Measured
2416
+ // 1.6ms at 300 messages and 5.1ms at 1500, per scroll.
2417
+ //
2418
+ // Keyed on identities rather than fields on purpose: selectActiveChat is NOT a
2419
+ // pure function of the chat array — it also appends a pending-question message,
2420
+ // an answered-question message and a session-error message, and branches on
2421
+ // ui.chatViewMode and branding. Anything derived from `session` therefore has
2422
+ // to invalidate when `session` does, which identity comparison gives for free.
2423
+ //
2424
+ // `byId` is in the key for two reasons found by adversarial review, both of
2425
+ // which produced visibly wrong output:
2426
+ //
2427
+ // 1. For a GROUP session, selectActiveChat ignores the group's own history
2428
+ // entirely and builds a Members table by scanning every OTHER session in
2429
+ // byId (title / status / shortSummary). Renaming a member changes none of
2430
+ // the group row's own aggregate counters, and the reducer PRESERVES a
2431
+ // session object's identity when nothing on it moved — so without byId the
2432
+ // table froze on the old titles indefinitely. (An earlier version of this
2433
+ // comment claimed the poll rebuilds the session object every time. It does
2434
+ // not, and that wrong assumption is what made the bug possible.)
2435
+ // 2. This memo is module-level and shared by callers that pass DIFFERENT
2436
+ // state shapes: the TUI's ChatPane passes a synthetic single-session byId
2437
+ // while the controller's scroll math passes the real full state. Every
2438
+ // other key field can match between them, so whichever missed first
2439
+ // populated one entry for both, and the member list flipped depending on
2440
+ // call order. Distinct byId objects now key them apart.
2441
+ const CHAT_LINES_MEMO_MAX = 4;
2442
+ let chatLinesMemo = [];
2443
+
2359
2444
  export function selectChatLines(state, maxWidth = 80, options = {}) {
2445
+ const memoSessionId = state?.sessions?.activeSessionId ?? null;
2446
+ const memoKey = {
2447
+ sessionId: memoSessionId,
2448
+ session: memoSessionId ? state?.sessions?.byId?.[memoSessionId] ?? null : null,
2449
+ byId: state?.sessions?.byId ?? null,
2450
+ chat: state?.history?.bySessionId?.get?.(memoSessionId)?.chat ?? null,
2451
+ viewMode: state?.ui?.chatViewMode ?? null,
2452
+ branding: state?.branding ?? null,
2453
+ principal: state?.auth?.principal ?? null,
2454
+ maxWidth,
2455
+ tableMode: options?.tableMode ?? null,
2456
+ };
2457
+ for (const entry of chatLinesMemo) {
2458
+ if (entry.sessionId === memoKey.sessionId
2459
+ && entry.session === memoKey.session
2460
+ && entry.byId === memoKey.byId
2461
+ && entry.chat === memoKey.chat
2462
+ && entry.viewMode === memoKey.viewMode
2463
+ && entry.branding === memoKey.branding
2464
+ && entry.principal === memoKey.principal
2465
+ && Object.is(entry.maxWidth, memoKey.maxWidth)
2466
+ && entry.tableMode === memoKey.tableMode) {
2467
+ return entry.lines;
2468
+ }
2469
+ }
2470
+
2471
+ const memoize = (lines) => {
2472
+ chatLinesMemo.unshift({ ...memoKey, lines });
2473
+ if (chatLinesMemo.length > CHAT_LINES_MEMO_MAX) chatLinesMemo.length = CHAT_LINES_MEMO_MAX;
2474
+ return lines;
2475
+ };
2476
+
2360
2477
  const messages = selectActiveChat(state);
2361
2478
  if (!messages || messages.length === 0) {
2362
- return [{ text: "No messages yet.", color: "gray" }];
2479
+ return memoize([{ text: "No messages yet.", color: "gray" }]);
2363
2480
  }
2364
2481
 
2365
2482
  // The current viewer's identity key — a user.message whose sender differs
@@ -2403,7 +2520,7 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2403
2520
  lines.push(createBlankLine());
2404
2521
  }
2405
2522
  }
2406
- return lines.length > 0 ? lines : [{ text: "No messages yet.", color: "gray" }];
2523
+ return memoize(lines.length > 0 ? lines : [{ text: "No messages yet.", color: "gray" }]);
2407
2524
  }
2408
2525
 
2409
2526
  // True when a chat message is plain user/assistant prose the rich (block)
@@ -2440,6 +2557,10 @@ function isRichRenderableChatMessage(message) {
2440
2557
  *
2441
2558
  * The TUI never calls this; selectChatLines remains the terminal path.
2442
2559
  */
2560
+ // Rich message blocks, cached on the message object. Keyed weakly so entries
2561
+ // die with the transcript they came from.
2562
+ const chatMessageBlockCache = new WeakMap();
2563
+
2443
2564
  export function selectChatBlocks(state, maxWidth = 80, options = {}) {
2444
2565
  const messages = selectActiveChat(state);
2445
2566
  if (!messages || messages.length === 0) return [];
@@ -2461,6 +2582,41 @@ export function selectChatBlocks(state, maxWidth = 80, options = {}) {
2461
2582
 
2462
2583
  const blocks = [];
2463
2584
  for (const message of messages) {
2585
+ // Fast path FIRST. Classifying a message as rich-renderable scans its
2586
+ // text (asked-and-answered parsing, system-notice splitting), and that
2587
+ // ran for every message on every call — so a pane-splitter drag paid
2588
+ // it across the whole transcript per width step. A cache entry only
2589
+ // exists for a message already classified rich, and `kind` is part of
2590
+ // what is compared, so a hit is safe to take before classifying.
2591
+ // Object.is, NOT ===: `createdAt` is NaN whenever the source event had
2592
+ // no parseable timestamp, and NaN === NaN is false, so a plain equality
2593
+ // check could never hit for those messages — the cache would silently
2594
+ // do nothing while still costing a lookup. Same trap as comparing
2595
+ // Infinity by subtraction.
2596
+ const cached = message ? chatMessageBlockCache.get(message) : null;
2597
+ if (cached
2598
+ && Object.is(cached.text, message.text)
2599
+ && Object.is(cached.kind, message.kind)
2600
+ && Object.is(cached.role, message.role)
2601
+ && Object.is(cached.id, message.id)
2602
+ && Object.is(cached.pendingPhase, message.pendingPhase)
2603
+ && Object.is(cached.stopped, message.stopped)
2604
+ // The header is derived from these, and the chips from attachments.
2605
+ // Messages are mutated in place (pendingPhase provably is), so every
2606
+ // field the block is built from has to be compared — not just the
2607
+ // obvious ones.
2608
+ && Object.is(cached.createdAt, message.createdAt)
2609
+ && Object.is(cached.time, message.time)
2610
+ && Object.is(cached.sender, message.sender)
2611
+ && Object.is(cached.attachments, message.attachments)
2612
+ && Object.is(cached.tableMode, buildOptions.tableMode)
2613
+ && Object.is(cached.viewerKey, buildOptions.viewerKey)
2614
+ && Object.is(cached.ownerKey, buildOptions.ownerKey)
2615
+ && Object.is(cached.sharedContext, buildOptions.sharedContext)) {
2616
+ blocks.push(cached.block);
2617
+ continue;
2618
+ }
2619
+
2464
2620
  if (message?.kind === "epoch-divider") {
2465
2621
  blocks.push({ kind: "lines", variant: "divider", lines: [buildEpochDividerLine(message, maxWidth)] });
2466
2622
  continue;
@@ -2474,9 +2630,15 @@ export function selectChatBlocks(state, maxWidth = 80, options = {}) {
2474
2630
  continue;
2475
2631
  }
2476
2632
  if (isRichRenderableChatMessage(message)) {
2633
+ // A rich message block does NOT depend on maxWidth — the browser
2634
+ // wraps this text with CSS; only the terminal needs a column count.
2635
+ // Rebuilding it on every width step meant dragging a pane splitter
2636
+ // re-parsed artifact links and re-derived headers for the entire
2637
+ // loaded transcript to produce byte-identical output. Cache on the
2638
+ // message itself, keyed by the inputs that CAN change it.
2477
2639
  const attachmentLines = buildAttachmentChipLines(message, { tableMode: "sentinel" });
2478
2640
  const attachmentSentinel = attachmentLines.find((line) => line?.kind === "imageAttachments") || null;
2479
- blocks.push({
2641
+ const block = {
2480
2642
  kind: "message",
2481
2643
  id: message?.id ?? null,
2482
2644
  role: message.role,
@@ -2486,17 +2648,107 @@ export function selectChatBlocks(state, maxWidth = 80, options = {}) {
2486
2648
  attachments: attachmentSentinel
2487
2649
  ? { sessionId: attachmentSentinel.sessionId, attachments: attachmentSentinel.attachments }
2488
2650
  : null,
2651
+ };
2652
+ chatMessageBlockCache.set(message, {
2653
+ block,
2654
+ text: message.text,
2655
+ kind: message.kind,
2656
+ createdAt: message.createdAt,
2657
+ time: message.time,
2658
+ sender: message.sender,
2659
+ attachments: message.attachments,
2660
+ role: message.role,
2661
+ id: message.id,
2662
+ pendingPhase: message.pendingPhase,
2663
+ stopped: message.stopped,
2664
+ tableMode: buildOptions.tableMode,
2665
+ viewerKey: buildOptions.viewerKey,
2666
+ ownerKey: buildOptions.ownerKey,
2667
+ sharedContext: buildOptions.sharedContext,
2489
2668
  });
2669
+ blocks.push(block);
2490
2670
  continue;
2491
2671
  }
2492
- const lines = buildChatMessageLines(message, maxWidth, buildOptions);
2493
- if (lines.length > 0) {
2494
- blocks.push({ kind: "lines", variant: "event", lines });
2495
- }
2672
+ const lineBlock = cachedLineBlock(message, maxWidth, buildOptions);
2673
+ if (lineBlock) blocks.push(lineBlock);
2496
2674
  }
2497
2675
  return blocks;
2498
2676
  }
2499
2677
 
2678
+ /**
2679
+ * Non-message blocks (thinking cards, system cards, splash, chrome-less
2680
+ * summaries) go through the terminal line builders, which DO depend on width —
2681
+ * so unlike message blocks these cannot be made width-independent. What they
2682
+ * can avoid is being rebuilt when the width has not moved, which is every poll:
2683
+ * the poll replaces `session`, the portal's useMemo sees a new selectorState,
2684
+ * and the whole transcript is re-derived. Measured at 1500 messages with 10%
2685
+ * line blocks that was 1.17ms a time, and 5.26ms at 50%.
2686
+ *
2687
+ * Only the LAST width is kept per message. A drag sweeps widths and will miss
2688
+ * on every step — that work is genuinely required, since these lines are
2689
+ * wrapped to a column count.
2690
+ *
2691
+ * The compared fields were enumerated from buildChatMessageLines and the
2692
+ * builders it delegates to (message/thinking/system-notice cards). `stopped` is
2693
+ * included because history.js sets it IN PLACE on an existing message.
2694
+ */
2695
+ const chatLineBlockCache = new WeakMap();
2696
+
2697
+ function cachedLineBlock(message, maxWidth, buildOptions) {
2698
+ if (!message || typeof message !== "object") {
2699
+ const lines = buildChatMessageLines(message, maxWidth, buildOptions);
2700
+ return lines.length > 0 ? { kind: "lines", variant: "event", lines } : null;
2701
+ }
2702
+ const cached = chatLineBlockCache.get(message);
2703
+ if (cached
2704
+ && Object.is(cached.maxWidth, maxWidth)
2705
+ && Object.is(cached.text, message.text)
2706
+ && Object.is(cached.mobileText, message.mobileText)
2707
+ && Object.is(cached.role, message.role)
2708
+ && Object.is(cached.time, message.time)
2709
+ && Object.is(cached.createdAt, message.createdAt)
2710
+ && Object.is(cached.splash, message.splash)
2711
+ && Object.is(cached.thinking, message.thinking)
2712
+ && Object.is(cached.thinkingLabel, message.thinkingLabel)
2713
+ && Object.is(cached.progressKind, message.progressKind)
2714
+ && Object.is(cached.noChrome, message.noChrome)
2715
+ && Object.is(cached.cardTitle, message.cardTitle)
2716
+ && Object.is(cached.cardTitleColor, message.cardTitleColor)
2717
+ && Object.is(cached.cardBorderColor, message.cardBorderColor)
2718
+ && Object.is(cached.stopped, message.stopped)
2719
+ && Object.is(cached.tableMode, buildOptions.tableMode)
2720
+ && Object.is(cached.viewerKey, buildOptions.viewerKey)
2721
+ && Object.is(cached.ownerKey, buildOptions.ownerKey)
2722
+ && Object.is(cached.sharedContext, buildOptions.sharedContext)) {
2723
+ return cached.block;
2724
+ }
2725
+ const lines = buildChatMessageLines(message, maxWidth, buildOptions);
2726
+ const block = lines.length > 0 ? { kind: "lines", variant: "event", lines } : null;
2727
+ chatLineBlockCache.set(message, {
2728
+ block,
2729
+ maxWidth,
2730
+ text: message.text,
2731
+ mobileText: message.mobileText,
2732
+ role: message.role,
2733
+ time: message.time,
2734
+ createdAt: message.createdAt,
2735
+ splash: message.splash,
2736
+ thinking: message.thinking,
2737
+ thinkingLabel: message.thinkingLabel,
2738
+ progressKind: message.progressKind,
2739
+ noChrome: message.noChrome,
2740
+ cardTitle: message.cardTitle,
2741
+ cardTitleColor: message.cardTitleColor,
2742
+ cardBorderColor: message.cardBorderColor,
2743
+ stopped: message.stopped,
2744
+ tableMode: buildOptions.tableMode,
2745
+ viewerKey: buildOptions.viewerKey,
2746
+ ownerKey: buildOptions.ownerKey,
2747
+ sharedContext: buildOptions.sharedContext,
2748
+ });
2749
+ return block;
2750
+ }
2751
+
2500
2752
  // A centered inline rule ("──── label ────") for transcript markers. The dash
2501
2753
  // runs are CAPPED (not stretched to maxWidth): the web portal wraps by pixel
2502
2754
  // width, and a maxWidth-long "─" run overflows a narrower pane and wraps the
@@ -5320,7 +5572,7 @@ export function selectArtifactPickerModal(state, maxWidth = 88) {
5320
5572
 
5321
5573
  // ── Stats Inspector Pane ────────────────────────────────────────────
5322
5574
 
5323
- function formatCompactNumber(n) {
5575
+ export function formatCompactNumber(n) {
5324
5576
  if (n == null || !Number.isFinite(n)) return "0";
5325
5577
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
5326
5578
  if (n >= 10_000) return `${(n / 1_000).toFixed(1)}K`;