pmx-canvas 0.4.2 → 0.4.3
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/.github/extensions/pmx-canvas/extension.mjs +5 -2
- package/CHANGELOG.md +33 -0
- package/Readme.md +34 -12
- package/dist/canvas/global.css +37 -28
- package/dist/canvas/index.js +62 -62
- package/dist/canvas/surface-theme.css +20 -20
- package/dist/types/client/state/theme-override.d.ts +27 -0
- package/docs/http-api.md +8 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +3 -1
- package/docs/screenshot.png +0 -0
- package/package.json +3 -3
- package/skills/pmx-canvas/SKILL.md +4 -0
- package/src/client/App.tsx +85 -78
- package/src/client/canvas/CommandPalette.tsx +4 -0
- package/src/client/nodes/ExtAppFrame.tsx +15 -4
- package/src/client/nodes/McpAppNode.tsx +6 -1
- package/src/client/state/sse-bridge.ts +8 -2
- package/src/client/state/theme-override.ts +83 -0
- package/src/client/theme/global.css +37 -28
- package/src/client/theme/surface-theme.css +20 -20
- package/src/server/operations/ops/canvas-wire.ts +9 -1
- package/src/shared/themes.ts +1 -1
|
@@ -218,32 +218,32 @@
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
:root[data-theme="ember"] {
|
|
221
|
-
/* ── Core palette (ember · charcoal + vivid
|
|
222
|
-
--c-bg: #
|
|
223
|
-
--c-panel: #
|
|
224
|
-
--c-panel-soft: #
|
|
225
|
-
--c-line: #
|
|
226
|
-
--c-text: #
|
|
227
|
-
--c-text-soft: #
|
|
228
|
-
--c-muted: #
|
|
229
|
-
--c-dim: #
|
|
230
|
-
--c-accent: #
|
|
221
|
+
/* ── Core palette (ember · neutral charcoal + vivid orange-red) ─── */
|
|
222
|
+
--c-bg: #0B0B0C;
|
|
223
|
+
--c-panel: #161618;
|
|
224
|
+
--c-panel-soft: #101012;
|
|
225
|
+
--c-line: #29292C;
|
|
226
|
+
--c-text: #EDEBE6;
|
|
227
|
+
--c-text-soft: #CBC9C2;
|
|
228
|
+
--c-muted: #96948D;
|
|
229
|
+
--c-dim: #66655F;
|
|
230
|
+
--c-accent: #F34E1C;
|
|
231
231
|
--c-ok: #4CC38A;
|
|
232
232
|
--c-warn: #F5B944;
|
|
233
233
|
--c-warn-alt: #FFB300;
|
|
234
234
|
--c-danger: #F0566A;
|
|
235
235
|
--c-purple: #A78BFA;
|
|
236
236
|
|
|
237
|
-
--color-bg: #
|
|
238
|
-
--color-panel: #
|
|
239
|
-
--color-surface: #
|
|
240
|
-
--color-border: #
|
|
241
|
-
--color-text: #
|
|
242
|
-
--color-text-primary: #
|
|
243
|
-
--color-text-secondary: #
|
|
244
|
-
--color-text-muted: #
|
|
245
|
-
--color-text-dim: #
|
|
246
|
-
--color-accent: #
|
|
237
|
+
--color-bg: #0B0B0C;
|
|
238
|
+
--color-panel: #161618;
|
|
239
|
+
--color-surface: #101012;
|
|
240
|
+
--color-border: #29292C;
|
|
241
|
+
--color-text: #EDEBE6;
|
|
242
|
+
--color-text-primary: #EDEBE6;
|
|
243
|
+
--color-text-secondary: #CBC9C2;
|
|
244
|
+
--color-text-muted: #96948D;
|
|
245
|
+
--color-text-dim: #66655F;
|
|
246
|
+
--color-accent: #F34E1C;
|
|
247
247
|
--color-success: #4CC38A;
|
|
248
248
|
--color-warning: #F5B944;
|
|
249
249
|
--color-danger: #F0566A;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session theme override — host-default theming.
|
|
3
|
+
*
|
|
4
|
+
* Hosts that embed the workbench (the GitHub Copilot panel, the Claude Code
|
|
5
|
+
* desktop browser pane, ChatGPT-style app browsers, …) can open it with
|
|
6
|
+
* `?theme=<name>` or `?theme=auto` to give THEIR panel a fitting default
|
|
7
|
+
* without touching the server-global theme every other client sees. The
|
|
8
|
+
* override is client-local: server theme frames are ignored while it is
|
|
9
|
+
* active, and it ends the moment the user explicitly picks a theme from the
|
|
10
|
+
* picker (which then applies + saves globally as usual).
|
|
11
|
+
*
|
|
12
|
+
* `theme=auto` follows the host's prefers-color-scheme live, mapping to the
|
|
13
|
+
* built-in light/dark themes — so a light-mode host panel gets a light canvas
|
|
14
|
+
* by default and flips with the host appearance.
|
|
15
|
+
*/
|
|
16
|
+
import { type CanvasThemeName } from '../../shared/themes.js';
|
|
17
|
+
export declare function themeOverrideActive(): boolean;
|
|
18
|
+
/** Test-only: reset the module state, including the page-lifetime latch. */
|
|
19
|
+
export declare function resetThemeOverrideForTests(): void;
|
|
20
|
+
/** The user picked a theme explicitly — the session override ends for good. */
|
|
21
|
+
export declare function clearThemeOverride(): void;
|
|
22
|
+
export declare function sessionThemeParam(): CanvasThemeName | 'auto' | null;
|
|
23
|
+
/**
|
|
24
|
+
* Activate the override from the URL (no-op without a valid `?theme=`).
|
|
25
|
+
* `apply` is the canvas theme applier (client-side only — never saved).
|
|
26
|
+
*/
|
|
27
|
+
export declare function initSessionThemeOverride(apply: (theme: CanvasThemeName) => void): void;
|
package/docs/http-api.md
CHANGED
|
@@ -345,6 +345,14 @@ produces no event within 3 seconds of connecting, it switches to polling.
|
|
|
345
345
|
Force a transport with `/workbench?transport=poll` (or `transport=sse` to
|
|
346
346
|
disable the fallback).
|
|
347
347
|
|
|
348
|
+
## Session theme override (host-default theming)
|
|
349
|
+
|
|
350
|
+
`/workbench?theme=<name>` gives that browser session its own theme without
|
|
351
|
+
touching the server-global theme other clients see — embedding hosts use it to
|
|
352
|
+
match their chrome (the bundled Copilot extension opens `?theme=light`).
|
|
353
|
+
`?theme=auto` follows the host's `prefers-color-scheme` live. Picking a theme
|
|
354
|
+
from the toolbar ends the override and saves globally as usual.
|
|
355
|
+
|
|
348
356
|
## Iframe embed probe (nested-iframe hosts)
|
|
349
357
|
|
|
350
358
|
Some hosts embed the canvas page itself inside an iframe (the Amp orb portal
|
|
@@ -74,5 +74,7 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
|
|
|
74
74
|
|---|---|
|
|
75
75
|
| Finding S — frame documents vs server restart | **Done + field-confirmed (0.4.1 reports, both hosts):** the browser revalidates minted frame-document URLs on every reconnect epoch (`workbenchConnectionEpoch`, HEAD probe) and re-mints against the new process when the store is gone; `/api/canvas/frame-documents/:id` answers HEAD. Copilot: IDs fully rotated, all post-restart 200, no frame-not-found tiles. Codex: re-mint without workbench reload. Server-side persistence deliberately NOT done — frames are client-derived and re-mintable. |
|
|
76
76
|
| Amp portal nested-iframe embedding | **Done (2026-08-04):** boot-time embed probe (`/api/canvas/iframe-probe`) + fetch()+srcdoc fallback for all same-origin surfaces (`useSurfaceFrame`, `useIframeDocument` srcdoc short-circuit), `?iframe-mode` override, e2e-forced-mode coverage. External (cross-origin) app URLs cannot be inlined — documented limitation. |
|
|
77
|
-
| Copilot adapter `canvas_snapshot list` descriptor lag | Host-side cached tool schema still requires `name`; fresh `--mcp` is correct. Adapter/packaging issue, not a daemon defect — nothing to change in core;
|
|
77
|
+
| Copilot adapter `canvas_snapshot list` descriptor lag | Host-side cached tool schema still requires `name`; fresh `--mcp` is correct. Adapter/packaging issue, not a daemon defect — nothing to change in core; 0.4.2 cycle saw the legacy tools gone from the host list after refresh. |
|
|
78
|
+
| Finding T — theme/More menu mis-anchored | **Done (2026-08-05):** menus moved inside `.toolbar-menu-anchor` wrappers around their triggers (the group-level `position:relative` + `right:0` anchored them ~212px right of the button, both hosts). Anchor-geometry e2e regression tests added. |
|
|
79
|
+
| Finding U — sticky `-32001` session-error banner over rendering content | **Mitigated (2026-08-05), root cause open:** when an ext-app session errors but the cached document still renders, the banner is now a soft degraded-interactivity notice instead of a red alarm. The underlying stressed-reconnect MCP timeout (long-lived tiles through theme churn + daemon respawn) is not clean-reproducible — investigate session re-establishment retry/backoff if the next cycle still hits it. |
|
|
78
80
|
| Registry-migration dead-code sweep | **Done (2026-08-03):** removed the orphaned `describeOp`/`describeGateOp` (composites.ts, dead since the v0.3.0 registration filter) and the `compactNodePayload`/`compactLayoutPayload`/`wantsFullPayload` duplicates in `mcp/server.ts` (live copies are in `ops/nodes.ts`). |
|
package/docs/screenshot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents \u2014 infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json",
|
|
44
44
|
"lint": "biome check .",
|
|
45
45
|
"lint:fix": "biome check --write .",
|
|
46
|
-
"test": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
|
|
46
|
+
"test": "if [ -d tests/unit ]; then PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit; else echo 'tests/ is not shipped in the npm package — clone the repo to run the suite'; fi",
|
|
47
47
|
"test:unit": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
|
|
48
|
-
"test:client": "bun test --preload ./tests/client/setup.ts tests/client",
|
|
48
|
+
"test:client": "if [ -d tests/client ]; then bun test --preload ./tests/client/setup.ts tests/client; else echo 'tests/ is not shipped in the npm package — clone the repo to run the suite'; fi",
|
|
49
49
|
"test:coverage": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json bun test tests/unit --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir coverage && bun scripts/check-coverage.ts",
|
|
50
50
|
"test:web-canvas": "PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun run build && PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bash scripts/run-playwright.sh",
|
|
51
51
|
"test:e2e": "bun run test:web-canvas",
|
|
@@ -181,6 +181,10 @@ Use the visible workbench when the human is actively curating layout:
|
|
|
181
181
|
embedded host panes and mobile) the secondary controls collapse into a single "⋯" More menu at
|
|
182
182
|
the end of the bar; every action is still there as a labeled row, including the theme list. If
|
|
183
183
|
an automation script can't find a toolbar button at panel width, open the More menu first.
|
|
184
|
+
- Embedding hosts can open `/workbench?theme=<name>` (or `?theme=auto` to follow the host's
|
|
185
|
+
light/dark appearance) for a session-local default theme that never changes the server-global
|
|
186
|
+
theme other clients see; an explicit pick from the theme menu ends the override. The bundled
|
|
187
|
+
Copilot extension opens its panel with `?theme=light`.
|
|
184
188
|
|
|
185
189
|
After changing files under `src/client/`, rebuild with `bun run build` before manual browser
|
|
186
190
|
verification.
|
package/src/client/App.tsx
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
IconZoomIn,
|
|
60
60
|
IconZoomOut,
|
|
61
61
|
} from './icons';
|
|
62
|
+
import { clearThemeOverride } from './state/theme-override';
|
|
62
63
|
import { invalidateTokenCache } from './theme/tokens';
|
|
63
64
|
import { MOD_KEY } from './utils/platform';
|
|
64
65
|
import {
|
|
@@ -184,6 +185,9 @@ function Toolbar({
|
|
|
184
185
|
}, [openMenu]);
|
|
185
186
|
|
|
186
187
|
const applyTheme = (next: CanvasThemeName) => {
|
|
188
|
+
// An explicit pick ends any ?theme= session override (host default) and
|
|
189
|
+
// returns this client to the shared server-global theme it just saved.
|
|
190
|
+
clearThemeOverride();
|
|
187
191
|
document.documentElement.setAttribute('data-theme', next);
|
|
188
192
|
invalidateTokenCache();
|
|
189
193
|
canvasTheme.value = next;
|
|
@@ -307,18 +311,27 @@ function Toolbar({
|
|
|
307
311
|
<IconMinimap />
|
|
308
312
|
</button>
|
|
309
313
|
</ToolbarHint>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
314
|
+
{/* Anchor wrapper: the theme menu must open under THIS control, not at
|
|
315
|
+
the toolbar-group's right edge (0.4.2 report Finding T). */}
|
|
316
|
+
<span class="toolbar-menu-anchor">
|
|
317
|
+
<ToolbarHint label="Theme" detail={`Current theme: ${CANVAS_THEME_META[activeTheme].label}`}>
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
onClick={() => setOpenMenu(openMenu === 'theme' ? null : 'theme')}
|
|
321
|
+
aria-label="Choose theme"
|
|
322
|
+
aria-haspopup="menu"
|
|
323
|
+
aria-expanded={openMenu === 'theme'}
|
|
324
|
+
style={{ color: openMenu === 'theme' ? 'var(--c-accent)' : undefined }}
|
|
325
|
+
>
|
|
326
|
+
{canvasThemeScheme(activeTheme) === 'dark' ? <IconSun /> : <IconMoon />}
|
|
327
|
+
</button>
|
|
328
|
+
</ToolbarHint>
|
|
329
|
+
{openMenu === 'theme' && (
|
|
330
|
+
<div class="toolbar-menu" role="menu" aria-label="Theme">
|
|
331
|
+
{themeMenuItems}
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
</span>
|
|
322
335
|
<ToolbarHint label="Snapshots" detail="Capture and restore canvas states" align="end">
|
|
323
336
|
<button
|
|
324
337
|
ref={snapshotBtnRef}
|
|
@@ -333,7 +346,7 @@ function Toolbar({
|
|
|
333
346
|
</span>
|
|
334
347
|
|
|
335
348
|
{/* Narrow screens: everything collapsed lives in this single menu. */}
|
|
336
|
-
<span class="toolbar-more">
|
|
349
|
+
<span class="toolbar-more toolbar-menu-anchor">
|
|
337
350
|
<ToolbarHint label="More" detail="All canvas actions" align="end">
|
|
338
351
|
<button
|
|
339
352
|
type="button"
|
|
@@ -348,6 +361,65 @@ function Toolbar({
|
|
|
348
361
|
</span>
|
|
349
362
|
</button>
|
|
350
363
|
</ToolbarHint>
|
|
364
|
+
{openMenu === 'more' && (
|
|
365
|
+
<div class="toolbar-menu" role="menu" aria-label="Canvas actions">
|
|
366
|
+
<button
|
|
367
|
+
type="button"
|
|
368
|
+
class="toolbar-menu-item"
|
|
369
|
+
onClick={menuAction(() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange()))}
|
|
370
|
+
>
|
|
371
|
+
<IconArrange />
|
|
372
|
+
<span>Arrange layout</span>
|
|
373
|
+
</button>
|
|
374
|
+
<button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleMinimap)}>
|
|
375
|
+
<IconMinimap />
|
|
376
|
+
<span>{minimapVisible ? 'Hide minimap' : 'Show minimap'}</span>
|
|
377
|
+
</button>
|
|
378
|
+
<button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleSnapshot)}>
|
|
379
|
+
<IconSnapshot />
|
|
380
|
+
<span>Snapshots</span>
|
|
381
|
+
</button>
|
|
382
|
+
<div class="toolbar-menu-separator" />
|
|
383
|
+
<button
|
|
384
|
+
type="button"
|
|
385
|
+
class="toolbar-menu-item"
|
|
386
|
+
onClick={menuAction(() => sendIntent('trace-toggle', { enabled: !isTraceOn }))}
|
|
387
|
+
>
|
|
388
|
+
<IconTrace />
|
|
389
|
+
<span>{isTraceOn ? 'Disable trace' : 'Enable trace'}</span>
|
|
390
|
+
</button>
|
|
391
|
+
{(isTraceOn || traceNodeCount > 0) && (
|
|
392
|
+
<button type="button" class="toolbar-menu-item" onClick={menuAction(() => sendIntent('trace-clear'))}>
|
|
393
|
+
<IconClearTrace />
|
|
394
|
+
<span>Clear trace</span>
|
|
395
|
+
</button>
|
|
396
|
+
)}
|
|
397
|
+
<button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleAnnotationMode)}>
|
|
398
|
+
<IconPen />
|
|
399
|
+
<span>{annotationTool === 'pen' ? 'Stop annotating' : 'Annotate canvas'}</span>
|
|
400
|
+
</button>
|
|
401
|
+
<button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleAnnotationEraser)}>
|
|
402
|
+
<IconEraser />
|
|
403
|
+
<span>{annotationTool === 'eraser' ? 'Stop erasing' : 'Erase annotations'}</span>
|
|
404
|
+
</button>
|
|
405
|
+
<button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleTextAnnotation)}>
|
|
406
|
+
<IconTextAnnotation />
|
|
407
|
+
<span>{annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}</span>
|
|
408
|
+
</button>
|
|
409
|
+
<div class="toolbar-menu-separator" />
|
|
410
|
+
<button type="button" class="toolbar-menu-item" onClick={menuAction(onOpenPalette)}>
|
|
411
|
+
<IconSearch />
|
|
412
|
+
<span>Search nodes and actions</span>
|
|
413
|
+
</button>
|
|
414
|
+
<button type="button" class="toolbar-menu-item" onClick={menuAction(onOpenShortcuts)}>
|
|
415
|
+
<IconShortcuts />
|
|
416
|
+
<span>Keyboard shortcuts</span>
|
|
417
|
+
</button>
|
|
418
|
+
<div class="toolbar-menu-separator" />
|
|
419
|
+
<div class="toolbar-menu-heading">Theme</div>
|
|
420
|
+
{themeMenuItems}
|
|
421
|
+
</div>
|
|
422
|
+
)}
|
|
351
423
|
</span>
|
|
352
424
|
</div>
|
|
353
425
|
|
|
@@ -443,71 +515,6 @@ function Toolbar({
|
|
|
443
515
|
{countsLabel}
|
|
444
516
|
</span>
|
|
445
517
|
</div>
|
|
446
|
-
|
|
447
|
-
{openMenu === 'theme' && (
|
|
448
|
-
<div class="toolbar-menu" role="menu" aria-label="Theme">
|
|
449
|
-
{themeMenuItems}
|
|
450
|
-
</div>
|
|
451
|
-
)}
|
|
452
|
-
{openMenu === 'more' && (
|
|
453
|
-
<div class="toolbar-menu" role="menu" aria-label="Canvas actions">
|
|
454
|
-
<button
|
|
455
|
-
type="button"
|
|
456
|
-
class="toolbar-menu-item"
|
|
457
|
-
onClick={menuAction(() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange()))}
|
|
458
|
-
>
|
|
459
|
-
<IconArrange />
|
|
460
|
-
<span>Arrange layout</span>
|
|
461
|
-
</button>
|
|
462
|
-
<button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleMinimap)}>
|
|
463
|
-
<IconMinimap />
|
|
464
|
-
<span>{minimapVisible ? 'Hide minimap' : 'Show minimap'}</span>
|
|
465
|
-
</button>
|
|
466
|
-
<button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleSnapshot)}>
|
|
467
|
-
<IconSnapshot />
|
|
468
|
-
<span>Snapshots</span>
|
|
469
|
-
</button>
|
|
470
|
-
<div class="toolbar-menu-separator" />
|
|
471
|
-
<button
|
|
472
|
-
type="button"
|
|
473
|
-
class="toolbar-menu-item"
|
|
474
|
-
onClick={menuAction(() => sendIntent('trace-toggle', { enabled: !isTraceOn }))}
|
|
475
|
-
>
|
|
476
|
-
<IconTrace />
|
|
477
|
-
<span>{isTraceOn ? 'Disable trace' : 'Enable trace'}</span>
|
|
478
|
-
</button>
|
|
479
|
-
{(isTraceOn || traceNodeCount > 0) && (
|
|
480
|
-
<button type="button" class="toolbar-menu-item" onClick={menuAction(() => sendIntent('trace-clear'))}>
|
|
481
|
-
<IconClearTrace />
|
|
482
|
-
<span>Clear trace</span>
|
|
483
|
-
</button>
|
|
484
|
-
)}
|
|
485
|
-
<button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleAnnotationMode)}>
|
|
486
|
-
<IconPen />
|
|
487
|
-
<span>{annotationTool === 'pen' ? 'Stop annotating' : 'Annotate canvas'}</span>
|
|
488
|
-
</button>
|
|
489
|
-
<button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleAnnotationEraser)}>
|
|
490
|
-
<IconEraser />
|
|
491
|
-
<span>{annotationTool === 'eraser' ? 'Stop erasing' : 'Erase annotations'}</span>
|
|
492
|
-
</button>
|
|
493
|
-
<button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleTextAnnotation)}>
|
|
494
|
-
<IconTextAnnotation />
|
|
495
|
-
<span>{annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}</span>
|
|
496
|
-
</button>
|
|
497
|
-
<div class="toolbar-menu-separator" />
|
|
498
|
-
<button type="button" class="toolbar-menu-item" onClick={menuAction(onOpenPalette)}>
|
|
499
|
-
<IconSearch />
|
|
500
|
-
<span>Search nodes and actions</span>
|
|
501
|
-
</button>
|
|
502
|
-
<button type="button" class="toolbar-menu-item" onClick={menuAction(onOpenShortcuts)}>
|
|
503
|
-
<IconShortcuts />
|
|
504
|
-
<span>Keyboard shortcuts</span>
|
|
505
|
-
</button>
|
|
506
|
-
<div class="toolbar-menu-separator" />
|
|
507
|
-
<div class="toolbar-menu-heading">Theme</div>
|
|
508
|
-
{themeMenuItems}
|
|
509
|
-
</div>
|
|
510
|
-
)}
|
|
511
518
|
</div>
|
|
512
519
|
);
|
|
513
520
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { createNodeFromClient, saveCanvasTheme } from '../state/intent-bridge';
|
|
12
12
|
import { TYPE_LABELS, type CanvasNodeState } from '../types';
|
|
13
13
|
import { invalidateTokenCache } from '../theme/tokens';
|
|
14
|
+
import { clearThemeOverride } from '../state/theme-override';
|
|
14
15
|
|
|
15
16
|
import { MOD_KEY } from '../utils/platform';
|
|
16
17
|
|
|
@@ -174,6 +175,9 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
|
|
|
174
175
|
badge: 'THEME',
|
|
175
176
|
action: () => {
|
|
176
177
|
const next = canvasTheme.value === 'dark' ? 'light' : 'dark';
|
|
178
|
+
// An explicit pick ends any ?theme= session override, same as the
|
|
179
|
+
// toolbar picker — otherwise the session stays sticky-overridden.
|
|
180
|
+
clearThemeOverride();
|
|
177
181
|
document.documentElement.setAttribute('data-theme', next);
|
|
178
182
|
invalidateTokenCache();
|
|
179
183
|
canvasTheme.value = next;
|
|
@@ -528,9 +528,17 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
528
528
|
latestToolInputRef.current = toolInput;
|
|
529
529
|
latestToolResultRef.current = toolResult;
|
|
530
530
|
|
|
531
|
+
// Finding U (0.4.2 report): a stressed reconnect can leave sessionStatus
|
|
532
|
+
// 'error' (e.g. "MCP error -32001: Request timed out") while the cached app
|
|
533
|
+
// document still renders fine below. When content IS available, present the
|
|
534
|
+
// error as a degraded-interactivity notice, not a full-alarm banner over a
|
|
535
|
+
// working diagram.
|
|
536
|
+
const sessionErrorWithContent = sessionStatus === 'error' && typeof html === 'string' && html.length > 0;
|
|
531
537
|
const sessionUnavailableMessage =
|
|
532
538
|
sessionStatus === 'error'
|
|
533
|
-
?
|
|
539
|
+
? sessionErrorWithContent
|
|
540
|
+
? `Live session unavailable — showing saved content. Reopen the app to restore interactivity.${sessionError ? ` (${sessionError})` : ''}`
|
|
541
|
+
: (sessionError ?? 'Saved app session is unavailable. Reopen the app to restore interactivity.')
|
|
534
542
|
: 'Reconnecting saved app session...';
|
|
535
543
|
|
|
536
544
|
const flushToolResult = (bridge: AppBridge | null): Promise<void> | null => {
|
|
@@ -1040,9 +1048,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1040
1048
|
style={{
|
|
1041
1049
|
padding: '6px 10px',
|
|
1042
1050
|
fontSize: '11px',
|
|
1043
|
-
background:
|
|
1044
|
-
|
|
1045
|
-
|
|
1051
|
+
background:
|
|
1052
|
+
sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger-12)' : 'var(--c-warn-10)',
|
|
1053
|
+
color: sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger)' : 'var(--c-warn)',
|
|
1054
|
+
borderBottom: `1px solid ${
|
|
1055
|
+
sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger-12)' : 'var(--c-warn-15)'
|
|
1056
|
+
}`,
|
|
1046
1057
|
}}
|
|
1047
1058
|
>
|
|
1048
1059
|
{sessionUnavailableMessage}
|
|
@@ -3,6 +3,7 @@ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
|
3
3
|
import { canvasThemeScheme } from '../../shared/themes.js';
|
|
4
4
|
import type { CanvasNodeState } from '../types';
|
|
5
5
|
import { axSurfaceState, canvasTheme } from '../state/canvas-store';
|
|
6
|
+
import { iframeMode } from '../state/iframe-mode';
|
|
6
7
|
import { shouldContentFitIframeNode } from '../canvas/auto-fit';
|
|
7
8
|
import { ExtAppFrame } from './ExtAppFrame';
|
|
8
9
|
import { useAxSurfaceBridge } from './use-ax-surface-bridge';
|
|
@@ -185,7 +186,11 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
185
186
|
class="mcp-app-frame"
|
|
186
187
|
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
187
188
|
allow="clipboard-read; clipboard-write"
|
|
188
|
-
loading
|
|
189
|
+
// Lazy loading only helps src mode (defers the HTTP request for
|
|
190
|
+
// off-screen nodes). In srcdoc mode the content is already inline —
|
|
191
|
+
// lazy would just delay PAINTING until the node scrolls near the
|
|
192
|
+
// viewport, which reads as "blank until opened" (Amp portal report).
|
|
193
|
+
loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
|
|
189
194
|
onLoad={pushAxState}
|
|
190
195
|
style={{ flex: 1, minHeight: 0, width: '100%' }}
|
|
191
196
|
title={`MCP App: ${sourceServer}`}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
workbenchConnectionEpoch,
|
|
27
27
|
} from './canvas-store';
|
|
28
28
|
import { fetchAxSurfaceState } from './intent-bridge';
|
|
29
|
+
import { initSessionThemeOverride, themeOverrideActive } from './theme-override';
|
|
29
30
|
import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
|
|
30
31
|
import { invalidateTokenCache } from '../theme/tokens';
|
|
31
32
|
import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
|
|
@@ -399,7 +400,9 @@ function handleConnected(data: Record<string, unknown>): void {
|
|
|
399
400
|
connectionStatus.value = 'connected';
|
|
400
401
|
// Reconnect marker for holders of server-minted URLs (Finding S).
|
|
401
402
|
workbenchConnectionEpoch.value += 1;
|
|
402
|
-
|
|
403
|
+
// A ?theme= session override (host-default theming) wins over the
|
|
404
|
+
// server-global theme for THIS client only.
|
|
405
|
+
if (typeof data.theme === 'string' && !themeOverrideActive()) {
|
|
403
406
|
applyCanvasTheme(data.theme);
|
|
404
407
|
}
|
|
405
408
|
if (data.ledgerSummary) {
|
|
@@ -890,7 +893,7 @@ function handleTraceState(data: Record<string, unknown>): void {
|
|
|
890
893
|
}
|
|
891
894
|
|
|
892
895
|
function handleThemeChanged(data: Record<string, unknown>): void {
|
|
893
|
-
if (typeof data.theme === 'string') {
|
|
896
|
+
if (typeof data.theme === 'string' && !themeOverrideActive()) {
|
|
894
897
|
applyCanvasTheme(data.theme);
|
|
895
898
|
}
|
|
896
899
|
}
|
|
@@ -1082,6 +1085,9 @@ function startPollingTransport(): () => void {
|
|
|
1082
1085
|
}
|
|
1083
1086
|
|
|
1084
1087
|
export function connectSSE(): () => void {
|
|
1088
|
+
// Host-default theming: a ?theme= param applies before the first server
|
|
1089
|
+
// frame so an embedding host panel never flashes the server-global theme.
|
|
1090
|
+
initSessionThemeOverride(applyCanvasTheme);
|
|
1085
1091
|
savedLayout = restoreLayout();
|
|
1086
1092
|
ensureStatusNode();
|
|
1087
1093
|
hasInitialServerLayout.value = false;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session theme override — host-default theming.
|
|
3
|
+
*
|
|
4
|
+
* Hosts that embed the workbench (the GitHub Copilot panel, the Claude Code
|
|
5
|
+
* desktop browser pane, ChatGPT-style app browsers, …) can open it with
|
|
6
|
+
* `?theme=<name>` or `?theme=auto` to give THEIR panel a fitting default
|
|
7
|
+
* without touching the server-global theme every other client sees. The
|
|
8
|
+
* override is client-local: server theme frames are ignored while it is
|
|
9
|
+
* active, and it ends the moment the user explicitly picks a theme from the
|
|
10
|
+
* picker (which then applies + saves globally as usual).
|
|
11
|
+
*
|
|
12
|
+
* `theme=auto` follows the host's prefers-color-scheme live, mapping to the
|
|
13
|
+
* built-in light/dark themes — so a light-mode host panel gets a light canvas
|
|
14
|
+
* by default and flips with the host appearance.
|
|
15
|
+
*/
|
|
16
|
+
import { type CanvasThemeName, isCanvasTheme } from '../../shared/themes.js';
|
|
17
|
+
|
|
18
|
+
let active = false;
|
|
19
|
+
// Latched when the user explicitly ends the override: the URL param survives
|
|
20
|
+
// reconnects (connectSSE re-inits on every transport drop), but the user's
|
|
21
|
+
// choice must stick for the page lifetime — only a reload re-activates.
|
|
22
|
+
let userEnded = false;
|
|
23
|
+
let schemeQuery: MediaQueryList | null = null;
|
|
24
|
+
let schemeListener: (() => void) | null = null;
|
|
25
|
+
|
|
26
|
+
export function themeOverrideActive(): boolean {
|
|
27
|
+
return active;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Test-only: reset the module state, including the page-lifetime latch. */
|
|
31
|
+
export function resetThemeOverrideForTests(): void {
|
|
32
|
+
active = false;
|
|
33
|
+
userEnded = false;
|
|
34
|
+
detachSchemeListener();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The user picked a theme explicitly — the session override ends for good. */
|
|
38
|
+
export function clearThemeOverride(): void {
|
|
39
|
+
active = false;
|
|
40
|
+
userEnded = true;
|
|
41
|
+
detachSchemeListener();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function detachSchemeListener(): void {
|
|
45
|
+
if (schemeQuery && schemeListener) {
|
|
46
|
+
schemeQuery.removeEventListener('change', schemeListener);
|
|
47
|
+
}
|
|
48
|
+
schemeQuery = null;
|
|
49
|
+
schemeListener = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function sessionThemeParam(): CanvasThemeName | 'auto' | null {
|
|
53
|
+
// DOM-less callers (server-side rendering of the module graph, unit tests
|
|
54
|
+
// that import the sse-bridge without a DOM) have no override.
|
|
55
|
+
if (typeof window === 'undefined' || !window.location) return null;
|
|
56
|
+
const value = new URLSearchParams(window.location.search).get('theme');
|
|
57
|
+
if (value === 'auto') return 'auto';
|
|
58
|
+
return isCanvasTheme(value) ? value : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Activate the override from the URL (no-op without a valid `?theme=`).
|
|
63
|
+
* `apply` is the canvas theme applier (client-side only — never saved).
|
|
64
|
+
*/
|
|
65
|
+
export function initSessionThemeOverride(apply: (theme: CanvasThemeName) => void): void {
|
|
66
|
+
if (userEnded) return;
|
|
67
|
+
const param = sessionThemeParam();
|
|
68
|
+
if (!param) return;
|
|
69
|
+
// Re-init happens on every reconnect — replace any prior listener instead
|
|
70
|
+
// of accumulating one per reconnect cycle.
|
|
71
|
+
detachSchemeListener();
|
|
72
|
+
active = true;
|
|
73
|
+
if (param !== 'auto') {
|
|
74
|
+
apply(param);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
schemeQuery = window.matchMedia('(prefers-color-scheme: light)');
|
|
78
|
+
schemeListener = () => {
|
|
79
|
+
if (active && schemeQuery) apply(schemeQuery.matches ? 'light' : 'dark');
|
|
80
|
+
};
|
|
81
|
+
schemeListener();
|
|
82
|
+
schemeQuery.addEventListener('change', schemeListener);
|
|
83
|
+
}
|