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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.4.0",
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.0",
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) {
@@ -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
- return { title: "PilotSwarm", splash: defaultSplash };
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
- return { title, splash };
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
- const { contentHeight, totalLines } = this.getActiveChatRenderMetrics(state);
5208
- const maxOffset = Math.max(0, totalLines - contentHeight);
5209
- if (targetOffset < maxOffset) return;
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
- if (preserveChatView && previousScrollOffset > 0) {
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);
@@ -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 splash = typeof session?.splash === "string" && session.splash.trim()
921
+ const sessionSplash = typeof session?.splash === "string" && session.splash.trim()
912
922
  ? session.splash
913
- : branding?.splash;
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
  }];
@@ -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
 
@@ -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
- return [{ kind: "markup", value: message.text }];
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) {
@@ -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,
@@ -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
- Math.max(0, node.scrollTop) / SCROLL_ROW_HEIGHT,
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
- return { normalizedLines, onScroll, onWheel };
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