pilotswarm 0.5.65 → 0.5.67

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 (74) hide show
  1. package/package.json +2 -2
  2. package/ui/core/src/controller.js +47 -24
  3. package/ui/core/src/index.js +1 -0
  4. package/ui/core/src/reducer.js +20 -5
  5. package/ui/core/src/selectors.js +154 -9
  6. package/ui/core/src/session-search.js +151 -0
  7. package/ui/react/src/web-app.js +114 -7
  8. package/web/dist/assets/{abnfDiagram-VRR7QNED-gIPHF2gV.js → abnfDiagram-VRR7QNED-rCdnr4Zk.js} +1 -1
  9. package/web/dist/assets/{arc-DMW_q5Ml.js → arc-DeoxxJ2S.js} +1 -1
  10. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DAKsrxcB.js → architectureDiagram-ZJ3FMSHR-DAz2X6IV.js} +1 -1
  11. package/web/dist/assets/{blockDiagram-677ZJIJ3-BUjSLpP3.js → blockDiagram-677ZJIJ3-DPsxUKQJ.js} +1 -1
  12. package/web/dist/assets/{c4Diagram-LMCZKHZV-Dw2nPoes.js → c4Diagram-LMCZKHZV-Cl8IpoAZ.js} +1 -1
  13. package/web/dist/assets/channel-DCTjhzIq.js +1 -0
  14. package/web/dist/assets/{chunk-2Q5K7J3B-BAL28Rv0.js → chunk-2Q5K7J3B-WHxPHUqg.js} +1 -1
  15. package/web/dist/assets/{chunk-32BRIVSS-DDlt9K8s.js → chunk-32BRIVSS-9S1suaZM.js} +1 -1
  16. package/web/dist/assets/{chunk-5VM5RSS4-BLQGJGoP.js → chunk-5VM5RSS4-DmBwY4rK.js} +1 -1
  17. package/web/dist/assets/{chunk-EX3LRPZG-XFMb3HRy.js → chunk-EX3LRPZG-BbKMT5Il.js} +1 -1
  18. package/web/dist/assets/{chunk-JWPE2WC7-DWnKTQ72.js → chunk-JWPE2WC7-B9Qns4SI.js} +1 -1
  19. package/web/dist/assets/{chunk-MOJQB5TN-MltzhnwP.js → chunk-MOJQB5TN-G6hkDffb.js} +1 -1
  20. package/web/dist/assets/{chunk-RYQCIY6F-Ce3q0-mJ.js → chunk-RYQCIY6F-Ca38LebY.js} +1 -1
  21. package/web/dist/assets/{chunk-V7JOEXUC-ThXLYY88.js → chunk-V7JOEXUC-DPeEQDsB.js} +1 -1
  22. package/web/dist/assets/{chunk-VR4S4FIN-DbhAykiW.js → chunk-VR4S4FIN-DYuh7YGE.js} +1 -1
  23. package/web/dist/assets/{chunk-XXDRQBXY-BpkNB0mD.js → chunk-XXDRQBXY-BDRBkhU_.js} +1 -1
  24. package/web/dist/assets/classDiagram-OUVF2IWQ-Cfa6ZtJR.js +1 -0
  25. package/web/dist/assets/classDiagram-v2-EOCWNBFH-Cfa6ZtJR.js +1 -0
  26. package/web/dist/assets/{cose-bilkent-JH36ORCC-rctqAPvj.js → cose-bilkent-JH36ORCC-CpHdEFyS.js} +1 -1
  27. package/web/dist/assets/{cynefin-VYW2F7L2-Dg1cltg6.js → cynefin-VYW2F7L2-DRYxiNM0.js} +1 -1
  28. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-Ci23Sv4Z.js → cynefinDiagram-TSTJHNR4-B8fSzWxH.js} +1 -1
  29. package/web/dist/assets/{dagre-VKFMJZFB-BpWTGhpv.js → dagre-VKFMJZFB-CnJyU6YF.js} +1 -1
  30. package/web/dist/assets/{diagram-FQU43EPY-B69JpiAe.js → diagram-FQU43EPY-BS2_uGW0.js} +1 -1
  31. package/web/dist/assets/{diagram-G47NLZAW-BHMk1TOO.js → diagram-G47NLZAW-DjKog-bE.js} +1 -1
  32. package/web/dist/assets/{diagram-NH7WQ7WH-CtvZIIsk.js → diagram-NH7WQ7WH-BaWtvMDL.js} +1 -1
  33. package/web/dist/assets/{diagram-OA4YK3LP-BFvKh800.js → diagram-OA4YK3LP-Z11ytyEX.js} +1 -1
  34. package/web/dist/assets/{diagram-WEI45ONY-CgB8sWPe.js → diagram-WEI45ONY-DiiH4Od8.js} +1 -1
  35. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DyqPNydM.js → ebnfDiagram-CCIWWBDH-xtE5CNkB.js} +1 -1
  36. package/web/dist/assets/{erDiagram-Q63AITRT-CwpvZQ3Q.js → erDiagram-Q63AITRT-Dj5wjLbd.js} +1 -1
  37. package/web/dist/assets/{flowDiagram-23GEKE2U-EBKSKxfC.js → flowDiagram-23GEKE2U-O_1XH_-x.js} +1 -1
  38. package/web/dist/assets/{ganttDiagram-NO4QXBWP-q5tMPAuC.js → ganttDiagram-NO4QXBWP-AuETTA_E.js} +1 -1
  39. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-DCRbi4KE.js → gitGraphDiagram-IHSO6WYX-gTS7mo_B.js} +1 -1
  40. package/web/dist/assets/{index-CO-D6M4t.js → index-29eY0ERe.js} +2 -2
  41. package/web/dist/assets/index-D4OiytLj.css +1 -0
  42. package/web/dist/assets/{infoDiagram-FWYZ7A6U-Bex5pSIX.js → infoDiagram-FWYZ7A6U-Fhv6oMjM.js} +1 -1
  43. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-Dj0li_TY.js → ishikawaDiagram-FXEZZL3T-BIWy0CgN.js} +1 -1
  44. package/web/dist/assets/{journeyDiagram-5HDEW3XC-BtEGEeVr.js → journeyDiagram-5HDEW3XC-0XQ9xC-A.js} +1 -1
  45. package/web/dist/assets/{kanban-definition-HUTT4EX6-BTFfZvYt.js → kanban-definition-HUTT4EX6-ChyhUUfr.js} +1 -1
  46. package/web/dist/assets/{linear-C3SQCsip.js → linear-CFXBfAtq.js} +1 -1
  47. package/web/dist/assets/{mermaid.core-DALDJDu8.js → mermaid.core-BPXpLhrQ.js} +4 -4
  48. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BZf3uw0u.js → mindmap-definition-LN4V7U3C-HEv8CBmG.js} +1 -1
  49. package/web/dist/assets/{papaparse.min-Bh9MZ-fk.js → papaparse.min-zNHCLHbr.js} +1 -1
  50. package/web/dist/assets/{pegDiagram-2B236MQR-PuOleiMg.js → pegDiagram-2B236MQR-Ciaic9kL.js} +1 -1
  51. package/web/dist/assets/{pieDiagram-ENE6RG2P-CcFORUaW.js → pieDiagram-ENE6RG2P-CPBwQT7c.js} +1 -1
  52. package/web/dist/assets/pilotswarm-t7ZSHp3x.js +109 -0
  53. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CEm0mp4y.js → quadrantDiagram-ABIIQ3AL-Bn1GbLE3.js} +1 -1
  54. package/web/dist/assets/{railroadDiagram-RFXS5EU6-DeH-uX0_.js → railroadDiagram-RFXS5EU6-DgbTuh6h.js} +1 -1
  55. package/web/dist/assets/{requirementDiagram-TGXJPOKE-CMbJVMc1.js → requirementDiagram-TGXJPOKE-C25kl_pE.js} +1 -1
  56. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-C9gL5w_b.js → sankeyDiagram-HTMAVEWB-IXSFhGpB.js} +1 -1
  57. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-BUo8TT74.js → sequenceDiagram-DBY2YBRQ-BkLHDo50.js} +1 -1
  58. package/web/dist/assets/{sizeCapture-X5ZJPWSS-C5lqG0ei.js → sizeCapture-X5ZJPWSS-DYnL5G7X.js} +1 -1
  59. package/web/dist/assets/{stateDiagram-2N3HPSRC-DyeISrEU.js → stateDiagram-2N3HPSRC-Cyn7hAE7.js} +1 -1
  60. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-CrXU-8_S.js +1 -0
  61. package/web/dist/assets/{swimlanes-5IMT3BWC-DawuBk_a.js → swimlanes-5IMT3BWC-CZ6EgHci.js} +2 -2
  62. package/web/dist/assets/swimlanesDiagram-G3AALYLV-QcV2Vg4L.js +8 -0
  63. package/web/dist/assets/{timeline-definition-FHXFAJF6-BZJ2YJcm.js → timeline-definition-FHXFAJF6-uvqzr_O1.js} +1 -1
  64. package/web/dist/assets/{vennDiagram-L72KCM5P-BMXBVCfe.js → vennDiagram-L72KCM5P-1ca2fSuA.js} +1 -1
  65. package/web/dist/assets/{wardleyDiagram-EHGQE667-DWedKsrQ.js → wardleyDiagram-EHGQE667-DLDKHVSh.js} +1 -1
  66. package/web/dist/assets/{xychartDiagram-FW5EYKEG--XdfYceC.js → xychartDiagram-FW5EYKEG-BcU1gt-_.js} +1 -1
  67. package/web/dist/index.html +3 -3
  68. package/web/dist/assets/channel-CcOa1LtY.js +0 -1
  69. package/web/dist/assets/classDiagram-OUVF2IWQ-B2VhHRSV.js +0 -1
  70. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B2VhHRSV.js +0 -1
  71. package/web/dist/assets/index-uhc851H3.css +0 -1
  72. package/web/dist/assets/pilotswarm-B9rLtXpI.js +0 -109
  73. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-5FZbaloZ.js +0 -1
  74. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DL8PgtmM.js +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.65",
3
+ "version": "0.5.67",
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.65",
84
+ "pilotswarm-sdk": "0.5.67",
85
85
  "react": "^19.2.4",
86
86
  "react-dom": "^19.2.4",
87
87
  "ws": "^8.18.2"
@@ -2530,13 +2530,16 @@ export class PilotSwarmUiController {
2530
2530
  }
2531
2531
  await this.refreshSessions();
2532
2532
  this.catalogTimer = setInterval(() => {
2533
+ // Do not queue more catalog traffic behind a slow periodic read.
2534
+ if (this.catalogPollInFlight) return;
2535
+ this.catalogPollInFlight = true;
2533
2536
  this.refreshSessions().catch((error) => {
2534
2537
  this.dispatch({
2535
2538
  type: "connection/error",
2536
2539
  error: error?.message || String(error),
2537
2540
  statusText: SESSION_REFRESH_FAILED_STATUS,
2538
2541
  });
2539
- });
2542
+ }).finally(() => { this.catalogPollInFlight = false; });
2540
2543
  }, 4000);
2541
2544
  }
2542
2545
 
@@ -2628,6 +2631,16 @@ export class PilotSwarmUiController {
2628
2631
  const recoveringConnection = !preRefreshState.connection.connected || Boolean(preRefreshState.connection.error);
2629
2632
  const shouldClearRefreshFailureBanner = preRefreshState.ui.statusText === SESSION_REFRESH_FAILED_STATUS;
2630
2633
  const previousActive = this.getState().sessions.activeSessionId;
2634
+ const previousNavigationGeneration = this.navigationGeneration;
2635
+ // The folder catalog is independent of the session pages: overlap its RTT.
2636
+ const groupRowsPromise = typeof this.transport.listSessionGroups === "function"
2637
+ ? Promise.resolve().then(() => this.transport.listSessionGroups())
2638
+ .then(groups => (Array.isArray(groups) ? groups : []).map(sessionGroupToRow).filter(Boolean))
2639
+ .catch(error => {
2640
+ console.warn(`[PilotSwarmUi] session-group fetch failed, keeping the known folders: ${error?.message || error}`);
2641
+ return null;
2642
+ })
2643
+ : Promise.resolve(null);
2631
2644
  let sessions = (await loadSessionCatalogPageWindow(this.transport)).map(normalizeSessionListRow);
2632
2645
  // Folders are NOT merged into the session payload any more: they live
2633
2646
  // in their own state slice, so a session refresh cannot drop them. A
@@ -2641,15 +2654,7 @@ export class PilotSwarmUiController {
2641
2654
  // state alone is judge which folders are still claimed, since the store
2642
2655
  // still holds the PREVIOUS membership at this point; the incoming rows
2643
2656
  // ride along on the action for exactly that.
2644
- let pendingGroupRows = null;
2645
- if (typeof this.transport.listSessionGroups === "function") {
2646
- try {
2647
- const groups = await this.transport.listSessionGroups();
2648
- pendingGroupRows = (Array.isArray(groups) ? groups : []).map(sessionGroupToRow).filter(Boolean);
2649
- } catch (error) {
2650
- console.warn(`[PilotSwarmUi] session-group fetch failed, keeping the known folders: ${error?.message || error}`);
2651
- }
2652
- }
2657
+ const pendingGroupRows = await groupRowsPromise;
2653
2658
  // A pending deep-link target may be readable but absent from the
2654
2659
  // paged catalog window — fetch it explicitly. A definitive failure
2655
2660
  // (unknown or unshared: identical 404 shapes) fails the intent; the
@@ -2753,6 +2758,8 @@ export class PilotSwarmUiController {
2753
2758
  if (!existingHistory?.events?.length) {
2754
2759
  await this.ensureSessionHistory(selected, { force: true }).catch(() => {});
2755
2760
  }
2761
+ if (this.getState().sessions.activeSessionId !== selected
2762
+ || this.navigationGeneration !== previousNavigationGeneration) return;
2756
2763
  // Boot-restore can hydrate the selection through THIS branch
2757
2764
  // instead of loadSession (whether the profile poll beats the first
2758
2765
  // sessions refresh is a race), so the canvas snapshot must be
@@ -2954,7 +2961,27 @@ export class PilotSwarmUiController {
2954
2961
  return history;
2955
2962
  }
2956
2963
 
2957
- async ensureSessionHistory(sessionId, { force = false } = {}) {
2964
+ async ensureSessionHistory(sessionId, options = {}) {
2965
+ if (!sessionId) return null;
2966
+ if (this.sessionHistoryLoads.has(sessionId)) return this.sessionHistoryLoads.get(sessionId);
2967
+ const existing = this.getState().history.bySessionId.get(sessionId);
2968
+ if (!options.force && existing?.events && existing?.loadState !== "failed") return existing;
2969
+ this.dispatch({ type: "history/loadState", sessionId, loadState: "loading" });
2970
+ const pending = this.loadSessionHistory(sessionId, { ...options, force: true });
2971
+ this.sessionHistoryLoads.set(sessionId, pending);
2972
+ try {
2973
+ const result = await pending;
2974
+ this.dispatch({ type: "history/loadState", sessionId, loadState: "loaded" });
2975
+ return result;
2976
+ } catch (error) {
2977
+ this.dispatch({ type: "history/loadState", sessionId, loadState: "failed" });
2978
+ throw error;
2979
+ } finally {
2980
+ if (this.sessionHistoryLoads.get(sessionId) === pending) this.sessionHistoryLoads.delete(sessionId);
2981
+ }
2982
+ }
2983
+
2984
+ async loadSessionHistory(sessionId, { force = false } = {}) {
2958
2985
  if (!sessionId) return null;
2959
2986
  const existingHistory = this.getState().history.bySessionId.get(sessionId);
2960
2987
  const requestedLimit = Math.min(
@@ -2967,9 +2994,6 @@ export class PilotSwarmUiController {
2967
2994
  if (!force && existingHistory?.events) {
2968
2995
  return existingHistory;
2969
2996
  }
2970
- if (!force && this.sessionHistoryLoads.has(sessionId)) {
2971
- return this.sessionHistoryLoads.get(sessionId);
2972
- }
2973
2997
 
2974
2998
  // Re-entry catch-up: when the expanded window is already in memory,
2975
2999
  // fetch only the delta after lastSeq and append — the user's pulled-in
@@ -3052,12 +3076,7 @@ export class PilotSwarmUiController {
3052
3076
  });
3053
3077
  }
3054
3078
  return history;
3055
- })()
3056
- .finally(() => {
3057
- this.sessionHistoryLoads.delete(sessionId);
3058
- });
3059
-
3060
- this.sessionHistoryLoads.set(sessionId, loadPromise);
3079
+ })();
3061
3080
  return loadPromise;
3062
3081
  }
3063
3082
 
@@ -5727,6 +5746,7 @@ export class PilotSwarmUiController {
5727
5746
 
5728
5747
  async loadSession(sessionId) {
5729
5748
  if (!sessionId) return;
5749
+ const navigationGeneration = this.navigationGeneration = (this.navigationGeneration || 0) + 1;
5730
5750
  const active = this.getState().sessions.activeSessionId;
5731
5751
  if (active !== sessionId) {
5732
5752
  if (this.getState().ui.promptEdit) {
@@ -5742,10 +5762,13 @@ export class PilotSwarmUiController {
5742
5762
  this.detachActiveSession();
5743
5763
  return;
5744
5764
  }
5745
- await this.ensureSessionHistory(sessionId, { force: true });
5746
- if (this.getState().sessions.activeSessionId !== sessionId) return;
5747
- await this.syncSessionDetail(sessionId).catch(() => {});
5748
- if (this.getState().sessions.activeSessionId !== sessionId) return;
5765
+ // Independent reads share one network-latency window. Cached chat stays visible.
5766
+ await Promise.all([
5767
+ this.ensureSessionHistory(sessionId, { force: true }),
5768
+ this.syncSessionDetail(sessionId).catch(() => {}),
5769
+ ]);
5770
+ if (this.navigationGeneration !== navigationGeneration
5771
+ || this.getState().sessions.activeSessionId !== sessionId) return;
5749
5772
  this.attachActiveSession(sessionId);
5750
5773
  this.ensureInspectorData().catch(() => {});
5751
5774
  // Canvas snapshot rides the selection burst. Invalidate-then-fetch on
@@ -6,6 +6,7 @@ export * from "./layout.js";
6
6
  export * from "./repo-links.js";
7
7
  export * from "./repo-import.js";
8
8
  export * from "./session-tree.js";
9
+ export * from "./session-search.js";
9
10
  export * from "./state.js";
10
11
  export * from "./reducer.js";
11
12
  export * from "./portal-links.js";
@@ -1073,8 +1073,11 @@ function baseReducer(state, action) {
1073
1073
  state.sessions.navigationIntent
1074
1074
  && state.sessions.navigationIntent.status !== "failed",
1075
1075
  );
1076
+ // Saved selection is a boot preference. Once this tab navigates,
1077
+ // a slow profile read (or another device) must not navigate it back.
1076
1078
  const hasActive = Object.prototype.hasOwnProperty.call(settings, "activeSessionId")
1077
- && !navigationIntentLatched;
1079
+ && !navigationIntentLatched
1080
+ && !state.sessions.localSelectionMade;
1078
1081
  const hasLoadedSessions = Object.keys(state.sessions.byId || {}).length > 0;
1079
1082
  const nextLayout = hasLayout
1080
1083
  ? {
@@ -1385,11 +1388,12 @@ function baseReducer(state, action) {
1385
1388
  filterExceptionId: null,
1386
1389
  ownerFilterAutoAdmitted: null,
1387
1390
  };
1388
- const selection = applyVisibleSessionSelection(state, nextSessions);
1389
1391
  return {
1390
1392
  ...state,
1391
- sessions: selection.sessions,
1392
- ui: selection.ui,
1393
+ // Search filters the list, not the attached chat. Keeping
1394
+ // the active session stable also prevents a keystroke in
1395
+ // the search box from moving the list and main pane.
1396
+ sessions: nextSessions,
1393
1397
  };
1394
1398
  }
1395
1399
 
@@ -1888,7 +1892,7 @@ function baseReducer(state, action) {
1888
1892
  }
1889
1893
 
1890
1894
  case "sessions/selected": {
1891
- state = { ...state, sessions: { ...state.sessions, listDeselected: false } };
1895
+ state = { ...state, sessions: { ...state.sessions, listDeselected: false, localSelectionMade: true } };
1892
1896
  // Per-session chat scroll memory: stash both the outgoing offset
1893
1897
  // and whether it follows the bottom. A paused reading position
1894
1898
  // stays paused as new messages arrive and across session switches.
@@ -2191,6 +2195,17 @@ function baseReducer(state, action) {
2191
2195
  };
2192
2196
  }
2193
2197
 
2198
+ case "history/loadState": {
2199
+ // A gone-session response may already have evicted this entry.
2200
+ if (!state.sessions.byId[action.sessionId]) return state;
2201
+ const bySessionId = cloneHistoryMap(state.history.bySessionId);
2202
+ bySessionId.set(action.sessionId, {
2203
+ ...bySessionId.get(action.sessionId),
2204
+ loadState: action.loadState,
2205
+ });
2206
+ return { ...state, history: { ...state.history, bySessionId } };
2207
+ }
2208
+
2194
2209
  case "history/set": {
2195
2210
  const previousHistory = state.history.bySessionId.get(action.sessionId) || null;
2196
2211
  const previousChat = previousHistory?.chat || [];
@@ -1,7 +1,12 @@
1
1
  import { INSPECTOR_TABS, FOCUS_REGIONS } from "./commands.js";
2
2
  import { chatCallLine, firstCallLine } from "./chat-activity.js";
3
3
  import { canvasKey as canvasSlotKey, parseCanvasKey } from "./state.js";
4
- import { isManuallyOrderableSession } from "./session-tree.js";
4
+ import { buildSessionTree, isManuallyOrderableSession } from "./session-tree.js";
5
+ import {
6
+ buildSessionSearchDocument,
7
+ parseSessionSearchQuery,
8
+ scoreSessionSearchDocument,
9
+ } from "./session-search.js";
5
10
  import {
6
11
  createSplashCard,
7
12
  isRehydrationNoticeText,
@@ -1287,6 +1292,132 @@ function matchesSearchQuery(value, query) {
1287
1292
  return String(value || "").toLowerCase().includes(normalizedQuery);
1288
1293
  }
1289
1294
 
1295
+ const searchedSessionFlatCache = new WeakMap();
1296
+
1297
+ function sessionGroupTitle(session, byId) {
1298
+ if (session?.isGroup) return session.title || "";
1299
+ if (session?.groupId) return byId?.[`group:${session.groupId}`]?.title || "";
1300
+ let current = session;
1301
+ const seen = new Set();
1302
+ while (current?.parentSessionId && !seen.has(current.parentSessionId)) {
1303
+ seen.add(current.parentSessionId);
1304
+ current = byId?.[current.parentSessionId];
1305
+ if (current?.groupId) return byId?.[`group:${current.groupId}`]?.title || "";
1306
+ }
1307
+ return "";
1308
+ }
1309
+
1310
+ function sessionSearchOwner(session, byId) {
1311
+ const direct = effectiveSessionOwner(session, byId);
1312
+ if (direct) return direct;
1313
+ let current = session;
1314
+ const seen = new Set();
1315
+ while (current?.parentSessionId && !seen.has(current.parentSessionId)) {
1316
+ seen.add(current.parentSessionId);
1317
+ current = byId?.[current.parentSessionId];
1318
+ if (current?.owner) return current.owner;
1319
+ }
1320
+ return null;
1321
+ }
1322
+
1323
+ function buildSearchedSessionFlat(state, query) {
1324
+ const existingFlat = Array.isArray(state.sessions?.flat) ? state.sessions.flat : [];
1325
+ if (!query) return existingFlat;
1326
+ const cached = searchedSessionFlatCache.get(existingFlat);
1327
+ if (cached?.query === query) return cached.result;
1328
+
1329
+ const byId = state.sessions?.byId || {};
1330
+ const parsed = parseSessionSearchQuery(query);
1331
+ const expanded = buildSessionTree(
1332
+ Object.values(byId),
1333
+ new Set(),
1334
+ state.sessions?.orderById,
1335
+ state.sessions?.pinnedIds,
1336
+ state.sessions?.manualOrder,
1337
+ );
1338
+ const parentById = new Map();
1339
+ const entryById = new Map();
1340
+ const baseIndex = new Map();
1341
+ const stack = [];
1342
+ for (let index = 0; index < expanded.length; index += 1) {
1343
+ const entry = expanded[index];
1344
+ stack[entry.depth] = entry.sessionId;
1345
+ stack.length = entry.depth + 1;
1346
+ parentById.set(entry.sessionId, entry.depth > 0 ? stack[entry.depth - 1] : null);
1347
+ entryById.set(entry.sessionId, entry);
1348
+ baseIndex.set(entry.sessionId, index);
1349
+ }
1350
+
1351
+ const directScores = new Map();
1352
+ for (const entry of expanded) {
1353
+ const session = byId[entry.sessionId] || entry.standIn;
1354
+ if (!session || !matchesOwnerFilter(session, state.sessions?.ownerFilter, state.auth || {}, byId)) continue;
1355
+ const owner = sessionSearchOwner(session, byId);
1356
+ const document = buildSessionSearchDocument(session, {
1357
+ owner,
1358
+ groupTitle: sessionGroupTitle(session, byId),
1359
+ });
1360
+ const score = scoreSessionSearchDocument(document, parsed);
1361
+ if (score > 0) directScores.set(entry.sessionId, score);
1362
+ }
1363
+ const matchedIds = new Set(directScores.keys());
1364
+ // A just-created or deep-linked session may be admitted explicitly while
1365
+ // the user's prior query still excludes it. Preserve that established
1366
+ // visibility contract and rank the exception first until the user edits
1367
+ // the search (which clears filterExceptionId in the reducer).
1368
+ const exceptionId = state.sessions?.filterExceptionId;
1369
+ if (exceptionId && entryById.has(exceptionId)) {
1370
+ directScores.set(exceptionId, Number.MAX_SAFE_INTEGER);
1371
+ }
1372
+
1373
+ const included = new Set();
1374
+ const subtreeScores = new Map();
1375
+ for (const [sessionId, score] of directScores) {
1376
+ let current = sessionId;
1377
+ const seen = new Set();
1378
+ while (current && !seen.has(current)) {
1379
+ seen.add(current);
1380
+ included.add(current);
1381
+ subtreeScores.set(current, Math.max(subtreeScores.get(current) || 0, score));
1382
+ current = parentById.get(current) || null;
1383
+ }
1384
+ }
1385
+
1386
+ const children = new Map();
1387
+ for (const sessionId of included) {
1388
+ const parentId = parentById.get(sessionId) || null;
1389
+ if (!children.has(parentId)) children.set(parentId, []);
1390
+ children.get(parentId).push(sessionId);
1391
+ }
1392
+ for (const siblings of children.values()) {
1393
+ siblings.sort((a, b) => (subtreeScores.get(b) || 0) - (subtreeScores.get(a) || 0)
1394
+ || (baseIndex.get(a) || 0) - (baseIndex.get(b) || 0));
1395
+ }
1396
+
1397
+ const result = [];
1398
+ const visit = (sessionId, depth) => {
1399
+ const entry = entryById.get(sessionId);
1400
+ if (!entry) return;
1401
+ const childIds = children.get(sessionId) || [];
1402
+ result.push({
1403
+ ...entry,
1404
+ depth,
1405
+ collapsed: false,
1406
+ hasChildren: childIds.length > 0,
1407
+ searchIncluded: true,
1408
+ searchMatch: matchedIds.has(sessionId),
1409
+ searchScore: directScores.get(sessionId) || 0,
1410
+ });
1411
+ for (const childId of childIds) visit(childId, depth + 1);
1412
+ };
1413
+ for (const rootId of children.get(null) || []) visit(rootId, 0);
1414
+ // A user can type an unbounded number of distinct queries while the
1415
+ // catalog array keeps the same identity. Keep only the latest result for
1416
+ // that catalog rather than retaining every intermediate keystroke.
1417
+ searchedSessionFlatCache.set(existingFlat, { query, result });
1418
+ return result;
1419
+ }
1420
+
1290
1421
  // The transient deep-link filter exception covers the linked session AND its
1291
1422
  // ancestor chain (real parents plus the synthetic group:<id> row) so the
1292
1423
  // linked row renders with its tree context instead of as an orphan.
@@ -1330,22 +1461,25 @@ function sameRowDeps(a, b) {
1330
1461
  }
1331
1462
 
1332
1463
  export function selectSessionRows(state) {
1464
+ // Keep field punctuation and quotes for the parser (`author:"Ada Lovelace"`).
1465
+ // Individual values are normalized inside session-search.
1466
+ const query = String(state.sessions?.filterQuery || "").trim();
1467
+ const sessionFlat = buildSearchedSessionFlat(state, query);
1333
1468
  const totalDescendantCounts = getTotalDescendantCounts(state.sessions.byId);
1334
- const visibleDescendantCounts = getVisibleDescendantCounts(state.sessions.flat, state.sessions.byId);
1335
- const query = normalizeSearchQuery(state.sessions?.filterQuery || "");
1469
+ const visibleDescendantCounts = getVisibleDescendantCounts(sessionFlat, state.sessions.byId);
1336
1470
  const ownerFilter = state.sessions?.ownerFilter || { all: true };
1337
1471
  const auth = state.auth || {};
1338
1472
  const pinnedSet = new Set(Array.isArray(state.sessions?.pinnedIds) ? state.sessions.pinnedIds : []);
1339
1473
  const selectedSet = new Set(Array.isArray(state.sessions?.selectedIds) ? state.sessions.selectedIds : []);
1340
1474
  const filterExceptionIds = collectFilterExceptionIds(state.sessions);
1341
1475
 
1342
- let memo = sessionRowMemo.get(state.sessions.flat);
1476
+ let memo = sessionRowMemo.get(sessionFlat);
1343
1477
  if (!memo) {
1344
1478
  memo = new Map();
1345
- sessionRowMemo.set(state.sessions.flat, memo);
1479
+ sessionRowMemo.set(sessionFlat, memo);
1346
1480
  }
1347
1481
 
1348
- return state.sessions.flat.filter((entry) => (
1482
+ return sessionFlat.filter((entry) => (
1349
1483
  // A row whose session is in neither place can render nothing but an
1350
1484
  // owner chip and a dash - the "[?]" ghost. Drop it rather than show it.
1351
1485
  Boolean(state.sessions.byId[entry.sessionId] || entry.standIn)
@@ -1433,10 +1567,13 @@ export function selectSessionRows(state) {
1433
1567
  // `chrome` as well so a host that draws its own row (the phone's
1434
1568
  // list) can act on it without reading the portal's descriptor.
1435
1569
  pause: rowView.chrome?.pause || null,
1570
+ searchMatch: entry.searchMatch === true,
1571
+ searchScore: Number(entry.searchScore) || 0,
1436
1572
  };
1437
1573
  memo.set(entry.sessionId, { deps, row });
1438
1574
  return row;
1439
1575
  }).filter((row) => {
1576
+ if (query) return true; // searchedSessionFlat already contains only matches and their ancestors.
1440
1577
  if (filterExceptionIds?.has(row.sessionId)) return true;
1441
1578
  const session = state.sessions.byId[row.sessionId];
1442
1579
  if (!matchesOwnerFilter(session, ownerFilter, auth, state.sessions.byId)) return false;
@@ -1444,11 +1581,9 @@ export function selectSessionRows(state) {
1444
1581
  const ownerSearchText = effectiveOwner
1445
1582
  ? `${ownerDisplayName(effectiveOwner, "")} ${effectiveOwner.email || ""}`
1446
1583
  : "";
1447
- const summarySearchText = "";
1448
1584
  return matchesSearchQuery(row.text, query)
1449
1585
  || matchesSearchQuery(row.sessionId, query)
1450
- || matchesSearchQuery(ownerSearchText, query)
1451
- || matchesSearchQuery(summarySearchText, query);
1586
+ || matchesSearchQuery(ownerSearchText, query);
1452
1587
  });
1453
1588
  }
1454
1589
 
@@ -1893,6 +2028,16 @@ export function selectActiveChat(state) {
1893
2028
  }
1894
2029
  const history = state.history.bySessionId.get(sessionId);
1895
2030
  const chat = history?.chat || [];
2031
+ if (!chat.length && ["loading", "failed"].includes(history?.loadState)) {
2032
+ return [{
2033
+ id: `history-load:${sessionId}:${history.loadState}`,
2034
+ role: "system",
2035
+ noChrome: true,
2036
+ text: history.loadState === "loading"
2037
+ ? "Loading conversation…"
2038
+ : "Could not load this conversation. Select the session again to retry.",
2039
+ }];
2040
+ }
1896
2041
  const events = history?.events || [];
1897
2042
  const pendingQuestionMessage = session?.pendingQuestion?.question
1898
2043
  && !chatAlreadyContainsPendingQuestion(chat, session.pendingQuestion.question)
@@ -0,0 +1,151 @@
1
+ const FIELD_ALIASES = Object.freeze({
2
+ author: "author",
3
+ owner: "author",
4
+ agent: "agent",
5
+ status: "status",
6
+ group: "group",
7
+ folder: "group",
8
+ model: "model",
9
+ id: "id",
10
+ topic: "topic",
11
+ summary: "summary",
12
+ });
13
+
14
+ const FREE_FIELD_WEIGHTS = Object.freeze({
15
+ title: 12,
16
+ id: 11,
17
+ author: 10,
18
+ agent: 9,
19
+ summary: 7,
20
+ group: 6,
21
+ model: 5,
22
+ status: 4,
23
+ });
24
+
25
+ export function normalizeSessionSearchText(value) {
26
+ return String(value || "")
27
+ .normalize("NFKD")
28
+ .replace(/[\u0300-\u036f]/g, "")
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9@._+-]+/g, " ")
31
+ .trim()
32
+ .replace(/\s+/g, " ");
33
+ }
34
+
35
+ export function parseSessionSearchQuery(value) {
36
+ const source = String(value || "").trim();
37
+ const terms = [];
38
+ const filters = [];
39
+ const pattern = /(?:(\w+):)?(?:"([^"]+)"|(\S+))/g;
40
+ let match;
41
+ while ((match = pattern.exec(source)) !== null) {
42
+ const rawField = String(match[1] || "").toLowerCase();
43
+ const rawValue = match[2] ?? match[3] ?? "";
44
+ const normalized = normalizeSessionSearchText(rawValue);
45
+ if (!normalized) continue;
46
+ const field = FIELD_ALIASES[rawField];
47
+ if (field) filters.push({ field, value: normalized });
48
+ else terms.push(normalizeSessionSearchText(rawField ? `${rawField} ${rawValue}` : rawValue));
49
+ }
50
+ return { source, terms: terms.filter(Boolean), filters };
51
+ }
52
+
53
+ function boundedDistance(a, b, limit) {
54
+ if (Math.abs(a.length - b.length) > limit) return limit + 1;
55
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
56
+ for (let i = 1; i <= a.length; i += 1) {
57
+ const current = [i];
58
+ let rowMinimum = current[0];
59
+ for (let j = 1; j <= b.length; j += 1) {
60
+ const value = Math.min(
61
+ current[j - 1] + 1,
62
+ previous[j] + 1,
63
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
64
+ );
65
+ current.push(value);
66
+ rowMinimum = Math.min(rowMinimum, value);
67
+ }
68
+ if (rowMinimum > limit) return limit + 1;
69
+ previous = current;
70
+ }
71
+ return previous[b.length];
72
+ }
73
+
74
+ function matchStrength(haystack, needle) {
75
+ if (!haystack || !needle) return 0;
76
+ if (haystack === needle) return 16;
77
+ const words = haystack.split(" ");
78
+ if (words.includes(needle)) return 14;
79
+ if (haystack.includes(needle)) return needle.includes(" ") ? 13 : 11;
80
+ if (needle.length >= 2 && words.some((word) => word.startsWith(needle))) return 9;
81
+ if (needle.length >= 4 && words.some((word) => word.includes(needle))) return 7;
82
+ if (needle.length < 4) return 0;
83
+ // One adjacent transposition is two edits under Levenshtein distance
84
+ // ("session" → "sessoin"). Permit that for ordinary words while keeping
85
+ // short tokens exact/prefix-only.
86
+ const limit = needle.length >= 6 ? 2 : 1;
87
+ return words.some((word) => boundedDistance(word, needle, limit) <= limit) ? 4 : 0;
88
+ }
89
+
90
+ function summaryStrings(summaryState) {
91
+ if (!summaryState || typeof summaryState !== "object") return [];
92
+ return [summaryState.topic, summaryState.intent, summaryState.summary, summaryState.title]
93
+ .filter((value) => typeof value === "string" && value.trim());
94
+ }
95
+
96
+ export function buildSessionSearchDocument(session, { owner = null, groupTitle = "" } = {}) {
97
+ const title = normalizeSessionSearchText(session?.title);
98
+ const summary = normalizeSessionSearchText([
99
+ session?.shortSummary,
100
+ ...summaryStrings(session?.summaryState),
101
+ ].filter(Boolean).join(" "));
102
+ const author = normalizeSessionSearchText([
103
+ owner?.displayName,
104
+ owner?.email,
105
+ owner?.subject,
106
+ ].filter(Boolean).join(" "));
107
+ return {
108
+ title,
109
+ summary,
110
+ topic: normalizeSessionSearchText(`${title} ${summary}`),
111
+ author,
112
+ agent: normalizeSessionSearchText(session?.agentId),
113
+ status: normalizeSessionSearchText(session?.status),
114
+ group: normalizeSessionSearchText(groupTitle),
115
+ model: normalizeSessionSearchText(`${session?.model || ""} ${session?.reasoningEffort || ""}`),
116
+ id: normalizeSessionSearchText(session?.sessionId),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Score one already-authorized, browser-resident session. Structured filters
122
+ * and free-text terms are ANDed; each free term may match any field. Exact,
123
+ * prefix and substring matches beat bounded typo matches.
124
+ */
125
+ export function scoreSessionSearchDocument(document, query) {
126
+ const parsed = typeof query === "string" ? parseSessionSearchQuery(query) : query;
127
+ if (!parsed || (parsed.terms.length === 0 && parsed.filters.length === 0)) return 1;
128
+ let score = 0;
129
+ for (const filter of parsed.filters) {
130
+ const strength = matchStrength(document?.[filter.field] || "", filter.value);
131
+ if (!strength) return 0;
132
+ score += strength * 14;
133
+ }
134
+ for (const term of parsed.terms) {
135
+ let best = 0;
136
+ for (const [field, weight] of Object.entries(FREE_FIELD_WEIGHTS)) {
137
+ // Short numbers such as "Session 2" occur in the fixed portions
138
+ // of every UUID. Treat IDs as an explicit/long-fragment field so
139
+ // the human title remains the natural interpretation; `id:...`
140
+ // is always available for deliberate ID lookup.
141
+ if (field === "id" && (/^\d+$/.test(term) || term.length < 8)) continue;
142
+ best = Math.max(best, matchStrength(document?.[field] || "", term) * weight);
143
+ }
144
+ if (!best) return 0;
145
+ score += best;
146
+ }
147
+ const phrase = parsed.terms.join(" ");
148
+ if (phrase && document?.title?.includes(phrase)) score += 120;
149
+ else if (phrase && document?.summary?.includes(phrase)) score += 60;
150
+ return score;
151
+ }