pilotswarm-web 0.1.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.
Files changed (57) hide show
  1. package/README.md +144 -0
  2. package/auth/authz/engine.js +139 -0
  3. package/auth/config.js +110 -0
  4. package/auth/index.js +153 -0
  5. package/auth/normalize/entra.js +22 -0
  6. package/auth/providers/entra.js +76 -0
  7. package/auth/providers/none.js +24 -0
  8. package/auth.js +10 -0
  9. package/bin/serve.js +53 -0
  10. package/config.js +20 -0
  11. package/dist/app.js +469 -0
  12. package/dist/assets/index-BSVg-lGb.css +1 -0
  13. package/dist/assets/index-BXD5YP7A.js +24 -0
  14. package/dist/assets/msal-CytV9RFv.js +7 -0
  15. package/dist/assets/pilotswarm-WX3NED6m.js +40 -0
  16. package/dist/assets/react-jg0oazEi.js +1 -0
  17. package/dist/index.html +16 -0
  18. package/node_modules/pilotswarm-ui-core/README.md +6 -0
  19. package/node_modules/pilotswarm-ui-core/package.json +32 -0
  20. package/node_modules/pilotswarm-ui-core/src/commands.js +72 -0
  21. package/node_modules/pilotswarm-ui-core/src/context-usage.js +212 -0
  22. package/node_modules/pilotswarm-ui-core/src/controller.js +3613 -0
  23. package/node_modules/pilotswarm-ui-core/src/formatting.js +872 -0
  24. package/node_modules/pilotswarm-ui-core/src/history.js +571 -0
  25. package/node_modules/pilotswarm-ui-core/src/index.js +13 -0
  26. package/node_modules/pilotswarm-ui-core/src/layout.js +196 -0
  27. package/node_modules/pilotswarm-ui-core/src/reducer.js +1027 -0
  28. package/node_modules/pilotswarm-ui-core/src/selectors.js +2786 -0
  29. package/node_modules/pilotswarm-ui-core/src/session-tree.js +109 -0
  30. package/node_modules/pilotswarm-ui-core/src/state.js +80 -0
  31. package/node_modules/pilotswarm-ui-core/src/store.js +23 -0
  32. package/node_modules/pilotswarm-ui-core/src/system-titles.js +24 -0
  33. package/node_modules/pilotswarm-ui-core/src/themes/catppuccin-mocha.js +56 -0
  34. package/node_modules/pilotswarm-ui-core/src/themes/cobalt2.js +56 -0
  35. package/node_modules/pilotswarm-ui-core/src/themes/dark-high-contrast.js +56 -0
  36. package/node_modules/pilotswarm-ui-core/src/themes/dracula.js +56 -0
  37. package/node_modules/pilotswarm-ui-core/src/themes/github-dark.js +56 -0
  38. package/node_modules/pilotswarm-ui-core/src/themes/gruvbox-dark.js +56 -0
  39. package/node_modules/pilotswarm-ui-core/src/themes/hacker-x-matrix.js +56 -0
  40. package/node_modules/pilotswarm-ui-core/src/themes/hacker-x-orion-prime.js +56 -0
  41. package/node_modules/pilotswarm-ui-core/src/themes/helpers.js +77 -0
  42. package/node_modules/pilotswarm-ui-core/src/themes/index.js +42 -0
  43. package/node_modules/pilotswarm-ui-core/src/themes/noctis-viola.js +56 -0
  44. package/node_modules/pilotswarm-ui-core/src/themes/noctis.js +56 -0
  45. package/node_modules/pilotswarm-ui-core/src/themes/nord.js +56 -0
  46. package/node_modules/pilotswarm-ui-core/src/themes/solarized-dark.js +56 -0
  47. package/node_modules/pilotswarm-ui-core/src/themes/tokyo-night.js +56 -0
  48. package/node_modules/pilotswarm-ui-react/README.md +5 -0
  49. package/node_modules/pilotswarm-ui-react/package.json +36 -0
  50. package/node_modules/pilotswarm-ui-react/src/components.js +1316 -0
  51. package/node_modules/pilotswarm-ui-react/src/index.js +4 -0
  52. package/node_modules/pilotswarm-ui-react/src/platform.js +15 -0
  53. package/node_modules/pilotswarm-ui-react/src/use-controller-state.js +38 -0
  54. package/node_modules/pilotswarm-ui-react/src/web-app.js +2661 -0
  55. package/package.json +64 -0
  56. package/runtime.js +146 -0
  57. package/server.js +311 -0
@@ -0,0 +1,3613 @@
1
+ import { UI_COMMANDS, FOCUS_REGIONS, INSPECTOR_TABS, cycleValue } from "./commands.js";
2
+ import {
3
+ appendEventToHistory,
4
+ buildHistoryModel,
5
+ DEFAULT_HISTORY_EVENT_LIMIT,
6
+ dedupeChatMessages,
7
+ getNextHistoryEventLimit,
8
+ } from "./history.js";
9
+ import { applySessionUsageEvent, cloneContextUsageSnapshot } from "./context-usage.js";
10
+ import {
11
+ computeLegacyLayout,
12
+ getFocusLeftTarget,
13
+ getFocusOrderForLayout,
14
+ getFocusRightTarget,
15
+ getPromptInputRows,
16
+ normalizeFocusRegion,
17
+ } from "./layout.js";
18
+ import { parseTerminalMarkupRuns } from "./formatting.js";
19
+ import {
20
+ selectActiveArtifactLinks,
21
+ selectActivityPane,
22
+ selectChatLines,
23
+ selectFileBrowserItems,
24
+ selectFilesScope,
25
+ selectFilesView,
26
+ selectInspector,
27
+ selectSessionRows,
28
+ selectSelectedFileBrowserItem,
29
+ selectVisibleSessionRows,
30
+ } from "./selectors.js";
31
+ import { getTheme, listThemes } from "./themes/index.js";
32
+
33
+ const ORCHESTRATION_STATS_REFRESH_MS = 20_000;
34
+ const SESSION_REFRESH_FAILED_STATUS = "Session refresh failed";
35
+ const FULLSCREENABLE_PANES = new Set([
36
+ FOCUS_REGIONS.SESSIONS,
37
+ FOCUS_REGIONS.CHAT,
38
+ FOCUS_REGIONS.INSPECTOR,
39
+ FOCUS_REGIONS.ACTIVITY,
40
+ ]);
41
+
42
+ function groupModelsByProvider(models = []) {
43
+ const groups = [];
44
+ const byProvider = new Map();
45
+
46
+ for (const model of models) {
47
+ const providerId = model?.providerId || "models";
48
+ let group = byProvider.get(providerId);
49
+ if (!group) {
50
+ group = {
51
+ providerId,
52
+ providerType: model?.providerType || "provider",
53
+ models: [],
54
+ };
55
+ byProvider.set(providerId, group);
56
+ groups.push(group);
57
+ }
58
+ group.models.push(model);
59
+ }
60
+
61
+ return groups;
62
+ }
63
+
64
+ function extractSessionModelFromEvents(events = []) {
65
+ for (let index = events.length - 1; index >= 0; index -= 1) {
66
+ const data = events[index]?.data;
67
+ if (data && typeof data === "object") {
68
+ if (typeof data.model === "string" && data.model) return data.model;
69
+ if (typeof data.currentModel === "string" && data.currentModel) return data.currentModel;
70
+ if (typeof data.newModel === "string" && data.newModel) return data.newModel;
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ function extractSessionModelFromEvent(event) {
77
+ return extractSessionModelFromEvents([event]);
78
+ }
79
+
80
+ function extractSessionContextUsageFromEvents(initialContextUsage, events = []) {
81
+ let current = cloneContextUsageSnapshot(initialContextUsage);
82
+ for (const event of events) {
83
+ const next = applySessionUsageEvent(current, event?.eventType, event?.data, {
84
+ timestamp: event?.createdAt,
85
+ });
86
+ if (next) current = next;
87
+ }
88
+ return current;
89
+ }
90
+
91
+ function areStructuredValuesEqual(left, right) {
92
+ if (Object.is(left, right)) return true;
93
+ if (Array.isArray(left) || Array.isArray(right)) {
94
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
95
+ for (let index = 0; index < left.length; index += 1) {
96
+ if (!areStructuredValuesEqual(left[index], right[index])) return false;
97
+ }
98
+ return true;
99
+ }
100
+ if (!left || !right || typeof left !== "object" || typeof right !== "object") {
101
+ return false;
102
+ }
103
+ const leftKeys = Object.keys(left);
104
+ const rightKeys = Object.keys(right);
105
+ if (leftKeys.length !== rightKeys.length) return false;
106
+ for (const key of leftKeys) {
107
+ if (!Object.prototype.hasOwnProperty.call(right, key)) return false;
108
+ if (!areStructuredValuesEqual(left[key], right[key])) return false;
109
+ }
110
+ return true;
111
+ }
112
+
113
+ function buildSessionMergePatch(previousSession, nextSession) {
114
+ if (!nextSession?.sessionId) return null;
115
+
116
+ const patch = { sessionId: nextSession.sessionId };
117
+ let changed = false;
118
+ for (const [key, value] of Object.entries(nextSession)) {
119
+ if (key === "sessionId" || value === undefined) continue;
120
+ if (areStructuredValuesEqual(previousSession?.[key], value)) continue;
121
+ patch[key] = value;
122
+ changed = true;
123
+ }
124
+
125
+ if (nextSession.pendingQuestion === undefined && previousSession?.pendingQuestion && nextSession.status !== "input_required") {
126
+ patch.pendingQuestion = null;
127
+ changed = true;
128
+ }
129
+ if (nextSession.waitReason === undefined && previousSession?.waitReason && nextSession.status !== "waiting" && nextSession.status !== "input_required") {
130
+ patch.waitReason = null;
131
+ changed = true;
132
+ }
133
+ if (nextSession.error === undefined && previousSession?.error && nextSession.status !== "failed" && nextSession.status !== "error") {
134
+ patch.error = null;
135
+ changed = true;
136
+ }
137
+ if (nextSession.result === undefined && previousSession?.result && nextSession.status !== "completed") {
138
+ patch.result = null;
139
+ changed = true;
140
+ }
141
+ if (nextSession.cronActive !== true) {
142
+ if (previousSession?.cronReason) {
143
+ patch.cronReason = null;
144
+ changed = true;
145
+ }
146
+ if (previousSession?.cronInterval != null && nextSession.cronInterval === undefined) {
147
+ patch.cronInterval = null;
148
+ changed = true;
149
+ }
150
+ }
151
+
152
+ const terminalSession = isTerminalSessionStatus(nextSession.status) || isTerminalOrchestrationStatus(nextSession.orchestrationStatus);
153
+ if (
154
+ terminalSession
155
+ && previousSession?.contextUsage?.compaction?.state === "running"
156
+ && nextSession.contextUsage === undefined
157
+ ) {
158
+ const nextContextUsage = { ...previousSession.contextUsage };
159
+ delete nextContextUsage.compaction;
160
+ patch.contextUsage = Object.keys(nextContextUsage).length > 0 ? nextContextUsage : null;
161
+ changed = true;
162
+ }
163
+
164
+ return changed ? patch : null;
165
+ }
166
+
167
+ function isTerminalOrchestrationStatus(status) {
168
+ return status === "Completed" || status === "Failed" || status === "Terminated";
169
+ }
170
+
171
+ function isTerminalSessionStatus(status) {
172
+ return status === "completed" || status === "failed" || status === "cancelled";
173
+ }
174
+
175
+ function isTerminalSendError(error) {
176
+ const message = String(error?.message || error || "");
177
+ return /instance is terminal|terminal orchestration|cannot accept new messages/i.test(message);
178
+ }
179
+
180
+ function appendSyntheticChatMessage(history, message) {
181
+ return {
182
+ ...(history || {}),
183
+ chat: [
184
+ ...((history && Array.isArray(history.chat)) ? history.chat : []),
185
+ message,
186
+ ],
187
+ };
188
+ }
189
+
190
+ function formatTerminalReferenceLine(label, value) {
191
+ return `- ${label}: ${value}`;
192
+ }
193
+
194
+ function buildTerminalSendRejectedMessage(session, error) {
195
+ const shortSessionId = String(session?.sessionId || "unknown").slice(0, 8);
196
+ const orchestrationStatus = String(session?.orchestrationStatus || "Unknown");
197
+ const sessionStatus = String(session?.status || "unknown");
198
+ const parentSessionId = session?.parentSessionId ? String(session.parentSessionId).slice(0, 8) : "root";
199
+ const cronSummary = session?.cronActive === true || typeof session?.cronInterval === "number"
200
+ ? `active${typeof session?.cronInterval === "number" ? ` (${session.cronInterval}s)` : ""}`
201
+ : "inactive";
202
+ const body = [
203
+ `Cannot send a new message because session ${shortSessionId} is attached to a terminal orchestration instance.`,
204
+ "",
205
+ "Reference:",
206
+ formatTerminalReferenceLine("Session status", sessionStatus),
207
+ formatTerminalReferenceLine("Orchestration status", orchestrationStatus),
208
+ formatTerminalReferenceLine("Parent session", parentSessionId),
209
+ formatTerminalReferenceLine("Cron", cronSummary),
210
+ ];
211
+
212
+ if (typeof session?.waitReason === "string" && session.waitReason.trim()) {
213
+ body.push(formatTerminalReferenceLine("Wait reason", session.waitReason.trim()));
214
+ }
215
+ if (typeof session?.error === "string" && session.error.trim()) {
216
+ body.push(formatTerminalReferenceLine("Error", session.error.trim().split("\n")[0]));
217
+ } else if (error?.message) {
218
+ body.push(formatTerminalReferenceLine("Reject reason", String(error.message).trim()));
219
+ }
220
+ if (typeof session?.result === "string" && session.result.trim()) {
221
+ body.push(formatTerminalReferenceLine("Result", "completed response available"));
222
+ }
223
+
224
+ body.push("", "Create a new session to continue.");
225
+
226
+ return {
227
+ id: `send-error:${session?.sessionId || "unknown"}:${Date.now()}`,
228
+ role: "system",
229
+ text: body.join("\n"),
230
+ time: "",
231
+ createdAt: Date.now(),
232
+ cardTitle: "Error",
233
+ cardTitleColor: "red",
234
+ cardBorderColor: "red",
235
+ };
236
+ }
237
+
238
+ function shortSessionIdValue(sessionId) {
239
+ return String(sessionId || "").slice(0, 8);
240
+ }
241
+
242
+ function getRenameSessionPrefix(session) {
243
+ if (!session?.agentId || session?.isSystem) return null;
244
+ const currentTitle = String(session?.title || "").trim();
245
+ if (!currentTitle) return null;
246
+ const separatorIndex = currentTitle.indexOf(": ");
247
+ if (separatorIndex > 0) {
248
+ return currentTitle.slice(0, separatorIndex).trim() || null;
249
+ }
250
+ return currentTitle || null;
251
+ }
252
+
253
+ function getRenameSessionMaxLength(session) {
254
+ const prefix = getRenameSessionPrefix(session);
255
+ if (!prefix) return 60;
256
+ return Math.max(1, 60 - `${prefix}: `.length);
257
+ }
258
+
259
+ function getRenameSessionEditableTitle(session) {
260
+ const currentTitle = String(session?.title || "").trim();
261
+ if (!currentTitle) return "";
262
+
263
+ const prefix = getRenameSessionPrefix(session);
264
+ if (prefix && currentTitle.startsWith(`${prefix}: `)) {
265
+ const suffix = currentTitle.slice(`${prefix}: `.length).trim();
266
+ if (!suffix || suffix === shortSessionIdValue(session?.sessionId)) return "";
267
+ return suffix;
268
+ }
269
+
270
+ if (currentTitle === shortSessionIdValue(session?.sessionId)) return "";
271
+ return currentTitle;
272
+ }
273
+
274
+ function formatAgentDisplayTitle(agentName, title) {
275
+ const normalizedTitle = String(title || "").trim();
276
+ if (normalizedTitle) return normalizedTitle;
277
+ const normalizedName = String(agentName || "").trim();
278
+ return normalizedName
279
+ ? normalizedName.charAt(0).toUpperCase() + normalizedName.slice(1)
280
+ : "Agent";
281
+ }
282
+
283
+ function buildPromptAttachmentToken(filename) {
284
+ return `📎 ${String(filename || "").trim()}`;
285
+ }
286
+
287
+ function extractPromptReferenceContext(prompt, cursorIndex) {
288
+ const text = String(prompt || "");
289
+ const safeCursor = clampPromptCursor(text, cursorIndex, text.length);
290
+ const left = text.slice(0, safeCursor);
291
+ const match = left.match(/(^|\s)(@@?)([^\s]*)$/u);
292
+ if (!match) return null;
293
+
294
+ const trigger = match[2];
295
+ const query = String(match[3] || "");
296
+ return {
297
+ kind: trigger === "@@" ? "sessions" : "artifacts",
298
+ query,
299
+ signature: `${trigger}${query}`,
300
+ tokenStart: (match.index || 0) + String(match[1] || "").length,
301
+ tokenEnd: safeCursor,
302
+ };
303
+ }
304
+
305
+ function replacePromptTextRange(prompt, start, end, replacement) {
306
+ const safePrompt = String(prompt || "");
307
+ const rangeStart = clampPromptCursor(safePrompt, start, 0);
308
+ const rangeEnd = clampPromptCursor(safePrompt, end, rangeStart);
309
+ const nextText = String(replacement || "");
310
+ return {
311
+ prompt: `${safePrompt.slice(0, rangeStart)}${nextText}${safePrompt.slice(rangeEnd)}`,
312
+ cursor: rangeStart + nextText.length,
313
+ };
314
+ }
315
+
316
+ function normalizePromptReferenceLabel(value, fallback = "session") {
317
+ const text = String(value || "").replace(/\s+/gu, " ").trim();
318
+ return text || fallback;
319
+ }
320
+
321
+ function buildPromptSessionReferenceText(session) {
322
+ const sessionId = String(session?.sessionId || "").trim();
323
+ const fallbackLabel = shortSessionIdValue(sessionId) || "session";
324
+ const label = normalizePromptReferenceLabel(session?.title, fallbackLabel);
325
+ return `Referenced session: ${label} — session://${sessionId}`;
326
+ }
327
+
328
+ function replacePromptReferenceContext(prompt, context, replacement) {
329
+ if (!context) {
330
+ return insertPromptTextAtCursor(prompt, String(prompt || "").length, replacement);
331
+ }
332
+ const safePrompt = String(prompt || "");
333
+ const nextChar = safePrompt.slice(context.tokenEnd, context.tokenEnd + 1);
334
+ const needsTrailingSpace = !nextChar || /\s/u.test(nextChar);
335
+ return replacePromptTextRange(
336
+ safePrompt,
337
+ context.tokenStart,
338
+ context.tokenEnd,
339
+ `${String(replacement || "")}${needsTrailingSpace ? " " : ""}`,
340
+ );
341
+ }
342
+
343
+ function stripPromptAttachmentTokens(prompt, attachments = []) {
344
+ let cleaned = String(prompt || "");
345
+ for (const attachment of attachments || []) {
346
+ const token = String(attachment?.token || "").trim();
347
+ if (!token) continue;
348
+ cleaned = cleaned.split(token).join(" ");
349
+ }
350
+ return cleaned
351
+ .replace(/[ \t]+\n/g, "\n")
352
+ .replace(/\n[ \t]+/g, "\n")
353
+ .replace(/[ \t]{2,}/g, " ")
354
+ .trim();
355
+ }
356
+
357
+ function expandPromptAttachments(prompt, attachments = []) {
358
+ const validAttachments = Array.isArray(attachments)
359
+ ? attachments.filter((attachment) => attachment?.sessionId && attachment?.filename)
360
+ : [];
361
+ if (validAttachments.length === 0) return String(prompt || "");
362
+
363
+ const attachmentRefs = validAttachments.map((attachment) => (
364
+ `[Attached file: ${attachment.filename} — artifact://${attachment.sessionId}/${attachment.filename}]`
365
+ ));
366
+ const cleanedPrompt = stripPromptAttachmentTokens(prompt, validAttachments);
367
+ return attachmentRefs.join("\n") + (cleanedPrompt ? `\n\n${cleanedPrompt}` : "");
368
+ }
369
+
370
+ function clampRenameSessionValue(value, maxLength) {
371
+ return String(value || "").replace(/\r?\n/g, " ").slice(0, Math.max(0, Number(maxLength) || 0));
372
+ }
373
+
374
+ function displayWidth(value) {
375
+ return Array.from(String(value || "")).length;
376
+ }
377
+
378
+ function normalizeRenderableLines(lines) {
379
+ const normalized = [];
380
+ for (const line of lines || []) {
381
+ if (line?.kind === "markup") {
382
+ const parsed = parseTerminalMarkupRuns(line.value || "");
383
+ for (const parsedLine of parsed) {
384
+ normalized.push(parsedLine);
385
+ }
386
+ continue;
387
+ }
388
+ if (Array.isArray(line)) {
389
+ normalized.push(line);
390
+ continue;
391
+ }
392
+ normalized.push(line);
393
+ }
394
+ return normalized;
395
+ }
396
+
397
+ function countWrappedTextLines(text, width) {
398
+ const safeWidth = Math.max(1, Number(width) || 1);
399
+ const renderedWidth = displayWidth(text);
400
+ return Math.max(1, Math.ceil(renderedWidth / safeWidth));
401
+ }
402
+
403
+ function countWrappedRenderableLines(lines, width) {
404
+ const safeWidth = Math.max(1, Number(width) || 1);
405
+ return normalizeRenderableLines(lines).reduce((sum, line) => {
406
+ if (!line) return sum + 1;
407
+ if (Array.isArray(line)) {
408
+ const lineWidth = line.reduce((acc, run) => acc + displayWidth(run?.text || ""), 0);
409
+ return sum + Math.max(1, Math.ceil(lineWidth / safeWidth));
410
+ }
411
+ return sum + countWrappedTextLines(line.text || "", safeWidth);
412
+ }, 0);
413
+ }
414
+
415
+ function clampPromptCursor(prompt, cursor) {
416
+ const text = String(prompt || "");
417
+ return Math.max(0, Math.min(Number(cursor) || 0, text.length));
418
+ }
419
+
420
+ function splitPromptLines(prompt) {
421
+ return String(prompt || "").split("\n");
422
+ }
423
+
424
+ function getPromptCursorPosition(prompt, cursor) {
425
+ const prefix = String(prompt || "").slice(0, clampPromptCursor(prompt, cursor));
426
+ const lines = prefix.split("\n");
427
+ const currentLine = lines[lines.length - 1] || "";
428
+ return {
429
+ line: Math.max(0, lines.length - 1),
430
+ column: currentLine.length,
431
+ };
432
+ }
433
+
434
+ function getPromptCursorIndex(prompt, line, column) {
435
+ const lines = splitPromptLines(prompt);
436
+ const safeLine = Math.max(0, Math.min(Number(line) || 0, Math.max(0, lines.length - 1)));
437
+ const safeColumn = Math.max(0, Math.min(Number(column) || 0, lines[safeLine]?.length || 0));
438
+ let index = 0;
439
+ for (let currentLine = 0; currentLine < safeLine; currentLine += 1) {
440
+ index += (lines[currentLine]?.length || 0) + 1;
441
+ }
442
+ return clampPromptCursor(prompt, index + safeColumn);
443
+ }
444
+
445
+ function insertPromptTextAtCursor(prompt, cursor, text) {
446
+ const safePrompt = String(prompt || "");
447
+ const safeCursor = clampPromptCursor(safePrompt, cursor);
448
+ const insertion = String(text || "");
449
+ return {
450
+ prompt: `${safePrompt.slice(0, safeCursor)}${insertion}${safePrompt.slice(safeCursor)}`,
451
+ cursor: safeCursor + insertion.length,
452
+ };
453
+ }
454
+
455
+ function deletePromptCharBackward(prompt, cursor) {
456
+ const safePrompt = String(prompt || "");
457
+ const safeCursor = clampPromptCursor(safePrompt, cursor);
458
+ if (safeCursor <= 0) {
459
+ return { prompt: safePrompt, cursor: safeCursor };
460
+ }
461
+ return {
462
+ prompt: `${safePrompt.slice(0, safeCursor - 1)}${safePrompt.slice(safeCursor)}`,
463
+ cursor: safeCursor - 1,
464
+ };
465
+ }
466
+
467
+ function isWordBoundaryWhitespace(value) {
468
+ return /\s/u.test(value || "");
469
+ }
470
+
471
+ function movePromptCursorByWord(prompt, cursor, direction) {
472
+ const safePrompt = String(prompt || "");
473
+ let index = clampPromptCursor(safePrompt, cursor);
474
+ if (direction < 0) {
475
+ while (index > 0 && isWordBoundaryWhitespace(safePrompt[index - 1])) index -= 1;
476
+ while (index > 0 && !isWordBoundaryWhitespace(safePrompt[index - 1])) index -= 1;
477
+ return index;
478
+ }
479
+ while (index < safePrompt.length && isWordBoundaryWhitespace(safePrompt[index])) index += 1;
480
+ while (index < safePrompt.length && !isWordBoundaryWhitespace(safePrompt[index])) index += 1;
481
+ return index;
482
+ }
483
+
484
+ function deletePromptWordBackward(prompt, cursor) {
485
+ const safePrompt = String(prompt || "");
486
+ const safeCursor = clampPromptCursor(safePrompt, cursor);
487
+ const nextCursor = movePromptCursorByWord(safePrompt, safeCursor, -1);
488
+ if (nextCursor === safeCursor) {
489
+ return { prompt: safePrompt, cursor: safeCursor };
490
+ }
491
+ return {
492
+ prompt: `${safePrompt.slice(0, nextCursor)}${safePrompt.slice(safeCursor)}`,
493
+ cursor: nextCursor,
494
+ };
495
+ }
496
+
497
+ function movePromptCursorVertically(prompt, cursor, direction) {
498
+ const lines = splitPromptLines(prompt);
499
+ if (lines.length <= 1) return clampPromptCursor(prompt, cursor);
500
+ const position = getPromptCursorPosition(prompt, cursor);
501
+ const targetLine = Math.max(0, Math.min(position.line + direction, lines.length - 1));
502
+ if (targetLine === position.line) return clampPromptCursor(prompt, cursor);
503
+ return getPromptCursorIndex(prompt, targetLine, position.column);
504
+ }
505
+
506
+ const AUTO_HISTORY_EVENT_SOFT_CAP = 3_000;
507
+ const INSPECTOR_BOTTOM_ANCHORED_TABS = new Set(["logs", "sequence"]);
508
+ const FILE_PREVIEW_CHAR_LIMIT = 200_000;
509
+ const MARKDOWN_FILE_EXTENSIONS = new Set([
510
+ ".md",
511
+ ".markdown",
512
+ ".mdown",
513
+ ".mkd",
514
+ ".mdx",
515
+ ]);
516
+ const JSON_FILE_EXTENSIONS = new Set([
517
+ ".json",
518
+ ".jsonl",
519
+ ]);
520
+ const BINARY_PREVIEW_EXTENSIONS = new Set([
521
+ ".png",
522
+ ".jpg",
523
+ ".jpeg",
524
+ ".gif",
525
+ ".webp",
526
+ ".svg",
527
+ ".ico",
528
+ ".pdf",
529
+ ".zip",
530
+ ".gz",
531
+ ".tgz",
532
+ ".tar",
533
+ ".wasm",
534
+ ".sqlite",
535
+ ".db",
536
+ ]);
537
+
538
+ function fileExtension(filename) {
539
+ const value = String(filename || "");
540
+ const lastDot = value.lastIndexOf(".");
541
+ return lastDot >= 0 ? value.slice(lastDot).toLowerCase() : "";
542
+ }
543
+
544
+ function isBinaryPreview(filename, contentType = "") {
545
+ const ext = fileExtension(filename);
546
+ const normalizedType = String(contentType || "").toLowerCase();
547
+ if (BINARY_PREVIEW_EXTENSIONS.has(ext)) return true;
548
+ return normalizedType.startsWith("image/")
549
+ || normalizedType === "application/pdf"
550
+ || normalizedType.startsWith("application/zip")
551
+ || normalizedType === "application/wasm";
552
+ }
553
+
554
+ function truncateFilePreview(content, limit = FILE_PREVIEW_CHAR_LIMIT) {
555
+ const text = String(content ?? "");
556
+ if (text.length <= limit) return text;
557
+ return `${text.slice(0, limit)}\n\n[Preview truncated at ${limit.toLocaleString()} characters. Open the artifact directly if you need the full file.]`;
558
+ }
559
+
560
+ function normalizePreviewPayload(filename, rawContent, contentType = "") {
561
+ const normalizedType = String(contentType || "").toLowerCase();
562
+ const ext = fileExtension(filename);
563
+
564
+ if (isBinaryPreview(filename, contentType)) {
565
+ return {
566
+ content: `Preview is not available in the terminal UI for ${filename}.\n\nThis artifact looks binary or non-text, so use the downloadable artifact instead.`,
567
+ contentType: contentType || "application/octet-stream",
568
+ renderMode: "note",
569
+ };
570
+ }
571
+
572
+ const truncatedText = truncateFilePreview(rawContent);
573
+ if (MARKDOWN_FILE_EXTENSIONS.has(ext) || normalizedType.includes("markdown")) {
574
+ return {
575
+ content: truncatedText,
576
+ contentType: contentType || "text/markdown",
577
+ renderMode: "markdown",
578
+ };
579
+ }
580
+
581
+ if (JSON_FILE_EXTENSIONS.has(ext) || normalizedType.includes("json")) {
582
+ try {
583
+ return {
584
+ content: truncateFilePreview(JSON.stringify(JSON.parse(String(rawContent ?? "")), null, 2)),
585
+ contentType: contentType || "application/json",
586
+ renderMode: "text",
587
+ };
588
+ } catch {
589
+ return {
590
+ content: truncatedText,
591
+ contentType: contentType || "application/json",
592
+ renderMode: "text",
593
+ };
594
+ }
595
+ }
596
+
597
+ return {
598
+ content: truncatedText,
599
+ contentType: contentType || "text/plain",
600
+ renderMode: "text",
601
+ };
602
+ }
603
+
604
+ export class PilotSwarmUiController {
605
+ constructor({ store, transport }) {
606
+ this.store = store;
607
+ this.transport = transport;
608
+ this.catalogTimer = null;
609
+ this.activeSessionUnsub = null;
610
+ this.activeSessionSubscriptionId = null;
611
+ this.activeSessionDetailTimer = null;
612
+ this.activeSessionDetailSessionId = null;
613
+ this.sessionRefreshTimer = null;
614
+ this.sessionHistoryLoads = new Map();
615
+ this.sessionHistoryExpansionLoads = new Map();
616
+ this.sessionOrchestrationStatsLoads = new Map();
617
+ this.logUnsubscribe = null;
618
+ this.promptReferenceSignature = null;
619
+ this.promptReferenceSyncVersion = 0;
620
+ }
621
+
622
+ getState() {
623
+ return this.store.getState();
624
+ }
625
+
626
+ subscribe(listener) {
627
+ return this.store.subscribe(listener);
628
+ }
629
+
630
+ dispatch(action) {
631
+ return this.store.dispatch(action);
632
+ }
633
+
634
+ setStatus(text) {
635
+ this.dispatch({ type: "ui/status", text });
636
+ }
637
+
638
+ getPromptAttachments() {
639
+ const attachments = this.getState().ui.promptAttachments;
640
+ return Array.isArray(attachments) ? attachments.filter(Boolean) : [];
641
+ }
642
+
643
+ setPromptAttachments(attachments) {
644
+ this.dispatch({
645
+ type: "ui/promptAttachments",
646
+ attachments: Array.isArray(attachments) ? attachments : [],
647
+ });
648
+ }
649
+
650
+ getPromptReferenceContext() {
651
+ const state = this.getState();
652
+ return extractPromptReferenceContext(state.ui.prompt, state.ui.promptCursor);
653
+ }
654
+
655
+ acceptPromptReferenceAutocomplete() {
656
+ const context = this.getPromptReferenceContext();
657
+ if (!context) return false;
658
+ if (context.kind === "sessions") {
659
+ return this.acceptSessionPromptReference(context);
660
+ }
661
+ return this.acceptArtifactPromptReference(context);
662
+ }
663
+
664
+ acceptArtifactPromptReference(context = this.getPromptReferenceContext()) {
665
+ if (!context || context.kind !== "artifacts") return false;
666
+
667
+ const state = this.getState();
668
+ const selectedItem = selectSelectedFileBrowserItem(state);
669
+ const sessionId = selectedItem?.sessionId || state.sessions.activeSessionId || null;
670
+ const filename = selectedItem?.filename || null;
671
+ if (!sessionId || !filename) return false;
672
+
673
+ const token = buildPromptAttachmentToken(filename);
674
+ const insertion = replacePromptReferenceContext(state.ui.prompt, context, token);
675
+ const previousAttachments = this.getPromptAttachments();
676
+ const existingAttachmentIndex = previousAttachments.findIndex((attachment) => (
677
+ attachment?.sessionId === sessionId
678
+ && attachment?.filename === filename
679
+ ));
680
+ const nextAttachment = {
681
+ ...(existingAttachmentIndex >= 0 ? previousAttachments[existingAttachmentIndex] : {}),
682
+ id: `${sessionId}/${filename}`,
683
+ sessionId,
684
+ filename,
685
+ resolvedPath: filename,
686
+ token,
687
+ };
688
+ const nextAttachments = existingAttachmentIndex >= 0
689
+ ? previousAttachments.map((attachment, index) => (index === existingAttachmentIndex ? nextAttachment : attachment))
690
+ : [...previousAttachments, nextAttachment];
691
+
692
+ this.setPrompt(insertion.prompt, insertion.cursor);
693
+ this.setPromptAttachments(nextAttachments);
694
+ this.setFocus(FOCUS_REGIONS.PROMPT);
695
+ this.dispatch({ type: "ui/status", text: `Attached ${filename}` });
696
+ return true;
697
+ }
698
+
699
+ acceptSessionPromptReference(context = this.getPromptReferenceContext()) {
700
+ if (!context || context.kind !== "sessions") return false;
701
+
702
+ const state = this.getState();
703
+ const sessionRows = selectSessionRows(state);
704
+ const targetRow = sessionRows[0] || null;
705
+ const targetSession = targetRow?.sessionId
706
+ ? state.sessions.byId[targetRow.sessionId] || null
707
+ : null;
708
+ if (!targetSession?.sessionId) return false;
709
+
710
+ const referenceText = buildPromptSessionReferenceText(targetSession);
711
+ const insertion = replacePromptReferenceContext(state.ui.prompt, context, referenceText);
712
+ this.setPrompt(insertion.prompt, insertion.cursor);
713
+ this.setFocus(FOCUS_REGIONS.PROMPT);
714
+ this.dispatch({
715
+ type: "ui/status",
716
+ text: `Referenced session ${shortSessionIdValue(targetSession.sessionId)}`,
717
+ });
718
+ return true;
719
+ }
720
+
721
+ setSessionFilterQuery(query = "") {
722
+ this.dispatch({
723
+ type: "sessions/filterQuery",
724
+ query: String(query || ""),
725
+ });
726
+ }
727
+
728
+ setFilesFilter(patch = {}) {
729
+ this.dispatch({
730
+ type: "files/filter",
731
+ filter: patch,
732
+ });
733
+ }
734
+
735
+ clearPromptReferenceBrowser() {
736
+ this.promptReferenceSignature = null;
737
+ this.promptReferenceSyncVersion += 1;
738
+ if (this.getState().sessions.filterQuery) {
739
+ this.setSessionFilterQuery("");
740
+ }
741
+ if (this.getState().files.filter?.query) {
742
+ this.setFilesFilter({ query: "" });
743
+ }
744
+ }
745
+
746
+ syncPromptReferenceBrowser() {
747
+ const state = this.getState();
748
+ const context = extractPromptReferenceContext(state.ui.prompt, state.ui.promptCursor);
749
+
750
+ if (!context) {
751
+ this.clearPromptReferenceBrowser();
752
+ return;
753
+ }
754
+
755
+ if (this.promptReferenceSignature === context.signature) {
756
+ return;
757
+ }
758
+ this.promptReferenceSignature = context.signature;
759
+
760
+ if (context.kind === "sessions") {
761
+ this.promptReferenceSyncVersion += 1;
762
+ this.setFilesFilter({ query: "" });
763
+ this.setSessionFilterQuery(context.query);
764
+ return;
765
+ }
766
+
767
+ const sessionId = state.sessions.activeSessionId;
768
+ this.setSessionFilterQuery("");
769
+ this.setFilesFilter({
770
+ scope: "selectedSession",
771
+ query: context.query,
772
+ });
773
+ if (!sessionId) return;
774
+
775
+ this.selectInspectorTab("files").catch(() => {});
776
+ const syncVersion = ++this.promptReferenceSyncVersion;
777
+ this.ensureFilesForSession(sessionId).then(async () => {
778
+ if (this.promptReferenceSyncVersion !== syncVersion) return;
779
+ const items = selectFileBrowserItems(this.getState()).filter((item) => item.sessionId === sessionId);
780
+ const nextItem = items[0] || null;
781
+ if (!nextItem?.filename) return;
782
+ this.dispatch({
783
+ type: "files/select",
784
+ sessionId,
785
+ filename: nextItem.filename,
786
+ });
787
+ await this.ensureFilePreview(sessionId, nextItem.filename).catch(() => {});
788
+ }).catch(() => {});
789
+ }
790
+
791
+ async start() {
792
+ await this.transport.start();
793
+ const logConfig = typeof this.transport.getLogConfig === "function"
794
+ ? this.transport.getLogConfig()
795
+ : null;
796
+ if (logConfig) {
797
+ this.dispatch({
798
+ type: "logs/config",
799
+ available: logConfig.available,
800
+ availabilityReason: logConfig.availabilityReason,
801
+ });
802
+ }
803
+ this.dispatch({
804
+ type: "connection/ready",
805
+ workersOnline: typeof this.transport.getWorkerCount === "function" ? this.transport.getWorkerCount() : null,
806
+ statusText: "Connected",
807
+ });
808
+ await this.refreshSessions();
809
+ this.catalogTimer = setInterval(() => {
810
+ this.refreshSessions().catch((error) => {
811
+ this.dispatch({
812
+ type: "connection/error",
813
+ error: error?.message || String(error),
814
+ statusText: SESSION_REFRESH_FAILED_STATUS,
815
+ });
816
+ });
817
+ }, 4000);
818
+ }
819
+
820
+ async stop() {
821
+ if (this.catalogTimer) clearInterval(this.catalogTimer);
822
+ this.catalogTimer = null;
823
+ if (this.activeSessionDetailTimer) clearTimeout(this.activeSessionDetailTimer);
824
+ this.activeSessionDetailTimer = null;
825
+ this.activeSessionDetailSessionId = null;
826
+ if (this.sessionRefreshTimer) clearTimeout(this.sessionRefreshTimer);
827
+ this.sessionRefreshTimer = null;
828
+ this.sessionHistoryExpansionLoads.clear();
829
+ this.sessionOrchestrationStatsLoads.clear();
830
+ this.detachActiveSession();
831
+ this.detachLogStream();
832
+ await this.transport.stop();
833
+ }
834
+
835
+ detachActiveSession() {
836
+ if (this.activeSessionUnsub) {
837
+ this.activeSessionUnsub();
838
+ this.activeSessionUnsub = null;
839
+ }
840
+ this.activeSessionSubscriptionId = null;
841
+ }
842
+
843
+ detachLogStream() {
844
+ if (this.logUnsubscribe) {
845
+ this.logUnsubscribe();
846
+ this.logUnsubscribe = null;
847
+ }
848
+ }
849
+
850
+ async refreshSessions() {
851
+ const preRefreshState = this.getState();
852
+ const recoveringConnection = !preRefreshState.connection.connected || Boolean(preRefreshState.connection.error);
853
+ const shouldClearRefreshFailureBanner = preRefreshState.ui.statusText === SESSION_REFRESH_FAILED_STATUS;
854
+ const previousActive = this.getState().sessions.activeSessionId;
855
+ let sessions = await this.transport.listSessions();
856
+ const active = previousActive;
857
+ if (
858
+ active
859
+ && !sessions.some((session) => session?.sessionId === active)
860
+ && typeof this.transport.getSession === "function"
861
+ ) {
862
+ const activeSession = await this.transport.getSession(active).catch(() => null);
863
+ if (activeSession?.sessionId) {
864
+ sessions = [...sessions, activeSession];
865
+ }
866
+ }
867
+ if (recoveringConnection) {
868
+ this.dispatch({
869
+ type: "connection/ready",
870
+ workersOnline: typeof this.transport.getWorkerCount === "function" ? this.transport.getWorkerCount() : null,
871
+ ...(shouldClearRefreshFailureBanner ? { statusText: "Connected" } : {}),
872
+ });
873
+ }
874
+ this.dispatch({ type: "sessions/loaded", sessions });
875
+ const selected = this.getState().sessions.activeSessionId;
876
+ const syncedIds = new Set();
877
+ if (selected) {
878
+ if (selected !== previousActive) {
879
+ await this.loadSession(selected);
880
+ return;
881
+ }
882
+ const existingHistory = this.getState().history.bySessionId.get(selected);
883
+ if (!existingHistory?.events?.length) {
884
+ await this.ensureSessionHistory(selected, { force: true }).catch(() => {});
885
+ }
886
+ if (this.activeSessionSubscriptionId !== selected) {
887
+ this.attachActiveSession(selected);
888
+ }
889
+ await this.syncSessionDetail(selected).catch(() => {});
890
+ await this.syncSessionEvents(selected).catch(() => {});
891
+ syncedIds.add(selected);
892
+ const state = this.getState();
893
+ const activeSession = state.sessions.byId[selected] || null;
894
+ if (activeSession?.parentSessionId && typeof this.transport.getSession === "function") {
895
+ const siblingIds = Object.values(state.sessions.byId)
896
+ .filter((session) => session?.parentSessionId === activeSession.parentSessionId)
897
+ .map((session) => session.sessionId)
898
+ .filter((sessionId) => sessionId && sessionId !== selected)
899
+ .slice(0, 6);
900
+ await Promise.all(siblingIds.map((sessionId) => this.syncSessionDetail(sessionId).catch(() => {})));
901
+ for (const sessionId of siblingIds) syncedIds.add(sessionId);
902
+ }
903
+ }
904
+ await this.syncVisibleSessionDetails(syncedIds).catch(() => {});
905
+ this.ensureInspectorData().catch(() => {});
906
+ this.evictStaleSessionState();
907
+ }
908
+
909
+ /**
910
+ * Evict cached orchestration, executionHistory, and file-preview state for sessions
911
+ * that are not the active session and haven't been fetched recently.
912
+ * Keeps memory bounded when hundreds of sessions accumulate.
913
+ */
914
+ evictStaleSessionState() {
915
+ const state = this.getState();
916
+ const activeSessionId = state.sessions.activeSessionId;
917
+ const now = Date.now();
918
+ const STALE_MS = 60_000; // 1 minute
919
+ const MAX_CACHED = 20;
920
+
921
+ // Evict stale orchestration stats
922
+ const orchEntries = Object.entries(state.orchestration.bySessionId || {});
923
+ if (orchEntries.length > MAX_CACHED) {
924
+ const toEvict = orchEntries
925
+ .filter(([id, entry]) => id !== activeSessionId && !entry?.loading)
926
+ .sort((a, b) => (a[1]?.fetchedAt || 0) - (b[1]?.fetchedAt || 0))
927
+ .slice(0, orchEntries.length - MAX_CACHED)
928
+ .filter(([, entry]) => (now - (entry?.fetchedAt || 0)) > STALE_MS);
929
+ if (toEvict.length > 0) {
930
+ this.dispatch({ type: "orchestration/evict", sessionIds: toEvict.map(([id]) => id) });
931
+ }
932
+ }
933
+
934
+ // Evict stale executionHistory
935
+ const execEntries = Object.entries(state.executionHistory?.bySessionId || {});
936
+ if (execEntries.length > MAX_CACHED) {
937
+ const toEvict = execEntries
938
+ .filter(([id, entry]) => id !== activeSessionId && !entry?.loading)
939
+ .sort((a, b) => (a[1]?.fetchedAt || 0) - (b[1]?.fetchedAt || 0))
940
+ .slice(0, execEntries.length - MAX_CACHED)
941
+ .filter(([, entry]) => (now - (entry?.fetchedAt || 0)) > STALE_MS);
942
+ if (toEvict.length > 0) {
943
+ this.dispatch({ type: "executionHistory/evict", sessionIds: toEvict.map(([id]) => id) });
944
+ }
945
+ }
946
+
947
+ // Evict stale file previews (keep entries list, drop preview content)
948
+ const fileEntries = Object.entries(state.files.bySessionId || {});
949
+ if (fileEntries.length > MAX_CACHED) {
950
+ const toEvict = fileEntries
951
+ .filter(([id]) => id !== activeSessionId)
952
+ .slice(0, fileEntries.length - MAX_CACHED);
953
+ if (toEvict.length > 0) {
954
+ this.dispatch({ type: "files/evictPreviews", sessionIds: toEvict.map(([id]) => id) });
955
+ }
956
+ }
957
+
958
+ // Evict stale history for non-visible sessions when count is very high
959
+ const historyEntries = [...state.history.bySessionId.entries()];
960
+ if (historyEntries.length > MAX_CACHED * 2) {
961
+ const toEvict = historyEntries
962
+ .filter(([id]) => id !== activeSessionId)
963
+ .slice(0, historyEntries.length - MAX_CACHED * 2)
964
+ .map(([id]) => id);
965
+ if (toEvict.length > 0) {
966
+ this.dispatch({ type: "history/evict", sessionIds: toEvict });
967
+ }
968
+ }
969
+ }
970
+
971
+ async syncVisibleSessionDetails(excludedIds = new Set()) {
972
+ if (typeof this.transport.getSession !== "function") return;
973
+
974
+ const state = this.getState();
975
+ const layout = computeLegacyLayout(
976
+ {
977
+ width: state.ui.layout.viewportWidth,
978
+ height: state.ui.layout.viewportHeight,
979
+ },
980
+ state.ui.layout.paneAdjust,
981
+ state.ui.promptRows ?? getPromptInputRows(state.ui.prompt),
982
+ state.ui.layout.sessionPaneAdjust,
983
+ state.ui.fullscreenPane,
984
+ );
985
+ const maxRows = this.getSessionListMaxRows(layout);
986
+ const visibleRows = selectVisibleSessionRows(state, maxRows);
987
+ const sessionIds = [...new Set(
988
+ visibleRows
989
+ .map((row) => row.sessionId)
990
+ .filter((sessionId) => sessionId && !excludedIds.has(sessionId)),
991
+ )];
992
+ if (sessionIds.length === 0) return;
993
+
994
+ await Promise.all(sessionIds.map((sessionId) => this.syncSessionDetail(sessionId).catch(() => {})));
995
+ }
996
+
997
+ async ensureSessionHistory(sessionId, { force = false } = {}) {
998
+ if (!sessionId) return null;
999
+ const existingHistory = this.getState().history.bySessionId.get(sessionId);
1000
+ const requestedLimit = Math.max(
1001
+ DEFAULT_HISTORY_EVENT_LIMIT,
1002
+ Number(existingHistory?.loadedEventLimit ?? DEFAULT_HISTORY_EVENT_LIMIT) || DEFAULT_HISTORY_EVENT_LIMIT,
1003
+ );
1004
+ if (!force && existingHistory?.events) {
1005
+ return existingHistory;
1006
+ }
1007
+ if (!force && this.sessionHistoryLoads.has(sessionId)) {
1008
+ return this.sessionHistoryLoads.get(sessionId);
1009
+ }
1010
+
1011
+ const loadPromise = (async () => {
1012
+ const events = await this.transport.getSessionEvents(sessionId, undefined, requestedLimit);
1013
+ const history = {
1014
+ ...buildHistoryModel(events, { requestedLimit }),
1015
+ lastSeq: events[events.length - 1]?.seq || 0,
1016
+ };
1017
+ this.dispatch({
1018
+ type: "history/set",
1019
+ sessionId,
1020
+ history,
1021
+ });
1022
+ const derivedModel = extractSessionModelFromEvents(events);
1023
+ const currentSession = this.getState().sessions.byId[sessionId] || { sessionId };
1024
+ const derivedContextUsage = extractSessionContextUsageFromEvents(currentSession.contextUsage, events);
1025
+ if (derivedModel || derivedContextUsage) {
1026
+ this.dispatch({
1027
+ type: "sessions/merged",
1028
+ session: {
1029
+ sessionId,
1030
+ ...(derivedModel ? { model: derivedModel } : {}),
1031
+ ...(derivedContextUsage ? { contextUsage: derivedContextUsage } : {}),
1032
+ },
1033
+ });
1034
+ }
1035
+ return history;
1036
+ })()
1037
+ .finally(() => {
1038
+ this.sessionHistoryLoads.delete(sessionId);
1039
+ });
1040
+
1041
+ this.sessionHistoryLoads.set(sessionId, loadPromise);
1042
+ return loadPromise;
1043
+ }
1044
+
1045
+ async ensureInspectorData(targetTab = this.getState().ui.inspectorTab) {
1046
+ if (targetTab === "sequence") {
1047
+ const activeSessionId = this.getState().sessions.activeSessionId;
1048
+ if (!activeSessionId) return;
1049
+ await this.ensureSessionHistory(activeSessionId).catch(() => {});
1050
+ await this.ensureOrchestrationStats(activeSessionId).catch(() => {});
1051
+ return;
1052
+ }
1053
+ if (targetTab === "nodes") {
1054
+ // Only fetch history for visible session rows — not the entire catalog.
1055
+ // With hundreds of sessions, fetching all of them every 4s causes unbounded memory growth.
1056
+ const state = this.getState();
1057
+ const layout = computeLegacyLayout(
1058
+ {
1059
+ width: state.ui.layout.viewportWidth,
1060
+ height: state.ui.layout.viewportHeight,
1061
+ },
1062
+ state.ui.layout.paneAdjust,
1063
+ state.ui.promptRows ?? getPromptInputRows(state.ui.prompt),
1064
+ state.ui.layout.sessionPaneAdjust,
1065
+ state.ui.fullscreenPane,
1066
+ );
1067
+ const maxRows = this.getSessionListMaxRows(layout);
1068
+ const visibleRows = selectVisibleSessionRows(state, maxRows);
1069
+ const sessionIds = [...new Set(
1070
+ visibleRows
1071
+ .map((row) => row.sessionId)
1072
+ .filter(Boolean),
1073
+ )];
1074
+ if (sessionIds.length === 0) return;
1075
+ await Promise.allSettled(sessionIds.map((sessionId) => this.ensureSessionHistory(sessionId)));
1076
+ return;
1077
+ }
1078
+ if (targetTab === "files") {
1079
+ await this.ensureFilesForScope(selectFilesScope(this.getState()));
1080
+ }
1081
+ if (targetTab === "history") {
1082
+ const activeSessionId = this.getState().sessions.activeSessionId;
1083
+ const current = activeSessionId
1084
+ ? this.getState().executionHistory?.bySessionId?.[activeSessionId] || null
1085
+ : null;
1086
+ if (activeSessionId && !current) {
1087
+ await this.ensureExecutionHistory(activeSessionId);
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ async ensureOrchestrationStats(sessionId, { force = false } = {}) {
1093
+ if (!sessionId || typeof this.transport.getOrchestrationStats !== "function") return null;
1094
+
1095
+ const current = this.getState().orchestration.bySessionId?.[sessionId] || null;
1096
+ const now = Date.now();
1097
+ if (!force && current?.loading) return current;
1098
+ if (
1099
+ !force
1100
+ && current
1101
+ && Number.isFinite(current.fetchedAt)
1102
+ && (now - current.fetchedAt) < ORCHESTRATION_STATS_REFRESH_MS
1103
+ ) {
1104
+ return current;
1105
+ }
1106
+ if (!force && this.sessionOrchestrationStatsLoads.has(sessionId)) {
1107
+ return this.sessionOrchestrationStatsLoads.get(sessionId);
1108
+ }
1109
+
1110
+ this.dispatch({ type: "orchestration/statsLoading", sessionId });
1111
+ const loadPromise = (async () => {
1112
+ try {
1113
+ const stats = await this.transport.getOrchestrationStats(sessionId);
1114
+ this.dispatch({
1115
+ type: "orchestration/statsLoaded",
1116
+ sessionId,
1117
+ stats,
1118
+ fetchedAt: Date.now(),
1119
+ });
1120
+ return this.getState().orchestration.bySessionId?.[sessionId] || null;
1121
+ } catch (error) {
1122
+ this.dispatch({
1123
+ type: "orchestration/statsError",
1124
+ sessionId,
1125
+ error: error?.message || String(error),
1126
+ fetchedAt: Date.now(),
1127
+ });
1128
+ return null;
1129
+ }
1130
+ })().finally(() => {
1131
+ this.sessionOrchestrationStatsLoads.delete(sessionId);
1132
+ });
1133
+ this.sessionOrchestrationStatsLoads.set(sessionId, loadPromise);
1134
+ return loadPromise;
1135
+ }
1136
+
1137
+ async ensureExecutionHistory(sessionId, { force = false } = {}) {
1138
+ if (!sessionId || typeof this.transport.getExecutionHistory !== "function") return null;
1139
+ const current = this.getState().executionHistory?.bySessionId?.[sessionId] || null;
1140
+ const now = Date.now();
1141
+ if (!force && current?.loading) return current;
1142
+ if (!force && current && Number.isFinite(current.fetchedAt) && (now - current.fetchedAt) < 15_000) {
1143
+ return current;
1144
+ }
1145
+ this.dispatch({ type: "executionHistory/loading", sessionId });
1146
+ try {
1147
+ const events = await this.transport.getExecutionHistory(sessionId);
1148
+ this.dispatch({
1149
+ type: "executionHistory/loaded",
1150
+ sessionId,
1151
+ events: events || [],
1152
+ fetchedAt: Date.now(),
1153
+ });
1154
+ } catch (error) {
1155
+ console.error("[executionHistory] fetch error:", error?.message || error);
1156
+ this.dispatch({
1157
+ type: "executionHistory/error",
1158
+ sessionId,
1159
+ error: error?.message || String(error),
1160
+ fetchedAt: Date.now(),
1161
+ });
1162
+ }
1163
+ return this.getState().executionHistory?.bySessionId?.[sessionId] || null;
1164
+ }
1165
+
1166
+ async ensureFilesForScope(scope = selectFilesScope(this.getState()), { force = false } = {}) {
1167
+ if (scope === "allSessions") {
1168
+ const sessionIds = [...new Set([
1169
+ ...(Array.isArray(this.getState().sessions.flat) ? this.getState().sessions.flat : []),
1170
+ ...Object.keys(this.getState().files.bySessionId || {}),
1171
+ ])].filter(Boolean);
1172
+ if (sessionIds.length === 0) return [];
1173
+ return Promise.allSettled(sessionIds.map((sessionId) => this.ensureFilesForSession(sessionId, { force })));
1174
+ }
1175
+ const sessionId = this.getState().sessions.activeSessionId;
1176
+ if (!sessionId) return null;
1177
+ return this.ensureFilesForSession(sessionId, { force });
1178
+ }
1179
+
1180
+ async ensureSelectedFilePreview() {
1181
+ const selectedItem = selectSelectedFileBrowserItem(this.getState());
1182
+ if (!selectedItem?.sessionId || !selectedItem?.filename) return null;
1183
+ return this.ensureFilePreview(selectedItem.sessionId, selectedItem.filename).catch(() => null);
1184
+ }
1185
+
1186
+ async ensureFilesForSession(sessionId, { force = false } = {}) {
1187
+ if (!sessionId || typeof this.transport.listArtifacts !== "function") return null;
1188
+
1189
+ const current = this.getState().files.bySessionId[sessionId];
1190
+ if (!force && current?.loading) return current;
1191
+ if (!force && current?.loaded) {
1192
+ if (current.selectedFilename) {
1193
+ await this.ensureFilePreview(sessionId, current.selectedFilename).catch(() => {});
1194
+ }
1195
+ return current;
1196
+ }
1197
+
1198
+ this.dispatch({ type: "files/sessionLoading", sessionId });
1199
+ try {
1200
+ const entries = await this.transport.listArtifacts(sessionId);
1201
+ this.dispatch({
1202
+ type: "files/sessionLoaded",
1203
+ sessionId,
1204
+ entries,
1205
+ });
1206
+ const nextState = this.getState().files.bySessionId[sessionId];
1207
+ if (nextState?.selectedFilename) {
1208
+ await this.ensureFilePreview(sessionId, nextState.selectedFilename).catch(() => {});
1209
+ }
1210
+ return nextState;
1211
+ } catch (error) {
1212
+ this.dispatch({
1213
+ type: "files/sessionError",
1214
+ sessionId,
1215
+ error: error?.message || String(error),
1216
+ });
1217
+ return null;
1218
+ }
1219
+ }
1220
+
1221
+ async ensureFilePreview(sessionId, filename, { force = false } = {}) {
1222
+ if (!sessionId || !filename || typeof this.transport.downloadArtifact !== "function") return null;
1223
+
1224
+ const current = this.getState().files.bySessionId[sessionId];
1225
+ const preview = current?.previews?.[filename];
1226
+ if (!force && preview?.loading) return preview;
1227
+ if (!force && preview && (preview.content !== undefined || preview.error)) {
1228
+ return preview;
1229
+ }
1230
+
1231
+ this.dispatch({ type: "files/previewLoading", sessionId, filename });
1232
+ try {
1233
+ const previewPayload = isBinaryPreview(filename)
1234
+ ? normalizePreviewPayload(filename, "", "")
1235
+ : normalizePreviewPayload(
1236
+ filename,
1237
+ await this.transport.downloadArtifact(sessionId, filename),
1238
+ "",
1239
+ );
1240
+ this.dispatch({
1241
+ type: "files/previewLoaded",
1242
+ sessionId,
1243
+ filename,
1244
+ ...previewPayload,
1245
+ });
1246
+ return previewPayload;
1247
+ } catch (error) {
1248
+ this.dispatch({
1249
+ type: "files/previewError",
1250
+ sessionId,
1251
+ filename,
1252
+ error: error?.message || String(error),
1253
+ });
1254
+ return null;
1255
+ }
1256
+ }
1257
+
1258
+ buildArtifactPickerItems(artifactLinks = []) {
1259
+ const items = (artifactLinks || []).map((link) => ({
1260
+ id: `${link.sessionId}/${link.filename}`,
1261
+ kind: "artifact",
1262
+ sessionId: link.sessionId,
1263
+ filename: link.filename,
1264
+ }));
1265
+
1266
+ if (items.length > 1) {
1267
+ items.push({
1268
+ id: "__downloadAll__",
1269
+ kind: "downloadAll",
1270
+ });
1271
+ }
1272
+
1273
+ return items;
1274
+ }
1275
+
1276
+ buildArtifactPickerModal({ artifactLinks, previousFocus, selectedId } = {}) {
1277
+ const items = this.buildArtifactPickerItems(artifactLinks);
1278
+ if (items.length === 0) return null;
1279
+ const selectedIndex = items.findIndex((item) => item.id === selectedId);
1280
+
1281
+ return {
1282
+ type: "artifactPicker",
1283
+ title: "Artifact Downloads",
1284
+ previousFocus,
1285
+ artifactLinks,
1286
+ items,
1287
+ selectedIndex: selectedIndex >= 0 ? selectedIndex : 0,
1288
+ exportDirectory: typeof this.transport.getArtifactExportDirectory === "function"
1289
+ ? this.transport.getArtifactExportDirectory()
1290
+ : null,
1291
+ };
1292
+ }
1293
+
1294
+ getArtifactPickerSelectionId() {
1295
+ const modal = this.getState().ui.modal;
1296
+ if (!modal || modal.type !== "artifactPicker") return null;
1297
+ return modal.items?.[modal.selectedIndex || 0]?.id || null;
1298
+ }
1299
+
1300
+ replaceArtifactPickerModal(selectedId = null) {
1301
+ const modal = this.getState().ui.modal;
1302
+ if (!modal || modal.type !== "artifactPicker") return;
1303
+
1304
+ const nextModal = this.buildArtifactPickerModal({
1305
+ artifactLinks: modal.artifactLinks || [],
1306
+ previousFocus: modal.previousFocus,
1307
+ selectedId: selectedId || this.getArtifactPickerSelectionId(),
1308
+ });
1309
+
1310
+ if (!nextModal) {
1311
+ this.dispatch({ type: "ui/modal", modal: null });
1312
+ this.dispatch({ type: "ui/status", text: "No artifact links in the current chat view" });
1313
+ return;
1314
+ }
1315
+
1316
+ this.dispatch({ type: "ui/modal", modal: nextModal });
1317
+ }
1318
+
1319
+ async saveArtifactDownload(sessionId, filename) {
1320
+ if (typeof this.transport.saveArtifactDownload !== "function") {
1321
+ this.dispatch({ type: "ui/status", text: "Artifact download is not supported by this transport" });
1322
+ return null;
1323
+ }
1324
+
1325
+ try {
1326
+ const download = await this.transport.saveArtifactDownload(sessionId, filename);
1327
+ this.dispatch({
1328
+ type: "files/downloaded",
1329
+ sessionId,
1330
+ filename,
1331
+ localPath: download?.localPath || "",
1332
+ downloadedAt: Date.now(),
1333
+ });
1334
+ const activeSessionId = this.getState().sessions.activeSessionId;
1335
+ const shouldRefreshFiles = sessionId === activeSessionId || this.getState().ui.inspectorTab === "files";
1336
+ if (shouldRefreshFiles) {
1337
+ await this.ensureFilesForSession(sessionId, { force: true }).catch(() => null);
1338
+ if (sessionId === activeSessionId) {
1339
+ this.dispatch({
1340
+ type: "files/select",
1341
+ sessionId,
1342
+ filename,
1343
+ });
1344
+ if (this.getState().ui.inspectorTab === "files") {
1345
+ await this.ensureFilePreview(sessionId, filename, { force: true }).catch(() => null);
1346
+ }
1347
+ }
1348
+ }
1349
+ return download;
1350
+ } catch (error) {
1351
+ this.dispatch({
1352
+ type: "ui/status",
1353
+ text: `Download failed: ${error?.message || String(error)}`,
1354
+ });
1355
+ return null;
1356
+ }
1357
+ }
1358
+
1359
+ async openSelectedFileInDefaultApp() {
1360
+ const state = this.getState();
1361
+ const selectedItem = selectSelectedFileBrowserItem(state);
1362
+ if (!selectedItem?.sessionId || !selectedItem?.filename) {
1363
+ this.dispatch({
1364
+ type: "ui/status",
1365
+ text: state.sessions.activeSessionId || selectFilesScope(state) === "allSessions"
1366
+ ? "No file selected"
1367
+ : "No session selected",
1368
+ });
1369
+ return;
1370
+ }
1371
+ const { sessionId, filename: selectedFilename } = selectedItem;
1372
+ const scope = selectFilesScope(state);
1373
+ if (typeof this.transport.openPathInDefaultApp !== "function") {
1374
+ this.dispatch({ type: "ui/status", text: "Opening files in the default app is not supported by this transport" });
1375
+ return;
1376
+ }
1377
+
1378
+ let localPath = state.files.bySessionId[sessionId]?.downloads?.[selectedFilename]?.localPath || null;
1379
+ if (!localPath) {
1380
+ this.dispatch({
1381
+ type: "ui/status",
1382
+ text: `Downloading ${selectedFilename} to open it...`,
1383
+ });
1384
+ const download = await this.saveArtifactDownload(sessionId, selectedFilename);
1385
+ localPath = download?.localPath || null;
1386
+ }
1387
+
1388
+ if (!localPath) {
1389
+ this.dispatch({
1390
+ type: "ui/status",
1391
+ text: `Open failed: could not save ${selectedFilename} locally`,
1392
+ });
1393
+ return;
1394
+ }
1395
+
1396
+ try {
1397
+ await this.transport.openPathInDefaultApp(localPath);
1398
+ this.dispatch({
1399
+ type: "ui/status",
1400
+ text: scope === "allSessions"
1401
+ ? `Opened ${shortSessionIdValue(sessionId)} ${selectedFilename} in the default app`
1402
+ : `Opened ${selectedFilename} in the default app`,
1403
+ });
1404
+ } catch (error) {
1405
+ this.dispatch({
1406
+ type: "ui/status",
1407
+ text: `Open failed: ${error?.message || String(error)}`,
1408
+ });
1409
+ }
1410
+ }
1411
+
1412
+ async downloadSelectedArtifact() {
1413
+ const selectedItem = selectSelectedFileBrowserItem(this.getState());
1414
+ if (!selectedItem?.sessionId || !selectedItem?.filename) {
1415
+ this.dispatch({ type: "ui/status", text: "No artifact selected" });
1416
+ return null;
1417
+ }
1418
+ this.dispatch({
1419
+ type: "ui/status",
1420
+ text: `Downloading ${selectedItem.filename}...`,
1421
+ });
1422
+ const download = await this.saveArtifactDownload(selectedItem.sessionId, selectedItem.filename);
1423
+ if (!download?.localPath) return null;
1424
+ this.dispatch({
1425
+ type: "ui/status",
1426
+ text: `Downloaded ${selectedItem.filename}`,
1427
+ });
1428
+ return download;
1429
+ }
1430
+
1431
+ async openArtifactPicker() {
1432
+ const state = this.getState();
1433
+ const activeSessionId = state.sessions.activeSessionId;
1434
+ if (!activeSessionId) {
1435
+ this.dispatch({ type: "ui/status", text: "No session selected" });
1436
+ return;
1437
+ }
1438
+
1439
+ const artifactLinks = selectActiveArtifactLinks(state);
1440
+ if (artifactLinks.length === 0) {
1441
+ this.dispatch({ type: "ui/status", text: "No artifact links in the current chat view" });
1442
+ return;
1443
+ }
1444
+
1445
+ const preferredSelectedFilename = this.getState().files.bySessionId[activeSessionId]?.selectedFilename || null;
1446
+ const preferredSelectedId = preferredSelectedFilename
1447
+ ? `${activeSessionId}/${preferredSelectedFilename}`
1448
+ : null;
1449
+ const nextModal = this.buildArtifactPickerModal({
1450
+ artifactLinks,
1451
+ previousFocus: state.ui.focusRegion,
1452
+ selectedId: preferredSelectedId,
1453
+ });
1454
+
1455
+ if (!nextModal) {
1456
+ this.dispatch({ type: "ui/status", text: "No artifact links in the current chat view" });
1457
+ return;
1458
+ }
1459
+
1460
+ this.dispatch({ type: "ui/modal", modal: nextModal });
1461
+ this.dispatch({ type: "ui/status", text: "Select a linked artifact and press Enter to download" });
1462
+ }
1463
+
1464
+ async downloadArtifactModalSelection() {
1465
+ const modal = this.getState().ui.modal;
1466
+ if (!modal || modal.type !== "artifactPicker") return;
1467
+
1468
+ const selectedItem = modal.items?.[modal.selectedIndex || 0];
1469
+ if (!selectedItem) return;
1470
+
1471
+ if (selectedItem.kind === "downloadAll") {
1472
+ const pending = (modal.items || []).filter((item) => {
1473
+ if (item.kind !== "artifact") return false;
1474
+ const download = this.getState().files.bySessionId[item.sessionId]?.downloads?.[item.filename];
1475
+ return !download?.localPath;
1476
+ });
1477
+
1478
+ if (pending.length === 0) {
1479
+ this.dispatch({ type: "ui/status", text: "All artifacts already downloaded" });
1480
+ return;
1481
+ }
1482
+
1483
+ this.dispatch({
1484
+ type: "ui/status",
1485
+ text: `Downloading ${pending.length} artifacts...`,
1486
+ });
1487
+
1488
+ let downloadedCount = 0;
1489
+ for (const item of pending) {
1490
+ const download = await this.saveArtifactDownload(item.sessionId, item.filename);
1491
+ if (download?.localPath) downloadedCount += 1;
1492
+ }
1493
+
1494
+ this.replaceArtifactPickerModal(selectedItem.id);
1495
+ this.dispatch({
1496
+ type: "ui/status",
1497
+ text: `Downloaded ${downloadedCount}/${pending.length} artifacts`,
1498
+ });
1499
+ return;
1500
+ }
1501
+
1502
+ this.dispatch({
1503
+ type: "ui/status",
1504
+ text: `Downloading ${selectedItem.filename}...`,
1505
+ });
1506
+ const download = await this.saveArtifactDownload(selectedItem.sessionId, selectedItem.filename);
1507
+ if (!download?.localPath) return;
1508
+
1509
+ this.replaceArtifactPickerModal(selectedItem.id);
1510
+ this.dispatch({
1511
+ type: "ui/status",
1512
+ text: `Downloaded ${selectedItem.filename}`,
1513
+ });
1514
+ }
1515
+
1516
+ async moveFileSelection(delta) {
1517
+ const scope = selectFilesScope(this.getState());
1518
+ await this.ensureFilesForScope(scope);
1519
+ const items = selectFileBrowserItems(this.getState());
1520
+ if (items.length === 0) return;
1521
+
1522
+ const currentItem = selectSelectedFileBrowserItem(this.getState()) || items[0];
1523
+ const currentIndex = Math.max(0, items.findIndex((item) => item.id === currentItem?.id));
1524
+ const nextIndex = Math.max(0, Math.min(items.length - 1, currentIndex + delta));
1525
+ const nextItem = items[nextIndex];
1526
+ if (!nextItem?.sessionId || !nextItem?.filename) return;
1527
+
1528
+ if (scope === "allSessions") {
1529
+ this.dispatch({
1530
+ type: "files/selectGlobal",
1531
+ artifactId: nextItem.id,
1532
+ });
1533
+ } else {
1534
+ this.dispatch({
1535
+ type: "files/select",
1536
+ sessionId: nextItem.sessionId,
1537
+ filename: nextItem.filename,
1538
+ });
1539
+ }
1540
+ await this.ensureFilePreview(nextItem.sessionId, nextItem.filename).catch(() => {});
1541
+ this.dispatch({
1542
+ type: "ui/status",
1543
+ text: scope === "allSessions"
1544
+ ? `Previewing ${shortSessionIdValue(nextItem.sessionId)} ${nextItem.filename}`
1545
+ : `Previewing ${nextItem.filename}`,
1546
+ });
1547
+ }
1548
+
1549
+ async selectFileBrowserItem(item) {
1550
+ if (!item?.sessionId || !item?.filename) return;
1551
+ const scope = selectFilesScope(this.getState());
1552
+ if (scope === "allSessions") {
1553
+ this.dispatch({
1554
+ type: "files/selectGlobal",
1555
+ artifactId: item.id,
1556
+ });
1557
+ } else {
1558
+ this.dispatch({
1559
+ type: "files/select",
1560
+ sessionId: item.sessionId,
1561
+ filename: item.filename,
1562
+ });
1563
+ }
1564
+ await this.ensureFilePreview(item.sessionId, item.filename).catch(() => {});
1565
+ this.dispatch({
1566
+ type: "ui/status",
1567
+ text: scope === "allSessions"
1568
+ ? `Previewing ${shortSessionIdValue(item.sessionId)} ${item.filename}`
1569
+ : `Previewing ${item.filename}`,
1570
+ });
1571
+ }
1572
+
1573
+ toggleFilePreviewFullscreen() {
1574
+ const state = this.getState();
1575
+ const sessionId = state.sessions.activeSessionId;
1576
+ if (!sessionId) return;
1577
+ const fileState = state.files.bySessionId[sessionId];
1578
+ if (!fileState?.selectedFilename) return;
1579
+ const nextFullscreen = !Boolean(state.files.fullscreen);
1580
+ this.dispatch({
1581
+ type: "files/fullscreen",
1582
+ fullscreen: nextFullscreen,
1583
+ });
1584
+ this.dispatch({
1585
+ type: "ui/status",
1586
+ text: nextFullscreen
1587
+ ? `Fullscreen files browser: ${fileState.selectedFilename}`
1588
+ : `Closed fullscreen files browser`,
1589
+ });
1590
+ }
1591
+
1592
+ toggleFocusedPaneFullscreen() {
1593
+ const state = this.getState();
1594
+ const focusRegion = state.ui.focusRegion;
1595
+ const currentFullscreenPane = state.ui.fullscreenPane || null;
1596
+ const targetPane = focusRegion === FOCUS_REGIONS.PROMPT
1597
+ ? currentFullscreenPane
1598
+ : focusRegion;
1599
+ if (!FULLSCREENABLE_PANES.has(targetPane)) return;
1600
+ if (targetPane === FOCUS_REGIONS.INSPECTOR && state.ui.inspectorTab === "files") return;
1601
+
1602
+ const nextFullscreenPane = currentFullscreenPane === targetPane ? null : targetPane;
1603
+ this.dispatch({
1604
+ type: "ui/fullscreenPane",
1605
+ fullscreenPane: nextFullscreenPane,
1606
+ });
1607
+ this.dispatch({
1608
+ type: "ui/status",
1609
+ text: nextFullscreenPane
1610
+ ? `Fullscreen ${targetPane} pane`
1611
+ : `Closed fullscreen ${targetPane} pane`,
1612
+ });
1613
+ if (!nextFullscreenPane && focusRegion === FOCUS_REGIONS.PROMPT) {
1614
+ this.setFocus(targetPane);
1615
+ }
1616
+ }
1617
+
1618
+ openFilesFilter() {
1619
+ const scope = selectFilesScope(this.getState());
1620
+ this.dispatch({
1621
+ type: "ui/modal",
1622
+ modal: {
1623
+ type: "filesFilter",
1624
+ title: "Files Filter",
1625
+ previousFocus: this.getState().ui.focusRegion,
1626
+ selectedIndex: 0,
1627
+ items: [
1628
+ {
1629
+ id: "scope",
1630
+ label: "Scope",
1631
+ description: "Choose whether the files browser shows only the selected session or aggregates exported files across all sessions.",
1632
+ options: [
1633
+ { id: "selectedSession", label: "Selected session" },
1634
+ { id: "allSessions", label: "All sessions" },
1635
+ ],
1636
+ },
1637
+ ],
1638
+ },
1639
+ });
1640
+ this.dispatch({
1641
+ type: "ui/status",
1642
+ text: `Editing files filter: Scope = ${scope === "allSessions" ? "All sessions" : "Selected session"}`,
1643
+ });
1644
+ }
1645
+
1646
+ async loadSession(sessionId) {
1647
+ if (!sessionId) return;
1648
+ const active = this.getState().sessions.activeSessionId;
1649
+ if (active !== sessionId) {
1650
+ this.dispatch({ type: "sessions/selected", sessionId });
1651
+ }
1652
+ await this.ensureSessionHistory(sessionId, { force: true });
1653
+ await this.syncSessionDetail(sessionId).catch(() => {});
1654
+ this.attachActiveSession(sessionId);
1655
+ this.ensureInspectorData().catch(() => {});
1656
+ }
1657
+
1658
+ mergeSessionEvent(sessionId, event) {
1659
+ if (!sessionId || !event) return false;
1660
+ const state = this.getState();
1661
+ const existing = state.history.bySessionId.get(sessionId) || { chat: [], activity: [], lastSeq: 0 };
1662
+ if (event.seq <= (existing.lastSeq || 0)) return false;
1663
+ this.dispatch({
1664
+ type: "history/set",
1665
+ sessionId,
1666
+ history: appendEventToHistory(existing, event),
1667
+ });
1668
+ const derivedModel = extractSessionModelFromEvent(event);
1669
+ const currentSession = this.getState().sessions.byId[sessionId] || { sessionId };
1670
+ const derivedContextUsage = applySessionUsageEvent(currentSession.contextUsage, event.eventType, event.data, {
1671
+ timestamp: event.createdAt,
1672
+ });
1673
+ if (derivedModel || derivedContextUsage) {
1674
+ this.dispatch({
1675
+ type: "sessions/merged",
1676
+ session: {
1677
+ sessionId,
1678
+ ...(derivedModel ? { model: derivedModel } : {}),
1679
+ ...(derivedContextUsage ? { contextUsage: derivedContextUsage } : {}),
1680
+ },
1681
+ });
1682
+ }
1683
+ this.scheduleSessionDetailSync(sessionId);
1684
+ return true;
1685
+ }
1686
+
1687
+ async syncSessionEvents(sessionId) {
1688
+ if (!sessionId || typeof this.transport.getSessionEvents !== "function") return;
1689
+ const existing = this.getState().history.bySessionId.get(sessionId);
1690
+ if (!existing?.events) {
1691
+ await this.ensureSessionHistory(sessionId, { force: true });
1692
+ return;
1693
+ }
1694
+ const afterSeq = Number(existing.lastSeq || 0);
1695
+ const events = await this.transport.getSessionEvents(sessionId, afterSeq, 200);
1696
+ if (!Array.isArray(events) || events.length === 0) return;
1697
+ for (const event of events) {
1698
+ this.mergeSessionEvent(sessionId, event);
1699
+ }
1700
+ }
1701
+
1702
+ attachActiveSession(sessionId) {
1703
+ if (this.activeSessionSubscriptionId === sessionId && this.activeSessionUnsub) {
1704
+ return;
1705
+ }
1706
+ this.detachActiveSession();
1707
+ this.activeSessionSubscriptionId = sessionId;
1708
+ this.activeSessionUnsub = this.transport.subscribeSession(sessionId, (event) => {
1709
+ this.mergeSessionEvent(sessionId, event);
1710
+ });
1711
+ this.syncSessionEvents(sessionId).catch(() => {});
1712
+ }
1713
+
1714
+ scheduleSessionDetailSync(sessionId, delayMs = 250) {
1715
+ if (typeof this.transport.getSession !== "function" || !sessionId) return;
1716
+ if (this.activeSessionDetailTimer) clearTimeout(this.activeSessionDetailTimer);
1717
+ this.activeSessionDetailSessionId = sessionId;
1718
+ this.activeSessionDetailTimer = setTimeout(() => {
1719
+ const targetSessionId = this.activeSessionDetailSessionId;
1720
+ this.activeSessionDetailTimer = null;
1721
+ this.activeSessionDetailSessionId = null;
1722
+ this.syncSessionDetail(targetSessionId).catch(() => {});
1723
+ }, delayMs);
1724
+ }
1725
+
1726
+ scheduleSessionsRefresh(delayMs = 0) {
1727
+ if (this.sessionRefreshTimer) clearTimeout(this.sessionRefreshTimer);
1728
+ this.sessionRefreshTimer = setTimeout(() => {
1729
+ this.sessionRefreshTimer = null;
1730
+ this.refreshSessions().catch(() => {});
1731
+ }, Math.max(0, delayMs));
1732
+ }
1733
+
1734
+ async syncSessionDetail(sessionId) {
1735
+ if (typeof this.transport.getSession !== "function" || !sessionId) return;
1736
+ const session = await this.transport.getSession(sessionId);
1737
+ if (!session) return;
1738
+ const previousSession = this.getState().sessions.byId[sessionId] || null;
1739
+ const patch = buildSessionMergePatch(previousSession, session);
1740
+ if (!patch) return;
1741
+ this.dispatch({ type: "sessions/merged", session: patch });
1742
+ }
1743
+
1744
+ async createSession(options = {}) {
1745
+ const created = await this.transport.createSession(options);
1746
+ await this.refreshSessions();
1747
+ await this.loadSession(created.sessionId);
1748
+ this.setFocus(FOCUS_REGIONS.PROMPT);
1749
+ this.dispatch({ type: "ui/status", text: `Created session ${created.sessionId.slice(0, 8)}` });
1750
+ }
1751
+
1752
+ async createSessionForAgent(agentName, options = {}) {
1753
+ if (typeof this.transport.createSessionForAgent !== "function") {
1754
+ throw new Error("Named-agent session creation is not supported by this transport");
1755
+ }
1756
+ const created = await this.transport.createSessionForAgent(agentName, options);
1757
+ await this.refreshSessions();
1758
+ await this.loadSession(created.sessionId);
1759
+ this.setFocus(FOCUS_REGIONS.PROMPT);
1760
+ this.dispatch({
1761
+ type: "ui/status",
1762
+ text: `Created ${formatAgentDisplayTitle(agentName, options.title)} session ${created.sessionId.slice(0, 8)}`,
1763
+ });
1764
+ }
1765
+
1766
+ async openSessionAgentPicker(options = {}) {
1767
+ const agents = typeof this.transport.listCreatableAgents === "function"
1768
+ ? await this.transport.listCreatableAgents()
1769
+ : [];
1770
+ const sessionPolicy = typeof this.transport.getSessionCreationPolicy === "function"
1771
+ ? this.transport.getSessionCreationPolicy()
1772
+ : null;
1773
+ const allowGeneric = sessionPolicy?.creation?.allowGeneric ?? true;
1774
+
1775
+ if (!Array.isArray(agents) || agents.length === 0) {
1776
+ if (!allowGeneric) {
1777
+ this.dispatch({
1778
+ type: "ui/status",
1779
+ text: "No user-creatable agents are available for this app",
1780
+ });
1781
+ return;
1782
+ }
1783
+ await this.createSession(options);
1784
+ return;
1785
+ }
1786
+
1787
+ const items = [];
1788
+ if (allowGeneric) {
1789
+ items.push({
1790
+ id: "__generic__",
1791
+ kind: "generic",
1792
+ title: "Generic Session",
1793
+ description: "Open-ended session with no specialized agent boundary.",
1794
+ tools: [],
1795
+ splash: null,
1796
+ initialPrompt: null,
1797
+ });
1798
+ }
1799
+
1800
+ for (const agent of agents) {
1801
+ const agentName = String(agent?.name || "").trim();
1802
+ if (!agentName) continue;
1803
+ items.push({
1804
+ id: agentName,
1805
+ kind: "agent",
1806
+ agentName,
1807
+ title: formatAgentDisplayTitle(agentName, agent?.title),
1808
+ description: String(agent?.description || "").trim(),
1809
+ tools: Array.isArray(agent?.tools) ? agent.tools.filter(Boolean) : [],
1810
+ splash: typeof agent?.splash === "string" && agent.splash.trim() ? agent.splash : null,
1811
+ initialPrompt: typeof agent?.initialPrompt === "string" && agent.initialPrompt.trim() ? agent.initialPrompt : null,
1812
+ });
1813
+ }
1814
+
1815
+ this.dispatch({
1816
+ type: "ui/modal",
1817
+ modal: {
1818
+ type: "sessionAgentPicker",
1819
+ title: "Select agent for new session",
1820
+ items,
1821
+ selectedIndex: 0,
1822
+ previousFocus: this.getState().ui.focusRegion,
1823
+ sessionOptions: options,
1824
+ },
1825
+ });
1826
+ this.dispatch({ type: "ui/status", text: "Select an agent and press Enter" });
1827
+ }
1828
+
1829
+ async openNewSessionFlow(options = {}) {
1830
+ await this.openSessionAgentPicker(options);
1831
+ }
1832
+
1833
+ async openModelPicker() {
1834
+ if (typeof this.transport.listModels !== "function") {
1835
+ await this.openNewSessionFlow();
1836
+ return;
1837
+ }
1838
+
1839
+ const models = await this.transport.listModels();
1840
+ if (!Array.isArray(models) || models.length === 0) {
1841
+ this.dispatch({ type: "ui/status", text: "No models available" });
1842
+ return;
1843
+ }
1844
+
1845
+ const defaultModel = typeof this.transport.getDefaultModel === "function"
1846
+ ? this.transport.getDefaultModel()
1847
+ : undefined;
1848
+ const groupedModels = typeof this.transport.getModelsByProvider === "function"
1849
+ ? this.transport.getModelsByProvider()
1850
+ : groupModelsByProvider(models);
1851
+ const items = [];
1852
+ const groups = groupedModels
1853
+ .map((group) => ({
1854
+ providerId: group.providerId,
1855
+ providerType: group.type || group.providerType,
1856
+ models: (group.models || []).map((model) => {
1857
+ const item = {
1858
+ id: model.qualifiedName,
1859
+ qualifiedName: model.qualifiedName,
1860
+ modelName: model.modelName || model.qualifiedName,
1861
+ providerId: model.providerId || group.providerId,
1862
+ providerType: model.providerType || group.type || group.providerType,
1863
+ description: model.description || "",
1864
+ cost: model.cost || null,
1865
+ isDefault: defaultModel === model.qualifiedName,
1866
+ };
1867
+ items.push(item);
1868
+ return item;
1869
+ }),
1870
+ }))
1871
+ .filter((group) => group.models.length > 0);
1872
+
1873
+ const selectedIndex = Math.max(0, items.findIndex((model) => model.qualifiedName === defaultModel));
1874
+ this.dispatch({
1875
+ type: "ui/modal",
1876
+ modal: {
1877
+ type: "modelPicker",
1878
+ title: "Select model for new session",
1879
+ items,
1880
+ groups,
1881
+ selectedIndex,
1882
+ previousFocus: this.getState().ui.focusRegion,
1883
+ },
1884
+ });
1885
+ this.dispatch({ type: "ui/status", text: "Select a model and press Enter" });
1886
+ }
1887
+
1888
+ openThemePicker() {
1889
+ const themes = listThemes().map((theme) => ({
1890
+ id: theme.id,
1891
+ label: theme.label,
1892
+ description: theme.description,
1893
+ page: theme.page,
1894
+ terminal: theme.terminal,
1895
+ tui: theme.tui,
1896
+ }));
1897
+ if (themes.length === 0) {
1898
+ this.dispatch({ type: "ui/status", text: "No themes available" });
1899
+ return;
1900
+ }
1901
+
1902
+ const currentThemeId = this.getState().ui.themeId;
1903
+ const selectedIndex = Math.max(0, themes.findIndex((theme) => theme.id === currentThemeId));
1904
+ this.dispatch({
1905
+ type: "ui/modal",
1906
+ modal: {
1907
+ type: "themePicker",
1908
+ title: "Theme Picker",
1909
+ items: themes,
1910
+ selectedIndex,
1911
+ previousFocus: this.getState().ui.focusRegion,
1912
+ currentThemeId,
1913
+ },
1914
+ });
1915
+ this.dispatch({ type: "ui/status", text: "Select a theme and press Enter" });
1916
+ }
1917
+
1918
+ getPromptDraftSessionId() {
1919
+ const promptAttachmentSessionId = this.getPromptAttachments()[0]?.sessionId || null;
1920
+ return promptAttachmentSessionId || this.getState().sessions.activeSessionId || null;
1921
+ }
1922
+
1923
+ openArtifactUploadModal() {
1924
+ if (typeof this.transport.uploadArtifactFromPath !== "function") {
1925
+ this.dispatch({ type: "ui/status", text: "Artifact upload is not supported by this transport" });
1926
+ return;
1927
+ }
1928
+
1929
+ const state = this.getState();
1930
+ const sessionId = this.getPromptDraftSessionId();
1931
+ this.dispatch({
1932
+ type: "ui/modal",
1933
+ modal: {
1934
+ type: "artifactUpload",
1935
+ title: sessionId
1936
+ ? `Upload Artifact (${shortSessionIdValue(sessionId)})`
1937
+ : "Upload Artifact",
1938
+ sessionId,
1939
+ previousFocus: state.ui.focusRegion,
1940
+ value: "",
1941
+ cursorIndex: 0,
1942
+ },
1943
+ });
1944
+ this.dispatch({
1945
+ type: "ui/status",
1946
+ text: sessionId
1947
+ ? "Paste a local file path and press Enter to upload it into this session's artifacts"
1948
+ : "Paste a local file path and press Enter to upload it; a new session will be created if needed",
1949
+ });
1950
+ }
1951
+
1952
+ updateArtifactUploadModal(updater) {
1953
+ const modal = this.getState().ui.modal;
1954
+ if (!modal || modal.type !== "artifactUpload") return null;
1955
+ const nextModal = typeof updater === "function" ? updater(modal) : updater;
1956
+ if (!nextModal) return null;
1957
+ this.dispatch({
1958
+ type: "ui/modal",
1959
+ modal: {
1960
+ ...modal,
1961
+ ...nextModal,
1962
+ },
1963
+ });
1964
+ return this.getState().ui.modal;
1965
+ }
1966
+
1967
+ setArtifactUploadValue(value, cursorIndex = String(value || "").length) {
1968
+ const modal = this.getState().ui.modal;
1969
+ if (!modal || modal.type !== "artifactUpload") return;
1970
+ const safeValue = String(value || "").replace(/\r?\n/g, "");
1971
+ const safeCursor = clampPromptCursor(safeValue, cursorIndex);
1972
+ this.updateArtifactUploadModal({
1973
+ value: safeValue,
1974
+ cursorIndex: safeCursor,
1975
+ });
1976
+ }
1977
+
1978
+ insertArtifactUploadText(text) {
1979
+ const modal = this.getState().ui.modal;
1980
+ if (!modal || modal.type !== "artifactUpload") return;
1981
+ const next = insertPromptTextAtCursor(modal.value || "", modal.cursorIndex || 0, String(text || "").replace(/\r?\n/g, ""));
1982
+ this.setArtifactUploadValue(next.prompt, next.cursor);
1983
+ }
1984
+
1985
+ deleteArtifactUploadChar() {
1986
+ const modal = this.getState().ui.modal;
1987
+ if (!modal || modal.type !== "artifactUpload") return;
1988
+ const next = deletePromptCharBackward(modal.value || "", modal.cursorIndex || 0);
1989
+ this.setArtifactUploadValue(next.prompt, next.cursor);
1990
+ }
1991
+
1992
+ moveArtifactUploadCursor(delta) {
1993
+ const modal = this.getState().ui.modal;
1994
+ if (!modal || modal.type !== "artifactUpload") return;
1995
+ this.setArtifactUploadValue(modal.value || "", clampPromptCursor(modal.value || "", (modal.cursorIndex || 0) + delta));
1996
+ }
1997
+
1998
+ moveArtifactUploadCursorToBoundary(kind) {
1999
+ const modal = this.getState().ui.modal;
2000
+ if (!modal || modal.type !== "artifactUpload") return;
2001
+ this.setArtifactUploadValue(modal.value || "", kind === "start" ? 0 : String(modal.value || "").length);
2002
+ }
2003
+
2004
+ async ensurePromptAttachmentSessionId() {
2005
+ const existingAttachmentSessionId = this.getPromptAttachments()[0]?.sessionId || null;
2006
+ if (existingAttachmentSessionId) {
2007
+ if (this.getState().sessions.activeSessionId !== existingAttachmentSessionId) {
2008
+ await this.loadSession(existingAttachmentSessionId);
2009
+ }
2010
+ return existingAttachmentSessionId;
2011
+ }
2012
+
2013
+ const activeSessionId = this.getState().sessions.activeSessionId;
2014
+ if (activeSessionId) return activeSessionId;
2015
+
2016
+ const created = await this.transport.createSession({});
2017
+ await this.refreshSessions();
2018
+ await this.loadSession(created.sessionId);
2019
+ this.setFocus(FOCUS_REGIONS.PROMPT);
2020
+ return created.sessionId;
2021
+ }
2022
+
2023
+ async finalizeArtifactUpload(upload, { sessionId = null, suppressStatus = false } = {}) {
2024
+ const resolvedSessionId = sessionId || upload?.sessionId || await this.ensurePromptAttachmentSessionId();
2025
+ if (!resolvedSessionId || !upload?.filename) {
2026
+ throw new Error("Upload did not return a session id and filename");
2027
+ }
2028
+
2029
+ if (this.getState().sessions.activeSessionId !== resolvedSessionId) {
2030
+ await this.loadSession(resolvedSessionId);
2031
+ }
2032
+
2033
+ await this.ensureFilesForSession(resolvedSessionId, { force: true }).catch(() => null);
2034
+ this.dispatch({
2035
+ type: "files/select",
2036
+ sessionId: resolvedSessionId,
2037
+ filename: upload.filename,
2038
+ });
2039
+ await this.ensureFilePreview(resolvedSessionId, upload.filename, { force: true }).catch(() => null);
2040
+
2041
+ if (!suppressStatus) {
2042
+ this.dispatch({
2043
+ type: "ui/status",
2044
+ text: `Uploaded ${upload.filename}`,
2045
+ });
2046
+ }
2047
+
2048
+ return {
2049
+ sessionId: resolvedSessionId,
2050
+ filename: upload.filename,
2051
+ };
2052
+ }
2053
+
2054
+ async applyUploadedPromptAttachment(upload, { sessionId = null, suppressStatus = false } = {}) {
2055
+ const resolvedSessionId = sessionId || upload?.sessionId || await this.ensurePromptAttachmentSessionId();
2056
+ if (!resolvedSessionId || !upload?.filename) {
2057
+ throw new Error("Upload did not return a session id and filename");
2058
+ }
2059
+
2060
+ const token = buildPromptAttachmentToken(upload.filename);
2061
+ const currentPrompt = this.getState().ui.prompt;
2062
+ const currentCursor = this.getState().ui.promptCursor;
2063
+ const previousAttachments = this.getPromptAttachments();
2064
+ const existingAttachmentIndex = previousAttachments.findIndex((attachment) => (
2065
+ attachment?.sessionId === resolvedSessionId
2066
+ && attachment?.filename === upload.filename
2067
+ ));
2068
+
2069
+ if (this.getState().sessions.activeSessionId !== resolvedSessionId) {
2070
+ await this.loadSession(resolvedSessionId);
2071
+ }
2072
+
2073
+ if (existingAttachmentIndex === -1 || !currentPrompt.includes(previousAttachments[existingAttachmentIndex]?.token || token)) {
2074
+ const insertion = insertPromptTextAtCursor(currentPrompt, currentCursor, `${token} `);
2075
+ this.setPrompt(insertion.prompt, insertion.cursor);
2076
+ }
2077
+
2078
+ const nextAttachments = existingAttachmentIndex >= 0
2079
+ ? previousAttachments.map((attachment, index) => (index === existingAttachmentIndex
2080
+ ? {
2081
+ ...attachment,
2082
+ sessionId: resolvedSessionId,
2083
+ filename: upload.filename,
2084
+ resolvedPath: upload.resolvedPath || upload.localPath || upload.filename,
2085
+ sizeBytes: upload.sizeBytes,
2086
+ token,
2087
+ }
2088
+ : attachment))
2089
+ : [
2090
+ ...previousAttachments,
2091
+ {
2092
+ id: `${resolvedSessionId}/${upload.filename}`,
2093
+ sessionId: resolvedSessionId,
2094
+ filename: upload.filename,
2095
+ resolvedPath: upload.resolvedPath || upload.localPath || upload.filename,
2096
+ sizeBytes: upload.sizeBytes,
2097
+ token,
2098
+ },
2099
+ ];
2100
+ this.setPromptAttachments(nextAttachments);
2101
+
2102
+ await this.ensureFilesForSession(resolvedSessionId, { force: true }).catch(() => null);
2103
+ this.dispatch({
2104
+ type: "files/select",
2105
+ sessionId: resolvedSessionId,
2106
+ filename: upload.filename,
2107
+ });
2108
+ if (this.getState().ui.inspectorTab === "files") {
2109
+ await this.ensureFilePreview(resolvedSessionId, upload.filename, { force: true }).catch(() => null);
2110
+ }
2111
+
2112
+ this.setFocus(FOCUS_REGIONS.PROMPT);
2113
+ if (!suppressStatus) {
2114
+ this.dispatch({
2115
+ type: "ui/status",
2116
+ text: existingAttachmentIndex >= 0
2117
+ ? `Re-attached ${upload.filename}`
2118
+ : `Attached ${upload.filename}`,
2119
+ });
2120
+ }
2121
+
2122
+ return {
2123
+ sessionId: resolvedSessionId,
2124
+ existingAttachmentIndex,
2125
+ token,
2126
+ };
2127
+ }
2128
+
2129
+ async uploadArtifactFiles(files = [], { sessionId = null } = {}) {
2130
+ const fileList = Array.isArray(files)
2131
+ ? files.filter((file) => file && typeof file.name === "string")
2132
+ : [];
2133
+ if (fileList.length === 0) {
2134
+ this.dispatch({ type: "ui/status", text: "No files selected" });
2135
+ return [];
2136
+ }
2137
+ if (typeof this.transport.uploadArtifactFromFile !== "function") {
2138
+ this.dispatch({ type: "ui/status", text: "Browser file uploads are not supported by this transport" });
2139
+ return [];
2140
+ }
2141
+
2142
+ this.dispatch({
2143
+ type: "ui/status",
2144
+ text: fileList.length === 1
2145
+ ? `Uploading ${fileList[0].name}...`
2146
+ : `Uploading ${fileList.length} artifact files...`,
2147
+ });
2148
+
2149
+ const resolvedSessionId = sessionId || await this.ensurePromptAttachmentSessionId();
2150
+ const uploads = [];
2151
+ let failures = 0;
2152
+ let lastError = null;
2153
+
2154
+ for (const file of fileList) {
2155
+ try {
2156
+ const upload = await this.transport.uploadArtifactFromFile(resolvedSessionId, file);
2157
+ uploads.push(upload);
2158
+ await this.finalizeArtifactUpload(upload, { sessionId: resolvedSessionId, suppressStatus: true });
2159
+ } catch (error) {
2160
+ failures += 1;
2161
+ lastError = error;
2162
+ }
2163
+ }
2164
+
2165
+ if (uploads.length > 0) {
2166
+ this.dispatch({
2167
+ type: "ui/status",
2168
+ text: failures > 0
2169
+ ? `Uploaded ${uploads.length}/${fileList.length} file(s); last error: ${lastError?.message || String(lastError)}`
2170
+ : uploads.length === 1
2171
+ ? `Uploaded ${uploads[0].filename}`
2172
+ : `Uploaded ${uploads.length} files`,
2173
+ });
2174
+ } else if (lastError) {
2175
+ this.dispatch({
2176
+ type: "ui/status",
2177
+ text: `Upload failed: ${lastError?.message || String(lastError)}`,
2178
+ });
2179
+ }
2180
+
2181
+ return uploads;
2182
+ }
2183
+
2184
+ async uploadPromptAttachmentFiles(files = []) {
2185
+ return this.uploadArtifactFiles(files);
2186
+ }
2187
+
2188
+ async confirmArtifactUploadModal() {
2189
+ const modal = this.getState().ui.modal;
2190
+ if (!modal || modal.type !== "artifactUpload") return;
2191
+ const filePath = String(modal.value || "").trim();
2192
+ if (!filePath) {
2193
+ this.dispatch({ type: "ui/status", text: "File path cannot be empty" });
2194
+ return;
2195
+ }
2196
+ if (typeof this.transport.uploadArtifactFromPath !== "function") {
2197
+ this.dispatch({ type: "ui/status", text: "Artifact upload is not supported by this transport" });
2198
+ return;
2199
+ }
2200
+
2201
+ this.dispatch({
2202
+ type: "ui/status",
2203
+ text: "Uploading artifact...",
2204
+ });
2205
+
2206
+ try {
2207
+ const sessionId = modal.sessionId || await this.ensurePromptAttachmentSessionId();
2208
+ const upload = await this.transport.uploadArtifactFromPath(sessionId, filePath);
2209
+ const result = await this.finalizeArtifactUpload(upload, { sessionId, suppressStatus: true });
2210
+ this.dispatch({ type: "ui/modal", modal: null });
2211
+ this.dispatch({
2212
+ type: "ui/status",
2213
+ text: `Uploaded ${result.filename}`,
2214
+ });
2215
+ } catch (error) {
2216
+ this.dispatch({
2217
+ type: "ui/status",
2218
+ text: `Upload failed: ${error?.message || String(error)}`,
2219
+ });
2220
+ }
2221
+ }
2222
+
2223
+ openRenameSessionModal() {
2224
+ const state = this.getState();
2225
+ const sessionId = state.sessions.activeSessionId;
2226
+ if (!sessionId) {
2227
+ this.dispatch({ type: "ui/status", text: "No session selected" });
2228
+ return;
2229
+ }
2230
+
2231
+ const session = state.sessions.byId[sessionId];
2232
+ if (!session) {
2233
+ this.dispatch({ type: "ui/status", text: "No session selected" });
2234
+ return;
2235
+ }
2236
+ if (session.isSystem) {
2237
+ this.dispatch({ type: "ui/status", text: "System session titles are fixed" });
2238
+ return;
2239
+ }
2240
+
2241
+ const value = getRenameSessionEditableTitle(session);
2242
+ const agentTitlePrefix = getRenameSessionPrefix(session);
2243
+ const maxLength = getRenameSessionMaxLength(session);
2244
+ this.dispatch({
2245
+ type: "ui/modal",
2246
+ modal: {
2247
+ type: "renameSession",
2248
+ title: `Rename (${shortSessionIdValue(sessionId)})`,
2249
+ sessionId,
2250
+ previousFocus: state.ui.focusRegion,
2251
+ value,
2252
+ cursorIndex: value.length,
2253
+ agentTitlePrefix,
2254
+ currentTitle: String(session.title || "").trim(),
2255
+ maxLength,
2256
+ },
2257
+ });
2258
+ this.dispatch({
2259
+ type: "ui/status",
2260
+ text: agentTitlePrefix
2261
+ ? `Rename title for ${agentTitlePrefix}; the agent-name prefix stays fixed`
2262
+ : "Type a new session title and press Enter to save",
2263
+ });
2264
+ }
2265
+
2266
+ updateRenameSessionModal(updater) {
2267
+ const modal = this.getState().ui.modal;
2268
+ if (!modal || modal.type !== "renameSession") return null;
2269
+ const nextModal = typeof updater === "function" ? updater(modal) : updater;
2270
+ if (!nextModal) return null;
2271
+ this.dispatch({
2272
+ type: "ui/modal",
2273
+ modal: {
2274
+ ...modal,
2275
+ ...nextModal,
2276
+ },
2277
+ });
2278
+ return this.getState().ui.modal;
2279
+ }
2280
+
2281
+ setRenameSessionValue(value, cursorIndex = String(value || "").length) {
2282
+ const modal = this.getState().ui.modal;
2283
+ if (!modal || modal.type !== "renameSession") return;
2284
+ const safeValue = clampRenameSessionValue(value, modal.maxLength);
2285
+ const safeCursor = clampPromptCursor(safeValue, cursorIndex);
2286
+ this.updateRenameSessionModal({
2287
+ value: safeValue,
2288
+ cursorIndex: safeCursor,
2289
+ });
2290
+ }
2291
+
2292
+ insertRenameSessionText(text) {
2293
+ const modal = this.getState().ui.modal;
2294
+ if (!modal || modal.type !== "renameSession") return;
2295
+ const next = insertPromptTextAtCursor(modal.value || "", modal.cursorIndex || 0, clampRenameSessionValue(text, modal.maxLength));
2296
+ this.setRenameSessionValue(next.prompt, next.cursor);
2297
+ }
2298
+
2299
+ deleteRenameSessionChar() {
2300
+ const modal = this.getState().ui.modal;
2301
+ if (!modal || modal.type !== "renameSession") return;
2302
+ const next = deletePromptCharBackward(modal.value || "", modal.cursorIndex || 0);
2303
+ this.setRenameSessionValue(next.prompt, next.cursor);
2304
+ }
2305
+
2306
+ moveRenameSessionCursor(delta) {
2307
+ const modal = this.getState().ui.modal;
2308
+ if (!modal || modal.type !== "renameSession") return;
2309
+ this.setRenameSessionValue(modal.value || "", clampPromptCursor(modal.value || "", (modal.cursorIndex || 0) + delta));
2310
+ }
2311
+
2312
+ moveRenameSessionCursorToBoundary(kind) {
2313
+ const modal = this.getState().ui.modal;
2314
+ if (!modal || modal.type !== "renameSession") return;
2315
+ this.setRenameSessionValue(modal.value || "", kind === "start" ? 0 : String(modal.value || "").length);
2316
+ }
2317
+
2318
+ async confirmRenameSessionModal() {
2319
+ const modal = this.getState().ui.modal;
2320
+ if (!modal || modal.type !== "renameSession") return;
2321
+ const sessionId = modal.sessionId;
2322
+ if (!sessionId) return;
2323
+
2324
+ const requestedTitle = String(modal.value || "").trim();
2325
+ if (!requestedTitle) {
2326
+ this.dispatch({ type: "ui/status", text: "Title cannot be empty" });
2327
+ return;
2328
+ }
2329
+ if (typeof this.transport.renameSession !== "function") {
2330
+ this.dispatch({ type: "ui/status", text: "Session renaming is not supported by this transport" });
2331
+ return;
2332
+ }
2333
+
2334
+ const previousFocus = modal.previousFocus;
2335
+ this.dispatch({ type: "ui/modal", modal: null });
2336
+ if (previousFocus) {
2337
+ this.setFocus(previousFocus);
2338
+ }
2339
+
2340
+ this.dispatch({
2341
+ type: "ui/status",
2342
+ text: `Renaming ${shortSessionIdValue(sessionId)}...`,
2343
+ });
2344
+
2345
+ try {
2346
+ await this.transport.renameSession(sessionId, requestedTitle);
2347
+ await this.refreshSessions();
2348
+ this.scheduleSessionDetailSync(sessionId, 100);
2349
+ this.dispatch({
2350
+ type: "ui/status",
2351
+ text: `Renamed ${shortSessionIdValue(sessionId)}`,
2352
+ });
2353
+ } catch (error) {
2354
+ this.dispatch({
2355
+ type: "ui/status",
2356
+ text: `Rename failed: ${error?.message || String(error)}`,
2357
+ });
2358
+ }
2359
+ }
2360
+
2361
+ closeModal() {
2362
+ const modal = this.getState().ui.modal;
2363
+ if (!modal) return;
2364
+ this.dispatch({ type: "ui/modal", modal: null });
2365
+ if (modal.previousFocus) {
2366
+ this.setFocus(modal.previousFocus);
2367
+ }
2368
+ this.dispatch({ type: "ui/status", text: "Connected" });
2369
+ }
2370
+
2371
+ moveModalSelection(delta) {
2372
+ const modal = this.getState().ui.modal;
2373
+ if (!modal || !Array.isArray(modal.items) || modal.items.length === 0) return;
2374
+ if (modal.type === "logFilter" || modal.type === "filesFilter" || modal.type === "historyFormat") {
2375
+ const currentPaneIndex = Math.max(0, Math.min(Number(modal.selectedIndex) || 0, modal.items.length - 1));
2376
+ const selected = modal.items[currentPaneIndex];
2377
+ if (!selected || !Array.isArray(selected.options) || selected.options.length === 0) return;
2378
+ const optionIds = selected.options.map((option) => option.id).filter(Boolean);
2379
+ if (optionIds.length === 0) return;
2380
+ const currentValue = modal.type === "filesFilter"
2381
+ ? this.getState().files.filter?.[selected.id] || optionIds[0]
2382
+ : modal.type === "historyFormat"
2383
+ ? this.getState().executionHistory?.format || optionIds[0]
2384
+ : this.getState().logs.filter?.[selected.id] || optionIds[0];
2385
+ const nextValue = cycleValue(optionIds, currentValue, delta);
2386
+ const nextOption = selected.options.find((option) => option.id === nextValue) || selected.options[0];
2387
+ this.dispatch({
2388
+ type: modal.type === "filesFilter" ? "files/filter" : modal.type === "historyFormat" ? "executionHistory/format" : "logs/filter",
2389
+ filter: modal.type === "historyFormat" ? undefined : { [selected.id]: nextValue },
2390
+ ...(modal.type === "historyFormat" ? { format: nextValue } : {}),
2391
+ });
2392
+ if (modal.type === "filesFilter") {
2393
+ this.ensureFilesForScope(nextValue).catch(() => {});
2394
+ this.ensureSelectedFilePreview().catch(() => {});
2395
+ }
2396
+ this.dispatch({
2397
+ type: "ui/status",
2398
+ text: `${modal.type === "filesFilter" ? "Files" : modal.type === "historyFormat" ? "History" : "Log"} filter updated: ${selected.label} = ${nextOption?.label || nextValue}`,
2399
+ });
2400
+ return;
2401
+ }
2402
+ const current = Math.max(0, Number(modal.selectedIndex) || 0);
2403
+ const next = Math.max(0, Math.min(current + delta, modal.items.length - 1));
2404
+ this.dispatch({ type: "ui/modalSelection", index: next });
2405
+ }
2406
+
2407
+ moveModalPane(delta) {
2408
+ const modal = this.getState().ui.modal;
2409
+ if (!modal || (modal.type !== "logFilter" && modal.type !== "filesFilter" && modal.type !== "historyFormat") || !Array.isArray(modal.items) || modal.items.length === 0) return;
2410
+ const current = Math.max(0, Number(modal.selectedIndex) || 0);
2411
+ const next = (current + delta + modal.items.length) % modal.items.length;
2412
+ const selected = modal.items[next];
2413
+ const currentValue = modal.type === "filesFilter"
2414
+ ? this.getState().files.filter?.[selected.id] || selected.options?.[0]?.id
2415
+ : modal.type === "historyFormat"
2416
+ ? this.getState().executionHistory?.format || selected.options?.[0]?.id
2417
+ : this.getState().logs.filter?.[selected.id] || selected.options?.[0]?.id;
2418
+ const currentOption = selected.options?.find((option) => option.id === currentValue) || selected.options?.[0];
2419
+ this.dispatch({ type: "ui/modalSelection", index: next });
2420
+ this.dispatch({
2421
+ type: "ui/status",
2422
+ text: `Editing ${selected.label}: ${currentOption?.label || currentValue || ""}`,
2423
+ });
2424
+ }
2425
+
2426
+ async confirmModal() {
2427
+ const modal = this.getState().ui.modal;
2428
+ if (!modal) return;
2429
+ if (modal.type === "artifactUpload") {
2430
+ await this.confirmArtifactUploadModal();
2431
+ return;
2432
+ }
2433
+ if (modal.type === "renameSession") {
2434
+ await this.confirmRenameSessionModal();
2435
+ return;
2436
+ }
2437
+ if (modal.type === "filesFilter") {
2438
+ const previousFocus = modal.previousFocus;
2439
+ this.dispatch({ type: "ui/modal", modal: null });
2440
+ if (previousFocus) this.setFocus(previousFocus);
2441
+ return;
2442
+ }
2443
+ if (modal.type === "themePicker") {
2444
+ const item = modal.items?.[modal.selectedIndex || 0];
2445
+ const nextTheme = getTheme(item?.id);
2446
+ if (!nextTheme) {
2447
+ this.dispatch({ type: "ui/status", text: "Unable to apply that theme" });
2448
+ return;
2449
+ }
2450
+ const previousFocus = modal.previousFocus;
2451
+ this.dispatch({ type: "ui/modal", modal: null });
2452
+ this.dispatch({ type: "ui/theme", themeId: nextTheme.id });
2453
+ if (previousFocus) {
2454
+ this.setFocus(previousFocus);
2455
+ }
2456
+ this.dispatch({ type: "ui/status", text: `Applied theme: ${nextTheme.label}` });
2457
+ return;
2458
+ }
2459
+ if (modal.type === "modelPicker") {
2460
+ const item = modal.items?.[modal.selectedIndex || 0];
2461
+ const previousFocus = modal.previousFocus;
2462
+ this.dispatch({ type: "ui/modal", modal: null });
2463
+ if (previousFocus) {
2464
+ this.setFocus(previousFocus);
2465
+ }
2466
+ await this.openNewSessionFlow(item?.id ? { model: item.id } : {});
2467
+ return;
2468
+ }
2469
+ if (modal.type === "sessionAgentPicker") {
2470
+ const item = modal.items?.[modal.selectedIndex || 0];
2471
+ const previousFocus = modal.previousFocus;
2472
+ const sessionOptions = modal.sessionOptions || {};
2473
+ this.dispatch({ type: "ui/modal", modal: null });
2474
+ if (previousFocus) {
2475
+ this.setFocus(previousFocus);
2476
+ }
2477
+ if (!item || item.kind === "generic") {
2478
+ await this.createSession(sessionOptions);
2479
+ return;
2480
+ }
2481
+ await this.createSessionForAgent(item.agentName, {
2482
+ ...sessionOptions,
2483
+ ...(item.title ? { title: item.title } : {}),
2484
+ ...(item.splash ? { splash: item.splash } : {}),
2485
+ ...(item.initialPrompt ? { initialPrompt: item.initialPrompt } : {}),
2486
+ });
2487
+ return;
2488
+ }
2489
+ if (modal.type === "logFilter") {
2490
+ this.closeModal();
2491
+ return;
2492
+ }
2493
+ if (modal.type === "historyFormat") {
2494
+ this.closeModal();
2495
+ return;
2496
+ }
2497
+ if (modal.type === "artifactPicker") {
2498
+ await this.downloadArtifactModalSelection();
2499
+ }
2500
+ }
2501
+
2502
+ openLogFilter() {
2503
+ const previousFocus = this.getState().ui.focusRegion;
2504
+ this.dispatch({
2505
+ type: "ui/modal",
2506
+ modal: {
2507
+ type: "logFilter",
2508
+ title: "Log Filters",
2509
+ previousFocus,
2510
+ selectedIndex: 0,
2511
+ items: [
2512
+ {
2513
+ id: "source",
2514
+ label: "Source nodes",
2515
+ description: "Choose whether the log pane shows logs from all nodes or only the current orchestration.",
2516
+ options: [
2517
+ { id: "allNodes", label: "All nodes" },
2518
+ { id: "currentOrchestration", label: "Current orchestration" },
2519
+ ],
2520
+ },
2521
+ {
2522
+ id: "level",
2523
+ label: "Levels",
2524
+ description: "Filter logs by severity/verbosity level.",
2525
+ options: [
2526
+ { id: "all", label: "All" },
2527
+ { id: "info", label: "Info" },
2528
+ { id: "warn", label: "Warn" },
2529
+ { id: "error", label: "Error" },
2530
+ { id: "debug", label: "Debug" },
2531
+ { id: "trace", label: "Trace" },
2532
+ ],
2533
+ },
2534
+ {
2535
+ id: "format",
2536
+ label: "Format",
2537
+ description: "Raw shows the structured time/node/level summary. Pretty shows the cleaned message text, colored by orchestration vs activity.",
2538
+ options: [
2539
+ { id: "pretty", label: "Pretty text" },
2540
+ { id: "raw", label: "Raw summary" },
2541
+ ],
2542
+ },
2543
+ ],
2544
+ },
2545
+ });
2546
+ this.dispatch({ type: "ui/status", text: "Tab/Shift-Tab switch filters · Up/Down change values · Enter close · Esc cancel" });
2547
+ }
2548
+
2549
+ openHistoryFormat() {
2550
+ const previousFocus = this.getState().ui.focusRegion;
2551
+ this.dispatch({
2552
+ type: "ui/modal",
2553
+ modal: {
2554
+ type: "historyFormat",
2555
+ title: "Execution History Format",
2556
+ previousFocus,
2557
+ selectedIndex: 0,
2558
+ items: [
2559
+ {
2560
+ id: "format",
2561
+ label: "Format",
2562
+ description: "Pretty prints a human-readable view with colored event kinds. Raw JSON shows the full event objects.",
2563
+ options: [
2564
+ { id: "pretty", label: "Pretty text" },
2565
+ { id: "raw", label: "Raw JSON" },
2566
+ ],
2567
+ },
2568
+ ],
2569
+ },
2570
+ });
2571
+ this.dispatch({ type: "ui/status", text: "Up/Down change format · Enter close · Esc cancel" });
2572
+ }
2573
+
2574
+ toggleLogTail() {
2575
+ const logs = this.getState().logs;
2576
+ if (!logs.available) {
2577
+ this.dispatch({
2578
+ type: "ui/status",
2579
+ text: logs.availabilityReason || "Log tailing is not available in this environment",
2580
+ });
2581
+ return;
2582
+ }
2583
+
2584
+ if (logs.tailing) {
2585
+ this.detachLogStream();
2586
+ if (typeof this.transport.stopLogTail === "function") {
2587
+ this.transport.stopLogTail().catch(() => {});
2588
+ }
2589
+ this.dispatch({ type: "logs/tailing", tailing: false });
2590
+ this.dispatch({ type: "ui/status", text: "Log tailing stopped" });
2591
+ return;
2592
+ }
2593
+
2594
+ if (typeof this.transport.startLogTail !== "function") {
2595
+ this.dispatch({ type: "ui/status", text: "Log tailing is not supported by this transport" });
2596
+ return;
2597
+ }
2598
+
2599
+ this.logUnsubscribe = this.transport.startLogTail((entryOrBatch) => {
2600
+ const entries = Array.isArray(entryOrBatch) ? entryOrBatch : [entryOrBatch];
2601
+ if (entries.length > 0) {
2602
+ this.dispatch({ type: "logs/append", entries });
2603
+ }
2604
+ });
2605
+ this.dispatch({ type: "logs/tailing", tailing: true });
2606
+ this.dispatch({ type: "ui/status", text: "Log tailing started" });
2607
+ }
2608
+
2609
+ async sendPrompt() {
2610
+ const state = this.getState();
2611
+ const rawPrompt = state.ui.prompt;
2612
+ const promptCursor = state.ui.promptCursor;
2613
+ const promptAttachments = this.getPromptAttachments();
2614
+ const attachmentSessionId = promptAttachments[0]?.sessionId || null;
2615
+ const prompt = expandPromptAttachments(rawPrompt, promptAttachments);
2616
+ if (!prompt.trim()) return;
2617
+
2618
+ let sessionId = state.sessions.activeSessionId;
2619
+ if (attachmentSessionId) {
2620
+ sessionId = attachmentSessionId;
2621
+ if (state.sessions.activeSessionId !== attachmentSessionId) {
2622
+ await this.loadSession(attachmentSessionId);
2623
+ }
2624
+ }
2625
+ let activeSession = sessionId ? state.sessions.byId[sessionId] || null : null;
2626
+ if (sessionId && !activeSession) {
2627
+ activeSession = this.getState().sessions.byId[sessionId] || null;
2628
+ }
2629
+ if (!sessionId) {
2630
+ const created = await this.transport.createSession({});
2631
+ sessionId = created.sessionId;
2632
+ await this.refreshSessions();
2633
+ await this.loadSession(sessionId);
2634
+ activeSession = this.getState().sessions.byId[sessionId] || null;
2635
+ }
2636
+
2637
+ const answeringPendingQuestion = Boolean(activeSession?.pendingQuestion?.question);
2638
+
2639
+ const existing = this.getState().history.bySessionId.get(sessionId) || { chat: [], activity: [], lastSeq: 0 };
2640
+ this.dispatch({
2641
+ type: "history/set",
2642
+ sessionId,
2643
+ history: {
2644
+ ...existing,
2645
+ chat: [
2646
+ ...existing.chat,
2647
+ {
2648
+ id: `optimistic:${Date.now()}`,
2649
+ role: "user",
2650
+ text: prompt,
2651
+ time: "",
2652
+ createdAt: Date.now(),
2653
+ optimistic: true,
2654
+ },
2655
+ ],
2656
+ },
2657
+ });
2658
+
2659
+ this.setPrompt("", 0);
2660
+ this.dispatch({ type: "ui/status", text: "Sending..." });
2661
+ try {
2662
+ if (answeringPendingQuestion && typeof this.transport.sendAnswer === "function") {
2663
+ await this.transport.sendAnswer(sessionId, prompt);
2664
+ this.dispatch({
2665
+ type: "sessions/merged",
2666
+ session: {
2667
+ sessionId,
2668
+ pendingQuestion: null,
2669
+ },
2670
+ });
2671
+ this.scheduleSessionDetailSync(sessionId, 100);
2672
+ this.syncSessionEvents(sessionId).catch(() => {});
2673
+ this.scheduleSessionsRefresh(1000);
2674
+ this.dispatch({ type: "ui/status", text: "Answer sent" });
2675
+ return;
2676
+ }
2677
+
2678
+ await this.transport.sendMessage(sessionId, prompt, {
2679
+ enqueueOnly: Boolean(activeSession?.isSystem || activeSession?.status === "running"),
2680
+ });
2681
+ this.syncSessionEvents(sessionId).catch(() => {});
2682
+ this.scheduleSessionsRefresh(1000);
2683
+ this.dispatch({ type: "ui/status", text: "Prompt sent" });
2684
+ } catch (error) {
2685
+ this.setPrompt(rawPrompt, promptCursor);
2686
+ this.setPromptAttachments(promptAttachments);
2687
+ await this.ensureSessionHistory(sessionId, { force: true }).catch(() => {});
2688
+ let latestSession = null;
2689
+ if (typeof this.transport.getSession === "function") {
2690
+ latestSession = await this.transport.getSession(sessionId).catch(() => null);
2691
+ const patch = buildSessionMergePatch(
2692
+ this.getState().sessions.byId[sessionId] || null,
2693
+ latestSession,
2694
+ );
2695
+ if (patch) {
2696
+ this.dispatch({ type: "sessions/merged", session: patch });
2697
+ }
2698
+ }
2699
+ const resolvedSession = latestSession || this.getState().sessions.byId[sessionId] || activeSession || { sessionId };
2700
+ if (isTerminalOrchestrationStatus(resolvedSession?.orchestrationStatus) || isTerminalSendError(error)) {
2701
+ const currentHistory = this.getState().history.bySessionId.get(sessionId) || { chat: [], activity: [], lastSeq: 0 };
2702
+ this.dispatch({
2703
+ type: "history/set",
2704
+ sessionId,
2705
+ history: appendSyntheticChatMessage(
2706
+ currentHistory,
2707
+ buildTerminalSendRejectedMessage(resolvedSession, error),
2708
+ ),
2709
+ });
2710
+ }
2711
+ this.dispatch({
2712
+ type: "ui/status",
2713
+ text: error?.message || String(error),
2714
+ });
2715
+ }
2716
+ }
2717
+
2718
+ setPrompt(prompt, promptCursor = String(prompt || "").length) {
2719
+ const nextPrompt = String(prompt || "");
2720
+ const nextCursor = Math.max(0, Math.min(
2721
+ Number.isFinite(promptCursor) ? promptCursor : nextPrompt.length,
2722
+ nextPrompt.length,
2723
+ ));
2724
+ const currentUi = this.getState().ui;
2725
+ if (currentUi.prompt === nextPrompt && currentUi.promptCursor === nextCursor) {
2726
+ return;
2727
+ }
2728
+ this.dispatch({ type: "ui/prompt", prompt: nextPrompt, promptCursor: nextCursor });
2729
+ this.syncPromptReferenceBrowser();
2730
+ }
2731
+
2732
+ insertPromptText(text) {
2733
+ const state = this.getState().ui;
2734
+ const next = insertPromptTextAtCursor(state.prompt, state.promptCursor, text);
2735
+ this.setPrompt(next.prompt, next.cursor);
2736
+ }
2737
+
2738
+ appendPromptChar(ch) {
2739
+ this.insertPromptText(ch);
2740
+ }
2741
+
2742
+ deletePromptChar() {
2743
+ const state = this.getState().ui;
2744
+ const next = deletePromptCharBackward(state.prompt, state.promptCursor);
2745
+ this.setPrompt(next.prompt, next.cursor);
2746
+ }
2747
+
2748
+ deletePromptWordBackward() {
2749
+ const state = this.getState().ui;
2750
+ const next = deletePromptWordBackward(state.prompt, state.promptCursor);
2751
+ this.setPrompt(next.prompt, next.cursor);
2752
+ }
2753
+
2754
+ movePromptCursor(delta) {
2755
+ const state = this.getState().ui;
2756
+ this.setPrompt(state.prompt, clampPromptCursor(state.prompt, state.promptCursor + delta));
2757
+ }
2758
+
2759
+ movePromptCursorWord(direction) {
2760
+ const state = this.getState().ui;
2761
+ this.setPrompt(state.prompt, movePromptCursorByWord(state.prompt, state.promptCursor, direction));
2762
+ }
2763
+
2764
+ movePromptCursorVertical(direction) {
2765
+ const state = this.getState().ui;
2766
+ this.setPrompt(state.prompt, movePromptCursorVertically(state.prompt, state.promptCursor, direction));
2767
+ }
2768
+
2769
+ getCurrentLayout(overrides = {}) {
2770
+ const layoutState = this.getState().ui.layout || {};
2771
+ const uiState = this.getState().ui;
2772
+ const prompt = overrides.prompt ?? uiState.prompt;
2773
+ return computeLegacyLayout({
2774
+ width: overrides.width ?? layoutState.viewportWidth ?? 120,
2775
+ height: overrides.height ?? layoutState.viewportHeight ?? 40,
2776
+ },
2777
+ overrides.paneAdjust ?? layoutState.paneAdjust ?? 0,
2778
+ overrides.promptRows ?? uiState.promptRows ?? getPromptInputRows(prompt),
2779
+ overrides.sessionPaneAdjust ?? layoutState.sessionPaneAdjust ?? 0,
2780
+ overrides.fullscreenPane ?? uiState.fullscreenPane ?? null);
2781
+ }
2782
+
2783
+ getSessionListMaxRows(layout = this.getCurrentLayout()) {
2784
+ const paneHeight = layout.fullscreenPane === FOCUS_REGIONS.SESSIONS
2785
+ ? layout.bodyHeight
2786
+ : layout.sessionPaneHeight;
2787
+ return Math.max(3, paneHeight - 2);
2788
+ }
2789
+
2790
+ setViewport(viewport = {}) {
2791
+ const nextWidth = Math.max(40, Number(viewport.width) || 120);
2792
+ const nextHeight = Math.max(18, Number(viewport.height) || 40);
2793
+ const currentLayout = this.getState().ui.layout || {};
2794
+ if (currentLayout.viewportWidth !== nextWidth || currentLayout.viewportHeight !== nextHeight) {
2795
+ this.dispatch({
2796
+ type: "ui/viewport",
2797
+ width: nextWidth,
2798
+ height: nextHeight,
2799
+ });
2800
+ }
2801
+ const nextLayout = this.getCurrentLayout({ width: nextWidth, height: nextHeight });
2802
+ const currentFocus = this.getState().ui.focusRegion;
2803
+ const safeFocus = normalizeFocusRegion(currentFocus, nextLayout);
2804
+ if (safeFocus !== currentFocus) {
2805
+ this.setFocus(safeFocus);
2806
+ }
2807
+ }
2808
+
2809
+ setFocus(focusRegion) {
2810
+ this.dispatch({ type: "ui/focus", focusRegion: normalizeFocusRegion(focusRegion, this.getCurrentLayout()) });
2811
+ }
2812
+
2813
+ focusNext() {
2814
+ const layout = this.getCurrentLayout();
2815
+ const current = normalizeFocusRegion(this.getState().ui.focusRegion, layout);
2816
+ const order = getFocusOrderForLayout(layout);
2817
+ this.setFocus(cycleValue(order, current, 1));
2818
+ }
2819
+
2820
+ focusPrev() {
2821
+ const layout = this.getCurrentLayout();
2822
+ const current = normalizeFocusRegion(this.getState().ui.focusRegion, layout);
2823
+ const order = getFocusOrderForLayout(layout);
2824
+ this.setFocus(cycleValue(order, current, -1));
2825
+ }
2826
+
2827
+ focusLeft() {
2828
+ const layout = this.getCurrentLayout();
2829
+ const current = normalizeFocusRegion(this.getState().ui.focusRegion, layout);
2830
+ this.setFocus(getFocusLeftTarget(current, layout));
2831
+ }
2832
+
2833
+ focusRight() {
2834
+ const layout = this.getCurrentLayout();
2835
+ const current = normalizeFocusRegion(this.getState().ui.focusRegion, layout);
2836
+ this.setFocus(getFocusRightTarget(current, layout));
2837
+ }
2838
+
2839
+ adjustPaneSplit(delta) {
2840
+ const layoutState = this.getState().ui.layout || {};
2841
+ const viewportWidth = layoutState.viewportWidth ?? 120;
2842
+ const nextAdjust = Math.max(-viewportWidth, Math.min(viewportWidth, (layoutState.paneAdjust || 0) + delta));
2843
+ this.dispatch({
2844
+ type: "ui/paneAdjust",
2845
+ paneAdjust: nextAdjust,
2846
+ });
2847
+ const nextLayout = this.getCurrentLayout({ paneAdjust: nextAdjust });
2848
+ const currentFocus = this.getState().ui.focusRegion;
2849
+ const safeFocus = normalizeFocusRegion(currentFocus, nextLayout);
2850
+ if (safeFocus !== currentFocus) {
2851
+ this.setFocus(safeFocus);
2852
+ }
2853
+ }
2854
+
2855
+ adjustSessionPaneSplit(delta) {
2856
+ const layoutState = this.getState().ui.layout || {};
2857
+ const currentLayout = this.getCurrentLayout();
2858
+ const bodyHeight = currentLayout.bodyHeight ?? (layoutState.viewportHeight ?? 40);
2859
+ const nextAdjust = Math.max(-bodyHeight, Math.min(bodyHeight, (layoutState.sessionPaneAdjust || 0) + delta));
2860
+ this.dispatch({
2861
+ type: "ui/sessionPaneAdjust",
2862
+ sessionPaneAdjust: nextAdjust,
2863
+ });
2864
+ }
2865
+
2866
+ nextInspectorTab() {
2867
+ const inspectorTab = cycleValue(INSPECTOR_TABS, this.getState().ui.inspectorTab, 1);
2868
+ this.dispatch({
2869
+ type: "ui/inspectorTab",
2870
+ inspectorTab,
2871
+ });
2872
+ this.ensureInspectorData(inspectorTab).catch(() => {});
2873
+ }
2874
+
2875
+ prevInspectorTab() {
2876
+ const inspectorTab = cycleValue(INSPECTOR_TABS, this.getState().ui.inspectorTab, -1);
2877
+ this.dispatch({
2878
+ type: "ui/inspectorTab",
2879
+ inspectorTab,
2880
+ });
2881
+ this.ensureInspectorData(inspectorTab).catch(() => {});
2882
+ }
2883
+
2884
+ cycleInspectorTab() {
2885
+ this.nextInspectorTab();
2886
+ }
2887
+
2888
+ async selectInspectorTab(inspectorTab) {
2889
+ if (!INSPECTOR_TABS.includes(inspectorTab)) return;
2890
+ this.dispatch({
2891
+ type: "ui/inspectorTab",
2892
+ inspectorTab,
2893
+ });
2894
+ await this.ensureInspectorData(inspectorTab).catch(() => {});
2895
+ }
2896
+
2897
+ async moveSession(delta) {
2898
+ const state = this.getState();
2899
+ const flat = state.sessions.flat;
2900
+ if (flat.length === 0) return;
2901
+ const currentId = state.sessions.activeSessionId || flat[0].sessionId;
2902
+ const currentIndex = Math.max(0, flat.findIndex((entry) => entry.sessionId === currentId));
2903
+ const nextIndex = Math.max(0, Math.min(flat.length - 1, currentIndex + delta));
2904
+ const nextId = flat[nextIndex].sessionId;
2905
+ await this.loadSession(nextId);
2906
+ }
2907
+
2908
+ getSessionPageSize() {
2909
+ const layout = this.getCurrentLayout();
2910
+ const paneHeight = layout.fullscreenPane === FOCUS_REGIONS.SESSIONS
2911
+ ? layout.bodyHeight
2912
+ : layout.sessionPaneHeight;
2913
+ return Math.max(1, paneHeight - 3);
2914
+ }
2915
+
2916
+ async moveSessionPage(deltaPages) {
2917
+ const pageSize = this.getSessionPageSize();
2918
+ await this.moveSession(pageSize * deltaPages);
2919
+ }
2920
+
2921
+ inspectorUsesBottomScroll() {
2922
+ return INSPECTOR_BOTTOM_ANCHORED_TABS.has(this.getState().ui.inspectorTab);
2923
+ }
2924
+
2925
+ expandActiveSession() {
2926
+ const sessionId = this.getState().sessions.activeSessionId;
2927
+ if (!sessionId) return;
2928
+ this.dispatch({ type: "sessions/expand", sessionId });
2929
+ }
2930
+
2931
+ collapseActiveSession() {
2932
+ const sessionId = this.getState().sessions.activeSessionId;
2933
+ if (!sessionId) return;
2934
+ this.dispatch({ type: "sessions/collapse", sessionId });
2935
+ }
2936
+
2937
+ scrollPane(pane, delta) {
2938
+ const state = this.getState();
2939
+ const maxOffset = this.getPaneMaxScrollOffset(pane, state);
2940
+ const current = Math.max(0, Math.min(Number(state.ui.scroll?.[pane]) || 0, maxOffset));
2941
+ const nextOffset = Math.max(0, Math.min(current + delta, maxOffset));
2942
+ this.dispatch({ type: "ui/scroll", pane, offset: nextOffset });
2943
+ if (pane === "chat" && delta > 0) {
2944
+ this.maybeAutoExpandActiveHistory(nextOffset).catch(() => {});
2945
+ }
2946
+ }
2947
+
2948
+ scrollPaneTo(pane, offset) {
2949
+ const maxOffset = this.getPaneMaxScrollOffset(pane, this.getState());
2950
+ const nextOffset = Math.max(0, Math.min(Number(offset) || 0, maxOffset));
2951
+ this.dispatch({ type: "ui/scroll", pane, offset: nextOffset });
2952
+ if (pane === "chat" && nextOffset > 0) {
2953
+ this.maybeAutoExpandActiveHistory(nextOffset).catch(() => {});
2954
+ }
2955
+ }
2956
+
2957
+ getScrollablePaneForFocus() {
2958
+ const focus = this.getState().ui.focusRegion;
2959
+ if (focus === FOCUS_REGIONS.CHAT) return "chat";
2960
+ if (focus === FOCUS_REGIONS.ACTIVITY) return "activity";
2961
+ if (focus === FOCUS_REGIONS.INSPECTOR) {
2962
+ if (this.getState().ui.inspectorTab === "files") return "filePreview";
2963
+ return "inspector";
2964
+ }
2965
+ return null;
2966
+ }
2967
+
2968
+ scrollCurrentPane(delta) {
2969
+ const pane = this.getScrollablePaneForFocus();
2970
+ if (!pane) return;
2971
+ const inspectorUsesBottomScroll = pane === "inspector" && this.inspectorUsesBottomScroll();
2972
+ const usesTopScroll = pane === "inspector" || pane === "filePreview";
2973
+ this.scrollPane(pane, usesTopScroll && !inspectorUsesBottomScroll ? -delta : delta);
2974
+ }
2975
+
2976
+ scrollCurrentPaneToTop() {
2977
+ const pane = this.getScrollablePaneForFocus();
2978
+ if (!pane) return;
2979
+ const inspectorUsesBottomScroll = pane === "inspector" && this.inspectorUsesBottomScroll();
2980
+ if (pane === "chat" || pane === "activity" || inspectorUsesBottomScroll) {
2981
+ this.scrollPaneTo(pane, Number.MAX_SAFE_INTEGER);
2982
+ return;
2983
+ }
2984
+ this.scrollPaneTo(pane, 0);
2985
+ }
2986
+
2987
+ scrollCurrentPaneToBottom() {
2988
+ const pane = this.getScrollablePaneForFocus();
2989
+ if (!pane) return;
2990
+ const inspectorUsesBottomScroll = pane === "inspector" && this.inspectorUsesBottomScroll();
2991
+ if (pane === "chat" || pane === "activity" || inspectorUsesBottomScroll) {
2992
+ this.scrollPaneTo(pane, 0);
2993
+ return;
2994
+ }
2995
+ this.scrollPaneTo(pane, Number.MAX_SAFE_INTEGER);
2996
+ }
2997
+
2998
+ async expandActiveHistory() {
2999
+ const sessionId = this.getState().sessions.activeSessionId;
3000
+ if (!sessionId) return;
3001
+ await this.expandSessionHistory(sessionId);
3002
+ }
3003
+
3004
+ getActiveChatRenderMetrics(state = this.getState()) {
3005
+ const layout = this.getCurrentLayout();
3006
+ if (layout.leftHidden) {
3007
+ return {
3008
+ contentWidth: 20,
3009
+ contentHeight: 1,
3010
+ totalLines: 0,
3011
+ };
3012
+ }
3013
+
3014
+ const contentWidth = Math.max(20, layout.leftWidth - 4);
3015
+ const contentHeight = Math.max(1, layout.chatPaneHeight - 2);
3016
+ const lines = selectChatLines(state, contentWidth);
3017
+ const totalLines = countWrappedRenderableLines(lines, contentWidth);
3018
+ return {
3019
+ contentWidth,
3020
+ contentHeight,
3021
+ totalLines,
3022
+ };
3023
+ }
3024
+
3025
+ getActivityRenderMetrics(state = this.getState()) {
3026
+ const layout = this.getCurrentLayout();
3027
+ if (layout.rightHidden) {
3028
+ return {
3029
+ contentWidth: 20,
3030
+ contentHeight: 1,
3031
+ totalLines: 0,
3032
+ };
3033
+ }
3034
+
3035
+ const contentWidth = Math.max(20, layout.rightWidth - 4);
3036
+ const contentHeight = Math.max(1, layout.activityPaneHeight - 2);
3037
+ const activeSessionId = state.sessions.activeSessionId;
3038
+ const selectorState = {
3039
+ sessions: {
3040
+ activeSessionId,
3041
+ byId: activeSessionId
3042
+ ? { [activeSessionId]: state.sessions.byId[activeSessionId] || null }
3043
+ : {},
3044
+ },
3045
+ history: {
3046
+ bySessionId: activeSessionId
3047
+ ? new Map([[activeSessionId, state.history.bySessionId.get(activeSessionId) || null]])
3048
+ : new Map(),
3049
+ },
3050
+ };
3051
+ const activity = selectActivityPane(selectorState);
3052
+ return {
3053
+ contentWidth,
3054
+ contentHeight,
3055
+ totalLines: countWrappedRenderableLines(activity.lines, contentWidth),
3056
+ };
3057
+ }
3058
+
3059
+ getInspectorRenderMetrics(state = this.getState()) {
3060
+ const layout = this.getCurrentLayout();
3061
+ if (layout.rightHidden) {
3062
+ return {
3063
+ contentWidth: 20,
3064
+ contentHeight: 1,
3065
+ stickyLineCount: 0,
3066
+ totalLines: 0,
3067
+ };
3068
+ }
3069
+
3070
+ const contentWidth = Math.max(20, layout.rightWidth - 4);
3071
+ const activeSessionId = state.sessions.activeSessionId;
3072
+ const activeOrchestration = activeSessionId
3073
+ ? state.orchestration.bySessionId?.[activeSessionId] || null
3074
+ : null;
3075
+ const selectorState = {
3076
+ branding: state.branding,
3077
+ sessions: {
3078
+ activeSessionId,
3079
+ byId: state.sessions.byId,
3080
+ flat: state.sessions.flat,
3081
+ },
3082
+ history: {
3083
+ bySessionId: state.history.bySessionId,
3084
+ },
3085
+ orchestration: {
3086
+ bySessionId: activeSessionId && activeOrchestration
3087
+ ? { [activeSessionId]: activeOrchestration }
3088
+ : {},
3089
+ },
3090
+ logs: state.logs,
3091
+ ui: {
3092
+ inspectorTab: state.ui.inspectorTab,
3093
+ },
3094
+ executionHistory: state.executionHistory,
3095
+ };
3096
+ const inspector = selectInspector(selectorState, { width: contentWidth });
3097
+ const tabLine = inspector.tabs.map((tab) => ({
3098
+ text: tab === inspector.activeTab ? `[${tab}] ` : `${tab} `,
3099
+ color: tab === inspector.activeTab ? "magenta" : "gray",
3100
+ bold: tab === inspector.activeTab,
3101
+ }));
3102
+ const normalizedLines = (inspector.lines || []).map((line) => (typeof line === "string"
3103
+ ? { text: line, color: "white" }
3104
+ : line));
3105
+ const stickyLines = inspector.activeTab === "sequence"
3106
+ ? [
3107
+ tabLine,
3108
+ ...((inspector.stickyLines || []).map((line) => (typeof line === "string"
3109
+ ? { text: line, color: "white" }
3110
+ : line))),
3111
+ ]
3112
+ : [];
3113
+ const bodyLines = inspector.activeTab === "sequence"
3114
+ ? normalizedLines
3115
+ : [tabLine, ...normalizedLines];
3116
+ return {
3117
+ contentWidth,
3118
+ contentHeight: Math.max(1, layout.inspectorPaneHeight - 2),
3119
+ stickyLineCount: countWrappedRenderableLines(stickyLines, contentWidth),
3120
+ totalLines: countWrappedRenderableLines(bodyLines, contentWidth),
3121
+ };
3122
+ }
3123
+
3124
+ getFilePreviewRenderMetrics(state = this.getState()) {
3125
+ const layout = this.getCurrentLayout();
3126
+ if (layout.rightHidden && !state.files?.fullscreen) {
3127
+ return {
3128
+ contentWidth: 20,
3129
+ contentHeight: 1,
3130
+ totalLines: 0,
3131
+ };
3132
+ }
3133
+
3134
+ const fullscreen = state.ui.inspectorTab === "files" && Boolean(state.files?.fullscreen);
3135
+ const width = fullscreen ? layout.totalWidth : layout.rightWidth;
3136
+ const height = fullscreen ? Math.max(10, layout.bodyHeight) : layout.inspectorPaneHeight;
3137
+ const outerContentWidth = Math.max(20, width - 4);
3138
+ const previewWidth = Math.max(8, outerContentWidth - 4);
3139
+
3140
+ const activeSessionId = state.sessions.activeSessionId;
3141
+ const activeSession = activeSessionId ? state.sessions.byId[activeSessionId] || null : null;
3142
+ const selectorState = {
3143
+ sessions: {
3144
+ activeSessionId,
3145
+ byId: activeSessionId && activeSession
3146
+ ? { [activeSessionId]: activeSession }
3147
+ : {},
3148
+ flat: state.sessions.flat,
3149
+ },
3150
+ files: {
3151
+ bySessionId: state.files.bySessionId,
3152
+ fullscreen: Boolean(state.files.fullscreen),
3153
+ selectedArtifactId: state.files.selectedArtifactId,
3154
+ filter: state.files.filter,
3155
+ },
3156
+ ui: {
3157
+ scroll: {
3158
+ filePreview: state.ui.scroll.filePreview,
3159
+ },
3160
+ },
3161
+ };
3162
+ const filesView = selectFilesView(selectorState, {
3163
+ listWidth: previewWidth,
3164
+ previewWidth,
3165
+ });
3166
+ const availablePanelsHeight = Math.max(9, height - 4);
3167
+ const maxListPanelHeight = Math.max(5, Math.min(10, Math.floor(availablePanelsHeight * 0.35)));
3168
+ let listPanelHeight = Math.max(5, Math.min(maxListPanelHeight, (filesView.listBodyLines || []).length + 2));
3169
+ let previewPanelHeight = Math.max(5, availablePanelsHeight - listPanelHeight - 1);
3170
+ const minPreviewPanelHeight = 8;
3171
+ if (previewPanelHeight < minPreviewPanelHeight) {
3172
+ const deficit = minPreviewPanelHeight - previewPanelHeight;
3173
+ listPanelHeight = Math.max(5, listPanelHeight - deficit);
3174
+ previewPanelHeight = Math.max(5, availablePanelsHeight - listPanelHeight - 1);
3175
+ }
3176
+ return {
3177
+ contentWidth: previewWidth,
3178
+ contentHeight: Math.max(1, previewPanelHeight - 2),
3179
+ totalLines: countWrappedRenderableLines(filesView.previewLines, previewWidth),
3180
+ };
3181
+ }
3182
+
3183
+ getPaneMaxScrollOffset(pane, state = this.getState()) {
3184
+ if (!pane) return 0;
3185
+ if (pane === "chat") {
3186
+ const metrics = this.getActiveChatRenderMetrics(state);
3187
+ return Math.max(0, metrics.totalLines - metrics.contentHeight);
3188
+ }
3189
+ if (pane === "activity") {
3190
+ const metrics = this.getActivityRenderMetrics(state);
3191
+ return Math.max(0, metrics.totalLines - metrics.contentHeight);
3192
+ }
3193
+ if (pane === "inspector") {
3194
+ const metrics = this.getInspectorRenderMetrics(state);
3195
+ const stickyLineCount = Math.min(metrics.contentHeight, metrics.stickyLineCount || 0);
3196
+ const scrollableHeight = Math.max(0, metrics.contentHeight - stickyLineCount);
3197
+ return Math.max(0, metrics.totalLines - scrollableHeight);
3198
+ }
3199
+ if (pane === "filePreview") {
3200
+ const metrics = this.getFilePreviewRenderMetrics(state);
3201
+ return Math.max(0, metrics.totalLines - metrics.contentHeight);
3202
+ }
3203
+ return 0;
3204
+ }
3205
+
3206
+ async maybeAutoExpandActiveHistory(targetOffset) {
3207
+ const state = this.getState();
3208
+ const sessionId = state.sessions.activeSessionId;
3209
+ if (!sessionId) return;
3210
+ const currentHistory = state.history.bySessionId.get(sessionId);
3211
+ if (!currentHistory?.hasOlderEvents) return;
3212
+ if (this.sessionHistoryExpansionLoads.has(sessionId)) return;
3213
+ if (Number(currentHistory.loadedEventCount || 0) >= AUTO_HISTORY_EVENT_SOFT_CAP) {
3214
+ this.dispatch({
3215
+ type: "ui/status",
3216
+ text: "Reached automatic history limit. Press e to load more older CMS events.",
3217
+ });
3218
+ return;
3219
+ }
3220
+
3221
+ const { contentHeight, totalLines } = this.getActiveChatRenderMetrics(state);
3222
+ const maxOffset = Math.max(0, totalLines - contentHeight);
3223
+ if (targetOffset < maxOffset) return;
3224
+
3225
+ await this.expandSessionHistory(sessionId, {
3226
+ requestedScrollOffset: targetOffset,
3227
+ autoTriggered: true,
3228
+ });
3229
+ }
3230
+
3231
+ async expandSessionHistory(sessionId, options = {}) {
3232
+ if (!sessionId) return;
3233
+ if (this.sessionHistoryExpansionLoads.has(sessionId)) {
3234
+ return this.sessionHistoryExpansionLoads.get(sessionId);
3235
+ }
3236
+
3237
+ const stateBefore = this.getState();
3238
+ const currentHistory = stateBefore.history.bySessionId.get(sessionId);
3239
+ const currentLimit = Math.max(
3240
+ DEFAULT_HISTORY_EVENT_LIMIT,
3241
+ Number(currentHistory?.loadedEventLimit ?? DEFAULT_HISTORY_EVENT_LIMIT) || DEFAULT_HISTORY_EVENT_LIMIT,
3242
+ );
3243
+ const pageLimit = DEFAULT_HISTORY_EVENT_LIMIT;
3244
+ const oldestSeq = Array.isArray(currentHistory?.events) && currentHistory.events.length > 0
3245
+ ? Number(currentHistory.events[0]?.seq || 0)
3246
+ : 0;
3247
+
3248
+ if (!currentHistory?.hasOlderEvents || oldestSeq <= 1) {
3249
+ this.dispatch({
3250
+ type: "ui/status",
3251
+ text: "Already showing the oldest loaded history",
3252
+ });
3253
+ return;
3254
+ }
3255
+
3256
+ const preserveChatView = sessionId === stateBefore.sessions.activeSessionId;
3257
+ const previousScrollOffset = Number(options.requestedScrollOffset ?? stateBefore.ui.scroll?.chat ?? 0);
3258
+ const previousRenderedLines = preserveChatView
3259
+ ? this.getActiveChatRenderMetrics(stateBefore).totalLines
3260
+ : 0;
3261
+
3262
+ const loadPromise = (async () => {
3263
+ let history;
3264
+ if (typeof this.transport.getSessionEventsBefore === "function" && oldestSeq > 0) {
3265
+ const olderEvents = await this.transport.getSessionEventsBefore(sessionId, oldestSeq, pageLimit);
3266
+ if (!Array.isArray(olderEvents) || olderEvents.length === 0) {
3267
+ history = {
3268
+ ...(currentHistory || buildHistoryModel([], { requestedLimit: currentLimit })),
3269
+ hasOlderEvents: false,
3270
+ };
3271
+ } else {
3272
+ const olderHistory = buildHistoryModel(olderEvents, { requestedLimit: pageLimit });
3273
+ const combinedEvents = [
3274
+ ...(olderHistory.events || []),
3275
+ ...(currentHistory?.events || []),
3276
+ ];
3277
+ history = {
3278
+ chat: dedupeChatMessages([
3279
+ ...(olderHistory.chat || []),
3280
+ ...(currentHistory?.chat || []),
3281
+ ]),
3282
+ activity: [
3283
+ ...(olderHistory.activity || []),
3284
+ ...(currentHistory?.activity || []),
3285
+ ],
3286
+ events: combinedEvents,
3287
+ lastSeq: currentHistory?.lastSeq || currentHistory?.events?.[currentHistory?.events?.length - 1]?.seq || olderEvents[olderEvents.length - 1]?.seq || 0,
3288
+ loadedEventLimit: combinedEvents.length,
3289
+ loadedEventCount: combinedEvents.length,
3290
+ hasOlderEvents: olderEvents.length >= pageLimit && Number(olderEvents[0]?.seq || 0) > 1,
3291
+ };
3292
+ }
3293
+ } else {
3294
+ const nextLimit = getNextHistoryEventLimit(currentLimit);
3295
+ if (nextLimit <= currentLimit) {
3296
+ this.dispatch({
3297
+ type: "ui/status",
3298
+ text: currentHistory?.hasOlderEvents
3299
+ ? `Already showing a large recent history window (${currentLimit} events)`
3300
+ : "Already showing the oldest loaded history",
3301
+ });
3302
+ return;
3303
+ }
3304
+ const events = await this.transport.getSessionEvents(sessionId, undefined, nextLimit);
3305
+ history = {
3306
+ ...buildHistoryModel(events, { requestedLimit: nextLimit }),
3307
+ lastSeq: events[events.length - 1]?.seq || 0,
3308
+ };
3309
+ }
3310
+ this.dispatch({
3311
+ type: "history/set",
3312
+ sessionId,
3313
+ history,
3314
+ });
3315
+
3316
+ if (preserveChatView && previousScrollOffset > 0) {
3317
+ const nextState = this.getState();
3318
+ const nextRenderedLines = this.getActiveChatRenderMetrics(nextState).totalLines;
3319
+ const addedLines = Math.max(0, nextRenderedLines - previousRenderedLines);
3320
+ if (addedLines > 0) {
3321
+ this.dispatch({
3322
+ type: "ui/scroll",
3323
+ pane: "chat",
3324
+ offset: previousScrollOffset + addedLines,
3325
+ });
3326
+ }
3327
+ }
3328
+
3329
+ const stateLabel = history.hasOlderEvents
3330
+ ? options.autoTriggered
3331
+ ? `Loaded older history page from CMS (${history.loadedEventCount} events loaded)`
3332
+ : `Loaded older history page (${history.loadedEventCount} events loaded)`
3333
+ : `Loaded full available history (${history.loadedEventCount} events)`;
3334
+ this.dispatch({
3335
+ type: "ui/status",
3336
+ text: stateLabel,
3337
+ });
3338
+ })().finally(() => {
3339
+ this.sessionHistoryExpansionLoads.delete(sessionId);
3340
+ });
3341
+
3342
+ this.sessionHistoryExpansionLoads.set(sessionId, loadPromise);
3343
+ return loadPromise;
3344
+ }
3345
+
3346
+ async cancelActiveSession() {
3347
+ const sessionId = this.getState().sessions.activeSessionId;
3348
+ if (!sessionId) return;
3349
+ await this.transport.cancelSession(sessionId);
3350
+ this.dispatch({ type: "ui/status", text: `Cancelled ${sessionId.slice(0, 8)}` });
3351
+ await this.refreshSessions();
3352
+ }
3353
+
3354
+ async completeActiveSession(reason = "Completed by user") {
3355
+ const sessionId = this.getState().sessions.activeSessionId;
3356
+ if (!sessionId) return;
3357
+ if (typeof this.transport.completeSession !== "function") {
3358
+ this.dispatch({ type: "ui/status", text: "Session completion is not supported by this transport" });
3359
+ return;
3360
+ }
3361
+
3362
+ const activeSession = this.getState().sessions.byId[sessionId];
3363
+ if (activeSession?.status === "completed" && !activeSession?.cronActive && !activeSession?.cronInterval) {
3364
+ this.dispatch({ type: "ui/status", text: `${sessionId.slice(0, 8)} is already completed` });
3365
+ return;
3366
+ }
3367
+
3368
+ this.dispatch({
3369
+ type: "ui/status",
3370
+ text: `Completing ${sessionId.slice(0, 8)} (cascading to sub-agents)...`,
3371
+ });
3372
+
3373
+ try {
3374
+ await this.transport.completeSession(sessionId, reason);
3375
+ await this.refreshSessions();
3376
+ this.scheduleSessionDetailSync(sessionId, 100);
3377
+ this.scheduleSessionsRefresh(900);
3378
+ } catch (error) {
3379
+ await this.loadSession(sessionId).catch(() => {});
3380
+ this.dispatch({
3381
+ type: "ui/status",
3382
+ text: `Failed to send /done: ${error?.message || String(error)}`,
3383
+ });
3384
+ }
3385
+ }
3386
+
3387
+ async deleteActiveSession() {
3388
+ const sessionId = this.getState().sessions.activeSessionId;
3389
+ if (!sessionId) return;
3390
+ await this.transport.deleteSession(sessionId);
3391
+ this.dispatch({ type: "ui/status", text: `Deleted ${sessionId.slice(0, 8)}` });
3392
+ await this.refreshSessions();
3393
+ }
3394
+
3395
+ async handleCommand(command) {
3396
+ switch (command) {
3397
+ case UI_COMMANDS.REFRESH:
3398
+ await this.refreshSessions();
3399
+ return;
3400
+ case UI_COMMANDS.NEW_SESSION:
3401
+ await this.openNewSessionFlow();
3402
+ return;
3403
+ case UI_COMMANDS.OPEN_MODEL_PICKER:
3404
+ await this.openModelPicker();
3405
+ return;
3406
+ case UI_COMMANDS.OPEN_THEME_PICKER:
3407
+ this.openThemePicker();
3408
+ return;
3409
+ case UI_COMMANDS.OPEN_RENAME_SESSION:
3410
+ this.openRenameSessionModal();
3411
+ return;
3412
+ case UI_COMMANDS.OPEN_ARTIFACT_UPLOAD:
3413
+ this.openArtifactUploadModal();
3414
+ return;
3415
+ case UI_COMMANDS.CLOSE_MODAL:
3416
+ this.closeModal();
3417
+ return;
3418
+ case UI_COMMANDS.MODAL_PREV:
3419
+ this.moveModalSelection(-1);
3420
+ return;
3421
+ case UI_COMMANDS.MODAL_NEXT:
3422
+ this.moveModalSelection(1);
3423
+ return;
3424
+ case UI_COMMANDS.MODAL_PANE_PREV:
3425
+ this.moveModalPane(-1);
3426
+ return;
3427
+ case UI_COMMANDS.MODAL_PANE_NEXT:
3428
+ this.moveModalPane(1);
3429
+ return;
3430
+ case UI_COMMANDS.MODAL_CONFIRM:
3431
+ await this.confirmModal();
3432
+ return;
3433
+ case UI_COMMANDS.SEND_PROMPT:
3434
+ await this.sendPrompt();
3435
+ return;
3436
+ case UI_COMMANDS.FOCUS_NEXT:
3437
+ this.focusNext();
3438
+ return;
3439
+ case UI_COMMANDS.FOCUS_PREV:
3440
+ this.focusPrev();
3441
+ return;
3442
+ case UI_COMMANDS.FOCUS_LEFT:
3443
+ this.focusLeft();
3444
+ return;
3445
+ case UI_COMMANDS.FOCUS_RIGHT:
3446
+ this.focusRight();
3447
+ return;
3448
+ case UI_COMMANDS.FOCUS_PROMPT:
3449
+ this.setFocus(FOCUS_REGIONS.PROMPT);
3450
+ return;
3451
+ case UI_COMMANDS.FOCUS_SESSIONS:
3452
+ this.setFocus(FOCUS_REGIONS.SESSIONS);
3453
+ return;
3454
+ case UI_COMMANDS.MOVE_SESSION_UP:
3455
+ await this.moveSession(-1);
3456
+ return;
3457
+ case UI_COMMANDS.MOVE_SESSION_DOWN:
3458
+ await this.moveSession(1);
3459
+ return;
3460
+ case UI_COMMANDS.EXPAND_SESSION:
3461
+ this.expandActiveSession();
3462
+ return;
3463
+ case UI_COMMANDS.COLLAPSE_SESSION:
3464
+ this.collapseActiveSession();
3465
+ return;
3466
+ case UI_COMMANDS.NEXT_INSPECTOR_TAB:
3467
+ this.nextInspectorTab();
3468
+ return;
3469
+ case UI_COMMANDS.PREV_INSPECTOR_TAB:
3470
+ this.prevInspectorTab();
3471
+ return;
3472
+ case UI_COMMANDS.CYCLE_INSPECTOR_TAB:
3473
+ this.cycleInspectorTab();
3474
+ return;
3475
+ case UI_COMMANDS.GROW_LEFT_PANE:
3476
+ this.adjustPaneSplit(8);
3477
+ return;
3478
+ case UI_COMMANDS.GROW_RIGHT_PANE:
3479
+ this.adjustPaneSplit(-8);
3480
+ return;
3481
+ case UI_COMMANDS.GROW_SESSION_PANE:
3482
+ this.adjustSessionPaneSplit(2);
3483
+ return;
3484
+ case UI_COMMANDS.SHRINK_SESSION_PANE:
3485
+ this.adjustSessionPaneSplit(-2);
3486
+ return;
3487
+ case UI_COMMANDS.OPEN_ARTIFACT_PICKER:
3488
+ await this.openArtifactPicker();
3489
+ return;
3490
+ case UI_COMMANDS.TOGGLE_LOG_TAIL:
3491
+ this.toggleLogTail();
3492
+ return;
3493
+ case UI_COMMANDS.OPEN_LOG_FILTER:
3494
+ this.openLogFilter();
3495
+ return;
3496
+ case UI_COMMANDS.OPEN_FILES_FILTER:
3497
+ this.openFilesFilter();
3498
+ return;
3499
+ case UI_COMMANDS.MOVE_FILE_UP:
3500
+ await this.moveFileSelection(-1);
3501
+ return;
3502
+ case UI_COMMANDS.MOVE_FILE_DOWN:
3503
+ await this.moveFileSelection(1);
3504
+ return;
3505
+ case UI_COMMANDS.DOWNLOAD_SELECTED_FILE:
3506
+ await this.downloadSelectedArtifact();
3507
+ return;
3508
+ case UI_COMMANDS.OPEN_SELECTED_FILE:
3509
+ await this.openSelectedFileInDefaultApp();
3510
+ return;
3511
+ case UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN:
3512
+ this.toggleFilePreviewFullscreen();
3513
+ return;
3514
+ case UI_COMMANDS.TOGGLE_PANE_FULLSCREEN:
3515
+ this.toggleFocusedPaneFullscreen();
3516
+ return;
3517
+ case UI_COMMANDS.SCROLL_UP:
3518
+ this.scrollCurrentPane(1);
3519
+ return;
3520
+ case UI_COMMANDS.SCROLL_DOWN:
3521
+ this.scrollCurrentPane(-1);
3522
+ return;
3523
+ case UI_COMMANDS.PAGE_UP:
3524
+ if (this.getState().ui.focusRegion === FOCUS_REGIONS.SESSIONS) {
3525
+ await this.moveSessionPage(-1);
3526
+ return;
3527
+ }
3528
+ this.scrollCurrentPane(10);
3529
+ return;
3530
+ case UI_COMMANDS.PAGE_DOWN:
3531
+ if (this.getState().ui.focusRegion === FOCUS_REGIONS.SESSIONS) {
3532
+ await this.moveSessionPage(1);
3533
+ return;
3534
+ }
3535
+ this.scrollCurrentPane(-10);
3536
+ return;
3537
+ case UI_COMMANDS.EXPAND_HISTORY:
3538
+ await this.expandActiveHistory();
3539
+ return;
3540
+ case UI_COMMANDS.SCROLL_TOP:
3541
+ this.scrollCurrentPaneToTop();
3542
+ return;
3543
+ case UI_COMMANDS.SCROLL_BOTTOM:
3544
+ this.scrollCurrentPaneToBottom();
3545
+ return;
3546
+ case UI_COMMANDS.CANCEL_SESSION:
3547
+ await this.cancelActiveSession();
3548
+ return;
3549
+ case UI_COMMANDS.DONE_SESSION:
3550
+ await this.completeActiveSession();
3551
+ return;
3552
+ case UI_COMMANDS.DELETE_SESSION:
3553
+ await this.deleteActiveSession();
3554
+ return;
3555
+ case UI_COMMANDS.OPEN_HISTORY_FORMAT:
3556
+ this.openHistoryFormat();
3557
+ return;
3558
+ case UI_COMMANDS.REFRESH_EXECUTION_HISTORY: {
3559
+ const sessionId = this.getState().sessions.activeSessionId;
3560
+ if (sessionId) {
3561
+ await this.ensureExecutionHistory(sessionId, { force: true });
3562
+ }
3563
+ return;
3564
+ }
3565
+ case UI_COMMANDS.EXPORT_EXECUTION_HISTORY: {
3566
+ await this.exportExecutionHistory();
3567
+ return;
3568
+ }
3569
+ default:
3570
+ return;
3571
+ }
3572
+ }
3573
+
3574
+ async exportExecutionHistory() {
3575
+ const sessionId = this.getState().sessions.activeSessionId;
3576
+ if (!sessionId) {
3577
+ this.dispatch({ type: "ui/status", text: "No session selected." });
3578
+ return;
3579
+ }
3580
+ if (typeof this.transport.exportExecutionHistory !== "function") {
3581
+ this.dispatch({ type: "ui/status", text: "History export is not supported by this transport." });
3582
+ return;
3583
+ }
3584
+ this.dispatch({ type: "ui/status", text: "Exporting execution history..." });
3585
+ try {
3586
+ const result = await this.transport.exportExecutionHistory(sessionId);
3587
+ if (result?.filename) {
3588
+ await this.ensureFilesForSession(sessionId, { force: true }).catch(() => null);
3589
+ this.dispatch({
3590
+ type: "files/select",
3591
+ sessionId,
3592
+ filename: result.filename,
3593
+ });
3594
+ this.dispatch({
3595
+ type: "files/selectGlobal",
3596
+ artifactId: `${sessionId}/${result.filename}`,
3597
+ });
3598
+ await this.ensureFilePreview(sessionId, result.filename, { force: true }).catch(() => null);
3599
+ }
3600
+ this.dispatch({
3601
+ type: "ui/status",
3602
+ text: result?.filename
3603
+ ? `History saved as artifact ${result.filename}`
3604
+ : `History exported → ${result?.artifactLink || "artifact created"}`,
3605
+ });
3606
+ } catch (error) {
3607
+ this.dispatch({
3608
+ type: "ui/status",
3609
+ text: `Export failed: ${error?.message || String(error)}`,
3610
+ });
3611
+ }
3612
+ }
3613
+ }