pilotswarm 0.5.55 → 0.5.57

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 (71) hide show
  1. package/package.json +2 -2
  2. package/ui/core/src/reducer.js +177 -11
  3. package/ui/core/src/selectors.js +75 -9
  4. package/ui/core/src/state.js +65 -0
  5. package/ui/react/src/web-app.js +89 -8
  6. package/web/dist/assets/{abnfDiagram-VRR7QNED-Cccg5mUx.js → abnfDiagram-VRR7QNED-CJOR2hPi.js} +1 -1
  7. package/web/dist/assets/{arc-DFWp1OiI.js → arc-Yh3kc_KB.js} +1 -1
  8. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-o2B4-sJ1.js → architectureDiagram-ZJ3FMSHR-DLRlUeyk.js} +1 -1
  9. package/web/dist/assets/{blockDiagram-677ZJIJ3-Cf_7Cm38.js → blockDiagram-677ZJIJ3-x_TGH_fp.js} +1 -1
  10. package/web/dist/assets/{c4Diagram-LMCZKHZV-CKXuPB0d.js → c4Diagram-LMCZKHZV-C-j_bxeF.js} +1 -1
  11. package/web/dist/assets/channel-BIn92AzB.js +1 -0
  12. package/web/dist/assets/{chunk-2Q5K7J3B-CZC3HwzC.js → chunk-2Q5K7J3B-CaI5Xqwo.js} +1 -1
  13. package/web/dist/assets/{chunk-32BRIVSS-7_X1k3eB.js → chunk-32BRIVSS-Bh_Crac6.js} +1 -1
  14. package/web/dist/assets/{chunk-5VM5RSS4-E2rxT4um.js → chunk-5VM5RSS4-XM-3ajKG.js} +1 -1
  15. package/web/dist/assets/{chunk-EX3LRPZG-BbW37HWx.js → chunk-EX3LRPZG-ZwkulfFp.js} +1 -1
  16. package/web/dist/assets/{chunk-JWPE2WC7-dj0DOnPl.js → chunk-JWPE2WC7-CT3Q7T2A.js} +1 -1
  17. package/web/dist/assets/{chunk-MOJQB5TN-TeWalpvK.js → chunk-MOJQB5TN-_Uc0ksg3.js} +1 -1
  18. package/web/dist/assets/{chunk-RYQCIY6F-ClHcH2bN.js → chunk-RYQCIY6F-DosoId7u.js} +1 -1
  19. package/web/dist/assets/{chunk-V7JOEXUC-Dy5XEnNL.js → chunk-V7JOEXUC-KRjq1cwM.js} +1 -1
  20. package/web/dist/assets/{chunk-VR4S4FIN-Den_q-0P.js → chunk-VR4S4FIN-B6Ul1z3S.js} +1 -1
  21. package/web/dist/assets/{chunk-XXDRQBXY-DZ1s-8xB.js → chunk-XXDRQBXY-AikdVXm4.js} +1 -1
  22. package/web/dist/assets/classDiagram-OUVF2IWQ-B7ktW6XM.js +1 -0
  23. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B7ktW6XM.js +1 -0
  24. package/web/dist/assets/{cose-bilkent-JH36ORCC-CKAukUTf.js → cose-bilkent-JH36ORCC-DTYwgEAa.js} +1 -1
  25. package/web/dist/assets/{cynefin-VYW2F7L2-CM8y129F.js → cynefin-VYW2F7L2-C_8v2wIz.js} +1 -1
  26. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-DLryvwYt.js → cynefinDiagram-TSTJHNR4-1-nw1zbQ.js} +1 -1
  27. package/web/dist/assets/{dagre-VKFMJZFB-BO0LMWmO.js → dagre-VKFMJZFB-CAjwRpvT.js} +1 -1
  28. package/web/dist/assets/{diagram-FQU43EPY-DiFLu7v2.js → diagram-FQU43EPY-Bg41nQw_.js} +1 -1
  29. package/web/dist/assets/{diagram-G47NLZAW-DMkq9we0.js → diagram-G47NLZAW-Cp6P-qMn.js} +1 -1
  30. package/web/dist/assets/{diagram-NH7WQ7WH-CxVQL8Dq.js → diagram-NH7WQ7WH-DNdFuedg.js} +1 -1
  31. package/web/dist/assets/{diagram-OA4YK3LP-B5TrTlUc.js → diagram-OA4YK3LP-DcHpELG2.js} +1 -1
  32. package/web/dist/assets/{diagram-WEI45ONY-Ca38mf1v.js → diagram-WEI45ONY-B2_kyMw3.js} +1 -1
  33. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DZR_44LD.js → ebnfDiagram-CCIWWBDH-DztuAo3o.js} +1 -1
  34. package/web/dist/assets/{erDiagram-Q63AITRT-Ca8mmsU1.js → erDiagram-Q63AITRT-DNjKTOzp.js} +1 -1
  35. package/web/dist/assets/{flowDiagram-23GEKE2U-C0DFTCS6.js → flowDiagram-23GEKE2U-DWjfeUM1.js} +1 -1
  36. package/web/dist/assets/{ganttDiagram-NO4QXBWP-COqaqM45.js → ganttDiagram-NO4QXBWP-Y0MgGVmy.js} +1 -1
  37. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Cwb9oGjZ.js → gitGraphDiagram-IHSO6WYX--lDKAKVz.js} +1 -1
  38. package/web/dist/assets/{index-DCx4DEMM.css → index-BxPfp0AO.css} +1 -1
  39. package/web/dist/assets/{index-B91BmvYM.js → index-rPji8Uud.js} +2 -2
  40. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CdPnS1-j.js → infoDiagram-FWYZ7A6U-CKmH8_YO.js} +1 -1
  41. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-D4Muo2uw.js → ishikawaDiagram-FXEZZL3T-D88R7zUc.js} +1 -1
  42. package/web/dist/assets/{journeyDiagram-5HDEW3XC-0uVdJDiB.js → journeyDiagram-5HDEW3XC-Ck7EMrsY.js} +1 -1
  43. package/web/dist/assets/{kanban-definition-HUTT4EX6-DWQU6DYI.js → kanban-definition-HUTT4EX6-hc-mOfSG.js} +1 -1
  44. package/web/dist/assets/{linear-BOxgX8xV.js → linear-BVhU4csP.js} +1 -1
  45. package/web/dist/assets/{mermaid.core-C8tHn40J.js → mermaid.core-C9u4FOCX.js} +4 -4
  46. package/web/dist/assets/{mindmap-definition-LN4V7U3C-C8NH0hQq.js → mindmap-definition-LN4V7U3C-DatQu9Xp.js} +1 -1
  47. package/web/dist/assets/{papaparse.min-CwI0VkgN.js → papaparse.min-BdnoXc8M.js} +1 -1
  48. package/web/dist/assets/{pegDiagram-2B236MQR-CksEEUgN.js → pegDiagram-2B236MQR-Cr_nZ_jR.js} +1 -1
  49. package/web/dist/assets/{pieDiagram-ENE6RG2P-Bq2Bv0cw.js → pieDiagram-ENE6RG2P-CcAbSIcY.js} +1 -1
  50. package/web/dist/assets/pilotswarm--kvfB_6q.js +99 -0
  51. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-vvpwUrN5.js → quadrantDiagram-ABIIQ3AL-CgJw5laM.js} +1 -1
  52. package/web/dist/assets/{railroadDiagram-RFXS5EU6-yXGWn-zZ.js → railroadDiagram-RFXS5EU6-CHeun9SN.js} +1 -1
  53. package/web/dist/assets/{requirementDiagram-TGXJPOKE-Bt1Q6Y60.js → requirementDiagram-TGXJPOKE-D1kpDj8W.js} +1 -1
  54. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-CPNAfqgE.js → sankeyDiagram-HTMAVEWB-C5vUSUaX.js} +1 -1
  55. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-Dz432FWB.js → sequenceDiagram-DBY2YBRQ-T1aPSxk1.js} +1 -1
  56. package/web/dist/assets/{sizeCapture-X5ZJPWSS-Cokz6gNF.js → sizeCapture-X5ZJPWSS-j-wh2LVX.js} +1 -1
  57. package/web/dist/assets/{stateDiagram-2N3HPSRC-ojQCUdRE.js → stateDiagram-2N3HPSRC-DfUZZR8N.js} +1 -1
  58. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-ilKtTdHG.js +1 -0
  59. package/web/dist/assets/{swimlanes-5IMT3BWC-DyTOkc1R.js → swimlanes-5IMT3BWC-OpASJiRw.js} +2 -2
  60. package/web/dist/assets/swimlanesDiagram-G3AALYLV-C1PthnB4.js +8 -0
  61. package/web/dist/assets/{timeline-definition-FHXFAJF6-DmuhBBl6.js → timeline-definition-FHXFAJF6-BBnc_z1U.js} +1 -1
  62. package/web/dist/assets/{vennDiagram-L72KCM5P-gTxQuU5b.js → vennDiagram-L72KCM5P-BlPQe9p4.js} +1 -1
  63. package/web/dist/assets/{wardleyDiagram-EHGQE667-YNXbSRf2.js → wardleyDiagram-EHGQE667-DBcBFUx2.js} +1 -1
  64. package/web/dist/assets/{xychartDiagram-FW5EYKEG-D7loT9cv.js → xychartDiagram-FW5EYKEG-BQUSGL6g.js} +1 -1
  65. package/web/dist/index.html +3 -3
  66. package/web/dist/assets/channel-CIBN5O06.js +0 -1
  67. package/web/dist/assets/classDiagram-OUVF2IWQ-Bq-5VkDc.js +0 -1
  68. package/web/dist/assets/classDiagram-v2-EOCWNBFH-Bq-5VkDc.js +0 -1
  69. package/web/dist/assets/pilotswarm-uebRfMjg.js +0 -99
  70. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BliVrz4U.js +0 -1
  71. 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.55",
3
+ "version": "0.5.57",
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.55",
84
+ "pilotswarm-sdk": "0.5.57",
85
85
  "react": "^19.2.4",
86
86
  "react-dom": "^19.2.4",
87
87
  "ws": "^8.18.2"
@@ -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: {
@@ -383,13 +383,13 @@ function sessionStatusIcon(session, mode = "local") {
383
383
  }
384
384
  }
385
385
 
386
- function ownerKeyForOwner(owner) {
386
+ export function ownerKeyForOwner(owner) {
387
387
  const provider = String(owner?.provider || "").trim();
388
388
  const subject = String(owner?.subject || "").trim();
389
389
  return provider && subject ? `${provider}\u0001${subject}` : null;
390
390
  }
391
391
 
392
- function ownerDisplayName(owner, fallback = "unknown user") {
392
+ export function ownerDisplayName(owner, fallback = "unknown user") {
393
393
  return String(owner?.displayName || owner?.email || "").trim() || fallback;
394
394
  }
395
395
 
@@ -398,7 +398,7 @@ function ownerDisplayName(owner, fallback = "unknown user") {
398
398
  // deletable) — but they belong to the same "System" bucket as the real system
399
399
  // agents, so the single System filter entry covers both. Computed via the same
400
400
  // join helper so it stays byte-identical to real owner keys.
401
- const SYSTEM_OWNER_KEY = ownerKeyForOwner({ provider: "system", subject: "system" });
401
+ export const SYSTEM_OWNER_KEY = ownerKeyForOwner({ provider: "system", subject: "system" });
402
402
 
403
403
  function initialsFromText(value) {
404
404
  const text = String(value || "").trim();
@@ -1457,6 +1457,17 @@ export function selectNavigationError(state) {
1457
1457
  * linked session is being shown despite the current filters excluding it.
1458
1458
  */
1459
1459
  export function selectSessionFilterExceptionNotice(state) {
1460
+ // A linked session whose owner was added to the filter so it is listed.
1461
+ // Durable (the filter itself changed and persists); the notice is what
1462
+ // is transient, cleared on the next manual navigation or filter change.
1463
+ const admitted = state.sessions?.ownerFilterAutoAdmitted || null;
1464
+ const stillAdmitted = admitted?.ownerKey
1465
+ && Array.isArray(state.sessions?.ownerFilter?.ownerKeys)
1466
+ && state.sessions.ownerFilter.ownerKeys.includes(admitted.ownerKey);
1467
+ if (admitted?.sessionId && stillAdmitted && state.sessions?.byId?.[admitted.sessionId]) {
1468
+ const owner = state.sessions.byId[admitted.sessionId]?.owner;
1469
+ return `Added ${ownerDisplayName(owner, "its owner")} to your session filters so the linked session is listed.`;
1470
+ }
1460
1471
  const exceptionId = state.sessions?.filterExceptionId || null;
1461
1472
  if (!exceptionId || !state.sessions?.byId?.[exceptionId]) return null;
1462
1473
  return "Showing linked session outside your current filters.";
@@ -1471,7 +1482,26 @@ export function canStopSessionTurn(session) {
1471
1482
  return (session.status || "") === "running";
1472
1483
  }
1473
1484
 
1474
- function buildPendingQuestionMessage(session) {
1485
+ // The moment an event of one of these types was recorded, in ms, or null.
1486
+ // Product cards (Question, Warning) take their timestamp from the event that
1487
+ // raised them. They used to stamp session.updatedAt, which moves on EVERY
1488
+ // poll and status tick — so the card's clock jumped forward with each update
1489
+ // and read as flicker.
1490
+ function latestEventCreatedAtMs(events = [], eventTypes = []) {
1491
+ const wanted = new Set(eventTypes);
1492
+ for (let index = (events || []).length - 1; index >= 0; index -= 1) {
1493
+ const event = events[index];
1494
+ if (!event || !wanted.has(event.eventType)) continue;
1495
+ const createdAt = event.createdAt;
1496
+ const ms = createdAt instanceof Date
1497
+ ? createdAt.getTime()
1498
+ : typeof createdAt === "number" ? createdAt : new Date(createdAt || 0).getTime();
1499
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
1500
+ }
1501
+ return null;
1502
+ }
1503
+
1504
+ function buildPendingQuestionMessage(session, events = []) {
1475
1505
  const pendingQuestion = session?.pendingQuestion;
1476
1506
  if (!pendingQuestion?.question) return null;
1477
1507
 
@@ -1500,7 +1530,7 @@ function buildPendingQuestionMessage(session) {
1500
1530
  role: "system",
1501
1531
  text: body.join("\n"),
1502
1532
  time: "",
1503
- createdAt: session.updatedAt || Date.now(),
1533
+ createdAt: latestEventCreatedAtMs(events, ["session.input_required_started"]),
1504
1534
  cardTitle: "Question",
1505
1535
  cardTitleColor: "cyan",
1506
1536
  cardBorderColor: "cyan",
@@ -1558,7 +1588,7 @@ function chatAlreadyContainsPendingQuestion(chat, question) {
1558
1588
  });
1559
1589
  }
1560
1590
 
1561
- function buildSessionErrorMessage(session) {
1591
+ function buildSessionErrorMessage(session, events = []) {
1562
1592
  const errorText = String(session?.error || "").trim();
1563
1593
  if (!errorText) return null;
1564
1594
 
@@ -1575,7 +1605,10 @@ function buildSessionErrorMessage(session) {
1575
1605
  role: "system",
1576
1606
  text: body,
1577
1607
  time: "",
1578
- createdAt: session.updatedAt || Date.now(),
1608
+ // The error event when one was recorded, else the end of the turn
1609
+ // that produced the error; never the session's rolling updatedAt.
1610
+ createdAt: latestEventCreatedAtMs(events, ["session.error"])
1611
+ ?? latestEventCreatedAtMs(events, ["session.turn_completed", "assistant.turn_end"]),
1579
1612
  cardTitle: isFailed ? "Error" : "Warning",
1580
1613
  cardTitleColor: isFailed ? "red" : "yellow",
1581
1614
  cardBorderColor: isFailed ? "red" : "yellow",
@@ -1801,12 +1834,13 @@ export function selectActiveChat(state) {
1801
1834
  }
1802
1835
  const history = state.history.bySessionId.get(sessionId);
1803
1836
  const chat = history?.chat || [];
1837
+ const events = history?.events || [];
1804
1838
  const pendingQuestionMessage = session?.pendingQuestion?.question
1805
1839
  && !chatAlreadyContainsPendingQuestion(chat, session.pendingQuestion.question)
1806
- ? buildPendingQuestionMessage(session)
1840
+ ? buildPendingQuestionMessage(session, events)
1807
1841
  : null;
1808
1842
  const answeredQuestionMessage = buildAnsweredPendingQuestionMessage(session, chat);
1809
- const sessionErrorMessage = buildSessionErrorMessage(session);
1843
+ const sessionErrorMessage = buildSessionErrorMessage(session, events);
1810
1844
 
1811
1845
  if ((!history || chat.length === 0) && !pendingQuestionMessage && !answeredQuestionMessage && !sessionErrorMessage) {
1812
1846
  return createSplashCard(state.branding, session);
@@ -2949,7 +2983,39 @@ export function sessionCanvasMark(state, sessionId) {
2949
2983
  return mark;
2950
2984
  }
2951
2985
 
2986
+ // Memoized on its five inputs. The view carries a fresh `slots` array, so
2987
+ // without this every shallow-equal subscriber (the app root, the toolbar)
2988
+ // saw a "change" on EVERY dispatch — a keystroke, a poll, a live event —
2989
+ // and re-rendered the whole tree under it. Measured on a phone viewport
2990
+ // with a 600-turn transcript: ~10 component renders per keystroke, down to
2991
+ // the composer alone once the identity held.
2992
+ let canvasViewCache = null;
2952
2993
  export function selectCanvasView(state) {
2994
+ const sessionId = state?.sessions?.activeSessionId || null;
2995
+ const bySessionId = state?.canvas?.bySessionId;
2996
+ const prefs = state?.canvas?.prefs;
2997
+ const canvasSlot = state?.ui?.canvasSlot;
2998
+ const canvasOpen = state?.ui?.canvasOpen;
2999
+ // flipSeq is the agent-driven "open the canvas now" tick. It changes
3000
+ // without any other key moving (a redraw of the already-open slot), and
3001
+ // the phone opens its canvas tab off it — so it MUST be a cache key.
3002
+ const flipSeq = state?.canvas?.flipSeq;
3003
+ const cached = canvasViewCache;
3004
+ if (cached
3005
+ && cached.sessionId === sessionId
3006
+ && cached.bySessionId === bySessionId
3007
+ && cached.prefs === prefs
3008
+ && cached.canvasSlot === canvasSlot
3009
+ && cached.canvasOpen === canvasOpen
3010
+ && cached.flipSeq === flipSeq) {
3011
+ return cached.result;
3012
+ }
3013
+ const result = computeCanvasView(state);
3014
+ canvasViewCache = { sessionId, bySessionId, prefs, canvasSlot, canvasOpen, flipSeq, result };
3015
+ return result;
3016
+ }
3017
+
3018
+ function computeCanvasView(state) {
2953
3019
  const sessionId = state?.sessions?.activeSessionId || null;
2954
3020
  // The pane shows ONE slot at a time. An out-of-range or never-drawn slot
2955
3021
  // falls back to 1 so a stale selection can never strand the pane.
@@ -169,6 +169,62 @@ export function normalizeStoredRightPaneMode(value) {
169
169
  * "panes" -> both off. "panes" was the default value rather than a choice,
170
170
  * and the new default is a clean two-column workspace.
171
171
  */
172
+ // ── Per-session desktop views ─────────────────────────────────────────────
173
+ // How a person set up the workspace for ONE session: which optional columns
174
+ // are open and how the right side is sized. Keyed by session id, then by
175
+ // device slot ("desktop" today; a "mobile" slot is reserved, not written).
176
+ // Absent = the default workspace: sessions and chat, no other pane.
177
+ export const SESSION_VIEW_LAYOUT_KEYS = Object.freeze([
178
+ "paneAdjust", "activityPaneAdjust", "canvasPaneAdjust", "diagnosticsPaneAdjust", "diagnosticsSplitAdjust",
179
+ ]);
180
+ export const SESSION_VIEW_DEVICES = Object.freeze(["desktop", "mobile"]);
181
+ // Bound on the profile document: the newest entries win.
182
+ export const SESSION_VIEWS_MAX = 300;
183
+
184
+ export function defaultSessionView() {
185
+ const layout = {};
186
+ for (const key of SESSION_VIEW_LAYOUT_KEYS) layout[key] = 0;
187
+ return { canvasOpen: false, diagnosticsOpen: false, zen: false, layout, at: 0 };
188
+ }
189
+
190
+ function normalizeStoredSessionView(value) {
191
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
192
+ const view = defaultSessionView();
193
+ view.canvasOpen = value.canvasOpen === true;
194
+ view.diagnosticsOpen = value.diagnosticsOpen === true;
195
+ view.zen = view.canvasOpen && value.zen === true;
196
+ const layout = value.layout && typeof value.layout === "object" ? value.layout : {};
197
+ for (const key of SESSION_VIEW_LAYOUT_KEYS) {
198
+ const n = Number(layout[key]);
199
+ view.layout[key] = Number.isFinite(n) ? Math.trunc(n) : 0;
200
+ }
201
+ const at = Number(value.at);
202
+ view.at = Number.isFinite(at) && at > 0 ? Math.trunc(at) : 0;
203
+ return view;
204
+ }
205
+
206
+ export function normalizeStoredSessionViews(value) {
207
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
208
+ const entries = [];
209
+ for (const [sessionId, slots] of Object.entries(value)) {
210
+ const id = String(sessionId || "").trim();
211
+ if (!id || !slots || typeof slots !== "object" || Array.isArray(slots)) continue;
212
+ const entry = {};
213
+ let newest = 0;
214
+ for (const device of SESSION_VIEW_DEVICES) {
215
+ const view = normalizeStoredSessionView(slots[device]);
216
+ if (!view) continue;
217
+ entry[device] = view;
218
+ newest = Math.max(newest, view.at);
219
+ }
220
+ if (Object.keys(entry).length > 0) entries.push([id, entry, newest]);
221
+ }
222
+ entries.sort((a, b) => b[2] - a[2]);
223
+ const out = {};
224
+ for (const [id, entry] of entries.slice(0, SESSION_VIEWS_MAX)) out[id] = entry;
225
+ return out;
226
+ }
227
+
172
228
  export function normalizeStoredDesktopPanes(value, legacyRightPaneMode) {
173
229
  const candidate = value && typeof value === "object" ? value : null;
174
230
  if (candidate && (typeof candidate.canvasOpen === "boolean" || typeof candidate.diagnosticsOpen === "boolean")) {
@@ -345,6 +401,14 @@ export function createInitialState({ mode = "local", branding = null, docs = nul
345
401
  // a "look at this now" gesture, and returning to a portal with no
346
402
  // chat and no session list would read as a broken app.
347
403
  canvasMaximized: false,
404
+ // Per-session desktop views (see normalizeStoredSessionViews).
405
+ // Applied when the active session changes, recorded when the
406
+ // columns or the right-side sizes change — desktop only, and only
407
+ // once the profile has been read (so a fresh tab does not record
408
+ // the defaults over a stored view before it has seen it).
409
+ sessionViews: {},
410
+ sessionViewsLoaded: false,
411
+ sessionViewDevice: null,
348
412
  statsViewMode: "session",
349
413
  prompt: "",
350
414
  promptCursor: 0,
@@ -430,6 +494,7 @@ export function createInitialState({ mode = "local", branding = null, docs = nul
430
494
  ownerFilter: normalizeSessionOwnerFilter(sessionOwnerFilter),
431
495
  navigationIntent: null,
432
496
  filterExceptionId: null,
497
+ ownerFilterAutoAdmitted: null,
433
498
  },
434
499
  history: {
435
500
  bySessionId: new Map(),