pilotswarm 0.4.0 → 0.4.1
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 +3 -2
- package/tui/src/node-sdk-transport.js +6 -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 +11 -1
- package/ui/core/src/state.js +3 -0
- package/ui/react/src/web-app.js +71 -4
- package/web/dist/assets/index-DmGOcKR-.css +1 -0
- package/web/dist/assets/{index-DMefB7Wb.js → index-xJ8IzIZY.js} +4 -4
- package/web/dist/assets/pilotswarm-D9pEmenA.js +90 -0
- package/web/dist/assets/{react-BZwsW8pe.js → react-CEPDSRB6.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/runtime.js +3 -2
- package/web/dist/assets/index-CBgQQk-j.css +0 -1
- package/web/dist/assets/pilotswarm-DyBlqVTY.js +0 -87
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pilotswarm",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"tui/src/**/*",
|
|
36
36
|
"tui/plugins/**/*",
|
|
37
37
|
"tui/tui-splash.txt",
|
|
38
|
+
"tui/tui-splash-mobile.txt",
|
|
38
39
|
"web/auth.js",
|
|
39
40
|
"web/auth/**/*",
|
|
40
41
|
"web/api/**/*",
|
|
@@ -75,7 +76,7 @@
|
|
|
75
76
|
"hono": "^4.12.10",
|
|
76
77
|
"ink": "^6.8.0",
|
|
77
78
|
"jose": "^6.2.2",
|
|
78
|
-
"pilotswarm-sdk": "^0.4.
|
|
79
|
+
"pilotswarm-sdk": "^0.4.1",
|
|
79
80
|
"react": "^19.2.4",
|
|
80
81
|
"react-dom": "^19.2.4",
|
|
81
82
|
"ws": "^8.18.2"
|
|
@@ -927,13 +927,14 @@ export class NodeSdkTransport {
|
|
|
927
927
|
return { sessionId: session.sessionId, model: effectiveModel, reasoningEffort: reasoningEffort || undefined };
|
|
928
928
|
}
|
|
929
929
|
|
|
930
|
-
async createSessionForAgent(agentName, { model, reasoningEffort, title, splash, initialPrompt, owner, groupId } = {}) {
|
|
930
|
+
async createSessionForAgent(agentName, { model, reasoningEffort, title, splash, splashMobile, initialPrompt, owner, groupId } = {}) {
|
|
931
931
|
const effectiveModel = await this.assertSessionModelCreatable({ model, owner });
|
|
932
932
|
const session = await this.client.createSessionForAgent(agentName, {
|
|
933
933
|
...(effectiveModel ? { model: effectiveModel } : {}),
|
|
934
934
|
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
935
935
|
...(title ? { title } : {}),
|
|
936
936
|
...(splash ? { splash } : {}),
|
|
937
|
+
...(splashMobile ? { splashMobile } : {}),
|
|
937
938
|
...(initialPrompt ? { initialPrompt } : {}),
|
|
938
939
|
...(owner ? { owner } : {}),
|
|
939
940
|
...(groupId ? { groupId } : {}),
|
|
@@ -1311,13 +1312,13 @@ export class NodeSdkTransport {
|
|
|
1311
1312
|
return this.mgmt.getDefaultModel();
|
|
1312
1313
|
}
|
|
1313
1314
|
|
|
1314
|
-
async getSessionEvents(sessionId, afterSeq, limit) {
|
|
1315
|
-
return this.mgmt.getSessionEvents(sessionId, afterSeq, limit);
|
|
1315
|
+
async getSessionEvents(sessionId, afterSeq, limit, eventTypes) {
|
|
1316
|
+
return this.mgmt.getSessionEvents(sessionId, afterSeq, limit, eventTypes);
|
|
1316
1317
|
}
|
|
1317
1318
|
|
|
1318
|
-
async getSessionEventsBefore(sessionId, beforeSeq, limit) {
|
|
1319
|
+
async getSessionEventsBefore(sessionId, beforeSeq, limit, eventTypes) {
|
|
1319
1320
|
if (typeof this.mgmt.getSessionEventsBefore !== "function") return [];
|
|
1320
|
-
return this.mgmt.getSessionEventsBefore(sessionId, beforeSeq, limit);
|
|
1321
|
+
return this.mgmt.getSessionEventsBefore(sessionId, beforeSeq, limit, eventTypes);
|
|
1321
1322
|
}
|
|
1322
1323
|
|
|
1323
1324
|
emitLogEntry(entry) {
|
package/tui/src/plugin-config.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
const pkgRoot = path.resolve(__dirname, "..");
|
|
7
7
|
const defaultTuiSplashPath = path.join(pkgRoot, "tui-splash.txt");
|
|
8
|
+
const defaultTuiSplashMobilePath = path.join(pkgRoot, "tui-splash-mobile.txt");
|
|
8
9
|
|
|
9
10
|
function fileExists(filePath) {
|
|
10
11
|
try {
|
|
@@ -93,10 +94,27 @@ function readSplashValue(baseDir, config, fallback) {
|
|
|
93
94
|
return fallback;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
// Narrow-viewport splash variant (splashMobile / splashMobileFile). No
|
|
98
|
+
// default art: null means "wrap the main splash when it does not fit".
|
|
99
|
+
function readSplashMobileValue(baseDir, config, fallback = null) {
|
|
100
|
+
if (typeof config?.splashMobile === "string" && config.splashMobile.trim()) {
|
|
101
|
+
return config.splashMobile;
|
|
102
|
+
}
|
|
103
|
+
if (typeof config?.splashMobileFile === "string" && config.splashMobileFile.trim()) {
|
|
104
|
+
const fileText = readRelativeTextFile(baseDir, config.splashMobileFile);
|
|
105
|
+
if (fileText != null) return fileText;
|
|
106
|
+
}
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
|
|
96
110
|
function getDefaultSplash() {
|
|
97
111
|
return readOptionalTextFile(defaultTuiSplashPath) || "{bold}{cyan-fg}PilotSwarm{/cyan-fg}{/bold}";
|
|
98
112
|
}
|
|
99
113
|
|
|
114
|
+
function getDefaultSplashMobile() {
|
|
115
|
+
return readOptionalTextFile(defaultTuiSplashMobilePath) || null;
|
|
116
|
+
}
|
|
117
|
+
|
|
100
118
|
export function readPluginMetadata(pluginDir) {
|
|
101
119
|
if (!pluginDir) return null;
|
|
102
120
|
const pluginJsonPath = path.join(pluginDir, "plugin.json");
|
|
@@ -130,12 +148,17 @@ export function resolveTuiBranding(pluginDir) {
|
|
|
130
148
|
const tui = pluginMeta?.tui;
|
|
131
149
|
const defaultSplash = getDefaultSplash();
|
|
132
150
|
if (!tui || typeof tui !== "object") {
|
|
133
|
-
|
|
151
|
+
const defaultMobile = getDefaultSplashMobile();
|
|
152
|
+
return { title: "PilotSwarm", splash: defaultSplash, ...(defaultMobile ? { splashMobile: defaultMobile } : {}) };
|
|
134
153
|
}
|
|
135
154
|
|
|
136
155
|
const title = firstNonEmptyString(tui.title, "PilotSwarm") || "PilotSwarm";
|
|
137
156
|
const splash = readSplashValue(pluginDir, tui, defaultSplash);
|
|
138
|
-
|
|
157
|
+
// The default mobile art only pairs with the default splash — an app that
|
|
158
|
+
// ships its own splash without a mobile variant wraps instead of showing
|
|
159
|
+
// PilotSwarm-branded art.
|
|
160
|
+
const splashMobile = readSplashMobileValue(pluginDir, tui, splash === defaultSplash ? getDefaultSplashMobile() : null);
|
|
161
|
+
return { title, splash, ...(splashMobile ? { splashMobile } : {}) };
|
|
139
162
|
}
|
|
140
163
|
|
|
141
164
|
export function resolvePortalConfigBundleFromPluginDirs(pluginDirs = []) {
|
|
@@ -145,6 +168,7 @@ export function resolvePortalConfigBundleFromPluginDirs(pluginDirs = []) {
|
|
|
145
168
|
title: "PilotSwarm",
|
|
146
169
|
pageTitle: "PilotSwarm",
|
|
147
170
|
splash: defaultSplash,
|
|
171
|
+
splashMobile: getDefaultSplashMobile(),
|
|
148
172
|
logoUrl: null,
|
|
149
173
|
faviconUrl: null,
|
|
150
174
|
},
|
|
@@ -179,6 +203,15 @@ export function resolvePortalConfigBundleFromPluginDirs(pluginDirs = []) {
|
|
|
179
203
|
portalBranding,
|
|
180
204
|
readSplashValue(absDir, portal, readSplashValue(absDir, tui, defaults.branding.splash)),
|
|
181
205
|
);
|
|
206
|
+
const splashMobile = readSplashMobileValue(
|
|
207
|
+
absDir,
|
|
208
|
+
portalBranding,
|
|
209
|
+
readSplashMobileValue(
|
|
210
|
+
absDir,
|
|
211
|
+
portal,
|
|
212
|
+
readSplashMobileValue(absDir, tui, splash === defaultSplash ? getDefaultSplashMobile() : null),
|
|
213
|
+
),
|
|
214
|
+
);
|
|
182
215
|
const logoAsset = resolvePortalAsset(absDir, {
|
|
183
216
|
file: firstNonEmptyString(portalBranding.logoFile, portal.logoFile),
|
|
184
217
|
url: firstNonEmptyString(portalBranding.logoUrl, portal.logoUrl),
|
|
@@ -193,6 +226,7 @@ export function resolvePortalConfigBundleFromPluginDirs(pluginDirs = []) {
|
|
|
193
226
|
title,
|
|
194
227
|
pageTitle,
|
|
195
228
|
splash,
|
|
229
|
+
splashMobile: splashMobile || null,
|
|
196
230
|
logoUrl: logoAsset.publicUrl || null,
|
|
197
231
|
faviconUrl: faviconAsset.publicUrl || null,
|
|
198
232
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{bold}{cyan-fg}█▀█ █ █ █▀█ ▀█▀{/cyan-fg} {magenta-fg}█▀ █ █ █ ▄▀█ █▀█ █▀▄▀█{/magenta-fg}{/bold}
|
|
2
|
+
{bold}{cyan-fg}█▀▀ █ █▄▄ █▄█ █ {/cyan-fg} {magenta-fg}▄█ ▀▄▀▄▀ █▀█ █▀▄ █ ▀ █{/magenta-fg}{/bold}
|
|
3
|
+
{yellow-fg}░▒▓██████████████████████████████████▓▒░{/yellow-fg}
|
|
4
|
+
{bold}{white-fg}Durable AI Agent Orchestration{/white-fg}{/bold}
|
|
5
|
+
{cyan-fg}Crash recovery{/cyan-fg} · {magenta-fg}Durable timers{/magenta-fg}
|
|
6
|
+
{yellow-fg}Sub-agents{/yellow-fg} · {green-fg}Multi-node scaling{/green-fg}
|
|
7
|
+
{gray-fg}Powered by duroxide + Copilot SDK{/gray-fg}
|
|
@@ -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,18 @@ 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).
|
|
1710
|
+
const useMobile = message.mobileText && splashArtWidth(message.text) > maxWidth;
|
|
1711
|
+
return [{ kind: "markup", value: useMobile ? message.mobileText : message.text }];
|
|
1702
1712
|
}
|
|
1703
1713
|
|
|
1704
1714
|
if (message?.thinking) {
|
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 = [
|
|
@@ -515,11 +522,32 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
515
522
|
scrollMode,
|
|
516
523
|
scrollOffset,
|
|
517
524
|
});
|
|
525
|
+
// Touch scrolling relies on native momentum for speed: a hard flick keeps
|
|
526
|
+
// scrolling long after the finger lifts. Re-asserting scrollTop from state
|
|
527
|
+
// on every render (live events, status updates) kills that momentum at the
|
|
528
|
+
// first re-render, so flicks degrade to slow drags. While a touch gesture
|
|
529
|
+
// or its momentum is in flight, the DOM is the source of truth and state
|
|
530
|
+
// echoes of our own scroll dispatches must not snap the pane back.
|
|
531
|
+
const userScrollRef = React.useRef({ touching: false, lastUserScrollAt: 0, lastDispatchedOffset: null });
|
|
518
532
|
|
|
519
533
|
React.useLayoutEffect(() => {
|
|
520
534
|
const node = ref.current;
|
|
521
535
|
if (!node) return;
|
|
522
536
|
const previousViewportState = previousViewportStateRef.current;
|
|
537
|
+
const interaction = userScrollRef.current;
|
|
538
|
+
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
539
|
+
const interacting = interaction.touching
|
|
540
|
+
|| (now - (interaction.lastUserScrollAt || 0)) < TOUCH_MOMENTUM_GRACE_MS;
|
|
541
|
+
const isEchoOffset = interaction.lastDispatchedOffset != null
|
|
542
|
+
&& Math.abs((Number(scrollOffset) || 0) - interaction.lastDispatchedOffset) < 2;
|
|
543
|
+
if (
|
|
544
|
+
interacting
|
|
545
|
+
&& scrollMode === previousViewportState?.scrollMode
|
|
546
|
+
&& (scrollOffset === previousViewportState?.scrollOffset || isEchoOffset)
|
|
547
|
+
) {
|
|
548
|
+
previousViewportStateRef.current = { scrollMode, scrollOffset };
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
523
551
|
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
524
552
|
const preservePausedStickyScroll = stickyBottom
|
|
525
553
|
&& scrollMode === "top"
|
|
@@ -559,6 +587,7 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
559
587
|
}
|
|
560
588
|
programmaticScrollRef.current = null;
|
|
561
589
|
}
|
|
590
|
+
userScrollRef.current.lastUserScrollAt = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
562
591
|
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
563
592
|
// When the pane has no scrollable content (transient loading state
|
|
564
593
|
// that briefly collapses the body to one line), the browser auto-
|
|
@@ -568,9 +597,11 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
568
597
|
// the desired scroll position from preserved state.
|
|
569
598
|
if (maxScroll <= 0) return;
|
|
570
599
|
if (stickyBottom && typeof controller.updatePaneScrollFromViewport === "function") {
|
|
600
|
+
const rowOffset = Math.max(0, node.scrollTop) / SCROLL_ROW_HEIGHT;
|
|
601
|
+
userScrollRef.current.lastDispatchedOffset = rowOffset;
|
|
571
602
|
controller.updatePaneScrollFromViewport(
|
|
572
603
|
paneKey,
|
|
573
|
-
|
|
604
|
+
rowOffset,
|
|
574
605
|
{ atBottom: isScrollViewportAtBottom(node) },
|
|
575
606
|
);
|
|
576
607
|
return;
|
|
@@ -579,6 +610,7 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
579
610
|
const pixels = scrollMode === "bottom"
|
|
580
611
|
? Math.max(0, maxScroll - node.scrollTop)
|
|
581
612
|
: Math.max(0, node.scrollTop);
|
|
613
|
+
userScrollRef.current.lastDispatchedOffset = pixels / SCROLL_ROW_HEIGHT;
|
|
582
614
|
controller.dispatch({
|
|
583
615
|
type: "ui/scroll",
|
|
584
616
|
pane: paneKey,
|
|
@@ -597,7 +629,42 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
597
629
|
controller.handleChatTopHistoryScrollIntent?.(maxScroll / SCROLL_ROW_HEIGHT);
|
|
598
630
|
}, [controller, paneKey, ref, scrollMode]);
|
|
599
631
|
|
|
600
|
-
|
|
632
|
+
// Touch equivalent of the wheel-at-top gesture: touch devices never fire
|
|
633
|
+
// wheel events, and once scrollTop sits at 0 a further swipe-down emits no
|
|
634
|
+
// scroll events either — so mobile had no way to request older history.
|
|
635
|
+
// A downward pull that starts while the pane is at (or near) the top fires
|
|
636
|
+
// the same top-history intent, once per gesture.
|
|
637
|
+
const touchPullRef = React.useRef({ startY: null, fired: false });
|
|
638
|
+
const onTouchStart = React.useCallback((event) => {
|
|
639
|
+
userScrollRef.current.touching = true;
|
|
640
|
+
if (paneKey !== "chat" || scrollMode !== "bottom") return;
|
|
641
|
+
touchPullRef.current = { startY: event.touches?.[0]?.clientY ?? null, fired: false };
|
|
642
|
+
}, [paneKey, scrollMode]);
|
|
643
|
+
const onTouchEnd = React.useCallback(() => {
|
|
644
|
+
userScrollRef.current.touching = false;
|
|
645
|
+
// Momentum continues past the finger lift; onScroll keeps refreshing
|
|
646
|
+
// lastUserScrollAt for as long as the glide emits scroll events.
|
|
647
|
+
userScrollRef.current.lastUserScrollAt = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
648
|
+
}, []);
|
|
649
|
+
const onTouchMove = React.useCallback((event) => {
|
|
650
|
+
const node = ref.current;
|
|
651
|
+
const pull = touchPullRef.current;
|
|
652
|
+
if (!node || paneKey !== "chat" || scrollMode !== "bottom") return;
|
|
653
|
+
if (pull.fired || pull.startY == null) return;
|
|
654
|
+
if (node.scrollTop > PROGRAMMATIC_SCROLL_TOLERANCE_PX) return;
|
|
655
|
+
const y = event.touches?.[0]?.clientY;
|
|
656
|
+
if (y == null || y - pull.startY < TOUCH_TOP_PULL_THRESHOLD_PX) return;
|
|
657
|
+
pull.fired = true;
|
|
658
|
+
// A deliberate pull at the top of the pane is an unambiguous request:
|
|
659
|
+
// force the load, bypassing both the arm/load two-stage handshake and
|
|
660
|
+
// the DOM-vs-render-metric offset gate (the two measurements disagree
|
|
661
|
+
// on narrow viewports). The wheel path keeps the handshake — one
|
|
662
|
+
// physical scroll emits MANY wheel events, so it needs debouncing.
|
|
663
|
+
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
664
|
+
controller.handleChatTopHistoryScrollIntent?.(maxScroll / SCROLL_ROW_HEIGHT + 1, { force: true });
|
|
665
|
+
}, [controller, paneKey, ref, scrollMode]);
|
|
666
|
+
|
|
667
|
+
return { normalizedLines, onScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd };
|
|
601
668
|
}
|
|
602
669
|
|
|
603
670
|
function Runs({ runs, theme }) {
|
|
@@ -1643,7 +1710,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
1643
1710
|
const ref = React.useRef(null);
|
|
1644
1711
|
const stickyRef = React.useRef(null);
|
|
1645
1712
|
const syncingHorizontalRef = React.useRef(false);
|
|
1646
|
-
const { normalizedLines, onScroll, onWheel } = useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom });
|
|
1713
|
+
const { normalizedLines, onScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd } = useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom });
|
|
1647
1714
|
const normalizedSticky = React.useMemo(() => normalizeLines(stickyLines), [stickyLines]);
|
|
1648
1715
|
const normalizedBottomSticky = React.useMemo(() => normalizeLines(bottomStickyLines), [bottomStickyLines]);
|
|
1649
1716
|
const preserveHorizontalScroll = className.includes("is-preserve") && panelClassName.includes("has-preserved-sticky");
|
|
@@ -1680,7 +1747,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
1680
1747
|
normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
|
|
1681
1748
|
)
|
|
1682
1749
|
: null,
|
|
1683
|
-
React.createElement("div", { ref, className: `ps-scroll-panel ${className}`.trim(), onScroll: handleBodyScroll, onWheel },
|
|
1750
|
+
React.createElement("div", { ref, className: `ps-scroll-panel ${className}`.trim(), onScroll: handleBodyScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
|
|
1684
1751
|
typeof renderBody === "function"
|
|
1685
1752
|
? renderBody(normalizedLines, theme)
|
|
1686
1753
|
: structuredBlocks
|