pilotswarm 0.5.34 → 0.5.36
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 +141 -0
- package/ui/core/src/themes/index.js +2 -0
- package/ui/core/src/themes/win95.js +4 -1
- package/ui/react/src/web-app.js +1007 -47
- package/web/dist/assets/{abnfDiagram-VRR7QNED-BFOimVWK.js → abnfDiagram-VRR7QNED-LOLCtsns.js} +1 -1
- package/web/dist/assets/{arc-uUHjvwEo.js → arc-BE-jVDEi.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CxeX_vea.js → architectureDiagram-ZJ3FMSHR-CdmUjwtj.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-Cno8kltG.js → blockDiagram-677ZJIJ3-NZ4TOa8L.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-viONVybf.js → c4Diagram-LMCZKHZV-9ubX8uGA.js} +1 -1
- package/web/dist/assets/channel-BhHZnKdH.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-CKfICIGD.js → chunk-2Q5K7J3B-DNDnjbP6.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-CxmwEY56.js → chunk-32BRIVSS-iilfiffr.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-_eVYLGll.js → chunk-5VM5RSS4-BakOMFmf.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-C4aqbjrf.js → chunk-EX3LRPZG-BhluhGyZ.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-Bh8ZxrI8.js → chunk-JWPE2WC7-B6GKLlNb.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-BXgLlWVP.js → chunk-MOJQB5TN-CXmt8msF.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-B1rMIdfF.js → chunk-RYQCIY6F-Bu58-SDr.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-C3AnSHXk.js → chunk-V7JOEXUC-AQdyoYHL.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-CeoMJIBs.js → chunk-VR4S4FIN-DeM5iMsE.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-DzaxQ9iP.js → chunk-XXDRQBXY-DjGjajy1.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-CM_bKBKz.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-CM_bKBKz.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-G46fvUOx.js → cose-bilkent-JH36ORCC-BGi4_egJ.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-DXwqVhWA.js → cynefin-VYW2F7L2-CcgV1ve4.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-DNNFmaXb.js → cynefinDiagram-TSTJHNR4-BRDEsbBG.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-BCfh-h3o.js → dagre-VKFMJZFB-OjuC8dJB.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-CyG9b6bj.js → diagram-FQU43EPY-CGdROWYh.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-DdxDahK3.js → diagram-G47NLZAW-B5J0EUfS.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-CjrZ_eja.js → diagram-NH7WQ7WH-zmb4-cP2.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-5NAjQpk2.js → diagram-OA4YK3LP-j514bgaH.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-fLuDb54-.js → diagram-WEI45ONY-h5G459mZ.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DL9qnZpa.js → ebnfDiagram-CCIWWBDH-GnMgbuFk.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-C_ynfrWY.js → erDiagram-Q63AITRT-BFgH3UQp.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-dYuH8HYv.js → flowDiagram-23GEKE2U-BsbQ_VXV.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-e8Meh3f3.js → ganttDiagram-NO4QXBWP-C_fjo2sf.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-D_tZ4VL9.js → gitGraphDiagram-IHSO6WYX-BUoXrAI9.js} +1 -1
- package/web/dist/assets/index-CnH1Yv_d.css +1 -0
- package/web/dist/assets/{index-DcQes9h8.js → index-ONJimzcx.js} +6 -6
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-Dffhqp73.js → infoDiagram-FWYZ7A6U-CJiSANyk.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-DYt9H7kU.js → ishikawaDiagram-FXEZZL3T-BKoQ-a2c.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-Qv1vVu0F.js → journeyDiagram-5HDEW3XC-Db6Crcd3.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-CTAUo40G.js → kanban-definition-HUTT4EX6-B9GaB0UJ.js} +1 -1
- package/web/dist/assets/{linear-CqLsWFLj.js → linear-BXwRb8MN.js} +1 -1
- package/web/dist/assets/{mermaid.core-okRCGPQQ.js → mermaid.core-DwzXN6up.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-DVOOrIwD.js → mindmap-definition-LN4V7U3C-DKC_C_OY.js} +1 -1
- package/web/dist/assets/{papaparse.min-Ddflu8oC.js → papaparse.min-Cm1Pr54l.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-BLJlC0MZ.js → pegDiagram-2B236MQR-IeawbZPt.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-DdOwICOg.js → pieDiagram-ENE6RG2P-BOdsYBt8.js} +1 -1
- package/web/dist/assets/pilotswarm-kCQFVjJm.js +99 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-B0Th1YO5.js → quadrantDiagram-ABIIQ3AL-Cr5yoN7C.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-BF2XKMKf.js → railroadDiagram-RFXS5EU6-E7wuFBuk.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-Mr1iSwbS.js → requirementDiagram-TGXJPOKE-sZCKiuDs.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-CrOjVZyg.js → sankeyDiagram-HTMAVEWB-B_EWziUR.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-H0X4Lydk.js → sequenceDiagram-DBY2YBRQ-D2mN7onX.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-Dus1QEqh.js → sizeCapture-X5ZJPWSS-CEL9Ly2T.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-BOBUSwdR.js → stateDiagram-2N3HPSRC-C7ejJoeN.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-UaVDt_gU.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-BoqT-Lh7.js → swimlanes-5IMT3BWC-Ck3pbFxk.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CqO_fF5t.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-BOrqUQHX.js → timeline-definition-FHXFAJF6-K3H8Yuyr.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-DDkNL-Oo.js → vennDiagram-L72KCM5P-C3NKjSv9.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-XUAJQ2X0.js → wardleyDiagram-EHGQE667-CeeVuFiO.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-ph69PV9y.js → xychartDiagram-FW5EYKEG-CF-vCvEq.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,
|
|
@@ -6454,6 +7329,30 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
|
|
|
6454
7329
|
return toolbar;
|
|
6455
7330
|
}
|
|
6456
7331
|
|
|
7332
|
+
/**
|
|
7333
|
+
* Pin a drag to the element that started it.
|
|
7334
|
+
*
|
|
7335
|
+
* Without capture, a resizer listens for pointerup on `window` — and never
|
|
7336
|
+
* hears it if the button is released over a CROSS-ORIGIN iframe. The artifact
|
|
7337
|
+
* reader is exactly that, and it sits directly beside the column resizer, so
|
|
7338
|
+
* releasing over a rendered artifact left the drag armed: the seam kept
|
|
7339
|
+
* following the pointer with no button held and the cursor stayed stuck in
|
|
7340
|
+
* col-resize.
|
|
7341
|
+
*
|
|
7342
|
+
* Pointer capture routes every subsequent event for this pointerId to the
|
|
7343
|
+
* capturing element regardless of what is underneath, iframes included. The
|
|
7344
|
+
* events still bubble to the window listeners the handles already use, so
|
|
7345
|
+
* this is additive.
|
|
7346
|
+
*/
|
|
7347
|
+
function capturePointerForDrag(event) {
|
|
7348
|
+
try {
|
|
7349
|
+
event.currentTarget?.setPointerCapture?.(event.pointerId);
|
|
7350
|
+
} catch {
|
|
7351
|
+
// Capture is best-effort; the body class below is the second line of
|
|
7352
|
+
// defence and the window listeners still work for same-origin drags.
|
|
7353
|
+
}
|
|
7354
|
+
}
|
|
7355
|
+
|
|
6457
7356
|
function ColumnResizeHandle({ controller, paneAdjust = 0 }) {
|
|
6458
7357
|
const dragStateRef = React.useRef(null);
|
|
6459
7358
|
const [dragging, setDragging] = React.useState(false);
|
|
@@ -6511,6 +7410,7 @@ function ColumnResizeHandle({ controller, paneAdjust = 0 }) {
|
|
|
6511
7410
|
onPointerDown: (event) => {
|
|
6512
7411
|
if (event.button !== 0) return;
|
|
6513
7412
|
event.preventDefault();
|
|
7413
|
+
capturePointerForDrag(event);
|
|
6514
7414
|
dragStateRef.current = {
|
|
6515
7415
|
startX: event.clientX,
|
|
6516
7416
|
appliedCells: 0,
|
|
@@ -6583,6 +7483,7 @@ function RowResizeHandle({ controller, sessionPaneAdjust = 0 }) {
|
|
|
6583
7483
|
onPointerDown: (event) => {
|
|
6584
7484
|
if (event.button !== 0) return;
|
|
6585
7485
|
event.preventDefault();
|
|
7486
|
+
capturePointerForDrag(event);
|
|
6586
7487
|
dragStateRef.current = {
|
|
6587
7488
|
startY: event.clientY,
|
|
6588
7489
|
appliedCells: 0,
|
|
@@ -6659,6 +7560,7 @@ function SessionColumnResizeHandle({ controller, portalSessionColumnAdjust = 0 }
|
|
|
6659
7560
|
onPointerDown: (event) => {
|
|
6660
7561
|
if (event.button !== 0) return;
|
|
6661
7562
|
event.preventDefault();
|
|
7563
|
+
capturePointerForDrag(event);
|
|
6662
7564
|
const gridNode = event.currentTarget.closest(".ps-workspace-main-grid");
|
|
6663
7565
|
const bounds = portalSessionColumnBounds(gridNode?.getBoundingClientRect?.().width);
|
|
6664
7566
|
dragStateRef.current = {
|
|
@@ -6704,6 +7606,36 @@ function SessionColumnResizeHandle({ controller, portalSessionColumnAdjust = 0 }
|
|
|
6704
7606
|
React.createElement("span", { className: "ps-column-resizer-dot" })));
|
|
6705
7607
|
}
|
|
6706
7608
|
|
|
7609
|
+
/**
|
|
7610
|
+
* How many pixels the inspector/activity seam actually moves per row of adjust.
|
|
7611
|
+
*
|
|
7612
|
+
* The split is stored in character ROWS, but the column renders them as `fr`
|
|
7613
|
+
* — so one row of adjustment moves the seam by (column height / total rows)
|
|
7614
|
+
* pixels, whatever that happens to be. Dividing the pointer delta by a fixed
|
|
7615
|
+
* GRID_CELL_HEIGHT only tracks if the column is exactly rows × 19px tall, and
|
|
7616
|
+
* it is not: the column excludes the header and toolbar above it, and the row
|
|
7617
|
+
* height moves with the type scale. So the seam ran ahead of the pointer or
|
|
7618
|
+
* trailed behind it, and the error grew with the drag.
|
|
7619
|
+
*
|
|
7620
|
+
* Both operands are fixed for the duration of a drag — the split moves, the
|
|
7621
|
+
* total does not — so this is measured once at pointerdown rather than on
|
|
7622
|
+
* every pointermove.
|
|
7623
|
+
*/
|
|
7624
|
+
function measureActivityRowPx(controller, handleEl) {
|
|
7625
|
+
try {
|
|
7626
|
+
const layout = controller.getCurrentLayout?.();
|
|
7627
|
+
const rows = (layout?.inspectorPaneHeight || 0) + (layout?.activityPaneHeight || 0);
|
|
7628
|
+
const column = handleEl?.closest?.(".ps-workspace-column");
|
|
7629
|
+
if (!column || rows <= 0) return GRID_CELL_HEIGHT;
|
|
7630
|
+
// The resizer's own track is not part of either pane's share.
|
|
7631
|
+
const usable = column.clientHeight - (handleEl.getBoundingClientRect().height || 0);
|
|
7632
|
+
const pxPerRow = usable / rows;
|
|
7633
|
+
return Number.isFinite(pxPerRow) && pxPerRow > 0 ? pxPerRow : GRID_CELL_HEIGHT;
|
|
7634
|
+
} catch {
|
|
7635
|
+
return GRID_CELL_HEIGHT;
|
|
7636
|
+
}
|
|
7637
|
+
}
|
|
7638
|
+
|
|
6707
7639
|
function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
|
|
6708
7640
|
const dragStateRef = React.useRef(null);
|
|
6709
7641
|
const [dragging, setDragging] = React.useState(false);
|
|
@@ -6720,7 +7652,7 @@ function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
|
|
|
6720
7652
|
const onPointerMove = (event) => {
|
|
6721
7653
|
const dragState = dragStateRef.current;
|
|
6722
7654
|
if (!dragState) return;
|
|
6723
|
-
const deltaCells = Math.round((event.clientY - dragState.startY) /
|
|
7655
|
+
const deltaCells = Math.round((event.clientY - dragState.startY) / dragState.pxPerRow);
|
|
6724
7656
|
const deltaIncrement = deltaCells - dragState.appliedCells;
|
|
6725
7657
|
if (!deltaIncrement) return;
|
|
6726
7658
|
controller.adjustActivityPaneSplit(-deltaIncrement);
|
|
@@ -6747,9 +7679,12 @@ function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
|
|
|
6747
7679
|
onPointerDown: (event) => {
|
|
6748
7680
|
if (event.button !== 0) return;
|
|
6749
7681
|
event.preventDefault();
|
|
7682
|
+
capturePointerForDrag(event);
|
|
6750
7683
|
dragStateRef.current = {
|
|
6751
7684
|
startY: event.clientY,
|
|
6752
7685
|
appliedCells: 0,
|
|
7686
|
+
// MEASURED, not assumed — see measureActivityRowPx.
|
|
7687
|
+
pxPerRow: measureActivityRowPx(controller, event.currentTarget),
|
|
6753
7688
|
};
|
|
6754
7689
|
setDragging(true);
|
|
6755
7690
|
document.body.classList.add("is-resizing-pane-y");
|
|
@@ -8548,6 +9483,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8548
9483
|
inspectorTab: rootState.ui.inspectorTab,
|
|
8549
9484
|
filesFullscreen: Boolean(rootState.files.fullscreen),
|
|
8550
9485
|
selectedArtifactId: rootState.files.selectedArtifactId || null,
|
|
9486
|
+
artifactPaneOpen: Boolean(rootState.files.paneOpen),
|
|
8551
9487
|
adminVisible: Boolean(rootState.admin?.visible),
|
|
8552
9488
|
}), shallowEqualObject);
|
|
8553
9489
|
const profileSettingsHydratedRef = React.useRef(false);
|
|
@@ -8556,6 +9492,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8556
9492
|
// endpoint replaces the whole settings object, so this is what keeps a
|
|
8557
9493
|
// desktop save from erasing the phone's preference.
|
|
8558
9494
|
const otherChatViewModeRef = React.useRef(null);
|
|
9495
|
+
const otherTouchScaleRef = React.useRef(null);
|
|
8559
9496
|
const profileSettingsSaveTimerRef = React.useRef(null);
|
|
8560
9497
|
const profileSettingsPollTimerRef = React.useRef(null);
|
|
8561
9498
|
const profileSettingsPollInFlightRef = React.useRef(false);
|
|
@@ -8613,8 +9550,11 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8613
9550
|
if (isChatViewMode(remoteNormalized[otherChatViewModeKey()])) {
|
|
8614
9551
|
otherChatViewModeRef.current = remoteNormalized[otherChatViewModeKey()];
|
|
8615
9552
|
}
|
|
9553
|
+
if (typeof remoteNormalized[otherTouchScaleKey()] === "boolean") {
|
|
9554
|
+
otherTouchScaleRef.current = remoteNormalized[otherTouchScaleKey()];
|
|
9555
|
+
}
|
|
8616
9556
|
defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
|
|
8617
|
-
controller.getState(), otherChatViewModeRef.current,
|
|
9557
|
+
controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
|
|
8618
9558
|
);
|
|
8619
9559
|
const settings = materializeProfileSettings(
|
|
8620
9560
|
profile?.profileSettings,
|
|
@@ -8622,7 +9562,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8622
9562
|
);
|
|
8623
9563
|
const settingsJson = JSON.stringify(settings);
|
|
8624
9564
|
const currentSettingsBeforeApply = profileSettingsFromViewState(
|
|
8625
|
-
controller.getState(), otherChatViewModeRef.current,
|
|
9565
|
+
controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
|
|
8626
9566
|
);
|
|
8627
9567
|
const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
|
|
8628
9568
|
const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
|
|
@@ -8649,7 +9589,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8649
9589
|
// remote state by definition and there is nothing to lose).
|
|
8650
9590
|
if (!profileSettingsHydratedRef.current || !hasPendingLocalWrite) {
|
|
8651
9591
|
const currentSettings = profileSettingsFromViewState(
|
|
8652
|
-
controller.getState(), otherChatViewModeRef.current,
|
|
9592
|
+
controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
|
|
8653
9593
|
);
|
|
8654
9594
|
lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
|
|
8655
9595
|
}
|
|
@@ -8683,7 +9623,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8683
9623
|
|
|
8684
9624
|
React.useEffect(() => {
|
|
8685
9625
|
if (!profileSettingsHydratedRef.current) return undefined;
|
|
8686
|
-
const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current);
|
|
9626
|
+
const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current, otherTouchScaleRef.current);
|
|
8687
9627
|
const settingsJson = JSON.stringify(settings);
|
|
8688
9628
|
if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
|
|
8689
9629
|
lastProfileSettingsJsonRef.current = settingsJson;
|
|
@@ -8748,6 +9688,20 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8748
9688
|
&& Boolean(state.selectedArtifactId)
|
|
8749
9689
|
&& !filesFullscreenActive;
|
|
8750
9690
|
|
|
9691
|
+
// The takeover pane only makes sense on desktop; the phone has its own
|
|
9692
|
+
// full-viewport overlay and no column to take over.
|
|
9693
|
+
const artifactPaneActive = !mobile
|
|
9694
|
+
&& state.artifactPaneOpen
|
|
9695
|
+
&& Boolean(state.selectedArtifactId)
|
|
9696
|
+
&& !filesFullscreenActive;
|
|
9697
|
+
|
|
9698
|
+
// Closing hands the column back — or collapses it again, if the user had
|
|
9699
|
+
// already resized it away before opening the pane. Both cases live in the
|
|
9700
|
+
// controller so the decision is testable without a DOM.
|
|
9701
|
+
const closeArtifactPane = React.useCallback(() => {
|
|
9702
|
+
controller.closeArtifactPane().catch(() => {});
|
|
9703
|
+
}, [controller]);
|
|
9704
|
+
|
|
8751
9705
|
React.useEffect(() => {
|
|
8752
9706
|
if (!filesFullscreenActive || !chatFocusMode) return;
|
|
8753
9707
|
setChatFocusMode(false);
|
|
@@ -8814,7 +9768,13 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
8814
9768
|
},
|
|
8815
9769
|
React.createElement(ChatPane, { controller }))),
|
|
8816
9770
|
React.createElement(ColumnResizeHandle, { controller, paneAdjust: state.paneAdjust }),
|
|
8817
|
-
|
|
9771
|
+
// The artifact reader takes the WHOLE right column — inspector, resizer and
|
|
9772
|
+
// activity all step aside. The column keeps its width and resizer, so the
|
|
9773
|
+
// chat/reader split is dragged exactly like the chat/inspector one was.
|
|
9774
|
+
artifactPaneActive
|
|
9775
|
+
? React.createElement("div", { className: "ps-workspace-column is-artifact" },
|
|
9776
|
+
React.createElement(ArtifactTakeoverPane, { controller, onClose: closeArtifactPane }))
|
|
9777
|
+
: React.createElement("div", {
|
|
8818
9778
|
className: "ps-workspace-column",
|
|
8819
9779
|
style: { gridTemplateRows: `${layout.inspectorPaneHeight}fr var(--ps-resizer-track, 16px) ${layout.activityPaneHeight}fr` },
|
|
8820
9780
|
},
|