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.
- package/mcp/README.md +2 -2
- package/mcp/dist/src/server.d.ts.map +1 -1
- package/mcp/dist/src/server.js +5 -1
- package/mcp/dist/src/server.js.map +1 -1
- package/mcp/dist/src/tools/capabilities.d.ts +1 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
- package/mcp/dist/src/tools/capabilities.js +5 -0
- package/mcp/dist/src/tools/capabilities.js.map +1 -1
- package/mcp/dist/src/tools/groups.d.ts +5 -4
- package/mcp/dist/src/tools/groups.d.ts.map +1 -1
- package/mcp/dist/src/tools/groups.js +43 -20
- package/mcp/dist/src/tools/groups.js.map +1 -1
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +4 -2
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/package.json +2 -2
- package/tui/src/node-sdk-transport.js +47 -6
- package/ui/core/src/controller.js +179 -29
- package/ui/core/src/reducer.js +117 -8
- package/ui/core/src/selectors.js +65 -0
- package/ui/core/src/state.js +3 -0
- package/ui/react/src/web-app.js +320 -117
- package/web/dist/assets/index-CZizkB5Z.js +24 -0
- package/web/dist/assets/index-D9e2TGjO.css +1 -0
- package/web/dist/assets/pilotswarm-KMqn3ZJs.js +90 -0
- package/web/dist/index.html +3 -3
- package/web/runtime.js +114 -42
- package/web/dist/assets/index-BnxC8cNG.js +0 -24
- package/web/dist/assets/index-Bx6KHIaj.css +0 -1
- package/web/dist/assets/pilotswarm-NE7H63ha.js +0 -90
|
@@ -92,7 +92,9 @@ function normalizeSessionListRow(session) {
|
|
|
92
92
|
if (session.isGroup) return session;
|
|
93
93
|
return {
|
|
94
94
|
...session,
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3398
|
+
{
|
|
3272
3399
|
id: "__new_group__",
|
|
3273
3400
|
kind: "newGroup",
|
|
3274
3401
|
label: "[New Group]",
|
|
3275
|
-
description: "Create a new group
|
|
3402
|
+
description: "Create a new group, then move the selected session(s) into it.",
|
|
3276
3403
|
groupId: null,
|
|
3277
3404
|
memberCount: 0,
|
|
3278
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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() {
|
package/ui/core/src/reducer.js
CHANGED
|
@@ -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
|
|
419
|
+
sessions,
|
|
367
420
|
};
|
|
368
|
-
const nextActiveSessionId = resolveVisibleActiveSessionId(nextState, Object.values(
|
|
369
|
-
const nextCollapsedIds = reconcileCollapsedIdsForActiveSession(
|
|
370
|
-
const collapsedChanged = !setsEqual(nextCollapsedIds,
|
|
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
|
-
...
|
|
426
|
+
...sessions,
|
|
374
427
|
collapsedIds: nextCollapsedIds,
|
|
375
|
-
flat: buildSessionTree(Object.values(
|
|
428
|
+
flat: buildSessionTree(Object.values(sessions.byId || {}), nextCollapsedIds, sessions.orderById, sessions.pinnedIds),
|
|
376
429
|
}
|
|
377
|
-
:
|
|
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
|
-
|
|
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,
|