pilotswarm 0.5.59 → 0.5.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +6 -0
  2. package/package.json +2 -2
  3. package/ui/core/src/controller.js +39 -13
  4. package/ui/core/src/history.js +15 -1
  5. package/ui/core/src/index.js +2 -0
  6. package/ui/core/src/moa.js +47 -0
  7. package/ui/core/src/reducer.js +19 -2
  8. package/ui/core/src/selectors.js +27 -13
  9. package/ui/core/src/session-errors.js +97 -0
  10. package/ui/core/src/state.js +1 -0
  11. package/ui/react/src/web-app.js +215 -77
  12. package/web/dist/assets/{abnfDiagram-VRR7QNED-D9Rv2VpV.js → abnfDiagram-VRR7QNED-CXEHJkTQ.js} +1 -1
  13. package/web/dist/assets/{arc-BATiucnq.js → arc-lInHlEVm.js} +1 -1
  14. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CSTMt0si.js → architectureDiagram-ZJ3FMSHR-vrocx6re.js} +1 -1
  15. package/web/dist/assets/{blockDiagram-677ZJIJ3-C_RGqfeY.js → blockDiagram-677ZJIJ3-B1KA5QIB.js} +1 -1
  16. package/web/dist/assets/{c4Diagram-LMCZKHZV-DH__s__0.js → c4Diagram-LMCZKHZV-Du9xJMjz.js} +1 -1
  17. package/web/dist/assets/channel-6wPrR6sC.js +1 -0
  18. package/web/dist/assets/{chunk-2Q5K7J3B-C197fD3B.js → chunk-2Q5K7J3B-BuHucQOQ.js} +1 -1
  19. package/web/dist/assets/{chunk-32BRIVSS-BjpuBroU.js → chunk-32BRIVSS-T-AE5Yz0.js} +1 -1
  20. package/web/dist/assets/{chunk-5VM5RSS4-W_jL8euj.js → chunk-5VM5RSS4-B_kTsNzt.js} +1 -1
  21. package/web/dist/assets/{chunk-EX3LRPZG-a9h0dtlp.js → chunk-EX3LRPZG-CtqgceBQ.js} +1 -1
  22. package/web/dist/assets/{chunk-JWPE2WC7-CQN0qE7F.js → chunk-JWPE2WC7-C_Fv9QUd.js} +1 -1
  23. package/web/dist/assets/{chunk-MOJQB5TN-D-Y6KQ7t.js → chunk-MOJQB5TN-D5GeF8ZZ.js} +1 -1
  24. package/web/dist/assets/{chunk-RYQCIY6F-jPwseC5f.js → chunk-RYQCIY6F-Cg63wDst.js} +1 -1
  25. package/web/dist/assets/{chunk-V7JOEXUC-DRNCEcQh.js → chunk-V7JOEXUC-Bz6G3lev.js} +1 -1
  26. package/web/dist/assets/{chunk-VR4S4FIN-DzhUcoZ7.js → chunk-VR4S4FIN-DKs_hvUD.js} +1 -1
  27. package/web/dist/assets/{chunk-XXDRQBXY-ldJfyPXK.js → chunk-XXDRQBXY-B9AtUlbn.js} +1 -1
  28. package/web/dist/assets/classDiagram-OUVF2IWQ-vvXyIfbC.js +1 -0
  29. package/web/dist/assets/classDiagram-v2-EOCWNBFH-vvXyIfbC.js +1 -0
  30. package/web/dist/assets/{cose-bilkent-JH36ORCC-BBVo21wR.js → cose-bilkent-JH36ORCC-rAG6oS0i.js} +1 -1
  31. package/web/dist/assets/{cynefin-VYW2F7L2-CgRQfdW1.js → cynefin-VYW2F7L2-CUy2C8UT.js} +1 -1
  32. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Ckz2X0t3.js → cynefinDiagram-TSTJHNR4-C1W8blXH.js} +1 -1
  33. package/web/dist/assets/{dagre-VKFMJZFB-C1SanJiY.js → dagre-VKFMJZFB-DC0XqwcC.js} +1 -1
  34. package/web/dist/assets/{diagram-FQU43EPY-dBdEifVy.js → diagram-FQU43EPY-COAY9zCP.js} +1 -1
  35. package/web/dist/assets/{diagram-G47NLZAW-Byqc1VCB.js → diagram-G47NLZAW-DLPr8eWG.js} +1 -1
  36. package/web/dist/assets/{diagram-NH7WQ7WH-9aeTaCsX.js → diagram-NH7WQ7WH-DoNgwoDz.js} +1 -1
  37. package/web/dist/assets/{diagram-OA4YK3LP-CL0a0o0k.js → diagram-OA4YK3LP-D3-5TINE.js} +1 -1
  38. package/web/dist/assets/{diagram-WEI45ONY-YlMQOJfY.js → diagram-WEI45ONY-DplCkBJC.js} +1 -1
  39. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-qMZQEnws.js → ebnfDiagram-CCIWWBDH-dvcVh7yr.js} +1 -1
  40. package/web/dist/assets/{erDiagram-Q63AITRT-C3CWBCtq.js → erDiagram-Q63AITRT-Cde8cTY6.js} +1 -1
  41. package/web/dist/assets/{flowDiagram-23GEKE2U-BN79sybL.js → flowDiagram-23GEKE2U-MGMNsh2E.js} +1 -1
  42. package/web/dist/assets/{ganttDiagram-NO4QXBWP-BOWhiAgB.js → ganttDiagram-NO4QXBWP-BiKw7AfS.js} +1 -1
  43. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-CKX9OACp.js → gitGraphDiagram-IHSO6WYX-vC7fsylq.js} +1 -1
  44. package/web/dist/assets/index-D9Ab1VTY.css +1 -0
  45. package/web/dist/assets/index-DDAyy127.js +24 -0
  46. package/web/dist/assets/{infoDiagram-FWYZ7A6U-QQEunPNs.js → infoDiagram-FWYZ7A6U-Du1uirB0.js} +1 -1
  47. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CvMwrnLQ.js → ishikawaDiagram-FXEZZL3T-Dk--Qshr.js} +1 -1
  48. package/web/dist/assets/{journeyDiagram-5HDEW3XC-giNpEVpj.js → journeyDiagram-5HDEW3XC-iDE4zf_N.js} +1 -1
  49. package/web/dist/assets/{kanban-definition-HUTT4EX6-CUPPpo27.js → kanban-definition-HUTT4EX6-D7WI8iRR.js} +1 -1
  50. package/web/dist/assets/{linear-AXqadfOR.js → linear-B1_kxCBK.js} +1 -1
  51. package/web/dist/assets/{mermaid.core-C78wdk8Y.js → mermaid.core-DNmC5O4Y.js} +4 -4
  52. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BGUmh9bK.js → mindmap-definition-LN4V7U3C-DIbLKLMA.js} +1 -1
  53. package/web/dist/assets/{papaparse.min-Bh8RLCk_.js → papaparse.min-BdWinjY-.js} +1 -1
  54. package/web/dist/assets/{pegDiagram-2B236MQR-BHsKXKhK.js → pegDiagram-2B236MQR-CkxxR07G.js} +1 -1
  55. package/web/dist/assets/{pieDiagram-ENE6RG2P-D3EvWTdH.js → pieDiagram-ENE6RG2P-Dxvp1mn7.js} +1 -1
  56. package/web/dist/assets/pilotswarm-493nqGmy.js +98 -0
  57. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CbkxfLkV.js → quadrantDiagram-ABIIQ3AL-CrL0hjZW.js} +1 -1
  58. package/web/dist/assets/{railroadDiagram-RFXS5EU6-H4M6JSDW.js → railroadDiagram-RFXS5EU6-CmVmFBPa.js} +1 -1
  59. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BJo7JW1v.js → requirementDiagram-TGXJPOKE-DJc3NRXb.js} +1 -1
  60. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-DL19MeOP.js → sankeyDiagram-HTMAVEWB-BJNqWqBb.js} +1 -1
  61. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DyVFc5pQ.js → sequenceDiagram-DBY2YBRQ-C0wddECV.js} +1 -1
  62. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CKbcKIEQ.js → sizeCapture-X5ZJPWSS-Cw0dqPWS.js} +1 -1
  63. package/web/dist/assets/{stateDiagram-2N3HPSRC-BSukSXzS.js → stateDiagram-2N3HPSRC-C5793RHZ.js} +1 -1
  64. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-qGaRayHj.js +1 -0
  65. package/web/dist/assets/{swimlanes-5IMT3BWC-BUd7UhsF.js → swimlanes-5IMT3BWC-Bhpm77Zu.js} +2 -2
  66. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BXjNM_Zw.js +8 -0
  67. package/web/dist/assets/{timeline-definition-FHXFAJF6-Dt1gludn.js → timeline-definition-FHXFAJF6-CwbE4NsH.js} +1 -1
  68. package/web/dist/assets/{vennDiagram-L72KCM5P-BHxt00GV.js → vennDiagram-L72KCM5P-DZnJEL_w.js} +1 -1
  69. package/web/dist/assets/{wardleyDiagram-EHGQE667-BT3fIDbe.js → wardleyDiagram-EHGQE667-DwTgBDev.js} +1 -1
  70. package/web/dist/assets/{xychartDiagram-FW5EYKEG-ChoYDS2b.js → xychartDiagram-FW5EYKEG-BiPjN3Qc.js} +1 -1
  71. package/web/dist/index.html +3 -3
  72. package/web/dist/assets/channel-CqgY9oZh.js +0 -1
  73. package/web/dist/assets/classDiagram-OUVF2IWQ-Uz4DYZs2.js +0 -1
  74. package/web/dist/assets/classDiagram-v2-EOCWNBFH-Uz4DYZs2.js +0 -1
  75. package/web/dist/assets/index-B4s2lTsK.js +0 -24
  76. package/web/dist/assets/index-DhGdE2Nd.css +0 -1
  77. package/web/dist/assets/pilotswarm-UV6T1_QI.js +0 -98
  78. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BhKG3pJB.js +0 -1
  79. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CgoFkXJE.js +0 -8
package/README.md CHANGED
@@ -260,3 +260,9 @@ 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 desktop portal includes a personal multi-session workspace with resizable
267
+ chat/canvas panels, one focused composer, and zen mode. Layouts save automatically.
268
+ 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.59",
3
+ "version": "0.5.60",
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.59",
84
+ "pilotswarm-sdk": "0.5.60",
85
85
  "react": "^19.2.4",
86
86
  "react-dom": "^19.2.4",
87
87
  "ws": "^8.18.2"
@@ -3255,6 +3255,8 @@ export class PilotSwarmUiController {
3255
3255
  */
3256
3256
  revealCreatedSession() {
3257
3257
  if (this.getState().admin?.visible) this.closeAdminConsole();
3258
+ this.closeBudget();
3259
+ this.dispatch({ type: "ui/revealCreatedSession", sessionId: this.getState().sessions.activeSessionId });
3258
3260
  this.setFocus(FOCUS_REGIONS.PROMPT);
3259
3261
  }
3260
3262
 
@@ -5454,7 +5456,9 @@ export class PilotSwarmUiController {
5454
5456
  return;
5455
5457
  }
5456
5458
  await this.ensureSessionHistory(sessionId, { force: true });
5459
+ if (this.getState().sessions.activeSessionId !== sessionId) return;
5457
5460
  await this.syncSessionDetail(sessionId).catch(() => {});
5461
+ if (this.getState().sessions.activeSessionId !== sessionId) return;
5458
5462
  this.attachActiveSession(sessionId);
5459
5463
  this.ensureInspectorData().catch(() => {});
5460
5464
  // Canvas snapshot rides the selection burst. Invalidate-then-fetch on
@@ -5590,8 +5594,9 @@ export class PilotSwarmUiController {
5590
5594
  * history pipeline recognizes the canonical prefix and keeps it out of
5591
5595
  * the portal chat pane; the transcript still records it for provenance.
5592
5596
  */
5593
- submitCanvasAction(sessionId, message) {
5597
+ submitCanvasAction(sessionId, message, slot = 1) {
5594
5598
  if (!sessionId) return Promise.resolve({ ok: false, reason: "no session" });
5599
+ if (!Number.isInteger(slot) || slot < 1 || slot > 5) return Promise.resolve({ ok: false, reason: "invalid canvas slot" });
5595
5600
  // Creator-only, mirrored from the server's enforcement (the server is
5596
5601
  // the authority; this is the friendly refusal). The canvas mutates —
5597
5602
  // a shared viewer may be looking at a different revision than the one
@@ -5601,7 +5606,7 @@ export class PilotSwarmUiController {
5601
5606
  // longer pre-refuses non-owners: a write-shared collaborator's click
5602
5607
  // must reach the agent, and the server's refusal for a read-only
5603
5608
  // viewer comes back as the result's reason.
5604
- const contract = this.getState().canvas.bySessionId[sessionId]?.responseContract || null;
5609
+ const contract = this.getState().canvas.bySessionId[canvasPrefKey(sessionId, slot)]?.responseContract || null;
5605
5610
  const verdict = validateCanvasAction(contract, message);
5606
5611
  if (!verdict.ok) return Promise.resolve(verdict);
5607
5612
  if (!this.canvasActionLimiters) this.canvasActionLimiters = new Map();
@@ -6011,15 +6016,42 @@ export class PilotSwarmUiController {
6011
6016
  this.maybeFlushQueuedOutbox(sessionId, session);
6012
6017
  }
6013
6018
 
6019
+ async openCreatedSession(created, options = {}) {
6020
+ const sessionId = created.sessionId;
6021
+ // Creation is explicit navigation, even if a paged/stale catalog or
6022
+ // the current filters omit the new row. Seed the response, preserve
6023
+ // the outgoing draft, and latch the same visibility rules as a link.
6024
+ // Invalidate catalog reads that started before this row existed.
6025
+ this.sessionRefreshSeq = (this.sessionRefreshSeq || 0) + 1;
6026
+ if (this.getState().ui.promptEdit) this.exitPendingPromptEdit({ restoreDraft: true });
6027
+ this.dispatch({ type: "sessions/selected", sessionId });
6028
+ this.dispatch({ type: "ui/sequenceExpandedTurns", turns: [] });
6029
+ this.dispatch({ type: "ui/sequenceSelectedTurn", turn: null });
6030
+ this.dispatch({ type: "sessions/navigationIntent", sessionId });
6031
+ this.dispatch({ type: "sessions/merged", session: normalizeSessionListRow({
6032
+ ...created,
6033
+ ...(options.groupId ? { viewerGroupId: options.groupId } : {}),
6034
+ }) });
6035
+ this.revealCreatedSession();
6036
+ this.dispatch({ type: "ui/status", text: `Created session ${sessionId.slice(0, 8)}` });
6037
+ // A follow-up read failure must not masquerade as a failed create and
6038
+ // invite a duplicate. The regular refresh loop can finish hydration.
6039
+ try {
6040
+ await this.loadSession(sessionId);
6041
+ } catch (error) {
6042
+ if (this.getState().sessions.activeSessionId === sessionId) {
6043
+ this.dispatch({ type: "ui/status", text: `Created session ${sessionId.slice(0, 8)}; could not load it yet: ${error?.message || error}` });
6044
+ }
6045
+ }
6046
+ this.scheduleSessionsRefresh(0);
6047
+ }
6048
+
6014
6049
  async createSession(options = {}) {
6015
6050
  try {
6016
6051
  const requestOptions = this.applyActiveGroupDefault(options);
6017
6052
  const created = await this.transport.createSession(requestOptions);
6018
6053
  await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
6019
- await this.refreshSessions();
6020
- await this.loadSession(created.sessionId);
6021
- this.revealCreatedSession();
6022
- this.dispatch({ type: "ui/status", text: `Created session ${created.sessionId.slice(0, 8)}` });
6054
+ await this.openCreatedSession(created, requestOptions);
6023
6055
  return created;
6024
6056
  } catch (error) {
6025
6057
  this.dispatch({ type: "ui/status", text: error?.message || String(error) || "Failed to create session" });
@@ -6039,13 +6071,7 @@ export class PilotSwarmUiController {
6039
6071
  // session that was just created, and a lost count only reorders a list.
6040
6072
  this._recordAgentPickerUse(agentName);
6041
6073
  await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
6042
- await this.refreshSessions();
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
- });
6074
+ await this.openCreatedSession(created, requestOptions);
6049
6075
  return created;
6050
6076
  } catch (error) {
6051
6077
  this.dispatch({ type: "ui/status", text: error?.message || String(error) || "Failed to create session" });
@@ -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
  }
@@ -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) };
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
+ }
@@ -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";
@@ -995,6 +997,10 @@ function baseReducer(state, action) {
995
997
  if (Boolean(action.collapsed) === Boolean(state.ui.sessionDetailCollapsed)) return state;
996
998
  return { ...state, ui: { ...state.ui, sessionDetailCollapsed: Boolean(action.collapsed) } };
997
999
 
1000
+ case "ui/moaSaveStatus":
1001
+ return { ...state, ui: { ...state.ui, moaSaveStatus: action.status, moaDirty: action.status === "saved" && state.ui.moaRevision === action.revision ? false : state.ui.moaDirty } };
1002
+ case "ui/moa":
1003
+ return { ...state, ui: { ...state.ui, moa: normalizeMoa(action.value), moaDirty: true, moaRevision: (state.ui.moaRevision || 0) + 1 } };
998
1004
  case "profileSettings/apply": {
999
1005
  const settings = action.settings && typeof action.settings === "object" && !Array.isArray(action.settings)
1000
1006
  ? action.settings
@@ -1098,6 +1104,8 @@ function baseReducer(state, action) {
1098
1104
  sessions: selection.sessions,
1099
1105
  ui: {
1100
1106
  ...selection.ui,
1107
+ moa: !state.ui.moaDirty && Object.prototype.hasOwnProperty.call(settings, "moa") ? normalizeMoa(settings.moa) : selection.ui.moa,
1108
+ moaLoaded: true,
1101
1109
  themeId: hasTheme ? settings.themeId.trim() : selection.ui.themeId,
1102
1110
  touchScale: hasTouchScale ? settings.touchScale : selection.ui.touchScale,
1103
1111
  sessionDetailCollapsed: hasSessionDetailCollapsed
@@ -1624,7 +1632,8 @@ function baseReducer(state, action) {
1624
1632
  }
1625
1633
  for (const session of action.sessions) {
1626
1634
  const previous = state.sessions.byId[session.sessionId];
1627
- byId[session.sessionId] = mergeDefinedSessionFields(previous, session);
1635
+ byId[session.sessionId] = retainSessionWarnings(previous, mergeDefinedSessionFields(previous, session),
1636
+ state.history.bySessionId.get(session.sessionId)?.events, nowMs);
1628
1637
  }
1629
1638
  if (
1630
1639
  state.sessions.activeSessionId
@@ -1807,7 +1816,8 @@ function baseReducer(state, action) {
1807
1816
  case "sessions/merged": {
1808
1817
  if (!action.session?.sessionId) return state;
1809
1818
  const previousSession = state.sessions.byId[action.session.sessionId];
1810
- const mergedSession = mergeDefinedSessionFields(previousSession, action.session);
1819
+ const mergedSession = retainSessionWarnings(previousSession, mergeDefinedSessionFields(previousSession, action.session),
1820
+ state.history.bySessionId.get(action.session.sessionId)?.events);
1811
1821
  // A single session going running/idle changes its ANCESTORS' counts
1812
1822
  // and therefore their visual status, so both passes run over the
1813
1823
  // whole map rather than the one row. buildSessionTree below is
@@ -1939,6 +1949,13 @@ function baseReducer(state, action) {
1939
1949
  };
1940
1950
  }
1941
1951
 
1952
+ case "ui/revealCreatedSession":
1953
+ return {
1954
+ ...state,
1955
+ files: { ...state.files, fullscreen: false, paneOpen: false },
1956
+ ui: { ...state.ui, revealedCreatedSessionId: action.sessionId, fullscreenPane: null },
1957
+ };
1958
+
1942
1959
  case "ui/fullscreenPane": {
1943
1960
  const fullscreenPane = normalizeFullscreenPane(action.fullscreenPane);
1944
1961
  return {
@@ -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
- const sessionErrorMessage = buildSessionErrorMessage(session, events);
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 ? [...chat] : createSplashCard(state.branding, session);
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.push(sessionErrorMessage);
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
- // The browser gives an in-flight assistant answer its own stable surface.
2524
- // Previously it was flattened into the ordinary transcript on every
2525
- // snapshot. As partial markdown changed shape (plain lines -> table/code
2526
- // block), React could replace several adjacent nodes and the whole answer
2527
- // appeared to flash. One keyed card keeps the outer DOM and its shading in
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: memoSessionId ? state?.sessions?.byId?.[memoSessionId] ?? null : null,
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
+ }
@@ -436,6 +436,7 @@ export function createInitialState({ mode = "local", branding = null, docs = nul
436
436
  agentPickerUsage: {},
437
437
  modal: null,
438
438
  fullscreenPane: null,
439
+ revealedCreatedSessionId: null,
439
440
  layout: {
440
441
  ...initialLayoutAdjustments,
441
442
  viewportWidth: 120,