pilotswarm 0.5.13 → 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/README.md +6 -0
- package/mcp/README.md +14 -2
- package/mcp/dist/src/context.d.ts +13 -0
- package/mcp/dist/src/context.d.ts.map +1 -1
- package/mcp/dist/src/context.js +20 -0
- package/mcp/dist/src/context.js.map +1 -1
- 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 +4 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
- package/mcp/dist/src/tools/capabilities.js +13 -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 +118 -2
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/package.json +3 -2
- package/tui/src/app.js +19 -2
- package/tui/src/auth/cli.js +13 -0
- package/tui/src/node-sdk-transport.js +99 -13
- package/tui/tui-splash-mobile.txt +5 -7
- package/tui/tui-splash.txt +13 -9
- package/ui/core/src/commands.js +2 -0
- package/ui/core/src/controller.js +454 -35
- package/ui/core/src/history.js +19 -1
- package/ui/core/src/reducer.js +121 -8
- package/ui/core/src/selectors.js +204 -14
- package/ui/core/src/state.js +3 -0
- package/ui/core/src/themes/helpers.js +4 -0
- package/ui/react/src/components.js +95 -6
- package/ui/react/src/web-app.js +798 -157
- package/web/api/router.js +7 -6
- package/web/api/ws.js +9 -0
- package/web/auth/index.js +5 -0
- package/web/auth/providers/dev.js +119 -0
- package/web/authz.js +142 -0
- 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/assets/react-l0sNRNKZ.js +1 -0
- package/web/dist/index.html +3 -4
- package/web/runtime.js +553 -37
- package/web/server.js +2 -2
- package/web/dist/assets/index-bQ2QInMX.js +0 -24
- package/web/dist/assets/index-oldX95Tp.css +0 -1
- package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
- package/web/dist/assets/react-C9iQPS2h.js +0 -1
|
@@ -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) {
|
|
@@ -1423,13 +1460,33 @@ export class PilotSwarmUiController {
|
|
|
1423
1460
|
this.scheduleSessionsRefresh(250);
|
|
1424
1461
|
return true;
|
|
1425
1462
|
})().catch((error) => {
|
|
1426
|
-
|
|
1427
|
-
|
|
1463
|
+
const authRefused = error?.code === "FORBIDDEN" || error?.code === "UNAUTHORIZED"
|
|
1464
|
+
|| error?.status === 403 || error?.status === 401;
|
|
1428
1465
|
const items = this.getSessionOutbox(sessionId);
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1466
|
+
if (authRefused) {
|
|
1467
|
+
// Authorization refusals are terminal — retrying can't succeed.
|
|
1468
|
+
// Mark the envelope rejected (renders as the red ✗, same as a
|
|
1469
|
+
// cancelled send) and drop it shortly after instead of leaving
|
|
1470
|
+
// a forever-pending item.
|
|
1471
|
+
const rejected = items.map((item) => (
|
|
1472
|
+
item.id === mergedItem.id ? { ...item, phase: "rejected" } : item
|
|
1473
|
+
));
|
|
1474
|
+
this.setSessionOutboxItems(sessionId, rejected);
|
|
1475
|
+
setTimeout(() => {
|
|
1476
|
+
const current = this.getSessionOutbox(sessionId);
|
|
1477
|
+
const remaining = current.filter((item) => !(item.id === mergedItem.id && item.phase === "rejected"));
|
|
1478
|
+
if (remaining.length !== current.length) {
|
|
1479
|
+
this.setSessionOutboxItems(sessionId, remaining);
|
|
1480
|
+
}
|
|
1481
|
+
}, 6000);
|
|
1482
|
+
} else {
|
|
1483
|
+
// Transient failure: revert the merged envelope back to the
|
|
1484
|
+
// original pending items so the user can edit/retry them.
|
|
1485
|
+
const reverted = items.flatMap((item) => (
|
|
1486
|
+
item.id === mergedItem.id ? pendingItems : [item]
|
|
1487
|
+
));
|
|
1488
|
+
this.setSessionOutboxItems(sessionId, reverted);
|
|
1489
|
+
}
|
|
1433
1490
|
this.dispatch({
|
|
1434
1491
|
type: "ui/status",
|
|
1435
1492
|
text: error?.message || String(error),
|
|
@@ -1676,7 +1733,7 @@ export class PilotSwarmUiController {
|
|
|
1676
1733
|
}).catch(() => {});
|
|
1677
1734
|
}
|
|
1678
1735
|
|
|
1679
|
-
async start() {
|
|
1736
|
+
async start({ initialSessionId = null } = {}) {
|
|
1680
1737
|
await this.transport.start();
|
|
1681
1738
|
const authContext = typeof this.transport.getAuthContext === "function"
|
|
1682
1739
|
? this.transport.getAuthContext()
|
|
@@ -1707,6 +1764,13 @@ export class PilotSwarmUiController {
|
|
|
1707
1764
|
workersOnline: typeof this.transport.getWorkerCount === "function" ? this.transport.getWorkerCount() : null,
|
|
1708
1765
|
statusText: "Connected",
|
|
1709
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
|
+
}
|
|
1710
1774
|
await this.refreshSessions();
|
|
1711
1775
|
this.catalogTimer = setInterval(() => {
|
|
1712
1776
|
this.refreshSessions().catch((error) => {
|
|
@@ -1762,6 +1826,36 @@ export class PilotSwarmUiController {
|
|
|
1762
1826
|
sessions = [...groupRows, ...sessions];
|
|
1763
1827
|
}
|
|
1764
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
|
+
}
|
|
1765
1859
|
const active = previousActive;
|
|
1766
1860
|
if (
|
|
1767
1861
|
active
|
|
@@ -1787,7 +1881,20 @@ export class PilotSwarmUiController {
|
|
|
1787
1881
|
if (selected) {
|
|
1788
1882
|
if (selected !== previousActive) {
|
|
1789
1883
|
if (!this.getState().sessions.byId[selected]?.isGroup) {
|
|
1790
|
-
|
|
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
|
+
}
|
|
1791
1898
|
}
|
|
1792
1899
|
return;
|
|
1793
1900
|
}
|
|
@@ -2999,6 +3106,32 @@ export class PilotSwarmUiController {
|
|
|
2999
3106
|
});
|
|
3000
3107
|
}
|
|
3001
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
|
+
|
|
3002
3135
|
async loadSession(sessionId) {
|
|
3003
3136
|
if (!sessionId) return;
|
|
3004
3137
|
const active = this.getState().sessions.activeSessionId;
|
|
@@ -3174,7 +3307,9 @@ export class PilotSwarmUiController {
|
|
|
3174
3307
|
|
|
3175
3308
|
async createSession(options = {}) {
|
|
3176
3309
|
try {
|
|
3177
|
-
const
|
|
3310
|
+
const requestOptions = this.applyActiveGroupDefault(options);
|
|
3311
|
+
const created = await this.transport.createSession(requestOptions);
|
|
3312
|
+
await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
|
|
3178
3313
|
await this.refreshSessions();
|
|
3179
3314
|
await this.loadSession(created.sessionId);
|
|
3180
3315
|
this.setFocus(FOCUS_REGIONS.PROMPT);
|
|
@@ -3191,7 +3326,9 @@ export class PilotSwarmUiController {
|
|
|
3191
3326
|
throw new Error("Named-agent session creation is not supported by this transport");
|
|
3192
3327
|
}
|
|
3193
3328
|
try {
|
|
3194
|
-
const
|
|
3329
|
+
const requestOptions = this.applyActiveGroupDefault(options);
|
|
3330
|
+
const created = await this.transport.createSessionForAgent(agentName, requestOptions);
|
|
3331
|
+
await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
|
|
3195
3332
|
await this.refreshSessions();
|
|
3196
3333
|
await this.loadSession(created.sessionId);
|
|
3197
3334
|
this.setFocus(FOCUS_REGIONS.PROMPT);
|
|
@@ -3206,6 +3343,20 @@ export class PilotSwarmUiController {
|
|
|
3206
3343
|
}
|
|
3207
3344
|
}
|
|
3208
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
|
+
|
|
3209
3360
|
getMovableGroupSessionSelection() {
|
|
3210
3361
|
const state = this.getState();
|
|
3211
3362
|
const selectedIds = Array.isArray(state.sessions.selectedIds) && state.sessions.selectedIds.length > 0
|
|
@@ -3229,16 +3380,12 @@ export class PilotSwarmUiController {
|
|
|
3229
3380
|
return null;
|
|
3230
3381
|
}
|
|
3231
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.
|
|
3232
3386
|
const groups = await this.transport.listSessionGroups().catch(() => []);
|
|
3233
3387
|
const sessionIds = eligible.map((session) => session.sessionId);
|
|
3234
3388
|
const firstGroupId = eligible.length === 1 ? eligible[0].groupId || null : null;
|
|
3235
|
-
const selectedOwnerKeys = new Set(eligible.map((session) => ownerKeyForPrincipal(session.owner) || ""));
|
|
3236
|
-
const singleOwnerKey = selectedOwnerKeys.size === 1 ? [...selectedOwnerKeys][0] : null;
|
|
3237
|
-
const singleOwner = selectedOwnerKeys.size === 1 ? eligible[0]?.owner ?? null : null;
|
|
3238
|
-
const compatibleGroups = selectedOwnerKeys.size === 1
|
|
3239
|
-
? (Array.isArray(groups) ? groups : []).filter((group) => (ownerKeyForPrincipal(group.owner) || "") === singleOwnerKey)
|
|
3240
|
-
: [];
|
|
3241
|
-
const canCreateOrAssignGroup = selectedOwnerKeys.size === 1;
|
|
3242
3389
|
const items = [
|
|
3243
3390
|
{
|
|
3244
3391
|
id: "__no_group__",
|
|
@@ -3248,22 +3395,20 @@ export class PilotSwarmUiController {
|
|
|
3248
3395
|
groupId: null,
|
|
3249
3396
|
memberCount: 0,
|
|
3250
3397
|
},
|
|
3251
|
-
|
|
3398
|
+
{
|
|
3252
3399
|
id: "__new_group__",
|
|
3253
3400
|
kind: "newGroup",
|
|
3254
3401
|
label: "[New Group]",
|
|
3255
|
-
description: "Create a new group
|
|
3402
|
+
description: "Create a new group, then move the selected session(s) into it.",
|
|
3256
3403
|
groupId: null,
|
|
3257
3404
|
memberCount: 0,
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
...compatibleGroups.map((group) => ({
|
|
3405
|
+
},
|
|
3406
|
+
...(Array.isArray(groups) ? groups : []).map((group) => ({
|
|
3261
3407
|
id: group.groupId,
|
|
3262
3408
|
kind: "group",
|
|
3263
3409
|
label: group.title || group.groupId,
|
|
3264
3410
|
description: group.description || "Move selected session(s) into this group.",
|
|
3265
3411
|
groupId: group.groupId,
|
|
3266
|
-
owner: group.owner ?? null,
|
|
3267
3412
|
memberCount: group.memberCount ?? 0,
|
|
3268
3413
|
})),
|
|
3269
3414
|
];
|
|
@@ -3285,9 +3430,7 @@ export class PilotSwarmUiController {
|
|
|
3285
3430
|
});
|
|
3286
3431
|
this.dispatch({
|
|
3287
3432
|
type: "ui/status",
|
|
3288
|
-
text:
|
|
3289
|
-
? "Choose a group, [New Group], or [No Group]"
|
|
3290
|
-
: "Selected sessions have different owners; only [No Group] is available",
|
|
3433
|
+
text: "Choose a group, [New Group], or [No Group]",
|
|
3291
3434
|
});
|
|
3292
3435
|
return items;
|
|
3293
3436
|
}
|
|
@@ -3302,7 +3445,10 @@ export class PilotSwarmUiController {
|
|
|
3302
3445
|
this.dispatch({ type: "ui/status", text: "No sessions selected to move" });
|
|
3303
3446
|
return null;
|
|
3304
3447
|
}
|
|
3305
|
-
|
|
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") {
|
|
3306
3452
|
await this.transport.moveSessionsToGroup(groupId ?? null, ids);
|
|
3307
3453
|
} else if (groupId && typeof this.transport.assignSessionsToGroup === "function") {
|
|
3308
3454
|
await this.transport.assignSessionsToGroup(groupId, ids);
|
|
@@ -3311,17 +3457,28 @@ export class PilotSwarmUiController {
|
|
|
3311
3457
|
return null;
|
|
3312
3458
|
}
|
|
3313
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
|
+
|
|
3314
3466
|
this.dispatch({ type: "sessions/selectClear" });
|
|
3315
3467
|
await this.refreshSessions();
|
|
3316
|
-
if (groupId) {
|
|
3468
|
+
if (groupId && placedCount > 0) {
|
|
3317
3469
|
await this.loadSession(`group:${groupId}`).catch(() => {});
|
|
3318
3470
|
}
|
|
3319
3471
|
const target = groupId ? `group ${statusTitle || groupId}` : "No Group";
|
|
3472
|
+
const skippedSummary = skippedRows.length > 0
|
|
3473
|
+
? ` · skipped ${skippedRows.length} (${summarizePlacementSkips(skippedRows)})`
|
|
3474
|
+
: "";
|
|
3320
3475
|
this.dispatch({
|
|
3321
3476
|
type: "ui/status",
|
|
3322
|
-
text:
|
|
3477
|
+
text: placedCount > 0
|
|
3478
|
+
? `Moved ${placedCount} session${placedCount === 1 ? "" : "s"} to ${target}${skippedSummary}`
|
|
3479
|
+
: `No sessions moved to ${target}${skippedSummary}`,
|
|
3323
3480
|
});
|
|
3324
|
-
return
|
|
3481
|
+
return placedCount > 0;
|
|
3325
3482
|
}
|
|
3326
3483
|
|
|
3327
3484
|
async confirmSessionGroupPickerModal() {
|
|
@@ -3340,7 +3497,6 @@ export class PilotSwarmUiController {
|
|
|
3340
3497
|
title: "New Group",
|
|
3341
3498
|
previousFocus: modal.previousFocus,
|
|
3342
3499
|
sessionIds: modal.sessionIds || [],
|
|
3343
|
-
owner: item.owner ?? null,
|
|
3344
3500
|
value: baseTitle,
|
|
3345
3501
|
cursorIndex: baseTitle.length,
|
|
3346
3502
|
maxLength: 80,
|
|
@@ -3420,11 +3576,14 @@ export class PilotSwarmUiController {
|
|
|
3420
3576
|
if (previousFocus) this.setFocus(previousFocus);
|
|
3421
3577
|
|
|
3422
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).
|
|
3423
3583
|
const group = await this.transport.createSessionGroup({
|
|
3424
3584
|
title,
|
|
3425
3585
|
description: `${(modal.sessionIds || []).length} grouped session${(modal.sessionIds || []).length === 1 ? "" : "s"}`,
|
|
3426
3586
|
sessionIds: modal.sessionIds || [],
|
|
3427
|
-
owner: modal.owner ?? null,
|
|
3428
3587
|
});
|
|
3429
3588
|
await this.moveSessionsToGroup(group.groupId, modal.sessionIds || [], { statusTitle: group.title || title });
|
|
3430
3589
|
} catch (error) {
|
|
@@ -3684,15 +3843,21 @@ export class PilotSwarmUiController {
|
|
|
3684
3843
|
this.dispatch({ type: "ui/status", text: "Select a model and press Enter" });
|
|
3685
3844
|
}
|
|
3686
3845
|
|
|
3687
|
-
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;
|
|
3688
3851
|
const state = this.getState();
|
|
3689
3852
|
const sessionId = state.sessions.activeSessionId;
|
|
3690
3853
|
const session = sessionId ? state.sessions.byId[sessionId] || null : null;
|
|
3691
3854
|
if (!session || session.isGroup) {
|
|
3855
|
+
this._onSwitchModelApplied = null;
|
|
3692
3856
|
this.dispatch({ type: "ui/status", text: "Select a session before switching model" });
|
|
3693
3857
|
return;
|
|
3694
3858
|
}
|
|
3695
3859
|
if (typeof this.transport.setSessionModel !== "function") {
|
|
3860
|
+
this._onSwitchModelApplied = null;
|
|
3696
3861
|
this.dispatch({ type: "ui/status", text: "Model switching is not supported by this transport" });
|
|
3697
3862
|
return;
|
|
3698
3863
|
}
|
|
@@ -3725,6 +3890,11 @@ export class PilotSwarmUiController {
|
|
|
3725
3890
|
const tierSuffix = options.contextTier ? ` · ${CONTEXT_TIER_LABELS[options.contextTier] || options.contextTier}` : "";
|
|
3726
3891
|
this.dispatch({ type: "ui/status", text: `Next turn will use ${modelLabel}${tierSuffix}` });
|
|
3727
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();
|
|
3728
3898
|
}
|
|
3729
3899
|
|
|
3730
3900
|
openHelpModal() {
|
|
@@ -4351,6 +4521,245 @@ export class PilotSwarmUiController {
|
|
|
4351
4521
|
}
|
|
4352
4522
|
}
|
|
4353
4523
|
|
|
4524
|
+
/**
|
|
4525
|
+
* Cycle the active session's general visibility:
|
|
4526
|
+
* private → shared_read → shared_write → private. Visibility is a
|
|
4527
|
+
* per-session property, so system sessions and group rows are refused.
|
|
4528
|
+
*/
|
|
4529
|
+
async cycleSessionVisibility() {
|
|
4530
|
+
const state = this.getState();
|
|
4531
|
+
const sessionId = state.sessions.activeSessionId;
|
|
4532
|
+
const session = sessionId ? state.sessions.byId[sessionId] : null;
|
|
4533
|
+
if (!session) {
|
|
4534
|
+
this.dispatch({ type: "ui/status", text: "No session selected" });
|
|
4535
|
+
return;
|
|
4536
|
+
}
|
|
4537
|
+
if (session.isSystem) {
|
|
4538
|
+
this.dispatch({ type: "ui/status", text: "System sessions are always private" });
|
|
4539
|
+
return;
|
|
4540
|
+
}
|
|
4541
|
+
if (session.isGroup) {
|
|
4542
|
+
this.dispatch({ type: "ui/status", text: "Groups don't have visibility" });
|
|
4543
|
+
return;
|
|
4544
|
+
}
|
|
4545
|
+
if (typeof this.transport.setSessionVisibility !== "function") {
|
|
4546
|
+
this.dispatch({ type: "ui/status", text: "Not supported by this transport" });
|
|
4547
|
+
return;
|
|
4548
|
+
}
|
|
4549
|
+
const next = cycleValue(
|
|
4550
|
+
["private", "shared_read", "shared_write"],
|
|
4551
|
+
session.visibility || "private",
|
|
4552
|
+
1,
|
|
4553
|
+
);
|
|
4554
|
+
try {
|
|
4555
|
+
await this.transport.setSessionVisibility(sessionId, next);
|
|
4556
|
+
await this.refreshSessions();
|
|
4557
|
+
this.dispatch({ type: "ui/status", text: `Visibility → ${next}` });
|
|
4558
|
+
} catch (error) {
|
|
4559
|
+
this.dispatch({ type: "ui/status", text: error?.message || String(error) });
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4563
|
+
/**
|
|
4564
|
+
* Open the Share modal for the active session: shows the general
|
|
4565
|
+
* visibility plus the per-person grant list, with a single input line
|
|
4566
|
+
* for edits — `name [r|w]` grants, `-name` revokes. The current grants
|
|
4567
|
+
* are loaded up front so the modal (and revoke matching) can render
|
|
4568
|
+
* them without another round trip.
|
|
4569
|
+
*/
|
|
4570
|
+
async openShareSessionModal() {
|
|
4571
|
+
const state = this.getState();
|
|
4572
|
+
const sessionId = state.sessions.activeSessionId;
|
|
4573
|
+
const session = sessionId ? state.sessions.byId[sessionId] : null;
|
|
4574
|
+
if (!session) {
|
|
4575
|
+
this.dispatch({ type: "ui/status", text: "No session selected" });
|
|
4576
|
+
return;
|
|
4577
|
+
}
|
|
4578
|
+
if (session.isSystem) {
|
|
4579
|
+
this.dispatch({ type: "ui/status", text: "System sessions are always private" });
|
|
4580
|
+
return;
|
|
4581
|
+
}
|
|
4582
|
+
if (session.isGroup) {
|
|
4583
|
+
this.dispatch({ type: "ui/status", text: "Groups can't be shared" });
|
|
4584
|
+
return;
|
|
4585
|
+
}
|
|
4586
|
+
if (typeof this.transport.listSessionShares !== "function") {
|
|
4587
|
+
this.dispatch({ type: "ui/status", text: "Session sharing is not supported by this transport" });
|
|
4588
|
+
return;
|
|
4589
|
+
}
|
|
4590
|
+
let shares;
|
|
4591
|
+
try {
|
|
4592
|
+
shares = await this.transport.listSessionShares(sessionId);
|
|
4593
|
+
} catch (error) {
|
|
4594
|
+
this.dispatch({ type: "ui/status", text: error?.message || String(error) });
|
|
4595
|
+
return;
|
|
4596
|
+
}
|
|
4597
|
+
this.dispatch({
|
|
4598
|
+
type: "ui/modal",
|
|
4599
|
+
modal: {
|
|
4600
|
+
type: "shareSession",
|
|
4601
|
+
title: `Share (${shortSessionIdValue(sessionId)})`,
|
|
4602
|
+
sessionId,
|
|
4603
|
+
previousFocus: state.ui.focusRegion,
|
|
4604
|
+
value: "",
|
|
4605
|
+
cursorIndex: 0,
|
|
4606
|
+
shares: Array.isArray(shares) ? shares : [],
|
|
4607
|
+
visibility: session.visibility || "private",
|
|
4608
|
+
},
|
|
4609
|
+
});
|
|
4610
|
+
this.dispatch({ type: "ui/status", text: "name [r|w] grants · -name revokes · Enter apply" });
|
|
4611
|
+
}
|
|
4612
|
+
|
|
4613
|
+
updateShareSessionModal(updater) {
|
|
4614
|
+
const modal = this.getState().ui.modal;
|
|
4615
|
+
if (!modal || modal.type !== "shareSession") return null;
|
|
4616
|
+
const nextModal = typeof updater === "function" ? updater(modal) : updater;
|
|
4617
|
+
if (!nextModal) return null;
|
|
4618
|
+
this.dispatch({ type: "ui/modal", modal: { ...modal, ...nextModal } });
|
|
4619
|
+
return this.getState().ui.modal;
|
|
4620
|
+
}
|
|
4621
|
+
|
|
4622
|
+
setShareSessionValue(value, cursorIndex = String(value || "").length) {
|
|
4623
|
+
const modal = this.getState().ui.modal;
|
|
4624
|
+
if (!modal || modal.type !== "shareSession") return;
|
|
4625
|
+
const safeValue = clampRenameSessionValue(value, 120);
|
|
4626
|
+
const safeCursor = clampPromptCursor(safeValue, cursorIndex);
|
|
4627
|
+
this.updateShareSessionModal({ value: safeValue, cursorIndex: safeCursor });
|
|
4628
|
+
}
|
|
4629
|
+
|
|
4630
|
+
insertShareSessionText(text) {
|
|
4631
|
+
const modal = this.getState().ui.modal;
|
|
4632
|
+
if (!modal || modal.type !== "shareSession") return;
|
|
4633
|
+
const next = insertPromptTextAtCursor(modal.value || "", modal.cursorIndex || 0, clampRenameSessionValue(text, 120));
|
|
4634
|
+
this.setShareSessionValue(next.prompt, next.cursor);
|
|
4635
|
+
}
|
|
4636
|
+
|
|
4637
|
+
deleteShareSessionChar() {
|
|
4638
|
+
const modal = this.getState().ui.modal;
|
|
4639
|
+
if (!modal || modal.type !== "shareSession") return;
|
|
4640
|
+
const next = deletePromptCharBackward(modal.value || "", modal.cursorIndex || 0);
|
|
4641
|
+
this.setShareSessionValue(next.prompt, next.cursor);
|
|
4642
|
+
}
|
|
4643
|
+
|
|
4644
|
+
moveShareSessionCursor(delta) {
|
|
4645
|
+
const modal = this.getState().ui.modal;
|
|
4646
|
+
if (!modal || modal.type !== "shareSession") return;
|
|
4647
|
+
this.setShareSessionValue(modal.value || "", clampPromptCursor(modal.value || "", (modal.cursorIndex || 0) + delta));
|
|
4648
|
+
}
|
|
4649
|
+
|
|
4650
|
+
moveShareSessionCursorToBoundary(kind) {
|
|
4651
|
+
const modal = this.getState().ui.modal;
|
|
4652
|
+
if (!modal || modal.type !== "shareSession") return;
|
|
4653
|
+
this.setShareSessionValue(modal.value || "", kind === "start" ? 0 : String(modal.value || "").length);
|
|
4654
|
+
}
|
|
4655
|
+
|
|
4656
|
+
/**
|
|
4657
|
+
* Apply the typed share command. Empty input just closes the modal.
|
|
4658
|
+
* `-name` (or `revoke name`) revokes an existing grant; anything else
|
|
4659
|
+
* grants, with an optional trailing r/read/w/write access token
|
|
4660
|
+
* (default read). The grantee is resolved against the member directory
|
|
4661
|
+
* when the transport exposes it; unmatched text falls back to a raw
|
|
4662
|
+
* subject so a grant can target someone who has never signed in —
|
|
4663
|
+
* an email-keyed grant binds at their first sign-in.
|
|
4664
|
+
*/
|
|
4665
|
+
async confirmShareSessionModal() {
|
|
4666
|
+
const modal = this.getState().ui.modal;
|
|
4667
|
+
if (!modal || modal.type !== "shareSession") return;
|
|
4668
|
+
const sessionId = modal.sessionId;
|
|
4669
|
+
if (!sessionId) return;
|
|
4670
|
+
|
|
4671
|
+
const raw = String(modal.value || "").trim();
|
|
4672
|
+
const previousFocus = modal.previousFocus;
|
|
4673
|
+
if (!raw) {
|
|
4674
|
+
this.dispatch({ type: "ui/modal", modal: null });
|
|
4675
|
+
if (previousFocus) this.setFocus(previousFocus);
|
|
4676
|
+
return;
|
|
4677
|
+
}
|
|
4678
|
+
|
|
4679
|
+
// Revoke: `-name` or `revoke name` targets an existing grant by
|
|
4680
|
+
// subject, email, or display name (case-insensitive).
|
|
4681
|
+
const revokeWho = raw.startsWith("-")
|
|
4682
|
+
? raw.slice(1).trim()
|
|
4683
|
+
: (raw.toLowerCase().startsWith("revoke ") ? raw.slice("revoke ".length).trim() : null);
|
|
4684
|
+
if (revokeWho !== null) {
|
|
4685
|
+
const needle = revokeWho.toLowerCase();
|
|
4686
|
+
const grant = (modal.shares || []).find((row) =>
|
|
4687
|
+
(row.subject && String(row.subject).toLowerCase() === needle)
|
|
4688
|
+
|| (row.email && String(row.email).toLowerCase() === needle)
|
|
4689
|
+
|| (row.displayName && String(row.displayName).toLowerCase() === needle));
|
|
4690
|
+
if (!grant) {
|
|
4691
|
+
this.dispatch({ type: "ui/status", text: `No grant matching "${revokeWho}"` });
|
|
4692
|
+
return;
|
|
4693
|
+
}
|
|
4694
|
+
this.dispatch({ type: "ui/modal", modal: null });
|
|
4695
|
+
if (previousFocus) this.setFocus(previousFocus);
|
|
4696
|
+
try {
|
|
4697
|
+
await this.transport.revokeSessionShare(sessionId, { provider: grant.provider, subject: grant.subject });
|
|
4698
|
+
this.dispatch({ type: "ui/status", text: `Revoked ${revokeWho}` });
|
|
4699
|
+
} catch (error) {
|
|
4700
|
+
this.dispatch({ type: "ui/status", text: error?.message || String(error) });
|
|
4701
|
+
}
|
|
4702
|
+
return;
|
|
4703
|
+
}
|
|
4704
|
+
|
|
4705
|
+
// Grant: an optional trailing access token picks read/write.
|
|
4706
|
+
const tokens = raw.split(/\s+/);
|
|
4707
|
+
const accessByToken = { r: "read", read: "read", w: "write", write: "write" };
|
|
4708
|
+
const lastToken = tokens.length > 1 ? tokens[tokens.length - 1].toLowerCase() : null;
|
|
4709
|
+
const access = lastToken && accessByToken[lastToken] ? accessByToken[lastToken] : "read";
|
|
4710
|
+
const who = lastToken && accessByToken[lastToken] ? tokens.slice(0, -1).join(" ") : raw;
|
|
4711
|
+
|
|
4712
|
+
// Resolve the typed text against the member directory: an exact
|
|
4713
|
+
// displayName/email/subject match wins; otherwise a unique partial
|
|
4714
|
+
// match is accepted and multiple partial matches are ambiguous.
|
|
4715
|
+
const needle = who.toLowerCase();
|
|
4716
|
+
let grantee = null;
|
|
4717
|
+
if (typeof this.transport.listKnownUsers === "function") {
|
|
4718
|
+
let users = [];
|
|
4719
|
+
try {
|
|
4720
|
+
users = await this.transport.listKnownUsers({ limit: 500 });
|
|
4721
|
+
} catch {
|
|
4722
|
+
users = [];
|
|
4723
|
+
}
|
|
4724
|
+
const candidates = Array.isArray(users) ? users : [];
|
|
4725
|
+
grantee = candidates.find((user) =>
|
|
4726
|
+
(user.displayName && user.displayName.toLowerCase() === needle)
|
|
4727
|
+
|| (user.email && user.email.toLowerCase() === needle)
|
|
4728
|
+
|| (user.subject && user.subject.toLowerCase() === needle)) || null;
|
|
4729
|
+
if (!grantee) {
|
|
4730
|
+
const partial = candidates.filter((user) =>
|
|
4731
|
+
(user.displayName && user.displayName.toLowerCase().includes(needle))
|
|
4732
|
+
|| (user.email && user.email.toLowerCase().includes(needle))
|
|
4733
|
+
|| (user.subject && user.subject.toLowerCase().includes(needle)));
|
|
4734
|
+
if (partial.length === 1) {
|
|
4735
|
+
grantee = partial[0];
|
|
4736
|
+
} else if (partial.length > 1) {
|
|
4737
|
+
this.dispatch({ type: "ui/status", text: `"${who}" is ambiguous (${partial.length} matches)` });
|
|
4738
|
+
return;
|
|
4739
|
+
}
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
if (!grantee) {
|
|
4743
|
+
// Not-yet-seen user: treat the text as a raw subject under the
|
|
4744
|
+
// caller's provider (mirrors the portal's resolveGrantee).
|
|
4745
|
+
const principal = this.getState().auth?.principal || null;
|
|
4746
|
+
grantee = { provider: principal?.provider || "dev", subject: who, email: null, displayName: null };
|
|
4747
|
+
}
|
|
4748
|
+
|
|
4749
|
+
this.dispatch({ type: "ui/modal", modal: null });
|
|
4750
|
+
if (previousFocus) this.setFocus(previousFocus);
|
|
4751
|
+
try {
|
|
4752
|
+
await this.transport.grantSessionShare(
|
|
4753
|
+
sessionId,
|
|
4754
|
+
{ provider: grantee.provider, subject: grantee.subject, email: grantee.email ?? null, displayName: grantee.displayName ?? null },
|
|
4755
|
+
access,
|
|
4756
|
+
);
|
|
4757
|
+
this.dispatch({ type: "ui/status", text: `Granted ${access} to ${who}` });
|
|
4758
|
+
} catch (error) {
|
|
4759
|
+
this.dispatch({ type: "ui/status", text: error?.message || String(error) });
|
|
4760
|
+
}
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4354
4763
|
closeModal() {
|
|
4355
4764
|
const modal = this.getState().ui.modal;
|
|
4356
4765
|
if (!modal) return;
|
|
@@ -4455,6 +4864,10 @@ export class PilotSwarmUiController {
|
|
|
4455
4864
|
await this.confirmRenameSessionModal();
|
|
4456
4865
|
return;
|
|
4457
4866
|
}
|
|
4867
|
+
if (modal.type === "shareSession") {
|
|
4868
|
+
await this.confirmShareSessionModal();
|
|
4869
|
+
return;
|
|
4870
|
+
}
|
|
4458
4871
|
if (modal.type === "sessionGroupPicker") {
|
|
4459
4872
|
await this.confirmSessionGroupPickerModal();
|
|
4460
4873
|
return;
|
|
@@ -6261,6 +6674,12 @@ export class PilotSwarmUiController {
|
|
|
6261
6674
|
case UI_COMMANDS.OPEN_RENAME_SESSION:
|
|
6262
6675
|
this.openRenameSessionModal();
|
|
6263
6676
|
return;
|
|
6677
|
+
case UI_COMMANDS.CYCLE_SESSION_VISIBILITY:
|
|
6678
|
+
await this.cycleSessionVisibility();
|
|
6679
|
+
return;
|
|
6680
|
+
case UI_COMMANDS.OPEN_SHARE_SESSION:
|
|
6681
|
+
await this.openShareSessionModal();
|
|
6682
|
+
return;
|
|
6264
6683
|
case UI_COMMANDS.OPEN_SESSION_FILTER:
|
|
6265
6684
|
this.openSessionOwnerFilter();
|
|
6266
6685
|
return;
|