pilotswarm 0.5.14 → 0.5.15

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.
@@ -92,7 +92,9 @@ function normalizeSessionListRow(session) {
92
92
  if (session.isGroup) return session;
93
93
  return {
94
94
  ...session,
95
- groupId: session.groupId ?? null,
95
+ // The wire DTO carries the viewer-private placement as viewerGroupId;
96
+ // the local groupId field is what the tree/selectors key off.
97
+ groupId: session.viewerGroupId ?? null,
96
98
  parentSessionId: session.parentSessionId ?? null,
97
99
  };
98
100
  }
@@ -101,6 +103,30 @@ function sessionGroupIdFromRowId(sessionId) {
101
103
  return String(sessionId || "").startsWith("group:") ? String(sessionId).slice("group:".length) : null;
102
104
  }
103
105
 
106
+ // Per-row placement skip reasons ('system', 'not_found') folded into a short
107
+ // status-bar suffix, e.g. "2 system, 1 not found".
108
+ function summarizePlacementSkips(rows) {
109
+ const counts = new Map();
110
+ for (const row of rows || []) {
111
+ const label = row?.reason === "system" ? "system" : "not found";
112
+ counts.set(label, (counts.get(label) || 0) + 1);
113
+ }
114
+ return [...counts.entries()].map(([label, count]) => `${count} ${label}`).join(", ");
115
+ }
116
+
117
+ // Deep-link load failures split into two renderable kinds: a definitive
118
+ // not-found/no-access answer from the server (the API deliberately returns
119
+ // identical shapes for unknown vs unshared sessions), and everything else —
120
+ // network/server faults — which stays retryable.
121
+ function classifyNavigationLoadError(error) {
122
+ const status = Number(error?.status);
123
+ const code = String(error?.code || "").toUpperCase();
124
+ if (status === 404 || status === 403 || code === "NOT_FOUND" || code === "FORBIDDEN") {
125
+ return "not_found";
126
+ }
127
+ return "network";
128
+ }
129
+
104
130
  async function loadSessionCatalogPageWindow(transport) {
105
131
  if (typeof transport.listSessionsPage !== "function") {
106
132
  return transport.listSessions();
@@ -506,6 +532,12 @@ function buildSessionOwnerFilterItems(state) {
506
532
  ownerKey: principalKey,
507
533
  description: "Show sessions owned by the authenticated user currently signed in.",
508
534
  });
535
+ items.push({
536
+ id: "shared",
537
+ kind: "shared",
538
+ label: "Shared with me",
539
+ description: "Show sessions other users have shared with you.",
540
+ });
509
541
  }
510
542
 
511
543
  const ownersByKey = new Map();
@@ -542,6 +574,7 @@ export function defaultOwnerFilterForPrincipal(principal) {
542
574
  includeSystem: true,
543
575
  includeUnowned: false,
544
576
  includeMe: true,
577
+ includeShared: true,
545
578
  ownerKeys: [],
546
579
  }
547
580
  : {
@@ -549,6 +582,7 @@ export function defaultOwnerFilterForPrincipal(principal) {
549
582
  includeSystem: false,
550
583
  includeUnowned: false,
551
584
  includeMe: false,
585
+ includeShared: false,
552
586
  ownerKeys: [],
553
587
  };
554
588
  }
@@ -558,6 +592,7 @@ function ownerFilterHasSelections(filter) {
558
592
  filter?.includeSystem
559
593
  || filter?.includeUnowned
560
594
  || filter?.includeMe
595
+ || filter?.includeShared
561
596
  || (Array.isArray(filter?.ownerKeys) && filter.ownerKeys.length > 0),
562
597
  );
563
598
  }
@@ -571,6 +606,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
571
606
  includeSystem: false,
572
607
  includeUnowned: false,
573
608
  includeMe: false,
609
+ includeShared: false,
574
610
  ownerKeys: [],
575
611
  };
576
612
  }
@@ -583,6 +619,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
583
619
  if (item.kind === "system") next.includeSystem = !next.includeSystem;
584
620
  if (item.kind === "unowned") next.includeUnowned = !next.includeUnowned;
585
621
  if (item.kind === "me") next.includeMe = !next.includeMe;
622
+ if (item.kind === "shared") next.includeShared = !next.includeShared;
586
623
  if (item.kind === "owner" && item.ownerKey) {
587
624
  const existingIndex = next.ownerKeys.indexOf(item.ownerKey);
588
625
  if (existingIndex >= 0) {
@@ -594,7 +631,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
594
631
 
595
632
  return ownerFilterHasSelections(next)
596
633
  ? next
597
- : { all: true, includeSystem: false, includeUnowned: false, includeMe: false, ownerKeys: [] };
634
+ : { all: true, includeSystem: false, includeUnowned: false, includeMe: false, includeShared: false, ownerKeys: [] };
598
635
  }
599
636
 
600
637
  function getRenameSessionPrefix(session) {
@@ -1696,7 +1733,7 @@ export class PilotSwarmUiController {
1696
1733
  }).catch(() => {});
1697
1734
  }
1698
1735
 
1699
- async start() {
1736
+ async start({ initialSessionId = null } = {}) {
1700
1737
  await this.transport.start();
1701
1738
  const authContext = typeof this.transport.getAuthContext === "function"
1702
1739
  ? this.transport.getAuthContext()
@@ -1727,6 +1764,13 @@ export class PilotSwarmUiController {
1727
1764
  workersOnline: typeof this.transport.getWorkerCount === "function" ? this.transport.getWorkerCount() : null,
1728
1765
  statusText: "Connected",
1729
1766
  });
1767
+ // Latch the deep-link intent AFTER the default owner-filter dispatch
1768
+ // (a filter change releases the latch) and BEFORE the first refresh,
1769
+ // so the first catalog load resolves selection onto the link target.
1770
+ const deepLinkSessionId = String(initialSessionId || "").trim();
1771
+ if (deepLinkSessionId) {
1772
+ this.dispatch({ type: "sessions/navigationIntent", sessionId: deepLinkSessionId });
1773
+ }
1730
1774
  await this.refreshSessions();
1731
1775
  this.catalogTimer = setInterval(() => {
1732
1776
  this.refreshSessions().catch((error) => {
@@ -1782,6 +1826,36 @@ export class PilotSwarmUiController {
1782
1826
  sessions = [...groupRows, ...sessions];
1783
1827
  }
1784
1828
  }
1829
+ // A pending deep-link target may be readable but absent from the
1830
+ // paged catalog window — fetch it explicitly. A definitive failure
1831
+ // (unknown or unshared: identical 404 shapes) fails the intent; the
1832
+ // reducer then refuses fallback selection so the renderer can show
1833
+ // the nav error instead of silently landing somewhere else.
1834
+ const pendingIntent = preRefreshState.sessions.navigationIntent;
1835
+ if (
1836
+ pendingIntent?.status === "pending"
1837
+ && !sessions.some((session) => session?.sessionId === pendingIntent.sessionId)
1838
+ && typeof this.transport.getSession === "function"
1839
+ ) {
1840
+ try {
1841
+ const intentSession = await this.transport.getSession(pendingIntent.sessionId);
1842
+ if (intentSession?.sessionId) {
1843
+ sessions = [...sessions, normalizeSessionListRow(intentSession)];
1844
+ } else {
1845
+ this.dispatch({
1846
+ type: "sessions/navigationIntentFailed",
1847
+ sessionId: pendingIntent.sessionId,
1848
+ errorKind: "not_found",
1849
+ });
1850
+ }
1851
+ } catch (error) {
1852
+ this.dispatch({
1853
+ type: "sessions/navigationIntentFailed",
1854
+ sessionId: pendingIntent.sessionId,
1855
+ errorKind: classifyNavigationLoadError(error),
1856
+ });
1857
+ }
1858
+ }
1785
1859
  const active = previousActive;
1786
1860
  if (
1787
1861
  active
@@ -1807,7 +1881,20 @@ export class PilotSwarmUiController {
1807
1881
  if (selected) {
1808
1882
  if (selected !== previousActive) {
1809
1883
  if (!this.getState().sessions.byId[selected]?.isGroup) {
1810
- await this.loadSession(selected);
1884
+ const selectedIntent = this.getState().sessions.navigationIntent;
1885
+ if (selectedIntent && selectedIntent.sessionId === selected && selectedIntent.status !== "failed") {
1886
+ try {
1887
+ await this.loadSession(selected);
1888
+ } catch (error) {
1889
+ this.dispatch({
1890
+ type: "sessions/navigationIntentFailed",
1891
+ sessionId: selected,
1892
+ errorKind: classifyNavigationLoadError(error),
1893
+ });
1894
+ }
1895
+ } else {
1896
+ await this.loadSession(selected);
1897
+ }
1811
1898
  }
1812
1899
  return;
1813
1900
  }
@@ -3019,6 +3106,32 @@ export class PilotSwarmUiController {
3019
3106
  });
3020
3107
  }
3021
3108
 
3109
+ /**
3110
+ * Latch a navigation intent (deep link) onto a session id. The intent
3111
+ * outranks in-memory selection and the profile's activeSessionId until
3112
+ * the user navigates manually or changes a filter. Also the retry path
3113
+ * for a failed intent: re-latching resets it to pending.
3114
+ */
3115
+ setNavigationIntent(sessionId) {
3116
+ const id = String(sessionId || "").trim();
3117
+ if (!id) return;
3118
+ this.dispatch({ type: "sessions/navigationIntent", sessionId: id });
3119
+ const state = this.getState();
3120
+ if (state.sessions.byId[id]) {
3121
+ this.loadSession(id).catch((error) => {
3122
+ this.dispatch({
3123
+ type: "sessions/navigationIntentFailed",
3124
+ sessionId: id,
3125
+ errorKind: classifyNavigationLoadError(error),
3126
+ });
3127
+ });
3128
+ return;
3129
+ }
3130
+ if (Object.keys(state.sessions.byId).length > 0) {
3131
+ this.scheduleSessionsRefresh(0);
3132
+ }
3133
+ }
3134
+
3022
3135
  async loadSession(sessionId) {
3023
3136
  if (!sessionId) return;
3024
3137
  const active = this.getState().sessions.activeSessionId;
@@ -3194,7 +3307,9 @@ export class PilotSwarmUiController {
3194
3307
 
3195
3308
  async createSession(options = {}) {
3196
3309
  try {
3197
- const created = await this.transport.createSession(this.applyActiveGroupDefault(options));
3310
+ const requestOptions = this.applyActiveGroupDefault(options);
3311
+ const created = await this.transport.createSession(requestOptions);
3312
+ await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
3198
3313
  await this.refreshSessions();
3199
3314
  await this.loadSession(created.sessionId);
3200
3315
  this.setFocus(FOCUS_REGIONS.PROMPT);
@@ -3211,7 +3326,9 @@ export class PilotSwarmUiController {
3211
3326
  throw new Error("Named-agent session creation is not supported by this transport");
3212
3327
  }
3213
3328
  try {
3214
- const created = await this.transport.createSessionForAgent(agentName, this.applyActiveGroupDefault(options));
3329
+ const requestOptions = this.applyActiveGroupDefault(options);
3330
+ const created = await this.transport.createSessionForAgent(agentName, requestOptions);
3331
+ await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
3215
3332
  await this.refreshSessions();
3216
3333
  await this.loadSession(created.sessionId);
3217
3334
  this.setFocus(FOCUS_REGIONS.PROMPT);
@@ -3226,6 +3343,20 @@ export class PilotSwarmUiController {
3226
3343
  }
3227
3344
  }
3228
3345
 
3346
+ /**
3347
+ * Post-create placement follow-up. createSession's groupId only places
3348
+ * when an owner principal reaches the catalog (web mode places
3349
+ * server-side; the local TUI passes no owner), so when a group was
3350
+ * requested, place explicitly — idempotent where the server already did.
3351
+ * Skipped when the create response already reports the placement.
3352
+ */
3353
+ async placeCreatedSessionInGroup(created, groupId) {
3354
+ if (!groupId || !created?.sessionId) return;
3355
+ if (typeof this.transport.placeSessionsInGroup !== "function") return;
3356
+ if ((created.viewerGroupId ?? null) === groupId) return;
3357
+ await this.transport.placeSessionsInGroup([created.sessionId], groupId).catch(() => {});
3358
+ }
3359
+
3229
3360
  getMovableGroupSessionSelection() {
3230
3361
  const state = this.getState();
3231
3362
  const selectedIds = Array.isArray(state.sessions.selectedIds) && state.sessions.selectedIds.length > 0
@@ -3249,16 +3380,12 @@ export class PilotSwarmUiController {
3249
3380
  return null;
3250
3381
  }
3251
3382
 
3383
+ // The server returns only the viewer's own groups, and placement is
3384
+ // viewer-private — any readable non-system selection is movable, so
3385
+ // mixed-owner selections are allowed and every group is offered.
3252
3386
  const groups = await this.transport.listSessionGroups().catch(() => []);
3253
3387
  const sessionIds = eligible.map((session) => session.sessionId);
3254
3388
  const firstGroupId = eligible.length === 1 ? eligible[0].groupId || null : null;
3255
- const selectedOwnerKeys = new Set(eligible.map((session) => ownerKeyForPrincipal(session.owner) || ""));
3256
- const singleOwnerKey = selectedOwnerKeys.size === 1 ? [...selectedOwnerKeys][0] : null;
3257
- const singleOwner = selectedOwnerKeys.size === 1 ? eligible[0]?.owner ?? null : null;
3258
- const compatibleGroups = selectedOwnerKeys.size === 1
3259
- ? (Array.isArray(groups) ? groups : []).filter((group) => (ownerKeyForPrincipal(group.owner) || "") === singleOwnerKey)
3260
- : [];
3261
- const canCreateOrAssignGroup = selectedOwnerKeys.size === 1;
3262
3389
  const items = [
3263
3390
  {
3264
3391
  id: "__no_group__",
@@ -3268,22 +3395,20 @@ export class PilotSwarmUiController {
3268
3395
  groupId: null,
3269
3396
  memberCount: 0,
3270
3397
  },
3271
- ...(canCreateOrAssignGroup ? [{
3398
+ {
3272
3399
  id: "__new_group__",
3273
3400
  kind: "newGroup",
3274
3401
  label: "[New Group]",
3275
- description: "Create a new group for this owner, then move the selected session(s) into it.",
3402
+ description: "Create a new group, then move the selected session(s) into it.",
3276
3403
  groupId: null,
3277
3404
  memberCount: 0,
3278
- owner: singleOwner,
3279
- }] : []),
3280
- ...compatibleGroups.map((group) => ({
3405
+ },
3406
+ ...(Array.isArray(groups) ? groups : []).map((group) => ({
3281
3407
  id: group.groupId,
3282
3408
  kind: "group",
3283
3409
  label: group.title || group.groupId,
3284
3410
  description: group.description || "Move selected session(s) into this group.",
3285
3411
  groupId: group.groupId,
3286
- owner: group.owner ?? null,
3287
3412
  memberCount: group.memberCount ?? 0,
3288
3413
  })),
3289
3414
  ];
@@ -3305,9 +3430,7 @@ export class PilotSwarmUiController {
3305
3430
  });
3306
3431
  this.dispatch({
3307
3432
  type: "ui/status",
3308
- text: canCreateOrAssignGroup
3309
- ? "Choose a group, [New Group], or [No Group]"
3310
- : "Selected sessions have different owners; only [No Group] is available",
3433
+ text: "Choose a group, [New Group], or [No Group]",
3311
3434
  });
3312
3435
  return items;
3313
3436
  }
@@ -3322,7 +3445,10 @@ export class PilotSwarmUiController {
3322
3445
  this.dispatch({ type: "ui/status", text: "No sessions selected to move" });
3323
3446
  return null;
3324
3447
  }
3325
- if (typeof this.transport.moveSessionsToGroup === "function") {
3448
+ let placementResults = null;
3449
+ if (typeof this.transport.placeSessionsInGroup === "function") {
3450
+ placementResults = await this.transport.placeSessionsInGroup(ids, groupId ?? null);
3451
+ } else if (typeof this.transport.moveSessionsToGroup === "function") {
3326
3452
  await this.transport.moveSessionsToGroup(groupId ?? null, ids);
3327
3453
  } else if (groupId && typeof this.transport.assignSessionsToGroup === "function") {
3328
3454
  await this.transport.assignSessionsToGroup(groupId, ids);
@@ -3331,17 +3457,28 @@ export class PilotSwarmUiController {
3331
3457
  return null;
3332
3458
  }
3333
3459
 
3460
+ const resultRows = Array.isArray(placementResults) ? placementResults.filter(Boolean) : null;
3461
+ const skippedRows = resultRows ? resultRows.filter((row) => row.placed !== true) : [];
3462
+ const placedCount = resultRows
3463
+ ? resultRows.filter((row) => row.placed === true).length
3464
+ : ids.length;
3465
+
3334
3466
  this.dispatch({ type: "sessions/selectClear" });
3335
3467
  await this.refreshSessions();
3336
- if (groupId) {
3468
+ if (groupId && placedCount > 0) {
3337
3469
  await this.loadSession(`group:${groupId}`).catch(() => {});
3338
3470
  }
3339
3471
  const target = groupId ? `group ${statusTitle || groupId}` : "No Group";
3472
+ const skippedSummary = skippedRows.length > 0
3473
+ ? ` · skipped ${skippedRows.length} (${summarizePlacementSkips(skippedRows)})`
3474
+ : "";
3340
3475
  this.dispatch({
3341
3476
  type: "ui/status",
3342
- text: `Moved ${ids.length} session${ids.length === 1 ? "" : "s"} to ${target}`,
3477
+ text: placedCount > 0
3478
+ ? `Moved ${placedCount} session${placedCount === 1 ? "" : "s"} to ${target}${skippedSummary}`
3479
+ : `No sessions moved to ${target}${skippedSummary}`,
3343
3480
  });
3344
- return true;
3481
+ return placedCount > 0;
3345
3482
  }
3346
3483
 
3347
3484
  async confirmSessionGroupPickerModal() {
@@ -3360,7 +3497,6 @@ export class PilotSwarmUiController {
3360
3497
  title: "New Group",
3361
3498
  previousFocus: modal.previousFocus,
3362
3499
  sessionIds: modal.sessionIds || [],
3363
- owner: item.owner ?? null,
3364
3500
  value: baseTitle,
3365
3501
  cursorIndex: baseTitle.length,
3366
3502
  maxLength: 80,
@@ -3440,11 +3576,14 @@ export class PilotSwarmUiController {
3440
3576
  if (previousFocus) this.setFocus(previousFocus);
3441
3577
 
3442
3578
  try {
3579
+ // No owner key: the transport/server stamps the caller's
3580
+ // principal. The UI never infers group ownership from the
3581
+ // selected sessions (owner: null is reserved for the portal
3582
+ // runtime's anonymous path).
3443
3583
  const group = await this.transport.createSessionGroup({
3444
3584
  title,
3445
3585
  description: `${(modal.sessionIds || []).length} grouped session${(modal.sessionIds || []).length === 1 ? "" : "s"}`,
3446
3586
  sessionIds: modal.sessionIds || [],
3447
- owner: modal.owner ?? null,
3448
3587
  });
3449
3588
  await this.moveSessionsToGroup(group.groupId, modal.sessionIds || [], { statusTitle: group.title || title });
3450
3589
  } catch (error) {
@@ -3704,15 +3843,21 @@ export class PilotSwarmUiController {
3704
3843
  this.dispatch({ type: "ui/status", text: "Select a model and press Enter" });
3705
3844
  }
3706
3845
 
3707
- async openSwitchModelPicker() {
3846
+ async openSwitchModelPicker(onApplied = null) {
3847
+ // Callers (e.g. the portal Manage modal) can be notified when a switch
3848
+ // is actually applied — distinct from cancelling the picker — so they
3849
+ // can close/reopen their own chrome accordingly.
3850
+ this._onSwitchModelApplied = typeof onApplied === "function" ? onApplied : null;
3708
3851
  const state = this.getState();
3709
3852
  const sessionId = state.sessions.activeSessionId;
3710
3853
  const session = sessionId ? state.sessions.byId[sessionId] || null : null;
3711
3854
  if (!session || session.isGroup) {
3855
+ this._onSwitchModelApplied = null;
3712
3856
  this.dispatch({ type: "ui/status", text: "Select a session before switching model" });
3713
3857
  return;
3714
3858
  }
3715
3859
  if (typeof this.transport.setSessionModel !== "function") {
3860
+ this._onSwitchModelApplied = null;
3716
3861
  this.dispatch({ type: "ui/status", text: "Model switching is not supported by this transport" });
3717
3862
  return;
3718
3863
  }
@@ -3745,6 +3890,11 @@ export class PilotSwarmUiController {
3745
3890
  const tierSuffix = options.contextTier ? ` · ${CONTEXT_TIER_LABELS[options.contextTier] || options.contextTier}` : "";
3746
3891
  this.dispatch({ type: "ui/status", text: `Next turn will use ${modelLabel}${tierSuffix}` });
3747
3892
  await this.refreshSessions();
3893
+ // Notify a Manage-modal-style caller that the switch was applied (vs
3894
+ // cancelled), so it can close rather than reopen.
3895
+ const onApplied = this._onSwitchModelApplied;
3896
+ this._onSwitchModelApplied = null;
3897
+ if (typeof onApplied === "function") onApplied();
3748
3898
  }
3749
3899
 
3750
3900
  openHelpModal() {
@@ -324,6 +324,12 @@ function resolveVisibleActiveSessionId(state, fallbackSessions = []) {
324
324
  if (currentSessionId && visibleRows.some((row) => row.sessionId === currentSessionId)) {
325
325
  return currentSessionId;
326
326
  }
327
+ // A navigation intent (deep link) owns selection until it is cleared by
328
+ // manual navigation or a filter change. While one exists — pending target
329
+ // still loading, or failed — never fall back to a default selection.
330
+ if (state.sessions?.navigationIntent) {
331
+ return currentSessionId;
332
+ }
327
333
  if (currentSessionId && state.sessions?.byId?.[currentSessionId] && !hasSessionVisibilityFilter(state.sessions)) {
328
334
  return currentSessionId;
329
335
  }
@@ -361,20 +367,67 @@ function updateUiForSessionSelection(state, nextActiveSessionId) {
361
367
  }
362
368
 
363
369
  function applyVisibleSessionSelection(state, nextSessions) {
370
+ let sessions = nextSessions;
371
+
372
+ // Navigation intent (deep link) outranks in-memory selection and profile
373
+ // activeSessionId: once its target session is present, latch the selection
374
+ // onto it and mark the intent resolved.
375
+ const intent = sessions.navigationIntent || null;
376
+ const intentTargetId = intent && intent.status !== "failed" && sessions.byId?.[intent.sessionId]
377
+ ? intent.sessionId
378
+ : null;
379
+ if (intentTargetId) {
380
+ sessions = {
381
+ ...sessions,
382
+ activeSessionId: intentTargetId,
383
+ navigationIntent: intent.status === "resolved"
384
+ ? intent
385
+ : { sessionId: intent.sessionId, status: "resolved" },
386
+ };
387
+ }
388
+
389
+ // Expand ancestors BEFORE resolving visibility: a selected session inside
390
+ // a collapsed group/parent is absent from the flat tree, so resolving
391
+ // first would drop the selection instead of revealing it.
392
+ const selectionCandidateId = sessions.activeSessionId || null;
393
+ if (selectionCandidateId && sessions.byId?.[selectionCandidateId]) {
394
+ const expandedCollapsedIds = reconcileCollapsedIdsForActiveSession(sessions.collapsedIds, sessions.byId, selectionCandidateId);
395
+ if (!setsEqual(expandedCollapsedIds, sessions.collapsedIds)) {
396
+ sessions = {
397
+ ...sessions,
398
+ collapsedIds: expandedCollapsedIds,
399
+ flat: buildSessionTree(Object.values(sessions.byId || {}), expandedCollapsedIds, sessions.orderById, sessions.pinnedIds),
400
+ };
401
+ }
402
+ }
403
+
404
+ // A resolved intent target excluded by the current filters gets a
405
+ // transient exception so the linked session is shown anyway. Never
406
+ // persisted; cleared on manual navigation or filter change.
407
+ if (intentTargetId && sessions.filterExceptionId !== intentTargetId) {
408
+ const probeRows = selectSessionRows({ ...state, sessions });
409
+ if (!probeRows.some((row) => row.sessionId === intentTargetId)) {
410
+ sessions = {
411
+ ...sessions,
412
+ filterExceptionId: intentTargetId,
413
+ };
414
+ }
415
+ }
416
+
364
417
  const nextState = {
365
418
  ...state,
366
- sessions: nextSessions,
419
+ sessions,
367
420
  };
368
- const nextActiveSessionId = resolveVisibleActiveSessionId(nextState, Object.values(nextSessions.byId || {}));
369
- const nextCollapsedIds = reconcileCollapsedIdsForActiveSession(nextSessions.collapsedIds, nextSessions.byId, nextActiveSessionId);
370
- const collapsedChanged = !setsEqual(nextCollapsedIds, nextSessions.collapsedIds);
421
+ const nextActiveSessionId = resolveVisibleActiveSessionId(nextState, Object.values(sessions.byId || {}));
422
+ const nextCollapsedIds = reconcileCollapsedIdsForActiveSession(sessions.collapsedIds, sessions.byId, nextActiveSessionId);
423
+ const collapsedChanged = !setsEqual(nextCollapsedIds, sessions.collapsedIds);
371
424
  const reconciledSessions = collapsedChanged
372
425
  ? {
373
- ...nextSessions,
426
+ ...sessions,
374
427
  collapsedIds: nextCollapsedIds,
375
- flat: buildSessionTree(Object.values(nextSessions.byId || {}), nextCollapsedIds, nextSessions.orderById, nextSessions.pinnedIds),
428
+ flat: buildSessionTree(Object.values(sessions.byId || {}), nextCollapsedIds, sessions.orderById, sessions.pinnedIds),
376
429
  }
377
- : nextSessions;
430
+ : sessions;
378
431
  return {
379
432
  sessions: {
380
433
  ...reconciledSessions,
@@ -515,7 +568,15 @@ export function appReducer(state, action) {
515
568
  && (settings.chatViewMode === "summary" || settings.chatViewMode === "transcript");
516
569
  const hasPins = Object.prototype.hasOwnProperty.call(settings, "pinnedSessionIds");
517
570
  const hasCollapsed = Object.prototype.hasOwnProperty.call(settings, "collapsedSessionIds");
518
- const hasActive = Object.prototype.hasOwnProperty.call(settings, "activeSessionId");
571
+ // A pending/resolved deep-link intent outranks the profile's
572
+ // persisted activeSessionId — a remote profile poll must not
573
+ // yank selection away from the linked session.
574
+ const navigationIntentLatched = Boolean(
575
+ state.sessions.navigationIntent
576
+ && state.sessions.navigationIntent.status !== "failed",
577
+ );
578
+ const hasActive = Object.prototype.hasOwnProperty.call(settings, "activeSessionId")
579
+ && !navigationIntentLatched;
519
580
  const hasLoadedSessions = Object.keys(state.sessions.byId || {}).length > 0;
520
581
  const nextLayout = hasLayout
521
582
  ? {
@@ -693,6 +754,10 @@ export function appReducer(state, action) {
693
754
  const nextSessions = {
694
755
  ...state.sessions,
695
756
  filterQuery: typeof action.query === "string" ? action.query : "",
757
+ // A filter change is an explicit user action: it releases
758
+ // the deep-link latch and its transient filter exception.
759
+ navigationIntent: null,
760
+ filterExceptionId: null,
696
761
  };
697
762
  const selection = applyVisibleSessionSelection(state, nextSessions);
698
763
  return {
@@ -708,6 +773,8 @@ export function appReducer(state, action) {
708
773
  ...state.sessions,
709
774
  ownerFilterExplicit: true,
710
775
  ownerFilter: normalizeSessionOwnerFilter(action.filter),
776
+ navigationIntent: null,
777
+ filterExceptionId: null,
711
778
  };
712
779
  const selection = applyVisibleSessionSelection(state, nextSessions);
713
780
  return {
@@ -717,6 +784,39 @@ export function appReducer(state, action) {
717
784
  };
718
785
  }
719
786
 
787
+ case "sessions/navigationIntent": {
788
+ const sessionId = String(action.sessionId || "").trim();
789
+ if (!sessionId) return state;
790
+ const nextSessions = {
791
+ ...state.sessions,
792
+ navigationIntent: { sessionId, status: "pending" },
793
+ filterExceptionId: null,
794
+ };
795
+ const selection = applyVisibleSessionSelection(state, nextSessions);
796
+ return {
797
+ ...state,
798
+ sessions: selection.sessions,
799
+ ui: selection.ui,
800
+ };
801
+ }
802
+
803
+ case "sessions/navigationIntentFailed": {
804
+ const intent = state.sessions.navigationIntent;
805
+ const sessionId = String(action.sessionId || "").trim();
806
+ if (!intent || (sessionId && intent.sessionId !== sessionId)) return state;
807
+ return {
808
+ ...state,
809
+ sessions: {
810
+ ...state.sessions,
811
+ navigationIntent: {
812
+ sessionId: intent.sessionId,
813
+ status: "failed",
814
+ errorKind: action.errorKind === "not_found" ? "not_found" : "network",
815
+ },
816
+ },
817
+ };
818
+ }
819
+
720
820
  case "ui/modalSelection": {
721
821
  const modal = state.ui.modal;
722
822
  if (!modal) return state;
@@ -991,11 +1091,20 @@ export function appReducer(state, action) {
991
1091
  if (previousActiveId && previousActiveId !== action.sessionId) {
992
1092
  savedChatScroll[previousActiveId] = Number(state.ui.scroll?.chat) || 0;
993
1093
  }
1094
+ // Manual navigation to a different session releases the deep-link
1095
+ // latch and its transient filter exception.
1096
+ const releasesNavigationLatch = Boolean(
1097
+ state.sessions.navigationIntent
1098
+ && state.sessions.navigationIntent.sessionId !== action.sessionId,
1099
+ );
994
1100
  return {
995
1101
  ...state,
996
1102
  sessions: {
997
1103
  ...state.sessions,
998
1104
  activeSessionId: action.sessionId,
1105
+ ...(releasesNavigationLatch
1106
+ ? { navigationIntent: null, filterExceptionId: null }
1107
+ : {}),
999
1108
  },
1000
1109
  ui: {
1001
1110
  ...state.ui,