pilotswarm 0.5.36 → 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/react/src/components.js +1 -7
- package/ui/react/src/web-app.js +792 -259
- package/web/dist/assets/{abnfDiagram-VRR7QNED-LOLCtsns.js → abnfDiagram-VRR7QNED-DlvPH-8O.js} +1 -1
- package/web/dist/assets/{arc-BE-jVDEi.js → arc-r1liz5e5.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CdmUjwtj.js → architectureDiagram-ZJ3FMSHR-Bci5dNH3.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-NZ4TOa8L.js → blockDiagram-677ZJIJ3-Bug32AXp.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-9ubX8uGA.js → c4Diagram-LMCZKHZV-DwxSFqgE.js} +1 -1
- package/web/dist/assets/channel-jVpSYat3.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-DNDnjbP6.js → chunk-2Q5K7J3B-B_dbI_xg.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-iilfiffr.js → chunk-32BRIVSS-BkF68W-R.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-BakOMFmf.js → chunk-5VM5RSS4-BSbNIbJu.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-BhluhGyZ.js → chunk-EX3LRPZG-Blv_Iqa2.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-B6GKLlNb.js → chunk-JWPE2WC7-Dk_dBFKd.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-CXmt8msF.js → chunk-MOJQB5TN-DqNFBrfw.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-Bu58-SDr.js → chunk-RYQCIY6F-Dlv7Svpb.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-AQdyoYHL.js → chunk-V7JOEXUC-DozGikwu.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-DeM5iMsE.js → chunk-VR4S4FIN-CmkqvGT4.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-DjGjajy1.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-BGi4_egJ.js → cose-bilkent-JH36ORCC-Bv-gRVvA.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CcgV1ve4.js → cynefin-VYW2F7L2-DRnAsyZU.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BRDEsbBG.js → cynefinDiagram-TSTJHNR4-B1fnUtjo.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-OjuC8dJB.js → dagre-VKFMJZFB-DM6-QENB.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-CGdROWYh.js → diagram-FQU43EPY-BPxxnLdU.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-B5J0EUfS.js → diagram-G47NLZAW-BgtJT3qF.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-zmb4-cP2.js → diagram-NH7WQ7WH-uekeIzsy.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-j514bgaH.js → diagram-OA4YK3LP-BVy3JZWE.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-h5G459mZ.js → diagram-WEI45ONY-BWdjUr32.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-GnMgbuFk.js → ebnfDiagram-CCIWWBDH-DD4fwhNK.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-BFgH3UQp.js → erDiagram-Q63AITRT-BllD_6Kh.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-BsbQ_VXV.js → flowDiagram-23GEKE2U-QEYKkAUN.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-C_fjo2sf.js → ganttDiagram-NO4QXBWP-uZcfKTSW.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BUoXrAI9.js → gitGraphDiagram-IHSO6WYX-XPdbPaH-.js} +1 -1
- package/web/dist/assets/index-BuDSoRIL.css +1 -0
- package/web/dist/assets/{index-ONJimzcx.js → index-D1itTxvB.js} +4 -4
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-CJiSANyk.js → infoDiagram-FWYZ7A6U-DXCnQOJs.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-BKoQ-a2c.js → ishikawaDiagram-FXEZZL3T-687_4FBy.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-Db6Crcd3.js → journeyDiagram-5HDEW3XC-GXlKuda8.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-B9GaB0UJ.js → kanban-definition-HUTT4EX6-C38-2DvN.js} +1 -1
- package/web/dist/assets/{linear-BXwRb8MN.js → linear-lGPXiBcz.js} +1 -1
- package/web/dist/assets/{mermaid.core-DwzXN6up.js → mermaid.core-C_neEtab.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-DKC_C_OY.js → mindmap-definition-LN4V7U3C-BaJ9tpEG.js} +1 -1
- package/web/dist/assets/{papaparse.min-Cm1Pr54l.js → papaparse.min-rDwKYbBf.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-IeawbZPt.js → pegDiagram-2B236MQR-BdXJLYgY.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-BOdsYBt8.js → pieDiagram-ENE6RG2P-DU19t6fM.js} +1 -1
- package/web/dist/assets/pilotswarm-BNKGG936.js +96 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cr5yoN7C.js → quadrantDiagram-ABIIQ3AL-CBkOdPp0.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-E7wuFBuk.js → railroadDiagram-RFXS5EU6-D2DMcGDw.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-sZCKiuDs.js → requirementDiagram-TGXJPOKE-Bx9tF1pr.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-B_EWziUR.js → sankeyDiagram-HTMAVEWB-DcLlRfsi.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-D2mN7onX.js → sequenceDiagram-DBY2YBRQ-xr6UVRBO.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-CEL9Ly2T.js → sizeCapture-X5ZJPWSS-0fUkSXmg.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-C7ejJoeN.js → stateDiagram-2N3HPSRC-BWgJGGpg.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DmAC8DdB.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-Ck3pbFxk.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-K3H8Yuyr.js → timeline-definition-FHXFAJF6-Df8BpeAu.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-C3NKjSv9.js → vennDiagram-L72KCM5P-ChX5bjT6.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-CeeVuFiO.js → wardleyDiagram-EHGQE667-Dr48BgaJ.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-CF-vCvEq.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-BhHZnKdH.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-CM_bKBKz.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-CM_bKBKz.js +0 -1
- package/web/dist/assets/index-CnH1Yv_d.css +0 -1
- package/web/dist/assets/pilotswarm-kCQFVjJm.js +0 -99
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-UaVDt_gU.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CqO_fF5t.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
|
|
|
@@ -7711,23 +8189,6 @@ function ActivityRowResizeHandle({ controller, activityPaneAdjust = 0 }) {
|
|
|
7711
8189
|
React.createElement("span", { className: "ps-row-resizer-dot" })));
|
|
7712
8190
|
}
|
|
7713
8191
|
|
|
7714
|
-
function MobileNav({ activePane, setActivePane, controller }) {
|
|
7715
|
-
const tabs = [
|
|
7716
|
-
{ id: "workspace", label: "Main", focus: "chat" },
|
|
7717
|
-
{ id: "inspector", label: "Inspector", focus: "inspector" },
|
|
7718
|
-
{ id: "activity", label: "Activity", focus: "activity" },
|
|
7719
|
-
];
|
|
7720
|
-
return React.createElement("div", { className: "ps-mobile-nav" },
|
|
7721
|
-
tabs.map((tab) => React.createElement("button", {
|
|
7722
|
-
key: tab.id,
|
|
7723
|
-
type: "button",
|
|
7724
|
-
className: `ps-mobile-nav-button${activePane === tab.id ? " is-active" : ""}`,
|
|
7725
|
-
onClick: () => {
|
|
7726
|
-
setActivePane(tab.id);
|
|
7727
|
-
controller.setFocus(tab.focus);
|
|
7728
|
-
},
|
|
7729
|
-
}, tab.label)));
|
|
7730
|
-
}
|
|
7731
8192
|
|
|
7732
8193
|
function ModalLayer({ controller }) {
|
|
7733
8194
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
@@ -8149,7 +8610,7 @@ function ModalLayer({ controller }) {
|
|
|
8149
8610
|
type: "button",
|
|
8150
8611
|
className: "ps-modal-button is-primary",
|
|
8151
8612
|
onClick: () => controller.handleCommand(UI_COMMANDS.MODAL_CONFIRM).catch(() => {}),
|
|
8152
|
-
}, "Create and Move")),
|
|
8613
|
+
}, modalState.sessionGroupName.mode === "rename" ? "Rename" : "Create and Move")),
|
|
8153
8614
|
));
|
|
8154
8615
|
}
|
|
8155
8616
|
if (modal.type === "terminatePicker") {
|
|
@@ -8438,11 +8899,6 @@ function useKeyboardShortcuts(controller, mobile) {
|
|
|
8438
8899
|
controller.handleCommand(UI_COMMANDS.REFRESH).catch(() => {});
|
|
8439
8900
|
return;
|
|
8440
8901
|
}
|
|
8441
|
-
if (focusRegion === "chat" && event.key === "s" && isPlainShortcut) {
|
|
8442
|
-
event.preventDefault();
|
|
8443
|
-
controller.handleCommand(UI_COMMANDS.TOGGLE_CHAT_VIEW).catch(() => {});
|
|
8444
|
-
return;
|
|
8445
|
-
}
|
|
8446
8902
|
if (event.key === "n" && isPlainShortcut) {
|
|
8447
8903
|
event.preventDefault();
|
|
8448
8904
|
controller.handleCommand(UI_COMMANDS.NEW_SESSION).catch(() => {});
|
|
@@ -9410,7 +9866,6 @@ export function createWebPilotSwarmController({ transport, mode = "remote", bran
|
|
|
9410
9866
|
mode,
|
|
9411
9867
|
branding,
|
|
9412
9868
|
docs,
|
|
9413
|
-
chatViewMode: isNarrowViewport ? "transcript" : "rich",
|
|
9414
9869
|
}));
|
|
9415
9870
|
return new PilotSwarmUiController({ store, transport });
|
|
9416
9871
|
}
|
|
@@ -9429,21 +9884,42 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9429
9884
|
// device — phones kept getting desktop splash art narrower than ~76 cols.
|
|
9430
9885
|
React.useEffect(() => {
|
|
9431
9886
|
const publish = () => {
|
|
9432
|
-
|
|
9433
|
-
|
|
9434
|
-
|
|
9435
|
-
|
|
9436
|
-
|
|
9437
|
-
|
|
9438
|
-
|
|
9439
|
-
|
|
9440
|
-
|
|
9441
|
-
|
|
9442
|
-
|
|
9443
|
-
|
|
9444
|
-
|
|
9445
|
-
|
|
9446
|
-
|
|
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
|
+
}
|
|
9447
9923
|
};
|
|
9448
9924
|
publish();
|
|
9449
9925
|
let timer;
|
|
@@ -9456,8 +9932,9 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9456
9932
|
window.removeEventListener("orientationchange", onResize);
|
|
9457
9933
|
};
|
|
9458
9934
|
}, [controller]);
|
|
9459
|
-
|
|
9460
|
-
|
|
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");
|
|
9461
9938
|
const state = useControllerSelector(controller, (rootState) => ({
|
|
9462
9939
|
themeId: rootState.ui.themeId,
|
|
9463
9940
|
touchScale: Boolean(rootState.ui.touchScale),
|
|
@@ -9473,7 +9950,10 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9473
9950
|
collapsedSessionIds: rootState.sessions.collapsedIds,
|
|
9474
9951
|
activeSessionId: rootState.sessions.activeSessionId || null,
|
|
9475
9952
|
promptRows: getStatePromptRows(rootState),
|
|
9476
|
-
|
|
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,
|
|
9477
9957
|
activeSessionIsGroup: Boolean(rootState.sessions.activeSessionId && rootState.sessions.byId[rootState.sessions.activeSessionId]?.isGroup),
|
|
9478
9958
|
paneAdjust: rootState.ui.layout?.paneAdjust ?? 0,
|
|
9479
9959
|
sessionPaneAdjust: rootState.ui.layout?.sessionPaneAdjust ?? 0,
|
|
@@ -9491,8 +9971,10 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9491
9971
|
// Last-known value of the OTHER device class's chat-view slot. The save
|
|
9492
9972
|
// endpoint replaces the whole settings object, so this is what keeps a
|
|
9493
9973
|
// desktop save from erasing the phone's preference.
|
|
9494
|
-
const otherChatViewModeRef = React.useRef(null);
|
|
9495
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);
|
|
9496
9978
|
const profileSettingsSaveTimerRef = React.useRef(null);
|
|
9497
9979
|
const profileSettingsPollTimerRef = React.useRef(null);
|
|
9498
9980
|
const profileSettingsPollInFlightRef = React.useRef(false);
|
|
@@ -9501,14 +9983,62 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9501
9983
|
const defaultProfileSettingsRef = React.useRef(null);
|
|
9502
9984
|
const [mobilePane, setMobilePane] = React.useState("workspace");
|
|
9503
9985
|
const mobile = (viewport.width || window.innerWidth || 0) < MOBILE_BREAKPOINT;
|
|
9504
|
-
const readOnlyChatPane = state.activeSessionIsGroup
|
|
9986
|
+
const readOnlyChatPane = state.activeSessionIsGroup;
|
|
9505
9987
|
const effectivePromptRows = readOnlyChatPane ? 0 : state.promptRows;
|
|
9506
9988
|
|
|
9507
9989
|
useKeyboardShortcuts(controller, mobile);
|
|
9508
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.
|
|
9509
10009
|
React.useEffect(() => {
|
|
9510
|
-
|
|
9511
|
-
|
|
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);
|
|
10015
|
+
React.useEffect(() => {
|
|
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.
|
|
9512
10042
|
|
|
9513
10043
|
React.useEffect(() => {
|
|
9514
10044
|
let active = true;
|
|
@@ -9547,14 +10077,14 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9547
10077
|
// so the "no persisted filter" fallback becomes the correct
|
|
9548
10078
|
// principal-scoped default (Me+System) instead of {all:true}.
|
|
9549
10079
|
const remoteNormalized = normalizeProfileSettings(profile?.profileSettings);
|
|
9550
|
-
if (isChatViewMode(remoteNormalized[otherChatViewModeKey()])) {
|
|
9551
|
-
otherChatViewModeRef.current = remoteNormalized[otherChatViewModeKey()];
|
|
9552
|
-
}
|
|
9553
10080
|
if (typeof remoteNormalized[otherTouchScaleKey()] === "boolean") {
|
|
9554
10081
|
otherTouchScaleRef.current = remoteNormalized[otherTouchScaleKey()];
|
|
9555
10082
|
}
|
|
10083
|
+
if (remoteNormalized.rightPaneMode === "canvas" || remoteNormalized.rightPaneMode === "panes") {
|
|
10084
|
+
desktopRightPaneModeRef.current = remoteNormalized.rightPaneMode;
|
|
10085
|
+
}
|
|
9556
10086
|
defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
|
|
9557
|
-
controller.getState(),
|
|
10087
|
+
controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
|
|
9558
10088
|
);
|
|
9559
10089
|
const settings = materializeProfileSettings(
|
|
9560
10090
|
profile?.profileSettings,
|
|
@@ -9562,7 +10092,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9562
10092
|
);
|
|
9563
10093
|
const settingsJson = JSON.stringify(settings);
|
|
9564
10094
|
const currentSettingsBeforeApply = profileSettingsFromViewState(
|
|
9565
|
-
controller.getState(),
|
|
10095
|
+
controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
|
|
9566
10096
|
);
|
|
9567
10097
|
const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
|
|
9568
10098
|
const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
|
|
@@ -9589,7 +10119,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9589
10119
|
// remote state by definition and there is nothing to lose).
|
|
9590
10120
|
if (!profileSettingsHydratedRef.current || !hasPendingLocalWrite) {
|
|
9591
10121
|
const currentSettings = profileSettingsFromViewState(
|
|
9592
|
-
controller.getState(),
|
|
10122
|
+
controller.getState(), otherTouchScaleRef.current, desktopRightPaneModeRef.current,
|
|
9593
10123
|
);
|
|
9594
10124
|
lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
|
|
9595
10125
|
}
|
|
@@ -9623,7 +10153,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9623
10153
|
|
|
9624
10154
|
React.useEffect(() => {
|
|
9625
10155
|
if (!profileSettingsHydratedRef.current) return undefined;
|
|
9626
|
-
const settings = profileSettingsFromViewState(state,
|
|
10156
|
+
const settings = profileSettingsFromViewState(state, otherTouchScaleRef.current, desktopRightPaneModeRef.current);
|
|
9627
10157
|
const settingsJson = JSON.stringify(settings);
|
|
9628
10158
|
if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
|
|
9629
10159
|
lastProfileSettingsJsonRef.current = settingsJson;
|
|
@@ -9640,7 +10170,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9640
10170
|
});
|
|
9641
10171
|
}, 400);
|
|
9642
10172
|
return undefined;
|
|
9643
|
-
}, [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]);
|
|
9644
10174
|
|
|
9645
10175
|
React.useEffect(() => {
|
|
9646
10176
|
applyDocumentTheme(state.themeId);
|
|
@@ -9660,12 +10190,13 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9660
10190
|
lastFocusRef.current = state.focusRegion;
|
|
9661
10191
|
if (previous === null) return;
|
|
9662
10192
|
if (previous === state.focusRegion) return;
|
|
9663
|
-
|
|
9664
|
-
|
|
9665
|
-
|
|
9666
|
-
|
|
9667
|
-
|
|
9668
|
-
|
|
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);
|
|
9669
10200
|
}
|
|
9670
10201
|
}, [mobile, state.focusRegion]);
|
|
9671
10202
|
|
|
@@ -9695,6 +10226,16 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9695
10226
|
&& Boolean(state.selectedArtifactId)
|
|
9696
10227
|
&& !filesFullscreenActive;
|
|
9697
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
|
+
|
|
9698
10239
|
// Closing hands the column back — or collapses it again, if the user had
|
|
9699
10240
|
// already resized it away before opening the pane. Both cases live in the
|
|
9700
10241
|
// controller so the decision is testable without a DOM.
|
|
@@ -9702,32 +10243,6 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9702
10243
|
controller.closeArtifactPane().catch(() => {});
|
|
9703
10244
|
}, [controller]);
|
|
9704
10245
|
|
|
9705
|
-
React.useEffect(() => {
|
|
9706
|
-
if (!filesFullscreenActive || !chatFocusMode) return;
|
|
9707
|
-
setChatFocusMode(false);
|
|
9708
|
-
setChatFocusPane(null);
|
|
9709
|
-
}, [chatFocusMode, filesFullscreenActive]);
|
|
9710
|
-
|
|
9711
|
-
const toggleChatFocusMode = React.useCallback(() => {
|
|
9712
|
-
setChatFocusMode((current) => {
|
|
9713
|
-
const next = !current;
|
|
9714
|
-
if (!next) {
|
|
9715
|
-
setChatFocusPane(null);
|
|
9716
|
-
} else {
|
|
9717
|
-
controller.setFocus("chat");
|
|
9718
|
-
}
|
|
9719
|
-
return next;
|
|
9720
|
-
});
|
|
9721
|
-
}, [controller]);
|
|
9722
|
-
|
|
9723
|
-
const toggleChatFocusPane = React.useCallback((paneId) => {
|
|
9724
|
-
setChatFocusPane((current) => {
|
|
9725
|
-
const next = current === paneId ? null : paneId;
|
|
9726
|
-
controller.setFocus(next || "chat");
|
|
9727
|
-
return next;
|
|
9728
|
-
});
|
|
9729
|
-
}, [controller]);
|
|
9730
|
-
|
|
9731
10246
|
const estimatedMainGridWidth = Math.max(0, (viewport.width || 0) * 0.68);
|
|
9732
10247
|
const measuredMainGridWidth = mainGridViewport.width || estimatedMainGridWidth;
|
|
9733
10248
|
const sessionColumnWidth = portalSessionColumnWidth(measuredMainGridWidth, state.portalSessionColumnAdjust);
|
|
@@ -9771,6 +10286,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9771
10286
|
// The artifact reader takes the WHOLE right column — inspector, resizer and
|
|
9772
10287
|
// activity all step aside. The column keeps its width and resizer, so the
|
|
9773
10288
|
// chat/reader split is dragged exactly like the chat/inspector one was.
|
|
10289
|
+
React.createElement("div", { className: "ps-workspace-column-host" },
|
|
9774
10290
|
artifactPaneActive
|
|
9775
10291
|
? React.createElement("div", { className: "ps-workspace-column is-artifact" },
|
|
9776
10292
|
React.createElement(ArtifactTakeoverPane, { controller, onClose: closeArtifactPane }))
|
|
@@ -9801,14 +10317,19 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9801
10317
|
// It yields back to Activity the moment the selection clears.
|
|
9802
10318
|
artifactPreviewDetached
|
|
9803
10319
|
? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
|
|
9804
|
-
: (!layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null)))
|
|
9805
|
-
|
|
9806
|
-
|
|
9807
|
-
|
|
9808
|
-
|
|
9809
|
-
|
|
9810
|
-
|
|
9811
|
-
|
|
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));
|
|
9812
10333
|
const fullscreenWorkspace = React.createElement("div", { className: "ps-workspace-full" },
|
|
9813
10334
|
React.createElement(InspectorPane, { controller, mobile: false }));
|
|
9814
10335
|
|
|
@@ -9831,36 +10352,48 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
9831
10352
|
React.createElement(InspectorPane, { controller, mobile: true }));
|
|
9832
10353
|
else if (mobilePane === "activity") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
|
|
9833
10354
|
React.createElement(ActivityPane, { controller }));
|
|
9834
|
-
else mobileContent = React.createElement(MobileWorkspace, { controller });
|
|
10355
|
+
else mobileContent = React.createElement(MobileWorkspace, { controller, layout: mobileMainLayout });
|
|
9835
10356
|
|
|
9836
10357
|
return React.createElement(ControllerContext.Provider, { value: controller },
|
|
9837
10358
|
React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
|
|
9838
|
-
//
|
|
9839
|
-
//
|
|
9840
|
-
//
|
|
9841
|
-
|
|
9842
|
-
// on a phone, which matters for the fleet-skills card and the
|
|
9843
|
-
// logs/sequence inspectors.
|
|
9844
|
-
(mobile && (chatFocusMode || mobilePane === "inspector" || mobilePane === "activity"))
|
|
9845
|
-
? null
|
|
9846
|
-
: 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, {
|
|
9847
10363
|
controller,
|
|
9848
10364
|
mobile,
|
|
9849
|
-
|
|
9850
|
-
|
|
9851
|
-
|
|
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
|
+
},
|
|
9852
10380
|
}),
|
|
9853
10381
|
React.createElement("div", { className: "ps-workspace" },
|
|
9854
10382
|
state.adminVisible
|
|
9855
10383
|
? React.createElement(AdminConsolePanel, { controller, mobile })
|
|
9856
10384
|
: (filesFullscreenActive
|
|
9857
10385
|
? fullscreenWorkspace
|
|
9858
|
-
: (
|
|
9859
|
-
? chatFocusWorkspace
|
|
9860
|
-
: (mobile ? mobileContent : desktopWorkspace)))),
|
|
9861
|
-
mobile && !chatFocusMode ? React.createElement(MobileNav, { activePane: mobilePane, setActivePane: setMobilePane, controller }) : null,
|
|
10386
|
+
: (mobile ? mobileContent : desktopWorkspace))),
|
|
9862
10387
|
mobile && filesFullscreenActive
|
|
9863
10388
|
? React.createElement(MobileArtifactOverlay, { controller })
|
|
9864
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,
|
|
9865
10398
|
React.createElement(ModalLayer, { controller })));
|
|
9866
10399
|
}
|