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