pilotswarm 0.5.35 → 0.5.37
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/tui/src/app.js +0 -4
- package/tui/src/index.js +0 -9
- package/ui/core/src/canvas-actions.js +148 -0
- package/ui/core/src/commands.js +0 -1
- package/ui/core/src/controller.js +217 -19
- package/ui/core/src/history.js +134 -1
- package/ui/core/src/index.js +1 -0
- package/ui/core/src/reducer.js +209 -19
- package/ui/core/src/selectors.js +143 -154
- package/ui/core/src/state.js +51 -2
- package/ui/core/src/themes/duroxide.js +6 -1
- package/ui/core/src/themes/win95.js +4 -1
- package/ui/react/src/components.js +1 -7
- package/ui/react/src/web-app.js +853 -260
- package/web/dist/assets/{abnfDiagram-VRR7QNED-BwJEohLG.js → abnfDiagram-VRR7QNED-DlvPH-8O.js} +1 -1
- package/web/dist/assets/{arc-CWUJ_S5s.js → arc-r1liz5e5.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-i6mrLxuE.js → architectureDiagram-ZJ3FMSHR-Bci5dNH3.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-C_KG-qPQ.js → blockDiagram-677ZJIJ3-Bug32AXp.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-BBJH7hbB.js → c4Diagram-LMCZKHZV-DwxSFqgE.js} +1 -1
- package/web/dist/assets/channel-jVpSYat3.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-CXwaFcaM.js → chunk-2Q5K7J3B-B_dbI_xg.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-C8ujjkK-.js → chunk-32BRIVSS-BkF68W-R.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-M9rz7YYk.js → chunk-5VM5RSS4-BSbNIbJu.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-bk6tRxOA.js → chunk-EX3LRPZG-Blv_Iqa2.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-DZ4WSHGH.js → chunk-JWPE2WC7-Dk_dBFKd.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-B5-y6tMF.js → chunk-MOJQB5TN-DqNFBrfw.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-yKcnHxm0.js → chunk-RYQCIY6F-Dlv7Svpb.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-CpoNcATD.js → chunk-V7JOEXUC-DozGikwu.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-Bf1yD2fn.js → chunk-VR4S4FIN-CmkqvGT4.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-EnPvDVhP.js → chunk-XXDRQBXY-DyByai5N.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-C78zSajE.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-C78zSajE.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-qhKoA2qj.js → cose-bilkent-JH36ORCC-Bv-gRVvA.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-jcyYelJS.js → cynefin-VYW2F7L2-DRnAsyZU.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-D4RLufLP.js → cynefinDiagram-TSTJHNR4-B1fnUtjo.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-C9BA3GTO.js → dagre-VKFMJZFB-DM6-QENB.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-DbSSrVUJ.js → diagram-FQU43EPY-BPxxnLdU.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-3egISQXQ.js → diagram-G47NLZAW-BgtJT3qF.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-8IKEMGRg.js → diagram-NH7WQ7WH-uekeIzsy.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-VnaogqtK.js → diagram-OA4YK3LP-BVy3JZWE.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-DxigXH0T.js → diagram-WEI45ONY-BWdjUr32.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-BSkNwJ9x.js → ebnfDiagram-CCIWWBDH-DD4fwhNK.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-CKN8JXl6.js → erDiagram-Q63AITRT-BllD_6Kh.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-DDaD5ExP.js → flowDiagram-23GEKE2U-QEYKkAUN.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-tOrBOuoI.js → ganttDiagram-NO4QXBWP-uZcfKTSW.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Cdp867ON.js → gitGraphDiagram-IHSO6WYX-XPdbPaH-.js} +1 -1
- package/web/dist/assets/index-BuDSoRIL.css +1 -0
- package/web/dist/assets/{index-BmwU43_u.js → index-D1itTxvB.js} +4 -4
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-DwcKHD2h.js → infoDiagram-FWYZ7A6U-DXCnQOJs.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CWffK5kz.js → ishikawaDiagram-FXEZZL3T-687_4FBy.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-BEAgS-EZ.js → journeyDiagram-5HDEW3XC-GXlKuda8.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-xDm-mRRj.js → kanban-definition-HUTT4EX6-C38-2DvN.js} +1 -1
- package/web/dist/assets/{linear-oP9MA0as.js → linear-lGPXiBcz.js} +1 -1
- package/web/dist/assets/{mermaid.core-DgXXt1OS.js → mermaid.core-C_neEtab.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-BBQgVrYC.js → mindmap-definition-LN4V7U3C-BaJ9tpEG.js} +1 -1
- package/web/dist/assets/{papaparse.min-CR6JmBWm.js → papaparse.min-rDwKYbBf.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-C2O0cf3j.js → pegDiagram-2B236MQR-BdXJLYgY.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-D08pUtYU.js → pieDiagram-ENE6RG2P-DU19t6fM.js} +1 -1
- package/web/dist/assets/pilotswarm-BNKGG936.js +96 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-D-Z7YoEK.js → quadrantDiagram-ABIIQ3AL-CBkOdPp0.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-ClkdveLI.js → railroadDiagram-RFXS5EU6-D2DMcGDw.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-IY4xbn-Q.js → requirementDiagram-TGXJPOKE-Bx9tF1pr.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-Cpt5i7KM.js → sankeyDiagram-HTMAVEWB-DcLlRfsi.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-c07lALcz.js → sequenceDiagram-DBY2YBRQ-xr6UVRBO.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-CmCDiNbI.js → sizeCapture-X5ZJPWSS-0fUkSXmg.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-Dueu17zX.js → stateDiagram-2N3HPSRC-BWgJGGpg.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DmAC8DdB.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-Csc2Fup8.js → swimlanes-5IMT3BWC-CZKya4_i.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CVYx20_X.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-4-lf702e.js → timeline-definition-FHXFAJF6-Df8BpeAu.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-CmlaKpLI.js → vennDiagram-L72KCM5P-ChX5bjT6.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-B9Qe44pi.js → wardleyDiagram-EHGQE667-Dr48BgaJ.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-BFcc0CLn.js → xychartDiagram-FW5EYKEG-Cia_MFfc.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/runtime.js +20 -1
- package/web/dist/assets/channel-5Ddqi9ib.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-B8u1fZiB.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-B8u1fZiB.js +0 -1
- package/web/dist/assets/index-D-nA7gUe.css +0 -1
- package/web/dist/assets/pilotswarm-D7NLCbuc.js +0 -99
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-AGpIV7wW.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTGZSV0N.js +0 -8
package/ui/react/src/web-app.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
getPromptInputRows,
|
|
21
21
|
getTheme,
|
|
22
22
|
isThemeLight,
|
|
23
|
+
normalizeStoredCanvasPrefs,
|
|
23
24
|
parseTerminalMarkupRuns,
|
|
24
25
|
PilotSwarmUiController,
|
|
25
26
|
tokenizeInlineMarkdown,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
selectChatLines,
|
|
33
34
|
selectChatPaneChrome,
|
|
34
35
|
selectOutboxOverlayLines,
|
|
36
|
+
selectCanvasView,
|
|
35
37
|
selectFileBrowserItems,
|
|
36
38
|
selectFilesFilterModal,
|
|
37
39
|
selectFilesScope,
|
|
@@ -247,21 +249,18 @@ function normalizeProfileSettings(settings) {
|
|
|
247
249
|
const id = candidate.activeSessionId == null ? null : String(candidate.activeSessionId).trim();
|
|
248
250
|
normalized.activeSessionId = id || null;
|
|
249
251
|
}
|
|
250
|
-
if (
|
|
251
|
-
normalized.
|
|
252
|
+
if (candidate.rightPaneMode === "canvas" || candidate.rightPaneMode === "panes") {
|
|
253
|
+
normalized.rightPaneMode = candidate.rightPaneMode;
|
|
254
|
+
}
|
|
255
|
+
if (hasOwn(candidate, "canvasPrefs")) {
|
|
256
|
+
normalized.canvasPrefs = normalizeStoredCanvasPrefs(candidate.canvasPrefs);
|
|
252
257
|
}
|
|
253
258
|
// Phones and desktops keep SEPARATE view preferences: rich prose reads well
|
|
254
259
|
// on a wide transcript and poorly in a 390px column, so a single shared
|
|
255
260
|
// value would have each device overwriting the other's choice.
|
|
256
|
-
if (isChatViewMode(candidate.chatViewModeMobile)) {
|
|
257
|
-
normalized.chatViewModeMobile = candidate.chatViewModeMobile;
|
|
258
|
-
}
|
|
259
261
|
return normalized;
|
|
260
262
|
}
|
|
261
263
|
|
|
262
|
-
function isChatViewMode(value) {
|
|
263
|
-
return value === "summary" || value === "transcript" || value === "rich";
|
|
264
|
-
}
|
|
265
264
|
|
|
266
265
|
/** True on phone-sized viewports — the device class that owns the mobile slot. */
|
|
267
266
|
function isNarrowViewport() {
|
|
@@ -271,17 +270,11 @@ function isNarrowViewport() {
|
|
|
271
270
|
}
|
|
272
271
|
|
|
273
272
|
/** Profile key this device reads and writes. */
|
|
274
|
-
function chatViewModeKey() {
|
|
275
|
-
return isNarrowViewport() ? "chatViewModeMobile" : "chatViewMode";
|
|
276
|
-
}
|
|
277
273
|
|
|
278
274
|
/** The key belonging to the OTHER device class. */
|
|
279
|
-
function otherChatViewModeKey() {
|
|
280
|
-
return isNarrowViewport() ? "chatViewMode" : "chatViewModeMobile";
|
|
281
|
-
}
|
|
282
275
|
|
|
283
276
|
/**
|
|
284
|
-
* The "Mobile" (touch-scale) preference is per DEVICE CLASS
|
|
277
|
+
* The "Mobile" (touch-scale) preference is per DEVICE CLASS:
|
|
285
278
|
* it describes how big the type and hit targets should be on the screen you are
|
|
286
279
|
* actually looking at, so turning it on from a phone must not scale up the
|
|
287
280
|
* desktop. It is otherwise orthogonal to the theme — it lives in the theme
|
|
@@ -326,7 +319,7 @@ function hasOwn(value, key) {
|
|
|
326
319
|
return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
|
|
327
320
|
}
|
|
328
321
|
|
|
329
|
-
function profileSettingsFromViewState(state,
|
|
322
|
+
function profileSettingsFromViewState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null) {
|
|
330
323
|
return normalizeProfileSettings({
|
|
331
324
|
themeId: state.themeId,
|
|
332
325
|
[touchScaleKey()]: state.touchScale,
|
|
@@ -341,20 +334,27 @@ function profileSettingsFromViewState(state, preservedOtherChatViewMode = null,
|
|
|
341
334
|
sessionOrder: state.manualOrder,
|
|
342
335
|
collapsedSessionIds: state.collapsedSessionIds,
|
|
343
336
|
activeSessionId: state.activeSessionId,
|
|
344
|
-
|
|
337
|
+
// rightPaneMode describes the DESKTOP right column; a phone has no
|
|
338
|
+
// such column (its canvas surface is a nav tab), so a phone must not
|
|
339
|
+
// write its own value — it preserves the last-known stored one, or a
|
|
340
|
+
// desktop that flipped to canvas would be flipped back by the phone's
|
|
341
|
+
// next save.
|
|
342
|
+
...(isNarrowViewport()
|
|
343
|
+
? (preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
|
|
344
|
+
? { rightPaneMode: preservedRightPaneMode }
|
|
345
|
+
: {})
|
|
346
|
+
: { rightPaneMode: state.rightPaneMode }),
|
|
347
|
+
canvasPrefs: state.canvasPrefs,
|
|
345
348
|
// setCurrentUserProfileSettings REPLACES the settings object, so the
|
|
346
349
|
// other device class's slot has to be written back verbatim or saving
|
|
347
350
|
// from a desktop would wipe the phone's preference (and vice versa).
|
|
348
|
-
...(isChatViewMode(preservedOtherChatViewMode)
|
|
349
|
-
? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
|
|
350
|
-
: {}),
|
|
351
351
|
...(typeof preservedOtherTouchScale === "boolean"
|
|
352
352
|
? { [otherTouchScaleKey()]: preservedOtherTouchScale }
|
|
353
353
|
: {}),
|
|
354
354
|
});
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
-
function buildDefaultProfileSettingsFromState(state,
|
|
357
|
+
function buildDefaultProfileSettingsFromState(state, preservedOtherTouchScale = null, preservedRightPaneMode = null) {
|
|
358
358
|
return normalizeProfileSettings({
|
|
359
359
|
themeId: state?.ui?.themeId,
|
|
360
360
|
[touchScaleKey()]: state?.ui?.touchScale,
|
|
@@ -370,10 +370,12 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode
|
|
|
370
370
|
sessionOrder: state?.sessions?.manualOrder,
|
|
371
371
|
collapsedSessionIds: state?.sessions?.collapsedIds,
|
|
372
372
|
activeSessionId: state?.sessions?.activeSessionId,
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
373
|
+
...(isNarrowViewport()
|
|
374
|
+
? (preservedRightPaneMode === "canvas" || preservedRightPaneMode === "panes"
|
|
375
|
+
? { rightPaneMode: preservedRightPaneMode }
|
|
376
|
+
: {})
|
|
377
|
+
: { rightPaneMode: state?.ui?.rightPaneMode }),
|
|
378
|
+
canvasPrefs: state?.canvas?.prefs,
|
|
377
379
|
...(typeof preservedOtherTouchScale === "boolean"
|
|
378
380
|
? { [otherTouchScaleKey()]: preservedOtherTouchScale }
|
|
379
381
|
: {}),
|
|
@@ -383,13 +385,11 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode
|
|
|
383
385
|
function materializeProfileSettings(remoteSettings, defaults) {
|
|
384
386
|
const normalizedRemote = normalizeProfileSettings(remoteSettings);
|
|
385
387
|
const normalizedDefaults = normalizeProfileSettings(defaults);
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
// off the materialized settings causes the `profileSettings/apply`
|
|
392
|
-
// reducer's `hasChatViewMode` guard to preserve the current value.
|
|
388
|
+
// No-defaults rule for per-device and toggle-like fields: when the remote
|
|
389
|
+
// profile lacks a key, do NOT synthesize a default — the poll runs every
|
|
390
|
+
// few seconds, and a synthesized value would clobber a fresh local change
|
|
391
|
+
// the server has not persisted yet. An omitted key leaves the
|
|
392
|
+
// profileSettings/apply guards preserving the current value.
|
|
393
393
|
return normalizeProfileSettings({
|
|
394
394
|
themeId: hasOwn(normalizedRemote, "themeId")
|
|
395
395
|
? normalizedRemote.themeId
|
|
@@ -419,13 +419,20 @@ function materializeProfileSettings(remoteSettings, defaults) {
|
|
|
419
419
|
// Apply only the slot this device owns. The other is preserved by the
|
|
420
420
|
// save path (see profileSettingsFromViewState), not applied here — it
|
|
421
421
|
// describes a screen size we are not on.
|
|
422
|
-
|
|
423
|
-
|
|
422
|
+
// Canvas keys follow the same no-defaults rule: the poll must not
|
|
423
|
+
// clobber a local flip/badge advance the server has not persisted yet.
|
|
424
|
+
// An omitted key leaves the profileSettings/apply guards preserving
|
|
425
|
+
// the current value.
|
|
426
|
+
...(hasOwn(normalizedRemote, "rightPaneMode")
|
|
427
|
+
? { rightPaneMode: normalizedRemote.rightPaneMode }
|
|
428
|
+
: {}),
|
|
429
|
+
...(hasOwn(normalizedRemote, "canvasPrefs")
|
|
430
|
+
? { canvasPrefs: normalizedRemote.canvasPrefs }
|
|
424
431
|
: {}),
|
|
425
432
|
// touchScale was missing from this merge entirely, which is why the
|
|
426
433
|
// "Mobile" checkbox never survived a reload: the save path wrote it,
|
|
427
434
|
// but nothing ever read it back, and the reducer's hasTouchScale guard
|
|
428
|
-
// then preserved the default. Same no-defaults rule
|
|
435
|
+
// then preserved the default. Same no-defaults rule —
|
|
429
436
|
// synthesizing one here would let a poll clobber a fresh local toggle.
|
|
430
437
|
...(hasOwn(normalizedRemote, touchScaleKey())
|
|
431
438
|
? { touchScale: normalizedRemote[touchScaleKey()] }
|
|
@@ -513,7 +520,7 @@ function computeStateLayout(state) {
|
|
|
513
520
|
}, state.ui.layout?.paneAdjust ?? 0, getStatePromptRows(state), state.ui.layout?.sessionPaneAdjust ?? 0);
|
|
514
521
|
}
|
|
515
522
|
|
|
516
|
-
function getPortalInspectorContentWidth(paneWidth, inspectorTab) {
|
|
523
|
+
function getPortalInspectorContentWidth(paneWidth, inspectorTab, mobile = false) {
|
|
517
524
|
// The sequence view once reserved 3 extra columns here so that "content
|
|
518
525
|
// that fits visually does not trip a cosmetic x-scrollbar". That was a
|
|
519
526
|
// fudge for a real bug: every sequence line was padded to the full width,
|
|
@@ -522,7 +529,14 @@ function getPortalInspectorContentWidth(paneWidth, inspectorTab) {
|
|
|
522
529
|
// and any rounding tipped it into a permanent scrollbar. The padding is now
|
|
523
530
|
// trimmed at the source (see trimTrailingRunPad in ui-core selectors), so
|
|
524
531
|
// the guard would only throw away 3 usable columns.
|
|
525
|
-
|
|
532
|
+
//
|
|
533
|
+
// A PHONE still needs a margin, for a different reason: the column count
|
|
534
|
+
// is derived from an assumed character cell, and iOS Safari's monospace
|
|
535
|
+
// fallback renders wider than the desktop's. A grid sized to the exact
|
|
536
|
+
// pane there overflows by a chunk, and the reader meets a view scrolled
|
|
537
|
+
// sideways with its timestamps and STATS header sliced off. Two columns of
|
|
538
|
+
// slack absorb the metric difference.
|
|
539
|
+
return Math.max(20, paneWidth - (mobile ? 6 : 4));
|
|
526
540
|
}
|
|
527
541
|
|
|
528
542
|
function normalizeLines(lines) {
|
|
@@ -2819,6 +2833,10 @@ function HtmlArtifactPreviewPanel({
|
|
|
2819
2833
|
title: filename || "HTML artifact",
|
|
2820
2834
|
// No allow-same-origin — see the block comment above.
|
|
2821
2835
|
sandbox: "allow-scripts allow-popups allow-popups-to-escape-sandbox",
|
|
2836
|
+
// Opaque origin ≠ "self", so the autoplay policy would
|
|
2837
|
+
// silently mute an interactive artifact (same fix the
|
|
2838
|
+
// canvas frame carries).
|
|
2839
|
+
allow: "autoplay *",
|
|
2822
2840
|
referrerPolicy: "no-referrer",
|
|
2823
2841
|
onLoad: () => frames.promote(nonce),
|
|
2824
2842
|
}))
|
|
@@ -3197,7 +3215,14 @@ function parseStructuredChatBlocks(lines = []) {
|
|
|
3197
3215
|
}
|
|
3198
3216
|
|
|
3199
3217
|
function StructuredChatBlocks({ lines, theme, controller = null }) {
|
|
3200
|
-
|
|
3218
|
+
// canvasUpdate-flagged lines are the TUI's affordance (an artifact link
|
|
3219
|
+
// for a host that cannot render the canvas). In the browser the canvas
|
|
3220
|
+
// pane updating IS the signal, so the chat skips them outright — desktop
|
|
3221
|
+
// and mobile share this renderer, so one filter covers both.
|
|
3222
|
+
const blocks = React.useMemo(
|
|
3223
|
+
() => parseStructuredChatBlocks((lines || []).filter((line) => !line?.canvasUpdate && !line?.canvasAction)),
|
|
3224
|
+
[lines],
|
|
3225
|
+
);
|
|
3201
3226
|
return React.createElement(StructuredBlockList, { blocks, theme, controller });
|
|
3202
3227
|
}
|
|
3203
3228
|
|
|
@@ -3626,6 +3651,28 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
3626
3651
|
const normalizedBottomSticky = React.useMemo(() => normalizeLines(bottomStickyLines), [bottomStickyLines]);
|
|
3627
3652
|
const preserveHorizontalScroll = className.includes("is-preserve") && panelClassName.includes("has-preserved-sticky");
|
|
3628
3653
|
|
|
3654
|
+
// A stale sideways offset is how a fitted grid greets the reader with its
|
|
3655
|
+
// timestamps and header sliced off: pan once, switch tabs or sessions, and
|
|
3656
|
+
// the new content inherits the old scrollLeft. Reset it whenever the pane's
|
|
3657
|
+
// identity changes — the user's own pan within a view still stands.
|
|
3658
|
+
const horizontalResetKey = `${paneKey}:${title}`;
|
|
3659
|
+
React.useEffect(() => {
|
|
3660
|
+
// Two passes, deliberately: the first runs before the new view's lines
|
|
3661
|
+
// have laid out, and the sticky-header scroll sync can copy a stale
|
|
3662
|
+
// offset back onto the body afterwards. The rAF pass lands after both
|
|
3663
|
+
// and is what actually sticks (observed on Stats, whose header row
|
|
3664
|
+
// syncs horizontally with the body).
|
|
3665
|
+
const reset = () => {
|
|
3666
|
+
if (ref.current) ref.current.scrollLeft = 0;
|
|
3667
|
+
if (stickyRef.current) stickyRef.current.scrollLeft = 0;
|
|
3668
|
+
};
|
|
3669
|
+
reset();
|
|
3670
|
+
const frame = typeof requestAnimationFrame === "function"
|
|
3671
|
+
? requestAnimationFrame(() => { reset(); })
|
|
3672
|
+
: null;
|
|
3673
|
+
return () => { if (frame != null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame); };
|
|
3674
|
+
}, [horizontalResetKey]);
|
|
3675
|
+
|
|
3629
3676
|
// Clicking (or touching) a scrollable pane makes the keyboard scroll keys
|
|
3630
3677
|
// act on THAT pane. Fires on mousedown rather than click so a drag-select
|
|
3631
3678
|
// inside the pane claims focus too.
|
|
@@ -4151,9 +4198,11 @@ function useHorizontalPanOptIn(ref) {
|
|
|
4151
4198
|
}, [ref]);
|
|
4152
4199
|
}
|
|
4153
4200
|
|
|
4154
|
-
function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false }) {
|
|
4155
|
-
// Mobile keeps its inline detail line and gets no detail box — a
|
|
4156
|
-
// footer would eat a meaningful slice of a phone screen.
|
|
4201
|
+
function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false, showDetailBox = null }) {
|
|
4202
|
+
// Mobile keeps its inline detail line and normally gets no detail box — a
|
|
4203
|
+
// reserved footer would eat a meaningful slice of a phone screen. The
|
|
4204
|
+
// sessions-ONLY layout is the exception: it has the whole screen and the
|
|
4205
|
+
// detail box is the point of asking for it.
|
|
4157
4206
|
const isMobilePane = String(panelClassName).includes("ps-mobile-session-pane");
|
|
4158
4207
|
// Vertical is the primary scroll axis; sideways takes a deliberate swipe.
|
|
4159
4208
|
const sessionListRef = React.useRef(null);
|
|
@@ -4936,12 +4985,12 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
4936
4985
|
// touch hijacks the finger that should be scrolling the list.
|
|
4937
4986
|
drag: touchInput ? null : dragHandlers,
|
|
4938
4987
|
}))),
|
|
4939
|
-
isMobilePane
|
|
4940
|
-
?
|
|
4941
|
-
: React.createElement(SessionDetailBox, {
|
|
4988
|
+
(showDetailBox === null ? !isMobilePane : showDetailBox)
|
|
4989
|
+
? React.createElement(SessionDetailBox, {
|
|
4942
4990
|
session: activeSession,
|
|
4943
4991
|
childCount: activeRow?.childCount || 0,
|
|
4944
|
-
})
|
|
4992
|
+
})
|
|
4993
|
+
: null),
|
|
4945
4994
|
(manageOpen && activeSession && !activeSession.isGroup)
|
|
4946
4995
|
? React.createElement(SessionModifyModal, {
|
|
4947
4996
|
controller,
|
|
@@ -5032,20 +5081,9 @@ function FunnelGlyph() {
|
|
|
5032
5081
|
}
|
|
5033
5082
|
|
|
5034
5083
|
// Summary — a written summary. Was "≣", the same codepoint the Logs tab used.
|
|
5035
|
-
function SummaryGlyph() {
|
|
5036
|
-
return React.createElement(Glyph, null,
|
|
5037
|
-
React.createElement("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }),
|
|
5038
|
-
React.createElement("path", { d: "M14 3v5h5" }),
|
|
5039
|
-
React.createElement("line", { x1: "8.5", y1: "13", x2: "15.5", y2: "13" }),
|
|
5040
|
-
React.createElement("line", { x1: "8.5", y1: "17", x2: "13", y2: "17" }));
|
|
5041
|
-
}
|
|
5042
5084
|
|
|
5043
5085
|
// Back to the transcript. Replaces the 💬 emoji, which iOS force-renders in
|
|
5044
5086
|
// colour against an otherwise monochrome row (see the note on the tab table).
|
|
5045
|
-
function TranscriptGlyph() {
|
|
5046
|
-
return React.createElement(Glyph, null,
|
|
5047
|
-
React.createElement("path", { d: "M21 14.5a2.5 2.5 0 0 1-2.5 2.5H8l-5 4V5.5A2.5 2.5 0 0 1 5.5 3h13A2.5 2.5 0 0 1 21 5.5z" }));
|
|
5048
|
-
}
|
|
5049
5087
|
|
|
5050
5088
|
function PlusGlyph() {
|
|
5051
5089
|
return React.createElement(Glyph, null,
|
|
@@ -5130,6 +5168,33 @@ function SidebarGlyph() {
|
|
|
5130
5168
|
React.createElement("path", { d: "M9.5 3.5v17" }));
|
|
5131
5169
|
}
|
|
5132
5170
|
|
|
5171
|
+
// The phone's view modes live in the top bar, not a bottom nav; each reads at
|
|
5172
|
+
// icon size without a label.
|
|
5173
|
+
// The Main pane, whatever layout it is in: a frame with a list rail and a
|
|
5174
|
+
// conversation beside it. Constant across the three-way cycle.
|
|
5175
|
+
function MainLayoutGlyph() {
|
|
5176
|
+
return React.createElement(Glyph, null,
|
|
5177
|
+
React.createElement("rect", { x: "3", y: "4", width: "18", height: "16", rx: "2" }),
|
|
5178
|
+
React.createElement("line", { x1: "9.5", y1: "4", x2: "9.5", y2: "20" }),
|
|
5179
|
+
React.createElement("line", { x1: "12.5", y1: "9", x2: "18", y2: "9" }),
|
|
5180
|
+
React.createElement("line", { x1: "12.5", y1: "13", x2: "18", y2: "13" }));
|
|
5181
|
+
}
|
|
5182
|
+
|
|
5183
|
+
// Inspector takes the pulse — the trace/sequence view is its default face.
|
|
5184
|
+
function InspectorPaneGlyph() {
|
|
5185
|
+
return React.createElement(Glyph, null,
|
|
5186
|
+
React.createElement("path", { d: "M3 12h4l2.5-6 4 12 2.5-6H21" }));
|
|
5187
|
+
}
|
|
5188
|
+
|
|
5189
|
+
// Activity reads a log: a page with lines on it.
|
|
5190
|
+
function ActivityPaneGlyph() {
|
|
5191
|
+
return React.createElement(Glyph, null,
|
|
5192
|
+
React.createElement("path", { d: "M6 3h8l4 4v14H6z" }),
|
|
5193
|
+
React.createElement("path", { d: "M14 3v4h4" }),
|
|
5194
|
+
React.createElement("line", { x1: "9", y1: "12", x2: "15", y2: "12" }),
|
|
5195
|
+
React.createElement("line", { x1: "9", y1: "16", x2: "15", y2: "16" }));
|
|
5196
|
+
}
|
|
5197
|
+
|
|
5133
5198
|
// Logs are console output; a prompt chevron says that and nothing else.
|
|
5134
5199
|
function LogsGlyph() {
|
|
5135
5200
|
return React.createElement(Glyph, null,
|
|
@@ -5574,7 +5639,6 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
|
|
|
5574
5639
|
sessionsById: state.sessions.byId,
|
|
5575
5640
|
sessionsFlat: state.sessions.flat,
|
|
5576
5641
|
inspectorTab: state.ui.inspectorTab,
|
|
5577
|
-
chatViewMode: state.ui.chatViewMode || "transcript",
|
|
5578
5642
|
activeSessionIsGroup: Boolean(activeSessionId && state.sessions.byId[activeSessionId]?.isGroup),
|
|
5579
5643
|
activeSessionStatus: activeSessionId ? String(state.sessions.byId[activeSessionId]?.status || "").toLowerCase() : "",
|
|
5580
5644
|
focused: state.ui.focusRegion === "chat",
|
|
@@ -5606,7 +5670,6 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
|
|
|
5606
5670
|
},
|
|
5607
5671
|
ui: {
|
|
5608
5672
|
inspectorTab: viewState.inspectorTab,
|
|
5609
|
-
chatViewMode: viewState.chatViewMode,
|
|
5610
5673
|
},
|
|
5611
5674
|
}), [
|
|
5612
5675
|
viewState.activeHistory,
|
|
@@ -5615,7 +5678,6 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
|
|
|
5615
5678
|
viewState.authPrincipal,
|
|
5616
5679
|
viewState.branding,
|
|
5617
5680
|
viewState.connection,
|
|
5618
|
-
viewState.chatViewMode,
|
|
5619
5681
|
viewState.inspectorTab,
|
|
5620
5682
|
viewState.sessionsById,
|
|
5621
5683
|
viewState.sessionsFlat,
|
|
@@ -5667,7 +5729,7 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
|
|
|
5667
5729
|
controller, viewState.activeSessionId, viewState.activeSessionIsGroup,
|
|
5668
5730
|
);
|
|
5669
5731
|
const navigationError = useControllerSelector(controller, selectNavigationError, shallowEqualObject);
|
|
5670
|
-
const composerBase = showComposer && !viewState.activeSessionIsGroup
|
|
5732
|
+
const composerBase = showComposer && !viewState.activeSessionIsGroup;
|
|
5671
5733
|
// Service sessions (⚗ tree-scoped machinery, e.g. the regen distiller) are
|
|
5672
5734
|
// read-only BY KIND: their transcript is the trace of runtime machinery,
|
|
5673
5735
|
// never a conversation surface — no prompt for anyone, owner included.
|
|
@@ -5887,6 +5949,19 @@ function ArtifactTakeoverPane({ controller, onClose }) {
|
|
|
5887
5949
|
* phone should feel like pushing a detail screen, the way a native app does,
|
|
5888
5950
|
* not like shrinking content into one more box. Desktop never mounts this.
|
|
5889
5951
|
*/
|
|
5952
|
+
/**
|
|
5953
|
+
* The phone's canvas: FULL-SCREEN, opened by the same toolbar toggle the
|
|
5954
|
+
* desktop has, closed by the pane's ✕ back to whatever was on screen. The
|
|
5955
|
+
* former bottom-nav tab is gone — one affordance on every device class.
|
|
5956
|
+
*/
|
|
5957
|
+
function MobileCanvasOverlay({ controller, onClose, visible = true }) {
|
|
5958
|
+
// Kept MOUNTED while hidden: unmounting destroys the sandboxed document,
|
|
5959
|
+
// and with it everything the user typed and scrolled. visibility (not
|
|
5960
|
+
// display) keeps layout real so no resize-reload fires on re-show.
|
|
5961
|
+
return React.createElement("div", { className: `ps-artifact-overlay ps-canvas-overlay${visible ? "" : " is-hidden"}`, ...(visible ? {} : { inert: true, "aria-hidden": "true" }) },
|
|
5962
|
+
React.createElement(CanvasPane, { controller, mobile: true, onClose, visible, focusOnPromote: true }));
|
|
5963
|
+
}
|
|
5964
|
+
|
|
5890
5965
|
function MobileArtifactOverlay({ controller }) {
|
|
5891
5966
|
const view = useControllerSelector(controller, (state) => {
|
|
5892
5967
|
const id = state.files.selectedArtifactId || "";
|
|
@@ -5952,18 +6027,370 @@ function MobileArtifactOverlay({ controller }) {
|
|
|
5952
6027
|
})));
|
|
5953
6028
|
}
|
|
5954
6029
|
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
6030
|
+
/**
|
|
6031
|
+
* The phone's Main pane, in one of three layouts cycled by the Main toolbar
|
|
6032
|
+
* button: split (sessions + chat), chat only, sessions only (list + the
|
|
6033
|
+
* detail sub-panel). This replaced chat-focus mode, which was a second way to
|
|
6034
|
+
* say "chat only" with its own chrome and its own exit.
|
|
6035
|
+
*/
|
|
6036
|
+
function MobileWorkspace({ controller, layout = "split" }) {
|
|
6037
|
+
const sessionPane = React.createElement(SessionPane, {
|
|
6038
|
+
controller,
|
|
6039
|
+
panelClassName: "ps-mobile-session-pane",
|
|
6040
|
+
});
|
|
6041
|
+
if (layout === "chat") {
|
|
6042
|
+
return React.createElement("div", { className: "ps-mobile-workspace is-chat-only" },
|
|
6043
|
+
React.createElement("div", { className: "ps-mobile-chat-pane" },
|
|
6044
|
+
React.createElement(ChatPane, { controller, mobile: true, fullWidth: true })));
|
|
6045
|
+
}
|
|
6046
|
+
if (layout === "sessions") {
|
|
6047
|
+
return React.createElement("div", { className: "ps-mobile-workspace is-sessions-only" },
|
|
6048
|
+
React.createElement(SessionPane, {
|
|
6049
|
+
controller,
|
|
6050
|
+
panelClassName: "ps-mobile-session-pane",
|
|
6051
|
+
showDetailBox: true,
|
|
6052
|
+
}));
|
|
6053
|
+
}
|
|
5958
6054
|
return React.createElement("div", { className: "ps-mobile-workspace" },
|
|
5959
|
-
|
|
5960
|
-
controller,
|
|
5961
|
-
panelClassName: "ps-mobile-session-pane",
|
|
5962
|
-
}),
|
|
6055
|
+
sessionPane,
|
|
5963
6056
|
React.createElement("div", { className: "ps-mobile-chat-pane" },
|
|
5964
6057
|
React.createElement(ChatPane, { controller, mobile: true, fullWidth: true })));
|
|
5965
6058
|
}
|
|
5966
6059
|
|
|
6060
|
+
/** Frame-and-easel glyph for the Canvas toggle. */
|
|
6061
|
+
function CanvasGlyph() {
|
|
6062
|
+
const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round" };
|
|
6063
|
+
return React.createElement("svg", { viewBox: "0 0 20 20", width: 16, height: 16, "aria-hidden": "true" },
|
|
6064
|
+
React.createElement("rect", { x: 2.5, y: 3.5, width: 15, height: 11, rx: 1.5, ...stroke }),
|
|
6065
|
+
React.createElement("path", { d: "M5.5 11.5l3-3.5 2.5 2.5 3.5-4", ...stroke }),
|
|
6066
|
+
React.createElement("path", { d: "M7 17.5h6M10 14.5v3", ...stroke }));
|
|
6067
|
+
}
|
|
6068
|
+
|
|
6069
|
+
/**
|
|
6070
|
+
* Double-buffered canvas frame with PER-REVISION blob URLs.
|
|
6071
|
+
*
|
|
6072
|
+
* Deliberately not the artifact preview panel: that component nulls its frame
|
|
6073
|
+
* URL when its fetch key changes (right for a DIFFERENT document, a blank
|
|
6074
|
+
* flash for a redraw of the same one) and shares one URL between live and
|
|
6075
|
+
* staging frames. Here each fetched revision owns its URL; the replacement
|
|
6076
|
+
* loads hidden-but-painted behind the live frame and is promoted on load, so
|
|
6077
|
+
* a live redraw is a composite swap of something already drawn.
|
|
6078
|
+
*/
|
|
6079
|
+
function CanvasFrame({ controller, sessionId, latestRev, zoom, visible = true, focusOnPromote = false, dataRev = 0, dataPayload = null }) {
|
|
6080
|
+
const [live, setLive] = React.useState(null); // { url, rev }
|
|
6081
|
+
const [staging, setStaging] = React.useState(null); // { url, rev }
|
|
6082
|
+
const [loadError, setLoadError] = React.useState(null); // { rev }
|
|
6083
|
+
const [retryTick, setRetryTick] = React.useState(0);
|
|
6084
|
+
// Blob URLs tracked in a ref so cleanup can revoke directly — setState
|
|
6085
|
+
// updaters are neither a reliable post-unmount hook nor a pure place for
|
|
6086
|
+
// side effects (StrictMode double-invokes them).
|
|
6087
|
+
const urlsRef = React.useRef({ live: null, staging: null });
|
|
6088
|
+
const autoRetryRef = React.useRef({ rev: 0, used: false });
|
|
6089
|
+
const hostRef = React.useRef(null);
|
|
6090
|
+
const liveIframeElRef = React.useRef(null);
|
|
6091
|
+
const hostBox = useElementBox(hostRef);
|
|
6092
|
+
// The frame geometry each revision LOADED at. A sandboxed page cannot be
|
|
6093
|
+
// poked after the fact (no reaching into an opaque origin), and pages
|
|
6094
|
+
// routinely measure the viewport exactly once — so when the pane settles
|
|
6095
|
+
// at a materially different size than the page loaded at (boot-time
|
|
6096
|
+
// hydration, a column resize, zoom), the only honest fix is the reader's:
|
|
6097
|
+
// reload through the double buffer at the new size. Verified live: a page
|
|
6098
|
+
// loading during boot measured the iframe's INTRINSIC 150px default and
|
|
6099
|
+
// baked it in, which is how a full-height game renders as a bottom sliver
|
|
6100
|
+
// under a black void.
|
|
6101
|
+
const loadedBoxRef = React.useRef(null);
|
|
6102
|
+
const zoomRef = React.useRef(zoom);
|
|
6103
|
+
zoomRef.current = zoom;
|
|
6104
|
+
const visibleRef = React.useRef(visible);
|
|
6105
|
+
visibleRef.current = visible;
|
|
6106
|
+
|
|
6107
|
+
// The interactive-canvas bridge. Provenance is THIS check: a message is
|
|
6108
|
+
// accepted only when its source window is the LIVE canvas iframe — the
|
|
6109
|
+
// staging frame, the artifact reader, file previews, and every other
|
|
6110
|
+
// window on the page fail the identity test. Contract validation and
|
|
6111
|
+
// rate limiting happen in controller.submitCanvasAction; with no
|
|
6112
|
+
// contract on the current revision, nothing is accepted at all.
|
|
6113
|
+
React.useEffect(() => {
|
|
6114
|
+
if (!controller || !sessionId) return undefined;
|
|
6115
|
+
const onMessage = (event) => {
|
|
6116
|
+
const frameEl = liveIframeElRef.current;
|
|
6117
|
+
if (!frameEl || !event.source || event.source !== frameEl.contentWindow) return;
|
|
6118
|
+
const payload = event.data;
|
|
6119
|
+
if (!payload || typeof payload !== "object" || payload.type !== "canvas-action") return;
|
|
6120
|
+
controller.submitCanvasAction(sessionId, payload).then((result) => {
|
|
6121
|
+
if (result && result.ok === false && typeof console !== "undefined") {
|
|
6122
|
+
console.warn(`canvas action rejected: ${result.reason}`);
|
|
6123
|
+
}
|
|
6124
|
+
}).catch(() => {});
|
|
6125
|
+
};
|
|
6126
|
+
window.addEventListener("message", onMessage);
|
|
6127
|
+
return () => window.removeEventListener("message", onMessage);
|
|
6128
|
+
}, [controller, sessionId]);
|
|
6129
|
+
|
|
6130
|
+
React.useEffect(() => {
|
|
6131
|
+
if (!controller || !sessionId || !latestRev) return undefined;
|
|
6132
|
+
let cancelled = false;
|
|
6133
|
+
let retryTimer = null;
|
|
6134
|
+
fetchArtifactHtmlObjectUrl(controller, sessionId, "canvas.html")
|
|
6135
|
+
.then((url) => {
|
|
6136
|
+
if (cancelled) { URL.revokeObjectURL(url); return; }
|
|
6137
|
+
// A staging revision that never loaded is superseded here —
|
|
6138
|
+
// revoke it, or every rapid redraw leaks a document-sized blob.
|
|
6139
|
+
if (urlsRef.current.staging && urlsRef.current.staging !== urlsRef.current.live) {
|
|
6140
|
+
URL.revokeObjectURL(urlsRef.current.staging);
|
|
6141
|
+
}
|
|
6142
|
+
urlsRef.current.staging = url;
|
|
6143
|
+
setLoadError(null);
|
|
6144
|
+
setStaging({ url, rev: latestRev });
|
|
6145
|
+
})
|
|
6146
|
+
.catch(() => {
|
|
6147
|
+
if (cancelled) return;
|
|
6148
|
+
// One quiet retry for transient blips; after that, surface it.
|
|
6149
|
+
// The next draw event or a pane re-open also refetches.
|
|
6150
|
+
if (autoRetryRef.current.rev !== latestRev) autoRetryRef.current = { rev: latestRev, used: false };
|
|
6151
|
+
if (!autoRetryRef.current.used) {
|
|
6152
|
+
autoRetryRef.current.used = true;
|
|
6153
|
+
retryTimer = setTimeout(() => { if (!cancelled) setRetryTick((t) => t + 1); }, 1500);
|
|
6154
|
+
return;
|
|
6155
|
+
}
|
|
6156
|
+
setLoadError({ rev: latestRev });
|
|
6157
|
+
});
|
|
6158
|
+
return () => {
|
|
6159
|
+
cancelled = true;
|
|
6160
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
6161
|
+
};
|
|
6162
|
+
}, [controller, sessionId, latestRev, retryTick]);
|
|
6163
|
+
|
|
6164
|
+
// Session switch / unmount: drop both frames — a different session is a
|
|
6165
|
+
// different document, and showing the old canvas under a new session is a
|
|
6166
|
+
// lie. Revocation goes through the ref, not state updaters.
|
|
6167
|
+
React.useEffect(() => () => {
|
|
6168
|
+
if (urlsRef.current.staging && urlsRef.current.staging !== urlsRef.current.live) {
|
|
6169
|
+
URL.revokeObjectURL(urlsRef.current.staging);
|
|
6170
|
+
}
|
|
6171
|
+
if (urlsRef.current.live) URL.revokeObjectURL(urlsRef.current.live);
|
|
6172
|
+
urlsRef.current = { live: null, staging: null };
|
|
6173
|
+
setLive(null);
|
|
6174
|
+
setStaging(null);
|
|
6175
|
+
setLoadError(null);
|
|
6176
|
+
}, [sessionId]);
|
|
6177
|
+
|
|
6178
|
+
const promote = React.useCallback((frame) => {
|
|
6179
|
+
// Only the CURRENT staging revision promotes — a load event from a
|
|
6180
|
+
// frame the fetch effect already superseded must not go live again.
|
|
6181
|
+
if (!frame || urlsRef.current.staging !== frame.url) return;
|
|
6182
|
+
const oldLive = urlsRef.current.live;
|
|
6183
|
+
urlsRef.current.live = frame.url;
|
|
6184
|
+
urlsRef.current.staging = null;
|
|
6185
|
+
if (oldLive && oldLive !== frame.url) URL.revokeObjectURL(oldLive);
|
|
6186
|
+
setLive(frame);
|
|
6187
|
+
setStaging((current) => (current && current.url === frame.url ? null : current));
|
|
6188
|
+
// Record the geometry this revision loaded at (in page-viewport
|
|
6189
|
+
// units, i.e. divided by zoom) — the settle effect below compares
|
|
6190
|
+
// against it to decide when a reload is owed.
|
|
6191
|
+
const hostEl = hostRef.current;
|
|
6192
|
+
if (hostEl) {
|
|
6193
|
+
const rect = hostEl.getBoundingClientRect();
|
|
6194
|
+
const z = zoomRef.current || 1;
|
|
6195
|
+
loadedBoxRef.current = { w: Math.round(rect.width / z), h: Math.round(rect.height / z) };
|
|
6196
|
+
}
|
|
6197
|
+
// The surface's receipt: THIS is the one place a revision is known to
|
|
6198
|
+
// be truly on screen, so this is where viewed-marking lives. Mode
|
|
6199
|
+
// transitions marking revs viewed claimed phones saw drawings their
|
|
6200
|
+
// layout never showed. A frame kept MOUNTED but hidden (the sticky
|
|
6201
|
+
// toggle) still promotes new revisions in the background — those are
|
|
6202
|
+
// NOT viewed; the badge must light until the user actually looks.
|
|
6203
|
+
if (controller && sessionId && visibleRef.current) {
|
|
6204
|
+
controller.dispatch({ type: "canvas/viewed", sessionId, rev: frame.rev });
|
|
6205
|
+
}
|
|
6206
|
+
// iOS: a programmatic focus into the freshly promoted frame is the
|
|
6207
|
+
// synchronous interaction that registers its scroller — the code
|
|
6208
|
+
// equivalent of the tap users discovered by accident. preventScroll
|
|
6209
|
+
// so the viewport never jumps. Phone-only (the desktop must not have
|
|
6210
|
+
// its composer focus stolen by a background draw).
|
|
6211
|
+
if (focusOnPromote && visibleRef.current) {
|
|
6212
|
+
try { liveIframeElRef.current?.focus({ preventScroll: true }); } catch { /* best effort */ }
|
|
6213
|
+
}
|
|
6214
|
+
}, [controller, sessionId, focusOnPromote]);
|
|
6215
|
+
|
|
6216
|
+
// The LLM→page half of the duplex: post the latest data tick INTO the
|
|
6217
|
+
// live frame. Keyed on BOTH the tick and the live frame identity, so a
|
|
6218
|
+
// freshly promoted document (redraw, reload, cold open) replays the
|
|
6219
|
+
// current state without the agent doing anything — the page just needs
|
|
6220
|
+
// one idempotent applyData(). Posting into a cross-origin frame is
|
|
6221
|
+
// allowed; a page with no listener ignores it.
|
|
6222
|
+
React.useEffect(() => {
|
|
6223
|
+
if (!live || !dataRev || dataPayload === null) return;
|
|
6224
|
+
try {
|
|
6225
|
+
liveIframeElRef.current?.contentWindow?.postMessage(
|
|
6226
|
+
{ type: "canvas-data", data: dataPayload, dataRev }, "*",
|
|
6227
|
+
);
|
|
6228
|
+
} catch { /* frame mid-teardown */ }
|
|
6229
|
+
// On-screen injection = seen; hidden injection leaves the badge lit
|
|
6230
|
+
// until the user actually looks (the catch-up below files it then).
|
|
6231
|
+
if (controller && sessionId && visibleRef.current) {
|
|
6232
|
+
controller.dispatch({ type: "canvas/viewed", sessionId, dataRev });
|
|
6233
|
+
}
|
|
6234
|
+
}, [live, dataRev, dataPayload, controller, sessionId]);
|
|
6235
|
+
|
|
6236
|
+
// Catch-up receipt: becoming visible with a live revision on screen IS
|
|
6237
|
+
// viewing it — this is what clears a badge earned while hidden.
|
|
6238
|
+
React.useEffect(() => {
|
|
6239
|
+
if (visible && live && controller && sessionId) {
|
|
6240
|
+
controller.dispatch({ type: "canvas/viewed", sessionId, rev: live.rev, ...(dataRev ? { dataRev } : {}) });
|
|
6241
|
+
if (focusOnPromote) {
|
|
6242
|
+
try { liveIframeElRef.current?.focus({ preventScroll: true }); } catch { /* best effort */ }
|
|
6243
|
+
}
|
|
6244
|
+
}
|
|
6245
|
+
}, [visible, live, controller, sessionId, focusOnPromote, dataRev]);
|
|
6246
|
+
|
|
6247
|
+
// Settle-reload: once the pane's size (or zoom) stops moving somewhere
|
|
6248
|
+
// materially different from where the live page loaded, refetch the same
|
|
6249
|
+
// revision through the staging buffer so the page re-runs its layout at
|
|
6250
|
+
// the true viewport. Debounced past the resize gesture; a couple of px
|
|
6251
|
+
// of tolerance so subpixel wobble never churns it.
|
|
6252
|
+
React.useEffect(() => {
|
|
6253
|
+
if (!live || !visible) return undefined;
|
|
6254
|
+
const z = zoom || 1;
|
|
6255
|
+
const w = Math.round((hostBox.width || 0) / z);
|
|
6256
|
+
const h = Math.round((hostBox.height || 0) / z);
|
|
6257
|
+
if (!w || !h) return undefined;
|
|
6258
|
+
const loadedAt = loadedBoxRef.current;
|
|
6259
|
+
if (loadedAt) {
|
|
6260
|
+
// A page that loaded against a REAL box owns its layout from then
|
|
6261
|
+
// on and is NEVER auto-reloaded: the inner window receives genuine
|
|
6262
|
+
// resize events when this element changes size (sandboxing blocks
|
|
6263
|
+
// DOM poking, not resize), and a reload would cost a stateful app
|
|
6264
|
+
// — a game, a half-filled form — everything it holds, for a
|
|
6265
|
+
// purely cosmetic guarantee. Observed live: dragging the desktop
|
|
6266
|
+
// window reset a farm game to day 1. Settle-reload exists ONLY to
|
|
6267
|
+
// rescue documents that loaded against a degenerate box — the
|
|
6268
|
+
// iframe's 300×150 intrinsic default before layout, or a hidden
|
|
6269
|
+
// host — and baked that measurement in.
|
|
6270
|
+
const loadedDegenerate = loadedAt.w < 220 || loadedAt.h < 170;
|
|
6271
|
+
if (!loadedDegenerate) return undefined;
|
|
6272
|
+
const dw = Math.abs(loadedAt.w - w);
|
|
6273
|
+
const dh = Math.abs(loadedAt.h - h);
|
|
6274
|
+
if (dw <= 2 && dh <= 2) return undefined;
|
|
6275
|
+
// Height-only jitter is iOS toolbar churn (the URL bar collapses
|
|
6276
|
+
// and expands, ±6% or so) — a reload for that costs the user
|
|
6277
|
+
// their scroll position and page state for nothing.
|
|
6278
|
+
if (dw <= 2 && dh / Math.max(1, loadedAt.h) < 0.15) return undefined;
|
|
6279
|
+
}
|
|
6280
|
+
const timer = setTimeout(() => {
|
|
6281
|
+
loadedBoxRef.current = { w, h };
|
|
6282
|
+
setRetryTick((t) => t + 1);
|
|
6283
|
+
}, 350);
|
|
6284
|
+
return () => clearTimeout(timer);
|
|
6285
|
+
}, [hostBox.width, hostBox.height, zoom, live, visible]);
|
|
6286
|
+
|
|
6287
|
+
const zoomStyle = zoom !== 1 && hostBox.width > 0
|
|
6288
|
+
? {
|
|
6289
|
+
flex: "none",
|
|
6290
|
+
width: `${Math.max(1, Math.round(hostBox.width / zoom))}px`,
|
|
6291
|
+
height: `${Math.max(1, Math.round(hostBox.height / zoom))}px`,
|
|
6292
|
+
transform: `scale(${zoom})`,
|
|
6293
|
+
transformOrigin: "top left",
|
|
6294
|
+
}
|
|
6295
|
+
: undefined;
|
|
6296
|
+
|
|
6297
|
+
return React.createElement("div", { ref: hostRef, className: "ps-html-preview-host is-fitting" },
|
|
6298
|
+
[live, staging].filter(Boolean).map((frame) => React.createElement("iframe", {
|
|
6299
|
+
key: `canvas:${frame.rev}:${frame.url}`,
|
|
6300
|
+
ref: frame === live ? ((el) => { liveIframeElRef.current = el; }) : undefined,
|
|
6301
|
+
className: `ps-html-preview-frame${frame === live ? "" : " is-staging"}`,
|
|
6302
|
+
style: zoomStyle,
|
|
6303
|
+
src: frame.url,
|
|
6304
|
+
title: "Session canvas",
|
|
6305
|
+
// Same sandbox contract as artifact previews — no allow-same-origin.
|
|
6306
|
+
sandbox: "allow-scripts allow-popups allow-popups-to-escape-sandbox",
|
|
6307
|
+
// The autoplay Permissions Policy defaults to `self`, and a
|
|
6308
|
+
// sandboxed frame has an OPAQUE origin — so a canvas page could
|
|
6309
|
+
// build an AudioContext, get no error, and emit nothing. Granting
|
|
6310
|
+
// autoplay is what lets a game's sound effects actually play.
|
|
6311
|
+
allow: "autoplay *",
|
|
6312
|
+
referrerPolicy: "no-referrer",
|
|
6313
|
+
onLoad: frame === staging ? () => promote(frame) : undefined,
|
|
6314
|
+
})),
|
|
6315
|
+
loadError
|
|
6316
|
+
? React.createElement("div", { className: "ps-canvas-load-error" },
|
|
6317
|
+
React.createElement("span", null,
|
|
6318
|
+
live
|
|
6319
|
+
? `Couldn't load rev ${loadError.rev} — showing rev ${live.rev}.`
|
|
6320
|
+
: `Couldn't load rev ${loadError.rev}.`),
|
|
6321
|
+
React.createElement("button", {
|
|
6322
|
+
type: "button",
|
|
6323
|
+
className: "ps-artifact-pane-btn",
|
|
6324
|
+
onClick: () => { autoRetryRef.current = { rev: 0, used: false }; setLoadError(null); setRetryTick((t) => t + 1); },
|
|
6325
|
+
}, "Retry"))
|
|
6326
|
+
: null);
|
|
6327
|
+
}
|
|
6328
|
+
|
|
6329
|
+
/**
|
|
6330
|
+
* Canvas mode for the right column: the session's standing display, rendered
|
|
6331
|
+
* from the reserved canvas.html artifact and converging live on
|
|
6332
|
+
* canvas_updated events. Replaces the inspector/activity split while active;
|
|
6333
|
+
* the artifact reader still takes precedence while open and returns here.
|
|
6334
|
+
*/
|
|
6335
|
+
function CanvasPane({ controller, mobile = false, onClose = null, visible = true, focusOnPromote = false }) {
|
|
6336
|
+
const view = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
|
|
6337
|
+
const zoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
|
|
6338
|
+
const setZoom = React.useCallback((next) => {
|
|
6339
|
+
controller.dispatch({ type: "files/htmlZoom", zoom: next });
|
|
6340
|
+
}, [controller]);
|
|
6341
|
+
|
|
6342
|
+
// Belt to the selection-burst braces: a pane mounted before the burst's
|
|
6343
|
+
// snapshot resolves (deep link straight into canvas mode) fetches it here;
|
|
6344
|
+
// the memo makes the duplicate call free.
|
|
6345
|
+
React.useEffect(() => {
|
|
6346
|
+
if (view.sessionId) controller.ensureCanvasSnapshot(view.sessionId).catch(() => {});
|
|
6347
|
+
}, [controller, view.sessionId]);
|
|
6348
|
+
|
|
6349
|
+
const backToPanes = React.useCallback(() => {
|
|
6350
|
+
controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: view.sessionId, manual: true });
|
|
6351
|
+
}, [controller, view.sessionId]);
|
|
6352
|
+
const close = onClose || (!mobile ? backToPanes : null);
|
|
6353
|
+
|
|
6354
|
+
return React.createElement("section", {
|
|
6355
|
+
className: "ps-artifact-pane ps-canvas-pane",
|
|
6356
|
+
role: "region",
|
|
6357
|
+
"aria-label": "Session canvas",
|
|
6358
|
+
},
|
|
6359
|
+
React.createElement("header", { className: "ps-artifact-pane-bar" },
|
|
6360
|
+
React.createElement("span", { className: "ps-artifact-pane-heading" },
|
|
6361
|
+
React.createElement("span", { className: "ps-artifact-pane-name ps-canvas-pane-glyph", title: view.note || "Canvas" },
|
|
6362
|
+
React.createElement(CanvasGlyph)),
|
|
6363
|
+
view.exists
|
|
6364
|
+
? React.createElement("span", { className: "ps-artifact-pane-type", title: view.note || undefined },
|
|
6365
|
+
`rev ${view.latestRev}`)
|
|
6366
|
+
: null),
|
|
6367
|
+
React.createElement("span", { className: "ps-artifact-pane-actions" },
|
|
6368
|
+
view.exists ? React.createElement(HtmlZoomControl, { zoom, setZoom }) : null,
|
|
6369
|
+
close ? React.createElement("button", {
|
|
6370
|
+
type: "button",
|
|
6371
|
+
className: "ps-artifact-pane-btn",
|
|
6372
|
+
onClick: close,
|
|
6373
|
+
title: mobile ? "Close the canvas" : "Back to the inspector and activity panes",
|
|
6374
|
+
"aria-label": mobile ? "Close canvas" : "Back to panes",
|
|
6375
|
+
}, "✕") : null)),
|
|
6376
|
+
React.createElement("div", { className: "ps-artifact-pane-body" },
|
|
6377
|
+
view.exists
|
|
6378
|
+
? React.createElement(CanvasFrame, {
|
|
6379
|
+
controller,
|
|
6380
|
+
sessionId: view.sessionId,
|
|
6381
|
+
latestRev: view.latestRev,
|
|
6382
|
+
zoom,
|
|
6383
|
+
visible,
|
|
6384
|
+
focusOnPromote,
|
|
6385
|
+
dataRev: view.latestDataRev,
|
|
6386
|
+
dataPayload: view.dataPayload,
|
|
6387
|
+
})
|
|
6388
|
+
: React.createElement("div", { className: "ps-canvas-blank" },
|
|
6389
|
+
React.createElement("p", { className: "ps-canvas-blank-title" }, "Nothing on the canvas yet."),
|
|
6390
|
+
React.createElement("p", null,
|
|
6391
|
+
"Ask the agent to draw — a dashboard, a chart, a diagram — or it will draw when it has something worth showing."))));
|
|
6392
|
+
}
|
|
6393
|
+
|
|
5967
6394
|
function InspectorTabs({ activeTab, controller }) {
|
|
5968
6395
|
const visibleTabs = React.useMemo(() => getVisibleInspectorTabs(controller), [controller]);
|
|
5969
6396
|
// Icon-only tabs: the full label (e.g. "Node Map") lives in the IconButton
|
|
@@ -6410,7 +6837,7 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
|
|
|
6410
6837
|
followBottom: state.ui.followBottom?.inspector !== false,
|
|
6411
6838
|
logsTailing: state.logs.tailing,
|
|
6412
6839
|
filesFullscreen: Boolean(state.files.fullscreen),
|
|
6413
|
-
contentWidth: getPortalInspectorContentWidth(paneWidth, inspectorTab),
|
|
6840
|
+
contentWidth: getPortalInspectorContentWidth(paneWidth, inspectorTab, mobile),
|
|
6414
6841
|
};
|
|
6415
6842
|
}, shallowEqualObject);
|
|
6416
6843
|
const selectorState = React.useMemo(() => ({
|
|
@@ -6465,10 +6892,13 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
|
|
|
6465
6892
|
viewState.sessionsById,
|
|
6466
6893
|
viewState.sessionsFlat,
|
|
6467
6894
|
]);
|
|
6895
|
+
// allowWideColumns let the sequence grid exceed the viewport so a phone
|
|
6896
|
+
// could pan it — in practice it rendered clipped on BOTH edges (the
|
|
6897
|
+
// timestamps and the STATS header sliced off) and read as broken. Fitting
|
|
6898
|
+
// to width collapses surplus lanes into "…" instead, which is legible.
|
|
6468
6899
|
const inspector = React.useMemo(() => selectInspector(selectorState, {
|
|
6469
6900
|
width: viewState.contentWidth,
|
|
6470
|
-
|
|
6471
|
-
}), [mobile, selectorState, viewState.contentWidth]);
|
|
6901
|
+
}), [selectorState, viewState.contentWidth]);
|
|
6472
6902
|
const completionByTurn = React.useMemo(() => {
|
|
6473
6903
|
const history = viewState.activeSessionId
|
|
6474
6904
|
? viewState.historyBySessionId?.get(viewState.activeSessionId) || null
|
|
@@ -6499,6 +6929,54 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
|
|
|
6499
6929
|
return () => window.clearInterval(timer);
|
|
6500
6930
|
}, [controller, viewState.inspectorTab]);
|
|
6501
6931
|
|
|
6932
|
+
// Ground-truth clamp, phones only. The global column count comes from a
|
|
6933
|
+
// window-level probe that phones have repeatedly proven able to lie to
|
|
6934
|
+
// (pinch state, desktop-site layout viewports, probe-context fonts). The
|
|
6935
|
+
// pane itself cannot lie: it measures its own content box and its own
|
|
6936
|
+
// rendered line font, and when the published count disagrees materially it
|
|
6937
|
+
// republishes from those measurements. Runs after paint on mount and tab
|
|
6938
|
+
// switches; the ±2 tolerance keeps it from ping-ponging with publish().
|
|
6939
|
+
const clampFrameRef = React.useRef(null);
|
|
6940
|
+
React.useEffect(() => {
|
|
6941
|
+
if (!mobile) return undefined;
|
|
6942
|
+
clampFrameRef.current = requestAnimationFrame(() => {
|
|
6943
|
+
try {
|
|
6944
|
+
const panel = document.querySelector(".ps-mobile-pane-fill .ps-inspector-pane .ps-scroll-panel");
|
|
6945
|
+
const line = panel ? panel.querySelector(".ps-line") : null;
|
|
6946
|
+
if (!panel || !line) return;
|
|
6947
|
+
const cs = window.getComputedStyle(line);
|
|
6948
|
+
const probe = document.createElement("span");
|
|
6949
|
+
probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
|
|
6950
|
+
probe.style.fontFamily = cs.fontFamily;
|
|
6951
|
+
probe.style.fontSize = cs.fontSize;
|
|
6952
|
+
probe.style.fontWeight = cs.fontWeight;
|
|
6953
|
+
probe.style.letterSpacing = cs.letterSpacing;
|
|
6954
|
+
probe.textContent = "0".repeat(100);
|
|
6955
|
+
line.parentElement.appendChild(probe);
|
|
6956
|
+
const charWidth = (probe.getBoundingClientRect().width / 100) || 8;
|
|
6957
|
+
probe.remove();
|
|
6958
|
+
const panelStyle = window.getComputedStyle(panel);
|
|
6959
|
+
const contentPx = panel.clientWidth
|
|
6960
|
+
- (parseFloat(panelStyle.paddingLeft) || 0)
|
|
6961
|
+
- (parseFloat(panelStyle.paddingRight) || 0);
|
|
6962
|
+
if (!(contentPx > 50)) return;
|
|
6963
|
+
const contentCols = Math.floor(contentPx / charWidth);
|
|
6964
|
+
// getPortalInspectorContentWidth hands the selector paneWidth-6
|
|
6965
|
+
// on mobile, so publishing contentCols+6 makes the selector see
|
|
6966
|
+
// exactly what the pane measured.
|
|
6967
|
+
const paneCols = contentCols + 6;
|
|
6968
|
+
const currentCols = controller.getState().ui.layout?.viewportWidth ?? 120;
|
|
6969
|
+
if (Math.abs(paneCols - currentCols) <= 2) return;
|
|
6970
|
+
controller.dispatch({ type: "ui/viewport", width: paneCols, height: null });
|
|
6971
|
+
} catch {
|
|
6972
|
+
// Measurement is best-effort; the publish path still stands.
|
|
6973
|
+
}
|
|
6974
|
+
});
|
|
6975
|
+
return () => {
|
|
6976
|
+
if (clampFrameRef.current) cancelAnimationFrame(clampFrameRef.current);
|
|
6977
|
+
};
|
|
6978
|
+
}, [controller, mobile, viewState.inspectorTab, viewState.activeSessionId]);
|
|
6979
|
+
|
|
6502
6980
|
if (viewState.inspectorTab === "files" && !inspector.disabled) {
|
|
6503
6981
|
return React.createElement(FilesPane, { controller, focused: viewState.focused, mobile });
|
|
6504
6982
|
}
|
|
@@ -6705,51 +7183,6 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
|
|
|
6705
7183
|
}, content);
|
|
6706
7184
|
}
|
|
6707
7185
|
|
|
6708
|
-
function ChatFocusWorkspace({ controller, openPane, onTogglePane, onExitFocus, mobile = false }) {
|
|
6709
|
-
const focusRegion = useControllerSelector(controller, (state) => state.ui.focusRegion);
|
|
6710
|
-
|
|
6711
|
-
// Icons, not words: two text buttons ate a whole row of phone height, and
|
|
6712
|
-
// IconButton carries the label as a hover tooltip / long-press on touch.
|
|
6713
|
-
const rail = mobile
|
|
6714
|
-
? React.createElement("div", { className: "ps-chat-focus-rail" },
|
|
6715
|
-
React.createElement(IconButton, {
|
|
6716
|
-
className: "ps-mini-button ps-chat-focus-button",
|
|
6717
|
-
icon: React.createElement(CollapseGlyph),
|
|
6718
|
-
label: "Exit focus mode",
|
|
6719
|
-
onClick: onExitFocus,
|
|
6720
|
-
}),
|
|
6721
|
-
React.createElement(IconButton, {
|
|
6722
|
-
className: "ps-mini-button ps-chat-focus-button",
|
|
6723
|
-
icon: React.createElement(SidebarGlyph),
|
|
6724
|
-
label: openPane === "sessions" ? "Hide sessions" : "Show sessions",
|
|
6725
|
-
active: openPane === "sessions",
|
|
6726
|
-
onClick: () => onTogglePane("sessions"),
|
|
6727
|
-
}))
|
|
6728
|
-
: React.createElement("div", { className: "ps-chat-focus-rail" },
|
|
6729
|
-
CHAT_FOCUS_PANES.map((pane) => React.createElement("button", {
|
|
6730
|
-
key: pane.id,
|
|
6731
|
-
type: "button",
|
|
6732
|
-
className: `ps-mini-button ps-chat-focus-button${openPane === pane.id ? " is-active" : ""}`,
|
|
6733
|
-
"aria-pressed": openPane === pane.id ? "true" : "false",
|
|
6734
|
-
onClick: () => onTogglePane(pane.id),
|
|
6735
|
-
}, pane.label)),
|
|
6736
|
-
React.createElement("div", { className: "ps-chat-focus-status" },
|
|
6737
|
-
openPane
|
|
6738
|
-
? `Focused: ${CHAT_FOCUS_PANES.find((pane) => pane.id === openPane)?.label || openPane}`
|
|
6739
|
-
: `Focused: ${focusRegion === "prompt" ? "Prompt" : "Chat"}`));
|
|
6740
|
-
|
|
6741
|
-
return React.createElement("div", { className: "ps-chat-focus-shell" },
|
|
6742
|
-
rail,
|
|
6743
|
-
React.createElement("div", { className: "ps-chat-focus-body" },
|
|
6744
|
-
React.createElement(ChatPane, { controller, mobile, fullWidth: true }),
|
|
6745
|
-
React.createElement(ChatFocusOverlay, {
|
|
6746
|
-
controller,
|
|
6747
|
-
pane: openPane,
|
|
6748
|
-
onClose: () => onTogglePane(openPane),
|
|
6749
|
-
mobile,
|
|
6750
|
-
})));
|
|
6751
|
-
}
|
|
6752
|
-
|
|
6753
7186
|
const EMPTY_ARRAY = Object.freeze([]);
|
|
6754
7187
|
|
|
6755
7188
|
function formatAttachmentSize(sizeBytes) {
|
|
@@ -7226,7 +7659,7 @@ function IconButton({ icon, label, onClick, disabled = false, active = false, cl
|
|
|
7226
7659
|
tooltipNode);
|
|
7227
7660
|
}
|
|
7228
7661
|
|
|
7229
|
-
function Toolbar({ controller, mobile,
|
|
7662
|
+
function Toolbar({ controller, mobile, canvasOverlayOpen = false, onToggleCanvasOverlay = null, mobilePane = "workspace", onSelectMobilePane = null, mobileMainLayout = "split" }) {
|
|
7230
7663
|
const [headerSlot, setHeaderSlot] = React.useState(null);
|
|
7231
7664
|
React.useEffect(() => {
|
|
7232
7665
|
if (typeof document === "undefined") return;
|
|
@@ -7237,10 +7670,7 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
|
|
|
7237
7670
|
setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
|
|
7238
7671
|
}, [mobile]);
|
|
7239
7672
|
const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
|
|
7240
|
-
const
|
|
7241
|
-
mode: state.ui.chatViewMode || "transcript",
|
|
7242
|
-
activeSessionIsGroup: Boolean(state.sessions.activeSessionId && state.sessions.byId[state.sessions.activeSessionId]?.isGroup),
|
|
7243
|
-
}), shallowEqualObject);
|
|
7673
|
+
const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
|
|
7244
7674
|
|
|
7245
7675
|
// Icon-first toolbar: the glyph is the affordance, the label rides a
|
|
7246
7676
|
// tooltip (desktop hover via title; mobile long-press via IconButton).
|
|
@@ -7267,24 +7697,38 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
|
|
|
7267
7697
|
label: "Theme",
|
|
7268
7698
|
onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_THEME_PICKER).catch(() => {}),
|
|
7269
7699
|
},
|
|
7700
|
+
// Canvas toggle, both device classes: the desktop flips the right
|
|
7701
|
+
// column, the phone toggles a full-screen overlay — one affordance,
|
|
7702
|
+
// one badge. While the canvas
|
|
7703
|
+
// is hidden, an undisplayed revision lights the badge.
|
|
7270
7704
|
{
|
|
7271
|
-
key: "
|
|
7272
|
-
icon: React.createElement(
|
|
7273
|
-
label:
|
|
7274
|
-
? "
|
|
7275
|
-
:
|
|
7276
|
-
onClick: () =>
|
|
7277
|
-
|
|
7278
|
-
|
|
7705
|
+
key: "canvas",
|
|
7706
|
+
icon: React.createElement(CanvasGlyph),
|
|
7707
|
+
label: (mobile ? canvasOverlayOpen : canvasView.mode === "canvas")
|
|
7708
|
+
? (mobile ? "Close the canvas" : "Show inspector and activity")
|
|
7709
|
+
: "Show canvas",
|
|
7710
|
+
onClick: () => {
|
|
7711
|
+
if (mobile) {
|
|
7712
|
+
if (onToggleCanvasOverlay) onToggleCanvasOverlay();
|
|
7713
|
+
return;
|
|
7714
|
+
}
|
|
7715
|
+
if (canvasView.mode === "canvas") {
|
|
7716
|
+
controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: canvasView.sessionId, manual: true });
|
|
7717
|
+
} else {
|
|
7718
|
+
controller.dispatch({ type: "ui/rightPaneMode", mode: "canvas", sessionId: canvasView.sessionId });
|
|
7719
|
+
}
|
|
7720
|
+
},
|
|
7721
|
+
active: mobile ? canvasOverlayOpen : canvasView.mode === "canvas",
|
|
7722
|
+
badge: mobile
|
|
7723
|
+
? (canvasView.exists
|
|
7724
|
+
&& (canvasView.latestRev > (canvasView.lastViewedRev || 0)
|
|
7725
|
+
|| (canvasView.latestDataRev || 0) > (canvasView.lastViewedDataRev || 0))
|
|
7726
|
+
&& !canvasOverlayOpen)
|
|
7727
|
+
: canvasView.unseen,
|
|
7728
|
+
// Three states: plain outline (empty), green dot bottom-left
|
|
7729
|
+
// (loaded), green + yellow (loaded with unseen changes).
|
|
7730
|
+
loadedDot: canvasView.exists,
|
|
7279
7731
|
},
|
|
7280
|
-
...(onToggleChatFocus ? [{
|
|
7281
|
-
key: "focus",
|
|
7282
|
-
icon: React.createElement(chatFocusMode ? CollapseGlyph : ExpandGlyph),
|
|
7283
|
-
label: chatFocusMode ? "Exit focus mode" : "Focus the chat pane",
|
|
7284
|
-
onClick: onToggleChatFocus,
|
|
7285
|
-
disabled: chatFocusDisabled,
|
|
7286
|
-
active: chatFocusMode,
|
|
7287
|
-
}] : []),
|
|
7288
7732
|
// Admin console is desktop-only: its settings tree, package detail
|
|
7289
7733
|
// and file preview need width the phone layout cannot give them, so
|
|
7290
7734
|
// the button is omitted rather than shipped half-working.
|
|
@@ -7297,22 +7741,56 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
|
|
|
7297
7741
|
}]),
|
|
7298
7742
|
];
|
|
7299
7743
|
|
|
7300
|
-
const renderButton = (def) =>
|
|
7301
|
-
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7306
|
-
|
|
7307
|
-
|
|
7744
|
+
const renderButton = (def) => {
|
|
7745
|
+
const button = React.createElement(IconButton, {
|
|
7746
|
+
key: def.badge ? undefined : def.key,
|
|
7747
|
+
icon: def.icon,
|
|
7748
|
+
label: def.label,
|
|
7749
|
+
onClick: def.onClick,
|
|
7750
|
+
disabled: Boolean(def.disabled),
|
|
7751
|
+
active: Boolean(def.active),
|
|
7752
|
+
});
|
|
7753
|
+
// Defs with corner markers wrap in the positioning span: yellow
|
|
7754
|
+
// top-right = unseen changes, green bottom-left = canvas has content.
|
|
7755
|
+
return (def.badge || def.loadedDot)
|
|
7756
|
+
? React.createElement("span", { key: def.key, className: "ps-canvas-toggle" },
|
|
7757
|
+
button,
|
|
7758
|
+
def.loadedDot ? React.createElement("span", { className: "ps-canvas-loaded-dot", "aria-hidden": "true" }) : null,
|
|
7759
|
+
def.badge ? React.createElement("span", { className: "ps-canvas-badge", "aria-hidden": "true" }) : null)
|
|
7760
|
+
: button;
|
|
7761
|
+
};
|
|
7308
7762
|
|
|
7309
7763
|
if (mobile) {
|
|
7310
|
-
//
|
|
7311
|
-
//
|
|
7312
|
-
//
|
|
7764
|
+
// One row, two groups: actions left, the three VIEW MODES right —
|
|
7765
|
+
// they used to be word tabs pinned to the bottom of the screen, and
|
|
7766
|
+
// folding them up here hands that whole strip back to the chat pane.
|
|
7767
|
+
// The Main button is a CYCLE once you are already on Main: split →
|
|
7768
|
+
// chat only → sessions only. ONE glyph for all three states (a
|
|
7769
|
+
// layout/panes mark) — a shape-shifting icon reads as three different
|
|
7770
|
+
// buttons; only the tooltip names what the next tap gives you.
|
|
7771
|
+
const mainLabels = {
|
|
7772
|
+
split: "Main — sessions and chat (tap for chat only)",
|
|
7773
|
+
chat: "Main — chat only (tap for sessions only)",
|
|
7774
|
+
sessions: "Main — sessions only (tap for both)",
|
|
7775
|
+
};
|
|
7776
|
+
const paneDefs = [
|
|
7777
|
+
{ id: "workspace", icon: MainLayoutGlyph, label: mainLabels[mobileMainLayout] || mainLabels.split, focus: "chat" },
|
|
7778
|
+
{ id: "activity", icon: ActivityPaneGlyph, label: "Activity", focus: "activity" },
|
|
7779
|
+
{ id: "inspector", icon: InspectorPaneGlyph, label: "Inspector", focus: "inspector" },
|
|
7780
|
+
];
|
|
7313
7781
|
return React.createElement("div", { className: "ps-toolbar is-mobile" },
|
|
7314
7782
|
React.createElement("div", { className: "ps-toolbar-row ps-toolbar-row-primary" },
|
|
7315
|
-
React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton))
|
|
7783
|
+
React.createElement("div", { className: "ps-toolbar-row-actions" }, buttonDefs.map(renderButton)),
|
|
7784
|
+
React.createElement("div", { className: "ps-toolbar-row-actions is-panes" },
|
|
7785
|
+
paneDefs.map((def) => React.createElement(IconButton, {
|
|
7786
|
+
key: def.id,
|
|
7787
|
+
icon: React.createElement(def.icon),
|
|
7788
|
+
label: def.label,
|
|
7789
|
+
active: mobilePane === def.id,
|
|
7790
|
+
onClick: () => {
|
|
7791
|
+
if (onSelectMobilePane) onSelectMobilePane(def.id, def.focus);
|
|
7792
|
+
},
|
|
7793
|
+
})))),
|
|
7316
7794
|
);
|
|
7317
7795
|
}
|
|
7318
7796
|
|
|
@@ -7329,6 +7807,30 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
|
|
|
7329
7807
|
return toolbar;
|
|
7330
7808
|
}
|
|
7331
7809
|
|
|
7810
|
+
/**
|
|
7811
|
+
* Pin a drag to the element that started it.
|
|
7812
|
+
*
|
|
7813
|
+
* Without capture, a resizer listens for pointerup on `window` — and never
|
|
7814
|
+
* hears it if the button is released over a CROSS-ORIGIN iframe. The artifact
|
|
7815
|
+
* reader is exactly that, and it sits directly beside the column resizer, so
|
|
7816
|
+
* releasing over a rendered artifact left the drag armed: the seam kept
|
|
7817
|
+
* following the pointer with no button held and the cursor stayed stuck in
|
|
7818
|
+
* col-resize.
|
|
7819
|
+
*
|
|
7820
|
+
* Pointer capture routes every subsequent event for this pointerId to the
|
|
7821
|
+
* capturing element regardless of what is underneath, iframes included. The
|
|
7822
|
+
* events still bubble to the window listeners the handles already use, so
|
|
7823
|
+
* this is additive.
|
|
7824
|
+
*/
|
|
7825
|
+
function capturePointerForDrag(event) {
|
|
7826
|
+
try {
|
|
7827
|
+
event.currentTarget?.setPointerCapture?.(event.pointerId);
|
|
7828
|
+
} catch {
|
|
7829
|
+
// Capture is best-effort; the body class below is the second line of
|
|
7830
|
+
// defence and the window listeners still work for same-origin drags.
|
|
7831
|
+
}
|
|
7832
|
+
}
|
|
7833
|
+
|
|
7332
7834
|
function ColumnResizeHandle({ controller, paneAdjust = 0 }) {
|
|
7333
7835
|
const dragStateRef = React.useRef(null);
|
|
7334
7836
|
const [dragging, setDragging] = React.useState(false);
|
|
@@ -7386,6 +7888,7 @@ function ColumnResizeHandle({ controller, paneAdjust = 0 }) {
|
|
|
7386
7888
|
onPointerDown: (event) => {
|
|
7387
7889
|
if (event.button !== 0) return;
|
|
7388
7890
|
event.preventDefault();
|
|
7891
|
+
capturePointerForDrag(event);
|
|
7389
7892
|
dragStateRef.current = {
|
|
7390
7893
|
startX: event.clientX,
|
|
7391
7894
|
appliedCells: 0,
|
|
@@ -7458,6 +7961,7 @@ function RowResizeHandle({ controller, sessionPaneAdjust = 0 }) {
|
|
|
7458
7961
|
onPointerDown: (event) => {
|
|
7459
7962
|
if (event.button !== 0) return;
|
|
7460
7963
|
event.preventDefault();
|
|
7964
|
+
capturePointerForDrag(event);
|
|
7461
7965
|
dragStateRef.current = {
|
|
7462
7966
|
startY: event.clientY,
|
|
7463
7967
|
appliedCells: 0,
|
|
@@ -7534,6 +8038,7 @@ function SessionColumnResizeHandle({ controller, portalSessionColumnAdjust = 0 }
|
|
|
7534
8038
|
onPointerDown: (event) => {
|
|
7535
8039
|
if (event.button !== 0) return;
|
|
7536
8040
|
event.preventDefault();
|
|
8041
|
+
capturePointerForDrag(event);
|
|
7537
8042
|
const gridNode = event.currentTarget.closest(".ps-workspace-main-grid");
|
|
7538
8043
|
const bounds = portalSessionColumnBounds(gridNode?.getBoundingClientRect?.().width);
|
|
7539
8044
|
dragStateRef.current = {
|
|
@@ -7579,6 +8084,36 @@ function SessionColumnResizeHandle({ controller, portalSessionColumnAdjust = 0 }
|
|
|
7579
8084
|
React.createElement("span", { className: "ps-column-resizer-dot" })));
|
|
7580
8085
|
}
|
|
7581
8086
|
|
|
8087
|
+
/**
|
|
8088
|
+
* How many pixels the inspector/activity seam actually moves per row of adjust.
|
|
8089
|
+
*
|
|
8090
|
+
* The split is stored in character ROWS, but the column renders them as `fr`
|
|
8091
|
+
* — so one row of adjustment moves the seam by (column height / total rows)
|
|
8092
|
+
* pixels, whatever that happens to be. Dividing the pointer delta by a fixed
|
|
8093
|
+
* GRID_CELL_HEIGHT only tracks if the column is exactly rows × 19px tall, and
|
|
8094
|
+
* it is not: the column excludes the header and toolbar above it, and the row
|
|
8095
|
+
* height moves with the type scale. So the seam ran ahead of the pointer or
|
|
8096
|
+
* trailed behind it, and the error grew with the drag.
|
|
8097
|
+
*
|
|
8098
|
+
* Both operands are fixed for the duration of a drag — the split moves, the
|
|
8099
|
+
* total does not — so this is measured once at pointerdown rather than on
|
|
8100
|
+
* every pointermove.
|
|
8101
|
+
*/
|
|
8102
|
+
function measureActivityRowPx(controller, handleEl) {
|
|
8103
|
+
try {
|
|
8104
|
+
const layout = controller.getCurrentLayout?.();
|
|
8105
|
+
const rows = (layout?.inspectorPaneHeight || 0) + (layout?.activityPaneHeight || 0);
|
|
8106
|
+
const column = handleEl?.closest?.(".ps-workspace-column");
|
|
8107
|
+
if (!column || rows <= 0) return GRID_CELL_HEIGHT;
|
|
8108
|
+
// The resizer's own track is not part of either pane's share.
|
|
8109
|
+
const usable = column.clientHeight - (handleEl.getBoundingClientRect().height || 0);
|
|
8110
|
+
const pxPerRow = usable / rows;
|
|
8111
|
+
return Number.isFinite(pxPerRow) && pxPerRow > 0 ? pxPerRow : GRID_CELL_HEIGHT;
|
|
8112
|
+
} catch {
|
|
8113
|
+
return GRID_CELL_HEIGHT;
|
|
8114
|
+
}
|
|
8115
|
+
}
|
|
8116
|
+
|
|
7582
8117
|
function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
|
|
7583
8118
|
const dragStateRef = React.useRef(null);
|
|
7584
8119
|
const [dragging, setDragging] = React.useState(false);
|
|
@@ -7595,7 +8130,7 @@ function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
|
|
|
7595
8130
|
const onPointerMove = (event) => {
|
|
7596
8131
|
const dragState = dragStateRef.current;
|
|
7597
8132
|
if (!dragState) return;
|
|
7598
|
-
const deltaCells = Math.round((event.clientY - dragState.startY) /
|
|
8133
|
+
const deltaCells = Math.round((event.clientY - dragState.startY) / dragState.pxPerRow);
|
|
7599
8134
|
const deltaIncrement = deltaCells - dragState.appliedCells;
|
|
7600
8135
|
if (!deltaIncrement) return;
|
|
7601
8136
|
controller.adjustActivityPaneSplit(-deltaIncrement);
|
|
@@ -7622,9 +8157,12 @@ function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
|
|
|
7622
8157
|
onPointerDown: (event) => {
|
|
7623
8158
|
if (event.button !== 0) return;
|
|
7624
8159
|
event.preventDefault();
|
|
8160
|
+
capturePointerForDrag(event);
|
|
7625
8161
|
dragStateRef.current = {
|
|
7626
8162
|
startY: event.clientY,
|
|
7627
8163
|
appliedCells: 0,
|
|
8164
|
+
// MEASURED, not assumed — see measureActivityRowPx.
|
|
8165
|
+
pxPerRow: measureActivityRowPx(controller, event.currentTarget),
|
|
7628
8166
|
};
|
|
7629
8167
|
setDragging(true);
|
|
7630
8168
|
document.body.classList.add("is-resizing-pane-y");
|
|
@@ -7651,23 +8189,6 @@ function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
|
|
|
7651
8189
|
React.createElement("span", { className: "ps-row-resizer-dot" })));
|
|
7652
8190
|
}
|
|
7653
8191
|
|
|
7654
|
-
function MobileNav({ activePane, setActivePane, controller }) {
|
|
7655
|
-
const tabs = [
|
|
7656
|
-
{ id: "workspace", label: "Main", focus: "chat" },
|
|
7657
|
-
{ id: "inspector", label: "Inspector", focus: "inspector" },
|
|
7658
|
-
{ id: "activity", label: "Activity", focus: "activity" },
|
|
7659
|
-
];
|
|
7660
|
-
return React.createElement("div", { className: "ps-mobile-nav" },
|
|
7661
|
-
tabs.map((tab) => React.createElement("button", {
|
|
7662
|
-
key: tab.id,
|
|
7663
|
-
type: "button",
|
|
7664
|
-
className: `ps-mobile-nav-button${activePane === tab.id ? " is-active" : ""}`,
|
|
7665
|
-
onClick: () => {
|
|
7666
|
-
setActivePane(tab.id);
|
|
7667
|
-
controller.setFocus(tab.focus);
|
|
7668
|
-
},
|
|
7669
|
-
}, tab.label)));
|
|
7670
|
-
}
|
|
7671
8192
|
|
|
7672
8193
|
function ModalLayer({ controller }) {
|
|
7673
8194
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
@@ -8089,7 +8610,7 @@ function ModalLayer({ controller }) {
|
|
|
8089
8610
|
type: "button",
|
|
8090
8611
|
className: "ps-modal-button is-primary",
|
|
8091
8612
|
onClick: () => controller.handleCommand(UI_COMMANDS.MODAL_CONFIRM).catch(() => {}),
|
|
8092
|
-
}, "Create and Move")),
|
|
8613
|
+
}, modalState.sessionGroupName.mode === "rename" ? "Rename" : "Create and Move")),
|
|
8093
8614
|
));
|
|
8094
8615
|
}
|
|
8095
8616
|
if (modal.type === "terminatePicker") {
|
|
@@ -8378,11 +8899,6 @@ function useKeyboardShortcuts(controller, mobile) {
|
|
|
8378
8899
|
controller.handleCommand(UI_COMMANDS.REFRESH).catch(() => {});
|
|
8379
8900
|
return;
|
|
8380
8901
|
}
|
|
8381
|
-
if (focusRegion === "chat" && event.key === "s" && isPlainShortcut) {
|
|
8382
|
-
event.preventDefault();
|
|
8383
|
-
controller.handleCommand(UI_COMMANDS.TOGGLE_CHAT_VIEW).catch(() => {});
|
|
8384
|
-
return;
|
|
8385
|
-
}
|
|
8386
8902
|
if (event.key === "n" && isPlainShortcut) {
|
|
8387
8903
|
event.preventDefault();
|
|
8388
8904
|
controller.handleCommand(UI_COMMANDS.NEW_SESSION).catch(() => {});
|
|
@@ -9350,7 +9866,6 @@ export function createWebPilotSwarmController({ transport, mode = "remote", bran
|
|
|
9350
9866
|
mode,
|
|
9351
9867
|
branding,
|
|
9352
9868
|
docs,
|
|
9353
|
-
chatViewMode: isNarrowViewport ? "transcript" : "rich",
|
|
9354
9869
|
}));
|
|
9355
9870
|
return new PilotSwarmUiController({ store, transport });
|
|
9356
9871
|
}
|
|
@@ -9369,21 +9884,42 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9369
9884
|
// device — phones kept getting desktop splash art narrower than ~76 cols.
|
|
9370
9885
|
React.useEffect(() => {
|
|
9371
9886
|
const publish = () => {
|
|
9372
|
-
|
|
9373
|
-
|
|
9374
|
-
|
|
9375
|
-
|
|
9376
|
-
|
|
9377
|
-
|
|
9378
|
-
|
|
9379
|
-
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
9385
|
-
|
|
9386
|
-
|
|
9887
|
+
try {
|
|
9888
|
+
const probe = document.createElement("span");
|
|
9889
|
+
probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
|
|
9890
|
+
// Measure with the font real lines render in — an off-context
|
|
9891
|
+
// probe can inherit a different size and skew the column count.
|
|
9892
|
+
// Longhands, not the `font` shorthand: Safari serializes the
|
|
9893
|
+
// shorthand as "" whenever the longhands do not reduce to a
|
|
9894
|
+
// canonical form, which silently reverted the probe to its
|
|
9895
|
+
// parent's font.
|
|
9896
|
+
const sample = document.querySelector(".ps-scroll-panel .ps-line") || document.querySelector(".ps-line");
|
|
9897
|
+
if (sample) {
|
|
9898
|
+
const cs = window.getComputedStyle(sample);
|
|
9899
|
+
probe.style.fontFamily = cs.fontFamily;
|
|
9900
|
+
probe.style.fontSize = cs.fontSize;
|
|
9901
|
+
probe.style.fontWeight = cs.fontWeight;
|
|
9902
|
+
probe.style.letterSpacing = cs.letterSpacing;
|
|
9903
|
+
}
|
|
9904
|
+
probe.textContent = "0".repeat(100);
|
|
9905
|
+
(sample?.parentElement || document.body).appendChild(probe);
|
|
9906
|
+
const charWidth = (probe.getBoundingClientRect().width / 100) || 8;
|
|
9907
|
+
probe.remove();
|
|
9908
|
+
// Layout viewport, NOT window.innerWidth: on iOS innerWidth tracks
|
|
9909
|
+
// the VISUAL viewport, so a probe taken while pinch-zoomed (or with
|
|
9910
|
+
// the keyboard up) publishes a wildly wrong column count and the
|
|
9911
|
+
// sequence grid is built for a viewport that doesn't exist.
|
|
9912
|
+
const layoutWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
9913
|
+
const layoutHeight = document.documentElement.clientHeight || window.innerHeight;
|
|
9914
|
+
controller.dispatch({
|
|
9915
|
+
type: "ui/viewport",
|
|
9916
|
+
width: Math.floor(layoutWidth / charWidth),
|
|
9917
|
+
height: Math.floor(layoutHeight / SCROLL_ROW_HEIGHT),
|
|
9918
|
+
});
|
|
9919
|
+
} catch {
|
|
9920
|
+
// A probe crash keeps the last published (or fallback) size;
|
|
9921
|
+
// the pane-clamp below self-corrects the inspector regardless.
|
|
9922
|
+
}
|
|
9387
9923
|
};
|
|
9388
9924
|
publish();
|
|
9389
9925
|
let timer;
|
|
@@ -9396,8 +9932,9 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9396
9932
|
window.removeEventListener("orientationchange", onResize);
|
|
9397
9933
|
};
|
|
9398
9934
|
}, [controller]);
|
|
9399
|
-
|
|
9400
|
-
|
|
9935
|
+
// The phone's Main layout: split | chat | sessions (cycled by the Main
|
|
9936
|
+
// toolbar button). Desktop has real columns and needs no such cycle.
|
|
9937
|
+
const [mobileMainLayout, setMobileMainLayout] = React.useState("split");
|
|
9401
9938
|
const state = useControllerSelector(controller, (rootState) => ({
|
|
9402
9939
|
themeId: rootState.ui.themeId,
|
|
9403
9940
|
touchScale: Boolean(rootState.ui.touchScale),
|
|
@@ -9413,7 +9950,10 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9413
9950
|
collapsedSessionIds: rootState.sessions.collapsedIds,
|
|
9414
9951
|
activeSessionId: rootState.sessions.activeSessionId || null,
|
|
9415
9952
|
promptRows: getStatePromptRows(rootState),
|
|
9416
|
-
|
|
9953
|
+
rightPaneMode: rootState.ui.rightPaneMode || "panes",
|
|
9954
|
+
// Live reference — reducer updates replace the object, so
|
|
9955
|
+
// shallow-equal sees changes and the save effect fires.
|
|
9956
|
+
canvasPrefs: rootState.canvas?.prefs,
|
|
9417
9957
|
activeSessionIsGroup: Boolean(rootState.sessions.activeSessionId && rootState.sessions.byId[rootState.sessions.activeSessionId]?.isGroup),
|
|
9418
9958
|
paneAdjust: rootState.ui.layout?.paneAdjust ?? 0,
|
|
9419
9959
|
sessionPaneAdjust: rootState.ui.layout?.sessionPaneAdjust ?? 0,
|
|
@@ -9431,8 +9971,10 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9431
9971
|
// Last-known value of the OTHER device class's chat-view slot. The save
|
|
9432
9972
|
// endpoint replaces the whole settings object, so this is what keeps a
|
|
9433
9973
|
// desktop save from erasing the phone's preference.
|
|
9434
|
-
const otherChatViewModeRef = React.useRef(null);
|
|
9435
9974
|
const otherTouchScaleRef = React.useRef(null);
|
|
9975
|
+
// Last-known stored rightPaneMode — the desktop-owned slot a phone must
|
|
9976
|
+
// carry through its saves untouched (see profileSettingsFromViewState).
|
|
9977
|
+
const desktopRightPaneModeRef = React.useRef(null);
|
|
9436
9978
|
const profileSettingsSaveTimerRef = React.useRef(null);
|
|
9437
9979
|
const profileSettingsPollTimerRef = React.useRef(null);
|
|
9438
9980
|
const profileSettingsPollInFlightRef = React.useRef(false);
|
|
@@ -9441,14 +9983,62 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9441
9983
|
const defaultProfileSettingsRef = React.useRef(null);
|
|
9442
9984
|
const [mobilePane, setMobilePane] = React.useState("workspace");
|
|
9443
9985
|
const mobile = (viewport.width || window.innerWidth || 0) < MOBILE_BREAKPOINT;
|
|
9444
|
-
const readOnlyChatPane = state.activeSessionIsGroup
|
|
9986
|
+
const readOnlyChatPane = state.activeSessionIsGroup;
|
|
9445
9987
|
const effectivePromptRows = readOnlyChatPane ? 0 : state.promptRows;
|
|
9446
9988
|
|
|
9447
9989
|
useKeyboardShortcuts(controller, mobile);
|
|
9448
9990
|
|
|
9991
|
+
// The agent-driven canvas flip, phone edition. The desktop column follows
|
|
9992
|
+
// ui.rightPaneMode by itself; the phone's pane lives in local tab state,
|
|
9993
|
+
// so it follows the flip TICK instead (which fires even when the mode was
|
|
9994
|
+
// already "canvas" — e.g. a persisted mode with the user on Main).
|
|
9995
|
+
const canvasShellView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
|
|
9996
|
+
const [mobileCanvasOpen, setMobileCanvasOpen] = React.useState(false);
|
|
9997
|
+
// Once opened, the canvas stays MOUNTED (hidden) for the rest of the
|
|
9998
|
+
// session selection — page state (typed input, scroll, game progress)
|
|
9999
|
+
// survives toggling. A session switch remounts fresh via the key below.
|
|
10000
|
+
const [mobileCanvasEverOpened, setMobileCanvasEverOpened] = React.useState(false);
|
|
10001
|
+
const [desktopCanvasEverOpened, setDesktopCanvasEverOpened] = React.useState(false);
|
|
10002
|
+
React.useEffect(() => {
|
|
10003
|
+
setMobileCanvasOpen(false);
|
|
10004
|
+
setMobileCanvasEverOpened(false);
|
|
10005
|
+
setDesktopCanvasEverOpened(false);
|
|
10006
|
+
}, [canvasShellView.sessionId]);
|
|
10007
|
+
// iOS scroll-chaining: while the overlay is up, a pan that Safari decides
|
|
10008
|
+
// is not the iframe's must not scroll the app shell behind it.
|
|
10009
|
+
React.useEffect(() => {
|
|
10010
|
+
if (typeof document === "undefined") return undefined;
|
|
10011
|
+
document.body.classList.toggle("ps-canvas-lock", Boolean(mobile && mobileCanvasOpen));
|
|
10012
|
+
return () => document.body.classList.remove("ps-canvas-lock");
|
|
10013
|
+
}, [mobile, mobileCanvasOpen]);
|
|
10014
|
+
const canvasFlipSeqRef = React.useRef(canvasShellView.flipSeq);
|
|
9449
10015
|
React.useEffect(() => {
|
|
9450
|
-
|
|
9451
|
-
|
|
10016
|
+
if (canvasShellView.flipSeq === canvasFlipSeqRef.current) return;
|
|
10017
|
+
canvasFlipSeqRef.current = canvasShellView.flipSeq;
|
|
10018
|
+
if (mobile) { setMobileCanvasOpen(true); setMobileCanvasEverOpened(true); }
|
|
10019
|
+
}, [canvasShellView.flipSeq, mobile]);
|
|
10020
|
+
const closeMobileCanvas = React.useCallback(() => {
|
|
10021
|
+
setMobileCanvasOpen(false);
|
|
10022
|
+
// Mirror the desktop ✕ exactly: a deliberate close is the opt-out
|
|
10023
|
+
// gesture — later draws badge the toggle instead of re-covering the
|
|
10024
|
+
// screen, and reopening clears the opt-out.
|
|
10025
|
+
controller.dispatch({ type: "ui/rightPaneMode", mode: "panes", sessionId: canvasShellView.sessionId, manual: true });
|
|
10026
|
+
}, [controller, canvasShellView.sessionId]);
|
|
10027
|
+
const toggleMobileCanvas = React.useCallback(() => {
|
|
10028
|
+
if (mobileCanvasOpen) { closeMobileCanvas(); return; }
|
|
10029
|
+
setMobileCanvasOpen(true);
|
|
10030
|
+
setMobileCanvasEverOpened(true);
|
|
10031
|
+
// Entering by hand un-opts-out, same as the desktop toggle.
|
|
10032
|
+
controller.dispatch({ type: "ui/rightPaneMode", mode: "canvas", sessionId: canvasShellView.sessionId });
|
|
10033
|
+
}, [mobileCanvasOpen, closeMobileCanvas, controller, canvasShellView.sessionId]);
|
|
10034
|
+
|
|
10035
|
+
// gridViewport used to be PUBLISHED here via controller.setViewport — a
|
|
10036
|
+
// second writer to ui.layout that derived columns from the hard-coded
|
|
10037
|
+
// GRID_CELL_WIDTH instead of the rendered font. The two publishers fought
|
|
10038
|
+
// (last writer wins), which is how a phone ended up laying out its
|
|
10039
|
+
// inspector for a column count no probe ever measured. The font-probe
|
|
10040
|
+
// effect is the only publisher now; gridViewport stays local to the
|
|
10041
|
+
// desktop pane splitter below.
|
|
9452
10042
|
|
|
9453
10043
|
React.useEffect(() => {
|
|
9454
10044
|
let active = true;
|
|
@@ -9487,14 +10077,14 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9487
10077
|
// so the "no persisted filter" fallback becomes the correct
|
|
9488
10078
|
// principal-scoped default (Me+System) instead of {all:true}.
|
|
9489
10079
|
const remoteNormalized = normalizeProfileSettings(profile?.profileSettings);
|
|
9490
|
-
if (isChatViewMode(remoteNormalized[otherChatViewModeKey()])) {
|
|
9491
|
-
otherChatViewModeRef.current = remoteNormalized[otherChatViewModeKey()];
|
|
9492
|
-
}
|
|
9493
10080
|
if (typeof remoteNormalized[otherTouchScaleKey()] === "boolean") {
|
|
9494
10081
|
otherTouchScaleRef.current = remoteNormalized[otherTouchScaleKey()];
|
|
9495
10082
|
}
|
|
10083
|
+
if (remoteNormalized.rightPaneMode === "canvas" || remoteNormalized.rightPaneMode === "panes") {
|
|
10084
|
+
desktopRightPaneModeRef.current = remoteNormalized.rightPaneMode;
|
|
10085
|
+
}
|
|
9496
10086
|
defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
|
|
9497
|
-
controller.getState(),
|
|
10087
|
+
controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
|
|
9498
10088
|
);
|
|
9499
10089
|
const settings = materializeProfileSettings(
|
|
9500
10090
|
profile?.profileSettings,
|
|
@@ -9502,7 +10092,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9502
10092
|
);
|
|
9503
10093
|
const settingsJson = JSON.stringify(settings);
|
|
9504
10094
|
const currentSettingsBeforeApply = profileSettingsFromViewState(
|
|
9505
|
-
controller.getState(),
|
|
10095
|
+
controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
|
|
9506
10096
|
);
|
|
9507
10097
|
const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
|
|
9508
10098
|
const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
|
|
@@ -9529,7 +10119,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9529
10119
|
// remote state by definition and there is nothing to lose).
|
|
9530
10120
|
if (!profileSettingsHydratedRef.current || !hasPendingLocalWrite) {
|
|
9531
10121
|
const currentSettings = profileSettingsFromViewState(
|
|
9532
|
-
controller.getState(),
|
|
10122
|
+
controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
|
|
9533
10123
|
);
|
|
9534
10124
|
lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
|
|
9535
10125
|
}
|
|
@@ -9563,7 +10153,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9563
10153
|
|
|
9564
10154
|
React.useEffect(() => {
|
|
9565
10155
|
if (!profileSettingsHydratedRef.current) return undefined;
|
|
9566
|
-
const settings = profileSettingsFromViewState(state,
|
|
10156
|
+
const settings = profileSettingsFromViewState(state, otherTouchScaleRef.current, desktopRightPaneModeRef.current);
|
|
9567
10157
|
const settingsJson = JSON.stringify(settings);
|
|
9568
10158
|
if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
|
|
9569
10159
|
lastProfileSettingsJsonRef.current = settingsJson;
|
|
@@ -9580,7 +10170,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9580
10170
|
});
|
|
9581
10171
|
}, 400);
|
|
9582
10172
|
return undefined;
|
|
9583
|
-
}, [controller, state.activeSessionId, state.activityPaneAdjust, state.
|
|
10173
|
+
}, [controller, state.activeSessionId, state.activityPaneAdjust, state.canvasPrefs, state.collapsedSessionIds, state.ownerFilter, state.paneAdjust, state.manualOrder, state.pinnedIds, state.portalSessionColumnAdjust, state.rightPaneMode, state.sessionPaneAdjust, state.themeId, state.touchScale]);
|
|
9584
10174
|
|
|
9585
10175
|
React.useEffect(() => {
|
|
9586
10176
|
applyDocumentTheme(state.themeId);
|
|
@@ -9600,12 +10190,13 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9600
10190
|
lastFocusRef.current = state.focusRegion;
|
|
9601
10191
|
if (previous === null) return;
|
|
9602
10192
|
if (previous === state.focusRegion) return;
|
|
9603
|
-
|
|
9604
|
-
|
|
9605
|
-
|
|
9606
|
-
|
|
9607
|
-
|
|
9608
|
-
|
|
10193
|
+
// Follow focus ONLY into the two panes that are nothing but focus
|
|
10194
|
+
// targets. Mapping chat/sessions/prompt back to Main fought the
|
|
10195
|
+
// toolbar: the sessions-only layout's list reclaims focus, which
|
|
10196
|
+
// bounced the user out of Inspector the instant they tapped it.
|
|
10197
|
+
// Returning to Main is the Main button's job now.
|
|
10198
|
+
if (mobile && (state.focusRegion === "inspector" || state.focusRegion === "activity")) {
|
|
10199
|
+
setMobilePane(state.focusRegion);
|
|
9609
10200
|
}
|
|
9610
10201
|
}, [mobile, state.focusRegion]);
|
|
9611
10202
|
|
|
@@ -9635,6 +10226,16 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9635
10226
|
&& Boolean(state.selectedArtifactId)
|
|
9636
10227
|
&& !filesFullscreenActive;
|
|
9637
10228
|
|
|
10229
|
+
// Canvas mode claims the column beneath the reader: the reader still takes
|
|
10230
|
+
// precedence while open, and its ✕ leaves rightPaneMode untouched — so
|
|
10231
|
+
// closing a preview returns to the canvas, restore-what-was-displaced.
|
|
10232
|
+
const canvasModeActive = !mobile
|
|
10233
|
+
&& state.rightPaneMode === "canvas"
|
|
10234
|
+
&& !filesFullscreenActive;
|
|
10235
|
+
React.useEffect(() => {
|
|
10236
|
+
if (canvasModeActive) setDesktopCanvasEverOpened(true);
|
|
10237
|
+
}, [canvasModeActive]);
|
|
10238
|
+
|
|
9638
10239
|
// Closing hands the column back — or collapses it again, if the user had
|
|
9639
10240
|
// already resized it away before opening the pane. Both cases live in the
|
|
9640
10241
|
// controller so the decision is testable without a DOM.
|
|
@@ -9642,32 +10243,6 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9642
10243
|
controller.closeArtifactPane().catch(() => {});
|
|
9643
10244
|
}, [controller]);
|
|
9644
10245
|
|
|
9645
|
-
React.useEffect(() => {
|
|
9646
|
-
if (!filesFullscreenActive || !chatFocusMode) return;
|
|
9647
|
-
setChatFocusMode(false);
|
|
9648
|
-
setChatFocusPane(null);
|
|
9649
|
-
}, [chatFocusMode, filesFullscreenActive]);
|
|
9650
|
-
|
|
9651
|
-
const toggleChatFocusMode = React.useCallback(() => {
|
|
9652
|
-
setChatFocusMode((current) => {
|
|
9653
|
-
const next = !current;
|
|
9654
|
-
if (!next) {
|
|
9655
|
-
setChatFocusPane(null);
|
|
9656
|
-
} else {
|
|
9657
|
-
controller.setFocus("chat");
|
|
9658
|
-
}
|
|
9659
|
-
return next;
|
|
9660
|
-
});
|
|
9661
|
-
}, [controller]);
|
|
9662
|
-
|
|
9663
|
-
const toggleChatFocusPane = React.useCallback((paneId) => {
|
|
9664
|
-
setChatFocusPane((current) => {
|
|
9665
|
-
const next = current === paneId ? null : paneId;
|
|
9666
|
-
controller.setFocus(next || "chat");
|
|
9667
|
-
return next;
|
|
9668
|
-
});
|
|
9669
|
-
}, [controller]);
|
|
9670
|
-
|
|
9671
10246
|
const estimatedMainGridWidth = Math.max(0, (viewport.width || 0) * 0.68);
|
|
9672
10247
|
const measuredMainGridWidth = mainGridViewport.width || estimatedMainGridWidth;
|
|
9673
10248
|
const sessionColumnWidth = portalSessionColumnWidth(measuredMainGridWidth, state.portalSessionColumnAdjust);
|
|
@@ -9711,6 +10286,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9711
10286
|
// The artifact reader takes the WHOLE right column — inspector, resizer and
|
|
9712
10287
|
// activity all step aside. The column keeps its width and resizer, so the
|
|
9713
10288
|
// chat/reader split is dragged exactly like the chat/inspector one was.
|
|
10289
|
+
React.createElement("div", { className: "ps-workspace-column-host" },
|
|
9714
10290
|
artifactPaneActive
|
|
9715
10291
|
? React.createElement("div", { className: "ps-workspace-column is-artifact" },
|
|
9716
10292
|
React.createElement(ArtifactTakeoverPane, { controller, onClose: closeArtifactPane }))
|
|
@@ -9741,14 +10317,19 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9741
10317
|
// It yields back to Activity the moment the selection clears.
|
|
9742
10318
|
artifactPreviewDetached
|
|
9743
10319
|
? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
|
|
9744
|
-
: (!layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null)))
|
|
9745
|
-
|
|
9746
|
-
|
|
9747
|
-
|
|
9748
|
-
|
|
9749
|
-
|
|
9750
|
-
|
|
9751
|
-
|
|
10320
|
+
: (!layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null))),
|
|
10321
|
+
// The canvas keeps its DOCUMENT alive across toggles: once opened for
|
|
10322
|
+
// this session it stays mounted in a hidden layer — unmounting destroys
|
|
10323
|
+
// typed input, scroll position, and any running page. Session switches
|
|
10324
|
+
// remount fresh via the key.
|
|
10325
|
+
desktopCanvasEverOpened
|
|
10326
|
+
? React.createElement("div", {
|
|
10327
|
+
key: `canvas:${state.activeSessionId || ""}`,
|
|
10328
|
+
className: `ps-workspace-column is-artifact ps-canvas-layer${canvasModeActive && !artifactPaneActive ? "" : " is-hidden"}`,
|
|
10329
|
+
...(canvasModeActive && !artifactPaneActive ? {} : { inert: true, "aria-hidden": "true" }),
|
|
10330
|
+
},
|
|
10331
|
+
React.createElement(CanvasPane, { controller, visible: canvasModeActive && !artifactPaneActive }))
|
|
10332
|
+
: null));
|
|
9752
10333
|
const fullscreenWorkspace = React.createElement("div", { className: "ps-workspace-full" },
|
|
9753
10334
|
React.createElement(InspectorPane, { controller, mobile: false }));
|
|
9754
10335
|
|
|
@@ -9771,36 +10352,48 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9771
10352
|
React.createElement(InspectorPane, { controller, mobile: true }));
|
|
9772
10353
|
else if (mobilePane === "activity") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
|
|
9773
10354
|
React.createElement(ActivityPane, { controller }));
|
|
9774
|
-
else mobileContent = React.createElement(MobileWorkspace, { controller });
|
|
10355
|
+
else mobileContent = React.createElement(MobileWorkspace, { controller, layout: mobileMainLayout });
|
|
9775
10356
|
|
|
9776
10357
|
return React.createElement(ControllerContext.Provider, { value: controller },
|
|
9777
10358
|
React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
|
|
9778
|
-
//
|
|
9779
|
-
//
|
|
9780
|
-
//
|
|
9781
|
-
|
|
9782
|
-
// on a phone, which matters for the fleet-skills card and the
|
|
9783
|
-
// logs/sequence inspectors.
|
|
9784
|
-
(mobile && (chatFocusMode || mobilePane === "inspector" || mobilePane === "activity"))
|
|
9785
|
-
? null
|
|
9786
|
-
: React.createElement(Toolbar, {
|
|
10359
|
+
// The phone's toolbar carries its NAVIGATION (the three view modes,
|
|
10360
|
+
// plus the Main layout cycle), so it renders on every pane — hiding it
|
|
10361
|
+
// anywhere would trap the user with no way back.
|
|
10362
|
+
React.createElement(Toolbar, {
|
|
9787
10363
|
controller,
|
|
9788
10364
|
mobile,
|
|
9789
|
-
|
|
9790
|
-
|
|
9791
|
-
|
|
10365
|
+
canvasOverlayOpen: mobileCanvasOpen,
|
|
10366
|
+
onToggleCanvasOverlay: toggleMobileCanvas,
|
|
10367
|
+
mobilePane,
|
|
10368
|
+
mobileMainLayout,
|
|
10369
|
+
onSelectMobilePane: (paneId, focus) => {
|
|
10370
|
+
// Tapping Main while already on Main cycles its layout; from
|
|
10371
|
+
// another pane it just returns you to Main, unchanged.
|
|
10372
|
+
if (paneId === "workspace" && mobilePane === "workspace") {
|
|
10373
|
+
setMobileMainLayout((current) => (
|
|
10374
|
+
current === "split" ? "chat" : current === "chat" ? "sessions" : "split"
|
|
10375
|
+
));
|
|
10376
|
+
}
|
|
10377
|
+
setMobilePane(paneId);
|
|
10378
|
+
if (focus) controller.setFocus(focus);
|
|
10379
|
+
},
|
|
9792
10380
|
}),
|
|
9793
10381
|
React.createElement("div", { className: "ps-workspace" },
|
|
9794
10382
|
state.adminVisible
|
|
9795
10383
|
? React.createElement(AdminConsolePanel, { controller, mobile })
|
|
9796
10384
|
: (filesFullscreenActive
|
|
9797
10385
|
? fullscreenWorkspace
|
|
9798
|
-
: (
|
|
9799
|
-
? chatFocusWorkspace
|
|
9800
|
-
: (mobile ? mobileContent : desktopWorkspace)))),
|
|
9801
|
-
mobile && !chatFocusMode ? React.createElement(MobileNav, { activePane: mobilePane, setActivePane: setMobilePane, controller }) : null,
|
|
10386
|
+
: (mobile ? mobileContent : desktopWorkspace))),
|
|
9802
10387
|
mobile && filesFullscreenActive
|
|
9803
10388
|
? React.createElement(MobileArtifactOverlay, { controller })
|
|
9804
10389
|
: null,
|
|
10390
|
+
mobile && mobileCanvasEverOpened
|
|
10391
|
+
? React.createElement(MobileCanvasOverlay, {
|
|
10392
|
+
key: `canvas:${canvasShellView.sessionId || ""}`,
|
|
10393
|
+
controller,
|
|
10394
|
+
onClose: closeMobileCanvas,
|
|
10395
|
+
visible: mobileCanvasOpen,
|
|
10396
|
+
})
|
|
10397
|
+
: null,
|
|
9805
10398
|
React.createElement(ModalLayer, { controller })));
|
|
9806
10399
|
}
|