pilotswarm 0.5.22 → 0.5.24

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 (83) hide show
  1. package/package.json +2 -1
  2. package/tui/src/node-sdk-transport.js +22 -5
  3. package/ui/core/src/controller.js +329 -6
  4. package/ui/core/src/formatting.js +46 -1
  5. package/ui/core/src/history.js +29 -0
  6. package/ui/core/src/reducer.js +52 -2
  7. package/ui/core/src/selectors.js +288 -11
  8. package/ui/core/src/state.js +12 -1
  9. package/ui/core/src/themes/github-light.js +5 -1
  10. package/ui/core/src/themes/index.js +4 -0
  11. package/ui/core/src/themes/light-high-contrast.js +4 -1
  12. package/ui/core/src/themes/ms-dos.js +84 -0
  13. package/ui/core/src/themes/win95.js +81 -0
  14. package/ui/react/src/web-app.js +1342 -84
  15. package/web/dist/assets/abnfDiagram-VRR7QNED-Bjz_3uNq.js +1 -0
  16. package/web/dist/assets/arc-a3km8Cfi.js +1 -0
  17. package/web/dist/assets/architectureDiagram-ZJ3FMSHR-4twPAKdk.js +36 -0
  18. package/web/dist/assets/blockDiagram-677ZJIJ3-FnFRjWxr.js +132 -0
  19. package/web/dist/assets/c4Diagram-LMCZKHZV-B8R4iXqh.js +10 -0
  20. package/web/dist/assets/channel-BQwFhqIW.js +1 -0
  21. package/web/dist/assets/chunk-2Q5K7J3B-C7dcVQSj.js +1 -0
  22. package/web/dist/assets/chunk-32BRIVSS-DQQ_NOog.js +1 -0
  23. package/web/dist/assets/chunk-5VM5RSS4-BRR5_oWJ.js +15 -0
  24. package/web/dist/assets/chunk-EX3LRPZG-C0Jg_k6w.js +231 -0
  25. package/web/dist/assets/chunk-JWPE2WC7-CoHkckoP.js +1 -0
  26. package/web/dist/assets/chunk-MOJQB5TN-CTIj5Opw.js +88 -0
  27. package/web/dist/assets/chunk-RYQCIY6F-BsP4OCo4.js +1 -0
  28. package/web/dist/assets/chunk-V7JOEXUC-B5KS36_O.js +206 -0
  29. package/web/dist/assets/chunk-VR4S4FIN-Dm92_7ut.js +1 -0
  30. package/web/dist/assets/chunk-XXDRQBXY-BGivbXmv.js +1 -0
  31. package/web/dist/assets/classDiagram-OUVF2IWQ-C12RKvcF.js +1 -0
  32. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C12RKvcF.js +1 -0
  33. package/web/dist/assets/cose-bilkent-JH36ORCC-DVjdL84y.js +1 -0
  34. package/web/dist/assets/cynefin-VYW2F7L2-pcKhk42O.js +166 -0
  35. package/web/dist/assets/cynefinDiagram-TSTJHNR4-CdHQXyCH.js +62 -0
  36. package/web/dist/assets/cytoscape.esm-D3_iZ_3b.js +321 -0
  37. package/web/dist/assets/dagre-VKFMJZFB-Okt50Noy.js +4 -0
  38. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  39. package/web/dist/assets/diagram-FQU43EPY-D52R5ow3.js +3 -0
  40. package/web/dist/assets/diagram-G47NLZAW-KiJTa2OF.js +24 -0
  41. package/web/dist/assets/diagram-NH7WQ7WH-DZ-NFYLH.js +24 -0
  42. package/web/dist/assets/diagram-OA4YK3LP-et-Lf7yH.js +30 -0
  43. package/web/dist/assets/diagram-WEI45ONY-eXhZ7Ksl.js +41 -0
  44. package/web/dist/assets/ebnfDiagram-CCIWWBDH-CTWYusI0.js +1 -0
  45. package/web/dist/assets/erDiagram-Q63AITRT-DwTN2EqF.js +85 -0
  46. package/web/dist/assets/flowDiagram-23GEKE2U-CmxOIpNk.js +156 -0
  47. package/web/dist/assets/ganttDiagram-NO4QXBWP-TsPnDFPN.js +292 -0
  48. package/web/dist/assets/gitGraphDiagram-IHSO6WYX-B_IVM0u8.js +106 -0
  49. package/web/dist/assets/graph-DOmOIIwC.js +1 -0
  50. package/web/dist/assets/{index-CuxEGti7.js → index-CB16o5aO.js} +3 -3
  51. package/web/dist/assets/index-CN_7NnbR.css +1 -0
  52. package/web/dist/assets/infoDiagram-FWYZ7A6U-BFjk6921.js +2 -0
  53. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  54. package/web/dist/assets/ishikawaDiagram-FXEZZL3T-BiTzJYKW.js +70 -0
  55. package/web/dist/assets/journeyDiagram-5HDEW3XC-eeyGUQSj.js +139 -0
  56. package/web/dist/assets/kanban-definition-HUTT4EX6-DPYZ3frK.js +89 -0
  57. package/web/dist/assets/katex-HP8lGamR.js +257 -0
  58. package/web/dist/assets/layout-D-LzfAck.js +1 -0
  59. package/web/dist/assets/linear-Buu9IOJQ.js +1 -0
  60. package/web/dist/assets/map-DxJ2ADlA.js +1 -0
  61. package/web/dist/assets/mermaid.core-xO1oSQS3.js +308 -0
  62. package/web/dist/assets/mindmap-definition-LN4V7U3C-TasB4KZ4.js +96 -0
  63. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  64. package/web/dist/assets/pegDiagram-2B236MQR-Btr2u_Xy.js +1 -0
  65. package/web/dist/assets/pieDiagram-ENE6RG2P-0iBpCJcu.js +39 -0
  66. package/web/dist/assets/pilotswarm-v4IdVngK.js +93 -0
  67. package/web/dist/assets/quadrantDiagram-ABIIQ3AL-CbVQOzUs.js +7 -0
  68. package/web/dist/assets/railroadDiagram-RFXS5EU6-BkwzDhMx.js +1 -0
  69. package/web/dist/assets/requirementDiagram-TGXJPOKE-DpF9yvmD.js +84 -0
  70. package/web/dist/assets/sankeyDiagram-HTMAVEWB-CjvbElms.js +40 -0
  71. package/web/dist/assets/sequenceDiagram-DBY2YBRQ-DRj2K28H.js +162 -0
  72. package/web/dist/assets/sizeCapture-X5ZJPWSS-Bi3gxz8F.js +1 -0
  73. package/web/dist/assets/stateDiagram-2N3HPSRC-_INdmyGU.js +1 -0
  74. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-A5HcuE43.js +1 -0
  75. package/web/dist/assets/swimlanes-5IMT3BWC-D1pFtL4Y.js +2 -0
  76. package/web/dist/assets/swimlanesDiagram-G3AALYLV-Bf_Rn9Zk.js +8 -0
  77. package/web/dist/assets/timeline-definition-FHXFAJF6-PYQ9uRSp.js +120 -0
  78. package/web/dist/assets/vennDiagram-L72KCM5P-xCF6u31z.js +34 -0
  79. package/web/dist/assets/wardleyDiagram-EHGQE667-DPcm9Msn.js +78 -0
  80. package/web/dist/assets/xychartDiagram-FW5EYKEG-D9ZNU0hG.js +7 -0
  81. package/web/dist/index.html +3 -3
  82. package/web/dist/assets/index-CxkLM8_a.css +0 -1
  83. package/web/dist/assets/pilotswarm-CjMx44so.js +0 -90
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.22",
3
+ "version": "0.5.24",
4
4
  "description": "PilotSwarm application package: terminal UI, browser portal + Web API server, and MCP server — one install, three bins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -78,6 +78,7 @@
78
78
  "hono": "^4.12.10",
79
79
  "ink": "^6.8.0",
80
80
  "jose": "^6.2.2",
81
+ "mermaid": "^11.16.0",
81
82
  "pilotswarm-sdk": "^0.5.20",
82
83
  "react": "^19.2.4",
83
84
  "react-dom": "^19.2.4",
@@ -1690,8 +1690,13 @@ export class NodeSdkTransport {
1690
1690
  const payload = JSON.parse(body || "{}");
1691
1691
  const items = Array.isArray(payload?.items) ? payload.items : [];
1692
1692
  resolve(items
1693
- .map((item) => String(item?.metadata?.name || "").trim())
1694
- .filter(Boolean));
1693
+ .map((item) => ({
1694
+ name: String(item?.metadata?.name || "").trim(),
1695
+ containers: (Array.isArray(item?.spec?.containers) ? item.spec.containers : [])
1696
+ .map((container) => String(container?.name || "").trim())
1697
+ .filter(Boolean),
1698
+ }))
1699
+ .filter((pod) => pod.name));
1695
1700
  } catch (error) {
1696
1701
  reject(error);
1697
1702
  }
@@ -1709,6 +1714,9 @@ export class NodeSdkTransport {
1709
1714
  timestamps: "true",
1710
1715
  tailLines: String(options.tailLines ?? 500),
1711
1716
  });
1717
+ // Multi-container pods (sidecars, init containers) reject log reads
1718
+ // that don't name a container (400 BadRequest).
1719
+ if (options.container) params.set("container", options.container);
1712
1720
  const pathName = `/api/v1/namespaces/${encodeURIComponent(config.namespace)}/pods/${encodeURIComponent(podName)}/log?${params.toString()}`;
1713
1721
 
1714
1722
  return new Promise((resolve, reject) => {
@@ -1810,9 +1818,9 @@ export class NodeSdkTransport {
1810
1818
  this.logTailHandle = handle;
1811
1819
 
1812
1820
  this.listPodsFromKubeApi(config, labelSelector)
1813
- .then(async (podNames) => {
1821
+ .then(async (pods) => {
1814
1822
  if (handle.stopped || this.logTailHandle !== handle) return;
1815
- if (podNames.length === 0) {
1823
+ if (pods.length === 0) {
1816
1824
  this.emitSyntheticLogMessage(
1817
1825
  `No pods matched label selector ${JSON.stringify(labelSelector)} in namespace ${config.namespace}.`,
1818
1826
  "warn",
@@ -1820,8 +1828,17 @@ export class NodeSdkTransport {
1820
1828
  return;
1821
1829
  }
1822
1830
 
1831
+ // Container selection per pod: explicit K8S_LOG_CONTAINER wins,
1832
+ // else the conventional "worker" container when present, else
1833
+ // the pod's first container. Single-container pods work either
1834
+ // way; multi-container pods 400 without an explicit name.
1835
+ const configuredContainer = String(process.env.K8S_LOG_CONTAINER || "").trim();
1823
1836
  const results = await Promise.allSettled(
1824
- podNames.map((podName) => this.streamPodLogsFromKubeApi(config, podName, handle)),
1837
+ pods.map((pod) => {
1838
+ const container = configuredContainer
1839
+ || (pod.containers.includes("worker") ? "worker" : pod.containers[0]);
1840
+ return this.streamPodLogsFromKubeApi(config, pod.name, handle, container ? { container } : {});
1841
+ }),
1825
1842
  );
1826
1843
 
1827
1844
  if (handle.stopped || this.logTailHandle !== handle) return;
@@ -32,6 +32,7 @@ import {
32
32
  selectLiveActivityLines,
33
33
  selectChatLines,
34
34
  selectFileBrowserItems,
35
+ selectChatOrderedArtifactIds,
35
36
  selectFileSessionIdsForScope,
36
37
  selectFilesScope,
37
38
  selectFilesView,
@@ -1067,6 +1068,9 @@ function normalizePreviewPayload(filename, rawContent, contentType = "", metadat
1067
1068
  };
1068
1069
  }
1069
1070
 
1071
+ /** How long a fetched artifact list is trusted before a refetch. */
1072
+ const FILES_LIST_TTL_MS = 10_000;
1073
+
1070
1074
  export class PilotSwarmUiController {
1071
1075
  constructor({ store, transport }) {
1072
1076
  this.store = store;
@@ -2761,7 +2765,13 @@ export class PilotSwarmUiController {
2761
2765
 
2762
2766
  const current = this.getState().files.bySessionId[sessionId];
2763
2767
  if (!force && current?.loading) return current;
2764
- if (!force && current?.loaded) {
2768
+ // A short TTL rather than force-on-every-call. Forcing unconditionally
2769
+ // made the periodic inspector refresh refetch every cycle, which
2770
+ // re-rendered the list and made it visibly flicker. Caching forever was
2771
+ // the original bug. Ten seconds keeps a live agent's new artifacts
2772
+ // appearing on their own without hammering the API.
2773
+ const age = Date.now() - (Number(current?.fetchedAt) || 0);
2774
+ if (!force && current?.loaded && age < FILES_LIST_TTL_MS) {
2765
2775
  if (current.selectedFilename) {
2766
2776
  await this.ensureFilePreview(sessionId, current.selectedFilename).catch(() => {});
2767
2777
  }
@@ -2771,10 +2781,12 @@ export class PilotSwarmUiController {
2771
2781
  this.dispatch({ type: "files/sessionLoading", sessionId });
2772
2782
  try {
2773
2783
  const entries = await this.transport.listArtifacts(sessionId);
2784
+ const fetchedAt = Date.now();
2774
2785
  this.dispatch({
2775
2786
  type: "files/sessionLoaded",
2776
2787
  sessionId,
2777
2788
  entries,
2789
+ fetchedAt,
2778
2790
  });
2779
2791
  const nextState = this.getState().files.bySessionId[sessionId];
2780
2792
  if (nextState?.selectedFilename) {
@@ -3024,6 +3036,97 @@ export class PilotSwarmUiController {
3024
3036
  return download;
3025
3037
  }
3026
3038
 
3039
+ /**
3040
+ * Bulk selection on the artifact list.
3041
+ *
3042
+ * Marking never moves selectedArtifactId, so the preview keeps showing the
3043
+ * artifact it was already on while you mark others for deletion.
3044
+ */
3045
+ toggleArtifactMark(item) {
3046
+ if (!item?.id) return;
3047
+ this.dispatch({ type: "files/toggleMark", artifactId: item.id });
3048
+ }
3049
+
3050
+ /** Shift-click: mark the contiguous run between the anchor and `item`. */
3051
+ markArtifactRange(item) {
3052
+ if (!item?.id) return;
3053
+ const items = selectFileBrowserItems(this.getState());
3054
+ const marked = this.getState().files.markedIds || [];
3055
+ const anchorId = marked.length > 0 ? marked[marked.length - 1] : this.getState().files.selectedArtifactId;
3056
+ const from = items.findIndex((entry) => entry.id === anchorId);
3057
+ const to = items.findIndex((entry) => entry.id === item.id);
3058
+ if (to < 0) return;
3059
+ if (from < 0) {
3060
+ this.dispatch({ type: "files/toggleMark", artifactId: item.id });
3061
+ return;
3062
+ }
3063
+ const [lo, hi] = from <= to ? [from, to] : [to, from];
3064
+ const range = items.slice(lo, hi + 1).map((entry) => entry.id);
3065
+ this.dispatch({ type: "files/setMarks", artifactIds: [...new Set([...marked, ...range])] });
3066
+ }
3067
+
3068
+ clearArtifactMarks() {
3069
+ this.dispatch({ type: "files/clearMarks" });
3070
+ }
3071
+
3072
+ /** Delete every marked artifact. Confirms once for the whole batch. */
3073
+ async deleteMarkedArtifacts({ confirmed = false } = {}) {
3074
+ const state = this.getState();
3075
+ const markedIds = state.files.markedIds || [];
3076
+ if (markedIds.length === 0) {
3077
+ this.dispatch({ type: "ui/status", text: "No artifacts marked" });
3078
+ return false;
3079
+ }
3080
+ if (typeof this.transport.deleteArtifact !== "function") {
3081
+ this.dispatch({ type: "ui/status", text: "Artifact deletion is not supported by this transport" });
3082
+ return false;
3083
+ }
3084
+ if (!confirmed) {
3085
+ this.dispatch({
3086
+ type: "ui/modal",
3087
+ modal: {
3088
+ type: "confirm",
3089
+ title: "Delete Artifacts",
3090
+ message: `Delete ${markedIds.length} artifact${markedIds.length === 1 ? "" : "s"}? This action cannot be undone.`,
3091
+ confirmLabel: `Delete ${markedIds.length}`,
3092
+ action: "deleteMarkedArtifacts",
3093
+ previousFocus: state.ui.focusRegion,
3094
+ },
3095
+ });
3096
+ return false;
3097
+ }
3098
+
3099
+ const touchedSessions = new Set();
3100
+ let deleted = 0;
3101
+ const failures = [];
3102
+ for (const id of markedIds) {
3103
+ const slash = String(id).indexOf("/");
3104
+ if (slash <= 0) continue;
3105
+ const sessionId = id.slice(0, slash);
3106
+ const filename = id.slice(slash + 1);
3107
+ try {
3108
+ await this.transport.deleteArtifact(sessionId, filename);
3109
+ this.dispatch({ type: "files/deleted", sessionId, filename });
3110
+ touchedSessions.add(sessionId);
3111
+ deleted += 1;
3112
+ } catch (error) {
3113
+ // Keep going: one failure should not strand the rest of the batch.
3114
+ failures.push(`${filename}: ${error?.message || String(error)}`);
3115
+ }
3116
+ }
3117
+ for (const sessionId of touchedSessions) {
3118
+ await this.ensureFilesForSession(sessionId, { force: true }).catch(() => null);
3119
+ }
3120
+ this.dispatch({ type: "files/clearMarks" });
3121
+ this.dispatch({
3122
+ type: "ui/status",
3123
+ text: failures.length > 0
3124
+ ? `Deleted ${deleted}, ${failures.length} failed — ${failures[0]}`
3125
+ : `Deleted ${deleted} artifact${deleted === 1 ? "" : "s"}`,
3126
+ });
3127
+ return failures.length === 0;
3128
+ }
3129
+
3027
3130
  async deleteSelectedArtifact({ confirmed = false } = {}) {
3028
3131
  const state = this.getState();
3029
3132
  const selectedItem = selectSelectedFileBrowserItem(state);
@@ -3228,6 +3331,7 @@ export class PilotSwarmUiController {
3228
3331
 
3229
3332
  async selectFileBrowserItem(item) {
3230
3333
  if (!item?.sessionId || !item?.filename) return;
3334
+ this.dispatch({ type: "files/previewOrigin", origin: "list" });
3231
3335
  const scope = selectFilesScope(this.getState());
3232
3336
  if (scope === "allSessions") {
3233
3337
  this.dispatch({
@@ -3870,7 +3974,7 @@ export class PilotSwarmUiController {
3870
3974
  }
3871
3975
 
3872
3976
  setChatViewMode(mode) {
3873
- if (mode !== "summary" && mode !== "transcript") return;
3977
+ if (mode !== "summary" && mode !== "transcript" && mode !== "rich") return;
3874
3978
  if (this.getActiveSession()?.isGroup) return;
3875
3979
  this.dispatch({ type: "ui/chatViewMode", mode });
3876
3980
  }
@@ -4331,6 +4435,129 @@ export class PilotSwarmUiController {
4331
4435
  return created.sessionId;
4332
4436
  }
4333
4437
 
4438
+ /**
4439
+ * Reveal an artifact in the Files tab with its preview loaded.
4440
+ *
4441
+ * The entry point for artifact links in the chat transcript: the chat only
4442
+ * ever offered "download", which is the wrong verb for a patch you want to
4443
+ * READ. Switches the inspector to Files, selects the artifact, and warms
4444
+ * the preview so the pane is populated by the time it renders.
4445
+ */
4446
+ async revealArtifact(sessionId, filename, { force = true, fullscreen = false } = {}) {
4447
+ const resolvedSessionId = sessionId || this.getState().sessions.activeSessionId;
4448
+ const name = String(filename || "").trim();
4449
+ if (!resolvedSessionId || !name) return false;
4450
+
4451
+ // Capture the list's current selection BEFORE we move it, so backing
4452
+ // out of a chat-opened preview leaves the artifact list untouched.
4453
+ this.dispatch({
4454
+ type: "files/previewOrigin",
4455
+ origin: "chat",
4456
+ restoreArtifactId: this.getState().files.selectedArtifactId || null,
4457
+ });
4458
+
4459
+ if (this.getState().sessions.activeSessionId !== resolvedSessionId) {
4460
+ await this.loadSession(resolvedSessionId).catch(() => null);
4461
+ }
4462
+ this.dispatch({ type: "ui/inspectorTab", inspectorTab: "files" });
4463
+ // Focus the inspector, not just the tab. On desktop this makes j/k
4464
+ // drive the artifact list immediately; on mobile the visible pane is
4465
+ // derived from focusRegion, so without this the preview would open on
4466
+ // a pane the user cannot see.
4467
+ this.setFocus("inspector");
4468
+ await this.ensureFilesForSession(resolvedSessionId, { force }).catch(() => null);
4469
+ this.dispatch({ type: "files/select", sessionId: resolvedSessionId, filename: name });
4470
+ await this.ensureFilePreview(resolvedSessionId, name, { force }).catch(() => null);
4471
+ if (fullscreen) this.dispatch({ type: "files/fullscreen", fullscreen: true });
4472
+ return true;
4473
+ }
4474
+
4475
+ /**
4476
+ * Leave the artifact preview, returning wherever the user came FROM.
4477
+ *
4478
+ * A chat-opened preview backs out to the chat pane and restores the list's
4479
+ * previous selection — following a link from the transcript must not
4480
+ * reorganize the Files tab behind the user's back. A list-opened preview
4481
+ * just drops out of full screen and stays on the list.
4482
+ */
4483
+ async closeArtifactPreview() {
4484
+ const state = this.getState();
4485
+ const origin = state.files.previewOrigin;
4486
+ this.dispatch({ type: "files/fullscreen", fullscreen: false });
4487
+
4488
+ if (origin !== "chat") {
4489
+ this.dispatch({ type: "files/previewOrigin", origin: null });
4490
+ this.setFocus(FOCUS_REGIONS.INSPECTOR);
4491
+ return;
4492
+ }
4493
+
4494
+ const restoreId = state.files.restoreArtifactId;
4495
+ if (restoreId) {
4496
+ const slash = String(restoreId).indexOf("/");
4497
+ if (slash > 0) {
4498
+ this.dispatch({
4499
+ type: "files/select",
4500
+ sessionId: restoreId.slice(0, slash),
4501
+ filename: restoreId.slice(slash + 1),
4502
+ });
4503
+ }
4504
+ } else {
4505
+ this.dispatch({ type: "files/select", sessionId: null, filename: null });
4506
+ }
4507
+ this.dispatch({ type: "files/previewOrigin", origin: null });
4508
+ // Dispatch the focus DIRECTLY rather than via setFocus: that runs the
4509
+ // region through normalizeFocusRegion, which falls back to order[0]
4510
+ // when the region is missing from the current layout's focus order —
4511
+ // so "chat" was being clamped to the inspector and back-from-chat
4512
+ // landed on the artifact list instead of the transcript.
4513
+ this.dispatch({ type: "ui/focus", focusRegion: FOCUS_REGIONS.CHAT });
4514
+ }
4515
+
4516
+ /**
4517
+ * Step the preview to the next/previous artifact. Never wraps.
4518
+ *
4519
+ * A chat-opened preview walks the CONVERSATION order — the order the work
4520
+ * happened in — while a list-opened one walks the Files list order, which
4521
+ * is sorted for browsing. Same buttons, two sequences, chosen by origin.
4522
+ */
4523
+ async stepArtifactPreview(delta) {
4524
+ const state = this.getState();
4525
+ if (state.files.previewOrigin !== "chat") {
4526
+ await this.moveFileSelection(delta);
4527
+ return;
4528
+ }
4529
+ const ordered = selectChatOrderedArtifactIds(state);
4530
+ if (ordered.length === 0) {
4531
+ await this.moveFileSelection(delta);
4532
+ return;
4533
+ }
4534
+ const currentIndex = ordered.indexOf(state.files.selectedArtifactId);
4535
+ // Unknown current position: start from whichever end we are heading in
4536
+ // from, rather than silently doing nothing.
4537
+ const from = currentIndex >= 0 ? currentIndex : (delta > 0 ? -1 : ordered.length);
4538
+ const nextIndex = from + (delta > 0 ? 1 : -1);
4539
+ if (nextIndex < 0 || nextIndex >= ordered.length) return; // no wraparound
4540
+ const nextId = ordered[nextIndex];
4541
+ const slash = nextId.indexOf("/");
4542
+ if (slash <= 0) return;
4543
+ const sessionId = nextId.slice(0, slash);
4544
+ const filename = nextId.slice(slash + 1);
4545
+ this.dispatch({ type: "files/select", sessionId, filename });
4546
+ await this.ensureFilePreview(sessionId, filename).catch(() => {});
4547
+ }
4548
+
4549
+ /** Can the preview step in this direction? Drives arrow disabled state. */
4550
+ canStepArtifactPreview(delta) {
4551
+ const state = this.getState();
4552
+ const ordered = state.files.previewOrigin === "chat"
4553
+ ? selectChatOrderedArtifactIds(state)
4554
+ : selectFileBrowserItems(state).map((item) => item.id);
4555
+ if (ordered.length === 0) return false;
4556
+ const i = ordered.indexOf(state.files.selectedArtifactId);
4557
+ if (i < 0) return true;
4558
+ return delta > 0 ? i < ordered.length - 1 : i > 0;
4559
+ }
4560
+
4334
4561
  async finalizeArtifactUpload(upload, { sessionId = null, suppressStatus = false } = {}) {
4335
4562
  const resolvedSessionId = sessionId || upload?.sessionId || await this.ensurePromptAttachmentSessionId();
4336
4563
  if (!resolvedSessionId || !upload?.filename) {
@@ -5160,6 +5387,8 @@ export class PilotSwarmUiController {
5160
5387
  await this.regenerateActiveSession({ confirmed: true, ...(modal.extras || {}) });
5161
5388
  } else if (modal.action === "deleteArtifact") {
5162
5389
  await this.deleteSelectedArtifact({ confirmed: true });
5390
+ } else if (modal.action === "deleteMarkedArtifacts") {
5391
+ await this.deleteMarkedArtifacts({ confirmed: true });
5163
5392
  }
5164
5393
  return;
5165
5394
  }
@@ -6058,10 +6287,30 @@ export class PilotSwarmUiController {
6058
6287
  });
6059
6288
  }
6060
6289
 
6290
+ /** True when the artifact preview has taken over the activity slot. */
6291
+ artifactPreviewOwnsActivitySlot(state = this.getState()) {
6292
+ return state.ui.inspectorTab === "files" && Boolean(state.files?.selectedArtifactId);
6293
+ }
6294
+
6295
+ /** Inspector focus with the Files tab open = the artifact list is driving. */
6296
+ focusIsArtifactList(state = this.getState()) {
6297
+ return state.ui.focusRegion === FOCUS_REGIONS.INSPECTOR && state.ui.inspectorTab === "files";
6298
+ }
6299
+
6061
6300
  getScrollablePaneForFocus() {
6062
- const focus = this.getState().ui.focusRegion;
6301
+ const state = this.getState();
6302
+ const focus = state.ui.focusRegion;
6063
6303
  if (focus === FOCUS_REGIONS.CHAT) return "chat";
6064
- if (focus === FOCUS_REGIONS.ACTIVITY) return "activity";
6304
+ if (focus === FOCUS_REGIONS.ACTIVITY) {
6305
+ // The preview occupies the activity slot while an artifact is
6306
+ // selected, so activity focus must scroll the PREVIEW. This is what
6307
+ // lets clicking the list and clicking the preview mean different
6308
+ // things: the list keeps inspector focus (arrows move the
6309
+ // selection) while the preview claims activity focus (arrows
6310
+ // scroll it).
6311
+ if (this.artifactPreviewOwnsActivitySlot(state)) return "filePreview";
6312
+ return "activity";
6313
+ }
6065
6314
  if (focus === FOCUS_REGIONS.INSPECTOR) {
6066
6315
  if (this.getState().ui.inspectorTab === "files") return "filePreview";
6067
6316
  return "inspector";
@@ -6659,7 +6908,14 @@ export class PilotSwarmUiController {
6659
6908
  });
6660
6909
  }
6661
6910
 
6662
- async regenerateActiveSession({ confirmed = false, instructions = "", distillMode = "llm" } = {}) {
6911
+ async regenerateActiveSession({
6912
+ confirmed = false,
6913
+ instructions = "",
6914
+ distillMode = "llm",
6915
+ distillerModel = "",
6916
+ distillerEffort = "",
6917
+ distillerContextTier = "",
6918
+ } = {}) {
6663
6919
  const state = this.getState();
6664
6920
  const sessionId = state.sessions.activeSessionId;
6665
6921
  if (!sessionId) return;
@@ -6695,7 +6951,18 @@ export class PilotSwarmUiController {
6695
6951
  // Distillation inputs the portal's renderer binds via
6696
6952
  // updateConfirmExtras; plain-confirm renderers submit these
6697
6953
  // defaults untouched.
6698
- extras: { instructions: "", distillMode: "llm" },
6954
+ extras: {
6955
+ instructions: "",
6956
+ distillMode: "llm",
6957
+ // Empty = deployment default (cluster default model,
6958
+ // model's own default effort/tier). The distiller runs
6959
+ // on machinery config, NOT the served session's model,
6960
+ // so these are explicit choices rather than inherited.
6961
+ distillerModel: "",
6962
+ distillerEffort: "",
6963
+ distillerContextTier: "",
6964
+ distillerModelOptions: await this._loadDistillerModelOptions(),
6965
+ },
6699
6966
  },
6700
6967
  });
6701
6968
  return;
@@ -6709,6 +6976,9 @@ export class PilotSwarmUiController {
6709
6976
  force: true,
6710
6977
  ...(trimmed ? { instructions: trimmed.slice(0, 4000) } : {}),
6711
6978
  ...(distillMode === "deterministic" ? { distillMode: "deterministic" } : {}),
6979
+ ...(distillerModel ? { distillerModel } : {}),
6980
+ ...(distillerEffort ? { distillerReasoningEffort: distillerEffort } : {}),
6981
+ ...(distillerContextTier ? { distillerContextTier } : {}),
6712
6982
  });
6713
6983
  this.dispatch({ type: "ui/status", text: `Regeneration requested for ${sessionId.slice(0, 8)} — rebuilding at the next boundary` });
6714
6984
  } catch (error) {
@@ -6718,6 +6988,49 @@ export class PilotSwarmUiController {
6718
6988
  await this.refreshSessions();
6719
6989
  }
6720
6990
 
6991
+ /**
6992
+ * Model choices for the regen distiller picker: qualified name plus the
6993
+ * efforts / context tiers each model actually supports, so the UI can
6994
+ * offer only valid combinations. Failure is non-fatal — the picker simply
6995
+ * falls back to "deployment default".
6996
+ */
6997
+ async _loadDistillerModelOptions() {
6998
+ if (typeof this.transport.listModels !== "function") return [];
6999
+ try {
7000
+ // The web transport's listModels() takes no arguments and returns a
7001
+ // FLAT model list, while the direct transport can return provider
7002
+ // groups. Reading only `group.models` flatMapped the flat shape to
7003
+ // nothing, so the portal offered "deployment default" and nothing
7004
+ // else — with the catch below hiding it. Accept both shapes.
7005
+ const raw = await this.transport.listModels({ groupByProvider: true });
7006
+ const top = Array.isArray(raw)
7007
+ ? raw
7008
+ : (Array.isArray(raw?.providers) ? raw.providers
7009
+ : (Array.isArray(raw?.models) ? raw.models : []));
7010
+ const list = top.flatMap((entry) => (
7011
+ Array.isArray(entry?.models)
7012
+ ? entry.models.map((m) => ({ ...m, providerId: m.providerId || entry.providerId }))
7013
+ : [entry]
7014
+ ));
7015
+ return list.filter((model) => model && (model.qualifiedName || model.modelName)).map((model) => ({
7016
+ qualifiedName: model.qualifiedName || model.modelName,
7017
+ modelName: model.modelName || model.qualifiedName,
7018
+ providerId: model.providerId || "",
7019
+ supportedReasoningEfforts: Array.isArray(model.supportedReasoningEfforts)
7020
+ ? model.supportedReasoningEfforts
7021
+ : [],
7022
+ supportedContextTiers: Array.isArray(model.supportedContextTiers)
7023
+ ? model.supportedContextTiers
7024
+ : [],
7025
+ }));
7026
+ } catch (error) {
7027
+ // Previously swallowed silently, which is how an empty picker
7028
+ // looked like "no models configured" instead of a failed call.
7029
+ this.dispatch({ type: "ui/status", text: `Could not load distiller models: ${error?.message || String(error)}` });
7030
+ return [];
7031
+ }
7032
+ }
7033
+
6721
7034
  /**
6722
7035
  * Stop the active session's in-flight LLM turn without touching session
6723
7036
  * lifecycle. Applies to user AND system sessions; only group/container
@@ -7265,6 +7578,12 @@ export class PilotSwarmUiController {
7265
7578
  await this.moveSessionPage(-1);
7266
7579
  return;
7267
7580
  }
7581
+ // Focused on the artifact LIST: page the selection, not a
7582
+ // scroll offset — the list is the thing being driven there.
7583
+ if (this.focusIsArtifactList()) {
7584
+ await this.moveFileSelection(-10);
7585
+ return;
7586
+ }
7268
7587
  this.scrollCurrentPane(10);
7269
7588
  return;
7270
7589
  case UI_COMMANDS.PAGE_DOWN:
@@ -7272,6 +7591,10 @@ export class PilotSwarmUiController {
7272
7591
  await this.moveSessionPage(1);
7273
7592
  return;
7274
7593
  }
7594
+ if (this.focusIsArtifactList()) {
7595
+ await this.moveFileSelection(10);
7596
+ return;
7597
+ }
7275
7598
  this.scrollCurrentPane(-10);
7276
7599
  return;
7277
7600
  case UI_COMMANDS.EXPAND_HISTORY:
@@ -100,6 +100,44 @@ export function shortModelName(model) {
100
100
  return value.includes(":") ? value.split(":").slice(1).join(":") : value;
101
101
  }
102
102
 
103
+ /**
104
+ * Compact timestamp for fixed-width columns (the sequence pane).
105
+ *
106
+ * formatTimestamp renders "26 Jul 20:10:19" once a row is older than today —
107
+ * 15 characters into a column sized for "20:10:19", so the date-bearing rows
108
+ * clipped to "26 Jul …" and the time was lost entirely. Dropping the seconds
109
+ * and the space inside the date gets a dated stamp to 11 characters, which
110
+ * fits alongside the same-day form without stealing width from the lanes.
111
+ */
112
+ export function formatTimestampCompact(value, now = new Date()) {
113
+ if (!value) return "";
114
+ try {
115
+ const date = value instanceof Date ? value : new Date(value);
116
+ if (Number.isNaN(date.getTime())) return "";
117
+ const hhmm = date.toLocaleTimeString(undefined, {
118
+ hour: "2-digit", minute: "2-digit", hour12: false,
119
+ });
120
+ const sameLocalDay = date.getFullYear() === now.getFullYear()
121
+ && date.getMonth() === now.getMonth()
122
+ && date.getDate() === now.getDate();
123
+ if (sameLocalDay) {
124
+ return date.toLocaleTimeString(undefined, {
125
+ hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
126
+ });
127
+ }
128
+ const day = String(date.getDate());
129
+ const month = date.toLocaleDateString("en-GB", { month: "short" });
130
+ // Year only when it differs — a two-digit suffix, since the column is
131
+ // shared with same-day rows and every extra char costs lane width.
132
+ const year = date.getFullYear() === now.getFullYear()
133
+ ? ""
134
+ : String(date.getFullYear()).slice(-2);
135
+ return `${day}${month}${year} ${hhmm}`;
136
+ } catch {
137
+ return "";
138
+ }
139
+ }
140
+
103
141
  export function formatTimestamp(value, now = new Date()) {
104
142
  if (!value) return "";
105
143
  try {
@@ -403,7 +441,14 @@ export function decorateArtifactLinksForChat(text) {
403
441
  const source = String(text || "");
404
442
  if (!source) return source;
405
443
  ARTIFACT_URI_RE.lastIndex = 0;
406
- const withArtifacts = source.replace(ARTIFACT_URI_RE, (_match, sessionId, rawFilename) => {
444
+ const withArtifacts = source.replace(ARTIFACT_URI_RE, (_match, sessionId, rawFilename, offset, whole) => {
445
+ // IDEMPOTENCE. This runs from ~8 call sites and some paths decorate the
446
+ // same text twice. A bare `artifact://` becomes `[artifact: f](artifact://…)`,
447
+ // and on a second pass the URI now sitting inside the link TARGET matched
448
+ // again, producing a nested link whose href was `[artifact: f](artifact://…`.
449
+ // The browser then treated that as a relative URL and navigated away from
450
+ // the SPA entirely. Skip any URI already in a markdown link target.
451
+ if (whole.slice(Math.max(0, offset - 2), offset) === "](") return _match;
407
452
  const filename = normalizeArtifactFilename(rawFilename);
408
453
  if (!sessionId || !filename) return _match;
409
454
  return `[artifact: ${filename}](artifact://${sessionId}/${filename}) (press a to download)`;
@@ -24,6 +24,12 @@ export const CHAT_HISTORY_EVENT_TYPES = [
24
24
  // A refused regeneration — surfaced inline so the optimistic "regeneration
25
25
  // accepted" ack is corrected by the real outcome (e.g. cooldown/too_young).
26
26
  "session.regenerate_refused",
27
+ // A regeneration that was accepted and then FAILED downstream (archive or
28
+ // distill). Previously invisible: the tool returns an optimistic ack on
29
+ // enqueue, so a failure left the agent reporting success while the epoch
30
+ // silently never flipped. Observed live — two attempts died on
31
+ // ARTIFACT_TOO_LARGE with nothing shown in the transcript.
32
+ "session.regenerate_failed",
27
33
  ];
28
34
 
29
35
  // Build the inline transcript divider marking a session-regeneration epoch
@@ -58,6 +64,20 @@ function buildRegenRefusedItem(event) {
58
64
  };
59
65
  }
60
66
 
67
+ function buildRegenFailedItem(event) {
68
+ const data = event?.data && typeof event.data === "object" ? event.data : {};
69
+ const rawError = typeof data.error === "string" ? data.error : "";
70
+ return {
71
+ id: `${event.sessionId}:${event.seq}:regen-failed`,
72
+ kind: "regen-failed",
73
+ role: "regen-failed",
74
+ stage: typeof data.stage === "string" ? data.stage : "unknown",
75
+ error: rawError,
76
+ time: formatTimestamp(event.createdAt),
77
+ createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
78
+ };
79
+ }
80
+
61
81
  function clampHistoryItems(items, maxItems) {
62
82
  const list = Array.isArray(items) ? items.filter(Boolean) : [];
63
83
  const safeMax = Math.max(DEFAULT_HISTORY_EVENT_LIMIT, Number(maxItems) || DEFAULT_HISTORY_EVENT_LIMIT);
@@ -947,6 +967,10 @@ export function buildHistoryModel(events = [], options = {}) {
947
967
  chat.push(buildRegenRefusedItem(event));
948
968
  continue;
949
969
  }
970
+ if (event.eventType === "session.regenerate_failed") {
971
+ chat.push(buildRegenFailedItem(event));
972
+ continue;
973
+ }
950
974
  const activityItem = formatActivity(event);
951
975
  if (activityItem) activity.push(activityItem);
952
976
  }
@@ -1054,6 +1078,11 @@ export function appendEventToHistory(history, event) {
1054
1078
  next.chat = clampHistoryItems(next.chat, loadedEventLimit);
1055
1079
  return next;
1056
1080
  }
1081
+ if (event.eventType === "session.regenerate_failed") {
1082
+ next.chat.push(buildRegenFailedItem(event));
1083
+ next.chat = clampHistoryItems(next.chat, loadedEventLimit);
1084
+ return next;
1085
+ }
1057
1086
  const activityItem = formatActivity(event);
1058
1087
  if (activityItem) {
1059
1088
  next.activity.push(activityItem);