pilotswarm 0.0.1 → 0.4.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 (149) hide show
  1. package/README.md +37 -1
  2. package/mcp/README.md +484 -0
  3. package/mcp/dist/bin/pilotswarm-mcp.d.ts +3 -0
  4. package/mcp/dist/bin/pilotswarm-mcp.d.ts.map +1 -0
  5. package/mcp/dist/bin/pilotswarm-mcp.js +367 -0
  6. package/mcp/dist/bin/pilotswarm-mcp.js.map +1 -0
  7. package/mcp/dist/src/auth.d.ts +7 -0
  8. package/mcp/dist/src/auth.d.ts.map +1 -0
  9. package/mcp/dist/src/auth.js +99 -0
  10. package/mcp/dist/src/auth.js.map +1 -0
  11. package/mcp/dist/src/context.d.ts +48 -0
  12. package/mcp/dist/src/context.d.ts.map +1 -0
  13. package/mcp/dist/src/context.js +83 -0
  14. package/mcp/dist/src/context.js.map +1 -0
  15. package/mcp/dist/src/index.d.ts +4 -0
  16. package/mcp/dist/src/index.d.ts.map +1 -0
  17. package/mcp/dist/src/index.js +3 -0
  18. package/mcp/dist/src/index.js.map +1 -0
  19. package/mcp/dist/src/prompts/skills.d.ts +4 -0
  20. package/mcp/dist/src/prompts/skills.d.ts.map +1 -0
  21. package/mcp/dist/src/prompts/skills.js +11 -0
  22. package/mcp/dist/src/prompts/skills.js.map +1 -0
  23. package/mcp/dist/src/resources/agents.d.ts +4 -0
  24. package/mcp/dist/src/resources/agents.d.ts.map +1 -0
  25. package/mcp/dist/src/resources/agents.js +64 -0
  26. package/mcp/dist/src/resources/agents.js.map +1 -0
  27. package/mcp/dist/src/resources/facts.d.ts +4 -0
  28. package/mcp/dist/src/resources/facts.d.ts.map +1 -0
  29. package/mcp/dist/src/resources/facts.js +125 -0
  30. package/mcp/dist/src/resources/facts.js.map +1 -0
  31. package/mcp/dist/src/resources/models.d.ts +4 -0
  32. package/mcp/dist/src/resources/models.d.ts.map +1 -0
  33. package/mcp/dist/src/resources/models.js +43 -0
  34. package/mcp/dist/src/resources/models.js.map +1 -0
  35. package/mcp/dist/src/resources/sessions.d.ts +4 -0
  36. package/mcp/dist/src/resources/sessions.d.ts.map +1 -0
  37. package/mcp/dist/src/resources/sessions.js +190 -0
  38. package/mcp/dist/src/resources/sessions.js.map +1 -0
  39. package/mcp/dist/src/resources/subscriptions.d.ts +9 -0
  40. package/mcp/dist/src/resources/subscriptions.d.ts.map +1 -0
  41. package/mcp/dist/src/resources/subscriptions.js +157 -0
  42. package/mcp/dist/src/resources/subscriptions.js.map +1 -0
  43. package/mcp/dist/src/server.d.ts +4 -0
  44. package/mcp/dist/src/server.d.ts.map +1 -0
  45. package/mcp/dist/src/server.js +59 -0
  46. package/mcp/dist/src/server.js.map +1 -0
  47. package/mcp/dist/src/tools/agents.d.ts +4 -0
  48. package/mcp/dist/src/tools/agents.d.ts.map +1 -0
  49. package/mcp/dist/src/tools/agents.js +317 -0
  50. package/mcp/dist/src/tools/agents.js.map +1 -0
  51. package/mcp/dist/src/tools/facts.d.ts +4 -0
  52. package/mcp/dist/src/tools/facts.d.ts.map +1 -0
  53. package/mcp/dist/src/tools/facts.js +151 -0
  54. package/mcp/dist/src/tools/facts.js.map +1 -0
  55. package/mcp/dist/src/tools/models.d.ts +4 -0
  56. package/mcp/dist/src/tools/models.d.ts.map +1 -0
  57. package/mcp/dist/src/tools/models.js +256 -0
  58. package/mcp/dist/src/tools/models.js.map +1 -0
  59. package/mcp/dist/src/tools/sessions.d.ts +4 -0
  60. package/mcp/dist/src/tools/sessions.d.ts.map +1 -0
  61. package/mcp/dist/src/tools/sessions.js +606 -0
  62. package/mcp/dist/src/tools/sessions.js.map +1 -0
  63. package/mcp/dist/src/util/command.d.ts +52 -0
  64. package/mcp/dist/src/util/command.d.ts.map +1 -0
  65. package/mcp/dist/src/util/command.js +78 -0
  66. package/mcp/dist/src/util/command.js.map +1 -0
  67. package/package.json +81 -6
  68. package/tui/README.md +35 -0
  69. package/tui/bin/tui.js +30 -0
  70. package/tui/plugins/.mcp.json +7 -0
  71. package/tui/plugins/plugin.json +13 -0
  72. package/tui/plugins/session-policy.json +8 -0
  73. package/tui/src/app.js +850 -0
  74. package/tui/src/auth/cli.js +111 -0
  75. package/tui/src/auth/entra-auth.js +218 -0
  76. package/tui/src/bootstrap-env.js +176 -0
  77. package/tui/src/embedded-workers.js +79 -0
  78. package/tui/src/http-transport-host.js +106 -0
  79. package/tui/src/index.js +340 -0
  80. package/tui/src/node-sdk-transport.js +1793 -0
  81. package/tui/src/platform.js +984 -0
  82. package/tui/src/plugin-config.js +239 -0
  83. package/tui/src/portal.js +7 -0
  84. package/tui/src/version.js +7 -0
  85. package/tui/tui-splash.txt +11 -0
  86. package/ui/core/README.md +6 -0
  87. package/ui/core/src/commands.js +93 -0
  88. package/ui/core/src/context-usage.js +212 -0
  89. package/ui/core/src/controller.js +6104 -0
  90. package/ui/core/src/formatting.js +1036 -0
  91. package/ui/core/src/history.js +932 -0
  92. package/ui/core/src/index.js +13 -0
  93. package/ui/core/src/layout.js +332 -0
  94. package/ui/core/src/reducer.js +1935 -0
  95. package/ui/core/src/selectors.js +5409 -0
  96. package/ui/core/src/session-errors.js +14 -0
  97. package/ui/core/src/session-tree.js +151 -0
  98. package/ui/core/src/state.js +248 -0
  99. package/ui/core/src/store.js +23 -0
  100. package/ui/core/src/system-titles.js +24 -0
  101. package/ui/core/src/themes/catppuccin-mocha.js +56 -0
  102. package/ui/core/src/themes/cobalt2.js +56 -0
  103. package/ui/core/src/themes/dark-high-contrast.js +56 -0
  104. package/ui/core/src/themes/daylight.js +62 -0
  105. package/ui/core/src/themes/dracula.js +56 -0
  106. package/ui/core/src/themes/github-dark.js +56 -0
  107. package/ui/core/src/themes/github-light.js +59 -0
  108. package/ui/core/src/themes/gruvbox-dark.js +56 -0
  109. package/ui/core/src/themes/hacker-x-matrix.js +56 -0
  110. package/ui/core/src/themes/hacker-x-orion-prime.js +56 -0
  111. package/ui/core/src/themes/helpers.js +79 -0
  112. package/ui/core/src/themes/high-contrast-mono.js +59 -0
  113. package/ui/core/src/themes/index.js +52 -0
  114. package/ui/core/src/themes/light-high-contrast.js +62 -0
  115. package/ui/core/src/themes/noctis-obscuro.js +56 -0
  116. package/ui/core/src/themes/noctis.js +56 -0
  117. package/ui/core/src/themes/paper-ink.js +62 -0
  118. package/ui/core/src/themes/solarized-ops.js +59 -0
  119. package/ui/core/src/themes/terminal-green.js +59 -0
  120. package/ui/core/src/themes/tokyo-night.js +56 -0
  121. package/ui/react/README.md +5 -0
  122. package/ui/react/src/chat-status.js +39 -0
  123. package/ui/react/src/components.js +1989 -0
  124. package/ui/react/src/index.js +4 -0
  125. package/ui/react/src/platform.js +15 -0
  126. package/ui/react/src/use-controller-state.js +38 -0
  127. package/ui/react/src/web-app.js +4390 -0
  128. package/web/README.md +198 -0
  129. package/web/api/router.js +196 -0
  130. package/web/api/ws.js +152 -0
  131. package/web/auth/authz/engine.js +204 -0
  132. package/web/auth/config.js +115 -0
  133. package/web/auth/index.js +175 -0
  134. package/web/auth/normalize/entra.js +22 -0
  135. package/web/auth/providers/entra.js +76 -0
  136. package/web/auth/providers/none.js +24 -0
  137. package/web/auth.js +10 -0
  138. package/web/bin/serve.js +53 -0
  139. package/web/config.js +20 -0
  140. package/web/dist/app.js +469 -0
  141. package/web/dist/assets/index-CBgQQk-j.css +1 -0
  142. package/web/dist/assets/index-DMefB7Wb.js +24 -0
  143. package/web/dist/assets/msal-CytV9RFv.js +7 -0
  144. package/web/dist/assets/pilotswarm-DyBlqVTY.js +87 -0
  145. package/web/dist/assets/react-BZwsW8pe.js +1 -0
  146. package/web/dist/index.html +16 -0
  147. package/web/runtime.js +454 -0
  148. package/web/server.js +276 -0
  149. package/index.js +0 -1
@@ -0,0 +1,1935 @@
1
+ import { buildSessionTree } from "./session-tree.js";
2
+ import { FOCUS_REGIONS } from "./commands.js";
3
+ import { DEFAULT_HISTORY_EVENT_LIMIT, dedupeChatMessages } from "./history.js";
4
+ import { getPromptInputRows } from "./layout.js";
5
+ import { selectSessionRows } from "./selectors.js";
6
+ import { systemSessionSortOrder } from "./system-titles.js";
7
+ import {
8
+ normalizeArtifactEntries,
9
+ normalizeSessionOwnerFilter,
10
+ normalizeStoredActiveSessionId,
11
+ normalizeStoredCollapsedSessionIds,
12
+ normalizeStoredLayoutAdjustments,
13
+ normalizeStoredPinnedSessionIds,
14
+ } from "./state.js";
15
+
16
+ function cloneHistoryMap(historyMap) {
17
+ return new Map(historyMap);
18
+ }
19
+
20
+ function cloneCollapsedIds(collapsedIds) {
21
+ return new Set(collapsedIds);
22
+ }
23
+
24
+ function clonePinnedIds(pinnedIds) {
25
+ return Array.isArray(pinnedIds) ? [...pinnedIds] : [];
26
+ }
27
+
28
+ function cloneSelectedIds(selectedIds) {
29
+ return Array.isArray(selectedIds) ? [...selectedIds] : [];
30
+ }
31
+
32
+ const ROW_VISUAL_STATUS_HOLD_MS = 5000;
33
+
34
+ function pruneIdList(ids, byId) {
35
+ const out = [];
36
+ const seen = new Set();
37
+ for (const id of ids || []) {
38
+ if (!id || seen.has(id)) continue;
39
+ if (!byId[id]) continue;
40
+ seen.add(id);
41
+ out.push(id);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function isPinnableSession(session) {
47
+ return Boolean(
48
+ session
49
+ && !session.isSystem
50
+ && (session.isGroup || (!session.parentSessionId && !session.groupId)),
51
+ );
52
+ }
53
+
54
+ function prunePinnedIds(ids, byId) {
55
+ return pruneIdList(ids, byId).filter((sessionId) => isPinnableSession(byId[sessionId]));
56
+ }
57
+
58
+ function collectCollapsibleSessionIds(sessions = []) {
59
+ const byId = new Set((sessions || []).map((session) => session?.sessionId).filter(Boolean));
60
+ const collapsibleIds = new Set();
61
+ for (const session of sessions || []) {
62
+ if (!session?.sessionId) continue;
63
+ if (session.isGroup) collapsibleIds.add(session.sessionId);
64
+ const parentSessionId = session.parentSessionId;
65
+ if (parentSessionId && byId.has(parentSessionId)) {
66
+ collapsibleIds.add(parentSessionId);
67
+ }
68
+ }
69
+ return collapsibleIds;
70
+ }
71
+
72
+ function pruneCollapsedIds(collapsedIds, byId) {
73
+ const collapsibleIds = collectCollapsibleSessionIds(Object.values(byId || {}));
74
+ const out = new Set();
75
+ for (const id of collapsedIds || []) {
76
+ if (collapsibleIds.has(id)) out.add(id);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function collectSessionAncestorIds(sessionId, byId) {
82
+ const ancestors = [];
83
+ const seen = new Set([sessionId]);
84
+ let session = byId?.[sessionId] || null;
85
+
86
+ while (session) {
87
+ let parentId = null;
88
+ if (session.parentSessionId && byId?.[session.parentSessionId]) {
89
+ parentId = session.parentSessionId;
90
+ } else if (!session.isGroup && session.groupId && byId?.[`group:${session.groupId}`]) {
91
+ parentId = `group:${session.groupId}`;
92
+ }
93
+
94
+ if (!parentId || seen.has(parentId)) break;
95
+ ancestors.push(parentId);
96
+ seen.add(parentId);
97
+ session = byId[parentId] || null;
98
+ }
99
+
100
+ return ancestors;
101
+ }
102
+
103
+ function reconcileCollapsedIdsForActiveSession(collapsedIds, byId, activeSessionId) {
104
+ const next = pruneCollapsedIds(collapsedIds, byId);
105
+ if (!activeSessionId || !byId?.[activeSessionId]) return next;
106
+ for (const ancestorId of collectSessionAncestorIds(activeSessionId, byId)) {
107
+ next.delete(ancestorId);
108
+ }
109
+ return next;
110
+ }
111
+
112
+ function setsEqual(left, right) {
113
+ if (left === right) return true;
114
+ if (!(left instanceof Set) || !(right instanceof Set)) return false;
115
+ if (left.size !== right.size) return false;
116
+ for (const value of left) {
117
+ if (!right.has(value)) return false;
118
+ }
119
+ return true;
120
+ }
121
+
122
+ function cloneOrderById(orderById) {
123
+ return { ...(orderById || {}) };
124
+ }
125
+
126
+ function cloneFilesBySessionId(bySessionId) {
127
+ return { ...(bySessionId || {}) };
128
+ }
129
+
130
+ function cloneOutboxBySessionId(bySessionId) {
131
+ return { ...(bySessionId || {}) };
132
+ }
133
+
134
+ function cloneOrchestrationBySessionId(bySessionId) {
135
+ return { ...(bySessionId || {}) };
136
+ }
137
+
138
+ function normalizeFilesFilter(filter) {
139
+ return {
140
+ scope: filter?.scope === "allSessions" ? "allSessions" : "selectedSession",
141
+ query: typeof filter?.query === "string" ? filter.query : "",
142
+ };
143
+ }
144
+
145
+ function normalizeLogEntries(entries) {
146
+ const list = Array.isArray(entries) ? entries.filter(Boolean) : [];
147
+ return list.slice(-1000);
148
+ }
149
+
150
+ function normalizeFullscreenPane(fullscreenPane) {
151
+ return [
152
+ FOCUS_REGIONS.SESSIONS,
153
+ FOCUS_REGIONS.CHAT,
154
+ FOCUS_REGIONS.INSPECTOR,
155
+ FOCUS_REGIONS.ACTIVITY,
156
+ ].includes(fullscreenPane)
157
+ ? fullscreenPane
158
+ : null;
159
+ }
160
+
161
+ function clampHistoryItems(items, maxItems) {
162
+ const list = Array.isArray(items) ? items.filter(Boolean) : [];
163
+ const safeMax = Math.max(DEFAULT_HISTORY_EVENT_LIMIT, Number(maxItems) || DEFAULT_HISTORY_EVENT_LIMIT);
164
+ return list.length > safeMax ? list.slice(-safeMax) : list;
165
+ }
166
+
167
+ function areStructuredValuesEqual(left, right) {
168
+ if (Object.is(left, right)) return true;
169
+ if (Array.isArray(left) || Array.isArray(right)) {
170
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
171
+ for (let index = 0; index < left.length; index += 1) {
172
+ if (!areStructuredValuesEqual(left[index], right[index])) return false;
173
+ }
174
+ return true;
175
+ }
176
+ if (!left || !right || typeof left !== "object" || typeof right !== "object") {
177
+ return false;
178
+ }
179
+ const leftKeys = Object.keys(left);
180
+ const rightKeys = Object.keys(right);
181
+ if (leftKeys.length !== rightKeys.length) return false;
182
+ for (const key of leftKeys) {
183
+ if (!Object.prototype.hasOwnProperty.call(right, key)) return false;
184
+ if (!areStructuredValuesEqual(left[key], right[key])) return false;
185
+ }
186
+ return true;
187
+ }
188
+
189
+ function mergeDefinedSessionFields(previousSession = {}, nextSession = {}) {
190
+ let merged = previousSession || {};
191
+ for (const [key, value] of Object.entries(nextSession || {})) {
192
+ if (value === undefined) continue;
193
+ if (key === "pendingQuestion" && isAnsweredPendingQuestion(previousSession, value)) {
194
+ if (merged === previousSession) {
195
+ merged = { ...(previousSession || {}) };
196
+ }
197
+ merged.pendingQuestion = null;
198
+ continue;
199
+ }
200
+ if (areStructuredValuesEqual(previousSession?.[key], value)) continue;
201
+ if (merged === previousSession) {
202
+ merged = { ...(previousSession || {}) };
203
+ }
204
+ merged[key] = value;
205
+ }
206
+ return merged;
207
+ }
208
+
209
+ function computeRawSessionVisualStatus(session) {
210
+ if (!session) return "unknown";
211
+ const status = session.status || "unknown";
212
+ if (
213
+ session.cronActive === true
214
+ && (status === "waiting" || status === "idle" || status === "unknown")
215
+ ) {
216
+ return "cron_waiting";
217
+ }
218
+ return status;
219
+ }
220
+
221
+ function mergeSessionRowVisualStatus(previousSession, nextSession, nowMs) {
222
+ if (!nextSession?.sessionId || nextSession.isGroup) return nextSession;
223
+
224
+ const desiredStatus = computeRawSessionVisualStatus(nextSession);
225
+ const previousDisplayStatus = previousSession?.rowVisualStatus
226
+ || computeRawSessionVisualStatus(previousSession || nextSession);
227
+
228
+ if (!previousSession) {
229
+ if (nextSession.rowVisualStatus === desiredStatus
230
+ && nextSession.rowVisualStatusCandidate == null
231
+ && nextSession.rowVisualStatusCandidateSince == null) {
232
+ return nextSession;
233
+ }
234
+ return {
235
+ ...nextSession,
236
+ rowVisualStatus: desiredStatus,
237
+ rowVisualStatusCandidate: undefined,
238
+ rowVisualStatusCandidateSince: undefined,
239
+ };
240
+ }
241
+
242
+ if (desiredStatus === previousDisplayStatus) {
243
+ if (nextSession.rowVisualStatus === desiredStatus
244
+ && nextSession.rowVisualStatusCandidate == null
245
+ && nextSession.rowVisualStatusCandidateSince == null) {
246
+ return nextSession;
247
+ }
248
+ return {
249
+ ...nextSession,
250
+ rowVisualStatus: desiredStatus,
251
+ rowVisualStatusCandidate: undefined,
252
+ rowVisualStatusCandidateSince: undefined,
253
+ };
254
+ }
255
+
256
+ const previousCandidate = previousSession.rowVisualStatusCandidate;
257
+ const candidateSince = previousCandidate === desiredStatus
258
+ ? Number(previousSession.rowVisualStatusCandidateSince) || nowMs
259
+ : nowMs;
260
+
261
+ if (nowMs - candidateSince >= ROW_VISUAL_STATUS_HOLD_MS) {
262
+ return {
263
+ ...nextSession,
264
+ rowVisualStatus: desiredStatus,
265
+ rowVisualStatusCandidate: undefined,
266
+ rowVisualStatusCandidateSince: undefined,
267
+ };
268
+ }
269
+
270
+ if (nextSession.rowVisualStatus === previousDisplayStatus
271
+ && nextSession.rowVisualStatusCandidate === desiredStatus
272
+ && Number(nextSession.rowVisualStatusCandidateSince) === candidateSince) {
273
+ return nextSession;
274
+ }
275
+
276
+ return {
277
+ ...nextSession,
278
+ rowVisualStatus: previousDisplayStatus,
279
+ rowVisualStatusCandidate: desiredStatus,
280
+ rowVisualStatusCandidateSince: candidateSince,
281
+ };
282
+ }
283
+
284
+ function normalizedPendingQuestionText(pendingQuestion) {
285
+ return String(pendingQuestion?.question || "").trim();
286
+ }
287
+
288
+ function isAnsweredPendingQuestion(previousSession, pendingQuestion) {
289
+ const answeredQuestion = normalizedPendingQuestionText(previousSession?.answeredPendingQuestion);
290
+ const incomingQuestion = normalizedPendingQuestionText(pendingQuestion);
291
+ return Boolean(answeredQuestion && incomingQuestion && answeredQuestion === incomingQuestion);
292
+ }
293
+
294
+ function pickDefaultActiveSessionId(sessions = []) {
295
+ const mainSystem = (sessions || []).find((session) => session?.sessionId && session.isSystem && systemSessionSortOrder(session) === 0);
296
+ if (mainSystem?.sessionId) return mainSystem.sessionId;
297
+ const firstSession = (sessions || []).find((session) => session?.sessionId);
298
+ return firstSession?.sessionId || null;
299
+ }
300
+
301
+ function collectDefaultCollapsedSessionIds(sessions = []) {
302
+ const byId = new Set((sessions || []).map((session) => session?.sessionId).filter(Boolean));
303
+ const collapsedIds = new Set();
304
+ for (const session of sessions || []) {
305
+ if (!session?.sessionId) continue;
306
+ if (session.isGroup) collapsedIds.add(session.sessionId);
307
+ const parentSessionId = session.parentSessionId;
308
+ if (parentSessionId && byId.has(parentSessionId)) {
309
+ collapsedIds.add(parentSessionId);
310
+ }
311
+ }
312
+ return collapsedIds;
313
+ }
314
+
315
+ function hasSessionVisibilityFilter(sessions = {}) {
316
+ const query = String(sessions?.filterQuery || "").trim();
317
+ const ownerFilter = normalizeSessionOwnerFilter(sessions?.ownerFilter);
318
+ return Boolean(query) || ownerFilter.all !== true;
319
+ }
320
+
321
+ function resolveVisibleActiveSessionId(state, fallbackSessions = []) {
322
+ const visibleRows = selectSessionRows(state);
323
+ const currentSessionId = state.sessions?.activeSessionId || null;
324
+ if (currentSessionId && visibleRows.some((row) => row.sessionId === currentSessionId)) {
325
+ return currentSessionId;
326
+ }
327
+ if (currentSessionId && state.sessions?.byId?.[currentSessionId] && !hasSessionVisibilityFilter(state.sessions)) {
328
+ return currentSessionId;
329
+ }
330
+ if (hasSessionVisibilityFilter(state.sessions)) {
331
+ return visibleRows[0]?.sessionId || null;
332
+ }
333
+
334
+ const visibleSessions = visibleRows.length > 0
335
+ ? visibleRows
336
+ .map((row) => state.sessions?.byId?.[row.sessionId] || null)
337
+ .filter(Boolean)
338
+ : fallbackSessions;
339
+
340
+ return pickDefaultActiveSessionId(visibleSessions);
341
+ }
342
+
343
+ function updateUiForSessionSelection(state, nextActiveSessionId) {
344
+ if (nextActiveSessionId === state.sessions.activeSessionId) {
345
+ return state.ui;
346
+ }
347
+ return {
348
+ ...state.ui,
349
+ scroll: {
350
+ ...state.ui.scroll,
351
+ chat: 0,
352
+ inspector: 0,
353
+ activity: 0,
354
+ },
355
+ followBottom: {
356
+ ...(state.ui.followBottom || {}),
357
+ inspector: true,
358
+ activity: true,
359
+ },
360
+ };
361
+ }
362
+
363
+ function applyVisibleSessionSelection(state, nextSessions) {
364
+ const nextState = {
365
+ ...state,
366
+ sessions: nextSessions,
367
+ };
368
+ const nextActiveSessionId = resolveVisibleActiveSessionId(nextState, Object.values(nextSessions.byId || {}));
369
+ const nextCollapsedIds = reconcileCollapsedIdsForActiveSession(nextSessions.collapsedIds, nextSessions.byId, nextActiveSessionId);
370
+ const collapsedChanged = !setsEqual(nextCollapsedIds, nextSessions.collapsedIds);
371
+ const reconciledSessions = collapsedChanged
372
+ ? {
373
+ ...nextSessions,
374
+ collapsedIds: nextCollapsedIds,
375
+ flat: buildSessionTree(Object.values(nextSessions.byId || {}), nextCollapsedIds, nextSessions.orderById, nextSessions.pinnedIds),
376
+ }
377
+ : nextSessions;
378
+ return {
379
+ sessions: {
380
+ ...reconciledSessions,
381
+ activeSessionId: nextActiveSessionId,
382
+ },
383
+ ui: updateUiForSessionSelection(state, nextActiveSessionId),
384
+ };
385
+ }
386
+
387
+ function assignStableSessionOrder(previousOrderById = {}, nextOrderOrdinal = 0, sessions = []) {
388
+ const orderById = cloneOrderById(previousOrderById);
389
+ let orderOrdinal = Number.isFinite(nextOrderOrdinal) ? nextOrderOrdinal : 0;
390
+
391
+ for (const session of sessions || []) {
392
+ const sessionId = session?.sessionId;
393
+ if (!sessionId) continue;
394
+ if (typeof orderById[sessionId] === "number") continue;
395
+ orderById[sessionId] = orderOrdinal;
396
+ orderOrdinal += 1;
397
+ }
398
+
399
+ return {
400
+ orderById,
401
+ nextOrderOrdinal: orderOrdinal,
402
+ };
403
+ }
404
+
405
+ function assignInitialSessionOrder(sessions = [], pinnedIds = []) {
406
+ const expandedFlat = buildSessionTree(sessions, new Set(), {}, pinnedIds);
407
+ const orderById = {};
408
+ let orderOrdinal = 0;
409
+
410
+ for (const entry of expandedFlat) {
411
+ const sessionId = entry?.sessionId;
412
+ if (!sessionId || typeof orderById[sessionId] === "number") continue;
413
+ orderById[sessionId] = orderOrdinal;
414
+ orderOrdinal += 1;
415
+ }
416
+
417
+ for (const session of sessions || []) {
418
+ const sessionId = session?.sessionId;
419
+ if (!sessionId || typeof orderById[sessionId] === "number") continue;
420
+ orderById[sessionId] = orderOrdinal;
421
+ orderOrdinal += 1;
422
+ }
423
+
424
+ return {
425
+ orderById,
426
+ nextOrderOrdinal: orderOrdinal,
427
+ };
428
+ }
429
+
430
+ function clampPromptCursor(prompt, cursor, fallback = null) {
431
+ const text = String(prompt || "");
432
+ const preferred = Number.isFinite(cursor)
433
+ ? cursor
434
+ : (Number.isFinite(fallback) ? fallback : text.length);
435
+ return Math.max(0, Math.min(preferred, text.length));
436
+ }
437
+
438
+ function normalizePromptAttachments(prompt, attachments) {
439
+ const safePrompt = String(prompt || "");
440
+ const list = Array.isArray(attachments) ? attachments.filter(Boolean) : [];
441
+ return list.filter((attachment) => {
442
+ const token = String(attachment?.token || "").trim();
443
+ return token && safePrompt.includes(token);
444
+ });
445
+ }
446
+
447
+ export function appReducer(state, action) {
448
+ switch (action.type) {
449
+ case "connection/ready":
450
+ return {
451
+ ...state,
452
+ connection: {
453
+ ...state.connection,
454
+ connected: true,
455
+ workersOnline: action.workersOnline ?? state.connection.workersOnline,
456
+ error: null,
457
+ },
458
+ ui: {
459
+ ...state.ui,
460
+ statusText: action.statusText ?? state.ui.statusText ?? "Ready",
461
+ },
462
+ };
463
+
464
+ case "connection/error":
465
+ return {
466
+ ...state,
467
+ connection: {
468
+ ...state.connection,
469
+ connected: false,
470
+ error: action.error,
471
+ },
472
+ ui: {
473
+ ...state.ui,
474
+ statusText: action.statusText || action.error || "Connection error",
475
+ },
476
+ };
477
+
478
+ case "auth/context":
479
+ return {
480
+ ...state,
481
+ auth: {
482
+ principal: action.principal ?? null,
483
+ authorization: action.authorization ?? null,
484
+ },
485
+ };
486
+
487
+ case "ui/status":
488
+ return {
489
+ ...state,
490
+ ui: {
491
+ ...state.ui,
492
+ statusText: action.text,
493
+ },
494
+ };
495
+
496
+ case "ui/theme":
497
+ return {
498
+ ...state,
499
+ ui: {
500
+ ...state.ui,
501
+ themeId: action.themeId || state.ui.themeId,
502
+ },
503
+ };
504
+
505
+ case "profileSettings/apply": {
506
+ const settings = action.settings && typeof action.settings === "object" && !Array.isArray(action.settings)
507
+ ? action.settings
508
+ : {};
509
+ const hasTheme = Object.prototype.hasOwnProperty.call(settings, "themeId")
510
+ && typeof settings.themeId === "string"
511
+ && settings.themeId.trim();
512
+ const hasOwnerFilter = Object.prototype.hasOwnProperty.call(settings, "sessionOwnerFilter");
513
+ const hasLayout = Object.prototype.hasOwnProperty.call(settings, "layoutAdjustments");
514
+ const hasChatViewMode = Object.prototype.hasOwnProperty.call(settings, "chatViewMode")
515
+ && (settings.chatViewMode === "summary" || settings.chatViewMode === "transcript");
516
+ const hasPins = Object.prototype.hasOwnProperty.call(settings, "pinnedSessionIds");
517
+ const hasCollapsed = Object.prototype.hasOwnProperty.call(settings, "collapsedSessionIds");
518
+ const hasActive = Object.prototype.hasOwnProperty.call(settings, "activeSessionId");
519
+ const hasLoadedSessions = Object.keys(state.sessions.byId || {}).length > 0;
520
+ const nextLayout = hasLayout
521
+ ? {
522
+ ...(state.ui.layout || {}),
523
+ ...normalizeStoredLayoutAdjustments(settings.layoutAdjustments),
524
+ }
525
+ : state.ui.layout;
526
+ const nextPinnedIds = hasPins
527
+ ? (hasLoadedSessions
528
+ ? prunePinnedIds(normalizeStoredPinnedSessionIds(settings.pinnedSessionIds), state.sessions.byId)
529
+ : normalizeStoredPinnedSessionIds(settings.pinnedSessionIds))
530
+ : state.sessions.pinnedIds;
531
+ const nextCollapsedIds = hasCollapsed
532
+ ? (hasLoadedSessions
533
+ ? pruneCollapsedIds(normalizeStoredCollapsedSessionIds(settings.collapsedSessionIds), state.sessions.byId)
534
+ : normalizeStoredCollapsedSessionIds(settings.collapsedSessionIds))
535
+ : state.sessions.collapsedIds;
536
+ const nextActiveSessionId = hasActive
537
+ ? normalizeStoredActiveSessionId(settings.activeSessionId)
538
+ : state.sessions.activeSessionId;
539
+ const nextSessions = {
540
+ ...state.sessions,
541
+ ...(hasOwnerFilter
542
+ ? {
543
+ ownerFilter: normalizeSessionOwnerFilter(settings.sessionOwnerFilter),
544
+ ownerFilterExplicit: true,
545
+ }
546
+ : {}),
547
+ pinnedIds: nextPinnedIds,
548
+ collapsedIds: nextCollapsedIds,
549
+ collapsedIdsExplicit: hasCollapsed ? true : state.sessions.collapsedIdsExplicit,
550
+ activeSessionId: nextActiveSessionId,
551
+ flat: (hasPins || hasCollapsed)
552
+ ? buildSessionTree(Object.values(state.sessions.byId), nextCollapsedIds, state.sessions.orderById, nextPinnedIds)
553
+ : state.sessions.flat,
554
+ };
555
+ const selection = hasLoadedSessions && (hasOwnerFilter || hasPins || hasCollapsed || hasActive)
556
+ ? applyVisibleSessionSelection(state, nextSessions)
557
+ : { sessions: nextSessions, ui: state.ui };
558
+ return {
559
+ ...state,
560
+ sessions: selection.sessions,
561
+ ui: {
562
+ ...selection.ui,
563
+ themeId: hasTheme ? settings.themeId.trim() : selection.ui.themeId,
564
+ chatViewMode: hasChatViewMode ? settings.chatViewMode : selection.ui.chatViewMode,
565
+ layout: nextLayout,
566
+ },
567
+ };
568
+ }
569
+
570
+ case "ui/chatViewMode":
571
+ if (action.mode !== "summary" && action.mode !== "transcript") {
572
+ return state;
573
+ }
574
+ return {
575
+ ...state,
576
+ ui: {
577
+ ...state.ui,
578
+ chatViewMode: action.mode,
579
+ scroll: {
580
+ ...state.ui.scroll,
581
+ chat: 0,
582
+ },
583
+ },
584
+ };
585
+
586
+ case "ui/modal":
587
+ return {
588
+ ...state,
589
+ ui: {
590
+ ...state.ui,
591
+ modal: action.modal ?? null,
592
+ },
593
+ };
594
+
595
+ case "ui/viewport": {
596
+ const currentWidth = state.ui.layout?.viewportWidth ?? 120;
597
+ const currentHeight = state.ui.layout?.viewportHeight ?? 40;
598
+ const nextWidth = Math.max(40, action.width ?? currentWidth);
599
+ const nextHeight = Math.max(18, action.height ?? currentHeight);
600
+ if (nextWidth === currentWidth && nextHeight === currentHeight) {
601
+ return state;
602
+ }
603
+ return {
604
+ ...state,
605
+ ui: {
606
+ ...state.ui,
607
+ layout: {
608
+ ...(state.ui.layout || {}),
609
+ viewportWidth: nextWidth,
610
+ viewportHeight: nextHeight,
611
+ },
612
+ },
613
+ };
614
+ }
615
+
616
+ case "ui/paneAdjust":
617
+ return {
618
+ ...state,
619
+ ui: {
620
+ ...state.ui,
621
+ layout: {
622
+ ...(state.ui.layout || {}),
623
+ paneAdjust: Number(action.paneAdjust) || 0,
624
+ },
625
+ },
626
+ };
627
+
628
+ case "ui/sessionPaneAdjust":
629
+ return {
630
+ ...state,
631
+ ui: {
632
+ ...state.ui,
633
+ layout: {
634
+ ...(state.ui.layout || {}),
635
+ sessionPaneAdjust: Number(action.sessionPaneAdjust) || 0,
636
+ },
637
+ },
638
+ };
639
+
640
+ case "ui/portalSessionColumnAdjust":
641
+ return {
642
+ ...state,
643
+ ui: {
644
+ ...state.ui,
645
+ layout: {
646
+ ...(state.ui.layout || {}),
647
+ portalSessionColumnAdjust: Number(action.portalSessionColumnAdjust) || 0,
648
+ },
649
+ },
650
+ };
651
+
652
+ case "ui/activityPaneAdjust":
653
+ return {
654
+ ...state,
655
+ ui: {
656
+ ...state.ui,
657
+ layout: {
658
+ ...(state.ui.layout || {}),
659
+ activityPaneAdjust: Number(action.activityPaneAdjust) || 0,
660
+ },
661
+ },
662
+ };
663
+
664
+ case "ui/focus":
665
+ return {
666
+ ...state,
667
+ ui: {
668
+ ...state.ui,
669
+ focusRegion: action.focusRegion,
670
+ },
671
+ };
672
+
673
+ case "sessions/filterQuery":
674
+ {
675
+ const nextSessions = {
676
+ ...state.sessions,
677
+ filterQuery: typeof action.query === "string" ? action.query : "",
678
+ };
679
+ const selection = applyVisibleSessionSelection(state, nextSessions);
680
+ return {
681
+ ...state,
682
+ sessions: selection.sessions,
683
+ ui: selection.ui,
684
+ };
685
+ }
686
+
687
+ case "sessions/ownerFilter":
688
+ {
689
+ const nextSessions = {
690
+ ...state.sessions,
691
+ ownerFilterExplicit: true,
692
+ ownerFilter: normalizeSessionOwnerFilter(action.filter),
693
+ };
694
+ const selection = applyVisibleSessionSelection(state, nextSessions);
695
+ return {
696
+ ...state,
697
+ sessions: selection.sessions,
698
+ ui: selection.ui,
699
+ };
700
+ }
701
+
702
+ case "ui/modalSelection": {
703
+ const modal = state.ui.modal;
704
+ if (!modal || !Array.isArray(modal.items) || modal.items.length === 0) {
705
+ return state;
706
+ }
707
+ const nextIndex = Math.max(0, Math.min(action.index ?? 0, modal.items.length - 1));
708
+ const previewThemeId = modal.type === "themePicker" ? modal.items[nextIndex]?.id : null;
709
+ return {
710
+ ...state,
711
+ ui: {
712
+ ...state.ui,
713
+ ...(previewThemeId ? { themeId: previewThemeId } : {}),
714
+ modal: {
715
+ ...modal,
716
+ selectedIndex: nextIndex,
717
+ },
718
+ },
719
+ };
720
+ }
721
+
722
+ case "ui/scroll":
723
+ return {
724
+ ...state,
725
+ ui: {
726
+ ...state.ui,
727
+ scroll: {
728
+ ...state.ui.scroll,
729
+ [action.pane]: Math.max(0, action.offset ?? 0),
730
+ },
731
+ },
732
+ };
733
+
734
+ case "ui/followBottom": {
735
+ if (action.pane !== "inspector" && action.pane !== "activity") {
736
+ return state;
737
+ }
738
+ const nextFollowBottom = Boolean(action.followBottom);
739
+ if (state.ui.followBottom?.[action.pane] === nextFollowBottom) {
740
+ return state;
741
+ }
742
+ return {
743
+ ...state,
744
+ ui: {
745
+ ...state.ui,
746
+ followBottom: {
747
+ ...(state.ui.followBottom || {}),
748
+ [action.pane]: nextFollowBottom,
749
+ },
750
+ },
751
+ };
752
+ }
753
+
754
+ case "ui/inspectorTab":
755
+ return {
756
+ ...state,
757
+ ui: {
758
+ ...state.ui,
759
+ inspectorTab: action.inspectorTab,
760
+ fullscreenPane: action.inspectorTab === "files" && state.ui.fullscreenPane === FOCUS_REGIONS.INSPECTOR
761
+ ? null
762
+ : state.ui.fullscreenPane,
763
+ scroll: {
764
+ ...state.ui.scroll,
765
+ inspector: 0,
766
+ },
767
+ followBottom: {
768
+ ...(state.ui.followBottom || {}),
769
+ inspector: true,
770
+ },
771
+ },
772
+ files: action.inspectorTab === "files"
773
+ ? state.files
774
+ : {
775
+ ...state.files,
776
+ fullscreen: false,
777
+ },
778
+ };
779
+
780
+ case "ui/statsViewMode":
781
+ return {
782
+ ...state,
783
+ ui: {
784
+ ...state.ui,
785
+ statsViewMode: ["session", "fleet", "users"].includes(action.statsViewMode)
786
+ ? action.statsViewMode
787
+ : "session",
788
+ scroll: {
789
+ ...state.ui.scroll,
790
+ inspector: 0,
791
+ },
792
+ },
793
+ };
794
+
795
+ case "ui/prompt":
796
+ return {
797
+ ...state,
798
+ ui: {
799
+ ...state.ui,
800
+ prompt: action.prompt,
801
+ promptCursor: clampPromptCursor(action.prompt, action.promptCursor, state.ui.promptCursor),
802
+ promptRows: getPromptInputRows(action.prompt),
803
+ promptAttachments: normalizePromptAttachments(action.prompt, state.ui.promptAttachments),
804
+ },
805
+ };
806
+
807
+ case "ui/promptEdit":
808
+ return {
809
+ ...state,
810
+ ui: {
811
+ ...state.ui,
812
+ promptEdit: action.promptEdit ?? null,
813
+ },
814
+ };
815
+
816
+ case "ui/promptAttachments":
817
+ return {
818
+ ...state,
819
+ ui: {
820
+ ...state.ui,
821
+ promptAttachments: normalizePromptAttachments(
822
+ state.ui.prompt,
823
+ action.attachments,
824
+ ),
825
+ },
826
+ };
827
+
828
+ case "sessions/loaded": {
829
+ const byId = {};
830
+ let anyChanged = false;
831
+ const nowMs = Date.now();
832
+ for (const session of action.sessions) {
833
+ const previous = state.sessions.byId[session.sessionId];
834
+ const merged = mergeSessionRowVisualStatus(
835
+ previous,
836
+ mergeDefinedSessionFields(previous, session),
837
+ nowMs,
838
+ );
839
+ byId[session.sessionId] = merged;
840
+ if (!anyChanged && merged !== previous) anyChanged = true;
841
+ }
842
+ if (
843
+ state.sessions.activeSessionId
844
+ && state.sessions.byId[state.sessions.activeSessionId]
845
+ && !state.sessions.byId[state.sessions.activeSessionId]?.isGroup
846
+ && !byId[state.sessions.activeSessionId]
847
+ ) {
848
+ byId[state.sessions.activeSessionId] = {
849
+ ...state.sessions.byId[state.sessions.activeSessionId],
850
+ };
851
+ anyChanged = true;
852
+ }
853
+ // Check if session set changed (added/removed)
854
+ const prevIds = Object.keys(state.sessions.byId);
855
+ const nextIds = Object.keys(byId);
856
+ if (prevIds.length !== nextIds.length) anyChanged = true;
857
+ if (!anyChanged) {
858
+ for (const id of nextIds) {
859
+ if (!state.sessions.byId[id]) { anyChanged = true; break; }
860
+ }
861
+ }
862
+ if (!anyChanged) return state;
863
+ const mergedSessions = Object.values(byId);
864
+ const hasExistingOrder = Object.keys(state.sessions.orderById || {}).length > 0;
865
+ const initialCollapsedIds = collectDefaultCollapsedSessionIds(mergedSessions);
866
+ const previousCollapsedIdsExplicit = Boolean(state.sessions.collapsedIdsExplicit);
867
+ const {
868
+ orderById,
869
+ nextOrderOrdinal,
870
+ } = hasExistingOrder
871
+ ? assignStableSessionOrder(
872
+ state.sessions.orderById,
873
+ state.sessions.nextOrderOrdinal,
874
+ mergedSessions,
875
+ )
876
+ : assignInitialSessionOrder(mergedSessions, state.sessions.pinnedIds);
877
+ const collapsedIds = previousCollapsedIdsExplicit
878
+ ? cloneCollapsedIds(state.sessions.collapsedIds)
879
+ : initialCollapsedIds;
880
+ if (previousCollapsedIdsExplicit) {
881
+ const previousDefaultCollapsedIds = collectDefaultCollapsedSessionIds(Object.values(state.sessions.byId));
882
+ for (const sessionId of initialCollapsedIds) {
883
+ if (!previousDefaultCollapsedIds.has(sessionId)) {
884
+ collapsedIds.add(sessionId);
885
+ }
886
+ }
887
+ }
888
+ // Drop pins/selection for sessions that no longer exist; only
889
+ // keep pins on top-level rows (groups and ungrouped top-level
890
+ // sessions). Sessions inside containers lose their pins.
891
+ const survivingPins = prunePinnedIds(state.sessions.pinnedIds, byId);
892
+ const survivingSelected = pruneIdList(state.sessions.selectedIds, byId);
893
+ const flat = buildSessionTree(mergedSessions, collapsedIds, orderById, survivingPins);
894
+ const nextSessions = {
895
+ ...state.sessions,
896
+ byId,
897
+ collapsedIds,
898
+ collapsedIdsExplicit: previousCollapsedIdsExplicit,
899
+ pinnedIds: survivingPins,
900
+ selectedIds: survivingSelected,
901
+ selectMode: state.sessions.selectMode && survivingSelected.length > 0,
902
+ flat,
903
+ activeSessionId: state.sessions.activeSessionId,
904
+ orderById,
905
+ nextOrderOrdinal,
906
+ };
907
+ const selection = applyVisibleSessionSelection(state, nextSessions);
908
+ return {
909
+ ...state,
910
+ sessions: selection.sessions,
911
+ ui: selection.ui,
912
+ };
913
+ }
914
+
915
+ case "sessions/merged": {
916
+ if (!action.session?.sessionId) return state;
917
+ const previousSession = state.sessions.byId[action.session.sessionId];
918
+ const mergedSession = mergeSessionRowVisualStatus(
919
+ previousSession,
920
+ mergeDefinedSessionFields(previousSession, action.session),
921
+ Date.now(),
922
+ );
923
+ if (mergedSession === previousSession) return state;
924
+ const byId = {
925
+ ...state.sessions.byId,
926
+ [action.session.sessionId]: mergedSession,
927
+ };
928
+ const survivingPins = prunePinnedIds(state.sessions.pinnedIds, byId);
929
+ const {
930
+ orderById,
931
+ nextOrderOrdinal,
932
+ } = assignStableSessionOrder(
933
+ state.sessions.orderById,
934
+ state.sessions.nextOrderOrdinal,
935
+ Object.values(byId),
936
+ );
937
+ const nextSessions = {
938
+ ...state.sessions,
939
+ byId,
940
+ pinnedIds: survivingPins,
941
+ flat: buildSessionTree(Object.values(byId), state.sessions.collapsedIds, orderById, survivingPins),
942
+ activeSessionId: state.sessions.activeSessionId,
943
+ orderById,
944
+ nextOrderOrdinal,
945
+ };
946
+ const selection = applyVisibleSessionSelection(state, nextSessions);
947
+ return {
948
+ ...state,
949
+ sessions: selection.sessions,
950
+ ui: selection.ui,
951
+ };
952
+ }
953
+
954
+ case "sessions/selected":
955
+ return {
956
+ ...state,
957
+ sessions: {
958
+ ...state.sessions,
959
+ activeSessionId: action.sessionId,
960
+ },
961
+ ui: {
962
+ ...state.ui,
963
+ scroll: {
964
+ ...state.ui.scroll,
965
+ chat: 0,
966
+ inspector: 0,
967
+ activity: 0,
968
+ },
969
+ followBottom: {
970
+ ...(state.ui.followBottom || {}),
971
+ inspector: true,
972
+ activity: true,
973
+ },
974
+ },
975
+ };
976
+
977
+ case "ui/fullscreenPane": {
978
+ const fullscreenPane = normalizeFullscreenPane(action.fullscreenPane);
979
+ return {
980
+ ...state,
981
+ files: fullscreenPane
982
+ ? {
983
+ ...state.files,
984
+ fullscreen: false,
985
+ }
986
+ : state.files,
987
+ ui: {
988
+ ...state.ui,
989
+ fullscreenPane,
990
+ focusRegion: fullscreenPane || state.ui.focusRegion,
991
+ },
992
+ };
993
+ }
994
+
995
+ case "sessions/collapse": {
996
+ const collapsedIds = cloneCollapsedIds(state.sessions.collapsedIds);
997
+ collapsedIds.add(action.sessionId);
998
+ return {
999
+ ...state,
1000
+ sessions: {
1001
+ ...state.sessions,
1002
+ collapsedIds,
1003
+ collapsedIdsExplicit: true,
1004
+ flat: buildSessionTree(Object.values(state.sessions.byId), collapsedIds, state.sessions.orderById, state.sessions.pinnedIds),
1005
+ },
1006
+ };
1007
+ }
1008
+
1009
+ case "sessions/expand": {
1010
+ const collapsedIds = cloneCollapsedIds(state.sessions.collapsedIds);
1011
+ collapsedIds.delete(action.sessionId);
1012
+ return {
1013
+ ...state,
1014
+ sessions: {
1015
+ ...state.sessions,
1016
+ collapsedIds,
1017
+ collapsedIdsExplicit: true,
1018
+ flat: buildSessionTree(Object.values(state.sessions.byId), collapsedIds, state.sessions.orderById, state.sessions.pinnedIds),
1019
+ },
1020
+ };
1021
+ }
1022
+
1023
+ case "sessions/pinToggle": {
1024
+ const sessionId = String(action.sessionId || "").trim();
1025
+ if (!sessionId) return state;
1026
+ const session = state.sessions.byId[sessionId];
1027
+ // Pinning is a TOP-LEVEL-only concept. Groups and ungrouped
1028
+ // top-level sessions can be pinned; contained sessions cannot.
1029
+ if (!isPinnableSession(session)) return state;
1030
+ const current = clonePinnedIds(state.sessions.pinnedIds);
1031
+ const existingIndex = current.indexOf(sessionId);
1032
+ const nextPinned = existingIndex >= 0
1033
+ ? current.filter((id) => id !== sessionId)
1034
+ : [...current, sessionId];
1035
+ return {
1036
+ ...state,
1037
+ sessions: {
1038
+ ...state.sessions,
1039
+ pinnedIds: nextPinned,
1040
+ flat: buildSessionTree(Object.values(state.sessions.byId), state.sessions.collapsedIds, state.sessions.orderById, nextPinned),
1041
+ },
1042
+ };
1043
+ }
1044
+
1045
+ case "sessions/selectMode": {
1046
+ const enabled = Boolean(action.enabled);
1047
+ if (enabled === Boolean(state.sessions.selectMode)
1048
+ && (enabled || state.sessions.selectedIds.length === 0)) {
1049
+ return state;
1050
+ }
1051
+ return {
1052
+ ...state,
1053
+ sessions: {
1054
+ ...state.sessions,
1055
+ selectMode: enabled,
1056
+ selectedIds: enabled ? state.sessions.selectedIds : [],
1057
+ },
1058
+ };
1059
+ }
1060
+
1061
+ case "sessions/selectToggle": {
1062
+ const sessionId = String(action.sessionId || "").trim();
1063
+ const session = sessionId ? state.sessions.byId[sessionId] : null;
1064
+ if (!session) return state;
1065
+ // System sessions (PilotSwarm, Sweeper, Resource Manager, etc.)
1066
+ // and synthetic group rows use dedicated action paths.
1067
+ if (session.isSystem || session.isGroup) return state;
1068
+ const current = cloneSelectedIds(state.sessions.selectedIds);
1069
+ const existingIndex = current.indexOf(sessionId);
1070
+ const nextSelected = existingIndex >= 0
1071
+ ? current.filter((id) => id !== sessionId)
1072
+ : [...current, sessionId];
1073
+ return {
1074
+ ...state,
1075
+ sessions: {
1076
+ ...state.sessions,
1077
+ selectedIds: nextSelected,
1078
+ selectMode: nextSelected.length > 0 ? true : state.sessions.selectMode,
1079
+ },
1080
+ };
1081
+ }
1082
+
1083
+ case "sessions/selectSet": {
1084
+ const ids = Array.isArray(action.sessionIds) ? action.sessionIds : [];
1085
+ // System sessions and group rows are not selectable for bulk operations.
1086
+ const next = pruneIdList(ids, state.sessions.byId)
1087
+ .filter((id) => !state.sessions.byId[id]?.isSystem && !state.sessions.byId[id]?.isGroup);
1088
+ return {
1089
+ ...state,
1090
+ sessions: {
1091
+ ...state.sessions,
1092
+ selectedIds: next,
1093
+ selectMode: next.length > 0 ? true : state.sessions.selectMode,
1094
+ },
1095
+ };
1096
+ }
1097
+
1098
+ case "sessions/selectClear": {
1099
+ if (state.sessions.selectedIds.length === 0 && !state.sessions.selectMode) return state;
1100
+ return {
1101
+ ...state,
1102
+ sessions: {
1103
+ ...state.sessions,
1104
+ selectedIds: [],
1105
+ selectMode: false,
1106
+ },
1107
+ };
1108
+ }
1109
+
1110
+ case "history/set": {
1111
+ const previousHistory = state.history.bySessionId.get(action.sessionId) || null;
1112
+ const previousChat = previousHistory?.chat || [];
1113
+ const loadedEventLimit = Math.max(
1114
+ DEFAULT_HISTORY_EVENT_LIMIT,
1115
+ Number(action.history?.loadedEventLimit ?? previousHistory?.loadedEventLimit ?? DEFAULT_HISTORY_EVENT_LIMIT) || DEFAULT_HISTORY_EVENT_LIMIT,
1116
+ );
1117
+ const nextChat = clampHistoryItems(dedupeChatMessages(action.history?.chat || []), loadedEventLimit);
1118
+ const previousLastChatId = previousChat[previousChat.length - 1]?.id || null;
1119
+ const nextLastChatId = nextChat[nextChat.length - 1]?.id || null;
1120
+ const activeChatUpdated = action.sessionId === state.sessions.activeSessionId
1121
+ && nextLastChatId !== previousLastChatId;
1122
+ const nextHistory = cloneHistoryMap(state.history.bySessionId);
1123
+ nextHistory.set(action.sessionId, {
1124
+ ...(action.history || {}),
1125
+ chat: nextChat,
1126
+ activity: clampHistoryItems(action.history?.activity || [], loadedEventLimit),
1127
+ events: clampHistoryItems(action.history?.events || [], loadedEventLimit),
1128
+ loadedEventLimit,
1129
+ });
1130
+ return {
1131
+ ...state,
1132
+ history: {
1133
+ ...state.history,
1134
+ bySessionId: nextHistory,
1135
+ },
1136
+ ui: activeChatUpdated
1137
+ ? {
1138
+ ...state.ui,
1139
+ scroll: {
1140
+ ...state.ui.scroll,
1141
+ chat: 0,
1142
+ },
1143
+ }
1144
+ : state.ui,
1145
+ };
1146
+ }
1147
+
1148
+ case "history/evict": {
1149
+ const ids = Array.isArray(action.sessionIds) ? action.sessionIds : [];
1150
+ if (ids.length === 0) return state;
1151
+ const nextHistory = cloneHistoryMap(state.history.bySessionId);
1152
+ for (const id of ids) nextHistory.delete(id);
1153
+ const nextOutbox = cloneOutboxBySessionId(state.outbox?.bySessionId);
1154
+ for (const id of ids) delete nextOutbox[id];
1155
+ return {
1156
+ ...state,
1157
+ history: {
1158
+ ...state.history,
1159
+ bySessionId: nextHistory,
1160
+ },
1161
+ outbox: {
1162
+ ...state.outbox,
1163
+ bySessionId: nextOutbox,
1164
+ },
1165
+ };
1166
+ }
1167
+
1168
+ case "outbox/setSessionItems": {
1169
+ const sessionId = action.sessionId;
1170
+ if (!sessionId) return state;
1171
+ const items = Array.isArray(action.items) ? action.items.filter(Boolean) : [];
1172
+ const nextOutbox = cloneOutboxBySessionId(state.outbox?.bySessionId);
1173
+ if (items.length === 0) {
1174
+ delete nextOutbox[sessionId];
1175
+ } else {
1176
+ nextOutbox[sessionId] = items;
1177
+ }
1178
+ return {
1179
+ ...state,
1180
+ outbox: {
1181
+ ...state.outbox,
1182
+ bySessionId: nextOutbox,
1183
+ },
1184
+ };
1185
+ }
1186
+
1187
+ case "orchestration/statsLoading": {
1188
+ const bySessionId = cloneOrchestrationBySessionId(state.orchestration.bySessionId);
1189
+ bySessionId[action.sessionId] = {
1190
+ ...(bySessionId[action.sessionId] || {}),
1191
+ loading: true,
1192
+ error: null,
1193
+ };
1194
+ return {
1195
+ ...state,
1196
+ orchestration: {
1197
+ ...state.orchestration,
1198
+ bySessionId,
1199
+ },
1200
+ };
1201
+ }
1202
+
1203
+ case "orchestration/statsLoaded": {
1204
+ const bySessionId = cloneOrchestrationBySessionId(state.orchestration.bySessionId);
1205
+ bySessionId[action.sessionId] = {
1206
+ loading: false,
1207
+ error: null,
1208
+ fetchedAt: action.fetchedAt || Date.now(),
1209
+ stats: action.stats || null,
1210
+ };
1211
+ return {
1212
+ ...state,
1213
+ orchestration: {
1214
+ ...state.orchestration,
1215
+ bySessionId,
1216
+ },
1217
+ };
1218
+ }
1219
+
1220
+ case "orchestration/statsError": {
1221
+ const bySessionId = cloneOrchestrationBySessionId(state.orchestration.bySessionId);
1222
+ bySessionId[action.sessionId] = {
1223
+ ...(bySessionId[action.sessionId] || {}),
1224
+ loading: false,
1225
+ error: action.error || "Failed to load orchestration stats",
1226
+ fetchedAt: action.fetchedAt || Date.now(),
1227
+ };
1228
+ return {
1229
+ ...state,
1230
+ orchestration: {
1231
+ ...state.orchestration,
1232
+ bySessionId,
1233
+ },
1234
+ };
1235
+ }
1236
+
1237
+ case "orchestration/evict": {
1238
+ const ids = Array.isArray(action.sessionIds) ? action.sessionIds : [];
1239
+ if (ids.length === 0) return state;
1240
+ const bySessionId = cloneOrchestrationBySessionId(state.orchestration.bySessionId);
1241
+ for (const id of ids) delete bySessionId[id];
1242
+ return {
1243
+ ...state,
1244
+ orchestration: {
1245
+ ...state.orchestration,
1246
+ bySessionId,
1247
+ },
1248
+ };
1249
+ }
1250
+
1251
+ case "executionHistory/loading": {
1252
+ const bySessionId = { ...(state.executionHistory?.bySessionId || {}) };
1253
+ bySessionId[action.sessionId] = {
1254
+ ...(bySessionId[action.sessionId] || {}),
1255
+ loading: true,
1256
+ error: null,
1257
+ };
1258
+ return {
1259
+ ...state,
1260
+ executionHistory: { ...state.executionHistory, bySessionId },
1261
+ };
1262
+ }
1263
+
1264
+ case "executionHistory/loaded": {
1265
+ const bySessionId = { ...(state.executionHistory?.bySessionId || {}) };
1266
+ const rawEvents = action.events || [];
1267
+ const MAX_EXECUTION_HISTORY_EVENTS = 1000;
1268
+ const clampedEvents = rawEvents.length > MAX_EXECUTION_HISTORY_EVENTS
1269
+ ? rawEvents.slice(-MAX_EXECUTION_HISTORY_EVENTS)
1270
+ : rawEvents;
1271
+ bySessionId[action.sessionId] = {
1272
+ loading: false,
1273
+ error: null,
1274
+ fetchedAt: action.fetchedAt || Date.now(),
1275
+ events: clampedEvents,
1276
+ };
1277
+ return {
1278
+ ...state,
1279
+ executionHistory: { ...state.executionHistory, bySessionId },
1280
+ };
1281
+ }
1282
+
1283
+ case "executionHistory/evict": {
1284
+ const ids = Array.isArray(action.sessionIds) ? action.sessionIds : [];
1285
+ if (ids.length === 0) return state;
1286
+ const bySessionId = { ...(state.executionHistory?.bySessionId || {}) };
1287
+ for (const id of ids) delete bySessionId[id];
1288
+ return {
1289
+ ...state,
1290
+ executionHistory: { ...state.executionHistory, bySessionId },
1291
+ };
1292
+ }
1293
+
1294
+ case "executionHistory/error": {
1295
+ const bySessionId = { ...(state.executionHistory?.bySessionId || {}) };
1296
+ bySessionId[action.sessionId] = {
1297
+ ...(bySessionId[action.sessionId] || {}),
1298
+ loading: false,
1299
+ error: action.error || "Failed to load execution history",
1300
+ fetchedAt: action.fetchedAt || Date.now(),
1301
+ };
1302
+ return {
1303
+ ...state,
1304
+ executionHistory: { ...state.executionHistory, bySessionId },
1305
+ };
1306
+ }
1307
+
1308
+ case "executionHistory/format": {
1309
+ return {
1310
+ ...state,
1311
+ executionHistory: {
1312
+ ...state.executionHistory,
1313
+ format: action.format || "pretty",
1314
+ },
1315
+ };
1316
+ }
1317
+
1318
+ // ── Session Stats ────────────────────────────────────
1319
+
1320
+ case "sessionStats/loading": {
1321
+ const bySessionId = { ...(state.sessionStats?.bySessionId || {}) };
1322
+ bySessionId[action.sessionId] = {
1323
+ ...(bySessionId[action.sessionId] || {}),
1324
+ loading: true,
1325
+ };
1326
+ return {
1327
+ ...state,
1328
+ sessionStats: { ...state.sessionStats, bySessionId },
1329
+ };
1330
+ }
1331
+
1332
+ case "sessionStats/loaded": {
1333
+ const bySessionId = { ...(state.sessionStats?.bySessionId || {}) };
1334
+ bySessionId[action.sessionId] = {
1335
+ loading: false,
1336
+ fetchedAt: Date.now(),
1337
+ summary: action.summary || null,
1338
+ tokensByModel: Array.isArray(action.tokensByModel) ? action.tokensByModel : [],
1339
+ treeStats: action.treeStats || null,
1340
+ skillUsage: action.skillUsage || null,
1341
+ treeSkillUsage: action.treeSkillUsage || null,
1342
+ factsStats: action.factsStats || null,
1343
+ treeFactsStats: action.treeFactsStats || null,
1344
+ };
1345
+ return {
1346
+ ...state,
1347
+ sessionStats: { ...state.sessionStats, bySessionId },
1348
+ };
1349
+ }
1350
+
1351
+ case "fleetStats/loading": {
1352
+ return {
1353
+ ...state,
1354
+ fleetStats: {
1355
+ ...state.fleetStats,
1356
+ loading: true,
1357
+ },
1358
+ };
1359
+ }
1360
+
1361
+ case "fleetStats/loaded": {
1362
+ return {
1363
+ ...state,
1364
+ fleetStats: {
1365
+ loading: false,
1366
+ data: action.data || null,
1367
+ userStats: action.userStats || null,
1368
+ skillUsage: action.skillUsage || null,
1369
+ retrievalUsage: action.retrievalUsage || null,
1370
+ sharedFactsStats: action.sharedFactsStats || null,
1371
+ factsTombstoneStats: action.factsTombstoneStats || null,
1372
+ fetchedAt: Date.now(),
1373
+ },
1374
+ };
1375
+ }
1376
+
1377
+ case "admin/visibility": {
1378
+ const visible = Boolean(action.visible);
1379
+ if (state.admin.visible === visible) return state;
1380
+ return {
1381
+ ...state,
1382
+ admin: {
1383
+ ...state.admin,
1384
+ visible,
1385
+ // Closing the console resets any in-progress edit so the
1386
+ // next open starts on the read-only view, not on a stale
1387
+ // draft.
1388
+ ghcpKey: visible
1389
+ ? state.admin.ghcpKey
1390
+ : { editing: false, draft: "", saving: false, error: null, lastSavedAt: state.admin.ghcpKey.lastSavedAt },
1391
+ },
1392
+ };
1393
+ }
1394
+ case "admin/profile/loading": {
1395
+ return {
1396
+ ...state,
1397
+ admin: {
1398
+ ...state.admin,
1399
+ loading: true,
1400
+ loadError: null,
1401
+ },
1402
+ };
1403
+ }
1404
+ case "admin/profile/loaded": {
1405
+ return {
1406
+ ...state,
1407
+ admin: {
1408
+ ...state.admin,
1409
+ loading: false,
1410
+ loadError: null,
1411
+ profile: action.profile || null,
1412
+ ghcpKey: {
1413
+ ...state.admin.ghcpKey,
1414
+ // A successful load doesn't clobber an in-progress
1415
+ // edit; the user may be in the middle of typing.
1416
+ error: state.admin.ghcpKey.editing ? state.admin.ghcpKey.error : null,
1417
+ },
1418
+ },
1419
+ };
1420
+ }
1421
+ case "admin/profile/loadFailed": {
1422
+ return {
1423
+ ...state,
1424
+ admin: {
1425
+ ...state.admin,
1426
+ loading: false,
1427
+ loadError: action.error ? String(action.error) : "Failed to load profile",
1428
+ },
1429
+ };
1430
+ }
1431
+ case "admin/ghcpKey/beginEdit": {
1432
+ const draft = typeof action.draft === "string" ? action.draft : "";
1433
+ return {
1434
+ ...state,
1435
+ admin: {
1436
+ ...state.admin,
1437
+ ghcpKey: {
1438
+ editing: true,
1439
+ draft,
1440
+ cursorIndex: draft.length,
1441
+ saving: false,
1442
+ error: null,
1443
+ lastSavedAt: state.admin.ghcpKey.lastSavedAt,
1444
+ },
1445
+ },
1446
+ };
1447
+ }
1448
+ case "admin/ghcpKey/setDraft": {
1449
+ if (!state.admin.ghcpKey.editing) return state;
1450
+ const draft = typeof action.draft === "string" ? action.draft : "";
1451
+ const cursorRaw = Number.isFinite(action.cursorIndex) ? Number(action.cursorIndex) : draft.length;
1452
+ const cursorIndex = Math.max(0, Math.min(cursorRaw, draft.length));
1453
+ if (draft === state.admin.ghcpKey.draft && cursorIndex === state.admin.ghcpKey.cursorIndex) return state;
1454
+ return {
1455
+ ...state,
1456
+ admin: {
1457
+ ...state.admin,
1458
+ ghcpKey: {
1459
+ ...state.admin.ghcpKey,
1460
+ draft,
1461
+ cursorIndex,
1462
+ error: null,
1463
+ },
1464
+ },
1465
+ };
1466
+ }
1467
+ case "admin/ghcpKey/cancelEdit": {
1468
+ if (!state.admin.ghcpKey.editing && !state.admin.ghcpKey.draft) return state;
1469
+ return {
1470
+ ...state,
1471
+ admin: {
1472
+ ...state.admin,
1473
+ ghcpKey: {
1474
+ editing: false,
1475
+ draft: "",
1476
+ cursorIndex: 0,
1477
+ saving: false,
1478
+ error: null,
1479
+ lastSavedAt: state.admin.ghcpKey.lastSavedAt,
1480
+ },
1481
+ },
1482
+ };
1483
+ }
1484
+ case "admin/ghcpKey/saving": {
1485
+ return {
1486
+ ...state,
1487
+ admin: {
1488
+ ...state.admin,
1489
+ ghcpKey: {
1490
+ ...state.admin.ghcpKey,
1491
+ saving: true,
1492
+ error: null,
1493
+ },
1494
+ },
1495
+ };
1496
+ }
1497
+ case "admin/ghcpKey/saveFailed": {
1498
+ return {
1499
+ ...state,
1500
+ admin: {
1501
+ ...state.admin,
1502
+ ghcpKey: {
1503
+ ...state.admin.ghcpKey,
1504
+ saving: false,
1505
+ error: action.error ? String(action.error) : "Failed to save key",
1506
+ },
1507
+ },
1508
+ };
1509
+ }
1510
+ case "admin/ghcpKey/saved": {
1511
+ return {
1512
+ ...state,
1513
+ admin: {
1514
+ ...state.admin,
1515
+ profile: action.profile || state.admin.profile,
1516
+ ghcpKey: {
1517
+ editing: false,
1518
+ draft: "",
1519
+ cursorIndex: 0,
1520
+ saving: false,
1521
+ error: null,
1522
+ lastSavedAt: Date.now(),
1523
+ },
1524
+ },
1525
+ };
1526
+ }
1527
+
1528
+ case "files/evictPreviews": {
1529
+ const ids = Array.isArray(action.sessionIds) ? action.sessionIds : [];
1530
+ if (ids.length === 0) return state;
1531
+ const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
1532
+ for (const id of ids) {
1533
+ if (bySessionId[id]) {
1534
+ // Keep entries list (lightweight), drop heavy preview content
1535
+ bySessionId[id] = {
1536
+ ...bySessionId[id],
1537
+ previews: {},
1538
+ };
1539
+ }
1540
+ }
1541
+ return {
1542
+ ...state,
1543
+ files: {
1544
+ ...state.files,
1545
+ bySessionId,
1546
+ },
1547
+ };
1548
+ }
1549
+
1550
+ case "files/sessionLoading": {
1551
+ const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
1552
+ const current = bySessionId[action.sessionId] || {
1553
+ entries: [],
1554
+ previews: {},
1555
+ downloads: {},
1556
+ selectedFilename: null,
1557
+ };
1558
+ bySessionId[action.sessionId] = {
1559
+ ...current,
1560
+ loading: true,
1561
+ error: null,
1562
+ };
1563
+ return {
1564
+ ...state,
1565
+ files: {
1566
+ ...state.files,
1567
+ bySessionId,
1568
+ },
1569
+ };
1570
+ }
1571
+
1572
+ case "files/sessionLoaded": {
1573
+ const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
1574
+ const current = bySessionId[action.sessionId] || {
1575
+ previews: {},
1576
+ downloads: {},
1577
+ };
1578
+ const entries = normalizeArtifactEntries(action.entries);
1579
+ const hasFilename = (filename) => entries.some((entry) => entry.filename === filename);
1580
+ const selectedFilename = current.selectedFilename && hasFilename(current.selectedFilename)
1581
+ ? current.selectedFilename
1582
+ : (action.selectedFilename && hasFilename(action.selectedFilename)
1583
+ ? action.selectedFilename
1584
+ : (entries[0]?.filename || null));
1585
+ bySessionId[action.sessionId] = {
1586
+ ...current,
1587
+ entries,
1588
+ selectedFilename,
1589
+ loading: false,
1590
+ loaded: true,
1591
+ error: null,
1592
+ };
1593
+ return {
1594
+ ...state,
1595
+ files: {
1596
+ ...state.files,
1597
+ bySessionId,
1598
+ },
1599
+ };
1600
+ }
1601
+
1602
+ case "files/sessionError": {
1603
+ const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
1604
+ const current = bySessionId[action.sessionId] || {
1605
+ entries: [],
1606
+ previews: {},
1607
+ downloads: {},
1608
+ selectedFilename: null,
1609
+ };
1610
+ bySessionId[action.sessionId] = {
1611
+ ...current,
1612
+ loading: false,
1613
+ loaded: true,
1614
+ error: action.error || "Failed to load files",
1615
+ };
1616
+ return {
1617
+ ...state,
1618
+ files: {
1619
+ ...state.files,
1620
+ bySessionId,
1621
+ },
1622
+ };
1623
+ }
1624
+
1625
+ case "files/select": {
1626
+ const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
1627
+ const current = bySessionId[action.sessionId] || {
1628
+ entries: [],
1629
+ previews: {},
1630
+ downloads: {},
1631
+ selectedFilename: null,
1632
+ };
1633
+ bySessionId[action.sessionId] = {
1634
+ ...current,
1635
+ selectedFilename: action.filename || null,
1636
+ };
1637
+ return {
1638
+ ...state,
1639
+ files: {
1640
+ ...state.files,
1641
+ bySessionId,
1642
+ selectedArtifactId: action.sessionId && action.filename
1643
+ ? `${action.sessionId}/${action.filename}`
1644
+ : state.files.selectedArtifactId,
1645
+ },
1646
+ ui: {
1647
+ ...state.ui,
1648
+ scroll: {
1649
+ ...state.ui.scroll,
1650
+ filePreview: 0,
1651
+ },
1652
+ },
1653
+ };
1654
+ }
1655
+
1656
+ case "files/selectGlobal":
1657
+ return {
1658
+ ...state,
1659
+ files: {
1660
+ ...state.files,
1661
+ selectedArtifactId: action.artifactId || null,
1662
+ },
1663
+ ui: {
1664
+ ...state.ui,
1665
+ scroll: {
1666
+ ...state.ui.scroll,
1667
+ filePreview: 0,
1668
+ },
1669
+ },
1670
+ };
1671
+
1672
+ case "files/previewLoading": {
1673
+ const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
1674
+ const current = bySessionId[action.sessionId] || {
1675
+ entries: [],
1676
+ previews: {},
1677
+ downloads: {},
1678
+ selectedFilename: action.filename || null,
1679
+ };
1680
+ const previews = {
1681
+ ...(current.previews || {}),
1682
+ [action.filename]: {
1683
+ ...(current.previews?.[action.filename] || {}),
1684
+ loading: true,
1685
+ error: null,
1686
+ },
1687
+ };
1688
+ bySessionId[action.sessionId] = {
1689
+ ...current,
1690
+ previews,
1691
+ };
1692
+ return {
1693
+ ...state,
1694
+ files: {
1695
+ ...state.files,
1696
+ bySessionId,
1697
+ },
1698
+ };
1699
+ }
1700
+
1701
+ case "files/previewLoaded": {
1702
+ const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
1703
+ const current = bySessionId[action.sessionId] || {
1704
+ entries: [],
1705
+ previews: {},
1706
+ downloads: {},
1707
+ selectedFilename: action.filename || null,
1708
+ };
1709
+ const previews = {
1710
+ ...(current.previews || {}),
1711
+ [action.filename]: {
1712
+ loading: false,
1713
+ error: null,
1714
+ content: action.content || "",
1715
+ contentType: action.contentType || "text/plain",
1716
+ renderMode: action.renderMode || "text",
1717
+ isBinary: action.isBinary === true,
1718
+ sizeBytes: Number.isFinite(action.sizeBytes) ? action.sizeBytes : null,
1719
+ uploadedAt: action.uploadedAt || "",
1720
+ source: action.source || "agent",
1721
+ },
1722
+ };
1723
+ bySessionId[action.sessionId] = {
1724
+ ...current,
1725
+ previews,
1726
+ };
1727
+ return {
1728
+ ...state,
1729
+ files: {
1730
+ ...state.files,
1731
+ bySessionId,
1732
+ },
1733
+ };
1734
+ }
1735
+
1736
+ case "files/previewError": {
1737
+ const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
1738
+ const current = bySessionId[action.sessionId] || {
1739
+ entries: [],
1740
+ previews: {},
1741
+ downloads: {},
1742
+ selectedFilename: action.filename || null,
1743
+ };
1744
+ const previews = {
1745
+ ...(current.previews || {}),
1746
+ [action.filename]: {
1747
+ ...(current.previews?.[action.filename] || {}),
1748
+ loading: false,
1749
+ error: action.error || "Failed to load file preview",
1750
+ },
1751
+ };
1752
+ bySessionId[action.sessionId] = {
1753
+ ...current,
1754
+ previews,
1755
+ };
1756
+ return {
1757
+ ...state,
1758
+ files: {
1759
+ ...state.files,
1760
+ bySessionId,
1761
+ },
1762
+ };
1763
+ }
1764
+
1765
+ case "files/downloaded": {
1766
+ const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
1767
+ const current = bySessionId[action.sessionId] || {
1768
+ entries: [],
1769
+ previews: {},
1770
+ downloads: {},
1771
+ selectedFilename: action.filename || null,
1772
+ };
1773
+ const downloads = {
1774
+ ...(current.downloads || {}),
1775
+ [action.filename]: {
1776
+ localPath: action.localPath || null,
1777
+ downloadedAt: action.downloadedAt || Date.now(),
1778
+ },
1779
+ };
1780
+ bySessionId[action.sessionId] = {
1781
+ ...current,
1782
+ downloads,
1783
+ };
1784
+ return {
1785
+ ...state,
1786
+ files: {
1787
+ ...state.files,
1788
+ bySessionId,
1789
+ },
1790
+ };
1791
+ }
1792
+
1793
+ case "files/deleted": {
1794
+ const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
1795
+ const current = bySessionId[action.sessionId] || {
1796
+ entries: [],
1797
+ previews: {},
1798
+ downloads: {},
1799
+ selectedFilename: null,
1800
+ };
1801
+ const entries = normalizeArtifactEntries(current.entries).filter((entry) => entry.filename !== action.filename);
1802
+ const nextSelectedFilename = current.selectedFilename === action.filename
1803
+ ? (entries[0]?.filename || null)
1804
+ : current.selectedFilename;
1805
+ const { [action.filename]: _deletedPreview, ...previews } = current.previews || {};
1806
+ const { [action.filename]: _deletedDownload, ...downloads } = current.downloads || {};
1807
+ const deletedArtifactId = action.sessionId && action.filename
1808
+ ? `${action.sessionId}/${action.filename}`
1809
+ : null;
1810
+
1811
+ bySessionId[action.sessionId] = {
1812
+ ...current,
1813
+ entries,
1814
+ previews,
1815
+ downloads,
1816
+ selectedFilename: nextSelectedFilename,
1817
+ };
1818
+
1819
+ return {
1820
+ ...state,
1821
+ files: {
1822
+ ...state.files,
1823
+ bySessionId,
1824
+ selectedArtifactId: state.files.selectedArtifactId === deletedArtifactId
1825
+ ? (nextSelectedFilename ? `${action.sessionId}/${nextSelectedFilename}` : null)
1826
+ : state.files.selectedArtifactId,
1827
+ },
1828
+ ui: {
1829
+ ...state.ui,
1830
+ scroll: {
1831
+ ...state.ui.scroll,
1832
+ filePreview: 0,
1833
+ },
1834
+ },
1835
+ };
1836
+ }
1837
+
1838
+ case "files/fullscreen":
1839
+ return {
1840
+ ...state,
1841
+ files: {
1842
+ ...state.files,
1843
+ fullscreen: Boolean(action.fullscreen),
1844
+ },
1845
+ ui: Boolean(action.fullscreen)
1846
+ ? {
1847
+ ...state.ui,
1848
+ focusRegion: "inspector",
1849
+ fullscreenPane: null,
1850
+ }
1851
+ : state.ui,
1852
+ };
1853
+
1854
+ case "files/filter":
1855
+ return {
1856
+ ...state,
1857
+ files: {
1858
+ ...state.files,
1859
+ filter: {
1860
+ ...normalizeFilesFilter(state.files.filter),
1861
+ ...normalizeFilesFilter(action.filter),
1862
+ },
1863
+ ...(normalizeFilesFilter(action.filter).scope === "selectedSession"
1864
+ ? {}
1865
+ : {
1866
+ selectedArtifactId: state.files.selectedArtifactId || null,
1867
+ }),
1868
+ },
1869
+ ui: {
1870
+ ...state.ui,
1871
+ scroll: {
1872
+ ...state.ui.scroll,
1873
+ filePreview: 0,
1874
+ },
1875
+ },
1876
+ };
1877
+
1878
+ case "logs/config":
1879
+ return {
1880
+ ...state,
1881
+ logs: {
1882
+ ...state.logs,
1883
+ available: Boolean(action.available),
1884
+ availabilityReason: action.availabilityReason || state.logs.availabilityReason,
1885
+ },
1886
+ };
1887
+
1888
+ case "logs/tailing":
1889
+ return {
1890
+ ...state,
1891
+ logs: {
1892
+ ...state.logs,
1893
+ tailing: Boolean(action.tailing),
1894
+ },
1895
+ };
1896
+
1897
+ case "logs/filter":
1898
+ return {
1899
+ ...state,
1900
+ logs: {
1901
+ ...state.logs,
1902
+ filter: {
1903
+ ...state.logs.filter,
1904
+ ...(action.filter || {}),
1905
+ },
1906
+ },
1907
+ };
1908
+
1909
+ case "logs/set":
1910
+ return {
1911
+ ...state,
1912
+ logs: {
1913
+ ...state.logs,
1914
+ entries: normalizeLogEntries(action.entries),
1915
+ },
1916
+ };
1917
+
1918
+ case "logs/append": {
1919
+ const newEntries = (Array.isArray(action.entries) ? action.entries : [action.entry]).filter(Boolean);
1920
+ if (newEntries.length === 0) return state;
1921
+ const combined = [...(state.logs.entries || []), ...newEntries];
1922
+ const capped = combined.length > 1000 ? combined.slice(-1000) : combined;
1923
+ return {
1924
+ ...state,
1925
+ logs: {
1926
+ ...state.logs,
1927
+ entries: capped,
1928
+ },
1929
+ };
1930
+ }
1931
+
1932
+ default:
1933
+ return state;
1934
+ }
1935
+ }