pilotswarm 0.5.15 → 0.5.17
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/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 +14 -2
- package/ui/core/src/controller.js +85 -11
- package/ui/core/src/formatting.js +29 -1
- package/ui/core/src/reducer.js +51 -0
- package/ui/core/src/selectors.js +11 -3
- package/ui/react/src/web-app.js +84 -18
- package/web/api/router.js +3 -1
- package/web/dist/assets/index-CFbG5y06.css +1 -0
- package/web/dist/assets/{index-CZizkB5Z.js → index-Cae-9EH8.js} +2 -2
- package/web/dist/assets/pilotswarm-Drj2bs6t.js +90 -0
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-D9e2TGjO.css +0 -1
- package/web/dist/assets/pilotswarm-KMqn3ZJs.js +0 -90
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pilotswarm",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.17",
|
|
4
4
|
"description": "PilotSwarm application package: terminal UI, browser portal + Web API server, and MCP server — one install, three bins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"hono": "^4.12.10",
|
|
79
79
|
"ink": "^6.8.0",
|
|
80
80
|
"jose": "^6.2.2",
|
|
81
|
-
"pilotswarm-sdk": "^0.5.
|
|
81
|
+
"pilotswarm-sdk": "^0.5.17",
|
|
82
82
|
"react": "^19.2.4",
|
|
83
83
|
"react-dom": "^19.2.4",
|
|
84
84
|
"ws": "^8.18.2"
|
package/tui/plugins/.mcp.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
schemaVersion: 2
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
name: deepwiki
|
|
5
|
+
title: DeepWiki Code Explorer
|
|
6
|
+
description: Answers questions about any public GitHub repository using the DeepWiki MCP server. Loads ONLY the DeepWiki MCP — no other servers — demonstrating per-agent MCP scoping.
|
|
7
|
+
mcpServers:
|
|
8
|
+
- deepwiki
|
|
9
|
+
inheritDefaultMcpServers: false
|
|
10
|
+
initialPrompt: >
|
|
11
|
+
Introduce yourself as the DeepWiki Code Explorer. Ask the user which public
|
|
12
|
+
GitHub repository (owner/repo) they want to explore and what they want to know.
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# DeepWiki Code Explorer
|
|
16
|
+
|
|
17
|
+
You answer questions about **public GitHub codebases** using the **DeepWiki**
|
|
18
|
+
MCP server — the only MCP server available to you. DeepWiki serves
|
|
19
|
+
AI-generated documentation grounded in a repository's code.
|
|
20
|
+
|
|
21
|
+
Your DeepWiki tools:
|
|
22
|
+
- `read_wiki_structure` — list the documentation topics/sections for a repo.
|
|
23
|
+
- `read_wiki_contents` — read the full generated documentation for a repo.
|
|
24
|
+
- `ask_question` — ask a natural-language question about a repo and get an
|
|
25
|
+
answer grounded in its code and docs.
|
|
26
|
+
|
|
27
|
+
All of these take a repository as `owner/repo` (for example `facebook/react`).
|
|
28
|
+
|
|
29
|
+
How to work:
|
|
30
|
+
1. Identify the target repository as `owner/repo`. If the user names a project
|
|
31
|
+
without the owner, or it's ambiguous, ask which `owner/repo` they mean
|
|
32
|
+
before calling a tool.
|
|
33
|
+
2. For a specific question, prefer `ask_question`. For an overview or to find
|
|
34
|
+
the right area first, start with `read_wiki_structure`, then drill in with
|
|
35
|
+
`read_wiki_contents` or `ask_question`.
|
|
36
|
+
3. Ground every answer in what DeepWiki returns and name the repo (and the
|
|
37
|
+
topic/section) you drew from. If DeepWiki has no coverage for a repo, say so
|
|
38
|
+
plainly instead of guessing.
|
|
39
|
+
4. Keep answers focused and technical; quote identifiers and file/module names
|
|
40
|
+
when they help.
|
|
41
|
+
|
|
42
|
+
You have no web browsing and cannot clone or read repositories directly —
|
|
43
|
+
everything comes through the DeepWiki MCP server. If a question can't be
|
|
44
|
+
answered from DeepWiki, say what's missing.
|
|
@@ -1037,8 +1037,20 @@ export class NodeSdkTransport {
|
|
|
1037
1037
|
: null;
|
|
1038
1038
|
if (profile?.githubCopilotKeySet === true) return effectiveModel;
|
|
1039
1039
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1040
|
+
// The caller never picked this Copilot model — it came from the
|
|
1041
|
+
// catalog default. Fall back to the first non-Copilot model instead
|
|
1042
|
+
// of failing a session the user has no key for.
|
|
1043
|
+
if (!model && typeof this.mgmt.listModels === "function") {
|
|
1044
|
+
const fallback = (this.mgmt.listModels() || [])
|
|
1045
|
+
.find((entry) => entry?.providerType !== "github" && entry?.qualifiedName);
|
|
1046
|
+
if (fallback) return fallback.qualifiedName;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
throw Object.assign(
|
|
1050
|
+
new Error(
|
|
1051
|
+
"GitHub Copilot key missing or invalid. Set GITHUB_TOKEN on the worker or configure your per-user GitHub Copilot key in Admin before using GitHub Copilot models.",
|
|
1052
|
+
),
|
|
1053
|
+
{ code: "GHCP_KEY_MISSING", status: 400 },
|
|
1042
1054
|
);
|
|
1043
1055
|
}
|
|
1044
1056
|
|
|
@@ -216,6 +216,22 @@ const CONTEXT_TIER_LABELS = {
|
|
|
216
216
|
long_context: "Long context (larger window, higher cost)",
|
|
217
217
|
};
|
|
218
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
|
+
|
|
219
235
|
function extractSessionModelFromEvents(events = []) {
|
|
220
236
|
// Only explicit model-change events may update the session's model.
|
|
221
237
|
// Deriving it from any event that happens to carry a `model` field lets
|
|
@@ -1103,6 +1119,7 @@ export class PilotSwarmUiController {
|
|
|
1103
1119
|
text: String(prompt || ""),
|
|
1104
1120
|
createdAt: Date.now(),
|
|
1105
1121
|
phase: normalizedPhase,
|
|
1122
|
+
attempted: false,
|
|
1106
1123
|
clientMessageIds: [id],
|
|
1107
1124
|
};
|
|
1108
1125
|
}
|
|
@@ -1122,9 +1139,9 @@ export class PilotSwarmUiController {
|
|
|
1122
1139
|
}
|
|
1123
1140
|
|
|
1124
1141
|
getEditableOutboxItems(sessionId) {
|
|
1125
|
-
//
|
|
1126
|
-
//
|
|
1127
|
-
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);
|
|
1128
1145
|
}
|
|
1129
1146
|
|
|
1130
1147
|
getPromptEditSessionMatch(sessionId = this.getState().sessions.activeSessionId) {
|
|
@@ -1412,7 +1429,11 @@ export class PilotSwarmUiController {
|
|
|
1412
1429
|
return false;
|
|
1413
1430
|
}
|
|
1414
1431
|
|
|
1415
|
-
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);
|
|
1416
1437
|
if (pendingItems.length === 0) return false;
|
|
1417
1438
|
|
|
1418
1439
|
// Merge all current pending items into a single durable envelope.
|
|
@@ -1430,6 +1451,7 @@ export class PilotSwarmUiController {
|
|
|
1430
1451
|
text: mergedText,
|
|
1431
1452
|
createdAt: pendingItems[0].createdAt || Date.now(),
|
|
1432
1453
|
phase: "pending",
|
|
1454
|
+
attempted: true,
|
|
1433
1455
|
clientMessageIds: mergedClientMessageIds,
|
|
1434
1456
|
};
|
|
1435
1457
|
const pendingIdSet = new Set(pendingItems.map((item) => item.id));
|
|
@@ -1480,10 +1502,11 @@ export class PilotSwarmUiController {
|
|
|
1480
1502
|
}
|
|
1481
1503
|
}, 6000);
|
|
1482
1504
|
} else {
|
|
1483
|
-
// Transient failure:
|
|
1484
|
-
//
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
|
1487
1510
|
));
|
|
1488
1511
|
this.setSessionOutboxItems(sessionId, reverted);
|
|
1489
1512
|
}
|
|
@@ -3763,7 +3786,8 @@ export class PilotSwarmUiController {
|
|
|
3763
3786
|
const items = supported.map((tier) => ({
|
|
3764
3787
|
id: tier,
|
|
3765
3788
|
tier,
|
|
3766
|
-
|
|
3789
|
+
tokenLimit: modelItem?.contextWindowSizes?.[tier] || null,
|
|
3790
|
+
label: formatContextTierLabel(tier, modelItem?.contextWindowSizes?.[tier]),
|
|
3767
3791
|
isDefault: selectedTier === tier,
|
|
3768
3792
|
}));
|
|
3769
3793
|
const selectedIndex = Math.max(0, items.findIndex((item) => item.id === selectedTier));
|
|
@@ -3800,25 +3824,38 @@ export class PilotSwarmUiController {
|
|
|
3800
3824
|
const groupedModels = typeof this.transport.getModelsByProvider === "function"
|
|
3801
3825
|
? this.transport.getModelsByProvider()
|
|
3802
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();
|
|
3803
3831
|
const items = [];
|
|
3804
3832
|
const groups = groupedModels
|
|
3805
3833
|
.map((group) => ({
|
|
3806
3834
|
providerId: group.providerId,
|
|
3807
3835
|
providerType: group.type || group.providerType,
|
|
3808
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;
|
|
3809
3843
|
const item = {
|
|
3810
3844
|
id: model.qualifiedName,
|
|
3811
3845
|
qualifiedName: model.qualifiedName,
|
|
3812
3846
|
modelName: model.modelName || model.qualifiedName,
|
|
3813
3847
|
providerId: model.providerId || group.providerId,
|
|
3814
|
-
providerType
|
|
3848
|
+
providerType,
|
|
3815
3849
|
description: model.description || "",
|
|
3816
3850
|
cost: model.cost || null,
|
|
3817
3851
|
supportedReasoningEfforts: normalizeReasoningEfforts(model.supportedReasoningEfforts),
|
|
3818
3852
|
defaultReasoningEffort: model.defaultReasoningEffort || null,
|
|
3819
3853
|
supportedContextTiers: normalizeContextTiers(model.supportedContextTiers),
|
|
3820
3854
|
defaultContextTier: model.defaultContextTier || null,
|
|
3855
|
+
contextWindowSizes: model.contextWindowSizes || null,
|
|
3821
3856
|
isDefault: defaultModel === model.qualifiedName,
|
|
3857
|
+
ghcpKeyMissing,
|
|
3858
|
+
disabled: ghcpKeyMissing,
|
|
3822
3859
|
};
|
|
3823
3860
|
items.push(item);
|
|
3824
3861
|
return item;
|
|
@@ -3827,7 +3864,12 @@ export class PilotSwarmUiController {
|
|
|
3827
3864
|
.filter((group) => group.models.length > 0);
|
|
3828
3865
|
|
|
3829
3866
|
const preferredModel = sessionOptions?.model || defaultModel;
|
|
3830
|
-
|
|
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;
|
|
3831
3873
|
this.dispatch({
|
|
3832
3874
|
type: "ui/modal",
|
|
3833
3875
|
modal: {
|
|
@@ -3843,6 +3885,27 @@ export class PilotSwarmUiController {
|
|
|
3843
3885
|
this.dispatch({ type: "ui/status", text: "Select a model and press Enter" });
|
|
3844
3886
|
}
|
|
3845
3887
|
|
|
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
|
+
|
|
3846
3909
|
async openSwitchModelPicker(onApplied = null) {
|
|
3847
3910
|
// Callers (e.g. the portal Manage modal) can be notified when a switch
|
|
3848
3911
|
// is actually applied — distinct from cancelling the picker — so they
|
|
@@ -4903,6 +4966,16 @@ export class PilotSwarmUiController {
|
|
|
4903
4966
|
}
|
|
4904
4967
|
if (modal.type === "modelPicker") {
|
|
4905
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
|
+
}
|
|
4906
4979
|
const previousFocus = modal.previousFocus;
|
|
4907
4980
|
this.dispatch({ type: "ui/modal", modal: null });
|
|
4908
4981
|
if (previousFocus) {
|
|
@@ -5243,6 +5316,7 @@ export class PilotSwarmUiController {
|
|
|
5243
5316
|
const items = this.getSessionOutbox(currentUi.promptEdit.sessionId);
|
|
5244
5317
|
const nextItems = items.map((item) => (
|
|
5245
5318
|
item.id === currentUi.promptEdit.itemId && item.phase === "pending"
|
|
5319
|
+
&& item.attempted !== true
|
|
5246
5320
|
? { ...item, text: nextPrompt }
|
|
5247
5321
|
: item
|
|
5248
5322
|
));
|
|
@@ -567,8 +567,11 @@ function splitMarkdownTableCells(line) {
|
|
|
567
567
|
return cells;
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
+
// GFM delimiter cells are `:?-+:?` — one dash is enough, so `--:` and `-` are
|
|
571
|
+
// as valid as `---`. Requiring three dashes silently demoted whole tables to
|
|
572
|
+
// raw pipe text whenever a model emitted the shorter alignment form.
|
|
570
573
|
function isMarkdownTableSeparatorRow(cells) {
|
|
571
|
-
return cells.length > 0 && cells.every((cell) =>
|
|
574
|
+
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(String(cell || "").replace(/\s+/g, "")));
|
|
572
575
|
}
|
|
573
576
|
|
|
574
577
|
function displayWidth(value) {
|
|
@@ -992,6 +995,7 @@ export function buildMessageCardLines({
|
|
|
992
995
|
borderColor = "gray",
|
|
993
996
|
bodyColor = null,
|
|
994
997
|
fitToContent = false,
|
|
998
|
+
tableMode = null,
|
|
995
999
|
} = {}) {
|
|
996
1000
|
const maxWidth = Math.max(fitToContent ? 12 : 20, Number(width) || (fitToContent ? 12 : 20));
|
|
997
1001
|
const maxContentWidth = Math.max(1, maxWidth - 4);
|
|
@@ -999,6 +1003,30 @@ export function buildMessageCardLines({
|
|
|
999
1003
|
{ text: ` ${String(title || "SYSTEM").toUpperCase()} `, color: titleColor, bold: true },
|
|
1000
1004
|
...(timestamp ? [{ text: ` ${String(timestamp)} `, color: "gray" }] : []),
|
|
1001
1005
|
];
|
|
1006
|
+
|
|
1007
|
+
// Sentinel mode (browser portal): the card is laid out with CSS, so
|
|
1008
|
+
// emit cardStart/cardEnd bounds around the UNWRAPPED markdown body
|
|
1009
|
+
// instead of drawing box art. Hard-wrapping here would mangle
|
|
1010
|
+
// preformatted content (box-drawn tables, ASCII art) that the browser
|
|
1011
|
+
// renders as real HTML tables or scrollable lines.
|
|
1012
|
+
if (tableMode === "sentinel") {
|
|
1013
|
+
const sentinelBodyLines = parseMarkdownLines(String(body || ""), {
|
|
1014
|
+
width: maxContentWidth,
|
|
1015
|
+
tableMode,
|
|
1016
|
+
});
|
|
1017
|
+
const sentinelTinted = bodyColor
|
|
1018
|
+
? sentinelBodyLines.map((lineRuns) => (Array.isArray(lineRuns)
|
|
1019
|
+
? recolorRuns(lineRuns, bodyColor)
|
|
1020
|
+
: lineRuns))
|
|
1021
|
+
: sentinelBodyLines;
|
|
1022
|
+
return [
|
|
1023
|
+
{ kind: "cardStart", runs: titleRuns, borderColor },
|
|
1024
|
+
...sentinelTinted,
|
|
1025
|
+
{ kind: "cardEnd" },
|
|
1026
|
+
[{ text: "", color: null }],
|
|
1027
|
+
];
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1002
1030
|
const bodyLines = parseMarkdownLines(String(body || ""), { width: maxContentWidth });
|
|
1003
1031
|
const normalizedBodyLines = bodyLines.length > 0
|
|
1004
1032
|
? bodyLines.flatMap((lineRuns) => wrapRunsToDisplayWidth(lineRuns, maxContentWidth))
|
package/ui/core/src/reducer.js
CHANGED
|
@@ -186,10 +186,61 @@ function areStructuredValuesEqual(left, right) {
|
|
|
186
186
|
return true;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
function sessionUpdateTimestampMs(session) {
|
|
190
|
+
const value = session?.updatedAt;
|
|
191
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
192
|
+
if (value instanceof Date) return value.getTime();
|
|
193
|
+
const parsed = Date.parse(value || "");
|
|
194
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// True when `nextSession` carries no newer information than what we already
|
|
198
|
+
// hold. Both timestamps must be present — an update with no `updatedAt` is
|
|
199
|
+
// treated as newer, because we cannot prove otherwise.
|
|
200
|
+
function isSameOrOlderSessionUpdate(previousSession, nextSession) {
|
|
201
|
+
const previousAt = sessionUpdateTimestampMs(previousSession);
|
|
202
|
+
const nextAt = sessionUpdateTimestampMs(nextSession);
|
|
203
|
+
return previousAt > 0 && nextAt > 0 && nextAt <= previousAt;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A run that has ended for good. These are authoritative and must always land,
|
|
207
|
+
// even from an update that looks stale — stranding a finished session as
|
|
208
|
+
// "running" is far worse than a brief wrong status.
|
|
209
|
+
const TERMINAL_SESSION_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
|
|
210
|
+
|
|
211
|
+
// Session state is written from several concurrent sources: the session-list
|
|
212
|
+
// poll, live events, and a per-session detail fetch. They do not agree
|
|
213
|
+
// instant-to-instant, and the list poll in particular can report a status the
|
|
214
|
+
// orchestration has already moved on from — observed live as:
|
|
215
|
+
//
|
|
216
|
+
// sessions/loaded running + waiting -> waiting prevAt == incomingAt
|
|
217
|
+
// sessions/merged waiting + running -> running (105ms later)
|
|
218
|
+
//
|
|
219
|
+
// while the server's own session row and orchestration both read "running"
|
|
220
|
+
// throughout. Consumers gated on status === "running" — the live-activity
|
|
221
|
+
// strip, the composer's Stop button — blink off for the width of that gap.
|
|
222
|
+
//
|
|
223
|
+
// Refuse ANY non-terminal downgrade out of "running" unless the incoming
|
|
224
|
+
// update is genuinely newer. Deliberately not restricted to idle-like
|
|
225
|
+
// statuses: the status actually observed clobbering a live run was "waiting",
|
|
226
|
+
// and an earlier version of this guard missed the bug by excluding it.
|
|
227
|
+
// Terminal statuses are exempt so a finished run can never be stranded, and
|
|
228
|
+
// the timestamp test means a real transition (which carries a newer
|
|
229
|
+
// updatedAt) still lands immediately.
|
|
230
|
+
function shouldPreserveRunningStatus(previousSession, nextSession) {
|
|
231
|
+
const nextStatus = String(nextSession?.status ?? "").toLowerCase();
|
|
232
|
+
return previousSession?.status === "running"
|
|
233
|
+
&& nextStatus !== "running"
|
|
234
|
+
&& !TERMINAL_SESSION_STATUSES.has(nextStatus)
|
|
235
|
+
&& isSameOrOlderSessionUpdate(previousSession, nextSession);
|
|
236
|
+
}
|
|
237
|
+
|
|
189
238
|
function mergeDefinedSessionFields(previousSession = {}, nextSession = {}) {
|
|
190
239
|
let merged = previousSession || {};
|
|
240
|
+
const preserveRunning = shouldPreserveRunningStatus(previousSession, nextSession);
|
|
191
241
|
for (const [key, value] of Object.entries(nextSession || {})) {
|
|
192
242
|
if (value === undefined) continue;
|
|
243
|
+
if (key === "status" && preserveRunning) continue;
|
|
193
244
|
if (key === "pendingQuestion" && isAnsweredPendingQuestion(previousSession, value)) {
|
|
194
245
|
if (merged === previousSession) {
|
|
195
246
|
merged = { ...(previousSession || {}) };
|
package/ui/core/src/selectors.js
CHANGED
|
@@ -1923,6 +1923,7 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
|
1923
1923
|
width: Math.max(20, maxWidth),
|
|
1924
1924
|
titleColor: USER_CHAT_COLOR,
|
|
1925
1925
|
borderColor: USER_CHAT_COLOR,
|
|
1926
|
+
tableMode: options.tableMode,
|
|
1926
1927
|
}),
|
|
1927
1928
|
...buildChatMessageLines({
|
|
1928
1929
|
...message,
|
|
@@ -2000,6 +2001,7 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
|
2000
2001
|
width: Math.max(20, maxWidth),
|
|
2001
2002
|
titleColor: message?.cardTitleColor || (message?.role === "system" ? "yellow" : "cyan"),
|
|
2002
2003
|
borderColor: message?.cardBorderColor || "gray",
|
|
2004
|
+
tableMode: options.tableMode,
|
|
2003
2005
|
...(isSystemCard ? { bodyColor: "gray", fitToContent: true } : {}),
|
|
2004
2006
|
});
|
|
2005
2007
|
}
|
|
@@ -3778,7 +3780,7 @@ function buildNodeMapHeaderLine(nodeLabels, colWidth) {
|
|
|
3778
3780
|
return runs;
|
|
3779
3781
|
}
|
|
3780
3782
|
|
|
3781
|
-
const SEQ_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "
|
|
3783
|
+
const SEQ_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
3782
3784
|
|
|
3783
3785
|
function shortModelForSequence(value) {
|
|
3784
3786
|
const model = String(value || "").trim();
|
|
@@ -4086,10 +4088,12 @@ export function selectModelPickerModal(state, maxWidth = 72) {
|
|
|
4086
4088
|
? modal.items.findIndex((item) => item.id === model.id)
|
|
4087
4089
|
: -1;
|
|
4088
4090
|
const isSelected = itemIndex === selectedIndex;
|
|
4091
|
+
const isDisabled = Boolean(model.disabled);
|
|
4089
4092
|
const labelRuns = fitRuns([
|
|
4090
4093
|
{ text: "· ", color: "gray" },
|
|
4091
|
-
{ text: model.modelName || model.qualifiedName || model.id, color: "white", bold: Boolean(model.isDefault) },
|
|
4094
|
+
{ text: model.modelName || model.qualifiedName || model.id, color: isDisabled ? "gray" : "white", bold: Boolean(model.isDefault) && !isDisabled },
|
|
4092
4095
|
...(model.cost ? [{ text: ` [${model.cost}]`, color: "gray" }] : []),
|
|
4096
|
+
...(isDisabled ? [{ text: " ⊘ needs GitHub key", color: "yellow" }] : []),
|
|
4093
4097
|
...(model.isDefault ? [{ text: " ← current default", color: "gray" }] : []),
|
|
4094
4098
|
], contentWidth);
|
|
4095
4099
|
|
|
@@ -4126,6 +4130,9 @@ export function selectModelPickerModal(state, maxWidth = 72) {
|
|
|
4126
4130
|
? [[{ text: `Reasoning: ${supportedReasoning.join(", ")}`, color: "gray" }]]
|
|
4127
4131
|
: []),
|
|
4128
4132
|
...(defaultReasoning ? [[{ text: `Default reasoning: ${defaultReasoning}`, color: "gray" }]] : []),
|
|
4133
|
+
...(selectedItem.disabled
|
|
4134
|
+
? [[{ text: "Unavailable: requires a GitHub Copilot key. Add yours in the Admin console, then reopen this picker.", color: "yellow" }]]
|
|
4135
|
+
: []),
|
|
4129
4136
|
[{ text: "", color: "gray" }],
|
|
4130
4137
|
[{
|
|
4131
4138
|
text: selectedItem.description || "No description available for this model.",
|
|
@@ -4298,11 +4305,12 @@ export function selectContextTierPickerModal(state, maxWidth = 64) {
|
|
|
4298
4305
|
|
|
4299
4306
|
const selectedItem = items[selectedIndex] || null;
|
|
4300
4307
|
const modelItem = modal.modelItem || null;
|
|
4308
|
+
const selectedLabel = selectedItem?.label || selectedItem?.id;
|
|
4301
4309
|
const detailsLines = [
|
|
4302
4310
|
[{ text: modelItem?.modelName || modelItem?.qualifiedName || "Selected model", color: "white", bold: true }],
|
|
4303
4311
|
[{ text: `${modelItem?.providerId || "provider"} (${modelItem?.providerType || "provider"})`, color: "gray" }],
|
|
4304
4312
|
[{ text: "", color: "gray" }],
|
|
4305
|
-
[{ text:
|
|
4313
|
+
[{ text: selectedLabel ? `Using context window: ${selectedLabel}` : "Choose a context window.", color: "white" }],
|
|
4306
4314
|
[{ text: "Long context costs more per token.", color: "gray" }],
|
|
4307
4315
|
];
|
|
4308
4316
|
|
package/ui/react/src/web-app.js
CHANGED
|
@@ -422,8 +422,9 @@ function normalizeLines(lines) {
|
|
|
422
422
|
continue;
|
|
423
423
|
}
|
|
424
424
|
// Sentinel kinds preserved as-is so parseStructuredChatBlocks can
|
|
425
|
-
// recognize and render them (e.g. markdownTable → HTML <table
|
|
426
|
-
|
|
425
|
+
// recognize and render them (e.g. markdownTable → HTML <table>,
|
|
426
|
+
// cardStart/cardEnd → styled card with structured body).
|
|
427
|
+
if (line?.kind === "markdownTable" || line?.kind === "cardStart" || line?.kind === "cardEnd") {
|
|
427
428
|
normalized.push(line);
|
|
428
429
|
continue;
|
|
429
430
|
}
|
|
@@ -593,6 +594,23 @@ export function isScrollViewportAtBottom(node) {
|
|
|
593
594
|
return getScrollDistanceToBottom(node) <= SCROLL_BOTTOM_EPSILON_PX;
|
|
594
595
|
}
|
|
595
596
|
|
|
597
|
+
export function computeAnchoredScrollTop(
|
|
598
|
+
node,
|
|
599
|
+
scrollOffset,
|
|
600
|
+
scrollMode,
|
|
601
|
+
preservePausedStickyScroll = false,
|
|
602
|
+
) {
|
|
603
|
+
if (!node) return 0;
|
|
604
|
+
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
605
|
+
if (preservePausedStickyScroll) {
|
|
606
|
+
return Math.max(0, Math.min(node.scrollTop, maxScroll));
|
|
607
|
+
}
|
|
608
|
+
const offsetPixels = Math.max(0, Number(scrollOffset) || 0) * SCROLL_ROW_HEIGHT;
|
|
609
|
+
return scrollMode === "bottom"
|
|
610
|
+
? Math.max(0, maxScroll - offsetPixels)
|
|
611
|
+
: Math.min(maxScroll, offsetPixels);
|
|
612
|
+
}
|
|
613
|
+
|
|
596
614
|
function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom = false } = {}) {
|
|
597
615
|
const normalizedLines = React.useMemo(() => normalizeLines(lines), [lines]);
|
|
598
616
|
// Programmatic scrollTop assignments fire a 'scroll' event that would
|
|
@@ -612,6 +630,23 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
612
630
|
// or its momentum is in flight, the DOM is the source of truth and state
|
|
613
631
|
// echoes of our own scroll dispatches must not snap the pane back.
|
|
614
632
|
const userScrollRef = React.useRef({ touching: false, lastUserScrollAt: 0, lastDispatchedOffset: null });
|
|
633
|
+
const viewportSizeRef = React.useRef({ width: null, height: null });
|
|
634
|
+
const [viewportRevision, setViewportRevision] = React.useState(0);
|
|
635
|
+
|
|
636
|
+
React.useLayoutEffect(() => {
|
|
637
|
+
const node = ref.current;
|
|
638
|
+
if (!node || typeof ResizeObserver === "undefined") return;
|
|
639
|
+
viewportSizeRef.current = { width: node.clientWidth, height: node.clientHeight };
|
|
640
|
+
const observer = new ResizeObserver(() => {
|
|
641
|
+
const next = { width: node.clientWidth, height: node.clientHeight };
|
|
642
|
+
const previous = viewportSizeRef.current;
|
|
643
|
+
if (next.width === previous.width && next.height === previous.height) return;
|
|
644
|
+
viewportSizeRef.current = next;
|
|
645
|
+
setViewportRevision((revision) => revision + 1);
|
|
646
|
+
});
|
|
647
|
+
observer.observe(node);
|
|
648
|
+
return () => observer.disconnect();
|
|
649
|
+
}, [ref]);
|
|
615
650
|
|
|
616
651
|
React.useLayoutEffect(() => {
|
|
617
652
|
const node = ref.current;
|
|
@@ -631,17 +666,16 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
631
666
|
previousViewportStateRef.current = { scrollMode, scrollOffset };
|
|
632
667
|
return;
|
|
633
668
|
}
|
|
634
|
-
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
635
669
|
const preservePausedStickyScroll = stickyBottom
|
|
636
670
|
&& scrollMode === "top"
|
|
637
671
|
&& previousViewportState?.scrollMode === "top"
|
|
638
672
|
&& previousViewportState?.scrollOffset === scrollOffset;
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
673
|
+
const nextScrollTop = computeAnchoredScrollTop(
|
|
674
|
+
node,
|
|
675
|
+
scrollOffset,
|
|
676
|
+
scrollMode,
|
|
677
|
+
preservePausedStickyScroll,
|
|
678
|
+
);
|
|
645
679
|
if (Math.abs(node.scrollTop - nextScrollTop) > PROGRAMMATIC_SCROLL_TOLERANCE_PX) {
|
|
646
680
|
const pendingProgrammaticScroll = { target: nextScrollTop };
|
|
647
681
|
programmaticScrollRef.current = pendingProgrammaticScroll;
|
|
@@ -658,7 +692,7 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
658
692
|
scrollMode,
|
|
659
693
|
scrollOffset,
|
|
660
694
|
};
|
|
661
|
-
}, [normalizedLines, ref, scrollMode, scrollOffset, stickyBottom]);
|
|
695
|
+
}, [normalizedLines, ref, scrollMode, scrollOffset, stickyBottom, viewportRevision]);
|
|
662
696
|
|
|
663
697
|
const onScroll = React.useCallback(() => {
|
|
664
698
|
const node = ref.current;
|
|
@@ -1415,6 +1449,30 @@ function parseStructuredChatBlocks(lines = []) {
|
|
|
1415
1449
|
continue;
|
|
1416
1450
|
}
|
|
1417
1451
|
|
|
1452
|
+
// Sentinel card bounds emitted by buildMessageCardLines in sentinel
|
|
1453
|
+
// mode. The body lines between the bounds are UNWRAPPED, so parse
|
|
1454
|
+
// them recursively — box-drawn/markdown tables inside the card
|
|
1455
|
+
// become real HTML tables instead of hard-wrapped box art.
|
|
1456
|
+
if (currentLine?.kind === "cardStart") {
|
|
1457
|
+
const innerLines = [];
|
|
1458
|
+
index += 1;
|
|
1459
|
+
while (index < lines.length && lines[index]?.kind !== "cardEnd") {
|
|
1460
|
+
innerLines.push(lines[index]);
|
|
1461
|
+
index += 1;
|
|
1462
|
+
}
|
|
1463
|
+
if (index < lines.length) index += 1;
|
|
1464
|
+
if (index < lines.length && lineText(lines[index]).trim().length === 0) {
|
|
1465
|
+
index += 1;
|
|
1466
|
+
}
|
|
1467
|
+
blocks.push({
|
|
1468
|
+
type: "card",
|
|
1469
|
+
headerRuns: Array.isArray(currentLine.runs) ? currentLine.runs : [],
|
|
1470
|
+
borderColor: currentLine.borderColor || "gray",
|
|
1471
|
+
blocks: parseStructuredChatBlocks(innerLines),
|
|
1472
|
+
});
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1418
1476
|
// Sentinel markdown-table line emitted by parseMarkdownLines when
|
|
1419
1477
|
// tableMode === "sentinel". Carries the raw header + rows so the
|
|
1420
1478
|
// portal renders a real HTML table with markdown cell content (so
|
|
@@ -1563,9 +1621,15 @@ function parseStructuredChatBlocks(lines = []) {
|
|
|
1563
1621
|
|
|
1564
1622
|
function StructuredChatBlocks({ lines, theme }) {
|
|
1565
1623
|
const blocks = React.useMemo(() => parseStructuredChatBlocks(lines), [lines]);
|
|
1624
|
+
return React.createElement(StructuredBlockList, { blocks, theme });
|
|
1625
|
+
}
|
|
1566
1626
|
|
|
1627
|
+
// Renders parsed chat blocks; sentinel card blocks recurse through this list
|
|
1628
|
+
// so structured content (box/markdown tables, code fences) inside a card
|
|
1629
|
+
// renders exactly the same as it does at top level.
|
|
1630
|
+
function StructuredBlockList({ blocks, theme }) {
|
|
1567
1631
|
return React.createElement(React.Fragment, null,
|
|
1568
|
-
blocks.map((block, index) => {
|
|
1632
|
+
(blocks || []).map((block, index) => {
|
|
1569
1633
|
if (block.type === "preserve") {
|
|
1570
1634
|
const variantClass = block.splashVariant ? ` is-splash-${block.splashVariant}` : "";
|
|
1571
1635
|
return React.createElement("div", { key: `preserve:${index}`, className: `ps-chat-preserve-block${variantClass}` },
|
|
@@ -1593,10 +1657,12 @@ function StructuredChatBlocks({ lines, theme }) {
|
|
|
1593
1657
|
React.createElement("header", { className: "ps-chat-card-header" },
|
|
1594
1658
|
React.createElement(Runs, { runs: block.headerRuns, theme })),
|
|
1595
1659
|
React.createElement("div", { className: "ps-chat-card-body" },
|
|
1596
|
-
(block.
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1660
|
+
Array.isArray(block.blocks)
|
|
1661
|
+
? React.createElement(StructuredBlockList, { blocks: block.blocks, theme })
|
|
1662
|
+
: (block.bodyLines || []).map((bodyRuns, bodyIndex) => React.createElement("div", {
|
|
1663
|
+
key: `card:${index}:line:${bodyIndex}`,
|
|
1664
|
+
className: "ps-chat-card-line",
|
|
1665
|
+
}, React.createElement(Runs, { runs: bodyRuns, theme })) )));
|
|
1600
1666
|
}
|
|
1601
1667
|
|
|
1602
1668
|
if (block.type === "table") {
|
|
@@ -2193,7 +2259,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
2193
2259
|
},
|
|
2194
2260
|
React.createElement("div", {
|
|
2195
2261
|
className: "ps-line ps-session-row-content",
|
|
2196
|
-
style: { paddingInlineStart: `${Math.max(0, row.depth) *
|
|
2262
|
+
style: { paddingInlineStart: `${Math.max(0, row.depth) * 4}px` },
|
|
2197
2263
|
},
|
|
2198
2264
|
React.createElement(SessionRowContent, { row, theme, structured: structuredRows })),
|
|
2199
2265
|
)),
|
|
@@ -4119,7 +4185,7 @@ function ModalLayer({ controller }) {
|
|
|
4119
4185
|
return React.createElement("button", {
|
|
4120
4186
|
key: item?.id || `row:${rowIndex}`,
|
|
4121
4187
|
type: "button",
|
|
4122
|
-
className: `ps-list-button ps-modal-list-button${itemIndex === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}`,
|
|
4188
|
+
className: `ps-list-button ps-modal-list-button${itemIndex === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
|
|
4123
4189
|
onClick: () => controller.dispatch({ type: "ui/modalSelection", index: itemIndex }),
|
|
4124
4190
|
},
|
|
4125
4191
|
React.createElement("div", { className: "ps-line ps-modal-list-line" },
|
|
@@ -4128,7 +4194,7 @@ function ModalLayer({ controller }) {
|
|
|
4128
4194
|
: (modal.items || []).map((item, index) => React.createElement("button", {
|
|
4129
4195
|
key: item.id || index,
|
|
4130
4196
|
type: "button",
|
|
4131
|
-
className: `ps-list-button ps-modal-list-button${index === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}`,
|
|
4197
|
+
className: `ps-list-button ps-modal-list-button${index === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
|
|
4132
4198
|
onClick: () => controller.dispatch({ type: "ui/modalSelection", index }),
|
|
4133
4199
|
},
|
|
4134
4200
|
React.createElement("div", { className: "ps-line ps-modal-list-line" },
|