pilotswarm 0.5.59 → 0.5.61
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/README.md +7 -0
- package/package.json +2 -2
- package/ui/core/src/controller.js +56 -13
- package/ui/core/src/history.js +15 -1
- package/ui/core/src/index.js +2 -0
- package/ui/core/src/moa.js +47 -0
- package/ui/core/src/reducer.js +70 -49
- package/ui/core/src/selectors.js +27 -13
- package/ui/core/src/session-errors.js +97 -0
- package/ui/core/src/state.js +1 -0
- package/ui/react/src/web-app.js +327 -136
- package/web/dist/assets/{abnfDiagram-VRR7QNED-D9Rv2VpV.js → abnfDiagram-VRR7QNED-CCVkGWsr.js} +1 -1
- package/web/dist/assets/{arc-BATiucnq.js → arc-y040btO3.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CSTMt0si.js → architectureDiagram-ZJ3FMSHR-24Yt5esy.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-C_RGqfeY.js → blockDiagram-677ZJIJ3-BlbS-_GI.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-DH__s__0.js → c4Diagram-LMCZKHZV-_mamn0Fv.js} +1 -1
- package/web/dist/assets/channel-COFs9dqk.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-C197fD3B.js → chunk-2Q5K7J3B-TCbIB2Ni.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-BjpuBroU.js → chunk-32BRIVSS-C9q5KNKG.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-W_jL8euj.js → chunk-5VM5RSS4-BvoqfU8P.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-a9h0dtlp.js → chunk-EX3LRPZG-zFJjZeNd.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-CQN0qE7F.js → chunk-JWPE2WC7-Ce_6C5M-.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-D-Y6KQ7t.js → chunk-MOJQB5TN-CPjpLM65.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-jPwseC5f.js → chunk-RYQCIY6F-CsmVUSDH.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-DRNCEcQh.js → chunk-V7JOEXUC-CgrmPPOd.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-DzhUcoZ7.js → chunk-VR4S4FIN-BxAHDR7h.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-ldJfyPXK.js → chunk-XXDRQBXY-BGfFoO5_.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-B5-KmsCx.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-B5-KmsCx.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-BBVo21wR.js → cose-bilkent-JH36ORCC-B6KcngJW.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CgRQfdW1.js → cynefin-VYW2F7L2-DaY7l4Ce.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Ckz2X0t3.js → cynefinDiagram-TSTJHNR4-BldfGsoa.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-C1SanJiY.js → dagre-VKFMJZFB-C4T9X5Bs.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-dBdEifVy.js → diagram-FQU43EPY-DCVA_t-e.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-Byqc1VCB.js → diagram-G47NLZAW-pbTZGgE5.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-9aeTaCsX.js → diagram-NH7WQ7WH-P8Iks122.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-CL0a0o0k.js → diagram-OA4YK3LP-hEjZPipp.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-YlMQOJfY.js → diagram-WEI45ONY-CVAzVCGu.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-qMZQEnws.js → ebnfDiagram-CCIWWBDH-BLL398qt.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-C3CWBCtq.js → erDiagram-Q63AITRT-CLTADyUG.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-BN79sybL.js → flowDiagram-23GEKE2U-BAdlWAt0.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-BOWhiAgB.js → ganttDiagram-NO4QXBWP-C7Z2wavr.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-CKX9OACp.js → gitGraphDiagram-IHSO6WYX-BI546pYu.js} +1 -1
- package/web/dist/assets/index-CvW-4eXF.css +1 -0
- package/web/dist/assets/index-DsPaDrf9.js +24 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-QQEunPNs.js → infoDiagram-FWYZ7A6U-8C7iQydo.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CvMwrnLQ.js → ishikawaDiagram-FXEZZL3T-Co4STVxQ.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-giNpEVpj.js → journeyDiagram-5HDEW3XC-DbUt11q-.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-CUPPpo27.js → kanban-definition-HUTT4EX6-DUNcFmme.js} +1 -1
- package/web/dist/assets/{linear-AXqadfOR.js → linear-Cjaxj94d.js} +1 -1
- package/web/dist/assets/{mermaid.core-C78wdk8Y.js → mermaid.core-B3cPFf4f.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-BGUmh9bK.js → mindmap-definition-LN4V7U3C-DaZtxB4N.js} +1 -1
- package/web/dist/assets/{papaparse.min-Bh8RLCk_.js → papaparse.min-vfdso2fs.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-BHsKXKhK.js → pegDiagram-2B236MQR-DkBpYV7m.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-D3EvWTdH.js → pieDiagram-ENE6RG2P-By3tI-vc.js} +1 -1
- package/web/dist/assets/pilotswarm-BL_NbZug.js +98 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CbkxfLkV.js → quadrantDiagram-ABIIQ3AL-DZmVm0xK.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-H4M6JSDW.js → railroadDiagram-RFXS5EU6-BC5Mbz7q.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-BJo7JW1v.js → requirementDiagram-TGXJPOKE-CyZNX_O1.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-DL19MeOP.js → sankeyDiagram-HTMAVEWB-7603NEmu.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DyVFc5pQ.js → sequenceDiagram-DBY2YBRQ-CXJ-i2Xj.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-CKbcKIEQ.js → sizeCapture-X5ZJPWSS-CKT0ATnS.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-BSukSXzS.js → stateDiagram-2N3HPSRC-BIDPX4YU.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Dr8oD9T6.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-BUd7UhsF.js → swimlanes-5IMT3BWC-CPoy6vnO.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-BV0EQRlB.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-Dt1gludn.js → timeline-definition-FHXFAJF6-BGdNkplk.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-BHxt00GV.js → vennDiagram-L72KCM5P-ByFzarUb.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-BT3fIDbe.js → wardleyDiagram-EHGQE667-CAk0SYuv.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-ChoYDS2b.js → xychartDiagram-FW5EYKEG-Np7h6_A0.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/channel-CqgY9oZh.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-Uz4DYZs2.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-Uz4DYZs2.js +0 -1
- package/web/dist/assets/index-B4s2lTsK.js +0 -24
- package/web/dist/assets/index-DhGdE2Nd.css +0 -1
- package/web/dist/assets/pilotswarm-UV6T1_QI.js +0 -98
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BhKG3pJB.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CgoFkXJE.js +0 -8
package/README.md
CHANGED
|
@@ -260,3 +260,10 @@ Docs: [Quick Start](https://github.com/affandar/pilotswarm/blob/main/docs/quicks
|
|
|
260
260
|
[MCP Setup](https://github.com/affandar/pilotswarm/blob/main/docs/user-guide/mcp-local-setup.md) ·
|
|
261
261
|
[Web API Reference](https://github.com/affandar/pilotswarm/blob/main/docs/api/reference.md) ·
|
|
262
262
|
[Architecture / Layering](https://github.com/affandar/pilotswarm/blob/main/docs/architecture/layering.md)
|
|
263
|
+
|
|
264
|
+
## Master of Agents
|
|
265
|
+
|
|
266
|
+
The portal includes a personal multi-session workspace with resizable desktop
|
|
267
|
+
chat/canvas panels, one focused composer, and zen mode. Phones show one panel
|
|
268
|
+
at a time with a layout minimap and swipe navigation. Layouts save automatically.
|
|
269
|
+
See the [MoA guide](https://github.com/affandar/PilotSwarm/blob/main/docs/user-guide/master-of-agents.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pilotswarm",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.61",
|
|
4
4
|
"description": "PilotSwarm application package: terminal UI, browser portal + Web API server, and MCP server — one install, three bins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"ink": "^6.8.0",
|
|
82
82
|
"jose": "^6.2.2",
|
|
83
83
|
"mermaid": "^11.16.0",
|
|
84
|
-
"pilotswarm-sdk": "0.5.
|
|
84
|
+
"pilotswarm-sdk": "0.5.61",
|
|
85
85
|
"react": "^19.2.4",
|
|
86
86
|
"react-dom": "^19.2.4",
|
|
87
87
|
"ws": "^8.18.2"
|
|
@@ -362,6 +362,16 @@ function timestampMs(value) {
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
function isSameOrOlderSessionUpdate(previousSession, nextSession) {
|
|
365
|
+
// Detail patch construction must use the same ordering as the reducer.
|
|
366
|
+
// Client event timestamps can exceed the server's next status write.
|
|
367
|
+
const version = (value) => {
|
|
368
|
+
if (value == null || value === "" || typeof value === "boolean") return null;
|
|
369
|
+
const number = Number(value);
|
|
370
|
+
return Number.isSafeInteger(number) && number > 0 ? number : null;
|
|
371
|
+
};
|
|
372
|
+
const previousVersion = version(previousSession?.statusVersion);
|
|
373
|
+
const nextVersion = version(nextSession?.statusVersion);
|
|
374
|
+
if (previousVersion != null && nextVersion != null) return nextVersion <= previousVersion;
|
|
365
375
|
const previousUpdatedAt = timestampMs(previousSession?.updatedAt);
|
|
366
376
|
const nextUpdatedAt = timestampMs(nextSession?.updatedAt);
|
|
367
377
|
return previousUpdatedAt > 0 && nextUpdatedAt > 0 && nextUpdatedAt <= previousUpdatedAt;
|
|
@@ -473,6 +483,13 @@ function buildSessionMergePatch(previousSession, nextSession) {
|
|
|
473
483
|
changed = true;
|
|
474
484
|
}
|
|
475
485
|
|
|
486
|
+
// Ordering metadata is required even when unchanged. A conflicting status
|
|
487
|
+
// at the same version must not masquerade as an unversioned live event.
|
|
488
|
+
if (changed) {
|
|
489
|
+
for (const key of ["statusVersion", "updatedAt"]) {
|
|
490
|
+
if (nextSession[key] !== undefined) patch[key] = nextSession[key];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
476
493
|
return changed ? patch : null;
|
|
477
494
|
}
|
|
478
495
|
|
|
@@ -3255,6 +3272,8 @@ export class PilotSwarmUiController {
|
|
|
3255
3272
|
*/
|
|
3256
3273
|
revealCreatedSession() {
|
|
3257
3274
|
if (this.getState().admin?.visible) this.closeAdminConsole();
|
|
3275
|
+
this.closeBudget();
|
|
3276
|
+
this.dispatch({ type: "ui/revealCreatedSession", sessionId: this.getState().sessions.activeSessionId });
|
|
3258
3277
|
this.setFocus(FOCUS_REGIONS.PROMPT);
|
|
3259
3278
|
}
|
|
3260
3279
|
|
|
@@ -5454,7 +5473,9 @@ export class PilotSwarmUiController {
|
|
|
5454
5473
|
return;
|
|
5455
5474
|
}
|
|
5456
5475
|
await this.ensureSessionHistory(sessionId, { force: true });
|
|
5476
|
+
if (this.getState().sessions.activeSessionId !== sessionId) return;
|
|
5457
5477
|
await this.syncSessionDetail(sessionId).catch(() => {});
|
|
5478
|
+
if (this.getState().sessions.activeSessionId !== sessionId) return;
|
|
5458
5479
|
this.attachActiveSession(sessionId);
|
|
5459
5480
|
this.ensureInspectorData().catch(() => {});
|
|
5460
5481
|
// Canvas snapshot rides the selection burst. Invalidate-then-fetch on
|
|
@@ -5590,8 +5611,9 @@ export class PilotSwarmUiController {
|
|
|
5590
5611
|
* history pipeline recognizes the canonical prefix and keeps it out of
|
|
5591
5612
|
* the portal chat pane; the transcript still records it for provenance.
|
|
5592
5613
|
*/
|
|
5593
|
-
submitCanvasAction(sessionId, message) {
|
|
5614
|
+
submitCanvasAction(sessionId, message, slot = 1) {
|
|
5594
5615
|
if (!sessionId) return Promise.resolve({ ok: false, reason: "no session" });
|
|
5616
|
+
if (!Number.isInteger(slot) || slot < 1 || slot > 5) return Promise.resolve({ ok: false, reason: "invalid canvas slot" });
|
|
5595
5617
|
// Creator-only, mirrored from the server's enforcement (the server is
|
|
5596
5618
|
// the authority; this is the friendly refusal). The canvas mutates —
|
|
5597
5619
|
// a shared viewer may be looking at a different revision than the one
|
|
@@ -5601,7 +5623,7 @@ export class PilotSwarmUiController {
|
|
|
5601
5623
|
// longer pre-refuses non-owners: a write-shared collaborator's click
|
|
5602
5624
|
// must reach the agent, and the server's refusal for a read-only
|
|
5603
5625
|
// viewer comes back as the result's reason.
|
|
5604
|
-
const contract = this.getState().canvas.bySessionId[sessionId]?.responseContract || null;
|
|
5626
|
+
const contract = this.getState().canvas.bySessionId[canvasPrefKey(sessionId, slot)]?.responseContract || null;
|
|
5605
5627
|
const verdict = validateCanvasAction(contract, message);
|
|
5606
5628
|
if (!verdict.ok) return Promise.resolve(verdict);
|
|
5607
5629
|
if (!this.canvasActionLimiters) this.canvasActionLimiters = new Map();
|
|
@@ -6011,15 +6033,42 @@ export class PilotSwarmUiController {
|
|
|
6011
6033
|
this.maybeFlushQueuedOutbox(sessionId, session);
|
|
6012
6034
|
}
|
|
6013
6035
|
|
|
6036
|
+
async openCreatedSession(created, options = {}) {
|
|
6037
|
+
const sessionId = created.sessionId;
|
|
6038
|
+
// Creation is explicit navigation, even if a paged/stale catalog or
|
|
6039
|
+
// the current filters omit the new row. Seed the response, preserve
|
|
6040
|
+
// the outgoing draft, and latch the same visibility rules as a link.
|
|
6041
|
+
// Invalidate catalog reads that started before this row existed.
|
|
6042
|
+
this.sessionRefreshSeq = (this.sessionRefreshSeq || 0) + 1;
|
|
6043
|
+
if (this.getState().ui.promptEdit) this.exitPendingPromptEdit({ restoreDraft: true });
|
|
6044
|
+
this.dispatch({ type: "sessions/selected", sessionId });
|
|
6045
|
+
this.dispatch({ type: "ui/sequenceExpandedTurns", turns: [] });
|
|
6046
|
+
this.dispatch({ type: "ui/sequenceSelectedTurn", turn: null });
|
|
6047
|
+
this.dispatch({ type: "sessions/navigationIntent", sessionId });
|
|
6048
|
+
this.dispatch({ type: "sessions/merged", session: normalizeSessionListRow({
|
|
6049
|
+
...created,
|
|
6050
|
+
...(options.groupId ? { viewerGroupId: options.groupId } : {}),
|
|
6051
|
+
}) });
|
|
6052
|
+
this.revealCreatedSession();
|
|
6053
|
+
this.dispatch({ type: "ui/status", text: `Created session ${sessionId.slice(0, 8)}` });
|
|
6054
|
+
// A follow-up read failure must not masquerade as a failed create and
|
|
6055
|
+
// invite a duplicate. The regular refresh loop can finish hydration.
|
|
6056
|
+
try {
|
|
6057
|
+
await this.loadSession(sessionId);
|
|
6058
|
+
} catch (error) {
|
|
6059
|
+
if (this.getState().sessions.activeSessionId === sessionId) {
|
|
6060
|
+
this.dispatch({ type: "ui/status", text: `Created session ${sessionId.slice(0, 8)}; could not load it yet: ${error?.message || error}` });
|
|
6061
|
+
}
|
|
6062
|
+
}
|
|
6063
|
+
this.scheduleSessionsRefresh(0);
|
|
6064
|
+
}
|
|
6065
|
+
|
|
6014
6066
|
async createSession(options = {}) {
|
|
6015
6067
|
try {
|
|
6016
6068
|
const requestOptions = this.applyActiveGroupDefault(options);
|
|
6017
6069
|
const created = await this.transport.createSession(requestOptions);
|
|
6018
6070
|
await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
|
|
6019
|
-
await this.
|
|
6020
|
-
await this.loadSession(created.sessionId);
|
|
6021
|
-
this.revealCreatedSession();
|
|
6022
|
-
this.dispatch({ type: "ui/status", text: `Created session ${created.sessionId.slice(0, 8)}` });
|
|
6071
|
+
await this.openCreatedSession(created, requestOptions);
|
|
6023
6072
|
return created;
|
|
6024
6073
|
} catch (error) {
|
|
6025
6074
|
this.dispatch({ type: "ui/status", text: error?.message || String(error) || "Failed to create session" });
|
|
@@ -6039,13 +6088,7 @@ export class PilotSwarmUiController {
|
|
|
6039
6088
|
// session that was just created, and a lost count only reorders a list.
|
|
6040
6089
|
this._recordAgentPickerUse(agentName);
|
|
6041
6090
|
await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
|
|
6042
|
-
await this.
|
|
6043
|
-
await this.loadSession(created.sessionId);
|
|
6044
|
-
this.revealCreatedSession();
|
|
6045
|
-
this.dispatch({
|
|
6046
|
-
type: "ui/status",
|
|
6047
|
-
text: `Created ${formatAgentDisplayTitle(agentName, options.title)} session ${created.sessionId.slice(0, 8)}`,
|
|
6048
|
-
});
|
|
6091
|
+
await this.openCreatedSession(created, requestOptions);
|
|
6049
6092
|
return created;
|
|
6050
6093
|
} catch (error) {
|
|
6051
6094
|
this.dispatch({ type: "ui/status", text: error?.message || String(error) || "Failed to create session" });
|
package/ui/core/src/history.js
CHANGED
|
@@ -2,6 +2,7 @@ import { formatHumanDurationSeconds, formatTimestamp, shortModelName, shortSessi
|
|
|
2
2
|
import { isCanvasActionContent, parseCanvasActionContent } from "./canvas-actions.js";
|
|
3
3
|
import { formatCompactionActivityRuns } from "./context-usage.js";
|
|
4
4
|
import { canonicalSystemTitle } from "./system-titles.js";
|
|
5
|
+
import { buildSessionWarning } from "./session-errors.js";
|
|
5
6
|
|
|
6
7
|
export const DEFAULT_HISTORY_EVENT_LIMIT = 300;
|
|
7
8
|
export const HISTORY_EVENT_LIMIT_STEPS = [
|
|
@@ -16,6 +17,7 @@ export const HISTORY_EVENT_LIMIT_STEPS = [
|
|
|
16
17
|
// noisy sessions (thousands of tool/orchestration events between messages)
|
|
17
18
|
// load transcript pages instead of raw-stream pages.
|
|
18
19
|
export const CHAT_HISTORY_EVENT_TYPES = [
|
|
20
|
+
"session.error",
|
|
19
21
|
"user.message",
|
|
20
22
|
"assistant.message",
|
|
21
23
|
// Needed to distinguish interim assistant output from the final answer
|
|
@@ -412,7 +414,8 @@ function areMessagesEquivalent(left, right) {
|
|
|
412
414
|
// event per rev), and share identical text by design (the artifact://
|
|
413
415
|
// link) — the redelivery time-window below would eat rev N-1 on every
|
|
414
416
|
// reload, making the transcript disagree with what live append showed.
|
|
415
|
-
if (left.kind === "canvas-update" || right.kind === "canvas-update"
|
|
417
|
+
if (left.kind === "canvas-update" || right.kind === "canvas-update"
|
|
418
|
+
|| left.kind === "session-warning" || right.kind === "session-warning") {
|
|
416
419
|
return left.kind === right.kind && left.id === right.id;
|
|
417
420
|
}
|
|
418
421
|
|
|
@@ -1211,6 +1214,10 @@ export function buildHistoryModel(events = [], options = {}) {
|
|
|
1211
1214
|
|
|
1212
1215
|
for (const event of events) {
|
|
1213
1216
|
storedEvents.push(event);
|
|
1217
|
+
if (event.eventType === "session.error") {
|
|
1218
|
+
const warning = buildSessionWarning(event);
|
|
1219
|
+
if (warning) chat.push(warning);
|
|
1220
|
+
}
|
|
1214
1221
|
if (["user.message", "session.turn_completed", "session.turn_stopped", "session.epoch_committed"].includes(event.eventType)) {
|
|
1215
1222
|
settleAssistantResponses(chat, event);
|
|
1216
1223
|
}
|
|
@@ -1308,6 +1315,13 @@ export function appendEventToHistory(history, event) {
|
|
|
1308
1315
|
stoppedMessageIds: Array.isArray(history?.stoppedMessageIds) ? history.stoppedMessageIds : [],
|
|
1309
1316
|
};
|
|
1310
1317
|
|
|
1318
|
+
if (event.eventType === "session.error") {
|
|
1319
|
+
const warning = buildSessionWarning(event);
|
|
1320
|
+
if (warning && !next.chat.some(item => item.id === warning.id)) {
|
|
1321
|
+
next.chat = clampHistoryItems([...next.chat, warning], loadedEventLimit);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1311
1325
|
if (["user.message", "session.turn_completed", "session.turn_stopped", "session.epoch_committed"].includes(event.eventType)) {
|
|
1312
1326
|
settleAssistantResponses(next.chat, event);
|
|
1313
1327
|
}
|
package/ui/core/src/index.js
CHANGED
|
@@ -16,3 +16,5 @@ export { isThemeLight } from "./themes/helpers.js";
|
|
|
16
16
|
export * from "./themes/index.js";
|
|
17
17
|
export { validateCanvasAction, formatCanvasActionPrompt, parseCanvasActionContent, isCanvasActionContent, createCanvasActionLimiter, CANVAS_ACTION_PREFIX } from "./canvas-actions.js";
|
|
18
18
|
export * from "./table-layout.js";
|
|
19
|
+
|
|
20
|
+
export * from "./moa.js";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// One personal desktop layout, containing references and geometry only.
|
|
2
|
+
export const MOA_MAX_PANELS = 16;
|
|
3
|
+
export const MOA_BREAKPOINT = 920;
|
|
4
|
+
const object = (v) => v && typeof v === "object" && !Array.isArray(v);
|
|
5
|
+
const id = (v) => typeof v === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(v);
|
|
6
|
+
export const emptyMoaPanel = () => ({ id: crypto.randomUUID(), type: "empty" });
|
|
7
|
+
export const moaLeaves = (n) => !n ? [] : n.type === "split" ? [...moaLeaves(n.first), ...moaLeaves(n.second)] : [n];
|
|
8
|
+
export function normalizeMoaLayout(value) {
|
|
9
|
+
if (!object(value)) throw new Error("Invalid MoA layout.");
|
|
10
|
+
const seen = new Set(); let panels = 0; let nodes = 0;
|
|
11
|
+
function walk(n, depth = 0) {
|
|
12
|
+
if (!object(n) || depth > 15 || ++nodes > 31 || !id(n.id) || seen.has(n.id)) throw new Error("Invalid MoA panel tree.");
|
|
13
|
+
seen.add(n.id);
|
|
14
|
+
if (n.type === "split") {
|
|
15
|
+
if (!["row", "column"].includes(n.direction) || !Number.isFinite(n.ratio)) throw new Error("Invalid MoA split.");
|
|
16
|
+
return { id: n.id, type: "split", direction: n.direction, ratio: Math.min(90, Math.max(10, n.ratio)), first: walk(n.first, depth + 1), second: walk(n.second, depth + 1) };
|
|
17
|
+
}
|
|
18
|
+
if (++panels > MOA_MAX_PANELS) throw new Error(`A MoA supports up to ${MOA_MAX_PANELS} panels.`);
|
|
19
|
+
if (n.type === "empty") return { id: n.id, type: "empty" };
|
|
20
|
+
if (!["chat", "canvas"].includes(n.type) || !id(n.sessionId)) throw new Error("Invalid MoA session reference.");
|
|
21
|
+
if (n.type === "canvas" && (!Number.isInteger(n.slot) || n.slot < 1 || n.slot > 5)) throw new Error("Invalid canvas slot.");
|
|
22
|
+
return { id: n.id, type: n.type, sessionId: n.sessionId, ...(n.type === "canvas" ? { slot: n.slot } : {}) };
|
|
23
|
+
}
|
|
24
|
+
return { name: typeof value.name === "string" ? value.name.trim().slice(0, 64) || "Untitled MoA" : "Untitled MoA", tree: value.tree == null ? null : walk(value.tree) };
|
|
25
|
+
}
|
|
26
|
+
export function normalizeMoa(value) {
|
|
27
|
+
const safeTree = layout => { try { return normalizeMoaLayout(layout).tree; } catch { return null; } };
|
|
28
|
+
// Version 2 has one layout. A deliberately cleared layout must stay empty.
|
|
29
|
+
if (value?.version === 2 || (object(value) && Object.hasOwn(value, "tree"))) {
|
|
30
|
+
return { version: 2, tree: safeTree(value), ...(Number.isFinite(value.aspectRatio) && value.aspectRatio >= 0.2 && value.aspectRatio <= 8 ? { aspectRatio: value.aspectRatio } : {}) };
|
|
31
|
+
}
|
|
32
|
+
// Migrate the selected legacy dashboard when populated; otherwise keep
|
|
33
|
+
// the first populated dashboard rather than a previously selected blank tab.
|
|
34
|
+
const slots = Array.isArray(value?.slots) ? value.slots.slice(0, 5) : [];
|
|
35
|
+
const active = Number.isInteger(value?.activeSlot) ? Math.max(0, Math.min(4, value.activeSlot)) : 0;
|
|
36
|
+
const tree = safeTree(slots[active]) || slots.map(safeTree).find(Boolean) || null;
|
|
37
|
+
return { version: 2, tree };
|
|
38
|
+
}
|
|
39
|
+
export function replaceMoaNode(tree, nodeId, next) {
|
|
40
|
+
if (!tree) return null;
|
|
41
|
+
if (tree.id === nodeId) return next;
|
|
42
|
+
if (tree.type !== "split") return tree;
|
|
43
|
+
const first = replaceMoaNode(tree.first, nodeId, next), second = replaceMoaNode(tree.second, nodeId, next);
|
|
44
|
+
if (!first) return second;
|
|
45
|
+
if (!second) return first;
|
|
46
|
+
return first === tree.first && second === tree.second ? tree : { ...tree, first, second };
|
|
47
|
+
}
|
package/ui/core/src/reducer.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeMoa } from "./moa.js";
|
|
2
|
+
import { retainSessionWarnings } from "./session-errors.js";
|
|
1
3
|
import { buildSessionTree, isManuallyOrderableSession } from "./session-tree.js";
|
|
2
4
|
import { FOCUS_REGIONS } from "./commands.js";
|
|
3
5
|
import { DEFAULT_HISTORY_EVENT_LIMIT, dedupeChatMessages } from "./history.js";
|
|
@@ -317,62 +319,66 @@ function isSameOrOlderSessionUpdate(previousSession, nextSession) {
|
|
|
317
319
|
return previousAt > 0 && nextAt > 0 && nextAt <= previousAt;
|
|
318
320
|
}
|
|
319
321
|
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
// "running" is far worse than a brief wrong status.
|
|
322
|
+
// Completion can come from orchestration metadata without another custom
|
|
323
|
+
// status write. Allow it at the same version, but never at an older version.
|
|
323
324
|
const TERMINAL_SESSION_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
|
|
324
325
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
//
|
|
343
|
-
// "Genuinely newer" is decided by the server's monotonic statusVersion when
|
|
344
|
-
// both sides carry one — wall-clock updatedAt is only the fallback. The
|
|
345
|
-
// timestamp heuristic alone can WEDGE a finished turn as "running" forever:
|
|
346
|
-
// client-side event merges (turn_completed, context usage) inflate the held
|
|
347
|
-
// session's updatedAt past the server row's idle-write timestamp, so every
|
|
348
|
-
// subsequent idle poll compares as stale and only a terminal status could
|
|
349
|
-
// ever land. Observed live (2026-07-21, local portal): server row idle at
|
|
350
|
-
// statusVersion 5 while the client showed "Working.." indefinitely.
|
|
351
|
-
function shouldPreserveRunningStatus(previousSession, nextSession) {
|
|
352
|
-
const nextStatus = String(nextSession?.status ?? "").toLowerCase();
|
|
353
|
-
if (previousSession?.status !== "running"
|
|
354
|
-
|| nextStatus === "running"
|
|
355
|
-
|| TERMINAL_SESSION_STATUSES.has(nextStatus)) {
|
|
356
|
-
return false;
|
|
357
|
-
}
|
|
358
|
-
const previousVersion = Number(previousSession?.statusVersion);
|
|
359
|
-
const nextVersion = Number(nextSession?.statusVersion);
|
|
360
|
-
if (Number.isFinite(previousVersion) && previousVersion > 0
|
|
361
|
-
&& Number.isFinite(nextVersion) && nextVersion > 0) {
|
|
362
|
-
// Monotonic server counter: a higher version is authoritative and
|
|
363
|
-
// must land; a same-or-lower version is provably stale and is held
|
|
364
|
-
// regardless of what the timestamps claim.
|
|
365
|
-
return nextVersion <= previousVersion;
|
|
326
|
+
function sessionStatusVersion(session) {
|
|
327
|
+
const value = session?.statusVersion;
|
|
328
|
+
if (value == null || value === "" || typeof value === "boolean") return null;
|
|
329
|
+
const version = Number(value);
|
|
330
|
+
return Number.isSafeInteger(version) && version > 0 ? version : null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// List polls and detail requests can arrive out of order. Compare the server
|
|
334
|
+
// counter in both directions: an old running row must not revive an idle
|
|
335
|
+
// session, and an old idle row must not hide a running one. Keep the version
|
|
336
|
+
// with its status; accepting an older counter would let the next stale row win.
|
|
337
|
+
function shouldPreserveSessionStatus(previousSession, nextSession) {
|
|
338
|
+
const previousVersion = sessionStatusVersion(previousSession);
|
|
339
|
+
const nextVersion = sessionStatusVersion(nextSession);
|
|
340
|
+
if (previousVersion != null && nextVersion != null) {
|
|
341
|
+
if (nextVersion < previousVersion) return true;
|
|
342
|
+
if (nextVersion > previousVersion) return false;
|
|
366
343
|
}
|
|
367
|
-
|
|
344
|
+
const previousStatus = previousSession?.status;
|
|
345
|
+
const nextStatus = nextSession?.status;
|
|
346
|
+
if (!previousStatus || !nextStatus || previousStatus === nextStatus
|
|
347
|
+
|| TERMINAL_SESSION_STATUSES.has(nextStatus)) return false;
|
|
348
|
+
if (previousVersion != null && nextVersion != null) return true;
|
|
349
|
+
// Legacy rows and live event patches may lack a counter. Do not guess
|
|
350
|
+
// their order without timestamps. Preserve the terminal-status escape
|
|
351
|
+
// hatch, since updatedAt also changes for non-status events.
|
|
352
|
+
if (previousStatus === "running") return isSameOrOlderSessionUpdate(previousSession, nextSession);
|
|
353
|
+
// An unversioned new turn may share its timestamp with the preceding idle
|
|
354
|
+
// row. Equal timestamps alone are not evidence that a start is stale.
|
|
355
|
+
const previousAt = sessionUpdateTimestampMs(previousSession);
|
|
356
|
+
const nextAt = sessionUpdateTimestampMs(nextSession);
|
|
357
|
+
return nextStatus === "running" && previousAt > 0 && nextAt > 0 && nextAt < previousAt;
|
|
368
358
|
}
|
|
369
359
|
|
|
360
|
+
// These values describe the same run as status. Mixing an old question or
|
|
361
|
+
// pause reason with a newer status can change composer routing and controls.
|
|
362
|
+
const SESSION_STATUS_FIELDS = new Set([
|
|
363
|
+
"status", "statusVersion", "updatedAt", "orchestrationStatus",
|
|
364
|
+
"pendingQuestion", "waitReason", "pauseState", "error", "result",
|
|
365
|
+
]);
|
|
366
|
+
|
|
370
367
|
function mergeDefinedSessionFields(previousSession = {}, nextSession = {}) {
|
|
371
368
|
let merged = previousSession || {};
|
|
372
|
-
const
|
|
369
|
+
const preserveStatus = shouldPreserveSessionStatus(previousSession, nextSession);
|
|
370
|
+
const previousVersion = sessionStatusVersion(previousSession);
|
|
373
371
|
for (const [key, value] of Object.entries(nextSession || {})) {
|
|
374
372
|
if (value === undefined) continue;
|
|
375
|
-
if (
|
|
373
|
+
if (preserveStatus && SESSION_STATUS_FIELDS.has(key)) continue;
|
|
374
|
+
// Repeated snapshots of one server version cannot roll its timestamp
|
|
375
|
+
// back and then admit an older unversioned running update.
|
|
376
|
+
if (key === "updatedAt" && previousVersion != null
|
|
377
|
+
&& previousVersion === sessionStatusVersion(nextSession)
|
|
378
|
+
&& sessionUpdateTimestampMs(nextSession) < sessionUpdateTimestampMs(previousSession)) continue;
|
|
379
|
+
// An absent/invalid counter must not erase the last known server version.
|
|
380
|
+
if (key === "statusVersion" && previousVersion != null
|
|
381
|
+
&& sessionStatusVersion(nextSession) == null) continue;
|
|
376
382
|
if (key === "pendingQuestion" && isAnsweredPendingQuestion(previousSession, value)) {
|
|
377
383
|
if (merged === previousSession) {
|
|
378
384
|
merged = { ...(previousSession || {}) };
|
|
@@ -995,6 +1001,10 @@ function baseReducer(state, action) {
|
|
|
995
1001
|
if (Boolean(action.collapsed) === Boolean(state.ui.sessionDetailCollapsed)) return state;
|
|
996
1002
|
return { ...state, ui: { ...state.ui, sessionDetailCollapsed: Boolean(action.collapsed) } };
|
|
997
1003
|
|
|
1004
|
+
case "ui/moaSaveStatus":
|
|
1005
|
+
return { ...state, ui: { ...state.ui, moaSaveStatus: action.status, moaDirty: action.status === "saved" && state.ui.moaRevision === action.revision ? false : state.ui.moaDirty } };
|
|
1006
|
+
case "ui/moa":
|
|
1007
|
+
return { ...state, ui: { ...state.ui, moa: normalizeMoa(action.value), moaDirty: true, moaRevision: (state.ui.moaRevision || 0) + 1 } };
|
|
998
1008
|
case "profileSettings/apply": {
|
|
999
1009
|
const settings = action.settings && typeof action.settings === "object" && !Array.isArray(action.settings)
|
|
1000
1010
|
? action.settings
|
|
@@ -1098,6 +1108,8 @@ function baseReducer(state, action) {
|
|
|
1098
1108
|
sessions: selection.sessions,
|
|
1099
1109
|
ui: {
|
|
1100
1110
|
...selection.ui,
|
|
1111
|
+
moa: !state.ui.moaDirty && Object.prototype.hasOwnProperty.call(settings, "moa") ? normalizeMoa(settings.moa) : selection.ui.moa,
|
|
1112
|
+
moaLoaded: true,
|
|
1101
1113
|
themeId: hasTheme ? settings.themeId.trim() : selection.ui.themeId,
|
|
1102
1114
|
touchScale: hasTouchScale ? settings.touchScale : selection.ui.touchScale,
|
|
1103
1115
|
sessionDetailCollapsed: hasSessionDetailCollapsed
|
|
@@ -1624,7 +1636,8 @@ function baseReducer(state, action) {
|
|
|
1624
1636
|
}
|
|
1625
1637
|
for (const session of action.sessions) {
|
|
1626
1638
|
const previous = state.sessions.byId[session.sessionId];
|
|
1627
|
-
byId[session.sessionId] = mergeDefinedSessionFields(previous, session)
|
|
1639
|
+
byId[session.sessionId] = retainSessionWarnings(previous, mergeDefinedSessionFields(previous, session),
|
|
1640
|
+
state.history.bySessionId.get(session.sessionId)?.events, nowMs);
|
|
1628
1641
|
}
|
|
1629
1642
|
if (
|
|
1630
1643
|
state.sessions.activeSessionId
|
|
@@ -1807,7 +1820,8 @@ function baseReducer(state, action) {
|
|
|
1807
1820
|
case "sessions/merged": {
|
|
1808
1821
|
if (!action.session?.sessionId) return state;
|
|
1809
1822
|
const previousSession = state.sessions.byId[action.session.sessionId];
|
|
1810
|
-
const mergedSession = mergeDefinedSessionFields(previousSession, action.session)
|
|
1823
|
+
const mergedSession = retainSessionWarnings(previousSession, mergeDefinedSessionFields(previousSession, action.session),
|
|
1824
|
+
state.history.bySessionId.get(action.session.sessionId)?.events);
|
|
1811
1825
|
// A single session going running/idle changes its ANCESTORS' counts
|
|
1812
1826
|
// and therefore their visual status, so both passes run over the
|
|
1813
1827
|
// whole map rather than the one row. buildSessionTree below is
|
|
@@ -1939,6 +1953,13 @@ function baseReducer(state, action) {
|
|
|
1939
1953
|
};
|
|
1940
1954
|
}
|
|
1941
1955
|
|
|
1956
|
+
case "ui/revealCreatedSession":
|
|
1957
|
+
return {
|
|
1958
|
+
...state,
|
|
1959
|
+
files: { ...state.files, fullscreen: false, paneOpen: false },
|
|
1960
|
+
ui: { ...state.ui, revealedCreatedSessionId: action.sessionId, fullscreenPane: null },
|
|
1961
|
+
};
|
|
1962
|
+
|
|
1942
1963
|
case "ui/fullscreenPane": {
|
|
1943
1964
|
const fullscreenPane = normalizeFullscreenPane(action.fullscreenPane);
|
|
1944
1965
|
return {
|
package/ui/core/src/selectors.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
getContextHeaderBadge,
|
|
29
29
|
} from "./context-usage.js";
|
|
30
30
|
import { canonicalSystemTitle } from "./system-titles.js";
|
|
31
|
+
import { withSessionWarnings } from "./session-errors.js";
|
|
31
32
|
import {
|
|
32
33
|
BUDGET_PERIODS,
|
|
33
34
|
BUDGET_SERIES_DAYS,
|
|
@@ -1894,13 +1895,15 @@ export function selectActiveChat(state) {
|
|
|
1894
1895
|
? buildPendingQuestionMessage(session, events)
|
|
1895
1896
|
: null;
|
|
1896
1897
|
const answeredQuestionMessage = buildAnsweredPendingQuestionMessage(session, chat);
|
|
1897
|
-
|
|
1898
|
+
// Legacy/synthetic selector callers may not have passed through the
|
|
1899
|
+
// reducer that captures status-only notices. Keep their fallback too.
|
|
1900
|
+
const sessionErrorMessage = session?.chatWarnings?.length ? null : buildSessionErrorMessage(session, events);
|
|
1898
1901
|
|
|
1899
|
-
if ((!history || chat.length === 0) && !pendingQuestionMessage && !answeredQuestionMessage && !sessionErrorMessage) {
|
|
1902
|
+
if ((!history || chat.length === 0) && !pendingQuestionMessage && !answeredQuestionMessage && !sessionErrorMessage && !session?.chatWarnings?.length) {
|
|
1900
1903
|
return createSplashCard(state.branding, session);
|
|
1901
1904
|
}
|
|
1902
1905
|
|
|
1903
|
-
const messages = chat.length > 0 ?
|
|
1906
|
+
const messages = withSessionWarnings(chat.length > 0 ? chat : createSplashCard(state.branding, session), session, events);
|
|
1904
1907
|
if (pendingQuestionMessage) {
|
|
1905
1908
|
messages.push(pendingQuestionMessage);
|
|
1906
1909
|
}
|
|
@@ -1908,7 +1911,8 @@ export function selectActiveChat(state) {
|
|
|
1908
1911
|
messages.push(answeredQuestionMessage);
|
|
1909
1912
|
}
|
|
1910
1913
|
if (sessionErrorMessage) {
|
|
1911
|
-
messages.
|
|
1914
|
+
const index = messages.findIndex(message => sessionErrorMessage.createdAt != null && message.createdAt > sessionErrorMessage.createdAt);
|
|
1915
|
+
messages.splice(index < 0 ? messages.length : index, 0, sessionErrorMessage);
|
|
1912
1916
|
}
|
|
1913
1917
|
return messages;
|
|
1914
1918
|
}
|
|
@@ -2520,19 +2524,23 @@ function buildChatMessageLinesUncached(message, maxWidth, options = {}) {
|
|
|
2520
2524
|
return buildThinkingCardLines(message, maxWidth);
|
|
2521
2525
|
}
|
|
2522
2526
|
|
|
2523
|
-
//
|
|
2524
|
-
//
|
|
2525
|
-
//
|
|
2526
|
-
//
|
|
2527
|
-
//
|
|
2528
|
-
// place while only the body grows. The TUI deliberately keeps its existing
|
|
2529
|
-
// line-oriented rendering.
|
|
2527
|
+
// One stable disclosure spans live output, saved updates and the final
|
|
2528
|
+
// answer. Its identity is not evidence of streaming: durable history uses
|
|
2529
|
+
// this shell too, including turns created with PILOTSWARM_LIVE_TURN=0.
|
|
2530
|
+
// Only actual transient content may use preview labels/status. The native
|
|
2531
|
+
// TUI keeps its line-oriented rendering of these same durable messages.
|
|
2530
2532
|
if ((message?.liveTurn || message?.streamSettling || message?.assistantPreview) && options.tableMode === "sentinel") {
|
|
2533
|
+
// streamSettling also preserves DOM through durable arrival; it does
|
|
2534
|
+
// not mean those saved bytes are still provisional.
|
|
2535
|
+
const provisional = !message?.assistantPreview && Boolean(message?.liveTurn || message?.streamSettling);
|
|
2531
2536
|
// Carry raw markdown: collapsed previews never parse their hidden body.
|
|
2532
2537
|
return [{
|
|
2533
2538
|
kind: "assistantPreview",
|
|
2534
2539
|
width: maxWidth,
|
|
2535
|
-
text: "Message preview",
|
|
2540
|
+
text: provisional ? "Message preview" : "Agent update",
|
|
2541
|
+
statusText: !provisional ? "" : message?.liveTurn
|
|
2542
|
+
? (message?.text ? "Responding" : "Thinking")
|
|
2543
|
+
: message?.streamSettling ? "Finishing" : "",
|
|
2536
2544
|
previewKey: `assistant:${message?.liveKey || message?.messageId || message?.id || "active-turn"}`,
|
|
2537
2545
|
body: decorateArtifactLinksForChat(message?.text || ""),
|
|
2538
2546
|
reasoningText: String(message?.reasoningText || message?.liveReasoningText || ""),
|
|
@@ -2971,11 +2979,16 @@ let chatLinesMemo = [];
|
|
|
2971
2979
|
|
|
2972
2980
|
export function selectChatLines(state, maxWidth = 80, options = {}) {
|
|
2973
2981
|
const memoSessionId = state?.sessions?.activeSessionId ?? null;
|
|
2982
|
+
const memoSession = memoSessionId ? state?.sessions?.byId?.[memoSessionId] ?? null : null;
|
|
2974
2983
|
const memoKey = {
|
|
2975
2984
|
sessionId: memoSessionId,
|
|
2976
|
-
session:
|
|
2985
|
+
session: memoSession,
|
|
2977
2986
|
byId: state?.sessions?.byId ?? null,
|
|
2978
2987
|
chat: state?.history?.bySessionId?.get?.(memoSessionId)?.chat ?? null,
|
|
2988
|
+
// Raw tool activity affects warning/question reconciliation, but must
|
|
2989
|
+
// not re-wrap an ordinary, unchanged transcript on every event.
|
|
2990
|
+
events: memoSession?.chatWarnings?.length || memoSession?.error || memoSession?.pendingQuestion
|
|
2991
|
+
? state?.history?.bySessionId?.get?.(memoSessionId)?.events ?? null : null,
|
|
2979
2992
|
branding: state?.branding ?? null,
|
|
2980
2993
|
principal: state?.auth?.principal ?? null,
|
|
2981
2994
|
maxWidth,
|
|
@@ -2986,6 +2999,7 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
|
|
|
2986
2999
|
&& entry.session === memoKey.session
|
|
2987
3000
|
&& entry.byId === memoKey.byId
|
|
2988
3001
|
&& entry.chat === memoKey.chat
|
|
3002
|
+
&& entry.events === memoKey.events
|
|
2989
3003
|
&& entry.viewMode === memoKey.viewMode
|
|
2990
3004
|
&& entry.branding === memoKey.branding
|
|
2991
3005
|
&& entry.principal === memoKey.principal
|
|
@@ -17,3 +17,100 @@ export function shouldKeepSessionWarning(previousSession, nextSession) {
|
|
|
17
17
|
const nextAt = time(nextSession?.updatedAt);
|
|
18
18
|
return previousAt > 0 && nextAt > 0 && nextAt <= previousAt;
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
const timeMs = value => value instanceof Date ? value.getTime()
|
|
22
|
+
: typeof value === "number" ? value : Date.parse(value || "");
|
|
23
|
+
const errorText = event => typeof event?.data === "string" ? event.data
|
|
24
|
+
: String(event?.data?.message || event?.data?.error || event?.data?.text || "").trim();
|
|
25
|
+
const sameError = (statusText, eventText) => Boolean(eventText) && statusText.includes(eventText);
|
|
26
|
+
|
|
27
|
+
export function buildSessionWarning(event) {
|
|
28
|
+
const text = errorText(event);
|
|
29
|
+
if (!text) return null;
|
|
30
|
+
const failed = event?.data?.fatal === true;
|
|
31
|
+
return {
|
|
32
|
+
id: `${event.sessionId}:${event.seq}:warning`,
|
|
33
|
+
kind: "session-warning", role: "system", text,
|
|
34
|
+
warningSeq: Number(event.seq),
|
|
35
|
+
createdAt: timeMs(event.createdAt) || null,
|
|
36
|
+
cardTitle: failed ? "Error" : "Warning",
|
|
37
|
+
cardTitleColor: failed ? "red" : "yellow",
|
|
38
|
+
cardBorderColor: failed ? "red" : "yellow",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Status-only failures have no chat event yet (some never get one). Capture
|
|
43
|
+
// their position ONCE in shared state, not on each render/poll. Recovery ends
|
|
44
|
+
// the active episode but does not erase its notice. Durable error events are
|
|
45
|
+
// rendered separately and survive reload/history paging.
|
|
46
|
+
export function retainSessionWarnings(previous, next, events = [], now = Date.now()) {
|
|
47
|
+
const warnings = previous?.chatWarnings || [];
|
|
48
|
+
const last = warnings.at(-1);
|
|
49
|
+
const text = String(next?.error || "").trim();
|
|
50
|
+
const failed = next?.status === "failed" || next?.orchestrationStatus === "Failed";
|
|
51
|
+
const active = Boolean(text) && (failed || !["completed", "cancelled", "terminated", "input_required"].includes(next?.status))
|
|
52
|
+
&& !["Completed", "Terminated"].includes(next?.orchestrationStatus);
|
|
53
|
+
if (!active) {
|
|
54
|
+
if (!last?.active) return next;
|
|
55
|
+
return { ...next, chatWarnings: [...warnings.slice(0, -1), { ...last, active: false }] };
|
|
56
|
+
}
|
|
57
|
+
const since = last?.createdAt || 0;
|
|
58
|
+
const event = events.findLast(e => e.eventType === "session.error"
|
|
59
|
+
&& timeMs(e.createdAt) > since && sameError(text, errorText(e)));
|
|
60
|
+
// A retained error field can outlive a terminal/wait state. A later
|
|
61
|
+
// running snapshot alone is not evidence of another failure episode.
|
|
62
|
+
if (last && !last.active && text === last.text && text === String(previous?.error || "").trim() && !event) return next;
|
|
63
|
+
const movedOn = last && text !== last.text && events.some(e =>
|
|
64
|
+
Number(e.seq) > last.afterSeq && timeMs(e.createdAt) > last.createdAt
|
|
65
|
+
&& ["user.message", "assistant.message"].includes(e.eventType));
|
|
66
|
+
if (last?.active && !movedOn) {
|
|
67
|
+
if (last.text === text && last.failed === failed) return next;
|
|
68
|
+
return { ...next, chatWarnings: [...warnings.slice(0, -1), { ...last, text, failed }] };
|
|
69
|
+
}
|
|
70
|
+
const ordinal = (last?.ordinal || 0) + 1;
|
|
71
|
+
const previousWarnings = last?.active ? [...warnings.slice(0, -1), { ...last, active: false }] : warnings;
|
|
72
|
+
return { ...next, chatWarnings: [...previousWarnings, {
|
|
73
|
+
id: `session-error:${next.sessionId}:${ordinal}`, ordinal, text, failed, active: true,
|
|
74
|
+
eventSeq: event ? Number(event.seq) : null,
|
|
75
|
+
afterSeq: Number(events.at(-1)?.seq) || 0,
|
|
76
|
+
since,
|
|
77
|
+
createdAt: timeMs(event?.createdAt) || timeMs(next.updatedAt) || now,
|
|
78
|
+
}].slice(-300) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function withSessionWarnings(chat, session, events = []) {
|
|
82
|
+
const messages = [...chat];
|
|
83
|
+
for (const warning of session?.chatWarnings || []) {
|
|
84
|
+
// Status often wins the race against its durable event. Match only
|
|
85
|
+
// that boundary, never a later failure after a new user/agent message.
|
|
86
|
+
const following = events.filter(e => Number(e.seq) > warning.afterSeq);
|
|
87
|
+
const boundary = following.find(e => ["session.error", "user.message", "assistant.message"].includes(e.eventType));
|
|
88
|
+
// On initial load the catalog can arrive before any history at all.
|
|
89
|
+
// Find its matching older event by the captured failure time, without
|
|
90
|
+
// reusing an event from a previous error episode with identical text.
|
|
91
|
+
// Chat can outlive the raw-event window when tool activity is noisy.
|
|
92
|
+
// Reconcile from the retained warning rows, not only from raw events.
|
|
93
|
+
const recorded = chat.findLast(m => m.kind === "session-warning"
|
|
94
|
+
&& timeMs(m.createdAt) > (warning.since || 0)
|
|
95
|
+
&& timeMs(m.createdAt) <= warning.createdAt
|
|
96
|
+
&& sameError(warning.text, m.text));
|
|
97
|
+
const eventSeq = warning.eventSeq ?? recorded?.warningSeq
|
|
98
|
+
?? (boundary?.eventType === "session.error" && sameError(warning.text, errorText(boundary)) ? Number(boundary.seq) : null);
|
|
99
|
+
const index = messages.findIndex(m => m.kind === "session-warning" && m.warningSeq === eventSeq);
|
|
100
|
+
const notice = {
|
|
101
|
+
id: warning.id, kind: "session-warning", role: "system", text: warning.text,
|
|
102
|
+
createdAt: warning.createdAt,
|
|
103
|
+
cardTitle: warning.failed ? "Error" : "Warning",
|
|
104
|
+
cardTitleColor: warning.failed ? "red" : "yellow",
|
|
105
|
+
cardBorderColor: warning.failed ? "red" : "yellow",
|
|
106
|
+
};
|
|
107
|
+
if (index >= 0) {
|
|
108
|
+
// Preserve the mounted card while the durable row takes over.
|
|
109
|
+
messages[index] = { ...messages[index], ...notice, createdAt: messages[index].createdAt };
|
|
110
|
+
} else {
|
|
111
|
+
const nextIndex = messages.findIndex(m => timeMs(m.createdAt) > warning.createdAt);
|
|
112
|
+
messages.splice(nextIndex < 0 ? messages.length : nextIndex, 0, notice);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return messages;
|
|
116
|
+
}
|