pilotswarm 0.5.34 → 0.5.35
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 -2
- package/ui/core/src/controller.js +152 -12
- package/ui/core/src/formatting.js +13 -1
- package/ui/core/src/history.js +8 -0
- package/ui/core/src/reducer.js +47 -0
- package/ui/core/src/selectors.js +10 -5
- package/ui/core/src/state.js +28 -0
- package/ui/core/src/themes/duroxide.js +136 -0
- package/ui/core/src/themes/index.js +2 -0
- package/ui/react/src/web-app.js +946 -46
- package/web/dist/assets/{abnfDiagram-VRR7QNED-BFOimVWK.js → abnfDiagram-VRR7QNED-BwJEohLG.js} +1 -1
- package/web/dist/assets/{arc-uUHjvwEo.js → arc-CWUJ_S5s.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CxeX_vea.js → architectureDiagram-ZJ3FMSHR-i6mrLxuE.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-Cno8kltG.js → blockDiagram-677ZJIJ3-C_KG-qPQ.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-viONVybf.js → c4Diagram-LMCZKHZV-BBJH7hbB.js} +1 -1
- package/web/dist/assets/channel-5Ddqi9ib.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-CKfICIGD.js → chunk-2Q5K7J3B-CXwaFcaM.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-CxmwEY56.js → chunk-32BRIVSS-C8ujjkK-.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-_eVYLGll.js → chunk-5VM5RSS4-M9rz7YYk.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-C4aqbjrf.js → chunk-EX3LRPZG-bk6tRxOA.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-Bh8ZxrI8.js → chunk-JWPE2WC7-DZ4WSHGH.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-BXgLlWVP.js → chunk-MOJQB5TN-B5-y6tMF.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-B1rMIdfF.js → chunk-RYQCIY6F-yKcnHxm0.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-C3AnSHXk.js → chunk-V7JOEXUC-CpoNcATD.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-CeoMJIBs.js → chunk-VR4S4FIN-Bf1yD2fn.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-DzaxQ9iP.js → chunk-XXDRQBXY-EnPvDVhP.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-B8u1fZiB.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-B8u1fZiB.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-G46fvUOx.js → cose-bilkent-JH36ORCC-qhKoA2qj.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-DXwqVhWA.js → cynefin-VYW2F7L2-jcyYelJS.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-DNNFmaXb.js → cynefinDiagram-TSTJHNR4-D4RLufLP.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-BCfh-h3o.js → dagre-VKFMJZFB-C9BA3GTO.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-CyG9b6bj.js → diagram-FQU43EPY-DbSSrVUJ.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-DdxDahK3.js → diagram-G47NLZAW-3egISQXQ.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-CjrZ_eja.js → diagram-NH7WQ7WH-8IKEMGRg.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-5NAjQpk2.js → diagram-OA4YK3LP-VnaogqtK.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-fLuDb54-.js → diagram-WEI45ONY-DxigXH0T.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DL9qnZpa.js → ebnfDiagram-CCIWWBDH-BSkNwJ9x.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-C_ynfrWY.js → erDiagram-Q63AITRT-CKN8JXl6.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-dYuH8HYv.js → flowDiagram-23GEKE2U-DDaD5ExP.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-e8Meh3f3.js → ganttDiagram-NO4QXBWP-tOrBOuoI.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-D_tZ4VL9.js → gitGraphDiagram-IHSO6WYX-Cdp867ON.js} +1 -1
- package/web/dist/assets/{index-DcQes9h8.js → index-BmwU43_u.js} +6 -6
- package/web/dist/assets/index-D-nA7gUe.css +1 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-Dffhqp73.js → infoDiagram-FWYZ7A6U-DwcKHD2h.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-DYt9H7kU.js → ishikawaDiagram-FXEZZL3T-CWffK5kz.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-Qv1vVu0F.js → journeyDiagram-5HDEW3XC-BEAgS-EZ.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-CTAUo40G.js → kanban-definition-HUTT4EX6-xDm-mRRj.js} +1 -1
- package/web/dist/assets/{linear-CqLsWFLj.js → linear-oP9MA0as.js} +1 -1
- package/web/dist/assets/{mermaid.core-okRCGPQQ.js → mermaid.core-DgXXt1OS.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-DVOOrIwD.js → mindmap-definition-LN4V7U3C-BBQgVrYC.js} +1 -1
- package/web/dist/assets/{papaparse.min-Ddflu8oC.js → papaparse.min-CR6JmBWm.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-BLJlC0MZ.js → pegDiagram-2B236MQR-C2O0cf3j.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-DdOwICOg.js → pieDiagram-ENE6RG2P-D08pUtYU.js} +1 -1
- package/web/dist/assets/pilotswarm-D7NLCbuc.js +99 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-B0Th1YO5.js → quadrantDiagram-ABIIQ3AL-D-Z7YoEK.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-BF2XKMKf.js → railroadDiagram-RFXS5EU6-ClkdveLI.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-Mr1iSwbS.js → requirementDiagram-TGXJPOKE-IY4xbn-Q.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-CrOjVZyg.js → sankeyDiagram-HTMAVEWB-Cpt5i7KM.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-H0X4Lydk.js → sequenceDiagram-DBY2YBRQ-c07lALcz.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-Dus1QEqh.js → sizeCapture-X5ZJPWSS-CmCDiNbI.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-BOBUSwdR.js → stateDiagram-2N3HPSRC-Dueu17zX.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-AGpIV7wW.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-BoqT-Lh7.js → swimlanes-5IMT3BWC-Csc2Fup8.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTGZSV0N.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-BOrqUQHX.js → timeline-definition-FHXFAJF6-4-lf702e.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-DDkNL-Oo.js → vennDiagram-L72KCM5P-CmlaKpLI.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-XUAJQ2X0.js → wardleyDiagram-EHGQE667-B9Qe44pi.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-ph69PV9y.js → xychartDiagram-FW5EYKEG-BFcc0CLn.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/channel-IURmSsjn.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-FsfpM3iu.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-FsfpM3iu.js +0 -1
- package/web/dist/assets/index-Ct7SQv1M.css +0 -1
- package/web/dist/assets/pilotswarm-CKh2MxfL.js +0 -99
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DABTDwFh.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-BF5mFN4t.js +0 -8
package/ui/react/src/web-app.js
CHANGED
|
@@ -7,6 +7,7 @@ import { appendAnimatedDotsToRuns, useAnimatedDots, useSpinnerFrame } from "./ch
|
|
|
7
7
|
import {
|
|
8
8
|
UI_COMMANDS,
|
|
9
9
|
INSPECTOR_TABS,
|
|
10
|
+
ARTIFACT_DOWNLOAD_HINT,
|
|
10
11
|
appReducer,
|
|
11
12
|
canStopSessionTurn,
|
|
12
13
|
computeLegacyLayout,
|
|
@@ -144,6 +145,7 @@ function supportsLocalFileOpen(controller) {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
const SESSION_LINK_COPIED_STATUS = "Session link copied to clipboard";
|
|
148
|
+
const ARTIFACT_LINK_COPIED_STATUS = "Artifact link copied to clipboard";
|
|
147
149
|
// Mirrors SESSION_NAV_SETTLE_MS in the controller: the access probe rides the
|
|
148
150
|
// same "wait for the selection to stop moving" rule as the session fetch.
|
|
149
151
|
const SESSION_ACCESS_SETTLE_MS = 140;
|
|
@@ -154,6 +156,21 @@ function buildSessionLinkUrl(sessionId) {
|
|
|
154
156
|
return `${window.location.origin}${window.location.pathname}?session=${encodeURIComponent(sessionId)}`;
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Deep link straight to one artifact's preview: `?session=…&artifact=…&view=full`.
|
|
161
|
+
*
|
|
162
|
+
* This is also what "open in a new tab" navigates to. It deliberately does NOT
|
|
163
|
+
* open the artifact's blob URL directly — a blob: document opened at top level
|
|
164
|
+
* runs at the PORTAL's origin, which would hand untrusted artifact markup the
|
|
165
|
+
* signed-in user's bearer token. Routing through the app keeps every byte of
|
|
166
|
+
* artifact HTML inside the sandboxed iframe.
|
|
167
|
+
*/
|
|
168
|
+
function buildArtifactLinkUrl(sessionId, filename, { fullscreen = true } = {}) {
|
|
169
|
+
const base = buildSessionLinkUrl(sessionId);
|
|
170
|
+
if (!base || !filename) return base;
|
|
171
|
+
return `${base}&artifact=${encodeURIComponent(filename)}${fullscreen ? "&view=full" : ""}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
157
174
|
function copySessionLinkText(url) {
|
|
158
175
|
if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(url).catch(() => {});
|
|
159
176
|
}
|
|
@@ -202,6 +219,9 @@ function normalizeProfileSettings(settings) {
|
|
|
202
219
|
if (typeof candidate.touchScale === "boolean") {
|
|
203
220
|
normalized.touchScale = candidate.touchScale;
|
|
204
221
|
}
|
|
222
|
+
if (typeof candidate.touchScaleMobile === "boolean") {
|
|
223
|
+
normalized.touchScaleMobile = candidate.touchScaleMobile;
|
|
224
|
+
}
|
|
205
225
|
if (hasOwn(candidate, "sessionOwnerFilter") && candidate.sessionOwnerFilter && typeof candidate.sessionOwnerFilter === "object") {
|
|
206
226
|
const storedFilter = candidate.sessionOwnerFilter;
|
|
207
227
|
// Profiles saved before the "Shared with me" bucket existed have no
|
|
@@ -260,6 +280,25 @@ function otherChatViewModeKey() {
|
|
|
260
280
|
return isNarrowViewport() ? "chatViewMode" : "chatViewModeMobile";
|
|
261
281
|
}
|
|
262
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The "Mobile" (touch-scale) preference is per DEVICE CLASS, like chatViewMode:
|
|
285
|
+
* it describes how big the type and hit targets should be on the screen you are
|
|
286
|
+
* actually looking at, so turning it on from a phone must not scale up the
|
|
287
|
+
* desktop. It is otherwise orthogonal to the theme — it lives in the theme
|
|
288
|
+
* picker's footer only because that is the "how this looks" surface.
|
|
289
|
+
*
|
|
290
|
+
* Note the desktop slot keeps the legacy name `touchScale`, so profiles written
|
|
291
|
+
* before the split keep working on desktop with no migration.
|
|
292
|
+
*/
|
|
293
|
+
function touchScaleKey() {
|
|
294
|
+
return isNarrowViewport() ? "touchScaleMobile" : "touchScale";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The touch-scale key belonging to the OTHER device class. */
|
|
298
|
+
function otherTouchScaleKey() {
|
|
299
|
+
return isNarrowViewport() ? "touchScale" : "touchScaleMobile";
|
|
300
|
+
}
|
|
301
|
+
|
|
263
302
|
function normalizeStoredCollapsedSessionIdsToArray(value) {
|
|
264
303
|
if (value instanceof Set) {
|
|
265
304
|
const out = [];
|
|
@@ -287,10 +326,10 @@ function hasOwn(value, key) {
|
|
|
287
326
|
return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
|
|
288
327
|
}
|
|
289
328
|
|
|
290
|
-
function profileSettingsFromViewState(state, preservedOtherChatViewMode = null) {
|
|
329
|
+
function profileSettingsFromViewState(state, preservedOtherChatViewMode = null, preservedOtherTouchScale = null) {
|
|
291
330
|
return normalizeProfileSettings({
|
|
292
331
|
themeId: state.themeId,
|
|
293
|
-
|
|
332
|
+
[touchScaleKey()]: state.touchScale,
|
|
294
333
|
sessionOwnerFilter: state.ownerFilter,
|
|
295
334
|
layoutAdjustments: {
|
|
296
335
|
paneAdjust: state.paneAdjust,
|
|
@@ -309,13 +348,16 @@ function profileSettingsFromViewState(state, preservedOtherChatViewMode = null)
|
|
|
309
348
|
...(isChatViewMode(preservedOtherChatViewMode)
|
|
310
349
|
? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
|
|
311
350
|
: {}),
|
|
351
|
+
...(typeof preservedOtherTouchScale === "boolean"
|
|
352
|
+
? { [otherTouchScaleKey()]: preservedOtherTouchScale }
|
|
353
|
+
: {}),
|
|
312
354
|
});
|
|
313
355
|
}
|
|
314
356
|
|
|
315
|
-
function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode = null) {
|
|
357
|
+
function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode = null, preservedOtherTouchScale = null) {
|
|
316
358
|
return normalizeProfileSettings({
|
|
317
359
|
themeId: state?.ui?.themeId,
|
|
318
|
-
|
|
360
|
+
[touchScaleKey()]: state?.ui?.touchScale,
|
|
319
361
|
// Derive the owner-filter default from the RESOLVED principal, never a
|
|
320
362
|
// snapshot of state.sessions.ownerFilter — at mount that snapshot is
|
|
321
363
|
// still {all:true} (principal not yet applied), and using it as the
|
|
@@ -332,6 +374,9 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode
|
|
|
332
374
|
...(isChatViewMode(preservedOtherChatViewMode)
|
|
333
375
|
? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
|
|
334
376
|
: {}),
|
|
377
|
+
...(typeof preservedOtherTouchScale === "boolean"
|
|
378
|
+
? { [otherTouchScaleKey()]: preservedOtherTouchScale }
|
|
379
|
+
: {}),
|
|
335
380
|
});
|
|
336
381
|
}
|
|
337
382
|
|
|
@@ -377,6 +422,19 @@ function materializeProfileSettings(remoteSettings, defaults) {
|
|
|
377
422
|
...(hasOwn(normalizedRemote, chatViewModeKey())
|
|
378
423
|
? { chatViewMode: normalizedRemote[chatViewModeKey()] }
|
|
379
424
|
: {}),
|
|
425
|
+
// touchScale was missing from this merge entirely, which is why the
|
|
426
|
+
// "Mobile" checkbox never survived a reload: the save path wrote it,
|
|
427
|
+
// but nothing ever read it back, and the reducer's hasTouchScale guard
|
|
428
|
+
// then preserved the default. Same no-defaults rule as chatViewMode —
|
|
429
|
+
// synthesizing one here would let a poll clobber a fresh local toggle.
|
|
430
|
+
...(hasOwn(normalizedRemote, touchScaleKey())
|
|
431
|
+
? { touchScale: normalizedRemote[touchScaleKey()] }
|
|
432
|
+
// Profiles written before the per-device split carry a single
|
|
433
|
+
// `touchScale`, which IS the desktop key — so only a phone needs to
|
|
434
|
+
// inherit it, and only until it saves a slot of its own.
|
|
435
|
+
: (isNarrowViewport() && hasOwn(normalizedRemote, "touchScale")
|
|
436
|
+
? { touchScale: normalizedRemote.touchScale }
|
|
437
|
+
: {})),
|
|
380
438
|
});
|
|
381
439
|
}
|
|
382
440
|
|
|
@@ -885,7 +943,9 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
885
943
|
|
|
886
944
|
function Runs({ runs, theme }) {
|
|
887
945
|
return React.createElement(React.Fragment, null,
|
|
888
|
-
(runs || []).map((run, index) => {
|
|
946
|
+
(runs || []).map((run, index, all) => {
|
|
947
|
+
// The download hint trails the artifact link as its own run.
|
|
948
|
+
const followsArtifactLink = index > 0 && Boolean(parseArtifactHref(all[index - 1]?.href));
|
|
889
949
|
const style = {
|
|
890
950
|
color: resolveColor(theme, run.color),
|
|
891
951
|
backgroundColor: resolveColor(theme, run.backgroundColor),
|
|
@@ -906,19 +966,23 @@ function Runs({ runs, theme }) {
|
|
|
906
966
|
const isExternalHref = /^https?:\/\//i.test(href);
|
|
907
967
|
// artifact:// links used to render as an inert span — the only
|
|
908
968
|
// affordance was "press a to download", which is the wrong verb
|
|
909
|
-
// for a patch you want to READ. They now
|
|
910
|
-
// the artifact
|
|
969
|
+
// for a patch you want to READ. They now render as a card that
|
|
970
|
+
// opens the artifact reader.
|
|
911
971
|
const artifactRef = parseArtifactHref(href);
|
|
912
972
|
|
|
913
973
|
if (artifactRef) {
|
|
914
974
|
return React.createElement(ArtifactLink, {
|
|
915
975
|
key: `${index}:${run.text || ""}`,
|
|
916
|
-
label: run.text || "",
|
|
917
976
|
artifactRef,
|
|
918
977
|
style,
|
|
919
978
|
});
|
|
920
979
|
}
|
|
921
980
|
|
|
981
|
+
const text = followsArtifactLink
|
|
982
|
+
? stripArtifactDownloadHint(run.text || "")
|
|
983
|
+
: (run.text || "");
|
|
984
|
+
if (followsArtifactLink && !text) return null;
|
|
985
|
+
|
|
922
986
|
return isExternalHref
|
|
923
987
|
? React.createElement("a", {
|
|
924
988
|
key: `${index}:${run.text || ""}`,
|
|
@@ -927,11 +991,11 @@ function Runs({ runs, theme }) {
|
|
|
927
991
|
target: "_blank",
|
|
928
992
|
rel: "noreferrer",
|
|
929
993
|
style,
|
|
930
|
-
},
|
|
994
|
+
}, text)
|
|
931
995
|
: React.createElement("span", {
|
|
932
996
|
key: `${index}:${run.text || ""}`,
|
|
933
997
|
style,
|
|
934
|
-
},
|
|
998
|
+
}, text);
|
|
935
999
|
}),
|
|
936
1000
|
);
|
|
937
1001
|
}
|
|
@@ -1098,6 +1162,26 @@ const DIFF_LANGUAGES = new Set(["diff", "patch", "udiff"]);
|
|
|
1098
1162
|
const DIFF_ARTIFACT_RE = /\.(patch|diff)$/i;
|
|
1099
1163
|
const IMAGE_CONTENT_TYPE_RE = /^image\/(png|jpe?g|gif|webp|avif|bmp|svg\+xml|x-icon|vnd\.microsoft\.icon)$/i;
|
|
1100
1164
|
const IMAGE_FILENAME_RE = /\.(png|jpe?g|gif|webp|avif|bmp|svg|ico)$/i;
|
|
1165
|
+
// Artifacts are stored with whatever content type the uploader declared, and
|
|
1166
|
+
// agents routinely write .svg as text/plain. A blob: URL carries its type
|
|
1167
|
+
// straight to <img>, which renders nothing for a non-image type — so the
|
|
1168
|
+
// extension is the fallback source of truth when the stored type is not one.
|
|
1169
|
+
const IMAGE_MIME_BY_EXTENSION = {
|
|
1170
|
+
png: "image/png",
|
|
1171
|
+
jpg: "image/jpeg",
|
|
1172
|
+
jpeg: "image/jpeg",
|
|
1173
|
+
gif: "image/gif",
|
|
1174
|
+
webp: "image/webp",
|
|
1175
|
+
avif: "image/avif",
|
|
1176
|
+
bmp: "image/bmp",
|
|
1177
|
+
svg: "image/svg+xml",
|
|
1178
|
+
ico: "image/x-icon",
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
function imageMimeFromFilename(filename) {
|
|
1182
|
+
const match = /\.([a-z0-9]+)$/i.exec(String(filename || ""));
|
|
1183
|
+
return match ? (IMAGE_MIME_BY_EXTENSION[match[1].toLowerCase()] || null) : null;
|
|
1184
|
+
}
|
|
1101
1185
|
|
|
1102
1186
|
/** Map an artifact filename to a highlighter language, or null for plain text. */
|
|
1103
1187
|
const CODE_ARTIFACT_LANGUAGES = {
|
|
@@ -1125,6 +1209,18 @@ function stripYamlFrontmatter(content) {
|
|
|
1125
1209
|
return text.slice(3 + close.index + close[0].length);
|
|
1126
1210
|
}
|
|
1127
1211
|
|
|
1212
|
+
const HTML_ARTIFACT_RE = /\.(html?|xhtml)$/i;
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* HTML-shaped artifacts, recognized by extension OR content type — the same
|
|
1216
|
+
* belt-and-braces rule the diff and tabular predicates use, because an agent
|
|
1217
|
+
* may write `text/html` bytes under a name we would not guess.
|
|
1218
|
+
*/
|
|
1219
|
+
function isHtmlArtifact(filename, contentType) {
|
|
1220
|
+
if (HTML_ARTIFACT_RE.test(String(filename || ""))) return true;
|
|
1221
|
+
return /^(text\/html|application\/xhtml\+xml)$/i.test(String(contentType || "").trim().split(";")[0]);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1128
1224
|
const TABULAR_ARTIFACT_RE = /\.(csv|tsv)$/i;
|
|
1129
1225
|
|
|
1130
1226
|
function isTabularArtifact(filename, contentType) {
|
|
@@ -1324,6 +1420,24 @@ function isDiffArtifact(filename, contentType) {
|
|
|
1324
1420
|
return /^text\/x-(patch|diff)$/i.test(String(contentType || "").trim());
|
|
1325
1421
|
}
|
|
1326
1422
|
|
|
1423
|
+
/**
|
|
1424
|
+
* Drop the TUI's "(press a to download)" tail that the shared link decorator
|
|
1425
|
+
* appends after an artifact link.
|
|
1426
|
+
*
|
|
1427
|
+
* It arrives as ordinary text immediately following the link, so it survives
|
|
1428
|
+
* into the portal where the key does not exist and download is the wrong verb
|
|
1429
|
+
* anyway — the card next to it opens a reader. Matched against the exported
|
|
1430
|
+
* constant rather than a second copy of the literal.
|
|
1431
|
+
*/
|
|
1432
|
+
function stripArtifactDownloadHint(text) {
|
|
1433
|
+
const value = String(text ?? "");
|
|
1434
|
+
if (value.startsWith(ARTIFACT_DOWNLOAD_HINT)) return value.slice(ARTIFACT_DOWNLOAD_HINT.length);
|
|
1435
|
+
// Leading whitespace already consumed by a preceding run/token.
|
|
1436
|
+
const trimmedHint = ARTIFACT_DOWNLOAD_HINT.trimStart();
|
|
1437
|
+
if (value.startsWith(trimmedHint)) return value.slice(trimmedHint.length);
|
|
1438
|
+
return value;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1327
1441
|
/** Parse an `artifact://<sessionId>/<filename>` href. */
|
|
1328
1442
|
function parseArtifactHref(href) {
|
|
1329
1443
|
const match = /^artifact:\/\/([^/]+)\/(.+)$/i.exec(String(href || "").trim());
|
|
@@ -1344,33 +1458,127 @@ function parseArtifactHref(href) {
|
|
|
1344
1458
|
*/
|
|
1345
1459
|
const ControllerContext = React.createContext(null);
|
|
1346
1460
|
|
|
1347
|
-
|
|
1461
|
+
/**
|
|
1462
|
+
* Classify an artifact for display: what kind of thing it is, said in words a
|
|
1463
|
+
* reader recognizes rather than a file extension.
|
|
1464
|
+
*
|
|
1465
|
+
* HTML is called out as its own kind because it behaves differently from every
|
|
1466
|
+
* other artifact — it opens as a live rendered page rather than as text — and
|
|
1467
|
+
* the card should promise that before the click, not after.
|
|
1468
|
+
*/
|
|
1469
|
+
function describeArtifact(filename) {
|
|
1470
|
+
const name = String(filename || "");
|
|
1471
|
+
const ext = (/\.([A-Za-z0-9]+)$/.exec(name)?.[1] || "").toLowerCase();
|
|
1472
|
+
if (isHtmlArtifact(name)) return { kind: "page", type: "HTML", noun: "Page" };
|
|
1473
|
+
if (isDiffArtifact(name)) return { kind: "diff", type: ext.toUpperCase() || "DIFF", noun: "Diff" };
|
|
1474
|
+
if (IMAGE_FILENAME_RE.test(name)) return { kind: "image", type: ext.toUpperCase(), noun: "Image" };
|
|
1475
|
+
if (isTabularArtifact(name)) return { kind: "table", type: ext.toUpperCase(), noun: "Table" };
|
|
1476
|
+
if (/\.md$/i.test(name)) return { kind: "doc", type: "MD", noun: "Document" };
|
|
1477
|
+
if (codeLanguageForArtifact(name)) return { kind: "code", type: ext.toUpperCase(), noun: "Code" };
|
|
1478
|
+
return { kind: "file", type: ext.toUpperCase() || "FILE", noun: "File" };
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* A filename read as a title: drop the extension, drop a trailing date stamp,
|
|
1483
|
+
* and turn separators into spaces. `icm-pg-outage-chain-20260805.html` reads as
|
|
1484
|
+
* "Icm pg outage chain" — the same move a document viewer makes, because the
|
|
1485
|
+
* card is a thing you recognize at a glance, not a path you retype.
|
|
1486
|
+
*/
|
|
1487
|
+
function artifactCardTitle(filename) {
|
|
1488
|
+
const base = String(filename || "").replace(/\.[A-Za-z0-9]+$/, "");
|
|
1489
|
+
const undated = base.replace(/[-_ ]?\d{6,8}$/, "");
|
|
1490
|
+
const words = (undated || base).replace(/[-_]+/g, " ").trim();
|
|
1491
|
+
if (!words) return String(filename || "Artifact");
|
|
1492
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/** Per-kind mark for the card's thumbnail. Inline SVG so it inherits color. */
|
|
1496
|
+
function ArtifactCardGlyph({ kind }) {
|
|
1497
|
+
const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
|
|
1498
|
+
const inner = kind === "page"
|
|
1499
|
+
// A browser window: what you get is a rendered page, not a file.
|
|
1500
|
+
? [
|
|
1501
|
+
React.createElement("rect", { key: "w", x: 2.5, y: 3.5, width: 15, height: 13, rx: 2, ...stroke }),
|
|
1502
|
+
React.createElement("path", { key: "b", d: "M2.5 7.5h15", ...stroke }),
|
|
1503
|
+
React.createElement("circle", { key: "d", cx: 5.2, cy: 5.5, r: 0.7, fill: "currentColor", stroke: "none" }),
|
|
1504
|
+
]
|
|
1505
|
+
: kind === "image"
|
|
1506
|
+
? [
|
|
1507
|
+
React.createElement("rect", { key: "f", x: 2.5, y: 3.5, width: 15, height: 13, rx: 2, ...stroke }),
|
|
1508
|
+
React.createElement("path", { key: "m", d: "M2.5 13l4-4 4 3.5 3-2.5 3.5 3", ...stroke }),
|
|
1509
|
+
]
|
|
1510
|
+
: kind === "table"
|
|
1511
|
+
? [
|
|
1512
|
+
React.createElement("rect", { key: "f", x: 2.5, y: 3.5, width: 15, height: 13, rx: 2, ...stroke }),
|
|
1513
|
+
React.createElement("path", { key: "g", d: "M2.5 8h15M8 8v8.5M2.5 12.5h15", ...stroke }),
|
|
1514
|
+
]
|
|
1515
|
+
: [
|
|
1516
|
+
// Document: a page with a folded corner and text lines.
|
|
1517
|
+
React.createElement("path", { key: "p", d: "M5 2.5h6.5L16 7v10.5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-14a1 1 0 0 1 1-1z", ...stroke }),
|
|
1518
|
+
React.createElement("path", { key: "c", d: "M11.5 2.5V7H16", ...stroke }),
|
|
1519
|
+
kind === "diff"
|
|
1520
|
+
? React.createElement("path", { key: "t", d: "M7 11h6M10 8.5v5", ...stroke })
|
|
1521
|
+
: React.createElement("path", { key: "t", d: "M7 10.5h6M7 13.5h4", ...stroke }),
|
|
1522
|
+
];
|
|
1523
|
+
return React.createElement("svg", {
|
|
1524
|
+
className: "ps-artifact-card-glyph",
|
|
1525
|
+
viewBox: "0 0 20 20",
|
|
1526
|
+
"aria-hidden": "true",
|
|
1527
|
+
}, inner);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* An artifact in the transcript, as a card rather than a link.
|
|
1532
|
+
*
|
|
1533
|
+
* The old inline link was a coloured filename followed by "(press a to
|
|
1534
|
+
* download)" — a keystroke that does not exist in a browser, describing the
|
|
1535
|
+
* wrong verb for something you want to read. A card names the thing, says what
|
|
1536
|
+
* kind it is, and opens the reader on click.
|
|
1537
|
+
*/
|
|
1538
|
+
function ArtifactLink({ artifactRef }) {
|
|
1348
1539
|
const controller = React.useContext(ControllerContext);
|
|
1349
|
-
const
|
|
1540
|
+
const descriptor = describeArtifact(artifactRef.filename);
|
|
1350
1541
|
const onOpen = React.useCallback((event) => {
|
|
1351
1542
|
event.preventDefault();
|
|
1352
1543
|
event.stopPropagation();
|
|
1353
|
-
|
|
1354
|
-
// On a phone the preview takes the whole pane — a list/detail split has
|
|
1355
|
-
// no room to be useful at 390px.
|
|
1356
|
-
const fullscreen = typeof window !== "undefined"
|
|
1357
|
-
&& typeof window.matchMedia === "function"
|
|
1358
|
-
&& window.matchMedia("(max-width: 920px)").matches;
|
|
1359
|
-
Promise.resolve(controller.revealArtifact(artifactRef.sessionId, artifactRef.filename, { fullscreen }))
|
|
1360
|
-
.catch(() => {});
|
|
1544
|
+
openArtifactFromChat(controller, artifactRef.sessionId, artifactRef.filename);
|
|
1361
1545
|
}, [controller, artifactRef.sessionId, artifactRef.filename]);
|
|
1362
1546
|
|
|
1547
|
+
// Deliberately NOT given the surrounding run's inline style. That style
|
|
1548
|
+
// carries the link colour and underline the transcript uses for inline
|
|
1549
|
+
// links, and inheriting it painted the card's title as underlined accent
|
|
1550
|
+
// text — a card is a surface, not a phrase.
|
|
1363
1551
|
return React.createElement("button", {
|
|
1364
1552
|
type: "button",
|
|
1365
|
-
className: `ps-artifact-
|
|
1553
|
+
className: `ps-artifact-card is-${descriptor.kind}`,
|
|
1366
1554
|
onClick: onOpen,
|
|
1367
|
-
title:
|
|
1368
|
-
? `Open ${artifactRef.filename} as a diff`
|
|
1369
|
-
: `Open ${artifactRef.filename} in Files`,
|
|
1370
|
-
style,
|
|
1555
|
+
title: `Open ${artifactRef.filename}`,
|
|
1371
1556
|
},
|
|
1372
|
-
|
|
1373
|
-
|
|
1557
|
+
React.createElement("span", { className: "ps-artifact-card-thumb", "aria-hidden": "true" },
|
|
1558
|
+
React.createElement(ArtifactCardGlyph, { kind: descriptor.kind })),
|
|
1559
|
+
React.createElement("span", { className: "ps-artifact-card-text" },
|
|
1560
|
+
React.createElement("span", { className: "ps-artifact-card-title" }, artifactCardTitle(artifactRef.filename)),
|
|
1561
|
+
React.createElement("span", { className: "ps-artifact-card-meta" }, `${descriptor.noun} · ${descriptor.type}`)),
|
|
1562
|
+
React.createElement("span", { className: "ps-artifact-card-open" }, "Open"));
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Open an artifact from the transcript.
|
|
1567
|
+
*
|
|
1568
|
+
* Desktop gets the takeover pane — the chat stays visible beside it, which is
|
|
1569
|
+
* the whole point of following a link from a conversation. A phone has no room
|
|
1570
|
+
* for two things at once and keeps the full-viewport overlay.
|
|
1571
|
+
*/
|
|
1572
|
+
function openArtifactFromChat(controller, sessionId, filename) {
|
|
1573
|
+
if (!controller?.revealArtifact) return;
|
|
1574
|
+
const isPhone = typeof window !== "undefined"
|
|
1575
|
+
&& typeof window.matchMedia === "function"
|
|
1576
|
+
&& window.matchMedia("(max-width: 920px)").matches;
|
|
1577
|
+
// Whether ✕ hands the column back or collapses it again is decided inside
|
|
1578
|
+
// revealArtifact, from the layout it can already see.
|
|
1579
|
+
Promise.resolve(isPhone
|
|
1580
|
+
? controller.revealArtifact(sessionId, filename, { fullscreen: true })
|
|
1581
|
+
: controller.revealArtifact(sessionId, filename, { pane: true })).catch(() => {});
|
|
1374
1582
|
}
|
|
1375
1583
|
|
|
1376
1584
|
function isDiffLanguage(language) {
|
|
@@ -1677,8 +1885,16 @@ function MermaidDiagram({ code, theme, fallback }) {
|
|
|
1677
1885
|
}
|
|
1678
1886
|
|
|
1679
1887
|
function renderInlineMarkdown(source, theme, keyPrefix = "md") {
|
|
1680
|
-
|
|
1888
|
+
const tokens = tokenizeInlineMarkdown(source);
|
|
1889
|
+
return tokens.map((token, index) => {
|
|
1681
1890
|
const key = `${keyPrefix}:${index}`;
|
|
1891
|
+
// The shared decorator appends the TUI's download hint as plain text
|
|
1892
|
+
// right after the artifact link; the portal drops it.
|
|
1893
|
+
if (index > 0 && tokens[index - 1]?.type === "link" && parseArtifactHref(tokens[index - 1]?.href)) {
|
|
1894
|
+
const stripped = stripArtifactDownloadHint(token.text || "");
|
|
1895
|
+
if (!stripped) return null;
|
|
1896
|
+
if (stripped !== token.text) token = { ...token, text: stripped };
|
|
1897
|
+
}
|
|
1682
1898
|
if (token.type === "code") {
|
|
1683
1899
|
return React.createElement("code", { key, className: "ps-md-inline-code" }, token.text);
|
|
1684
1900
|
}
|
|
@@ -1691,12 +1907,7 @@ function renderInlineMarkdown(source, theme, keyPrefix = "md") {
|
|
|
1691
1907
|
if (token.type === "link") {
|
|
1692
1908
|
const artifactRef = parseArtifactHref(token.href);
|
|
1693
1909
|
if (artifactRef) {
|
|
1694
|
-
return React.createElement(ArtifactLink, {
|
|
1695
|
-
key,
|
|
1696
|
-
label: token.text,
|
|
1697
|
-
artifactRef,
|
|
1698
|
-
style: { color: resolveColor(theme, "cyan") },
|
|
1699
|
-
});
|
|
1910
|
+
return React.createElement(ArtifactLink, { key, artifactRef });
|
|
1700
1911
|
}
|
|
1701
1912
|
// Only http(s) becomes a navigable anchor. Anything else — an
|
|
1702
1913
|
// unknown scheme, or a malformed href from a bad transform — would
|
|
@@ -2197,7 +2408,18 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
|
|
|
2197
2408
|
className: "ps-binary-preview-image",
|
|
2198
2409
|
src: imageUrl,
|
|
2199
2410
|
alt: filename || "artifact image",
|
|
2200
|
-
|
|
2411
|
+
// Click-through goes to the artifact deep link, NOT to the
|
|
2412
|
+
// blob URL. A blob: document opened at top level runs at
|
|
2413
|
+
// the portal's own origin, and IMAGE_FILENAME_RE admits
|
|
2414
|
+
// .svg — an SVG is a scriptable document, so opening one
|
|
2415
|
+
// that way would execute artifact-authored script with
|
|
2416
|
+
// access to the signed-in user's token. Rendering it in
|
|
2417
|
+
// <img> (below) is safe; navigating to it is not.
|
|
2418
|
+
onClick: () => {
|
|
2419
|
+
const target = buildArtifactLinkUrl(sessionId, filename);
|
|
2420
|
+
if (!target) return;
|
|
2421
|
+
try { window.open(target, "_blank", "noopener"); } catch { /* popup blocked */ }
|
|
2422
|
+
},
|
|
2201
2423
|
})
|
|
2202
2424
|
: React.createElement("p", { className: "ps-binary-preview-copy" },
|
|
2203
2425
|
isRasterImage
|
|
@@ -2205,6 +2427,463 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
|
|
|
2205
2427
|
: "Preview is intentionally disabled for non-text artifacts in the browser workspace. Use Download to save the file and open it in the default app.")));
|
|
2206
2428
|
}
|
|
2207
2429
|
|
|
2430
|
+
/**
|
|
2431
|
+
* Full-fidelity HTML artifact preview.
|
|
2432
|
+
*
|
|
2433
|
+
* Two things make this different from every other preview branch:
|
|
2434
|
+
*
|
|
2435
|
+
* 1. It renders from the DOWNLOAD stream, not `previewContent`. The text
|
|
2436
|
+
* preview pipeline truncates at FILE_PREVIEW_CHAR_LIMIT (200k chars), so a
|
|
2437
|
+
* real agent-built dashboard is routinely cut off mid-document. "Full
|
|
2438
|
+
* fidelity" has to mean all of the bytes.
|
|
2439
|
+
*
|
|
2440
|
+
* 2. The document runs its own scripts, inside a sandbox with NO
|
|
2441
|
+
* `allow-same-origin`. That combination is the whole security model here:
|
|
2442
|
+
* blob: URLs inherit the origin of whoever created them, so withholding
|
|
2443
|
+
* allow-same-origin is what forces the frame into an opaque origin. The
|
|
2444
|
+
* artifact's tooltips, SVG interactions and layout scripts all work; its
|
|
2445
|
+
* script cannot read the portal's localStorage, cookies, bearer token, or
|
|
2446
|
+
* reach across into the parent DOM. Popups are allowed so external links
|
|
2447
|
+
* still work; forms and top-level navigation are not.
|
|
2448
|
+
*/
|
|
2449
|
+
/**
|
|
2450
|
+
* Re-run a rendered artifact's layout when the frame's width changes.
|
|
2451
|
+
*
|
|
2452
|
+
* Agent-authored HTML routinely computes geometry ONCE at load — this
|
|
2453
|
+
* dashboard measures the container and writes absolute SVG coordinates, with
|
|
2454
|
+
* no resize listener anywhere in it. Widening the pane reflowed its CSS grid
|
|
2455
|
+
* (media queries need no script) but left the graph drawn for the old width,
|
|
2456
|
+
* with three columns stacked on top of each other.
|
|
2457
|
+
*
|
|
2458
|
+
* Nothing can be fixed from out here: the frame is deliberately opaque-origin,
|
|
2459
|
+
* so we cannot call into it, read its scroll position, or re-run its init. The
|
|
2460
|
+
* one lever that works from outside is making the document load again at the
|
|
2461
|
+
* new size — a remount, which re-executes the same blob with no refetch.
|
|
2462
|
+
*
|
|
2463
|
+
* So: debounce until the drag settles, ignore trivial jitter, and never fire
|
|
2464
|
+
* on the initial measurement. That costs one reload per resize gesture, and it
|
|
2465
|
+
* does lose scroll position — unavoidable across an opaque origin, and the
|
|
2466
|
+
* alternative is a picture that is simply wrong at the size the user chose.
|
|
2467
|
+
*/
|
|
2468
|
+
const HTML_PREVIEW_RESIZE_SETTLE_MS = 260;
|
|
2469
|
+
// Comfortably wider than a scrollbar. A reload changes the document's height,
|
|
2470
|
+
// which can add or remove the frame's vertical scrollbar, which changes the
|
|
2471
|
+
// host's width by ~15px — enough to clear a smaller threshold and schedule
|
|
2472
|
+
// another reload, which toggles the scrollbar back. That loop is what made the
|
|
2473
|
+
// panel appear to redraw continuously; measuring against the last COMMITTED
|
|
2474
|
+
// width (below) closes it, and the wider band keeps it shut.
|
|
2475
|
+
const HTML_PREVIEW_RESIZE_MIN_DELTA_PX = 28;
|
|
2476
|
+
|
|
2477
|
+
/**
|
|
2478
|
+
* Reload the rendered frame when the pane's width settles, double-buffered.
|
|
2479
|
+
*
|
|
2480
|
+
* Two rules keep this from being visible:
|
|
2481
|
+
*
|
|
2482
|
+
* - Compare against the width the CURRENT document was rendered at, not the
|
|
2483
|
+
* last width observed. Otherwise every intermediate measurement becomes a
|
|
2484
|
+
* new baseline and the scrollbar feedback loop above never terminates.
|
|
2485
|
+
* - Load the replacement behind the one on screen and swap on its `load`
|
|
2486
|
+
* event. A remounted iframe is blank while it loads, and a blank frame
|
|
2487
|
+
* paints its background — white, so a light-assuming document is readable —
|
|
2488
|
+
* which is exactly the flash. Nothing is ever swapped in unpainted.
|
|
2489
|
+
*
|
|
2490
|
+
* Returns the two nonces the caller renders: `live` is on screen, `pending` is
|
|
2491
|
+
* loading behind it. Both use the same element key across promotion so React
|
|
2492
|
+
* reuses the element instead of remounting it (which would reload it again).
|
|
2493
|
+
*/
|
|
2494
|
+
function useHtmlPreviewReload(elementRef, enabled, resetKey, relayoutKey) {
|
|
2495
|
+
const [live, setLive] = React.useState(null);
|
|
2496
|
+
const [pending, setPending] = React.useState(0);
|
|
2497
|
+
const committedWidthRef = React.useRef(null);
|
|
2498
|
+
const nonceRef = React.useRef(0);
|
|
2499
|
+
|
|
2500
|
+
// A different artifact is a different document: drop what is on screen and
|
|
2501
|
+
// stage the new one, so the old page is never shown under a new title.
|
|
2502
|
+
React.useEffect(() => {
|
|
2503
|
+
committedWidthRef.current = null;
|
|
2504
|
+
nonceRef.current += 1;
|
|
2505
|
+
setLive(null);
|
|
2506
|
+
setPending(nonceRef.current);
|
|
2507
|
+
}, [resetKey]);
|
|
2508
|
+
|
|
2509
|
+
// Zoom changes the frame's LAYOUT width (see the width/transform pair in
|
|
2510
|
+
// the stylesheet), so a document that computed its geometry once is just
|
|
2511
|
+
// as stale after zooming as after a drag. Stage a replacement — but keep
|
|
2512
|
+
// the current one on screen, unlike resetKey above, because this is the
|
|
2513
|
+
// same document at a new size rather than a different one.
|
|
2514
|
+
const firstRelayout = React.useRef(true);
|
|
2515
|
+
React.useEffect(() => {
|
|
2516
|
+
if (firstRelayout.current) { firstRelayout.current = false; return; }
|
|
2517
|
+
if (!enabled) return;
|
|
2518
|
+
committedWidthRef.current = null;
|
|
2519
|
+
nonceRef.current += 1;
|
|
2520
|
+
setPending(nonceRef.current);
|
|
2521
|
+
}, [relayoutKey, enabled]);
|
|
2522
|
+
|
|
2523
|
+
React.useEffect(() => {
|
|
2524
|
+
const node = elementRef.current;
|
|
2525
|
+
if (!enabled || !node || typeof ResizeObserver === "undefined") return undefined;
|
|
2526
|
+
let timer = null;
|
|
2527
|
+
const observer = new ResizeObserver((entries) => {
|
|
2528
|
+
const width = Math.round(entries[0]?.contentRect?.width || 0);
|
|
2529
|
+
if (!width) return;
|
|
2530
|
+
if (committedWidthRef.current === null) {
|
|
2531
|
+
committedWidthRef.current = width;
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
if (Math.abs(width - committedWidthRef.current) < HTML_PREVIEW_RESIZE_MIN_DELTA_PX) return;
|
|
2535
|
+
if (timer) clearTimeout(timer);
|
|
2536
|
+
timer = setTimeout(() => {
|
|
2537
|
+
timer = null;
|
|
2538
|
+
// Commit the width as of the moment we decide to reload, so the
|
|
2539
|
+
// next comparison is against what the new document will render
|
|
2540
|
+
// at rather than against a mid-drag sample.
|
|
2541
|
+
committedWidthRef.current = Math.round(node.getBoundingClientRect().width) || width;
|
|
2542
|
+
nonceRef.current += 1;
|
|
2543
|
+
setPending(nonceRef.current);
|
|
2544
|
+
}, HTML_PREVIEW_RESIZE_SETTLE_MS);
|
|
2545
|
+
});
|
|
2546
|
+
observer.observe(node);
|
|
2547
|
+
return () => {
|
|
2548
|
+
if (timer) clearTimeout(timer);
|
|
2549
|
+
observer.disconnect();
|
|
2550
|
+
};
|
|
2551
|
+
}, [elementRef, enabled]);
|
|
2552
|
+
|
|
2553
|
+
// `load` means the document finished loading, NOT that it finished
|
|
2554
|
+
// painting. Promoting on the load event alone swapped in a frame the
|
|
2555
|
+
// compositor had not drawn yet, so the white background showed through for
|
|
2556
|
+
// a beat. Two frames of slack is enough for the paint to land, and it is
|
|
2557
|
+
// invisible next to the reload it follows.
|
|
2558
|
+
const promote = React.useCallback((nonce) => {
|
|
2559
|
+
const swap = () => {
|
|
2560
|
+
setPending((current) => (current === nonce ? null : current));
|
|
2561
|
+
setLive((current) => (current === nonce ? current : nonce));
|
|
2562
|
+
};
|
|
2563
|
+
if (typeof requestAnimationFrame !== "function") { swap(); return; }
|
|
2564
|
+
requestAnimationFrame(() => requestAnimationFrame(swap));
|
|
2565
|
+
}, []);
|
|
2566
|
+
|
|
2567
|
+
return { live, pending, promote };
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
/**
|
|
2571
|
+
* Width the frame LAYS OUT at while fitting, before being scaled down.
|
|
2572
|
+
*
|
|
2573
|
+
* It has to be a constant: the frame is sandboxed without `allow-same-origin`,
|
|
2574
|
+
* so the document's real content width is unreadable from here — there is no
|
|
2575
|
+
* contentDocument to measure. 1024 is the width a fixed-layout page is most
|
|
2576
|
+
* likely to have been written for, and a responsive one reflows to it happily.
|
|
2577
|
+
*/
|
|
2578
|
+
const HTML_PREVIEW_FIT_LAYOUT_WIDTH_PX = 1024;
|
|
2579
|
+
|
|
2580
|
+
/** Fit-to-width switch. Rendered by whichever surface owns the artifact bar. */
|
|
2581
|
+
function HtmlFitWidthToggle({ fitWidth, setFitWidth }) {
|
|
2582
|
+
return React.createElement(IconButton, {
|
|
2583
|
+
icon: fitWidth ? "1:1" : "⤢",
|
|
2584
|
+
label: fitWidth ? "Actual size" : "Fit page to width",
|
|
2585
|
+
active: fitWidth,
|
|
2586
|
+
onClick: () => setFitWidth(!fitWidth),
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
const HTML_ZOOM_STEPS = [0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3];
|
|
2591
|
+
|
|
2592
|
+
/**
|
|
2593
|
+
* Zoom for the rendered artifact ONLY.
|
|
2594
|
+
*
|
|
2595
|
+
* Browser zoom scales the entire workspace — the session list and the
|
|
2596
|
+
* transcript along with it — when the thing you actually wanted to read
|
|
2597
|
+
* larger is one dense chart. This moves the artifact and leaves the interface
|
|
2598
|
+
* around it alone.
|
|
2599
|
+
*/
|
|
2600
|
+
function HtmlZoomControl({ zoom, setZoom }) {
|
|
2601
|
+
const step = (direction) => {
|
|
2602
|
+
const index = HTML_ZOOM_STEPS.findIndex((value) => Math.abs(value - zoom) < 0.005);
|
|
2603
|
+
const from = index === -1 ? HTML_ZOOM_STEPS.indexOf(1) : index;
|
|
2604
|
+
const next = HTML_ZOOM_STEPS[Math.min(HTML_ZOOM_STEPS.length - 1, Math.max(0, from + direction))];
|
|
2605
|
+
setZoom(next);
|
|
2606
|
+
};
|
|
2607
|
+
return React.createElement("span", { className: "ps-html-zoom", role: "group", "aria-label": "Artifact zoom" },
|
|
2608
|
+
React.createElement("button", {
|
|
2609
|
+
type: "button",
|
|
2610
|
+
className: "ps-html-zoom-btn",
|
|
2611
|
+
onClick: () => step(-1),
|
|
2612
|
+
disabled: zoom <= HTML_ZOOM_STEPS[0] + 0.001,
|
|
2613
|
+
title: "Zoom out (artifact only)",
|
|
2614
|
+
"aria-label": "Zoom out",
|
|
2615
|
+
}, "−"),
|
|
2616
|
+
// Doubles as reset — the fastest way back from an exploratory zoom.
|
|
2617
|
+
React.createElement("button", {
|
|
2618
|
+
type: "button",
|
|
2619
|
+
className: "ps-html-zoom-level",
|
|
2620
|
+
onClick: () => setZoom(1),
|
|
2621
|
+
title: "Reset zoom to 100%",
|
|
2622
|
+
"aria-label": `Zoom ${Math.round(zoom * 100)} percent, click to reset`,
|
|
2623
|
+
}, `${Math.round(zoom * 100)}%`),
|
|
2624
|
+
React.createElement("button", {
|
|
2625
|
+
type: "button",
|
|
2626
|
+
className: "ps-html-zoom-btn",
|
|
2627
|
+
onClick: () => step(1),
|
|
2628
|
+
disabled: zoom >= HTML_ZOOM_STEPS[HTML_ZOOM_STEPS.length - 1] - 0.001,
|
|
2629
|
+
title: "Zoom in (artifact only)",
|
|
2630
|
+
"aria-label": "Zoom in",
|
|
2631
|
+
}, "+"));
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
/** Rendered-page vs. HTML-source switch. Shared by the Files tab and the reader. */
|
|
2635
|
+
function HtmlViewModeToggle({ mode, setMode }) {
|
|
2636
|
+
return React.createElement("span", {
|
|
2637
|
+
className: "ps-html-preview-modes",
|
|
2638
|
+
role: "group",
|
|
2639
|
+
"aria-label": "Preview mode",
|
|
2640
|
+
}, ["rendered", "source"].map((value) => React.createElement("button", {
|
|
2641
|
+
key: value,
|
|
2642
|
+
type: "button",
|
|
2643
|
+
className: `ps-html-preview-mode${mode === value ? " is-active" : ""}`,
|
|
2644
|
+
"aria-pressed": mode === value,
|
|
2645
|
+
onClick: () => setMode(value),
|
|
2646
|
+
}, value === "rendered" ? "Rendered" : "Source")));
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
function useElementBox(ref) {
|
|
2650
|
+
const [box, setBox] = React.useState({ width: 0, height: 0 });
|
|
2651
|
+
React.useLayoutEffect(() => {
|
|
2652
|
+
const node = ref.current;
|
|
2653
|
+
if (!node || typeof ResizeObserver === "undefined") return undefined;
|
|
2654
|
+
const update = () => {
|
|
2655
|
+
const rect = node.getBoundingClientRect();
|
|
2656
|
+
setBox((current) => {
|
|
2657
|
+
const width = Math.round(rect.width) || 0;
|
|
2658
|
+
const height = Math.round(rect.height) || 0;
|
|
2659
|
+
return current.width === width && current.height === height
|
|
2660
|
+
? current
|
|
2661
|
+
: { width, height };
|
|
2662
|
+
});
|
|
2663
|
+
};
|
|
2664
|
+
update();
|
|
2665
|
+
const observer = new ResizeObserver(update);
|
|
2666
|
+
observer.observe(node);
|
|
2667
|
+
return () => observer.disconnect();
|
|
2668
|
+
}, [ref]);
|
|
2669
|
+
return box;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
function fetchArtifactHtmlObjectUrl(controller, sessionId, filename) {
|
|
2673
|
+
const downloadResponse = controller?.transport?.api?.downloadArtifactResponse;
|
|
2674
|
+
if (typeof downloadResponse !== "function") {
|
|
2675
|
+
return Promise.reject(new Error("artifact download unavailable"));
|
|
2676
|
+
}
|
|
2677
|
+
return downloadResponse.call(controller.transport.api, sessionId, filename)
|
|
2678
|
+
.then(async (response) => {
|
|
2679
|
+
if (!response?.ok) throw new Error(`download failed (${response?.status})`);
|
|
2680
|
+
// Re-type the blob explicitly rather than trusting the response's
|
|
2681
|
+
// content-type. An artifact stored as application/octet-stream
|
|
2682
|
+
// would otherwise make the iframe offer a download instead of
|
|
2683
|
+
// rendering, and a missing charset would mojibake UTF-8 content.
|
|
2684
|
+
const bytes = await response.arrayBuffer();
|
|
2685
|
+
return URL.createObjectURL(new Blob([bytes], { type: "text/html;charset=utf-8" }));
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
function HtmlArtifactPreviewPanel({
|
|
2690
|
+
controller, sessionId, filename, contentType, sizeBytes, theme,
|
|
2691
|
+
color = "cyan", focused = false, title = null, chromeless = false,
|
|
2692
|
+
}) {
|
|
2693
|
+
// Mode lives in controller state so the reader pane's header can own the
|
|
2694
|
+
// toggle. Inside the Files tab this panel still draws its own.
|
|
2695
|
+
const mode = useControllerSelector(controller, (state) => state.files.htmlViewMode || "rendered");
|
|
2696
|
+
const setMode = React.useCallback((next) => {
|
|
2697
|
+
controller.dispatch({ type: "files/htmlViewMode", mode: next });
|
|
2698
|
+
}, [controller]);
|
|
2699
|
+
const [frameUrl, setFrameUrl] = React.useState(null);
|
|
2700
|
+
const [error, setError] = React.useState(null);
|
|
2701
|
+
// Measured on the wrapper, not the iframe: an iframe being remounted is
|
|
2702
|
+
// briefly absent, and observing an element that disappears would drop the
|
|
2703
|
+
// baseline and re-fire on the way back.
|
|
2704
|
+
const frameHostRef = React.useRef(null);
|
|
2705
|
+
const fitWidth = useControllerSelector(controller, (state) => Boolean(state.files.htmlFitWidth));
|
|
2706
|
+
const setFitWidth = React.useCallback((next) => {
|
|
2707
|
+
controller.dispatch({ type: "files/htmlFitWidth", enabled: next });
|
|
2708
|
+
}, [controller]);
|
|
2709
|
+
const hostBox = useElementBox(frameHostRef);
|
|
2710
|
+
// Only ever scale DOWN. When the pane is already wider than the layout
|
|
2711
|
+
// width there is nothing to fit, and blowing the page up to fill it would
|
|
2712
|
+
// just make a responsive document coarse.
|
|
2713
|
+
const fitScale = fitWidth && hostBox.width > 0 && hostBox.width < HTML_PREVIEW_FIT_LAYOUT_WIDTH_PX
|
|
2714
|
+
? hostBox.width / HTML_PREVIEW_FIT_LAYOUT_WIDTH_PX
|
|
2715
|
+
: 1;
|
|
2716
|
+
const fitActive = fitScale < 1;
|
|
2717
|
+
// Zoom composes with fit rather than competing with it: fit chooses the
|
|
2718
|
+
// base scale that shows the whole width, zoom moves from there. At
|
|
2719
|
+
// zoom 1 with fit off, neither touches the frame at all.
|
|
2720
|
+
const zoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
|
|
2721
|
+
const setZoom = React.useCallback((next) => {
|
|
2722
|
+
controller.dispatch({ type: "files/htmlZoom", zoom: next });
|
|
2723
|
+
}, [controller]);
|
|
2724
|
+
const renderScale = fitActive ? fitScale * zoom : zoom;
|
|
2725
|
+
// While fitting, the frame's own width is pinned to the layout constant, so
|
|
2726
|
+
// a pane resize changes only the SCALE — the document does not need to be
|
|
2727
|
+
// reloaded, and reloading would cost the reader their scroll position on
|
|
2728
|
+
// something as ordinary as rotating the phone.
|
|
2729
|
+
const frames = useHtmlPreviewReload(frameHostRef, mode === "rendered" && !fitActive, `${sessionId}/${filename}`, zoom);
|
|
2730
|
+
|
|
2731
|
+
// The object URL is owned by this component — created on mount, revoked on
|
|
2732
|
+
// unmount or artifact change. It deliberately does not share the thumbnail
|
|
2733
|
+
// LRU: that cache revokes at its cap, which would blank a frame the user
|
|
2734
|
+
// is still reading.
|
|
2735
|
+
React.useEffect(() => {
|
|
2736
|
+
setFrameUrl(null);
|
|
2737
|
+
setError(null);
|
|
2738
|
+
if (mode !== "rendered" || !controller || !sessionId || !filename) return undefined;
|
|
2739
|
+
let cancelled = false;
|
|
2740
|
+
let created = null;
|
|
2741
|
+
fetchArtifactHtmlObjectUrl(controller, sessionId, filename)
|
|
2742
|
+
.then((url) => {
|
|
2743
|
+
created = url;
|
|
2744
|
+
if (cancelled) {
|
|
2745
|
+
URL.revokeObjectURL(url);
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
setFrameUrl(url);
|
|
2749
|
+
})
|
|
2750
|
+
.catch((err) => { if (!cancelled) setError(err?.message || String(err)); });
|
|
2751
|
+
return () => {
|
|
2752
|
+
cancelled = true;
|
|
2753
|
+
if (created) URL.revokeObjectURL(created);
|
|
2754
|
+
};
|
|
2755
|
+
}, [controller, sessionId, filename, mode]);
|
|
2756
|
+
|
|
2757
|
+
const deepLink = buildArtifactLinkUrl(sessionId, filename);
|
|
2758
|
+
const actions = chromeless ? null : React.createElement(React.Fragment, null,
|
|
2759
|
+
React.createElement(HtmlViewModeToggle, { mode, setMode }),
|
|
2760
|
+
mode === "rendered"
|
|
2761
|
+
? React.createElement(HtmlFitWidthToggle, { fitWidth, setFitWidth })
|
|
2762
|
+
: null,
|
|
2763
|
+
mode === "rendered"
|
|
2764
|
+
? React.createElement(HtmlZoomControl, { zoom, setZoom })
|
|
2765
|
+
: null,
|
|
2766
|
+
deepLink
|
|
2767
|
+
? React.createElement(IconButton, {
|
|
2768
|
+
icon: "↗",
|
|
2769
|
+
label: "Open in a new tab",
|
|
2770
|
+
onClick: () => { try { window.open(deepLink, "_blank", "noopener"); } catch { /* popup blocked */ } },
|
|
2771
|
+
})
|
|
2772
|
+
: null);
|
|
2773
|
+
|
|
2774
|
+
const body = mode === "source"
|
|
2775
|
+
? React.createElement(HtmlArtifactSourcePanel, { controller, sessionId, filename, theme })
|
|
2776
|
+
: error
|
|
2777
|
+
? React.createElement("div", { className: "ps-html-preview-status" },
|
|
2778
|
+
React.createElement("p", null, `Could not load ${filename}: ${error}`),
|
|
2779
|
+
React.createElement("p", null, "Use Download to save the file and open it locally."))
|
|
2780
|
+
: frameUrl
|
|
2781
|
+
// Live and pending are rendered together while a reload is in
|
|
2782
|
+
// flight; the pending one is offscreen-transparent until its
|
|
2783
|
+
// load fires. Keys are `frame:<nonce>` in BOTH roles so
|
|
2784
|
+
// promotion is a class change, not a remount — remounting
|
|
2785
|
+
// would reload the document we just finished loading.
|
|
2786
|
+
? [frames.live, frames.pending]
|
|
2787
|
+
.filter((nonce, index, all) => nonce !== null && all.indexOf(nonce) === index)
|
|
2788
|
+
.map((nonce) => React.createElement("iframe", {
|
|
2789
|
+
key: `frame:${nonce}`,
|
|
2790
|
+
className: `ps-html-preview-frame${nonce === frames.live ? "" : " is-staging"}`,
|
|
2791
|
+
// A transform does not change the layout box, so the
|
|
2792
|
+
// frame is laid out at the full layout width and a
|
|
2793
|
+
// proportionally TALLER height, then scaled back into
|
|
2794
|
+
// the host. Height in layout px, not %, because a
|
|
2795
|
+
// percentage would resolve against a flex parent whose
|
|
2796
|
+
// definite height is not guaranteed.
|
|
2797
|
+
style: fitActive
|
|
2798
|
+
? {
|
|
2799
|
+
flex: "none",
|
|
2800
|
+
width: `${HTML_PREVIEW_FIT_LAYOUT_WIDTH_PX}px`,
|
|
2801
|
+
height: `${Math.max(1, Math.round(hostBox.height / renderScale))}px`,
|
|
2802
|
+
transform: `scale(${renderScale})`,
|
|
2803
|
+
transformOrigin: "top left",
|
|
2804
|
+
}
|
|
2805
|
+
// Zoom without fit takes the other route: shrink the
|
|
2806
|
+
// frame's LAYOUT box by the zoom factor and scale it
|
|
2807
|
+
// back up, so the document reflows at the zoomed size
|
|
2808
|
+
// instead of being magnified as a flat picture.
|
|
2809
|
+
: zoom !== 1 && hostBox.width > 0
|
|
2810
|
+
? {
|
|
2811
|
+
flex: "none",
|
|
2812
|
+
width: `${Math.max(1, Math.round(hostBox.width / zoom))}px`,
|
|
2813
|
+
height: `${Math.max(1, Math.round(hostBox.height / zoom))}px`,
|
|
2814
|
+
transform: `scale(${zoom})`,
|
|
2815
|
+
transformOrigin: "top left",
|
|
2816
|
+
}
|
|
2817
|
+
: undefined,
|
|
2818
|
+
src: frameUrl,
|
|
2819
|
+
title: filename || "HTML artifact",
|
|
2820
|
+
// No allow-same-origin — see the block comment above.
|
|
2821
|
+
sandbox: "allow-scripts allow-popups allow-popups-to-escape-sandbox",
|
|
2822
|
+
referrerPolicy: "no-referrer",
|
|
2823
|
+
onLoad: () => frames.promote(nonce),
|
|
2824
|
+
}))
|
|
2825
|
+
: React.createElement("div", { className: "ps-html-preview-status" },
|
|
2826
|
+
React.createElement("p", null, `Rendering ${filename}…`),
|
|
2827
|
+
sizeBytes != null
|
|
2828
|
+
? React.createElement("p", { className: "ps-html-preview-meta" },
|
|
2829
|
+
[contentType, formatArtifactPreviewBytes(sizeBytes)].filter(Boolean).join(" • "))
|
|
2830
|
+
: null);
|
|
2831
|
+
|
|
2832
|
+
// The measured host wraps the rendered branch only — in Source mode there
|
|
2833
|
+
// is no document whose layout could go stale, so nothing observes width.
|
|
2834
|
+
const hosted = mode === "rendered"
|
|
2835
|
+
? React.createElement("div", {
|
|
2836
|
+
ref: frameHostRef,
|
|
2837
|
+
// The frame's layout box stays HTML_PREVIEW_FIT_LAYOUT_WIDTH_PX wide
|
|
2838
|
+
// while fitting; without clipping, that overflow would hand the host
|
|
2839
|
+
// a horizontal scrollbar for content that is already fully visible.
|
|
2840
|
+
className: `ps-html-preview-host${fitActive || zoom !== 1 ? " is-fitting" : ""}`,
|
|
2841
|
+
}, body)
|
|
2842
|
+
: body;
|
|
2843
|
+
|
|
2844
|
+
return React.createElement(Panel, { title, color, focused, theme, actions, className: "ps-html-preview" }, hosted);
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
/**
|
|
2848
|
+
* Source view for an HTML artifact — highlighted, and fetched in full rather
|
|
2849
|
+
* than reusing the 200k-truncated preview text, so toggling Rendered→Source
|
|
2850
|
+
* does not silently lose two thirds of a large document.
|
|
2851
|
+
*/
|
|
2852
|
+
function HtmlArtifactSourcePanel({ controller, sessionId, filename, theme }) {
|
|
2853
|
+
const [text, setText] = React.useState(null);
|
|
2854
|
+
const [error, setError] = React.useState(null);
|
|
2855
|
+
React.useEffect(() => {
|
|
2856
|
+
setText(null);
|
|
2857
|
+
setError(null);
|
|
2858
|
+
const downloadResponse = controller?.transport?.api?.downloadArtifactResponse;
|
|
2859
|
+
if (typeof downloadResponse !== "function" || !sessionId || !filename) {
|
|
2860
|
+
setError("artifact download unavailable");
|
|
2861
|
+
return undefined;
|
|
2862
|
+
}
|
|
2863
|
+
let cancelled = false;
|
|
2864
|
+
downloadResponse.call(controller.transport.api, sessionId, filename)
|
|
2865
|
+
.then(async (response) => {
|
|
2866
|
+
if (!response?.ok) throw new Error(`download failed (${response?.status})`);
|
|
2867
|
+
return response.text();
|
|
2868
|
+
})
|
|
2869
|
+
.then((value) => { if (!cancelled) setText(value); })
|
|
2870
|
+
.catch((err) => { if (!cancelled) setError(err?.message || String(err)); });
|
|
2871
|
+
return () => { cancelled = true; };
|
|
2872
|
+
}, [controller, sessionId, filename]);
|
|
2873
|
+
|
|
2874
|
+
if (error) {
|
|
2875
|
+
return React.createElement("div", { className: "ps-html-preview-status" },
|
|
2876
|
+
React.createElement("p", null, `Could not load source: ${error}`));
|
|
2877
|
+
}
|
|
2878
|
+
if (text == null) {
|
|
2879
|
+
return React.createElement("div", { className: "ps-html-preview-status" },
|
|
2880
|
+
React.createElement("p", null, "Loading source…"));
|
|
2881
|
+
}
|
|
2882
|
+
return React.createElement("div", { className: "ps-scroll-panel is-preview ps-diff-preview" },
|
|
2883
|
+
React.createElement("pre", { className: "ps-md-code-pre" },
|
|
2884
|
+
React.createElement("code", null, renderHighlightedCode(text, "html", theme))));
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2208
2887
|
function isBoxTopLine(text) {
|
|
2209
2888
|
const value = String(text || "").trim();
|
|
2210
2889
|
return value.startsWith("┌") && value.endsWith("┐");
|
|
@@ -2545,7 +3224,18 @@ function fetchArtifactObjectUrl(controller, sessionId, filename) {
|
|
|
2545
3224
|
.then(async (response) => {
|
|
2546
3225
|
if (!response?.ok) throw new Error(`download failed (${response?.status})`);
|
|
2547
3226
|
const blob = await response.blob();
|
|
2548
|
-
|
|
3227
|
+
// Re-type from the extension when the stored content type is not an
|
|
3228
|
+
// image one. This is what made an agent-written bar_chart.svg
|
|
3229
|
+
// (uploaded as text/plain) render as a broken-image glyph: the blob
|
|
3230
|
+
// inherited text/plain and <img> will not paint that. Re-typing does
|
|
3231
|
+
// NOT widen the SVG-script exposure the block below guards against —
|
|
3232
|
+
// <img> never runs script in an SVG regardless of how it is typed,
|
|
3233
|
+
// and the click-through still goes to the artifact deep link.
|
|
3234
|
+
const imageMime = imageMimeFromFilename(filename);
|
|
3235
|
+
const typedBlob = imageMime && !IMAGE_CONTENT_TYPE_RE.test(blob.type)
|
|
3236
|
+
? new Blob([blob], { type: imageMime })
|
|
3237
|
+
: blob;
|
|
3238
|
+
return URL.createObjectURL(typedBlob);
|
|
2549
3239
|
})
|
|
2550
3240
|
.catch((error) => {
|
|
2551
3241
|
// Failed fetches must not be cached — a retry on next render is fine.
|
|
@@ -3596,6 +4286,13 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
3596
4286
|
);
|
|
3597
4287
|
const selectedCount = Array.isArray(viewState.selectedIds) ? viewState.selectedIds.length : 0;
|
|
3598
4288
|
const isBulkSelection = selectedCount > 1;
|
|
4289
|
+
// A folder has none of the session-manage surface — no model, no sharing,
|
|
4290
|
+
// no visibility — which is why canModifyActiveSession excludes groups. But
|
|
4291
|
+
// it does have a TITLE, and the rename modal already routes groups to
|
|
4292
|
+
// updateSessionGroup. Renaming is the one manage action a folder supports.
|
|
4293
|
+
// Session groups are private per-user organisation, so a folder you can
|
|
4294
|
+
// see is a folder you own; there is no separate ownership check to make.
|
|
4295
|
+
const canRenameActiveGroup = Boolean(activeSession?.isGroup && !isBulkSelection);
|
|
3599
4296
|
const canPinActiveSession = Boolean(
|
|
3600
4297
|
activeSession
|
|
3601
4298
|
&& !activeSession.isSystem
|
|
@@ -4116,8 +4813,17 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
4116
4813
|
React.createElement(IconButton, {
|
|
4117
4814
|
className: "ps-mini-button",
|
|
4118
4815
|
icon: React.createElement(ManageGlyph),
|
|
4119
|
-
onClick: () =>
|
|
4120
|
-
|
|
4816
|
+
onClick: () => {
|
|
4817
|
+
// Folders reuse this control rather than dead-ending on it:
|
|
4818
|
+
// it is where people look for "change this thing's name", and
|
|
4819
|
+
// it is where this one was reported from.
|
|
4820
|
+
if (activeSession?.isGroup) {
|
|
4821
|
+
controller.handleCommand(UI_COMMANDS.OPEN_RENAME_SESSION).catch(() => {});
|
|
4822
|
+
return;
|
|
4823
|
+
}
|
|
4824
|
+
setManageOpen(true);
|
|
4825
|
+
},
|
|
4826
|
+
disabled: (!canModifyActiveSession && !canRenameActiveGroup) || isBulkSelection,
|
|
4121
4827
|
// A disabled control has to say WHY. This label used to describe
|
|
4122
4828
|
// the ENABLED behaviour in every disabled case except bulk
|
|
4123
4829
|
// selection, so on a system session the tooltip promised "rename,
|
|
@@ -4128,7 +4834,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
4128
4834
|
: activeSession?.isSystem
|
|
4129
4835
|
? "System sessions are managed by administrators"
|
|
4130
4836
|
: activeSession?.isGroup
|
|
4131
|
-
? "
|
|
4837
|
+
? "Rename folder"
|
|
4132
4838
|
: !canModifyActiveSession
|
|
4133
4839
|
? "Only this session's owner or an admin can manage it"
|
|
4134
4840
|
: "Manage session — rename, switch model, and sharing",
|
|
@@ -5038,6 +5744,141 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
|
|
|
5038
5744
|
});
|
|
5039
5745
|
}
|
|
5040
5746
|
|
|
5747
|
+
/**
|
|
5748
|
+
* The desktop artifact reader: a full-height pane that takes over the right
|
|
5749
|
+
* column, replacing the inspector AND activity panes for as long as it is open.
|
|
5750
|
+
*
|
|
5751
|
+
* Why a takeover rather than one more box: an artifact worth opening from the
|
|
5752
|
+
* transcript is the thing you want to READ, and the previous arrangement gave
|
|
5753
|
+
* it the bottom third of a column it shared with a file list. The chat stays
|
|
5754
|
+
* beside it — that is the half of the split that matters — while everything
|
|
5755
|
+
* that was only ever context for the chat steps aside.
|
|
5756
|
+
*
|
|
5757
|
+
* ✕ hands the column back to the inspector/activity split, or re-collapses it
|
|
5758
|
+
* if it was already collapsed when the pane opened (see closeArtifactPane).
|
|
5759
|
+
*/
|
|
5760
|
+
function ArtifactTakeoverPane({ controller, onClose }) {
|
|
5761
|
+
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
5762
|
+
const theme = getTheme(themeId);
|
|
5763
|
+
const view = useControllerSelector(controller, (state) => {
|
|
5764
|
+
const id = state.files.selectedArtifactId || "";
|
|
5765
|
+
const slash = id.indexOf("/");
|
|
5766
|
+
return {
|
|
5767
|
+
sessionId: slash > 0 ? id.slice(0, slash) : null,
|
|
5768
|
+
filename: slash > 0 ? id.slice(slash + 1) : "",
|
|
5769
|
+
canPrev: controller.canStepArtifactPreview(-1),
|
|
5770
|
+
canNext: controller.canStepArtifactPreview(1),
|
|
5771
|
+
};
|
|
5772
|
+
}, shallowEqualObject);
|
|
5773
|
+
|
|
5774
|
+
const descriptor = describeArtifact(view.filename);
|
|
5775
|
+
const htmlViewMode = useControllerSelector(controller, (state) => state.files.htmlViewMode || "rendered");
|
|
5776
|
+
const htmlFitWidth = useControllerSelector(controller, (state) => Boolean(state.files.htmlFitWidth));
|
|
5777
|
+
const setHtmlFitWidth = React.useCallback((next) => {
|
|
5778
|
+
controller.dispatch({ type: "files/htmlFitWidth", enabled: next });
|
|
5779
|
+
}, [controller]);
|
|
5780
|
+
const setHtmlViewMode = React.useCallback((next) => {
|
|
5781
|
+
controller.dispatch({ type: "files/htmlViewMode", mode: next });
|
|
5782
|
+
}, [controller]);
|
|
5783
|
+
const htmlZoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
|
|
5784
|
+
const setHtmlZoom = React.useCallback((next) => {
|
|
5785
|
+
controller.dispatch({ type: "files/htmlZoom", zoom: next });
|
|
5786
|
+
}, [controller]);
|
|
5787
|
+
const step = React.useCallback((delta) => {
|
|
5788
|
+
controller.stepArtifactPreview(delta).catch(() => {});
|
|
5789
|
+
}, [controller]);
|
|
5790
|
+
|
|
5791
|
+
// ‹ › as keys, not just buttons — the same navigation the phone overlay
|
|
5792
|
+
// has. Bound on the pane itself rather than the window so it cannot
|
|
5793
|
+
// hijack the arrow keys while the user is typing in the composer.
|
|
5794
|
+
const onKeyDown = React.useCallback((event) => {
|
|
5795
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
5796
|
+
if (event.key === "ArrowLeft" || event.key === "[") { event.preventDefault(); step(-1); return; }
|
|
5797
|
+
if (event.key === "ArrowRight" || event.key === "]") { event.preventDefault(); step(1); return; }
|
|
5798
|
+
if (event.key === "Escape") { event.preventDefault(); onClose(); }
|
|
5799
|
+
}, [onClose, step]);
|
|
5800
|
+
|
|
5801
|
+
const deepLink = buildArtifactLinkUrl(view.sessionId, view.filename);
|
|
5802
|
+
|
|
5803
|
+
return React.createElement("section", {
|
|
5804
|
+
className: "ps-artifact-pane",
|
|
5805
|
+
onKeyDown,
|
|
5806
|
+
tabIndex: -1,
|
|
5807
|
+
role: "region",
|
|
5808
|
+
"aria-label": `Artifact: ${view.filename || "none"}`,
|
|
5809
|
+
},
|
|
5810
|
+
React.createElement("header", { className: "ps-artifact-pane-bar" },
|
|
5811
|
+
// Rendered/Source lives HERE for HTML rather than in a second header
|
|
5812
|
+
// row below — one bar owns the artifact, the way the reference does.
|
|
5813
|
+
descriptor.kind === "page"
|
|
5814
|
+
? React.createElement(HtmlViewModeToggle, { mode: htmlViewMode, setMode: setHtmlViewMode })
|
|
5815
|
+
: null,
|
|
5816
|
+
// The panel is chromeless in here, so this bar owns the fit control —
|
|
5817
|
+
// and this is the surface that needs it most: a fixed-width page in a
|
|
5818
|
+
// phone-width frame is exactly the case fitting exists for.
|
|
5819
|
+
descriptor.kind === "page" && htmlViewMode === "rendered"
|
|
5820
|
+
? React.createElement(HtmlFitWidthToggle, { fitWidth: htmlFitWidth, setFitWidth: setHtmlFitWidth })
|
|
5821
|
+
: null,
|
|
5822
|
+
// Zoom belongs here for the same reason: it scales the ARTIFACT, not
|
|
5823
|
+
// the workspace, which is the whole distinction from browser zoom.
|
|
5824
|
+
descriptor.kind === "page" && htmlViewMode === "rendered"
|
|
5825
|
+
? React.createElement(HtmlZoomControl, { zoom: htmlZoom, setZoom: setHtmlZoom })
|
|
5826
|
+
: null,
|
|
5827
|
+
React.createElement("span", { className: "ps-artifact-pane-heading" },
|
|
5828
|
+
React.createElement("span", {
|
|
5829
|
+
className: "ps-artifact-pane-name",
|
|
5830
|
+
title: view.filename,
|
|
5831
|
+
}, artifactCardTitle(view.filename) || "Artifact"),
|
|
5832
|
+
React.createElement("span", { className: "ps-artifact-pane-type" }, descriptor.type)),
|
|
5833
|
+
React.createElement("span", { className: "ps-artifact-pane-actions" },
|
|
5834
|
+
React.createElement("button", {
|
|
5835
|
+
type: "button",
|
|
5836
|
+
className: "ps-artifact-pane-btn",
|
|
5837
|
+
disabled: !view.canPrev,
|
|
5838
|
+
onClick: () => step(-1),
|
|
5839
|
+
title: "Previous artifact (←)",
|
|
5840
|
+
"aria-label": "Previous artifact",
|
|
5841
|
+
}, "‹"),
|
|
5842
|
+
React.createElement("button", {
|
|
5843
|
+
type: "button",
|
|
5844
|
+
className: "ps-artifact-pane-btn",
|
|
5845
|
+
disabled: !view.canNext,
|
|
5846
|
+
onClick: () => step(1),
|
|
5847
|
+
title: "Next artifact (→)",
|
|
5848
|
+
"aria-label": "Next artifact",
|
|
5849
|
+
}, "›"),
|
|
5850
|
+
React.createElement("button", {
|
|
5851
|
+
type: "button",
|
|
5852
|
+
className: "ps-artifact-pane-btn",
|
|
5853
|
+
onClick: () => {
|
|
5854
|
+
if (!deepLink) return;
|
|
5855
|
+
copySessionLinkText(deepLink);
|
|
5856
|
+
controller.dispatch({ type: "ui/status", text: ARTIFACT_LINK_COPIED_STATUS });
|
|
5857
|
+
},
|
|
5858
|
+
disabled: !deepLink,
|
|
5859
|
+
title: "Copy a link that reopens this artifact",
|
|
5860
|
+
"aria-label": "Copy link",
|
|
5861
|
+
}, "🔗"),
|
|
5862
|
+
React.createElement("button", {
|
|
5863
|
+
type: "button",
|
|
5864
|
+
className: "ps-artifact-pane-btn",
|
|
5865
|
+
onClick: () => controller.handleCommand(UI_COMMANDS.DOWNLOAD_SELECTED_FILE).catch(() => {}),
|
|
5866
|
+
title: "Download",
|
|
5867
|
+
"aria-label": "Download",
|
|
5868
|
+
}, React.createElement(DownloadGlyph)),
|
|
5869
|
+
React.createElement("button", {
|
|
5870
|
+
type: "button",
|
|
5871
|
+
className: "ps-artifact-pane-btn is-close",
|
|
5872
|
+
onClick: onClose,
|
|
5873
|
+
title: "Close (Esc)",
|
|
5874
|
+
"aria-label": "Close artifact",
|
|
5875
|
+
}, "✕"))),
|
|
5876
|
+
React.createElement("div", { className: "ps-artifact-pane-body" },
|
|
5877
|
+
React.createElement(FilesPane, {
|
|
5878
|
+
controller, focused: false, previewOnly: true, chromeless: true, theme,
|
|
5879
|
+
})));
|
|
5880
|
+
}
|
|
5881
|
+
|
|
5041
5882
|
/**
|
|
5042
5883
|
* Mobile artifact viewer — a genuine full-viewport overlay, not a pane.
|
|
5043
5884
|
*
|
|
@@ -5146,7 +5987,7 @@ function InspectorTabs({ activeTab, controller }) {
|
|
|
5146
5987
|
// can host it in the activity slot where it gets the existing row resizer.
|
|
5147
5988
|
// Detached this way the preview is resizable; nested inside the inspector it
|
|
5148
5989
|
// could only ever have half of a pane.
|
|
5149
|
-
function FilesPane({ controller, focused, mobile = false, previewOnly = false, nativeScroll = false }) {
|
|
5990
|
+
function FilesPane({ controller, focused, mobile = false, previewOnly = false, nativeScroll = false, chromeless = false }) {
|
|
5150
5991
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
5151
5992
|
const theme = getTheme(themeId);
|
|
5152
5993
|
const fileInputRef = React.useRef(null);
|
|
@@ -5279,6 +6120,20 @@ function FilesPane({ controller, focused, mobile = false, previewOnly = false, n
|
|
|
5279
6120
|
onClick: () => controller.handleCommand(UI_COMMANDS.DOWNLOAD_SELECTED_FILE).catch(() => {}),
|
|
5280
6121
|
disabled: !hasSelection,
|
|
5281
6122
|
}),
|
|
6123
|
+
// Deep link to THIS artifact, opened full screen. Same URL shape the
|
|
6124
|
+
// agent's show_artifact tool hands back, so a link pasted in chat and a
|
|
6125
|
+
// link copied here land in exactly the same place.
|
|
6126
|
+
React.createElement(IconButton, {
|
|
6127
|
+
icon: "🔗",
|
|
6128
|
+
label: "Copy link to this artifact",
|
|
6129
|
+
onClick: () => {
|
|
6130
|
+
const url = buildArtifactLinkUrl(filesView.selectedSessionId, filesView.selectedFilename);
|
|
6131
|
+
if (!url) return;
|
|
6132
|
+
copySessionLinkText(url);
|
|
6133
|
+
controller.dispatch({ type: "ui/status", text: ARTIFACT_LINK_COPIED_STATUS });
|
|
6134
|
+
},
|
|
6135
|
+
disabled: !hasSelection || !filesView.selectedFilename,
|
|
6136
|
+
}),
|
|
5282
6137
|
// Trash, not "✕" — the bulk-delete two buttons away was already a trash
|
|
5283
6138
|
// can, and "✕" means "clear marks" in this same row.
|
|
5284
6139
|
viewState.canDeleteArtifacts ? React.createElement(IconButton, {
|
|
@@ -5390,7 +6245,27 @@ function FilesPane({ controller, focused, mobile = false, previewOnly = false, n
|
|
|
5390
6245
|
&& isTabularArtifact(filesView.selectedFilename, filesView.previewContentType)
|
|
5391
6246
|
&& Boolean(filesView.previewContent);
|
|
5392
6247
|
|
|
5393
|
-
|
|
6248
|
+
// HTML renders as HTML. Note this branch does NOT require previewContent:
|
|
6249
|
+
// the frame streams the artifact's own bytes, so it works even when the
|
|
6250
|
+
// text preview was truncated or never loaded.
|
|
6251
|
+
const previewIsHtml = previewReady
|
|
6252
|
+
&& !filesView.previewIsBinary
|
|
6253
|
+
&& isHtmlArtifact(filesView.selectedFilename, filesView.previewContentType);
|
|
6254
|
+
|
|
6255
|
+
const previewPane = previewIsHtml
|
|
6256
|
+
? React.createElement(HtmlArtifactPreviewPanel, {
|
|
6257
|
+
controller,
|
|
6258
|
+
sessionId: filesView.selectedSessionId,
|
|
6259
|
+
filename: filesView.selectedFilename,
|
|
6260
|
+
contentType: filesView.previewContentType,
|
|
6261
|
+
sizeBytes: filesView.previewSizeBytes,
|
|
6262
|
+
theme,
|
|
6263
|
+
color: "cyan",
|
|
6264
|
+
focused: false,
|
|
6265
|
+
title: null,
|
|
6266
|
+
chromeless,
|
|
6267
|
+
})
|
|
6268
|
+
: previewIsTabular
|
|
5394
6269
|
? React.createElement(RenderedPreviewPanel, {
|
|
5395
6270
|
controller, title: null, color: "cyan", focused: false, theme,
|
|
5396
6271
|
paneKey: previewPaneKey, scrollOffset: viewState.previewScroll,
|
|
@@ -8548,6 +9423,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8548
9423
|
inspectorTab: rootState.ui.inspectorTab,
|
|
8549
9424
|
filesFullscreen: Boolean(rootState.files.fullscreen),
|
|
8550
9425
|
selectedArtifactId: rootState.files.selectedArtifactId || null,
|
|
9426
|
+
artifactPaneOpen: Boolean(rootState.files.paneOpen),
|
|
8551
9427
|
adminVisible: Boolean(rootState.admin?.visible),
|
|
8552
9428
|
}), shallowEqualObject);
|
|
8553
9429
|
const profileSettingsHydratedRef = React.useRef(false);
|
|
@@ -8556,6 +9432,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8556
9432
|
// endpoint replaces the whole settings object, so this is what keeps a
|
|
8557
9433
|
// desktop save from erasing the phone's preference.
|
|
8558
9434
|
const otherChatViewModeRef = React.useRef(null);
|
|
9435
|
+
const otherTouchScaleRef = React.useRef(null);
|
|
8559
9436
|
const profileSettingsSaveTimerRef = React.useRef(null);
|
|
8560
9437
|
const profileSettingsPollTimerRef = React.useRef(null);
|
|
8561
9438
|
const profileSettingsPollInFlightRef = React.useRef(false);
|
|
@@ -8613,8 +9490,11 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8613
9490
|
if (isChatViewMode(remoteNormalized[otherChatViewModeKey()])) {
|
|
8614
9491
|
otherChatViewModeRef.current = remoteNormalized[otherChatViewModeKey()];
|
|
8615
9492
|
}
|
|
9493
|
+
if (typeof remoteNormalized[otherTouchScaleKey()] === "boolean") {
|
|
9494
|
+
otherTouchScaleRef.current = remoteNormalized[otherTouchScaleKey()];
|
|
9495
|
+
}
|
|
8616
9496
|
defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
|
|
8617
|
-
controller.getState(), otherChatViewModeRef.current,
|
|
9497
|
+
controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
|
|
8618
9498
|
);
|
|
8619
9499
|
const settings = materializeProfileSettings(
|
|
8620
9500
|
profile?.profileSettings,
|
|
@@ -8622,7 +9502,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8622
9502
|
);
|
|
8623
9503
|
const settingsJson = JSON.stringify(settings);
|
|
8624
9504
|
const currentSettingsBeforeApply = profileSettingsFromViewState(
|
|
8625
|
-
controller.getState(), otherChatViewModeRef.current,
|
|
9505
|
+
controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
|
|
8626
9506
|
);
|
|
8627
9507
|
const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
|
|
8628
9508
|
const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
|
|
@@ -8649,7 +9529,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8649
9529
|
// remote state by definition and there is nothing to lose).
|
|
8650
9530
|
if (!profileSettingsHydratedRef.current || !hasPendingLocalWrite) {
|
|
8651
9531
|
const currentSettings = profileSettingsFromViewState(
|
|
8652
|
-
controller.getState(), otherChatViewModeRef.current,
|
|
9532
|
+
controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
|
|
8653
9533
|
);
|
|
8654
9534
|
lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
|
|
8655
9535
|
}
|
|
@@ -8683,7 +9563,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8683
9563
|
|
|
8684
9564
|
React.useEffect(() => {
|
|
8685
9565
|
if (!profileSettingsHydratedRef.current) return undefined;
|
|
8686
|
-
const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current);
|
|
9566
|
+
const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current, otherTouchScaleRef.current);
|
|
8687
9567
|
const settingsJson = JSON.stringify(settings);
|
|
8688
9568
|
if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
|
|
8689
9569
|
lastProfileSettingsJsonRef.current = settingsJson;
|
|
@@ -8748,6 +9628,20 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8748
9628
|
&& Boolean(state.selectedArtifactId)
|
|
8749
9629
|
&& !filesFullscreenActive;
|
|
8750
9630
|
|
|
9631
|
+
// The takeover pane only makes sense on desktop; the phone has its own
|
|
9632
|
+
// full-viewport overlay and no column to take over.
|
|
9633
|
+
const artifactPaneActive = !mobile
|
|
9634
|
+
&& state.artifactPaneOpen
|
|
9635
|
+
&& Boolean(state.selectedArtifactId)
|
|
9636
|
+
&& !filesFullscreenActive;
|
|
9637
|
+
|
|
9638
|
+
// Closing hands the column back — or collapses it again, if the user had
|
|
9639
|
+
// already resized it away before opening the pane. Both cases live in the
|
|
9640
|
+
// controller so the decision is testable without a DOM.
|
|
9641
|
+
const closeArtifactPane = React.useCallback(() => {
|
|
9642
|
+
controller.closeArtifactPane().catch(() => {});
|
|
9643
|
+
}, [controller]);
|
|
9644
|
+
|
|
8751
9645
|
React.useEffect(() => {
|
|
8752
9646
|
if (!filesFullscreenActive || !chatFocusMode) return;
|
|
8753
9647
|
setChatFocusMode(false);
|
|
@@ -8814,7 +9708,13 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8814
9708
|
},
|
|
8815
9709
|
React.createElement(ChatPane, { controller }))),
|
|
8816
9710
|
React.createElement(ColumnResizeHandle, { controller, paneAdjust: state.paneAdjust }),
|
|
8817
|
-
|
|
9711
|
+
// The artifact reader takes the WHOLE right column — inspector, resizer and
|
|
9712
|
+
// activity all step aside. The column keeps its width and resizer, so the
|
|
9713
|
+
// chat/reader split is dragged exactly like the chat/inspector one was.
|
|
9714
|
+
artifactPaneActive
|
|
9715
|
+
? React.createElement("div", { className: "ps-workspace-column is-artifact" },
|
|
9716
|
+
React.createElement(ArtifactTakeoverPane, { controller, onClose: closeArtifactPane }))
|
|
9717
|
+
: React.createElement("div", {
|
|
8818
9718
|
className: "ps-workspace-column",
|
|
8819
9719
|
style: { gridTemplateRows: `${layout.inspectorPaneHeight}fr var(--ps-resizer-track, 16px) ${layout.activityPaneHeight}fr` },
|
|
8820
9720
|
},
|