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
@@ -12,6 +12,10 @@ import {
12
12
  computeLegacyLayout,
13
13
  createInitialState,
14
14
  createStore,
15
+ AUTO_HISTORY_EVENT_SOFT_CAP,
16
+ formatCompactNumber,
17
+ formatCronTimestampForClient,
18
+ formatHumanDurationSeconds,
15
19
  getPromptInputRows,
16
20
  getTheme,
17
21
  isThemeLight,
@@ -136,6 +140,9 @@ function supportsLocalFileOpen(controller) {
136
140
  }
137
141
 
138
142
  const SESSION_LINK_COPIED_STATUS = "Session link copied to clipboard";
143
+ // Mirrors SESSION_NAV_SETTLE_MS in the controller: the access probe rides the
144
+ // same "wait for the selection to stop moving" rule as the session fetch.
145
+ const SESSION_ACCESS_SETTLE_MS = 140;
139
146
  const SESSION_LINK_PRIVATE_WARNING = "Only people with access can open this link.";
140
147
 
141
148
  function buildSessionLinkUrl(sessionId) {
@@ -1080,6 +1087,194 @@ function stripYamlFrontmatter(content) {
1080
1087
  return text.slice(3 + close.index + close[0].length);
1081
1088
  }
1082
1089
 
1090
+ const TABULAR_ARTIFACT_RE = /\.(csv|tsv)$/i;
1091
+
1092
+ function isTabularArtifact(filename, contentType) {
1093
+ if (TABULAR_ARTIFACT_RE.test(String(filename || ""))) return true;
1094
+ return /^text\/(csv|tab-separated-values)$/i.test(String(contentType || "").trim());
1095
+ }
1096
+
1097
+ /**
1098
+ * Tabular preview.
1099
+ *
1100
+ * Parsing is delegated to Papa Parse rather than split(",") because the cases
1101
+ * that break naive parsers are exactly the ones real exports contain: quoted
1102
+ * fields holding commas, embedded newlines inside a quoted cell, and escaped
1103
+ * quotes. It is loaded lazily so opening a .md never pays for the parser.
1104
+ *
1105
+ * Rendering is our own table, not a viewer library's: the preview has to
1106
+ * inherit the active theme's chrome (Win95 bevels, MS-DOS reverse video), and
1107
+ * a drop-in viewer ships its own styling that cannot participate in that.
1108
+ */
1109
+ const TABULAR_MAX_ROWS = 2000;
1110
+
1111
+ /**
1112
+ * Quote a cell for TSV the way a spreadsheet expects: only when the value
1113
+ * would otherwise break the row/column framing.
1114
+ */
1115
+ function tsvCell(value) {
1116
+ const text = String(value ?? "");
1117
+ return /[\t\n\r"]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
1118
+ }
1119
+
1120
+ function TabularPreview({ content, filename }) {
1121
+ const [parsed, setParsed] = React.useState(null);
1122
+ const wrapRef = React.useRef(null);
1123
+ const tableRef = React.useRef(null);
1124
+ const anchorCellRef = React.useRef(null);
1125
+
1126
+ React.useEffect(() => {
1127
+ let cancelled = false;
1128
+ setParsed(null);
1129
+ import("papaparse")
1130
+ .then((mod) => {
1131
+ if (cancelled) return;
1132
+ const Papa = mod.default || mod;
1133
+ const delimiter = /\.tsv$/i.test(String(filename || "")) ? "\t" : "";
1134
+ const out = Papa.parse(String(content || "").trim(), {
1135
+ delimiter,
1136
+ skipEmptyLines: "greedy",
1137
+ });
1138
+ setParsed({ rows: out.data || [], errors: out.errors || [] });
1139
+ })
1140
+ .catch(() => { if (!cancelled) setParsed({ rows: null, errors: [{ message: "parser unavailable" }] }); });
1141
+ return () => { cancelled = true; };
1142
+ }, [content, filename]);
1143
+
1144
+ // The drag can end anywhere, including outside the pane or the window.
1145
+ React.useEffect(() => {
1146
+ const release = () => { anchorCellRef.current = null; };
1147
+ window.addEventListener("mouseup", release);
1148
+ return () => window.removeEventListener("mouseup", release);
1149
+ }, []);
1150
+
1151
+ /** Select whole cells: snap both range ends to cell boundaries. */
1152
+ const selectCellSpan = React.useCallback((a, b) => {
1153
+ if (!a || !b) return;
1154
+ const selection = window.getSelection();
1155
+ if (!selection) return;
1156
+ const backwards = !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_PRECEDING);
1157
+ const range = document.createRange();
1158
+ range.setStartBefore(backwards ? b : a);
1159
+ range.setEndAfter(backwards ? a : b);
1160
+ selection.removeAllRanges();
1161
+ selection.addRange(range);
1162
+ }, []);
1163
+
1164
+ const cellFrom = (event) => (event.target?.closest ? event.target.closest("td,th") : null);
1165
+
1166
+ const onMouseDown = React.useCallback((event) => {
1167
+ if (event.button !== 0) return;
1168
+ const cell = cellFrom(event);
1169
+ if (!cell) return;
1170
+ // Suppress the native character-level selection before it starts, then
1171
+ // take focus by hand (preventDefault would otherwise deny it, and the
1172
+ // wrapper needs focus for Ctrl+A to reach us).
1173
+ event.preventDefault();
1174
+ wrapRef.current?.focus();
1175
+ anchorCellRef.current = cell;
1176
+ selectCellSpan(cell, cell);
1177
+ }, [selectCellSpan]);
1178
+
1179
+ const onMouseMove = React.useCallback((event) => {
1180
+ if (!anchorCellRef.current || !(event.buttons & 1)) return;
1181
+ const cell = cellFrom(event);
1182
+ if (cell) selectCellSpan(anchorCellRef.current, cell);
1183
+ }, [selectCellSpan]);
1184
+
1185
+ // Ctrl/Cmd+A selects the TABLE, not the page. Scoped to the pane, so it only
1186
+ // applies once the table has focus.
1187
+ const onKeyDown = React.useCallback((event) => {
1188
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
1189
+ if (event.key !== "a" && event.key !== "A") return;
1190
+ const table = tableRef.current;
1191
+ const selection = window.getSelection();
1192
+ if (!table || !selection) return;
1193
+ event.preventDefault();
1194
+ event.stopPropagation();
1195
+ const range = document.createRange();
1196
+ range.selectNodeContents(table);
1197
+ selection.removeAllRanges();
1198
+ selection.addRange(range);
1199
+ }, []);
1200
+
1201
+ /**
1202
+ * Emit real TSV rather than trusting each browser's table serializer, so a
1203
+ * paste into a spreadsheet lands in the right cells every time.
1204
+ */
1205
+ const onCopy = React.useCallback((event) => {
1206
+ const table = tableRef.current;
1207
+ const selection = window.getSelection();
1208
+ if (!table || !selection || selection.rangeCount === 0) return;
1209
+ const range = selection.getRangeAt(0);
1210
+ const rows = new Map();
1211
+ for (const cell of table.querySelectorAll("th,td")) {
1212
+ if (!range.intersectsNode(cell)) continue;
1213
+ const row = cell.parentElement;
1214
+ if (!rows.has(row)) rows.set(row, []);
1215
+ rows.get(row).push(tsvCell(cell.textContent));
1216
+ }
1217
+ // Nothing of the table is selected — leave the event alone.
1218
+ if (rows.size === 0) return;
1219
+ event.clipboardData.setData("text/plain", Array.from(rows.values(), (cells) => cells.join("\t")).join("\n"));
1220
+ event.preventDefault();
1221
+ }, []);
1222
+
1223
+ if (!parsed) {
1224
+ return React.createElement("div", { className: "ps-csv-status" }, "Parsing…");
1225
+ }
1226
+ // Fall back to the raw text rather than showing nothing: a file that will
1227
+ // not parse is still readable, and hiding it would be worse than plain.
1228
+ if (!parsed.rows || parsed.rows.length === 0) {
1229
+ return React.createElement("pre", { className: "ps-md-code-pre" },
1230
+ React.createElement("code", null, String(content || "")));
1231
+ }
1232
+
1233
+ const [header, ...body] = parsed.rows;
1234
+
1235
+ // Width is the WIDEST row, not the header's. A data row with more cells
1236
+ // than the header is malformed but common (trailing delimiters, a short
1237
+ // header), and papaparse reports no error for it — iterating the header
1238
+ // would silently drop those cells, which is the one thing a data viewer
1239
+ // must never do. Reduce, not Math.max(...rows): spreading tens of
1240
+ // thousands of arguments overflows the stack.
1241
+ const columnCount = body.reduce((max, row) => Math.max(max, row.length), header.length);
1242
+ const columns = Array.from({ length: columnCount }, (_, i) => header[i] ?? "");
1243
+
1244
+ // Cap the DOM. A 1 MiB CSV (the artifact ceiling) can be ~20k rows, and
1245
+ // 20k × N cells makes the pane unusable. Showing a bounded window and
1246
+ // saying so is better than hanging the tab.
1247
+ const truncated = body.length > TABULAR_MAX_ROWS;
1248
+ const visible = truncated ? body.slice(0, TABULAR_MAX_ROWS) : body;
1249
+
1250
+ // No row/column tally — it is not part of the file, and a footer under the
1251
+ // table is one more thing to avoid when copying. The only notes worth
1252
+ // showing are the ones that say the view is NOT the whole truth.
1253
+ const notes = [];
1254
+ if (truncated) notes.push(`showing first ${TABULAR_MAX_ROWS} of ${body.length} rows`);
1255
+ if (parsed.errors.length) notes.push(`${parsed.errors.length} parse warning${parsed.errors.length === 1 ? "" : "s"}`);
1256
+
1257
+ return React.createElement("div", {
1258
+ className: "ps-csv-wrap",
1259
+ ref: wrapRef,
1260
+ tabIndex: 0,
1261
+ onMouseDown,
1262
+ onMouseMove,
1263
+ onKeyDown,
1264
+ onCopy,
1265
+ },
1266
+ React.createElement("table", { className: "ps-csv-table", ref: tableRef },
1267
+ React.createElement("thead", null,
1268
+ React.createElement("tr", null,
1269
+ columns.map((cell, i) => React.createElement("th", { key: `h${i}` }, String(cell ?? ""))))),
1270
+ React.createElement("tbody", null,
1271
+ visible.map((row, r) => React.createElement("tr", { key: `r${r}` },
1272
+ columns.map((_, c) => React.createElement("td", { key: `c${c}` }, String(row[c] ?? ""))))))),
1273
+ notes.length
1274
+ ? React.createElement("div", { className: "ps-csv-status" }, notes.join(" · "))
1275
+ : null);
1276
+ }
1277
+
1083
1278
  function codeLanguageForArtifact(filename) {
1084
1279
  const ext = /\.([A-Za-z0-9]+)$/.exec(String(filename || ""));
1085
1280
  if (!ext) return null;
@@ -2294,30 +2489,29 @@ function StructuredChatBlocks({ lines, theme, controller = null }) {
2294
2489
  // every other block (splash, thinking cards, system cards, epoch dividers)
2295
2490
  // reuses the structured line renderer so the terminal-era special cases
2296
2491
  // keep exactly one implementation.
2297
- function RichChatBlocks({ blocks, theme, controller = null }) {
2298
- return React.createElement("div", { className: "ps-rich-chat" },
2299
- (blocks || []).map((block, index) => {
2300
- if (block.kind === "message") {
2301
- const header = block.header || {};
2302
- const roleClass = block.role === "user" ? " is-user" : " is-assistant";
2303
- const pendingClass = block.pendingPhase ? ` is-${block.pendingPhase}` : "";
2304
- const text = String(block.text || "");
2305
- // A speaker label only earns its place when it disambiguates:
2306
- // another human in a shared session. The viewer's own turns
2307
- // and the agent's are obvious from side and styling.
2308
- const showLabel = block.role === "user" && header.fromOtherPerson;
2309
- // Consecutive turns from the same speaker read as one
2310
- // continuous passage — no repeated header, tighter spacing.
2311
- const previous = blocks[index - 1];
2312
- const continued = previous
2313
- && previous.kind === "message"
2314
- && previous.role === block.role
2315
- && !(previous.header && previous.header.fromOtherPerson)
2316
- && !showLabel;
2317
- return React.createElement("article", {
2318
- key: block.id != null ? `msg:${block.id}` : `msg:${index}`,
2319
- className: `ps-rich-msg${roleClass}${pendingClass}${continued ? " is-continued" : ""}`,
2320
- },
2492
+ /**
2493
+ * One rich message, memoized.
2494
+ *
2495
+ * The body runs markdown through MarkdownPreviewContent — the single most
2496
+ * expensive thing in the transcript — and a pane resize used to re-run it for
2497
+ * every loaded message. `selectChatBlocks` now returns identity-stable blocks,
2498
+ * so this bails out instead. Every prop must stay referentially stable for
2499
+ * that to hold: `theme` comes from a Map, `controller` is a singleton, and
2500
+ * `continued` is hoisted to a boolean because it depends on the PREVIOUS block
2501
+ * and would otherwise have to be recomputed inside.
2502
+ */
2503
+ const RichChatMessage = React.memo(function RichChatMessage({ block, theme, controller, continued }) {
2504
+ const header = block.header || {};
2505
+ const roleClass = block.role === "user" ? " is-user" : " is-assistant";
2506
+ const pendingClass = block.pendingPhase ? ` is-${block.pendingPhase}` : "";
2507
+ const text = String(block.text || "");
2508
+ // A speaker label only earns its place when it disambiguates: another human
2509
+ // in a shared session. The viewer's own turns and the agent's are obvious
2510
+ // from side and styling.
2511
+ const showLabel = block.role === "user" && header.fromOtherPerson;
2512
+ return React.createElement("article", {
2513
+ className: `ps-rich-msg${roleClass}${pendingClass}${continued ? " is-continued" : ""}`,
2514
+ },
2321
2515
  React.createElement("header", { className: "ps-rich-msg-head" },
2322
2516
  showLabel
2323
2517
  ? React.createElement("span", {
@@ -2342,22 +2536,53 @@ function RichChatBlocks({ blocks, theme, controller = null }) {
2342
2536
  ? React.createElement("div", { className: "ps-rich-msg-body" },
2343
2537
  React.createElement(MarkdownPreviewContent, { content: text, theme }))
2344
2538
  : null,
2345
- block.attachments
2346
- ? React.createElement(ArtifactImageStrip, {
2347
- controller,
2348
- sessionId: block.attachments.sessionId,
2349
- attachments: block.attachments.attachments,
2350
- })
2351
- : null);
2539
+ block.attachments
2540
+ ? React.createElement(ArtifactImageStrip, {
2541
+ controller,
2542
+ sessionId: block.attachments.sessionId,
2543
+ attachments: block.attachments.attachments,
2544
+ })
2545
+ : null);
2546
+ });
2547
+
2548
+ /** A non-message block (splash, system card, divider), memoized the same way. */
2549
+ const RichChatLineBlock = React.memo(function RichChatLineBlock({ block, theme, controller }) {
2550
+ const lines = React.useMemo(() => normalizeLines(block.lines || []), [block.lines]);
2551
+ return React.createElement("div", {
2552
+ className: `ps-rich-lineblock is-${block.variant || "event"}`,
2553
+ }, React.createElement(StructuredChatBlocks, { lines, theme, controller }));
2554
+ });
2555
+
2556
+ function RichChatBlocks({ blocks, theme, controller = null }) {
2557
+ return React.createElement("div", { className: "ps-rich-chat" },
2558
+ (blocks || []).map((block, index) => {
2559
+ if (block.kind === "message") {
2560
+ // Consecutive turns from the same speaker read as one
2561
+ // continuous passage — no repeated header, tighter spacing.
2562
+ // Computed here because it reads the previous block; passing it
2563
+ // down as a boolean is what keeps the child memoizable.
2564
+ const previous = blocks[index - 1];
2565
+ const header = block.header || {};
2566
+ const showLabel = block.role === "user" && header.fromOtherPerson;
2567
+ const continued = Boolean(previous
2568
+ && previous.kind === "message"
2569
+ && previous.role === block.role
2570
+ && !(previous.header && previous.header.fromOtherPerson)
2571
+ && !showLabel);
2572
+ return React.createElement(RichChatMessage, {
2573
+ key: block.id != null ? `msg:${block.id}` : `msg:${index}`,
2574
+ block,
2575
+ theme,
2576
+ controller,
2577
+ continued,
2578
+ });
2352
2579
  }
2353
- return React.createElement("div", {
2580
+ return React.createElement(RichChatLineBlock, {
2354
2581
  key: `lines:${index}`,
2355
- className: `ps-rich-lineblock is-${block.variant || "event"}`,
2356
- }, React.createElement(StructuredChatBlocks, {
2357
- lines: normalizeLines(block.lines || []),
2582
+ block,
2358
2583
  theme,
2359
2584
  controller,
2360
- }));
2585
+ });
2361
2586
  }));
2362
2587
  }
2363
2588
 
@@ -2837,13 +3062,134 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
2837
3062
  // with a guide rail rather than an ASCII "└" prefix.
2838
3063
  const SESSION_KIND_GLYPHS = { group: "🗂", system: "⚙", service: "⚗" };
2839
3064
 
2840
- function RichSessionRow({ row, theme }) {
3065
+ /**
3066
+ * Selected-session details, pinned to the bottom of the Sessions panel.
3067
+ *
3068
+ * This exists so the ROWS can stay one clean line each. Everything that used
3069
+ * to unfold under the selected row (and, in the rich list, under every row)
3070
+ * lives here instead, at a fixed spot the eye can return to — so switching
3071
+ * sessions no longer reflows the list.
3072
+ *
3073
+ * Web-only by construction: the TUI renders `row.detailRuns` itself and is
3074
+ * untouched by this.
3075
+ */
3076
+ const SESSION_DETAIL_NONE = "—";
3077
+
3078
+ function SessionDetailBox({ session, childCount = 0 }) {
3079
+ // EVERY field renders on EVERY selection, empty ones as an em dash. The box
3080
+ // is a fixed grid of rows, so moving through the list cannot change its
3081
+ // height — a box that grew and shrank would shove the list under the
3082
+ // cursor mid-scroll. That is also why each value is a single clamped line.
3083
+ const field = (label, value, extraClass = "") => {
3084
+ const text = (value == null || value === "") ? SESSION_DETAIL_NONE : String(value);
3085
+ const empty = text === SESSION_DETAIL_NONE;
3086
+ return React.createElement("div", {
3087
+ className: `ps-session-detail-field${extraClass ? ` ${extraClass}` : ""}${empty ? " is-none" : ""}`,
3088
+ key: label,
3089
+ },
3090
+ React.createElement("span", { className: "ps-session-detail-label" }, label),
3091
+ React.createElement("span", { className: "ps-session-detail-value", title: text }, text));
3092
+ };
3093
+
3094
+ const usage = session?.contextUsage;
3095
+ const hasUsage = usage && Number.isFinite(usage.currentTokens) && Number.isFinite(usage.tokenLimit) && usage.tokenLimit > 0;
3096
+ const percent = hasUsage ? Math.round((usage.currentTokens / usage.tokenLimit) * 100) : null;
3097
+ const context = hasUsage
3098
+ ? `${formatCompactNumber(usage.currentTokens)} / ${formatCompactNumber(usage.tokenLimit)} · ${percent}%`
3099
+ : null;
3100
+
3101
+ // "Cron vs not" is a question the list could only answer with a glyph, so
3102
+ // spell it out — an unscheduled session says "off" rather than going blank,
3103
+ // which would be indistinguishable from "unknown".
3104
+ let cron = null;
3105
+ if (session && !session.isGroup) {
3106
+ cron = "off";
3107
+ if (session.cronActive === true) {
3108
+ cron = session.cronKind === "wall-clock"
3109
+ ? `next ${formatCronTimestampForClient(session.cronNextFireAt)}`
3110
+ : typeof session.cronInterval === "number"
3111
+ ? `every ${formatHumanDurationSeconds(session.cronInterval)}`
3112
+ : "on";
3113
+ }
3114
+ }
3115
+
3116
+ const model = session?.model
3117
+ ? (session.reasoningEffort && !String(session.model).includes(String(session.reasoningEffort))
3118
+ ? `${session.model}:${session.reasoningEffort}`
3119
+ : session.model)
3120
+ : null;
3121
+
3122
+ const access = !session ? null
3123
+ : session.visibility === "shared_write" ? "shared · write"
3124
+ : session.visibility === "shared_read" ? "shared · read"
3125
+ : "private";
3126
+
3127
+ // Groups have members rather than descendants; both answer "how many are
3128
+ // under this row", so they share the field.
3129
+ const children = session?.isGroup
3130
+ ? (session.memberCount == null ? null : String(session.memberCount))
3131
+ : (childCount > 0 ? String(childCount) : null);
3132
+
3133
+ return React.createElement("div", { className: "ps-session-detail-box" },
3134
+ // The row above ellipsizes to one line, so this is the only place the
3135
+ // whole name is legible. Two lines are RESERVED whether or not they are
3136
+ // used, so the box height still cannot move with the selection.
3137
+ field("Title", session?.title, "is-title"),
3138
+ field("ID", session?.sessionId, "is-id"),
3139
+ field("Model", model),
3140
+ field("Context", context, percent != null && percent >= 85 ? "is-hot" : percent != null && percent >= 70 ? "is-warm" : ""),
3141
+ field("Cron", cron, session?.cronActive === true ? "is-armed" : ""),
3142
+ field("Agent", session?.agentId),
3143
+ field("Status", session?.status),
3144
+ field("Children", children),
3145
+ field("Access", access));
3146
+ }
3147
+
3148
+ /**
3149
+ * One session row, memoized.
3150
+ *
3151
+ * The list re-renders whenever the selection moves, but `selectSessionRows`
3152
+ * hands back the SAME row object for every row whose inputs did not change, so
3153
+ * memoizing here means a keypress reconciles two rows instead of the whole
3154
+ * fleet. That only holds while every prop is referentially stable — hence the
3155
+ * hoisted click handler and ref setter rather than closures built per row.
3156
+ */
3157
+ const SessionListRow = React.memo(function SessionListRow({
3158
+ row, theme, rich, structuredRows, mobile, onRowClick, setRef,
3159
+ }) {
3160
+ const ref = React.useCallback((node) => setRef(row.sessionId, node), [setRef, row.sessionId]);
3161
+ const onClick = React.useCallback((event) => onRowClick(event, row), [onRowClick, row]);
3162
+
3163
+ return React.createElement("button", {
3164
+ type: "button",
3165
+ ref,
3166
+ className: `ps-list-button ps-session-list-button${row.active ? " is-selected" : ""}${row.selected ? " is-multiselected" : ""}${row.pinned ? " is-pinned" : ""}`,
3167
+ tabIndex: row.active ? 0 : -1,
3168
+ "aria-selected": row.active ? "true" : "false",
3169
+ onClick,
3170
+ },
3171
+ React.createElement("div", {
3172
+ className: rich ? "ps-session-row-content is-rich" : "ps-line ps-session-row-content",
3173
+ style: {
3174
+ paddingInlineStart: rich
3175
+ ? `${Math.max(0, row.depth) * 14}px`
3176
+ : `${Math.max(0, row.depth) * 4}px`,
3177
+ },
3178
+ },
3179
+ rich
3180
+ ? React.createElement(RichSessionRow, { row, theme, showDetail: mobile })
3181
+ : React.createElement(SessionRowContent, { row, theme, structured: structuredRows, showInlineDetail: mobile })));
3182
+ });
3183
+
3184
+ function RichSessionRow({ row, theme, showDetail = false }) {
2841
3185
  const chrome = row.chrome;
2842
- if (!chrome) return React.createElement(SessionRowContent, { row, theme, structured: true });
3186
+ if (!chrome) return React.createElement(SessionRowContent, { row, theme, structured: true, showInlineDetail: showDetail });
2843
3187
 
2844
3188
  const kindGlyph = SESSION_KIND_GLYPHS[chrome.kind] || null;
2845
3189
  const accent = resolveColor(theme, chrome.accentColor) || undefined;
2846
- const detailRuns = Array.isArray(row.detailRuns) ? row.detailRuns : [];
3190
+ // Mobile has no room for a detail box, so it keeps the inline detail line
3191
+ // it always had. Desktop moves that content to the panel footer.
3192
+ const detailRuns = showDetail && Array.isArray(row.detailRuns) ? row.detailRuns : [];
2847
3193
 
2848
3194
  return React.createElement("div", { className: "ps-rich-session-row" },
2849
3195
  React.createElement("div", { className: "ps-rich-session-main" },
@@ -2884,16 +3230,13 @@ function RichSessionRow({ row, theme }) {
2884
3230
  style: { color: resolveColor(theme, chrome.ctx.color) || undefined },
2885
3231
  }, chrome.ctx.text)
2886
3232
  : null),
2887
- // The detail line is ALWAYS rendered, selected or not. Swapping row
2888
- // content on selection made the whole list jump; now the only things
2889
- // that reflow are expand/collapse and the highlight moving.
2890
3233
  detailRuns.length > 0
2891
3234
  ? React.createElement("div", { className: "ps-rich-session-detail" },
2892
3235
  React.createElement(Runs, { runs: detailRuns, theme }))
2893
3236
  : null);
2894
3237
  }
2895
3238
 
2896
- function SessionRowContent({ row, theme, structured = false }) {
3239
+ function SessionRowContent({ row, theme, structured = false, showInlineDetail = false }) {
2897
3240
  const hasStructuredRuns = structured && Array.isArray(row.titleRuns);
2898
3241
  if (!hasStructuredRuns) {
2899
3242
  return Array.isArray(row.runs)
@@ -2902,14 +3245,16 @@ function SessionRowContent({ row, theme, structured = false }) {
2902
3245
  }
2903
3246
 
2904
3247
  // Dense row: the title takes one line (clamped by CSS) and the context %
2905
- // is pinned to the right. The full id · time · model · ctx detail unfolds
2906
- // only under the selected row.
3248
+ // is pinned to the right. id · time · model · ctx now live in the panel's
3249
+ // detail box rather than unfolding under the row, so selecting a session
3250
+ // no longer reflows the list.
2907
3251
  const ctxRuns = Array.isArray(row.ctxRuns) ? row.ctxRuns : [];
3252
+ const hasCtx = ctxRuns.length > 0;
3253
+ // Mobile keeps the inline unfold under the selected row (no detail box).
2908
3254
  const detailRuns = Array.isArray(row.detailRuns)
2909
3255
  ? row.detailRuns
2910
3256
  : (Array.isArray(row.selectedMetaRuns) ? row.selectedMetaRuns : []);
2911
- const hasCtx = ctxRuns.length > 0;
2912
- const hasDetail = row.active && detailRuns.length > 0;
3257
+ const hasDetail = showInlineDetail && row.active && detailRuns.length > 0;
2913
3258
 
2914
3259
  return React.createElement(React.Fragment, null,
2915
3260
  React.createElement("div", { className: "ps-session-row-line" },
@@ -2956,10 +3301,14 @@ function useStableValue(value) {
2956
3301
  }
2957
3302
 
2958
3303
  function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false }) {
3304
+ // Mobile keeps its inline detail line and gets no detail box — a reserved
3305
+ // footer would eat a meaningful slice of a phone screen.
3306
+ const isMobilePane = String(panelClassName).includes("ps-mobile-session-pane");
2959
3307
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
2960
3308
  const theme = getTheme(themeId);
2961
3309
  const sessionButtonRefs = React.useRef(new Map());
2962
3310
  const viewState = useControllerSelector(controller, (state) => ({
3311
+ branding: state.branding,
2963
3312
  activeSessionId: state.sessions.activeSessionId,
2964
3313
  sessionsById: state.sessions.byId,
2965
3314
  sessionsFlat: state.sessions.flat,
@@ -2993,12 +3342,21 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
2993
3342
  connection: {
2994
3343
  mode: viewState.connectionMode,
2995
3344
  },
2996
- }), [viewState.activeSessionId, viewState.auth, viewState.connectionMode, viewState.filterQuery, viewState.ownerFilter, viewState.pinnedIds, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
3345
+ // The root system session renders as the deployment's branding title.
3346
+ // Omitting this made the list fall back to "PilotSwarm" on a branded
3347
+ // deployment, while the controller's own calls (which pass real state)
3348
+ // produced the branded name.
3349
+ branding: viewState.branding,
3350
+ }), [viewState.activeSessionId, viewState.auth, viewState.branding, viewState.connectionMode, viewState.filterQuery, viewState.ownerFilter, viewState.pinnedIds, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
2997
3351
  // Hold the previous rows when a poll produced identical output.
2998
3352
  const rows = useStableValue(computedRows);
2999
3353
  const activeSession = viewState.activeSessionId
3000
3354
  ? viewState.sessionsById[viewState.activeSessionId] || null
3001
3355
  : null;
3356
+ // The row carries the computed child badge; the raw session does not.
3357
+ const activeRow = viewState.activeSessionId
3358
+ ? rows.find((r) => r.sessionId === viewState.activeSessionId) || null
3359
+ : null;
3002
3360
  // "Manage session" combines rename, model, and sharing in one tabbed modal
3003
3361
  // (opened from the toolbar so the composer chrome stays minimal, esp. on
3004
3362
  // mobile). Rename and sharing are owner/admin-only (session:manage /
@@ -3082,6 +3440,61 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
3082
3440
  : (activeSession && !activeSession.isSystem && !activeSession.isGroup && !activeSession.parentSessionId ? [activeSession.sessionId] : []);
3083
3441
  const canMoveToGroup = groupableIds.length > 0;
3084
3442
  const combinedPanelClassName = `ps-session-pane${viewState.rich ? " is-rich" : ""}${panelClassName ? ` ${panelClassName}` : ""}`;
3443
+ // The click handler must be referentially stable or every memoized row
3444
+ // re-renders on each keypress, defeating the point. It reads the current
3445
+ // rows/viewState from a ref that is refreshed on every render instead of
3446
+ // closing over them.
3447
+ const clickEnv = React.useRef(null);
3448
+ clickEnv.current = { rows, viewState, controller };
3449
+ const handleRowClick = React.useCallback((event, row) => {
3450
+ const { rows: currentRows, viewState: current, controller: ctl } = clickEnv.current;
3451
+ // Cmd/Ctrl-click toggles multi-selection (any row).
3452
+ if (event.metaKey || event.ctrlKey) {
3453
+ event.preventDefault();
3454
+ if (!current.selectMode) {
3455
+ ctl.dispatch({ type: "sessions/selectMode", enabled: true });
3456
+ }
3457
+ ctl.dispatch({ type: "sessions/selectToggle", sessionId: row.sessionId });
3458
+ ctl.setFocus("sessions");
3459
+ return;
3460
+ }
3461
+ // Shift-click selects a contiguous range from the active row.
3462
+ if (event.shiftKey && current.activeSessionId) {
3463
+ event.preventDefault();
3464
+ const ids = currentRows.map((r) => r.sessionId);
3465
+ const startIndex = ids.indexOf(current.activeSessionId);
3466
+ const endIndex = ids.indexOf(row.sessionId);
3467
+ if (startIndex >= 0 && endIndex >= 0) {
3468
+ const [from, to] = startIndex <= endIndex
3469
+ ? [startIndex, endIndex]
3470
+ : [endIndex, startIndex];
3471
+ const range = ids.slice(from, to + 1);
3472
+ const merged = Array.from(new Set([...(current.selectedIds || []), ...range]));
3473
+ ctl.setSessionSelection(merged);
3474
+ ctl.setFocus("sessions");
3475
+ return;
3476
+ }
3477
+ }
3478
+ const shouldToggleChildren = row.hasChildren && row.active;
3479
+ if (shouldToggleChildren) {
3480
+ ctl.dispatch({
3481
+ type: row.collapsed ? "sessions/expand" : "sessions/collapse",
3482
+ sessionId: row.sessionId,
3483
+ });
3484
+ ctl.setFocus("sessions");
3485
+ return;
3486
+ }
3487
+ // A normal click on a different row clears any multi-selection and
3488
+ // switches the active session.
3489
+ if (current.selectMode) {
3490
+ ctl.dispatch({ type: "sessions/selectClear" });
3491
+ }
3492
+ ctl.setFocus("sessions");
3493
+ if (!row.active) {
3494
+ ctl.loadSession(row.sessionId).catch(() => {});
3495
+ }
3496
+ }, []);
3497
+
3085
3498
  const setSessionButtonRef = React.useCallback((sessionId, node) => {
3086
3499
  if (!sessionId) return;
3087
3500
  if (node) {
@@ -3194,74 +3607,22 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
3194
3607
  ? React.createElement("div", { className: "ps-empty-state" }, viewState.filterQuery
3195
3608
  ? `No sessions matched "@@${viewState.filterQuery}".`
3196
3609
  : "No sessions yet.")
3197
- : rows.map((row) => React.createElement("button", {
3610
+ : rows.map((row) => React.createElement(SessionListRow, {
3198
3611
  key: row.sessionId,
3199
- type: "button",
3200
- ref: (node) => setSessionButtonRef(row.sessionId, node),
3201
- className: `ps-list-button ps-session-list-button${row.active ? " is-selected" : ""}${row.selected ? " is-multiselected" : ""}${row.pinned ? " is-pinned" : ""}`,
3202
- tabIndex: row.active ? 0 : -1,
3203
- "aria-selected": row.active ? "true" : "false",
3204
- onClick: (event) => {
3205
- // Cmd/Ctrl-click toggles multi-selection (any row).
3206
- if (event.metaKey || event.ctrlKey) {
3207
- event.preventDefault();
3208
- if (!viewState.selectMode) {
3209
- controller.dispatch({ type: "sessions/selectMode", enabled: true });
3210
- }
3211
- controller.dispatch({ type: "sessions/selectToggle", sessionId: row.sessionId });
3212
- controller.setFocus("sessions");
3213
- return;
3214
- }
3215
- // Shift-click selects a contiguous range from the active row.
3216
- if (event.shiftKey && viewState.activeSessionId) {
3217
- event.preventDefault();
3218
- const ids = rows.map((r) => r.sessionId);
3219
- const startIndex = ids.indexOf(viewState.activeSessionId);
3220
- const endIndex = ids.indexOf(row.sessionId);
3221
- if (startIndex >= 0 && endIndex >= 0) {
3222
- const [from, to] = startIndex <= endIndex
3223
- ? [startIndex, endIndex]
3224
- : [endIndex, startIndex];
3225
- const range = ids.slice(from, to + 1);
3226
- const merged = Array.from(new Set([...(viewState.selectedIds || []), ...range]));
3227
- controller.setSessionSelection(merged);
3228
- controller.setFocus("sessions");
3229
- return;
3230
- }
3231
- }
3232
- const shouldToggleChildren = row.hasChildren && row.active;
3233
- if (shouldToggleChildren) {
3234
- controller.dispatch({
3235
- type: row.collapsed ? "sessions/expand" : "sessions/collapse",
3236
- sessionId: row.sessionId,
3237
- });
3238
- controller.setFocus("sessions");
3239
- return;
3240
- }
3241
- // A normal click on a different row clears any
3242
- // multi-selection and switches the active session.
3243
- if (viewState.selectMode) {
3244
- controller.dispatch({ type: "sessions/selectClear" });
3245
- }
3246
- controller.setFocus("sessions");
3247
- if (!row.active) {
3248
- controller.loadSession(row.sessionId).catch(() => {});
3249
- }
3250
- },
3251
- },
3252
- React.createElement("div", {
3253
- className: viewState.rich ? "ps-session-row-content is-rich" : "ps-line ps-session-row-content",
3254
- style: {
3255
- paddingInlineStart: viewState.rich
3256
- ? `${Math.max(0, row.depth) * 14}px`
3257
- : `${Math.max(0, row.depth) * 4}px`,
3258
- },
3259
- },
3260
- viewState.rich
3261
- ? React.createElement(RichSessionRow, { row, theme })
3262
- : React.createElement(SessionRowContent, { row, theme, structured: structuredRows })),
3263
- )),
3264
- )),
3612
+ row,
3613
+ theme,
3614
+ rich: viewState.rich,
3615
+ structuredRows,
3616
+ mobile: isMobilePane,
3617
+ onRowClick: handleRowClick,
3618
+ setRef: setSessionButtonRef,
3619
+ }))),
3620
+ isMobilePane
3621
+ ? null
3622
+ : React.createElement(SessionDetailBox, {
3623
+ session: activeSession,
3624
+ childCount: activeRow?.childCount || 0,
3625
+ })),
3265
3626
  (manageOpen && activeSession && !activeSession.isGroup)
3266
3627
  ? React.createElement(SessionModifyModal, {
3267
3628
  controller,
@@ -3385,7 +3746,19 @@ function useActiveSessionAccess(controller, activeSessionId, isGroup) {
3385
3746
  .catch(() => { if (!cancelled) setState({ access: null, loading: false }); });
3386
3747
  return () => { cancelled = true; };
3387
3748
  }, [controller, activeSessionId, isGroup]);
3388
- React.useEffect(() => reload(), [reload]);
3749
+ // Deferred for the same reason the session fetch is: this fires on every
3750
+ // activeSessionId change, and scrolling the list changes that per keypress.
3751
+ // Without the settle, moving through a list of sessions issued one access
3752
+ // round trip per row. `reload` stays immediate for callers that ask for it
3753
+ // explicitly (after a sharing change).
3754
+ React.useEffect(() => {
3755
+ let cancelInFlight;
3756
+ const timer = setTimeout(() => { cancelInFlight = reload(); }, SESSION_ACCESS_SETTLE_MS);
3757
+ return () => {
3758
+ clearTimeout(timer);
3759
+ if (typeof cancelInFlight === "function") cancelInFlight();
3760
+ };
3761
+ }, [reload]);
3389
3762
  return { access: state.access, loading: state.loading, reload };
3390
3763
  }
3391
3764
 
@@ -3712,6 +4085,15 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
3712
4085
  [animatedDots, chrome.animateTitleRight, chrome.titleRight],
3713
4086
  );
3714
4087
  const richMode = viewState.chatViewMode === "rich" && !viewState.activeSessionIsGroup;
4088
+ const [loadingOlder, setLoadingOlder] = React.useState(false);
4089
+ // Scroll-up expands the transcript automatically until the soft cap, then
4090
+ // refuses — and the portal had no control to ask for more, so a busy
4091
+ // session's history became unreachable from the browser. Surface the
4092
+ // control exactly when the automatic path has given up.
4093
+ const showLoadOlder = Boolean(
4094
+ viewState.activeHistory?.hasOlderEvents
4095
+ && Number(viewState.activeHistory?.loadedEventCount || 0) >= AUTO_HISTORY_EVENT_SOFT_CAP,
4096
+ );
3715
4097
  const richBlocks = React.useMemo(
3716
4098
  () => (richMode ? selectChatBlocks(selectorState, viewState.contentWidth, { tableMode: "sentinel" }) : null),
3717
4099
  [richMode, selectorState, viewState.contentWidth],
@@ -3815,6 +4197,20 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
3815
4197
  className: richMode ? "is-wrapped is-rich" : "is-wrapped",
3816
4198
  panelClassName: richMode ? "ps-chat-panel is-rich" : "ps-chat-panel",
3817
4199
  bottomContent: composer,
4200
+ topContent: showLoadOlder
4201
+ ? React.createElement("div", { className: "ps-load-older-bar" },
4202
+ React.createElement("button", {
4203
+ type: "button",
4204
+ className: "ps-load-older-button",
4205
+ disabled: loadingOlder,
4206
+ onClick: () => {
4207
+ setLoadingOlder(true);
4208
+ controller.handleCommand(UI_COMMANDS.EXPAND_HISTORY)
4209
+ .catch(() => {})
4210
+ .finally(() => setLoadingOlder(false));
4211
+ },
4212
+ }, loadingOlder ? "Loading older messages…" : "↑ Load older messages"))
4213
+ : null,
3818
4214
  structuredBlocks: true,
3819
4215
  renderBody: richBlocks
3820
4216
  ? (_bodyLines, bodyTheme) => React.createElement(RichChatBlocks, {
@@ -4171,7 +4567,21 @@ function FilesPane({ controller, focused, mobile = false, previewOnly = false, n
4171
4567
  // In the mobile overlay the browser owns scrolling outright.
4172
4568
  const previewPaneKey = nativeScroll ? null : "filePreview";
4173
4569
 
4174
- const previewPane = previewIsImage
4570
+ const previewIsTabular = previewReady
4571
+ && !filesView.previewIsBinary
4572
+ && isTabularArtifact(filesView.selectedFilename, filesView.previewContentType)
4573
+ && Boolean(filesView.previewContent);
4574
+
4575
+ const previewPane = previewIsTabular
4576
+ ? React.createElement(RenderedPreviewPanel, {
4577
+ controller, title: null, color: "cyan", focused: false, theme,
4578
+ paneKey: previewPaneKey, scrollOffset: viewState.previewScroll,
4579
+ className: "ps-csv-preview", focusRegion: previewFocusRegion,
4580
+ }, React.createElement(TabularPreview, {
4581
+ content: filesView.previewContent,
4582
+ filename: filesView.selectedFilename,
4583
+ }))
4584
+ : previewIsImage
4175
4585
  ? React.createElement(BinaryArtifactPreviewPanel, {
4176
4586
  title: null,
4177
4587
  color: "cyan",