pilotswarm 0.5.56 → 0.5.58

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 (80) hide show
  1. package/package.json +2 -2
  2. package/tui/src/node-sdk-transport.js +5 -0
  3. package/ui/core/src/controller.js +117 -4
  4. package/ui/core/src/history.js +112 -1
  5. package/ui/core/src/reducer.js +177 -11
  6. package/ui/core/src/selectors.js +239 -18
  7. package/ui/core/src/state.js +65 -0
  8. package/ui/react/src/web-app.js +251 -27
  9. package/web/api/live-plane.js +193 -0
  10. package/web/api/ws.js +118 -2
  11. package/web/dist/assets/{abnfDiagram-VRR7QNED-Cccg5mUx.js → abnfDiagram-VRR7QNED-CCTGbkH5.js} +1 -1
  12. package/web/dist/assets/{arc-DFWp1OiI.js → arc-w8Hl2ORd.js} +1 -1
  13. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-o2B4-sJ1.js → architectureDiagram-ZJ3FMSHR-DAFM73jL.js} +1 -1
  14. package/web/dist/assets/{blockDiagram-677ZJIJ3-Cf_7Cm38.js → blockDiagram-677ZJIJ3-BYfTQFe7.js} +1 -1
  15. package/web/dist/assets/{c4Diagram-LMCZKHZV-CKXuPB0d.js → c4Diagram-LMCZKHZV-bNF2gRsg.js} +1 -1
  16. package/web/dist/assets/channel-s4nVWOVf.js +1 -0
  17. package/web/dist/assets/{chunk-2Q5K7J3B-CZC3HwzC.js → chunk-2Q5K7J3B-DhqJnJ4W.js} +1 -1
  18. package/web/dist/assets/{chunk-32BRIVSS-7_X1k3eB.js → chunk-32BRIVSS-Jg1a6n_3.js} +1 -1
  19. package/web/dist/assets/{chunk-5VM5RSS4-E2rxT4um.js → chunk-5VM5RSS4-y7NKpClZ.js} +1 -1
  20. package/web/dist/assets/{chunk-EX3LRPZG-BbW37HWx.js → chunk-EX3LRPZG-Lks3EhVc.js} +1 -1
  21. package/web/dist/assets/{chunk-JWPE2WC7-dj0DOnPl.js → chunk-JWPE2WC7-BjcSkfk2.js} +1 -1
  22. package/web/dist/assets/{chunk-MOJQB5TN-TeWalpvK.js → chunk-MOJQB5TN-USDZsR-1.js} +1 -1
  23. package/web/dist/assets/{chunk-RYQCIY6F-ClHcH2bN.js → chunk-RYQCIY6F--HnsBTqS.js} +1 -1
  24. package/web/dist/assets/{chunk-V7JOEXUC-Dy5XEnNL.js → chunk-V7JOEXUC-CUql3Oy3.js} +1 -1
  25. package/web/dist/assets/{chunk-VR4S4FIN-Den_q-0P.js → chunk-VR4S4FIN-CpS-l3FG.js} +1 -1
  26. package/web/dist/assets/{chunk-XXDRQBXY-DZ1s-8xB.js → chunk-XXDRQBXY-Bjb7ifTV.js} +1 -1
  27. package/web/dist/assets/classDiagram-OUVF2IWQ-tj8tRjww.js +1 -0
  28. package/web/dist/assets/classDiagram-v2-EOCWNBFH-tj8tRjww.js +1 -0
  29. package/web/dist/assets/{cose-bilkent-JH36ORCC-CKAukUTf.js → cose-bilkent-JH36ORCC-CmY3-Xrd.js} +1 -1
  30. package/web/dist/assets/{cynefin-VYW2F7L2-CM8y129F.js → cynefin-VYW2F7L2-DVtcyDi5.js} +1 -1
  31. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-DLryvwYt.js → cynefinDiagram-TSTJHNR4-BPqkLecJ.js} +1 -1
  32. package/web/dist/assets/{dagre-VKFMJZFB-BO0LMWmO.js → dagre-VKFMJZFB-B73fSRpn.js} +1 -1
  33. package/web/dist/assets/{diagram-FQU43EPY-DiFLu7v2.js → diagram-FQU43EPY-uRCPZyuW.js} +1 -1
  34. package/web/dist/assets/{diagram-G47NLZAW-DMkq9we0.js → diagram-G47NLZAW-CHs_l9pw.js} +1 -1
  35. package/web/dist/assets/{diagram-NH7WQ7WH-CxVQL8Dq.js → diagram-NH7WQ7WH-BDs35ueJ.js} +1 -1
  36. package/web/dist/assets/{diagram-OA4YK3LP-B5TrTlUc.js → diagram-OA4YK3LP-Dsv48zWW.js} +1 -1
  37. package/web/dist/assets/{diagram-WEI45ONY-Ca38mf1v.js → diagram-WEI45ONY-Bty-lxLj.js} +1 -1
  38. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DZR_44LD.js → ebnfDiagram-CCIWWBDH-DgaxsuDg.js} +1 -1
  39. package/web/dist/assets/{erDiagram-Q63AITRT-Ca8mmsU1.js → erDiagram-Q63AITRT-CR6l4OsK.js} +1 -1
  40. package/web/dist/assets/{flowDiagram-23GEKE2U-C0DFTCS6.js → flowDiagram-23GEKE2U-4y1ts4PX.js} +1 -1
  41. package/web/dist/assets/{ganttDiagram-NO4QXBWP-COqaqM45.js → ganttDiagram-NO4QXBWP-CyKdJ2e9.js} +1 -1
  42. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Cwb9oGjZ.js → gitGraphDiagram-IHSO6WYX-Ct5ShF3i.js} +1 -1
  43. package/web/dist/assets/index-DaRnh5NC.css +1 -0
  44. package/web/dist/assets/index-aFRNNWe9.js +24 -0
  45. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CdPnS1-j.js → infoDiagram-FWYZ7A6U-uWoTXOM3.js} +1 -1
  46. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-D4Muo2uw.js → ishikawaDiagram-FXEZZL3T-BTlDV_pc.js} +1 -1
  47. package/web/dist/assets/{journeyDiagram-5HDEW3XC-0uVdJDiB.js → journeyDiagram-5HDEW3XC-_R-XB78s.js} +1 -1
  48. package/web/dist/assets/{kanban-definition-HUTT4EX6-DWQU6DYI.js → kanban-definition-HUTT4EX6-D7iwBjoS.js} +1 -1
  49. package/web/dist/assets/{linear-BOxgX8xV.js → linear-BugN16AP.js} +1 -1
  50. package/web/dist/assets/{mermaid.core-C8tHn40J.js → mermaid.core-De_KIbl-.js} +4 -4
  51. package/web/dist/assets/{mindmap-definition-LN4V7U3C-C8NH0hQq.js → mindmap-definition-LN4V7U3C-B72NDgcU.js} +1 -1
  52. package/web/dist/assets/{papaparse.min-CwI0VkgN.js → papaparse.min-BsD41u4P.js} +1 -1
  53. package/web/dist/assets/{pegDiagram-2B236MQR-CksEEUgN.js → pegDiagram-2B236MQR-C6oifn4x.js} +1 -1
  54. package/web/dist/assets/{pieDiagram-ENE6RG2P-Bq2Bv0cw.js → pieDiagram-ENE6RG2P-Cpn0_Hp6.js} +1 -1
  55. package/web/dist/assets/pilotswarm-9Szh1sEC.js +99 -0
  56. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-vvpwUrN5.js → quadrantDiagram-ABIIQ3AL-CJT4OoDA.js} +1 -1
  57. package/web/dist/assets/{railroadDiagram-RFXS5EU6-yXGWn-zZ.js → railroadDiagram-RFXS5EU6-BKtBN8XC.js} +1 -1
  58. package/web/dist/assets/{requirementDiagram-TGXJPOKE-Bt1Q6Y60.js → requirementDiagram-TGXJPOKE-Dw7BCEzo.js} +1 -1
  59. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-CPNAfqgE.js → sankeyDiagram-HTMAVEWB-Avmr1rvJ.js} +1 -1
  60. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-Dz432FWB.js → sequenceDiagram-DBY2YBRQ-BRNnslq1.js} +1 -1
  61. package/web/dist/assets/{sizeCapture-X5ZJPWSS-Cokz6gNF.js → sizeCapture-X5ZJPWSS-BNmM8zTz.js} +1 -1
  62. package/web/dist/assets/{stateDiagram-2N3HPSRC-ojQCUdRE.js → stateDiagram-2N3HPSRC-CKXaP1Y6.js} +1 -1
  63. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Dr0juUsm.js +1 -0
  64. package/web/dist/assets/{swimlanes-5IMT3BWC-DyTOkc1R.js → swimlanes-5IMT3BWC-Dkeka0gO.js} +2 -2
  65. package/web/dist/assets/swimlanesDiagram-G3AALYLV-q-jLf0ZX.js +8 -0
  66. package/web/dist/assets/{timeline-definition-FHXFAJF6-DmuhBBl6.js → timeline-definition-FHXFAJF6-5hTTq-SL.js} +1 -1
  67. package/web/dist/assets/{vennDiagram-L72KCM5P-gTxQuU5b.js → vennDiagram-L72KCM5P-DL8MolqK.js} +1 -1
  68. package/web/dist/assets/{wardleyDiagram-EHGQE667-YNXbSRf2.js → wardleyDiagram-EHGQE667-CwGhy6eW.js} +1 -1
  69. package/web/dist/assets/{xychartDiagram-FW5EYKEG-D7loT9cv.js → xychartDiagram-FW5EYKEG-Dn4T-3Dx.js} +1 -1
  70. package/web/dist/index.html +3 -3
  71. package/web/runtime.js +8 -0
  72. package/web/server.js +8 -0
  73. package/web/dist/assets/channel-CIBN5O06.js +0 -1
  74. package/web/dist/assets/classDiagram-OUVF2IWQ-Bq-5VkDc.js +0 -1
  75. package/web/dist/assets/classDiagram-v2-EOCWNBFH-Bq-5VkDc.js +0 -1
  76. package/web/dist/assets/index-BOkjbFY2.js +0 -24
  77. package/web/dist/assets/index-DCx4DEMM.css +0 -1
  78. package/web/dist/assets/pilotswarm-uebRfMjg.js +0 -99
  79. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BliVrz4U.js +0 -1
  80. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DmwmYUnc.js +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.56",
3
+ "version": "0.5.58",
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.56",
84
+ "pilotswarm-sdk": "0.5.58",
85
85
  "react": "^19.2.4",
86
86
  "react-dom": "^19.2.4",
87
87
  "ws": "^8.18.2"
@@ -1976,6 +1976,11 @@ export class NodeSdkTransport {
1976
1976
  return this.mgmt.getCanvasLive(sessionId);
1977
1977
  }
1978
1978
 
1979
+ async getLive(sessionId, topics) {
1980
+ if (typeof this.mgmt.getLive !== "function") return [];
1981
+ return this.mgmt.getLive(sessionId, topics);
1982
+ }
1983
+
1979
1984
  // The canvas KV store. The portal runtime hands a resolved principal
1980
1985
  // (door 1); the native TUI has no canvas surface and never calls these.
1981
1986
  async readCanvasKv(sessionId, slot, principal, query = {}) {
@@ -4,8 +4,10 @@ import { parseAgentSourceLink } from "./repo-links.js";
4
4
  import { importPackageFilesFromLink, readImportedPackageName } from "./repo-import.js";
5
5
  import {
6
6
  appendEventToHistory,
7
+ applyLiveTurnToHistory,
7
8
  buildHistoryModel,
8
9
  CHAT_HISTORY_EVENT_TYPES,
10
+ clearLiveTurnFromHistory,
9
11
  DEFAULT_HISTORY_EVENT_LIMIT,
10
12
  dedupeChatMessages,
11
13
  getNextHistoryEventLimit,
@@ -2503,6 +2505,8 @@ export class PilotSwarmUiController {
2503
2505
  }
2504
2506
 
2505
2507
  async stop() {
2508
+ for (const timer of this._liveTurnIdleTimers?.values() || []) clearTimeout(timer);
2509
+ this._liveTurnIdleTimers?.clear();
2506
2510
  if (this.catalogTimer) clearInterval(this.catalogTimer);
2507
2511
  this.catalogTimer = null;
2508
2512
  if (this.activeSessionDetailTimer) clearTimeout(this.activeSessionDetailTimer);
@@ -2518,6 +2522,16 @@ export class PilotSwarmUiController {
2518
2522
  }
2519
2523
 
2520
2524
  detachActiveSession() {
2525
+ // Timers belong to the subscription, not to a future visit to this session.
2526
+ const sessionId = this.activeSessionSubscriptionId;
2527
+ if (sessionId) {
2528
+ clearTimeout(this._liveTurnIdleTimers?.get(sessionId));
2529
+ this._liveTurnIdleTimers?.delete(sessionId);
2530
+ const history = this.getState().history.bySessionId.get(sessionId);
2531
+ if (history) this.dispatch({ type: "history/set", sessionId, history: {
2532
+ ...history, chat: history.chat.filter((item) => !item.liveTurn),
2533
+ } });
2534
+ }
2521
2535
  if (this.activeSessionUnsub) {
2522
2536
  this.activeSessionUnsub();
2523
2537
  this.activeSessionUnsub = null;
@@ -2866,8 +2880,9 @@ export class PilotSwarmUiController {
2866
2880
  if (newEvents.length === 0) {
2867
2881
  return existingHistory;
2868
2882
  }
2869
- let history = existingHistory;
2883
+ let history = this.getState().history.bySessionId.get(sessionId) || existingHistory;
2870
2884
  for (const event of newEvents) {
2885
+ if (event.seq <= (history.lastSeq || 0)) continue;
2871
2886
  history = appendEventToHistory(history, event);
2872
2887
  }
2873
2888
  history = {
@@ -2937,10 +2952,37 @@ export class PilotSwarmUiController {
2937
2952
  }
2938
2953
  throw error;
2939
2954
  }
2940
- const history = {
2941
- ...buildHistoryModel(events, { requestedLimit }),
2942
- lastSeq: events[events.length - 1]?.seq || 0,
2955
+ const current = this.getState().history.bySessionId.get(sessionId);
2956
+ // Durable events and previews can arrive while REST is pending.
2957
+ // Merge that newer truth before replacing the loaded window.
2958
+ const mergedEvents = [...new Map([...(events || []), ...(current?.events || [])]
2959
+ .map((event) => [event.seq, event])).values()].sort((a, b) => a.seq - b.seq);
2960
+ let history = {
2961
+ ...buildHistoryModel(mergedEvents, { requestedLimit }),
2962
+ lastSeq: mergedEvents.at(-1)?.seq || 0,
2943
2963
  };
2964
+ history.closedLiveKeys = current?.closedLiveKeys || [];
2965
+ history.closedLiveStreams = current?.closedLiveStreams || [];
2966
+ const newTerminal = mergedEvents.some((event) => event.seq > (current?.lastSeq || 0)
2967
+ && ["session.turn_completed", "session.turn_stopped"].includes(event.eventType));
2968
+ for (const item of current?.chat || []) {
2969
+ if (item.streamSettling) {
2970
+ history.chat = history.chat.map((message) => message.messageId === item.messageId
2971
+ ? { ...item, ...message } : message);
2972
+ } else if (item.liveTurn && !newTerminal) {
2973
+ // Reuse the item itself: its key, reveal clock and memoized
2974
+ // rendering belong to this visit, not the REST response.
2975
+ if (!history.chat.some((message) => message.messageId && message.messageId === item.messageId)) {
2976
+ history.chat.push(item);
2977
+ } else {
2978
+ const finalEvent = mergedEvents.find((event) => event.eventType === "assistant.message" && event.data?.messageId === item.messageId);
2979
+ if (finalEvent) {
2980
+ const reconciled = appendEventToHistory({ chat: [item], events: [], activity: [], lastSeq: 0 }, finalEvent).chat[0];
2981
+ history.chat = history.chat.map((message) => message.messageId === item.messageId ? { ...reconciled, ...message } : message);
2982
+ }
2983
+ }
2984
+ }
2985
+ }
2944
2986
  this.dispatch({
2945
2987
  type: "history/set",
2946
2988
  sessionId,
@@ -5708,6 +5750,71 @@ export class PilotSwarmUiController {
5708
5750
  // history would corrupt the replay cursor — and they must never
5709
5751
  // appear in the transcript.
5710
5752
  if (event.transient === true) {
5753
+ if (event.eventType === "assistant.live_tick") {
5754
+ // A worker can die leaving a reasoning-only retained row.
5755
+ // On re-entry its message ID cannot match a durable final;
5756
+ // use database timestamps to reject snapshots older than an
5757
+ // already-loaded terminal event. Never use client wall time.
5758
+ const snapshotTime = event.liveUpdatedAt ? new Date(event.liveUpdatedAt).getTime() : NaN;
5759
+ if (event.data?.phase === "live" && Number.isFinite(snapshotTime)) {
5760
+ const history = this.getState().history.bySessionId.get(sessionId);
5761
+ if (history?.events?.some((entry) => ["session.turn_completed", "session.turn_stopped"].includes(entry.eventType)
5762
+ && new Date(entry.createdAt).getTime() > snapshotTime)) return false;
5763
+ }
5764
+ if (!this._liveTurnIdleTimers) this._liveTurnIdleTimers = new Map();
5765
+ const pendingIdle = this._liveTurnIdleTimers.get(sessionId);
5766
+ if (pendingIdle && event.data?.phase === "idle") {
5767
+ clearTimeout(pendingIdle);
5768
+ this._liveTurnIdleTimers.delete(sessionId);
5769
+ }
5770
+ // The live plane and durable event stream are independent.
5771
+ // An idle tick commonly wins the race by a few milliseconds;
5772
+ // tearing down immediately makes the live answer flash out
5773
+ // before its durable replacement lands. Give reconciliation a
5774
+ // bounded grace window, then clear only if it is still live.
5775
+ if (event.data?.phase === "idle") {
5776
+ const ended = this.getState().history.bySessionId.get(sessionId);
5777
+ if (!ended) return true;
5778
+ const streamId = event.data?.streamId || null;
5779
+ const endedKeys = new Set(ended.chat.filter((item) => item.liveTurn && (
5780
+ !streamId || item.streamId === streamId
5781
+ || (ended.closedLiveStreams || []).includes(item.streamId)
5782
+ || (ended.closedLiveKeys || []).includes(item.liveKey)
5783
+ )).map((item) => item.liveKey));
5784
+ // Never reveal a preview that has already finished. Keep
5785
+ // only previews that crossed the reveal threshold, and
5786
+ // close their keys now so late packets cannot revive them.
5787
+ this.dispatch({ type: "history/set", sessionId, history: {
5788
+ ...clearLiveTurnFromHistory(ended, null, streamId),
5789
+ chat: ended.chat.filter((item) => !item.liveTurn || !endedKeys.has(item.liveKey) || Date.now() - item.liveStartedAt >= 200),
5790
+ } });
5791
+ const timer = setTimeout(() => {
5792
+ this._liveTurnIdleTimers?.delete(sessionId);
5793
+ const current = this.getState().history.bySessionId.get(sessionId);
5794
+ if (!current) return;
5795
+ const chat = current.chat.filter((item) => !item.liveTurn || !endedKeys.has(item.liveKey));
5796
+ const cleared = chat.length === current.chat.length ? current : { ...current, chat };
5797
+ if (cleared !== current) {
5798
+ this.dispatch({ type: "history/set", sessionId, history: cleared });
5799
+ }
5800
+ }, 1_200);
5801
+ timer.unref?.();
5802
+ this._liveTurnIdleTimers.set(sessionId, timer);
5803
+ return true;
5804
+ }
5805
+ const existing = this.getState().history.bySessionId.get(sessionId)
5806
+ || { chat: [], activity: [], events: [], lastSeq: 0 };
5807
+ this.dispatch({
5808
+ type: "history/set",
5809
+ sessionId,
5810
+ history: applyLiveTurnToHistory(existing, event.data, {
5811
+ sessionId,
5812
+ seq: event.liveSeq,
5813
+ createdAt: Date.now(),
5814
+ }),
5815
+ });
5816
+ return true;
5817
+ }
5711
5818
  if (event.eventType === "session.canvas_data") {
5712
5819
  this.applyCanvasDataEvent(sessionId, event);
5713
5820
  }
@@ -5730,6 +5837,12 @@ export class PilotSwarmUiController {
5730
5837
  }
5731
5838
  return false;
5732
5839
  }
5840
+ if (this._liveTurnIdleTimers?.has(sessionId)
5841
+ && (event.eventType === "session.turn_completed"
5842
+ || event.eventType === "session.turn_stopped")) {
5843
+ clearTimeout(this._liveTurnIdleTimers.get(sessionId));
5844
+ this._liveTurnIdleTimers.delete(sessionId);
5845
+ }
5733
5846
  const state = this.getState();
5734
5847
  const existing = state.history.bySessionId.get(sessionId) || { chat: [], activity: [], lastSeq: 0 };
5735
5848
  if (event.seq <= (existing.lastSeq || 0)) return false;
@@ -530,6 +530,9 @@ function buildChatMessage(event, role) {
530
530
  const attachments = Array.isArray(event?.data?.attachments)
531
531
  ? event.data.attachments.filter((a) => a && typeof a.filename === "string" && a.filename)
532
532
  : [];
533
+ const messageId = typeof event?.data?.messageId === "string" && event.data.messageId
534
+ ? event.data.messageId
535
+ : null;
533
536
  return {
534
537
  id: `${event.sessionId}:${event.seq}`,
535
538
  role: deriveChatRole(event, role, text),
@@ -539,9 +542,76 @@ function buildChatMessage(event, role) {
539
542
  ...(clientMessageIds.length > 0 ? { clientMessageIds } : {}),
540
543
  ...(sender ? { sender } : {}),
541
544
  ...(attachments.length > 0 ? { attachments } : {}),
545
+ ...(messageId ? { messageId } : {}),
542
546
  };
543
547
  }
544
548
 
549
+ /** Apply one transient `turn` topic snapshot without touching durable events/seq. */
550
+ export function applyLiveTurnToHistory(history, payload, meta = {}) {
551
+ if (!payload) return history;
552
+ if (payload.phase === "idle") return clearLiveTurnFromHistory(history, null, payload.streamId || null);
553
+ if (payload.phase !== "live") return history;
554
+ const messageId = typeof payload.messageId === "string" && payload.messageId ? payload.messageId : null;
555
+ const reasoningId = typeof payload.reasoningId === "string" && payload.reasoningId ? payload.reasoningId : null;
556
+ const liveKey = messageId || (reasoningId ? `reasoning:${reasoningId}` : "active-turn");
557
+ const createdAt = Number(meta.createdAt) || Date.now();
558
+ const current = history || { chat: [], activity: [], events: [], lastSeq: 0 };
559
+ const oldChat = current.chat || [];
560
+ // Durable truth wins even when a delayed retained snapshot arrives later.
561
+ if ((messageId && oldChat.some((item) => !item.liveTurn && item.messageId === messageId))
562
+ || (current.closedLiveKeys || []).includes(liveKey)
563
+ || (payload.streamId && (current.closedLiveStreams || []).includes(payload.streamId))) return current;
564
+ const matches = (item) => item?.liveTurn === true && (
565
+ item?.liveKey === liveKey
566
+ || (messageId && item?.messageId === messageId)
567
+ || (reasoningId && item?.reasoningId === reasoningId && (!item.messageId || !messageId))
568
+ );
569
+ const firstMatch = oldChat.findIndex(matches);
570
+ const previous = firstMatch >= 0 ? oldChat[firstMatch] : null;
571
+ const chat = oldChat.filter((item) => !matches(item));
572
+ const reasoningText = typeof payload.reasoningText === "string" ? payload.reasoningText : "";
573
+ const text = typeof payload.text === "string" ? payload.text : "";
574
+ const liveStartedAt = Number(previous?.liveStartedAt || previous?.createdAt) || createdAt;
575
+ // Reasoning and answer text are one visual turn, not two independently
576
+ // mounted transcript rows. Keeping them in one keyed item lets the browser
577
+ // collapse reasoning above the answer and lets the durable final settle
578
+ // into the exact same React component instead of flashing between nodes.
579
+ const replacement = (reasoningText || text) ? [{
580
+ id: previous?.id || `live:${meta.sessionId || ""}:${liveKey}`,
581
+ role: "assistant",
582
+ ...(messageId ? { messageId } : {}),
583
+ ...(reasoningId ? { reasoningId } : {}),
584
+ liveKey: previous?.liveKey || liveKey,
585
+ liveTurn: true,
586
+ streamId: payload.streamId || previous?.streamId || null,
587
+ streaming: true,
588
+ truncated: payload.truncated === true,
589
+ text,
590
+ reasoningText,
591
+ liveStartedAt,
592
+ createdAt: liveStartedAt,
593
+ time: "",
594
+ }] : [];
595
+ chat.splice(firstMatch < 0 ? chat.length : Math.min(firstMatch, chat.length), 0, ...replacement);
596
+ return { ...current, chat };
597
+ }
598
+
599
+ export function clearLiveTurnFromHistory(history, messageId = null, streamId = null) {
600
+ if (!history?.chat) return history;
601
+ const chat = history.chat.filter((item) => !(
602
+ item?.liveTurn === true && (!messageId || item?.messageId === messageId) && (!streamId || item?.streamId === streamId)
603
+ ));
604
+ const retired = history.chat.filter((item) => item.liveTurn && (!messageId || item.messageId === messageId) && (!streamId || item.streamId === streamId));
605
+ const closedLiveKeys = [...new Set([...(history.closedLiveKeys || []),
606
+ ...retired.flatMap((item) => [item.liveKey, item.messageId, item.reasoningId && `reasoning:${item.reasoningId}`].filter(Boolean)),
607
+ ])].slice(-256);
608
+ const closedLiveStreams = [...new Set([...(history.closedLiveStreams || []),
609
+ ...(!messageId ? retired.map((item) => item.streamId).filter(Boolean) : []),
610
+ ...(streamId ? [streamId] : []),
611
+ ])].slice(-256);
612
+ return chat.length === history.chat.length && !streamId ? history : { ...history, chat, closedLiveKeys, closedLiveStreams };
613
+ }
614
+
545
615
  // The Copilot SDK echoes the system prompt as a system.message on EVERY
546
616
  // turn, and the worker keeps only a 120-char snippet of it (session-proxy.ts,
547
617
  // "Full content omitted from CMS"). For one release (0.5.47) each echo also
@@ -765,6 +835,12 @@ function formatActivity(event) {
765
835
  case "assistant.streaming_progress":
766
836
  return null;
767
837
 
838
+ case "model.call_start": {
839
+ const model = String(event?.data?.model || event?.data?.modelId || "model").split(":").pop();
840
+ runs = buildLabeledActivityRuns(time, "[model]", "cyan", `calling ${model}`, "white");
841
+ break;
842
+ }
843
+
768
844
  case "session.artifact_presented": {
769
845
  const data = (event?.data ?? {}) || {};
770
846
  const filename = String(data.filename || "artifact");
@@ -1167,6 +1243,8 @@ export function appendEventToHistory(history, event) {
1167
1243
  ? existingEvents
1168
1244
  : [...existingEvents, event].slice(-loadedEventLimit);
1169
1245
  const next = {
1246
+ closedLiveKeys: history?.closedLiveKeys || [],
1247
+ closedLiveStreams: history?.closedLiveStreams || [],
1170
1248
  chat: clampHistoryItems(history?.chat || [], loadedEventLimit),
1171
1249
  activity: clampHistoryItems(history?.activity || [], loadedEventLimit),
1172
1250
  events: nextEvents,
@@ -1201,6 +1279,7 @@ export function appendEventToHistory(history, event) {
1201
1279
  : m
1202
1280
  ));
1203
1281
  }
1282
+ Object.assign(next, clearLiveTurnFromHistory(next));
1204
1283
  return next;
1205
1284
  }
1206
1285
 
@@ -1241,10 +1320,42 @@ export function appendEventToHistory(history, event) {
1241
1320
  next.activity = clampHistoryItems(next.activity, loadedEventLimit);
1242
1321
  const message = buildChatMessage(event, "assistant");
1243
1322
  if (!message) return next;
1244
- next.chat.push(message);
1323
+ const liveIndex = message.messageId
1324
+ ? next.chat.findIndex((item) => item?.liveTurn === true && item?.messageId === message.messageId)
1325
+ : -1;
1326
+ const liveItem = liveIndex >= 0 ? next.chat[liveIndex] : null;
1327
+ Object.assign(next, clearLiveTurnFromHistory(next, message.messageId || null));
1328
+ // Preserve the model-call slot when several assistant messages are
1329
+ // interleaved: the durable final replaces its own live bubble in
1330
+ // place instead of jumping behind a later streaming message.
1331
+ if (liveIndex >= 0) {
1332
+ const settledAt = Date.now();
1333
+ const liveStartedAt = Number(liveItem?.liveStartedAt || liveItem?.createdAt) || settledAt;
1334
+ const liveAgeMs = Math.max(0, settledAt - liveStartedAt);
1335
+ // A live surface that never crossed the reveal threshold remains
1336
+ // invisible and the durable answer renders normally. Once it was
1337
+ // eligible to be seen, preserve the key and reasoning long enough
1338
+ // for the browser to morph it smoothly into committed transcript.
1339
+ if (liveAgeMs >= 200) {
1340
+ const streamSettleDelayMs = Math.max(0, 900 - liveAgeMs);
1341
+ Object.assign(message, {
1342
+ streamSettling: true,
1343
+ liveKey: liveItem?.liveKey || message.messageId,
1344
+ liveStartedAt,
1345
+ liveReasoningText: liveItem?.reasoningText || "",
1346
+ streamSettleDelayMs,
1347
+ streamSettleAt: settledAt + streamSettleDelayMs + 180,
1348
+ });
1349
+ }
1350
+ next.chat.splice(Math.min(liveIndex, next.chat.length), 0, message);
1351
+ }
1352
+ else next.chat.push(message);
1245
1353
  next.chat = clampHistoryItems(dedupeChatMessages(next.chat), loadedEventLimit);
1246
1354
  return next;
1247
1355
  }
1356
+ if (event.eventType === "session.turn_completed" || event.eventType === "session.turn_stopped") {
1357
+ Object.assign(next, clearLiveTurnFromHistory(next));
1358
+ }
1248
1359
  if (event.eventType === "system.message") {
1249
1360
  if (shouldRenderSystemMessageAsActivity(event)) {
1250
1361
  const activityItem = formatActivity(event);
@@ -2,7 +2,7 @@ import { buildSessionTree, isManuallyOrderableSession } from "./session-tree.js"
2
2
  import { FOCUS_REGIONS } from "./commands.js";
3
3
  import { DEFAULT_HISTORY_EVENT_LIMIT, dedupeChatMessages } from "./history.js";
4
4
  import { getPromptInputRows } from "./layout.js";
5
- import { selectSessionRows } from "./selectors.js";
5
+ import { SYSTEM_OWNER_KEY, ownerKeyForOwner, selectSessionRows } from "./selectors.js";
6
6
  import { systemSessionSortOrder } from "./system-titles.js";
7
7
  import {
8
8
  normalizeArtifactEntries,
@@ -20,6 +20,9 @@ import {
20
20
  BUDGET_SERIES_DAYS,
21
21
  BUDGET_SERIES_RANGES,
22
22
  createInitialState,
23
+ SESSION_VIEW_LAYOUT_KEYS,
24
+ defaultSessionView,
25
+ normalizeStoredSessionViews,
23
26
  } from "./state.js";
24
27
 
25
28
  /**
@@ -635,6 +638,38 @@ function updateUiForSessionSelection(state, nextActiveSessionId) {
635
638
  };
636
639
  }
637
640
 
641
+ // Add the linked session's owner to the owner filter when that is what hides
642
+ // it. Returns the next sessions slice, or null when the owner filter is not
643
+ // the reason (an `all` filter, a system or unowned session, an owner the
644
+ // filter already admits) so the caller falls back to the transient exception.
645
+ function admitLinkedSessionOwner(state, sessions, targetId) {
646
+ const target = sessions.byId?.[targetId];
647
+ if (!target || target.isGroup || target.isSystem) return null;
648
+ const filter = normalizeSessionOwnerFilter(sessions.ownerFilter);
649
+ if (filter.all === true) return null;
650
+ const ownerKey = ownerKeyForOwner(target.owner);
651
+ if (!ownerKey || ownerKey === SYSTEM_OWNER_KEY) return null;
652
+ const viewerKey = ownerKeyForOwner(state.auth?.principal);
653
+ if (viewerKey && ownerKey === viewerKey) return null;
654
+ if (filter.includeShared || filter.ownerKeys.includes(ownerKey)) return null;
655
+ const candidate = {
656
+ ...sessions,
657
+ ownerFilterExplicit: true,
658
+ ownerFilter: { ...filter, ownerKeys: [...filter.ownerKeys, ownerKey] },
659
+ ownerFilterAutoAdmitted: { sessionId: targetId, ownerKey },
660
+ };
661
+ // Prove it worked: a session hidden by something else as well (a text
662
+ // filter, a collapsed ancestor) must not change the filter for nothing.
663
+ const rows = selectSessionRows({ ...state, sessions: candidate });
664
+ return rows.some((row) => row.sessionId === targetId) ? candidate : null;
665
+ }
666
+
667
+ function keepAdmittedOwner(filter, admitted) {
668
+ const ownerKey = admitted?.ownerKey;
669
+ if (!ownerKey || filter.all === true || filter.includeShared || filter.ownerKeys.includes(ownerKey)) return filter;
670
+ return { ...filter, ownerKeys: [...filter.ownerKeys, ownerKey] };
671
+ }
672
+
638
673
  function applyVisibleSessionSelection(state, nextSessions) {
639
674
  let sessions = nextSessions;
640
675
 
@@ -671,16 +706,27 @@ function applyVisibleSessionSelection(state, nextSessions) {
671
706
  }
672
707
  }
673
708
 
674
- // A resolved intent target excluded by the current filters gets a
675
- // transient exception so the linked session is shown anyway. Never
676
- // persisted; cleared on manual navigation or filter change.
677
- if (intentTargetId && sessions.filterExceptionId !== intentTargetId) {
709
+ // A resolved intent target excluded by the current filters. First choice:
710
+ // when the only thing hiding it is that its owner is not in the owner
711
+ // filter, ADD that owner to the filter. The filter persists with the
712
+ // profile, so the linked session — and that person's other shared
713
+ // sessions — stay listed after the link is gone. Without this the link
714
+ // opened the chat but the row never appeared in the list. Fallback (no
715
+ // human owner, or hidden for another reason): the transient exception,
716
+ // never persisted, cleared on manual navigation or filter change.
717
+ if (intentTargetId && sessions.filterExceptionId !== intentTargetId
718
+ && sessions.ownerFilterAutoAdmitted?.sessionId !== intentTargetId) {
678
719
  const probeRows = selectSessionRows({ ...state, sessions });
679
720
  if (!probeRows.some((row) => row.sessionId === intentTargetId)) {
680
- sessions = {
681
- ...sessions,
682
- filterExceptionId: intentTargetId,
683
- };
721
+ const admitted = admitLinkedSessionOwner(state, sessions, intentTargetId);
722
+ if (admitted) {
723
+ sessions = admitted;
724
+ } else {
725
+ sessions = {
726
+ ...sessions,
727
+ filterExceptionId: intentTargetId,
728
+ };
729
+ }
684
730
  }
685
731
  }
686
732
 
@@ -771,6 +817,103 @@ function normalizePromptAttachments(prompt, attachments) {
771
817
  }
772
818
 
773
819
  export function appReducer(state, action) {
820
+ const next = baseReducer(state, action);
821
+ if (next === state) return next;
822
+ return reconcileSessionView(state, next, action);
823
+ }
824
+
825
+ // Per-session desktop views, kept in step with the ui slice.
826
+ //
827
+ // active session changed → apply that session's stored view (or the
828
+ // default: no columns, even sizes)
829
+ // profile settings applied → re-apply the active session's view, so the
830
+ // global desktopPanes/layoutAdjustments a poll
831
+ // carries never override a per-session choice
832
+ // anything else changed a column toggle or a right-side size while the
833
+ // session stayed the same → record it for that session
834
+ //
835
+ // Desktop only (the phone has no columns; its layout is untouched), and only
836
+ // once the profile has been read, so the defaults cannot be recorded over a
837
+ // stored view the tab has not seen yet.
838
+ function reconcileSessionView(prev, next, action) {
839
+ const ui = next.ui;
840
+ if (ui.sessionViewDevice !== "desktop") return next;
841
+ const active = next.sessions.activeSessionId || null;
842
+ if (!active || next.sessions.byId?.[active]?.isGroup) return next;
843
+ const prevActive = prev.sessions.activeSessionId || null;
844
+ // A profile read (the first one included) re-applies the active
845
+ // session's view from the merged map. Local edits made before the read
846
+ // were recorded below and win the merge, so a column opened while the
847
+ // profile was still loading is not shut by the answer.
848
+ if (action.type === "profileSettings/apply") {
849
+ return { ...next, ui: applySessionViewToUi(ui, ui.sessionViews?.[active]?.desktop || defaultSessionView()) };
850
+ }
851
+ if (active !== prevActive) {
852
+ // Before the read there is nothing to apply on a switch; the stored
853
+ // view lands with the read.
854
+ if (!ui.sessionViewsLoaded) return next;
855
+ return { ...next, ui: applySessionViewToUi(ui, ui.sessionViews?.[active]?.desktop || defaultSessionView()) };
856
+ }
857
+ const current = sessionViewFromUi(ui);
858
+ const before = sessionViewFromUi(prev.ui);
859
+ if (sessionViewsEqual(current, before)) return next;
860
+ const entry = { ...(ui.sessionViews?.[active] || {}), desktop: { ...current, at: Date.now() } };
861
+ return {
862
+ ...next,
863
+ ui: {
864
+ ...ui,
865
+ sessionViews: { ...(ui.sessionViews || {}), [active]: entry },
866
+ },
867
+ };
868
+ }
869
+
870
+ function sessionViewFromUi(ui) {
871
+ const layout = {};
872
+ for (const key of SESSION_VIEW_LAYOUT_KEYS) layout[key] = Number(ui.layout?.[key]) || 0;
873
+ return {
874
+ canvasOpen: ui.canvasOpen === true,
875
+ diagnosticsOpen: ui.diagnosticsOpen === true,
876
+ zen: ui.canvasOpen === true && ui.canvasZen === true,
877
+ layout,
878
+ };
879
+ }
880
+
881
+ function sessionViewsEqual(a, b) {
882
+ if (a.canvasOpen !== b.canvasOpen || a.diagnosticsOpen !== b.diagnosticsOpen || a.zen !== b.zen) return false;
883
+ for (const key of SESSION_VIEW_LAYOUT_KEYS) if (a.layout[key] !== b.layout[key]) return false;
884
+ return true;
885
+ }
886
+
887
+ function applySessionViewToUi(ui, view) {
888
+ const current = sessionViewFromUi(ui);
889
+ const wanted = { canvasOpen: view.canvasOpen === true, diagnosticsOpen: view.diagnosticsOpen === true, zen: view.canvasOpen === true && view.zen === true, layout: view.layout || {} };
890
+ if (sessionViewsEqual(current, { ...wanted, layout: { ...current.layout, ...wanted.layout } })) return ui;
891
+ return {
892
+ ...ui,
893
+ canvasOpen: wanted.canvasOpen,
894
+ diagnosticsOpen: wanted.diagnosticsOpen,
895
+ canvasZen: wanted.zen,
896
+ rightPaneMode: wanted.canvasOpen ? "canvas" : "panes",
897
+ ...(wanted.canvasOpen ? {} : { canvasMaximized: false }),
898
+ layout: { ...(ui.layout || {}), ...wanted.layout },
899
+ };
900
+ }
901
+
902
+ // Newest wins, per session: every record carries the time it was made, so a
903
+ // local edit newer than the stored one survives the poll, and another
904
+ // desktop's later edit replaces this tab's older one. No "touched" set —
905
+ // one that never cleared made two desktops fight over the same session.
906
+ function mergeSessionViews(remote, local) {
907
+ const out = { ...(remote || {}) };
908
+ for (const [id, entry] of Object.entries(local || {})) {
909
+ const localAt = Number(entry?.desktop?.at) || 0;
910
+ const remoteAt = Number(out[id]?.desktop?.at) || 0;
911
+ if (!out[id] || localAt > remoteAt) out[id] = entry;
912
+ }
913
+ return out;
914
+ }
915
+
916
+ function baseReducer(state, action) {
774
917
  switch (action.type) {
775
918
  case "connection/ready":
776
919
  return {
@@ -828,6 +971,12 @@ export function appReducer(state, action) {
828
971
  },
829
972
  };
830
973
 
974
+ case "ui/sessionViewDevice": {
975
+ const device = action.device === "desktop" || action.device === "mobile" ? action.device : null;
976
+ if (device === state.ui.sessionViewDevice) return state;
977
+ return { ...state, ui: { ...state.ui, sessionViewDevice: device } };
978
+ }
979
+
831
980
  case "ui/touchScale":
832
981
  if (Boolean(action.enabled) === Boolean(state.ui.touchScale)) return state;
833
982
  return { ...state, ui: { ...state.ui, touchScale: Boolean(action.enabled) } };
@@ -868,6 +1017,11 @@ export function appReducer(state, action) {
868
1017
  // migrating that — see normalizeStoredDesktopPanes.
869
1018
  const hasDesktopPanes = Object.prototype.hasOwnProperty.call(settings, "desktopPanes")
870
1019
  && settings.desktopPanes && typeof settings.desktopPanes === "object";
1020
+ const hasSessionViews = Object.prototype.hasOwnProperty.call(settings, "sessionViews")
1021
+ && settings.sessionViews && typeof settings.sessionViews === "object";
1022
+ const nextSessionViews = hasSessionViews
1023
+ ? mergeSessionViews(normalizeStoredSessionViews(settings.sessionViews), state.ui.sessionViews)
1024
+ : state.ui.sessionViews;
871
1025
  const nextDesktopPanes = (hasDesktopPanes || hasRightPaneMode)
872
1026
  ? normalizeStoredDesktopPanes(
873
1027
  hasDesktopPanes ? settings.desktopPanes : null,
@@ -914,7 +1068,13 @@ export function appReducer(state, action) {
914
1068
  ...state.sessions,
915
1069
  ...(hasOwnerFilter
916
1070
  ? {
917
- ownerFilter: normalizeSessionOwnerFilter(settings.sessionOwnerFilter),
1071
+ // A stored filter that predates a just-admitted owner
1072
+ // (the first poll racing the deep link, or another
1073
+ // device's save) must not drop that owner again.
1074
+ ownerFilter: keepAdmittedOwner(
1075
+ normalizeSessionOwnerFilter(settings.sessionOwnerFilter),
1076
+ state.sessions.ownerFilterAutoAdmitted,
1077
+ ),
918
1078
  ownerFilterExplicit: true,
919
1079
  }
920
1080
  : {}),
@@ -948,6 +1108,9 @@ export function appReducer(state, action) {
948
1108
  ? { canvasOpen: nextDesktopPanes.canvasOpen, diagnosticsOpen: nextDesktopPanes.diagnosticsOpen, canvasZen: nextDesktopPanes.zen === true }
949
1109
  : {}),
950
1110
  layout: nextLayout,
1111
+ sessionViews: nextSessionViews,
1112
+ // Read once is enough: from here on this tab records.
1113
+ sessionViewsLoaded: true,
951
1114
  },
952
1115
  canvas: hasCanvasPrefs
953
1116
  ? { ...state.canvas, prefs: mergeCanvasPrefs(state.canvas.prefs, normalizeStoredCanvasPrefs(settings.canvasPrefs), hasLoadedSessions ? state.sessions.byId : null) }
@@ -1184,6 +1347,7 @@ export function appReducer(state, action) {
1184
1347
  // the deep-link latch and its transient filter exception.
1185
1348
  navigationIntent: null,
1186
1349
  filterExceptionId: null,
1350
+ ownerFilterAutoAdmitted: null,
1187
1351
  };
1188
1352
  const selection = applyVisibleSessionSelection(state, nextSessions);
1189
1353
  return {
@@ -1201,6 +1365,7 @@ export function appReducer(state, action) {
1201
1365
  ownerFilter: normalizeSessionOwnerFilter(action.filter),
1202
1366
  navigationIntent: null,
1203
1367
  filterExceptionId: null,
1368
+ ownerFilterAutoAdmitted: null,
1204
1369
  };
1205
1370
  const selection = applyVisibleSessionSelection(state, nextSessions);
1206
1371
  return {
@@ -1221,6 +1386,7 @@ export function appReducer(state, action) {
1221
1386
  listDeselected: false,
1222
1387
  navigationIntent: { sessionId, status: "pending" },
1223
1388
  filterExceptionId: null,
1389
+ ownerFilterAutoAdmitted: null,
1224
1390
  };
1225
1391
  const selection = applyVisibleSessionSelection(state, nextSessions);
1226
1392
  return {
@@ -1719,7 +1885,7 @@ export function appReducer(state, action) {
1719
1885
  ...state.sessions,
1720
1886
  activeSessionId: action.sessionId,
1721
1887
  ...(releasesNavigationLatch
1722
- ? { navigationIntent: null, filterExceptionId: null }
1888
+ ? { navigationIntent: null, filterExceptionId: null, ownerFilterAutoAdmitted: null }
1723
1889
  : {}),
1724
1890
  },
1725
1891
  ui: {