pilotswarm 0.4.0 → 0.5.0
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 +88 -4
- package/mcp/dist/src/context.d.ts +36 -6
- package/mcp/dist/src/context.d.ts.map +1 -1
- package/mcp/dist/src/context.js +102 -8
- package/mcp/dist/src/context.js.map +1 -1
- package/mcp/dist/src/resources/artifacts.d.ts +5 -0
- package/mcp/dist/src/resources/artifacts.d.ts.map +1 -0
- package/mcp/dist/src/resources/artifacts.js +39 -0
- package/mcp/dist/src/resources/artifacts.js.map +1 -0
- package/mcp/dist/src/resources/capabilities.d.ts +4 -0
- package/mcp/dist/src/resources/capabilities.d.ts.map +1 -0
- package/mcp/dist/src/resources/capabilities.js +17 -0
- package/mcp/dist/src/resources/capabilities.js.map +1 -0
- package/mcp/dist/src/resources/graph.d.ts +5 -0
- package/mcp/dist/src/resources/graph.d.ts.map +1 -0
- package/mcp/dist/src/resources/graph.js +45 -0
- package/mcp/dist/src/resources/graph.js.map +1 -0
- package/mcp/dist/src/server.d.ts.map +1 -1
- package/mcp/dist/src/server.js +61 -2
- package/mcp/dist/src/server.js.map +1 -1
- package/mcp/dist/src/tools/agents.d.ts.map +1 -1
- package/mcp/dist/src/tools/agents.js +3 -2
- package/mcp/dist/src/tools/agents.js.map +1 -1
- package/mcp/dist/src/tools/artifacts.d.ts +12 -0
- package/mcp/dist/src/tools/artifacts.d.ts.map +1 -0
- package/mcp/dist/src/tools/artifacts.js +92 -0
- package/mcp/dist/src/tools/artifacts.js.map +1 -0
- package/mcp/dist/src/tools/capabilities.d.ts +27 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -0
- package/mcp/dist/src/tools/capabilities.js +69 -0
- package/mcp/dist/src/tools/capabilities.js.map +1 -0
- package/mcp/dist/src/tools/debug.d.ts +4 -0
- package/mcp/dist/src/tools/debug.d.ts.map +1 -0
- package/mcp/dist/src/tools/debug.js +94 -0
- package/mcp/dist/src/tools/debug.js.map +1 -0
- package/mcp/dist/src/tools/facts-enhanced.d.ts +13 -0
- package/mcp/dist/src/tools/facts-enhanced.d.ts.map +1 -0
- package/mcp/dist/src/tools/facts-enhanced.js +80 -0
- package/mcp/dist/src/tools/facts-enhanced.js.map +1 -0
- package/mcp/dist/src/tools/graph.d.ts +12 -0
- package/mcp/dist/src/tools/graph.d.ts.map +1 -0
- package/mcp/dist/src/tools/graph.js +237 -0
- package/mcp/dist/src/tools/graph.js.map +1 -0
- package/mcp/dist/src/tools/groups.d.ts +10 -0
- package/mcp/dist/src/tools/groups.d.ts.map +1 -0
- package/mcp/dist/src/tools/groups.js +107 -0
- package/mcp/dist/src/tools/groups.js.map +1 -0
- package/mcp/dist/src/tools/models.d.ts.map +1 -1
- package/mcp/dist/src/tools/models.js +16 -4
- package/mcp/dist/src/tools/models.js.map +1 -1
- package/mcp/dist/src/tools/observability.d.ts +13 -0
- package/mcp/dist/src/tools/observability.d.ts.map +1 -0
- package/mcp/dist/src/tools/observability.js +174 -0
- package/mcp/dist/src/tools/observability.js.map +1 -0
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +120 -94
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/mcp/dist/src/tools/system.d.ts +14 -0
- package/mcp/dist/src/tools/system.d.ts.map +1 -0
- package/mcp/dist/src/tools/system.js +110 -0
- package/mcp/dist/src/tools/system.js.map +1 -0
- package/mcp/dist/src/tools/turn-control.d.ts +13 -0
- package/mcp/dist/src/tools/turn-control.d.ts.map +1 -0
- package/mcp/dist/src/tools/turn-control.js +98 -0
- package/mcp/dist/src/tools/turn-control.js.map +1 -0
- package/mcp/dist/src/util/respond.d.ts +21 -0
- package/mcp/dist/src/util/respond.d.ts.map +1 -0
- package/mcp/dist/src/util/respond.js +42 -0
- package/mcp/dist/src/util/respond.js.map +1 -0
- package/package.json +6 -4
- package/tui/bin/tui.js +0 -3
- package/tui/src/node-sdk-transport.js +30 -5
- package/tui/src/plugin-config.js +36 -2
- package/tui/tui-splash-mobile.txt +7 -0
- package/ui/core/src/controller.js +104 -7
- package/ui/core/src/history.js +20 -2
- package/ui/core/src/reducer.js +19 -2
- package/ui/core/src/selectors.js +32 -1
- package/ui/core/src/state.js +3 -0
- package/ui/react/src/web-app.js +122 -10
- package/web/dist/assets/{index-DMefB7Wb.js → index-B8ge3rzb.js} +4 -4
- package/web/dist/assets/index-DXRIxmIv.css +1 -0
- package/web/dist/assets/pilotswarm-Dt1qY6hD.js +90 -0
- package/web/dist/assets/{react-BZwsW8pe.js → react-IRUjK5Iz.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/runtime.js +33 -2
- package/web/dist/assets/index-CBgQQk-j.css +0 -1
- package/web/dist/assets/pilotswarm-DyBlqVTY.js +0 -87
|
@@ -2,6 +2,7 @@ import { UI_COMMANDS, FOCUS_REGIONS, INSPECTOR_TABS, cycleValue } from "./comman
|
|
|
2
2
|
import {
|
|
3
3
|
appendEventToHistory,
|
|
4
4
|
buildHistoryModel,
|
|
5
|
+
CHAT_HISTORY_EVENT_TYPES,
|
|
5
6
|
DEFAULT_HISTORY_EVENT_LIMIT,
|
|
6
7
|
dedupeChatMessages,
|
|
7
8
|
getNextHistoryEventLimit,
|
|
@@ -1833,6 +1834,57 @@ export class PilotSwarmUiController {
|
|
|
1833
1834
|
await Promise.all(sessionIds.map((sessionId) => this.syncSessionDetail(sessionId).catch(() => {})));
|
|
1834
1835
|
}
|
|
1835
1836
|
|
|
1837
|
+
/**
|
|
1838
|
+
* Append the events recorded after `afterSeq` to an in-memory history.
|
|
1839
|
+
* Returns the merged history, the unchanged history when there is
|
|
1840
|
+
* nothing new, or null when the delta is clamped (caller reloads).
|
|
1841
|
+
*/
|
|
1842
|
+
async catchUpSessionHistory(sessionId, existingHistory, afterSeq) {
|
|
1843
|
+
const CATCH_UP_PAGE_LIMIT = 1000;
|
|
1844
|
+
let newEvents;
|
|
1845
|
+
try {
|
|
1846
|
+
newEvents = await this.transport.getSessionEvents(sessionId, afterSeq, CATCH_UP_PAGE_LIMIT);
|
|
1847
|
+
} catch {
|
|
1848
|
+
return null;
|
|
1849
|
+
}
|
|
1850
|
+
if (!Array.isArray(newEvents) || newEvents.length >= CATCH_UP_PAGE_LIMIT) {
|
|
1851
|
+
return null;
|
|
1852
|
+
}
|
|
1853
|
+
if (newEvents.length === 0) {
|
|
1854
|
+
return existingHistory;
|
|
1855
|
+
}
|
|
1856
|
+
let history = existingHistory;
|
|
1857
|
+
for (const event of newEvents) {
|
|
1858
|
+
history = appendEventToHistory(history, event);
|
|
1859
|
+
}
|
|
1860
|
+
history = {
|
|
1861
|
+
...history,
|
|
1862
|
+
// Appends must never shrink the expanded window's clamp budget.
|
|
1863
|
+
loadedEventLimit: Math.max(
|
|
1864
|
+
Number(existingHistory.loadedEventLimit) || 0,
|
|
1865
|
+
Array.isArray(history.events) ? history.events.length : 0,
|
|
1866
|
+
),
|
|
1867
|
+
};
|
|
1868
|
+
this.dispatch({ type: "history/set", sessionId, history });
|
|
1869
|
+
for (const event of newEvents) {
|
|
1870
|
+
this.reconcileOutboxAgainstEvent(sessionId, event);
|
|
1871
|
+
}
|
|
1872
|
+
const derivedModel = extractSessionModelFromEvents(newEvents);
|
|
1873
|
+
const currentSession = this.getState().sessions.byId[sessionId] || { sessionId };
|
|
1874
|
+
const derivedContextUsage = extractSessionContextUsageFromEvents(currentSession.contextUsage, newEvents);
|
|
1875
|
+
if (derivedModel || derivedContextUsage) {
|
|
1876
|
+
this.dispatch({
|
|
1877
|
+
type: "sessions/merged",
|
|
1878
|
+
session: {
|
|
1879
|
+
sessionId,
|
|
1880
|
+
...(derivedModel || {}),
|
|
1881
|
+
...(derivedContextUsage ? { contextUsage: derivedContextUsage } : {}),
|
|
1882
|
+
},
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
return history;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1836
1888
|
async ensureSessionHistory(sessionId, { force = false } = {}) {
|
|
1837
1889
|
if (!sessionId) return null;
|
|
1838
1890
|
const existingHistory = this.getState().history.bySessionId.get(sessionId);
|
|
@@ -1847,6 +1899,17 @@ export class PilotSwarmUiController {
|
|
|
1847
1899
|
return this.sessionHistoryLoads.get(sessionId);
|
|
1848
1900
|
}
|
|
1849
1901
|
|
|
1902
|
+
// Re-entry catch-up: when the expanded window is already in memory,
|
|
1903
|
+
// fetch only the delta after lastSeq and append — the user's pulled-in
|
|
1904
|
+
// older history (and its cursor) survives switching sessions. Fall
|
|
1905
|
+
// back to a full window reload when the delta hits the server's
|
|
1906
|
+
// 1000-row page clamp (too far behind to append reliably).
|
|
1907
|
+
const catchUpFrom = Number(existingHistory?.lastSeq) || 0;
|
|
1908
|
+
if (force && catchUpFrom > 0 && Array.isArray(existingHistory?.events) && existingHistory.events.length > 0) {
|
|
1909
|
+
const caughtUp = await this.catchUpSessionHistory(sessionId, existingHistory, catchUpFrom);
|
|
1910
|
+
if (caughtUp) return caughtUp;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1850
1913
|
const loadPromise = (async () => {
|
|
1851
1914
|
const events = await this.transport.getSessionEvents(sessionId, undefined, requestedLimit);
|
|
1852
1915
|
const history = {
|
|
@@ -3285,6 +3348,7 @@ export class PilotSwarmUiController {
|
|
|
3285
3348
|
description: "Open-ended session with no specialized agent boundary.",
|
|
3286
3349
|
tools: [],
|
|
3287
3350
|
splash: null,
|
|
3351
|
+
splashMobile: null,
|
|
3288
3352
|
initialPrompt: null,
|
|
3289
3353
|
});
|
|
3290
3354
|
}
|
|
@@ -3300,6 +3364,7 @@ export class PilotSwarmUiController {
|
|
|
3300
3364
|
description: String(agent?.description || "").trim(),
|
|
3301
3365
|
tools: Array.isArray(agent?.tools) ? agent.tools.filter(Boolean) : [],
|
|
3302
3366
|
splash: typeof agent?.splash === "string" && agent.splash.trim() ? agent.splash : null,
|
|
3367
|
+
splashMobile: typeof agent?.splashMobile === "string" && agent.splashMobile.trim() ? agent.splashMobile : null,
|
|
3303
3368
|
initialPrompt: typeof agent?.initialPrompt === "string" && agent.initialPrompt.trim() ? agent.initialPrompt : null,
|
|
3304
3369
|
});
|
|
3305
3370
|
}
|
|
@@ -4275,6 +4340,7 @@ export class PilotSwarmUiController {
|
|
|
4275
4340
|
...sessionOptions,
|
|
4276
4341
|
...(item.title ? { title: item.title } : {}),
|
|
4277
4342
|
...(item.splash ? { splash: item.splash } : {}),
|
|
4343
|
+
...(item.splashMobile ? { splashMobile: item.splashMobile } : {}),
|
|
4278
4344
|
...(item.initialPrompt ? { initialPrompt: item.initialPrompt } : {}),
|
|
4279
4345
|
});
|
|
4280
4346
|
return;
|
|
@@ -4906,17 +4972,18 @@ export class PilotSwarmUiController {
|
|
|
4906
4972
|
this.chatTopHistoryLoadSessionId = null;
|
|
4907
4973
|
}
|
|
4908
4974
|
|
|
4909
|
-
async handleChatTopHistoryScrollIntent(requestedScrollOffset) {
|
|
4975
|
+
async handleChatTopHistoryScrollIntent(requestedScrollOffset, options = {}) {
|
|
4910
4976
|
const state = this.getState();
|
|
4911
4977
|
const sessionId = state.sessions.activeSessionId;
|
|
4912
4978
|
if (!sessionId) return;
|
|
4913
|
-
if (!this.chatTopHistoryLoadArmed || this.chatTopHistoryLoadSessionId !== sessionId) {
|
|
4979
|
+
if (!options.force && (!this.chatTopHistoryLoadArmed || this.chatTopHistoryLoadSessionId !== sessionId)) {
|
|
4914
4980
|
this.armChatTopHistoryLoad();
|
|
4915
4981
|
return;
|
|
4916
4982
|
}
|
|
4917
4983
|
|
|
4918
4984
|
await this.maybeAutoExpandActiveHistory(requestedScrollOffset, {
|
|
4919
4985
|
pages: AUTO_HISTORY_SCROLL_PAGE_COUNT,
|
|
4986
|
+
force: Boolean(options.force),
|
|
4920
4987
|
});
|
|
4921
4988
|
}
|
|
4922
4989
|
|
|
@@ -5204,14 +5271,26 @@ export class PilotSwarmUiController {
|
|
|
5204
5271
|
return;
|
|
5205
5272
|
}
|
|
5206
5273
|
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5274
|
+
// The scroll-position gate compares a DOM-derived target offset with
|
|
5275
|
+
// ui-core render metrics, and the two disagree on narrow viewports.
|
|
5276
|
+
// Skip it when the caller vouches for the gesture (options.force: an
|
|
5277
|
+
// explicit touch pull at the top of the pane) or when the transcript
|
|
5278
|
+
// has no real chat messages (splash-only: tall art inflates
|
|
5279
|
+
// totalLines past the viewport, so the gate can never pass).
|
|
5280
|
+
const hasChatMessages = Array.isArray(currentHistory?.chat) && currentHistory.chat.length > 0;
|
|
5281
|
+
if (!options.force && hasChatMessages) {
|
|
5282
|
+
const { contentHeight, totalLines } = this.getActiveChatRenderMetrics(state);
|
|
5283
|
+
const maxOffset = Math.max(0, totalLines - contentHeight);
|
|
5284
|
+
if (targetOffset < maxOffset) return;
|
|
5285
|
+
}
|
|
5210
5286
|
|
|
5211
5287
|
await this.expandSessionHistory(sessionId, {
|
|
5212
5288
|
requestedScrollOffset: targetOffset,
|
|
5213
5289
|
autoTriggered: true,
|
|
5214
5290
|
pages: options.pages,
|
|
5291
|
+
// Chat-driven pull: page backward over renderable message types
|
|
5292
|
+
// only, so each page is transcript instead of raw event noise.
|
|
5293
|
+
eventTypes: CHAT_HISTORY_EVENT_TYPES,
|
|
5215
5294
|
});
|
|
5216
5295
|
}
|
|
5217
5296
|
|
|
@@ -5249,6 +5328,14 @@ export class PilotSwarmUiController {
|
|
|
5249
5328
|
const loadPromise = (async () => {
|
|
5250
5329
|
let history;
|
|
5251
5330
|
const pagesToLoad = Math.max(1, Math.floor(Number(options.pages) || 1));
|
|
5331
|
+
// Optional server-side type filter (chat pull passes the renderable
|
|
5332
|
+
// message types). A short/empty filtered page means the transcript
|
|
5333
|
+
// is complete, so exhausting it clears hasOlderEvents like a raw
|
|
5334
|
+
// page would. Servers without filter support return unfiltered
|
|
5335
|
+
// pages, which this loop already handles (today's raw paging).
|
|
5336
|
+
const eventTypes = Array.isArray(options.eventTypes) && options.eventTypes.length > 0
|
|
5337
|
+
? options.eventTypes
|
|
5338
|
+
: undefined;
|
|
5252
5339
|
if (typeof this.transport.getSessionEventsBefore === "function" && oldestSeq > 0) {
|
|
5253
5340
|
history = currentHistory || buildHistoryModel([], { requestedLimit: currentLimit });
|
|
5254
5341
|
for (let pageIndex = 0; pageIndex < pagesToLoad; pageIndex += 1) {
|
|
@@ -5265,7 +5352,7 @@ export class PilotSwarmUiController {
|
|
|
5265
5352
|
if (Number(history.loadedEventCount || 0) >= AUTO_HISTORY_EVENT_SOFT_CAP) {
|
|
5266
5353
|
break;
|
|
5267
5354
|
}
|
|
5268
|
-
const olderEvents = await this.transport.getSessionEventsBefore(sessionId, pageOldestSeq, pageLimit);
|
|
5355
|
+
const olderEvents = await this.transport.getSessionEventsBefore(sessionId, pageOldestSeq, pageLimit, eventTypes);
|
|
5269
5356
|
if (!Array.isArray(olderEvents) || olderEvents.length === 0) {
|
|
5270
5357
|
history = {
|
|
5271
5358
|
...history,
|
|
@@ -5321,7 +5408,17 @@ export class PilotSwarmUiController {
|
|
|
5321
5408
|
this.reconcileOutboxAgainstEvent(sessionId, event);
|
|
5322
5409
|
}
|
|
5323
5410
|
|
|
5324
|
-
|
|
5411
|
+
const hadChatBefore = Array.isArray(currentHistory?.chat) && currentHistory.chat.length > 0;
|
|
5412
|
+
if (preserveChatView && !hadChatBefore) {
|
|
5413
|
+
// The splash (or an empty transcript) was showing — there is no
|
|
5414
|
+
// reading position to preserve, so land on the latest messages
|
|
5415
|
+
// instead of wherever the offset math would leave the view.
|
|
5416
|
+
this.dispatch({
|
|
5417
|
+
type: "ui/scroll",
|
|
5418
|
+
pane: "chat",
|
|
5419
|
+
offset: 0,
|
|
5420
|
+
});
|
|
5421
|
+
} else if (preserveChatView && previousScrollOffset > 0) {
|
|
5325
5422
|
const nextState = this.getState();
|
|
5326
5423
|
const nextRenderedLines = this.getActiveChatRenderMetrics(nextState).totalLines;
|
|
5327
5424
|
const addedLines = Math.max(0, nextRenderedLines - previousRenderedLines);
|
package/ui/core/src/history.js
CHANGED
|
@@ -10,6 +10,16 @@ export const HISTORY_EVENT_LIMIT_STEPS = [
|
|
|
10
10
|
10_000,
|
|
11
11
|
];
|
|
12
12
|
|
|
13
|
+
// The event types buildHistoryModel can render as chat transcript items.
|
|
14
|
+
// Passed as the server-side filter when paging backward for chat history, so
|
|
15
|
+
// noisy sessions (thousands of tool/orchestration events between messages)
|
|
16
|
+
// load transcript pages instead of raw-stream pages.
|
|
17
|
+
export const CHAT_HISTORY_EVENT_TYPES = [
|
|
18
|
+
"user.message",
|
|
19
|
+
"assistant.message",
|
|
20
|
+
"system.message",
|
|
21
|
+
];
|
|
22
|
+
|
|
13
23
|
function clampHistoryItems(items, maxItems) {
|
|
14
24
|
const list = Array.isArray(items) ? items.filter(Boolean) : [];
|
|
15
25
|
const safeMax = Math.max(DEFAULT_HISTORY_EVENT_LIMIT, Number(maxItems) || DEFAULT_HISTORY_EVENT_LIMIT);
|
|
@@ -908,10 +918,17 @@ export function appendEventToHistory(history, event) {
|
|
|
908
918
|
}
|
|
909
919
|
|
|
910
920
|
export function createSplashCard(branding, session = null) {
|
|
911
|
-
const
|
|
921
|
+
const sessionSplash = typeof session?.splash === "string" && session.splash.trim()
|
|
912
922
|
? session.splash
|
|
913
|
-
:
|
|
923
|
+
: null;
|
|
924
|
+
const splash = sessionSplash || branding?.splash;
|
|
914
925
|
if (!splash) return [];
|
|
926
|
+
// The narrow-viewport variant must come from the same source as the
|
|
927
|
+
// splash it replaces (a session splash never falls back to the branding
|
|
928
|
+
// mobile art). The renderer swaps it in when the main art is wider than
|
|
929
|
+
// the pane.
|
|
930
|
+
const mobileSource = sessionSplash ? session?.splashMobile : branding?.splashMobile;
|
|
931
|
+
const mobileSplash = typeof mobileSource === "string" && mobileSource.trim() ? mobileSource : null;
|
|
915
932
|
const title = session?.isSystem
|
|
916
933
|
? canonicalSystemTitle(session, branding?.title || "PilotSwarm")
|
|
917
934
|
: (session?.title || branding?.title || "PilotSwarm");
|
|
@@ -920,6 +937,7 @@ export function createSplashCard(branding, session = null) {
|
|
|
920
937
|
id: `splash:${title}`,
|
|
921
938
|
role: "system",
|
|
922
939
|
text: `${splash}\n\n${hint}`,
|
|
940
|
+
...(mobileSplash ? { mobileText: `${mobileSplash}\n\n${hint}` } : {}),
|
|
923
941
|
time: "",
|
|
924
942
|
splash: true,
|
|
925
943
|
}];
|
package/ui/core/src/reducer.js
CHANGED
|
@@ -951,7 +951,16 @@ export function appReducer(state, action) {
|
|
|
951
951
|
};
|
|
952
952
|
}
|
|
953
953
|
|
|
954
|
-
case "sessions/selected":
|
|
954
|
+
case "sessions/selected": {
|
|
955
|
+
// Per-session chat scroll memory: stash the outgoing session's
|
|
956
|
+
// offset and restore the incoming one's. If new chat arrives on
|
|
957
|
+
// re-entry, history/set's activeChatUpdated reset still snaps to
|
|
958
|
+
// latest — "latest chat, or where you left it".
|
|
959
|
+
const previousActiveId = state.sessions.activeSessionId;
|
|
960
|
+
const savedChatScroll = { ...(state.ui.chatScrollBySession || {}) };
|
|
961
|
+
if (previousActiveId && previousActiveId !== action.sessionId) {
|
|
962
|
+
savedChatScroll[previousActiveId] = Number(state.ui.scroll?.chat) || 0;
|
|
963
|
+
}
|
|
955
964
|
return {
|
|
956
965
|
...state,
|
|
957
966
|
sessions: {
|
|
@@ -960,9 +969,10 @@ export function appReducer(state, action) {
|
|
|
960
969
|
},
|
|
961
970
|
ui: {
|
|
962
971
|
...state.ui,
|
|
972
|
+
chatScrollBySession: savedChatScroll,
|
|
963
973
|
scroll: {
|
|
964
974
|
...state.ui.scroll,
|
|
965
|
-
chat: 0,
|
|
975
|
+
chat: Number(savedChatScroll[action.sessionId]) || 0,
|
|
966
976
|
inspector: 0,
|
|
967
977
|
activity: 0,
|
|
968
978
|
},
|
|
@@ -973,6 +983,7 @@ export function appReducer(state, action) {
|
|
|
973
983
|
},
|
|
974
984
|
},
|
|
975
985
|
};
|
|
986
|
+
}
|
|
976
987
|
|
|
977
988
|
case "ui/fullscreenPane": {
|
|
978
989
|
const fullscreenPane = normalizeFullscreenPane(action.fullscreenPane);
|
|
@@ -1152,6 +1163,8 @@ export function appReducer(state, action) {
|
|
|
1152
1163
|
for (const id of ids) nextHistory.delete(id);
|
|
1153
1164
|
const nextOutbox = cloneOutboxBySessionId(state.outbox?.bySessionId);
|
|
1154
1165
|
for (const id of ids) delete nextOutbox[id];
|
|
1166
|
+
const nextChatScroll = { ...(state.ui.chatScrollBySession || {}) };
|
|
1167
|
+
for (const id of ids) delete nextChatScroll[id];
|
|
1155
1168
|
return {
|
|
1156
1169
|
...state,
|
|
1157
1170
|
history: {
|
|
@@ -1162,6 +1175,10 @@ export function appReducer(state, action) {
|
|
|
1162
1175
|
...state.outbox,
|
|
1163
1176
|
bySessionId: nextOutbox,
|
|
1164
1177
|
},
|
|
1178
|
+
ui: {
|
|
1179
|
+
...state.ui,
|
|
1180
|
+
chatScrollBySession: nextChatScroll,
|
|
1181
|
+
},
|
|
1165
1182
|
};
|
|
1166
1183
|
}
|
|
1167
1184
|
|
package/ui/core/src/selectors.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
parseMarkdownLines,
|
|
18
18
|
shortModelName,
|
|
19
19
|
shortSessionId,
|
|
20
|
+
stripTerminalMarkupTags,
|
|
20
21
|
wrapRunsToDisplayWidth,
|
|
21
22
|
} from "./formatting.js";
|
|
22
23
|
import {
|
|
@@ -1696,9 +1697,26 @@ function buildThinkingCardLines(message, maxWidth) {
|
|
|
1696
1697
|
return lines;
|
|
1697
1698
|
}
|
|
1698
1699
|
|
|
1700
|
+
function splashArtWidth(text) {
|
|
1701
|
+
return String(text || "")
|
|
1702
|
+
.split("\n")
|
|
1703
|
+
.reduce((widest, line) => Math.max(widest, stripTerminalMarkupTags(line).length), 0);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1699
1706
|
function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
1700
1707
|
if (message?.splash) {
|
|
1701
|
-
|
|
1708
|
+
// Swap in the narrow-viewport variant when the main art would
|
|
1709
|
+
// overflow the pane (mobile portal, narrow terminals). When the
|
|
1710
|
+
// width metrics keep the desktop art, still hand renderers the
|
|
1711
|
+
// mobile variant (splashAlt) so environments with real media
|
|
1712
|
+
// queries (the browser) can make the final call by CSS viewport —
|
|
1713
|
+
// character metrics and device width can disagree.
|
|
1714
|
+
const useMobile = message.mobileText && splashArtWidth(message.text) > maxWidth;
|
|
1715
|
+
return [{
|
|
1716
|
+
kind: "markup",
|
|
1717
|
+
value: useMobile ? message.mobileText : message.text,
|
|
1718
|
+
splashAlt: !useMobile && message.mobileText ? message.mobileText : null,
|
|
1719
|
+
}];
|
|
1702
1720
|
}
|
|
1703
1721
|
|
|
1704
1722
|
if (message?.thinking) {
|
|
@@ -2838,6 +2856,15 @@ function formatCompactBytes(value) {
|
|
|
2838
2856
|
return `${mb >= 10 ? Math.round(mb) : mb.toFixed(1)} MB`;
|
|
2839
2857
|
}
|
|
2840
2858
|
|
|
2859
|
+
/** "12.4×" compression ratio (raw / stored); null when either is missing. */
|
|
2860
|
+
function formatCompressionRatio(rawBytes, storedBytes) {
|
|
2861
|
+
const raw = Number(rawBytes);
|
|
2862
|
+
const stored = Number(storedBytes);
|
|
2863
|
+
if (!Number.isFinite(raw) || !Number.isFinite(stored) || raw <= 0 || stored <= 0) return null;
|
|
2864
|
+
const ratio = raw / stored;
|
|
2865
|
+
return `${ratio >= 10 ? ratio.toFixed(0) : ratio.toFixed(1)}×`;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2841
2868
|
function summarizeEventPreview(text, maxLength = 18) {
|
|
2842
2869
|
const normalized = String(text || "")
|
|
2843
2870
|
.replace(/\s+/g, " ")
|
|
@@ -4453,6 +4480,8 @@ function buildSessionStatsLines(state, session, maxWidth) {
|
|
|
4453
4480
|
title: "Persistence",
|
|
4454
4481
|
body: formatKeyValueTable([
|
|
4455
4482
|
["Snapshot", formatCompactBytes(summary.snapshotSizeBytes)],
|
|
4483
|
+
["Uncompressed", summary.rawSizeBytes ? formatCompactBytes(summary.rawSizeBytes) : null],
|
|
4484
|
+
["Compression", formatCompressionRatio(summary.rawSizeBytes, summary.snapshotSizeBytes)],
|
|
4456
4485
|
["Dehydrations", String(summary.dehydrationCount)],
|
|
4457
4486
|
["Hydrations", String(summary.hydrationCount)],
|
|
4458
4487
|
["Lossy Handoffs", String(summary.lossyHandoffCount)],
|
|
@@ -4482,6 +4511,8 @@ function buildSessionStatsLines(state, session, maxWidth) {
|
|
|
4482
4511
|
["Cache Write", formatCompactNumber(tree.totalTokensCacheWrite)],
|
|
4483
4512
|
["Hit Ratio", treeHitLabel],
|
|
4484
4513
|
["Snapshot", formatCompactBytes(tree.totalSnapshotSizeBytes)],
|
|
4514
|
+
["Uncompressed", tree.totalRawSizeBytes ? formatCompactBytes(tree.totalRawSizeBytes) : null],
|
|
4515
|
+
["Compression", formatCompressionRatio(tree.totalRawSizeBytes, tree.totalSnapshotSizeBytes)],
|
|
4485
4516
|
["Dehydrations", String(tree.totalDehydrationCount)],
|
|
4486
4517
|
["Hydrations", String(tree.totalHydrationCount)],
|
|
4487
4518
|
]),
|
package/ui/core/src/state.js
CHANGED
|
@@ -150,6 +150,9 @@ export function createInitialState({ mode = "local", branding = null, themeId =
|
|
|
150
150
|
activity: 0,
|
|
151
151
|
filePreview: 0,
|
|
152
152
|
},
|
|
153
|
+
// Per-session chat scroll memory (rows from bottom), restored on
|
|
154
|
+
// sessions/selected and evicted with the session's history.
|
|
155
|
+
chatScrollBySession: {},
|
|
153
156
|
followBottom: {
|
|
154
157
|
inspector: true,
|
|
155
158
|
activity: true,
|
package/ui/react/src/web-app.js
CHANGED
|
@@ -55,6 +55,13 @@ const PORTAL_SESSION_HIDDEN_COLUMN_PX = 48;
|
|
|
55
55
|
const SCROLL_ROW_HEIGHT = 16;
|
|
56
56
|
const SCROLL_BOTTOM_EPSILON_PX = 0.5;
|
|
57
57
|
const PROGRAMMATIC_SCROLL_TOLERANCE_PX = SCROLL_BOTTOM_EPSILON_PX;
|
|
58
|
+
// Minimum downward finger travel (px) while at the top of the chat pane before
|
|
59
|
+
// a touch pull counts as a load-older-history request.
|
|
60
|
+
const TOUCH_TOP_PULL_THRESHOLD_PX = 24;
|
|
61
|
+
// How long after the last user-driven scroll event the pane still counts as
|
|
62
|
+
// momentum-scrolling (native flick glide), during which programmatic scrollTop
|
|
63
|
+
// restores are suppressed so they don't kill the glide.
|
|
64
|
+
const TOUCH_MOMENTUM_GRACE_MS = 700;
|
|
58
65
|
const PROFILE_SETTINGS_POLL_MS = 5000;
|
|
59
66
|
const REASONING_EFFORT_LABELS = new Set(["low", "medium", "high", "xhigh"]);
|
|
60
67
|
const LEGACY_BROWSER_PREFERENCE_STORAGE_KEYS = [
|
|
@@ -335,9 +342,17 @@ function normalizeLines(lines) {
|
|
|
335
342
|
// Markup lines carry preformatted art (splash screens). Tag them
|
|
336
343
|
// so the chat renderer keeps each line intact: the pane's
|
|
337
344
|
// pre-wrap/overflow-wrap rules would rewrap wide art lines at
|
|
338
|
-
// arbitrary points and scramble the image.
|
|
339
|
-
|
|
340
|
-
|
|
345
|
+
// arbitrary points and scramble the image. When a splashAlt is
|
|
346
|
+
// present, emit BOTH variants tagged by name — CSS shows exactly
|
|
347
|
+
// one based on the real device viewport, which beats character
|
|
348
|
+
// metrics at deciding what actually fits.
|
|
349
|
+
const variants = line.splashAlt
|
|
350
|
+
? [{ text: line.value, variant: "desktop" }, { text: line.splashAlt, variant: "mobile" }]
|
|
351
|
+
: [{ text: line.value, variant: null }];
|
|
352
|
+
for (const v of variants) {
|
|
353
|
+
for (const parsedLine of parseTerminalMarkupRuns(v.text || "")) {
|
|
354
|
+
normalized.push({ kind: "runs", runs: parsedLine, preserve: true, splashVariant: v.variant });
|
|
355
|
+
}
|
|
341
356
|
}
|
|
342
357
|
continue;
|
|
343
358
|
}
|
|
@@ -515,11 +530,32 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
515
530
|
scrollMode,
|
|
516
531
|
scrollOffset,
|
|
517
532
|
});
|
|
533
|
+
// Touch scrolling relies on native momentum for speed: a hard flick keeps
|
|
534
|
+
// scrolling long after the finger lifts. Re-asserting scrollTop from state
|
|
535
|
+
// on every render (live events, status updates) kills that momentum at the
|
|
536
|
+
// first re-render, so flicks degrade to slow drags. While a touch gesture
|
|
537
|
+
// or its momentum is in flight, the DOM is the source of truth and state
|
|
538
|
+
// echoes of our own scroll dispatches must not snap the pane back.
|
|
539
|
+
const userScrollRef = React.useRef({ touching: false, lastUserScrollAt: 0, lastDispatchedOffset: null });
|
|
518
540
|
|
|
519
541
|
React.useLayoutEffect(() => {
|
|
520
542
|
const node = ref.current;
|
|
521
543
|
if (!node) return;
|
|
522
544
|
const previousViewportState = previousViewportStateRef.current;
|
|
545
|
+
const interaction = userScrollRef.current;
|
|
546
|
+
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
547
|
+
const interacting = interaction.touching
|
|
548
|
+
|| (now - (interaction.lastUserScrollAt || 0)) < TOUCH_MOMENTUM_GRACE_MS;
|
|
549
|
+
const isEchoOffset = interaction.lastDispatchedOffset != null
|
|
550
|
+
&& Math.abs((Number(scrollOffset) || 0) - interaction.lastDispatchedOffset) < 2;
|
|
551
|
+
if (
|
|
552
|
+
interacting
|
|
553
|
+
&& scrollMode === previousViewportState?.scrollMode
|
|
554
|
+
&& (scrollOffset === previousViewportState?.scrollOffset || isEchoOffset)
|
|
555
|
+
) {
|
|
556
|
+
previousViewportStateRef.current = { scrollMode, scrollOffset };
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
523
559
|
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
524
560
|
const preservePausedStickyScroll = stickyBottom
|
|
525
561
|
&& scrollMode === "top"
|
|
@@ -559,6 +595,7 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
559
595
|
}
|
|
560
596
|
programmaticScrollRef.current = null;
|
|
561
597
|
}
|
|
598
|
+
userScrollRef.current.lastUserScrollAt = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
562
599
|
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
563
600
|
// When the pane has no scrollable content (transient loading state
|
|
564
601
|
// that briefly collapses the body to one line), the browser auto-
|
|
@@ -568,9 +605,11 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
568
605
|
// the desired scroll position from preserved state.
|
|
569
606
|
if (maxScroll <= 0) return;
|
|
570
607
|
if (stickyBottom && typeof controller.updatePaneScrollFromViewport === "function") {
|
|
608
|
+
const rowOffset = Math.max(0, node.scrollTop) / SCROLL_ROW_HEIGHT;
|
|
609
|
+
userScrollRef.current.lastDispatchedOffset = rowOffset;
|
|
571
610
|
controller.updatePaneScrollFromViewport(
|
|
572
611
|
paneKey,
|
|
573
|
-
|
|
612
|
+
rowOffset,
|
|
574
613
|
{ atBottom: isScrollViewportAtBottom(node) },
|
|
575
614
|
);
|
|
576
615
|
return;
|
|
@@ -579,6 +618,7 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
579
618
|
const pixels = scrollMode === "bottom"
|
|
580
619
|
? Math.max(0, maxScroll - node.scrollTop)
|
|
581
620
|
: Math.max(0, node.scrollTop);
|
|
621
|
+
userScrollRef.current.lastDispatchedOffset = pixels / SCROLL_ROW_HEIGHT;
|
|
582
622
|
controller.dispatch({
|
|
583
623
|
type: "ui/scroll",
|
|
584
624
|
pane: paneKey,
|
|
@@ -597,7 +637,42 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
597
637
|
controller.handleChatTopHistoryScrollIntent?.(maxScroll / SCROLL_ROW_HEIGHT);
|
|
598
638
|
}, [controller, paneKey, ref, scrollMode]);
|
|
599
639
|
|
|
600
|
-
|
|
640
|
+
// Touch equivalent of the wheel-at-top gesture: touch devices never fire
|
|
641
|
+
// wheel events, and once scrollTop sits at 0 a further swipe-down emits no
|
|
642
|
+
// scroll events either — so mobile had no way to request older history.
|
|
643
|
+
// A downward pull that starts while the pane is at (or near) the top fires
|
|
644
|
+
// the same top-history intent, once per gesture.
|
|
645
|
+
const touchPullRef = React.useRef({ startY: null, fired: false });
|
|
646
|
+
const onTouchStart = React.useCallback((event) => {
|
|
647
|
+
userScrollRef.current.touching = true;
|
|
648
|
+
if (paneKey !== "chat" || scrollMode !== "bottom") return;
|
|
649
|
+
touchPullRef.current = { startY: event.touches?.[0]?.clientY ?? null, fired: false };
|
|
650
|
+
}, [paneKey, scrollMode]);
|
|
651
|
+
const onTouchEnd = React.useCallback(() => {
|
|
652
|
+
userScrollRef.current.touching = false;
|
|
653
|
+
// Momentum continues past the finger lift; onScroll keeps refreshing
|
|
654
|
+
// lastUserScrollAt for as long as the glide emits scroll events.
|
|
655
|
+
userScrollRef.current.lastUserScrollAt = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
656
|
+
}, []);
|
|
657
|
+
const onTouchMove = React.useCallback((event) => {
|
|
658
|
+
const node = ref.current;
|
|
659
|
+
const pull = touchPullRef.current;
|
|
660
|
+
if (!node || paneKey !== "chat" || scrollMode !== "bottom") return;
|
|
661
|
+
if (pull.fired || pull.startY == null) return;
|
|
662
|
+
if (node.scrollTop > PROGRAMMATIC_SCROLL_TOLERANCE_PX) return;
|
|
663
|
+
const y = event.touches?.[0]?.clientY;
|
|
664
|
+
if (y == null || y - pull.startY < TOUCH_TOP_PULL_THRESHOLD_PX) return;
|
|
665
|
+
pull.fired = true;
|
|
666
|
+
// A deliberate pull at the top of the pane is an unambiguous request:
|
|
667
|
+
// force the load, bypassing both the arm/load two-stage handshake and
|
|
668
|
+
// the DOM-vs-render-metric offset gate (the two measurements disagree
|
|
669
|
+
// on narrow viewports). The wheel path keeps the handshake — one
|
|
670
|
+
// physical scroll emits MANY wheel events, so it needs debouncing.
|
|
671
|
+
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
672
|
+
controller.handleChatTopHistoryScrollIntent?.(maxScroll / SCROLL_ROW_HEIGHT + 1, { force: true });
|
|
673
|
+
}, [controller, paneKey, ref, scrollMode]);
|
|
674
|
+
|
|
675
|
+
return { normalizedLines, onScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd };
|
|
601
676
|
}
|
|
602
677
|
|
|
603
678
|
function Runs({ runs, theme }) {
|
|
@@ -1255,12 +1330,13 @@ function parseStructuredChatBlocks(lines = []) {
|
|
|
1255
1330
|
// detection entirely and render as one preserve block, so no line
|
|
1256
1331
|
// ever rewraps or gets reinterpreted as a structured element.
|
|
1257
1332
|
if (currentLine?.preserve) {
|
|
1333
|
+
const variant = currentLine.splashVariant || null;
|
|
1258
1334
|
const preserveLines = [];
|
|
1259
|
-
while (index < lines.length && lines[index]?.preserve) {
|
|
1335
|
+
while (index < lines.length && lines[index]?.preserve && (lines[index].splashVariant || null) === variant) {
|
|
1260
1336
|
preserveLines.push(lines[index]);
|
|
1261
1337
|
index += 1;
|
|
1262
1338
|
}
|
|
1263
|
-
blocks.push({ type: "preserve", lines: preserveLines });
|
|
1339
|
+
blocks.push({ type: "preserve", lines: preserveLines, splashVariant: variant });
|
|
1264
1340
|
continue;
|
|
1265
1341
|
}
|
|
1266
1342
|
|
|
@@ -1416,7 +1492,8 @@ function StructuredChatBlocks({ lines, theme }) {
|
|
|
1416
1492
|
return React.createElement(React.Fragment, null,
|
|
1417
1493
|
blocks.map((block, index) => {
|
|
1418
1494
|
if (block.type === "preserve") {
|
|
1419
|
-
|
|
1495
|
+
const variantClass = block.splashVariant ? ` is-splash-${block.splashVariant}` : "";
|
|
1496
|
+
return React.createElement("div", { key: `preserve:${index}`, className: `ps-chat-preserve-block${variantClass}` },
|
|
1420
1497
|
block.lines.map((line, lineIndex) => React.createElement(Line, {
|
|
1421
1498
|
key: `preserve:${index}:${lineIndex}`,
|
|
1422
1499
|
line,
|
|
@@ -1643,7 +1720,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
1643
1720
|
const ref = React.useRef(null);
|
|
1644
1721
|
const stickyRef = React.useRef(null);
|
|
1645
1722
|
const syncingHorizontalRef = React.useRef(false);
|
|
1646
|
-
const { normalizedLines, onScroll, onWheel } = useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom });
|
|
1723
|
+
const { normalizedLines, onScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd } = useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom });
|
|
1647
1724
|
const normalizedSticky = React.useMemo(() => normalizeLines(stickyLines), [stickyLines]);
|
|
1648
1725
|
const normalizedBottomSticky = React.useMemo(() => normalizeLines(bottomStickyLines), [bottomStickyLines]);
|
|
1649
1726
|
const preserveHorizontalScroll = className.includes("is-preserve") && panelClassName.includes("has-preserved-sticky");
|
|
@@ -1680,7 +1757,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
1680
1757
|
normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
|
|
1681
1758
|
)
|
|
1682
1759
|
: null,
|
|
1683
|
-
React.createElement("div", { ref, className: `ps-scroll-panel ${className}`.trim(), onScroll: handleBodyScroll, onWheel },
|
|
1760
|
+
React.createElement("div", { ref, className: `ps-scroll-panel ${className}`.trim(), onScroll: handleBodyScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
|
|
1684
1761
|
typeof renderBody === "function"
|
|
1685
1762
|
? renderBody(normalizedLines, theme)
|
|
1686
1763
|
: structuredBlocks
|
|
@@ -4078,6 +4155,41 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
4078
4155
|
const viewport = useMeasuredViewport(viewportRef);
|
|
4079
4156
|
const mainGridViewport = useMeasuredViewport(mainGridRef);
|
|
4080
4157
|
const gridViewport = computeGridViewport(viewport);
|
|
4158
|
+
|
|
4159
|
+
// Publish the real viewport in character cells. Without this the ui-core
|
|
4160
|
+
// layout model runs on its 120×40 fallback everywhere in the browser, so
|
|
4161
|
+
// width-derived behavior (markdown wrap width, render metrics, the
|
|
4162
|
+
// splashMobile width swap) assumed a ~120-column terminal regardless of
|
|
4163
|
+
// device — phones kept getting desktop splash art narrower than ~76 cols.
|
|
4164
|
+
React.useEffect(() => {
|
|
4165
|
+
const publish = () => {
|
|
4166
|
+
const probe = document.createElement("span");
|
|
4167
|
+
probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
|
|
4168
|
+
// Measure with the font real lines render in — an off-context
|
|
4169
|
+
// probe can inherit a different size and skew the column count.
|
|
4170
|
+
const sample = document.querySelector(".ps-scroll-panel .ps-line") || document.querySelector(".ps-line");
|
|
4171
|
+
if (sample) probe.style.font = window.getComputedStyle(sample).font;
|
|
4172
|
+
probe.textContent = "0".repeat(100);
|
|
4173
|
+
(sample?.parentElement || document.body).appendChild(probe);
|
|
4174
|
+
const charWidth = (probe.getBoundingClientRect().width / 100) || 8;
|
|
4175
|
+
probe.remove();
|
|
4176
|
+
controller.dispatch({
|
|
4177
|
+
type: "ui/viewport",
|
|
4178
|
+
width: Math.floor(window.innerWidth / charWidth),
|
|
4179
|
+
height: Math.floor(window.innerHeight / SCROLL_ROW_HEIGHT),
|
|
4180
|
+
});
|
|
4181
|
+
};
|
|
4182
|
+
publish();
|
|
4183
|
+
let timer;
|
|
4184
|
+
const onResize = () => { clearTimeout(timer); timer = setTimeout(publish, 150); };
|
|
4185
|
+
window.addEventListener("resize", onResize);
|
|
4186
|
+
window.addEventListener("orientationchange", onResize);
|
|
4187
|
+
return () => {
|
|
4188
|
+
clearTimeout(timer);
|
|
4189
|
+
window.removeEventListener("resize", onResize);
|
|
4190
|
+
window.removeEventListener("orientationchange", onResize);
|
|
4191
|
+
};
|
|
4192
|
+
}, [controller]);
|
|
4081
4193
|
const [chatFocusMode, setChatFocusMode] = React.useState(false);
|
|
4082
4194
|
const [chatFocusPane, setChatFocusPane] = React.useState(null);
|
|
4083
4195
|
const state = useControllerSelector(controller, (rootState) => ({
|