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.
- package/package.json +2 -1
- package/tui/src/node-sdk-transport.js +22 -5
- package/ui/core/src/controller.js +329 -6
- package/ui/core/src/formatting.js +46 -1
- package/ui/core/src/history.js +29 -0
- package/ui/core/src/reducer.js +52 -2
- package/ui/core/src/selectors.js +288 -11
- package/ui/core/src/state.js +12 -1
- package/ui/core/src/themes/github-light.js +5 -1
- package/ui/core/src/themes/index.js +4 -0
- package/ui/core/src/themes/light-high-contrast.js +4 -1
- package/ui/core/src/themes/ms-dos.js +84 -0
- package/ui/core/src/themes/win95.js +81 -0
- package/ui/react/src/web-app.js +1342 -84
- package/web/dist/assets/abnfDiagram-VRR7QNED-Bjz_3uNq.js +1 -0
- package/web/dist/assets/arc-a3km8Cfi.js +1 -0
- package/web/dist/assets/architectureDiagram-ZJ3FMSHR-4twPAKdk.js +36 -0
- package/web/dist/assets/blockDiagram-677ZJIJ3-FnFRjWxr.js +132 -0
- package/web/dist/assets/c4Diagram-LMCZKHZV-B8R4iXqh.js +10 -0
- package/web/dist/assets/channel-BQwFhqIW.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-C7dcVQSj.js +1 -0
- package/web/dist/assets/chunk-32BRIVSS-DQQ_NOog.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-BRR5_oWJ.js +15 -0
- package/web/dist/assets/chunk-EX3LRPZG-C0Jg_k6w.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-CoHkckoP.js +1 -0
- package/web/dist/assets/chunk-MOJQB5TN-CTIj5Opw.js +88 -0
- package/web/dist/assets/chunk-RYQCIY6F-BsP4OCo4.js +1 -0
- package/web/dist/assets/chunk-V7JOEXUC-B5KS36_O.js +206 -0
- package/web/dist/assets/chunk-VR4S4FIN-Dm92_7ut.js +1 -0
- package/web/dist/assets/chunk-XXDRQBXY-BGivbXmv.js +1 -0
- package/web/dist/assets/classDiagram-OUVF2IWQ-C12RKvcF.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-C12RKvcF.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-DVjdL84y.js +1 -0
- package/web/dist/assets/cynefin-VYW2F7L2-pcKhk42O.js +166 -0
- package/web/dist/assets/cynefinDiagram-TSTJHNR4-CdHQXyCH.js +62 -0
- package/web/dist/assets/cytoscape.esm-D3_iZ_3b.js +321 -0
- package/web/dist/assets/dagre-VKFMJZFB-Okt50Noy.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-FQU43EPY-D52R5ow3.js +3 -0
- package/web/dist/assets/diagram-G47NLZAW-KiJTa2OF.js +24 -0
- package/web/dist/assets/diagram-NH7WQ7WH-DZ-NFYLH.js +24 -0
- package/web/dist/assets/diagram-OA4YK3LP-et-Lf7yH.js +30 -0
- package/web/dist/assets/diagram-WEI45ONY-eXhZ7Ksl.js +41 -0
- package/web/dist/assets/ebnfDiagram-CCIWWBDH-CTWYusI0.js +1 -0
- package/web/dist/assets/erDiagram-Q63AITRT-DwTN2EqF.js +85 -0
- package/web/dist/assets/flowDiagram-23GEKE2U-CmxOIpNk.js +156 -0
- package/web/dist/assets/ganttDiagram-NO4QXBWP-TsPnDFPN.js +292 -0
- package/web/dist/assets/gitGraphDiagram-IHSO6WYX-B_IVM0u8.js +106 -0
- package/web/dist/assets/graph-DOmOIIwC.js +1 -0
- package/web/dist/assets/{index-CuxEGti7.js → index-CB16o5aO.js} +3 -3
- package/web/dist/assets/index-CN_7NnbR.css +1 -0
- package/web/dist/assets/infoDiagram-FWYZ7A6U-BFjk6921.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-FXEZZL3T-BiTzJYKW.js +70 -0
- package/web/dist/assets/journeyDiagram-5HDEW3XC-eeyGUQSj.js +139 -0
- package/web/dist/assets/kanban-definition-HUTT4EX6-DPYZ3frK.js +89 -0
- package/web/dist/assets/katex-HP8lGamR.js +257 -0
- package/web/dist/assets/layout-D-LzfAck.js +1 -0
- package/web/dist/assets/linear-Buu9IOJQ.js +1 -0
- package/web/dist/assets/map-DxJ2ADlA.js +1 -0
- package/web/dist/assets/mermaid.core-xO1oSQS3.js +308 -0
- package/web/dist/assets/mindmap-definition-LN4V7U3C-TasB4KZ4.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-2B236MQR-Btr2u_Xy.js +1 -0
- package/web/dist/assets/pieDiagram-ENE6RG2P-0iBpCJcu.js +39 -0
- package/web/dist/assets/pilotswarm-v4IdVngK.js +93 -0
- package/web/dist/assets/quadrantDiagram-ABIIQ3AL-CbVQOzUs.js +7 -0
- package/web/dist/assets/railroadDiagram-RFXS5EU6-BkwzDhMx.js +1 -0
- package/web/dist/assets/requirementDiagram-TGXJPOKE-DpF9yvmD.js +84 -0
- package/web/dist/assets/sankeyDiagram-HTMAVEWB-CjvbElms.js +40 -0
- package/web/dist/assets/sequenceDiagram-DBY2YBRQ-DRj2K28H.js +162 -0
- package/web/dist/assets/sizeCapture-X5ZJPWSS-Bi3gxz8F.js +1 -0
- package/web/dist/assets/stateDiagram-2N3HPSRC-_INdmyGU.js +1 -0
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-A5HcuE43.js +1 -0
- package/web/dist/assets/swimlanes-5IMT3BWC-D1pFtL4Y.js +2 -0
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-Bf_Rn9Zk.js +8 -0
- package/web/dist/assets/timeline-definition-FHXFAJF6-PYQ9uRSp.js +120 -0
- package/web/dist/assets/vennDiagram-L72KCM5P-xCF6u31z.js +34 -0
- package/web/dist/assets/wardleyDiagram-EHGQE667-DPcm9Msn.js +78 -0
- package/web/dist/assets/xychartDiagram-FW5EYKEG-D9ZNU0hG.js +7 -0
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-CxkLM8_a.css +0 -1
- 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.
|
|
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) =>
|
|
1694
|
-
|
|
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 (
|
|
1821
|
+
.then(async (pods) => {
|
|
1814
1822
|
if (handle.stopped || this.logTailHandle !== handle) return;
|
|
1815
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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({
|
|
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: {
|
|
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)`;
|
package/ui/core/src/history.js
CHANGED
|
@@ -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);
|