pilotswarm 0.5.14 → 0.5.16
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/plugins/.mcp.json +7 -1
- package/tui/plugins/agents/deepwiki.agent.md +44 -0
- package/tui/src/node-sdk-transport.js +61 -8
- package/ui/core/src/controller.js +264 -40
- package/ui/core/src/formatting.js +25 -0
- package/ui/core/src/reducer.js +117 -8
- package/ui/core/src/selectors.js +76 -3
- package/ui/core/src/state.js +3 -0
- package/ui/react/src/web-app.js +363 -127
- package/web/api/router.js +3 -1
- package/web/dist/assets/index-Df1mD9p0.js +24 -0
- package/web/dist/assets/index-whIft7M6.css +1 -0
- package/web/dist/assets/pilotswarm-DR5j-z8y.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();
|
|
@@ -190,6 +216,22 @@ const CONTEXT_TIER_LABELS = {
|
|
|
190
216
|
long_context: "Long context (larger window, higher cost)",
|
|
191
217
|
};
|
|
192
218
|
|
|
219
|
+
function formatContextWindowSize(value) {
|
|
220
|
+
const tokens = Number(value);
|
|
221
|
+
if (!Number.isSafeInteger(tokens) || tokens <= 0) return null;
|
|
222
|
+
if (tokens % 1_000_000 === 0) return `${tokens / 1_000_000}M`;
|
|
223
|
+
if (tokens % 1_000 === 0) return `${tokens / 1_000}K`;
|
|
224
|
+
return tokens.toLocaleString("en-US");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function formatContextTierLabel(tier, tokenLimit) {
|
|
228
|
+
const size = formatContextWindowSize(tokenLimit);
|
|
229
|
+
if (!size) return CONTEXT_TIER_LABELS[tier] || tier;
|
|
230
|
+
return tier === "long_context"
|
|
231
|
+
? `Long context (${size} tokens, higher cost)`
|
|
232
|
+
: `Default (${size} tokens)`;
|
|
233
|
+
}
|
|
234
|
+
|
|
193
235
|
function extractSessionModelFromEvents(events = []) {
|
|
194
236
|
// Only explicit model-change events may update the session's model.
|
|
195
237
|
// Deriving it from any event that happens to carry a `model` field lets
|
|
@@ -506,6 +548,12 @@ function buildSessionOwnerFilterItems(state) {
|
|
|
506
548
|
ownerKey: principalKey,
|
|
507
549
|
description: "Show sessions owned by the authenticated user currently signed in.",
|
|
508
550
|
});
|
|
551
|
+
items.push({
|
|
552
|
+
id: "shared",
|
|
553
|
+
kind: "shared",
|
|
554
|
+
label: "Shared with me",
|
|
555
|
+
description: "Show sessions other users have shared with you.",
|
|
556
|
+
});
|
|
509
557
|
}
|
|
510
558
|
|
|
511
559
|
const ownersByKey = new Map();
|
|
@@ -542,6 +590,7 @@ export function defaultOwnerFilterForPrincipal(principal) {
|
|
|
542
590
|
includeSystem: true,
|
|
543
591
|
includeUnowned: false,
|
|
544
592
|
includeMe: true,
|
|
593
|
+
includeShared: true,
|
|
545
594
|
ownerKeys: [],
|
|
546
595
|
}
|
|
547
596
|
: {
|
|
@@ -549,6 +598,7 @@ export function defaultOwnerFilterForPrincipal(principal) {
|
|
|
549
598
|
includeSystem: false,
|
|
550
599
|
includeUnowned: false,
|
|
551
600
|
includeMe: false,
|
|
601
|
+
includeShared: false,
|
|
552
602
|
ownerKeys: [],
|
|
553
603
|
};
|
|
554
604
|
}
|
|
@@ -558,6 +608,7 @@ function ownerFilterHasSelections(filter) {
|
|
|
558
608
|
filter?.includeSystem
|
|
559
609
|
|| filter?.includeUnowned
|
|
560
610
|
|| filter?.includeMe
|
|
611
|
+
|| filter?.includeShared
|
|
561
612
|
|| (Array.isArray(filter?.ownerKeys) && filter.ownerKeys.length > 0),
|
|
562
613
|
);
|
|
563
614
|
}
|
|
@@ -571,6 +622,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
|
|
|
571
622
|
includeSystem: false,
|
|
572
623
|
includeUnowned: false,
|
|
573
624
|
includeMe: false,
|
|
625
|
+
includeShared: false,
|
|
574
626
|
ownerKeys: [],
|
|
575
627
|
};
|
|
576
628
|
}
|
|
@@ -583,6 +635,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
|
|
|
583
635
|
if (item.kind === "system") next.includeSystem = !next.includeSystem;
|
|
584
636
|
if (item.kind === "unowned") next.includeUnowned = !next.includeUnowned;
|
|
585
637
|
if (item.kind === "me") next.includeMe = !next.includeMe;
|
|
638
|
+
if (item.kind === "shared") next.includeShared = !next.includeShared;
|
|
586
639
|
if (item.kind === "owner" && item.ownerKey) {
|
|
587
640
|
const existingIndex = next.ownerKeys.indexOf(item.ownerKey);
|
|
588
641
|
if (existingIndex >= 0) {
|
|
@@ -594,7 +647,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
|
|
|
594
647
|
|
|
595
648
|
return ownerFilterHasSelections(next)
|
|
596
649
|
? next
|
|
597
|
-
: { all: true, includeSystem: false, includeUnowned: false, includeMe: false, ownerKeys: [] };
|
|
650
|
+
: { all: true, includeSystem: false, includeUnowned: false, includeMe: false, includeShared: false, ownerKeys: [] };
|
|
598
651
|
}
|
|
599
652
|
|
|
600
653
|
function getRenameSessionPrefix(session) {
|
|
@@ -1066,6 +1119,7 @@ export class PilotSwarmUiController {
|
|
|
1066
1119
|
text: String(prompt || ""),
|
|
1067
1120
|
createdAt: Date.now(),
|
|
1068
1121
|
phase: normalizedPhase,
|
|
1122
|
+
attempted: false,
|
|
1069
1123
|
clientMessageIds: [id],
|
|
1070
1124
|
};
|
|
1071
1125
|
}
|
|
@@ -1085,9 +1139,9 @@ export class PilotSwarmUiController {
|
|
|
1085
1139
|
}
|
|
1086
1140
|
|
|
1087
1141
|
getEditableOutboxItems(sessionId) {
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
return this.getPendingOutboxItems(sessionId);
|
|
1142
|
+
// Attempted envelopes are immutable because the server may already
|
|
1143
|
+
// have accepted their IDs even when the client saw a transport error.
|
|
1144
|
+
return this.getPendingOutboxItems(sessionId).filter((item) => item?.attempted !== true);
|
|
1091
1145
|
}
|
|
1092
1146
|
|
|
1093
1147
|
getPromptEditSessionMatch(sessionId = this.getState().sessions.activeSessionId) {
|
|
@@ -1375,7 +1429,11 @@ export class PilotSwarmUiController {
|
|
|
1375
1429
|
return false;
|
|
1376
1430
|
}
|
|
1377
1431
|
|
|
1378
|
-
const
|
|
1432
|
+
const allPendingItems = this.getPendingOutboxItems(sessionId);
|
|
1433
|
+
const attemptedItem = allPendingItems.find((item) => item?.attempted === true);
|
|
1434
|
+
const pendingItems = attemptedItem
|
|
1435
|
+
? [attemptedItem]
|
|
1436
|
+
: allPendingItems.filter((item) => item?.attempted !== true);
|
|
1379
1437
|
if (pendingItems.length === 0) return false;
|
|
1380
1438
|
|
|
1381
1439
|
// Merge all current pending items into a single durable envelope.
|
|
@@ -1393,6 +1451,7 @@ export class PilotSwarmUiController {
|
|
|
1393
1451
|
text: mergedText,
|
|
1394
1452
|
createdAt: pendingItems[0].createdAt || Date.now(),
|
|
1395
1453
|
phase: "pending",
|
|
1454
|
+
attempted: true,
|
|
1396
1455
|
clientMessageIds: mergedClientMessageIds,
|
|
1397
1456
|
};
|
|
1398
1457
|
const pendingIdSet = new Set(pendingItems.map((item) => item.id));
|
|
@@ -1443,10 +1502,11 @@ export class PilotSwarmUiController {
|
|
|
1443
1502
|
}
|
|
1444
1503
|
}, 6000);
|
|
1445
1504
|
} else {
|
|
1446
|
-
// Transient failure:
|
|
1447
|
-
//
|
|
1448
|
-
|
|
1449
|
-
|
|
1505
|
+
// Transient failure: preserve the exact attempted envelope.
|
|
1506
|
+
// Re-merging it with fresh messages could make server-side
|
|
1507
|
+
// duplicate suppression drop the fresh content too.
|
|
1508
|
+
const reverted = items.map((item) => (
|
|
1509
|
+
item.id === mergedItem.id ? { ...mergedItem, phase: "pending", attempted: true } : item
|
|
1450
1510
|
));
|
|
1451
1511
|
this.setSessionOutboxItems(sessionId, reverted);
|
|
1452
1512
|
}
|
|
@@ -1696,7 +1756,7 @@ export class PilotSwarmUiController {
|
|
|
1696
1756
|
}).catch(() => {});
|
|
1697
1757
|
}
|
|
1698
1758
|
|
|
1699
|
-
async start() {
|
|
1759
|
+
async start({ initialSessionId = null } = {}) {
|
|
1700
1760
|
await this.transport.start();
|
|
1701
1761
|
const authContext = typeof this.transport.getAuthContext === "function"
|
|
1702
1762
|
? this.transport.getAuthContext()
|
|
@@ -1727,6 +1787,13 @@ export class PilotSwarmUiController {
|
|
|
1727
1787
|
workersOnline: typeof this.transport.getWorkerCount === "function" ? this.transport.getWorkerCount() : null,
|
|
1728
1788
|
statusText: "Connected",
|
|
1729
1789
|
});
|
|
1790
|
+
// Latch the deep-link intent AFTER the default owner-filter dispatch
|
|
1791
|
+
// (a filter change releases the latch) and BEFORE the first refresh,
|
|
1792
|
+
// so the first catalog load resolves selection onto the link target.
|
|
1793
|
+
const deepLinkSessionId = String(initialSessionId || "").trim();
|
|
1794
|
+
if (deepLinkSessionId) {
|
|
1795
|
+
this.dispatch({ type: "sessions/navigationIntent", sessionId: deepLinkSessionId });
|
|
1796
|
+
}
|
|
1730
1797
|
await this.refreshSessions();
|
|
1731
1798
|
this.catalogTimer = setInterval(() => {
|
|
1732
1799
|
this.refreshSessions().catch((error) => {
|
|
@@ -1782,6 +1849,36 @@ export class PilotSwarmUiController {
|
|
|
1782
1849
|
sessions = [...groupRows, ...sessions];
|
|
1783
1850
|
}
|
|
1784
1851
|
}
|
|
1852
|
+
// A pending deep-link target may be readable but absent from the
|
|
1853
|
+
// paged catalog window — fetch it explicitly. A definitive failure
|
|
1854
|
+
// (unknown or unshared: identical 404 shapes) fails the intent; the
|
|
1855
|
+
// reducer then refuses fallback selection so the renderer can show
|
|
1856
|
+
// the nav error instead of silently landing somewhere else.
|
|
1857
|
+
const pendingIntent = preRefreshState.sessions.navigationIntent;
|
|
1858
|
+
if (
|
|
1859
|
+
pendingIntent?.status === "pending"
|
|
1860
|
+
&& !sessions.some((session) => session?.sessionId === pendingIntent.sessionId)
|
|
1861
|
+
&& typeof this.transport.getSession === "function"
|
|
1862
|
+
) {
|
|
1863
|
+
try {
|
|
1864
|
+
const intentSession = await this.transport.getSession(pendingIntent.sessionId);
|
|
1865
|
+
if (intentSession?.sessionId) {
|
|
1866
|
+
sessions = [...sessions, normalizeSessionListRow(intentSession)];
|
|
1867
|
+
} else {
|
|
1868
|
+
this.dispatch({
|
|
1869
|
+
type: "sessions/navigationIntentFailed",
|
|
1870
|
+
sessionId: pendingIntent.sessionId,
|
|
1871
|
+
errorKind: "not_found",
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
} catch (error) {
|
|
1875
|
+
this.dispatch({
|
|
1876
|
+
type: "sessions/navigationIntentFailed",
|
|
1877
|
+
sessionId: pendingIntent.sessionId,
|
|
1878
|
+
errorKind: classifyNavigationLoadError(error),
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1785
1882
|
const active = previousActive;
|
|
1786
1883
|
if (
|
|
1787
1884
|
active
|
|
@@ -1807,7 +1904,20 @@ export class PilotSwarmUiController {
|
|
|
1807
1904
|
if (selected) {
|
|
1808
1905
|
if (selected !== previousActive) {
|
|
1809
1906
|
if (!this.getState().sessions.byId[selected]?.isGroup) {
|
|
1810
|
-
|
|
1907
|
+
const selectedIntent = this.getState().sessions.navigationIntent;
|
|
1908
|
+
if (selectedIntent && selectedIntent.sessionId === selected && selectedIntent.status !== "failed") {
|
|
1909
|
+
try {
|
|
1910
|
+
await this.loadSession(selected);
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
this.dispatch({
|
|
1913
|
+
type: "sessions/navigationIntentFailed",
|
|
1914
|
+
sessionId: selected,
|
|
1915
|
+
errorKind: classifyNavigationLoadError(error),
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
} else {
|
|
1919
|
+
await this.loadSession(selected);
|
|
1920
|
+
}
|
|
1811
1921
|
}
|
|
1812
1922
|
return;
|
|
1813
1923
|
}
|
|
@@ -3019,6 +3129,32 @@ export class PilotSwarmUiController {
|
|
|
3019
3129
|
});
|
|
3020
3130
|
}
|
|
3021
3131
|
|
|
3132
|
+
/**
|
|
3133
|
+
* Latch a navigation intent (deep link) onto a session id. The intent
|
|
3134
|
+
* outranks in-memory selection and the profile's activeSessionId until
|
|
3135
|
+
* the user navigates manually or changes a filter. Also the retry path
|
|
3136
|
+
* for a failed intent: re-latching resets it to pending.
|
|
3137
|
+
*/
|
|
3138
|
+
setNavigationIntent(sessionId) {
|
|
3139
|
+
const id = String(sessionId || "").trim();
|
|
3140
|
+
if (!id) return;
|
|
3141
|
+
this.dispatch({ type: "sessions/navigationIntent", sessionId: id });
|
|
3142
|
+
const state = this.getState();
|
|
3143
|
+
if (state.sessions.byId[id]) {
|
|
3144
|
+
this.loadSession(id).catch((error) => {
|
|
3145
|
+
this.dispatch({
|
|
3146
|
+
type: "sessions/navigationIntentFailed",
|
|
3147
|
+
sessionId: id,
|
|
3148
|
+
errorKind: classifyNavigationLoadError(error),
|
|
3149
|
+
});
|
|
3150
|
+
});
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
if (Object.keys(state.sessions.byId).length > 0) {
|
|
3154
|
+
this.scheduleSessionsRefresh(0);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3022
3158
|
async loadSession(sessionId) {
|
|
3023
3159
|
if (!sessionId) return;
|
|
3024
3160
|
const active = this.getState().sessions.activeSessionId;
|
|
@@ -3194,7 +3330,9 @@ export class PilotSwarmUiController {
|
|
|
3194
3330
|
|
|
3195
3331
|
async createSession(options = {}) {
|
|
3196
3332
|
try {
|
|
3197
|
-
const
|
|
3333
|
+
const requestOptions = this.applyActiveGroupDefault(options);
|
|
3334
|
+
const created = await this.transport.createSession(requestOptions);
|
|
3335
|
+
await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
|
|
3198
3336
|
await this.refreshSessions();
|
|
3199
3337
|
await this.loadSession(created.sessionId);
|
|
3200
3338
|
this.setFocus(FOCUS_REGIONS.PROMPT);
|
|
@@ -3211,7 +3349,9 @@ export class PilotSwarmUiController {
|
|
|
3211
3349
|
throw new Error("Named-agent session creation is not supported by this transport");
|
|
3212
3350
|
}
|
|
3213
3351
|
try {
|
|
3214
|
-
const
|
|
3352
|
+
const requestOptions = this.applyActiveGroupDefault(options);
|
|
3353
|
+
const created = await this.transport.createSessionForAgent(agentName, requestOptions);
|
|
3354
|
+
await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
|
|
3215
3355
|
await this.refreshSessions();
|
|
3216
3356
|
await this.loadSession(created.sessionId);
|
|
3217
3357
|
this.setFocus(FOCUS_REGIONS.PROMPT);
|
|
@@ -3226,6 +3366,20 @@ export class PilotSwarmUiController {
|
|
|
3226
3366
|
}
|
|
3227
3367
|
}
|
|
3228
3368
|
|
|
3369
|
+
/**
|
|
3370
|
+
* Post-create placement follow-up. createSession's groupId only places
|
|
3371
|
+
* when an owner principal reaches the catalog (web mode places
|
|
3372
|
+
* server-side; the local TUI passes no owner), so when a group was
|
|
3373
|
+
* requested, place explicitly — idempotent where the server already did.
|
|
3374
|
+
* Skipped when the create response already reports the placement.
|
|
3375
|
+
*/
|
|
3376
|
+
async placeCreatedSessionInGroup(created, groupId) {
|
|
3377
|
+
if (!groupId || !created?.sessionId) return;
|
|
3378
|
+
if (typeof this.transport.placeSessionsInGroup !== "function") return;
|
|
3379
|
+
if ((created.viewerGroupId ?? null) === groupId) return;
|
|
3380
|
+
await this.transport.placeSessionsInGroup([created.sessionId], groupId).catch(() => {});
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3229
3383
|
getMovableGroupSessionSelection() {
|
|
3230
3384
|
const state = this.getState();
|
|
3231
3385
|
const selectedIds = Array.isArray(state.sessions.selectedIds) && state.sessions.selectedIds.length > 0
|
|
@@ -3249,16 +3403,12 @@ export class PilotSwarmUiController {
|
|
|
3249
3403
|
return null;
|
|
3250
3404
|
}
|
|
3251
3405
|
|
|
3406
|
+
// The server returns only the viewer's own groups, and placement is
|
|
3407
|
+
// viewer-private — any readable non-system selection is movable, so
|
|
3408
|
+
// mixed-owner selections are allowed and every group is offered.
|
|
3252
3409
|
const groups = await this.transport.listSessionGroups().catch(() => []);
|
|
3253
3410
|
const sessionIds = eligible.map((session) => session.sessionId);
|
|
3254
3411
|
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
3412
|
const items = [
|
|
3263
3413
|
{
|
|
3264
3414
|
id: "__no_group__",
|
|
@@ -3268,22 +3418,20 @@ export class PilotSwarmUiController {
|
|
|
3268
3418
|
groupId: null,
|
|
3269
3419
|
memberCount: 0,
|
|
3270
3420
|
},
|
|
3271
|
-
|
|
3421
|
+
{
|
|
3272
3422
|
id: "__new_group__",
|
|
3273
3423
|
kind: "newGroup",
|
|
3274
3424
|
label: "[New Group]",
|
|
3275
|
-
description: "Create a new group
|
|
3425
|
+
description: "Create a new group, then move the selected session(s) into it.",
|
|
3276
3426
|
groupId: null,
|
|
3277
3427
|
memberCount: 0,
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
...compatibleGroups.map((group) => ({
|
|
3428
|
+
},
|
|
3429
|
+
...(Array.isArray(groups) ? groups : []).map((group) => ({
|
|
3281
3430
|
id: group.groupId,
|
|
3282
3431
|
kind: "group",
|
|
3283
3432
|
label: group.title || group.groupId,
|
|
3284
3433
|
description: group.description || "Move selected session(s) into this group.",
|
|
3285
3434
|
groupId: group.groupId,
|
|
3286
|
-
owner: group.owner ?? null,
|
|
3287
3435
|
memberCount: group.memberCount ?? 0,
|
|
3288
3436
|
})),
|
|
3289
3437
|
];
|
|
@@ -3305,9 +3453,7 @@ export class PilotSwarmUiController {
|
|
|
3305
3453
|
});
|
|
3306
3454
|
this.dispatch({
|
|
3307
3455
|
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",
|
|
3456
|
+
text: "Choose a group, [New Group], or [No Group]",
|
|
3311
3457
|
});
|
|
3312
3458
|
return items;
|
|
3313
3459
|
}
|
|
@@ -3322,7 +3468,10 @@ export class PilotSwarmUiController {
|
|
|
3322
3468
|
this.dispatch({ type: "ui/status", text: "No sessions selected to move" });
|
|
3323
3469
|
return null;
|
|
3324
3470
|
}
|
|
3325
|
-
|
|
3471
|
+
let placementResults = null;
|
|
3472
|
+
if (typeof this.transport.placeSessionsInGroup === "function") {
|
|
3473
|
+
placementResults = await this.transport.placeSessionsInGroup(ids, groupId ?? null);
|
|
3474
|
+
} else if (typeof this.transport.moveSessionsToGroup === "function") {
|
|
3326
3475
|
await this.transport.moveSessionsToGroup(groupId ?? null, ids);
|
|
3327
3476
|
} else if (groupId && typeof this.transport.assignSessionsToGroup === "function") {
|
|
3328
3477
|
await this.transport.assignSessionsToGroup(groupId, ids);
|
|
@@ -3331,17 +3480,28 @@ export class PilotSwarmUiController {
|
|
|
3331
3480
|
return null;
|
|
3332
3481
|
}
|
|
3333
3482
|
|
|
3483
|
+
const resultRows = Array.isArray(placementResults) ? placementResults.filter(Boolean) : null;
|
|
3484
|
+
const skippedRows = resultRows ? resultRows.filter((row) => row.placed !== true) : [];
|
|
3485
|
+
const placedCount = resultRows
|
|
3486
|
+
? resultRows.filter((row) => row.placed === true).length
|
|
3487
|
+
: ids.length;
|
|
3488
|
+
|
|
3334
3489
|
this.dispatch({ type: "sessions/selectClear" });
|
|
3335
3490
|
await this.refreshSessions();
|
|
3336
|
-
if (groupId) {
|
|
3491
|
+
if (groupId && placedCount > 0) {
|
|
3337
3492
|
await this.loadSession(`group:${groupId}`).catch(() => {});
|
|
3338
3493
|
}
|
|
3339
3494
|
const target = groupId ? `group ${statusTitle || groupId}` : "No Group";
|
|
3495
|
+
const skippedSummary = skippedRows.length > 0
|
|
3496
|
+
? ` · skipped ${skippedRows.length} (${summarizePlacementSkips(skippedRows)})`
|
|
3497
|
+
: "";
|
|
3340
3498
|
this.dispatch({
|
|
3341
3499
|
type: "ui/status",
|
|
3342
|
-
text:
|
|
3500
|
+
text: placedCount > 0
|
|
3501
|
+
? `Moved ${placedCount} session${placedCount === 1 ? "" : "s"} to ${target}${skippedSummary}`
|
|
3502
|
+
: `No sessions moved to ${target}${skippedSummary}`,
|
|
3343
3503
|
});
|
|
3344
|
-
return
|
|
3504
|
+
return placedCount > 0;
|
|
3345
3505
|
}
|
|
3346
3506
|
|
|
3347
3507
|
async confirmSessionGroupPickerModal() {
|
|
@@ -3360,7 +3520,6 @@ export class PilotSwarmUiController {
|
|
|
3360
3520
|
title: "New Group",
|
|
3361
3521
|
previousFocus: modal.previousFocus,
|
|
3362
3522
|
sessionIds: modal.sessionIds || [],
|
|
3363
|
-
owner: item.owner ?? null,
|
|
3364
3523
|
value: baseTitle,
|
|
3365
3524
|
cursorIndex: baseTitle.length,
|
|
3366
3525
|
maxLength: 80,
|
|
@@ -3440,11 +3599,14 @@ export class PilotSwarmUiController {
|
|
|
3440
3599
|
if (previousFocus) this.setFocus(previousFocus);
|
|
3441
3600
|
|
|
3442
3601
|
try {
|
|
3602
|
+
// No owner key: the transport/server stamps the caller's
|
|
3603
|
+
// principal. The UI never infers group ownership from the
|
|
3604
|
+
// selected sessions (owner: null is reserved for the portal
|
|
3605
|
+
// runtime's anonymous path).
|
|
3443
3606
|
const group = await this.transport.createSessionGroup({
|
|
3444
3607
|
title,
|
|
3445
3608
|
description: `${(modal.sessionIds || []).length} grouped session${(modal.sessionIds || []).length === 1 ? "" : "s"}`,
|
|
3446
3609
|
sessionIds: modal.sessionIds || [],
|
|
3447
|
-
owner: modal.owner ?? null,
|
|
3448
3610
|
});
|
|
3449
3611
|
await this.moveSessionsToGroup(group.groupId, modal.sessionIds || [], { statusTitle: group.title || title });
|
|
3450
3612
|
} catch (error) {
|
|
@@ -3624,7 +3786,8 @@ export class PilotSwarmUiController {
|
|
|
3624
3786
|
const items = supported.map((tier) => ({
|
|
3625
3787
|
id: tier,
|
|
3626
3788
|
tier,
|
|
3627
|
-
|
|
3789
|
+
tokenLimit: modelItem?.contextWindowSizes?.[tier] || null,
|
|
3790
|
+
label: formatContextTierLabel(tier, modelItem?.contextWindowSizes?.[tier]),
|
|
3628
3791
|
isDefault: selectedTier === tier,
|
|
3629
3792
|
}));
|
|
3630
3793
|
const selectedIndex = Math.max(0, items.findIndex((item) => item.id === selectedTier));
|
|
@@ -3661,25 +3824,38 @@ export class PilotSwarmUiController {
|
|
|
3661
3824
|
const groupedModels = typeof this.transport.getModelsByProvider === "function"
|
|
3662
3825
|
? this.transport.getModelsByProvider()
|
|
3663
3826
|
: groupModelsByProvider(models);
|
|
3827
|
+
// Whether the current user has a per-user GitHub Copilot key:
|
|
3828
|
+
// true/false when known, null when the transport can't tell (then
|
|
3829
|
+
// no model gets disabled — never lock models out on a guess).
|
|
3830
|
+
const ghcpUserKeySet = await this._resolveGhcpUserKeySet();
|
|
3664
3831
|
const items = [];
|
|
3665
3832
|
const groups = groupedModels
|
|
3666
3833
|
.map((group) => ({
|
|
3667
3834
|
providerId: group.providerId,
|
|
3668
3835
|
providerType: group.type || group.providerType,
|
|
3669
3836
|
models: (group.models || []).map((model) => {
|
|
3837
|
+
const providerType = model.providerType || group.type || group.providerType;
|
|
3838
|
+
// Copilot models with no worker-level token are unusable
|
|
3839
|
+
// for users who haven't stored their own GitHub key.
|
|
3840
|
+
const ghcpKeyMissing = providerType === "github"
|
|
3841
|
+
&& model.credentialAvailable === false
|
|
3842
|
+
&& ghcpUserKeySet === false;
|
|
3670
3843
|
const item = {
|
|
3671
3844
|
id: model.qualifiedName,
|
|
3672
3845
|
qualifiedName: model.qualifiedName,
|
|
3673
3846
|
modelName: model.modelName || model.qualifiedName,
|
|
3674
3847
|
providerId: model.providerId || group.providerId,
|
|
3675
|
-
providerType
|
|
3848
|
+
providerType,
|
|
3676
3849
|
description: model.description || "",
|
|
3677
3850
|
cost: model.cost || null,
|
|
3678
3851
|
supportedReasoningEfforts: normalizeReasoningEfforts(model.supportedReasoningEfforts),
|
|
3679
3852
|
defaultReasoningEffort: model.defaultReasoningEffort || null,
|
|
3680
3853
|
supportedContextTiers: normalizeContextTiers(model.supportedContextTiers),
|
|
3681
3854
|
defaultContextTier: model.defaultContextTier || null,
|
|
3855
|
+
contextWindowSizes: model.contextWindowSizes || null,
|
|
3682
3856
|
isDefault: defaultModel === model.qualifiedName,
|
|
3857
|
+
ghcpKeyMissing,
|
|
3858
|
+
disabled: ghcpKeyMissing,
|
|
3683
3859
|
};
|
|
3684
3860
|
items.push(item);
|
|
3685
3861
|
return item;
|
|
@@ -3688,7 +3864,12 @@ export class PilotSwarmUiController {
|
|
|
3688
3864
|
.filter((group) => group.models.length > 0);
|
|
3689
3865
|
|
|
3690
3866
|
const preferredModel = sessionOptions?.model || defaultModel;
|
|
3691
|
-
|
|
3867
|
+
// Never preselect a key-blocked Copilot model: when the preferred
|
|
3868
|
+
// model (often the catalog default) is unusable, land on the first
|
|
3869
|
+
// usable model instead.
|
|
3870
|
+
let selectedIndex = items.findIndex((model) => model.qualifiedName === preferredModel && !model.disabled);
|
|
3871
|
+
if (selectedIndex < 0) selectedIndex = items.findIndex((model) => !model.disabled);
|
|
3872
|
+
if (selectedIndex < 0) selectedIndex = 0;
|
|
3692
3873
|
this.dispatch({
|
|
3693
3874
|
type: "ui/modal",
|
|
3694
3875
|
modal: {
|
|
@@ -3704,15 +3885,42 @@ export class PilotSwarmUiController {
|
|
|
3704
3885
|
this.dispatch({ type: "ui/status", text: "Select a model and press Enter" });
|
|
3705
3886
|
}
|
|
3706
3887
|
|
|
3707
|
-
async
|
|
3888
|
+
async _resolveGhcpUserKeySet() {
|
|
3889
|
+
// Freshest signal first: the profile the Admin console loaded in this
|
|
3890
|
+
// session (updates immediately after the user saves a key there).
|
|
3891
|
+
const adminProfile = this.getState().admin?.profile;
|
|
3892
|
+
if (typeof adminProfile?.githubCopilotKeySet === "boolean") {
|
|
3893
|
+
return adminProfile.githubCopilotKeySet;
|
|
3894
|
+
}
|
|
3895
|
+
if (typeof this.transport.getCurrentUserProfile !== "function") return null;
|
|
3896
|
+
try {
|
|
3897
|
+
const profile = await this.transport.getCurrentUserProfile();
|
|
3898
|
+
if (profile && typeof profile.githubCopilotKeySet === "boolean") {
|
|
3899
|
+
return profile.githubCopilotKeySet;
|
|
3900
|
+
}
|
|
3901
|
+
// Profile fetch succeeded but no stored flag — a user with no
|
|
3902
|
+
// profile row has no key.
|
|
3903
|
+
return false;
|
|
3904
|
+
} catch {
|
|
3905
|
+
return null;
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
async openSwitchModelPicker(onApplied = null) {
|
|
3910
|
+
// Callers (e.g. the portal Manage modal) can be notified when a switch
|
|
3911
|
+
// is actually applied — distinct from cancelling the picker — so they
|
|
3912
|
+
// can close/reopen their own chrome accordingly.
|
|
3913
|
+
this._onSwitchModelApplied = typeof onApplied === "function" ? onApplied : null;
|
|
3708
3914
|
const state = this.getState();
|
|
3709
3915
|
const sessionId = state.sessions.activeSessionId;
|
|
3710
3916
|
const session = sessionId ? state.sessions.byId[sessionId] || null : null;
|
|
3711
3917
|
if (!session || session.isGroup) {
|
|
3918
|
+
this._onSwitchModelApplied = null;
|
|
3712
3919
|
this.dispatch({ type: "ui/status", text: "Select a session before switching model" });
|
|
3713
3920
|
return;
|
|
3714
3921
|
}
|
|
3715
3922
|
if (typeof this.transport.setSessionModel !== "function") {
|
|
3923
|
+
this._onSwitchModelApplied = null;
|
|
3716
3924
|
this.dispatch({ type: "ui/status", text: "Model switching is not supported by this transport" });
|
|
3717
3925
|
return;
|
|
3718
3926
|
}
|
|
@@ -3745,6 +3953,11 @@ export class PilotSwarmUiController {
|
|
|
3745
3953
|
const tierSuffix = options.contextTier ? ` · ${CONTEXT_TIER_LABELS[options.contextTier] || options.contextTier}` : "";
|
|
3746
3954
|
this.dispatch({ type: "ui/status", text: `Next turn will use ${modelLabel}${tierSuffix}` });
|
|
3747
3955
|
await this.refreshSessions();
|
|
3956
|
+
// Notify a Manage-modal-style caller that the switch was applied (vs
|
|
3957
|
+
// cancelled), so it can close rather than reopen.
|
|
3958
|
+
const onApplied = this._onSwitchModelApplied;
|
|
3959
|
+
this._onSwitchModelApplied = null;
|
|
3960
|
+
if (typeof onApplied === "function") onApplied();
|
|
3748
3961
|
}
|
|
3749
3962
|
|
|
3750
3963
|
openHelpModal() {
|
|
@@ -4753,6 +4966,16 @@ export class PilotSwarmUiController {
|
|
|
4753
4966
|
}
|
|
4754
4967
|
if (modal.type === "modelPicker") {
|
|
4755
4968
|
const item = modal.items?.[modal.selectedIndex || 0];
|
|
4969
|
+
if (item?.disabled) {
|
|
4970
|
+
// Keep the picker open — the selection is unusable, not wrong.
|
|
4971
|
+
this.dispatch({
|
|
4972
|
+
type: "ui/status",
|
|
4973
|
+
text: item.ghcpKeyMissing
|
|
4974
|
+
? "This model needs a GitHub Copilot key — add yours in the Admin console first"
|
|
4975
|
+
: "This model is not available",
|
|
4976
|
+
});
|
|
4977
|
+
return;
|
|
4978
|
+
}
|
|
4756
4979
|
const previousFocus = modal.previousFocus;
|
|
4757
4980
|
this.dispatch({ type: "ui/modal", modal: null });
|
|
4758
4981
|
if (previousFocus) {
|
|
@@ -5093,6 +5316,7 @@ export class PilotSwarmUiController {
|
|
|
5093
5316
|
const items = this.getSessionOutbox(currentUi.promptEdit.sessionId);
|
|
5094
5317
|
const nextItems = items.map((item) => (
|
|
5095
5318
|
item.id === currentUi.promptEdit.itemId && item.phase === "pending"
|
|
5319
|
+
&& item.attempted !== true
|
|
5096
5320
|
? { ...item, text: nextPrompt }
|
|
5097
5321
|
: item
|
|
5098
5322
|
));
|
|
@@ -992,6 +992,7 @@ export function buildMessageCardLines({
|
|
|
992
992
|
borderColor = "gray",
|
|
993
993
|
bodyColor = null,
|
|
994
994
|
fitToContent = false,
|
|
995
|
+
tableMode = null,
|
|
995
996
|
} = {}) {
|
|
996
997
|
const maxWidth = Math.max(fitToContent ? 12 : 20, Number(width) || (fitToContent ? 12 : 20));
|
|
997
998
|
const maxContentWidth = Math.max(1, maxWidth - 4);
|
|
@@ -999,6 +1000,30 @@ export function buildMessageCardLines({
|
|
|
999
1000
|
{ text: ` ${String(title || "SYSTEM").toUpperCase()} `, color: titleColor, bold: true },
|
|
1000
1001
|
...(timestamp ? [{ text: ` ${String(timestamp)} `, color: "gray" }] : []),
|
|
1001
1002
|
];
|
|
1003
|
+
|
|
1004
|
+
// Sentinel mode (browser portal): the card is laid out with CSS, so
|
|
1005
|
+
// emit cardStart/cardEnd bounds around the UNWRAPPED markdown body
|
|
1006
|
+
// instead of drawing box art. Hard-wrapping here would mangle
|
|
1007
|
+
// preformatted content (box-drawn tables, ASCII art) that the browser
|
|
1008
|
+
// renders as real HTML tables or scrollable lines.
|
|
1009
|
+
if (tableMode === "sentinel") {
|
|
1010
|
+
const sentinelBodyLines = parseMarkdownLines(String(body || ""), {
|
|
1011
|
+
width: maxContentWidth,
|
|
1012
|
+
tableMode,
|
|
1013
|
+
});
|
|
1014
|
+
const sentinelTinted = bodyColor
|
|
1015
|
+
? sentinelBodyLines.map((lineRuns) => (Array.isArray(lineRuns)
|
|
1016
|
+
? recolorRuns(lineRuns, bodyColor)
|
|
1017
|
+
: lineRuns))
|
|
1018
|
+
: sentinelBodyLines;
|
|
1019
|
+
return [
|
|
1020
|
+
{ kind: "cardStart", runs: titleRuns, borderColor },
|
|
1021
|
+
...sentinelTinted,
|
|
1022
|
+
{ kind: "cardEnd" },
|
|
1023
|
+
[{ text: "", color: null }],
|
|
1024
|
+
];
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1002
1027
|
const bodyLines = parseMarkdownLines(String(body || ""), { width: maxContentWidth });
|
|
1003
1028
|
const normalizedBodyLines = bodyLines.length > 0
|
|
1004
1029
|
? bodyLines.flatMap((lineRuns) => wrapRunsToDisplayWidth(lineRuns, maxContentWidth))
|