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.
- package/package.json +1 -1
- package/ui/core/src/controller.js +66 -6
- package/ui/core/src/selectors.js +265 -13
- package/ui/react/src/web-app.js +527 -117
- package/web/dist/assets/{abnfDiagram-VRR7QNED-C4qRR7va.js → abnfDiagram-VRR7QNED-DLV45wH4.js} +1 -1
- package/web/dist/assets/{arc-BQw2V2pL.js → arc-CE4xAYdE.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DMdWnLGG.js → architectureDiagram-ZJ3FMSHR-DfzR8-9F.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-DZnALw8j.js → blockDiagram-677ZJIJ3-C2pZdqvH.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-BGGZ42iX.js → c4Diagram-LMCZKHZV-CKRspTan.js} +1 -1
- package/web/dist/assets/channel-D7p-E4El.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-Dastiegd.js → chunk-2Q5K7J3B-CNjLrfHT.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-B4XexRzf.js → chunk-32BRIVSS-Bv5EwyaJ.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-D66MNGCN.js → chunk-5VM5RSS4-NDBesEbJ.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-DeqPS4Xm.js → chunk-EX3LRPZG-DHQht9ty.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-DIMWiXpM.js → chunk-JWPE2WC7-r0OQpJRF.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-vTyL0qgx.js → chunk-MOJQB5TN-fcwIDgtl.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-C7U9MKGw.js → chunk-RYQCIY6F-x7XQlDO1.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-BzPwQ7n3.js → chunk-V7JOEXUC-U8ZNVy1m.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-CmDsyK3b.js → chunk-VR4S4FIN-DLEdPLcN.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-ZJn6BaGL.js → chunk-XXDRQBXY-TeyAtB-i.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-yFZ-1yMb.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-yFZ-1yMb.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-CAA8JZZM.js → cose-bilkent-JH36ORCC-B3bAl1-Z.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-B6pX-lUD.js → cynefin-VYW2F7L2-DexP8nG-.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-sKTHU_UH.js → cynefinDiagram-TSTJHNR4-Doa2XnSs.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-EuakJ578.js → dagre-VKFMJZFB-DZMBKQcJ.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-DR3M_pic.js → diagram-FQU43EPY-BkzibmSq.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-BlS6yM0u.js → diagram-G47NLZAW-CihyHR3a.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-CQNXe6dc.js → diagram-NH7WQ7WH-OMDUUxwW.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-BizBeSQo.js → diagram-OA4YK3LP-Lc0imJba.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-C-_KM6Gg.js → diagram-WEI45ONY-Dlcy8hPe.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-BapWEdic.js → ebnfDiagram-CCIWWBDH-Dh3u17Dg.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-Btijn1Xj.js → erDiagram-Q63AITRT-C0OhlUC2.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-D8zK8HkH.js → flowDiagram-23GEKE2U-CADKxT-x.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-DyjkOIMW.js → ganttDiagram-NO4QXBWP-iVVdxWB7.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-3r9_QeYN.js → gitGraphDiagram-IHSO6WYX-BwDUec_a.js} +1 -1
- package/web/dist/assets/index-BGWTu1KK.css +1 -0
- package/web/dist/assets/{index-CIt9PdC-.js → index-Djfdr7ts.js} +2 -2
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-Ccd8ACUz.js → infoDiagram-FWYZ7A6U-CGPUif0s.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-DPRUC8V2.js → ishikawaDiagram-FXEZZL3T-QYnrl-KA.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-CkvayMsL.js → journeyDiagram-5HDEW3XC-CeKixxL0.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-CAK9Z-ha.js → kanban-definition-HUTT4EX6-CeYokFBS.js} +1 -1
- package/web/dist/assets/{linear-B-_ZjN2w.js → linear-BbA6_gnz.js} +1 -1
- package/web/dist/assets/{mermaid.core-BN9c--2r.js → mermaid.core--xGRoQTa.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-DuD_AHRU.js → mindmap-definition-LN4V7U3C-D53u8h0z.js} +1 -1
- package/web/dist/assets/papaparse.min-BU0o7jXn.js +10 -0
- package/web/dist/assets/{pegDiagram-2B236MQR-Cc1v58KA.js → pegDiagram-2B236MQR-B7QSyG-3.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-Bc32--ur.js → pieDiagram-ENE6RG2P-XtSGL7GG.js} +1 -1
- package/web/dist/assets/pilotswarm-CtO819QH.js +94 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-DpPTc2EP.js → quadrantDiagram-ABIIQ3AL-LZoYSbTH.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-DXMzwejw.js → railroadDiagram-RFXS5EU6-B5FREx2U.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-B3ImAxw-.js → requirementDiagram-TGXJPOKE-1VRFkBQX.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-hmVMffE2.js → sankeyDiagram-HTMAVEWB-J456gWvu.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-D53fk6uF.js → sequenceDiagram-DBY2YBRQ-CaCcw6KO.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-4U-I5d2g.js → sizeCapture-X5ZJPWSS-DClsP3_O.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-0Y5TgWhW.js → stateDiagram-2N3HPSRC-D6VyrKhd.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-D7eKFhHb.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-Umhy4qUu.js → swimlanes-5IMT3BWC-q678iAuo.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-DlVHQBQx.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-D5hOg3Qu.js → timeline-definition-FHXFAJF6-WnPK_zhe.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-QVIcE3UD.js → vennDiagram-L72KCM5P-WpumuLld.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-BEj1M2cB.js → wardleyDiagram-EHGQE667-CBrYLUm_.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-xojRIXIf.js → xychartDiagram-FW5EYKEG-DbX1rnDW.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/channel-CB157dQ5.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-B6JwBYlv.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-B6JwBYlv.js +0 -1
- package/web/dist/assets/index-CT2_MRcC.css +0 -1
- package/web/dist/assets/pilotswarm-BgzdDy09.js +0 -93
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DqIcGfAY.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-DA9txLi6.js +0 -8
package/ui/react/src/web-app.js
CHANGED
|
@@ -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
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
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
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
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(
|
|
2580
|
+
return React.createElement(RichChatLineBlock, {
|
|
2354
2581
|
key: `lines:${index}`,
|
|
2355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2906
|
-
//
|
|
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
|
|
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
|
-
|
|
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(
|
|
3610
|
+
: rows.map((row) => React.createElement(SessionListRow, {
|
|
3198
3611
|
key: row.sessionId,
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
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
|
-
|
|
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
|
|
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",
|