pmx-canvas 0.2.0 → 0.2.2

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/Readme.md +2 -2
  3. package/dist/canvas/global.css +260 -0
  4. package/dist/canvas/index.js +76 -76
  5. package/dist/json-render/index.js +2 -2
  6. package/dist/types/client/canvas/IntentLayer.d.ts +1 -0
  7. package/dist/types/client/state/intent-bridge.d.ts +10 -0
  8. package/dist/types/client/state/intent-store.d.ts +25 -0
  9. package/dist/types/json-render/server.d.ts +1 -1
  10. package/dist/types/server/ax-state-manager.d.ts +11 -0
  11. package/dist/types/server/ax-state.d.ts +2 -0
  12. package/dist/types/server/canvas-db.d.ts +13 -0
  13. package/dist/types/server/canvas-state.d.ts +5 -0
  14. package/dist/types/server/index.d.ts +34 -4
  15. package/dist/types/server/intent-registry.d.ts +45 -0
  16. package/dist/types/server/operations/ops/intent.d.ts +2 -0
  17. package/dist/types/shared/ax-intent.d.ts +58 -0
  18. package/docs/ax-host-adapter-contract.md +19 -1
  19. package/docs/http-api.md +4 -0
  20. package/docs/mcp.md +22 -3
  21. package/docs/screenshot.png +0 -0
  22. package/package.json +1 -1
  23. package/skills/pmx-canvas/SKILL.md +197 -1283
  24. package/skills/pmx-canvas/evals/evals.json +199 -0
  25. package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
  26. package/skills/pmx-canvas/references/full-reference.md +1441 -0
  27. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +23 -7
  28. package/src/cli/index.ts +21 -4
  29. package/src/client/canvas/CanvasNode.tsx +13 -13
  30. package/src/client/canvas/CanvasViewport.tsx +2 -0
  31. package/src/client/canvas/ContextMenu.tsx +25 -19
  32. package/src/client/canvas/IntentLayer.tsx +278 -0
  33. package/src/client/nodes/ExtAppFrame.tsx +31 -22
  34. package/src/client/state/intent-bridge.ts +31 -0
  35. package/src/client/state/intent-store.ts +107 -0
  36. package/src/client/state/sse-bridge.ts +31 -0
  37. package/src/client/theme/global.css +260 -0
  38. package/src/json-render/charts/components.tsx +18 -4
  39. package/src/json-render/renderer/index.tsx +11 -2
  40. package/src/json-render/server.ts +1 -1
  41. package/src/server/ax-context.ts +8 -1
  42. package/src/server/ax-state-manager.ts +18 -0
  43. package/src/server/ax-state.ts +8 -0
  44. package/src/server/canvas-db.ts +35 -0
  45. package/src/server/canvas-state.ts +8 -0
  46. package/src/server/index.ts +240 -158
  47. package/src/server/intent-registry.ts +324 -0
  48. package/src/server/operations/composites.ts +11 -0
  49. package/src/server/operations/index.ts +2 -0
  50. package/src/server/operations/ops/edges.ts +1 -0
  51. package/src/server/operations/ops/groups.ts +3 -0
  52. package/src/server/operations/ops/intent.ts +132 -0
  53. package/src/server/operations/ops/json-render.ts +3 -0
  54. package/src/server/operations/ops/nodes.ts +3 -0
  55. package/src/server/operations/registry.ts +68 -3
  56. package/src/server/server.ts +40 -12
  57. package/src/shared/ax-intent.ts +64 -0
  58. package/src/shared/surface.ts +5 -1
@@ -0,0 +1,107 @@
1
+ import { signal } from '@preact/signals';
2
+ import type { PmxAxIntent } from '../../shared/ax-intent.js';
3
+
4
+ /**
5
+ * Client-side store for the Ghost Cursor of Intent. Ghosts are ephemeral
6
+ * presence pushed over SSE (`ax-intent` / `ax-intent-clear`); this store mirrors
7
+ * them into a signal the IntentLayer renders, tracks a short exit phase so
8
+ * settle/dissolve can animate, and prunes anything the server's TTL frame did
9
+ * not reach (SSE backstop). Nothing here is ever persisted.
10
+ */
11
+
12
+ export type IntentPhase = 'forming' | 'settling' | 'dissolving';
13
+
14
+ export interface ClientIntent extends PmxAxIntent {
15
+ phase: IntentPhase;
16
+ /** The real node a settled intent became — seeds the settle morph. */
17
+ settledNodeId?: string;
18
+ }
19
+
20
+ export const intents = signal<Map<string, ClientIntent>>(new Map());
21
+ /** The ghost currently hovered — drives Esc-to-veto. */
22
+ export const hoveredIntentId = signal<string | null>(null);
23
+
24
+ const SETTLE_MS = 480;
25
+ const DISSOLVE_MS = 320;
26
+
27
+ const exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
28
+ let pruneTimer: ReturnType<typeof setInterval> | null = null;
29
+
30
+ function writeIntents(next: Map<string, ClientIntent>): void {
31
+ intents.value = next;
32
+ ensurePrune();
33
+ }
34
+
35
+ function clearExitTimer(id: string): void {
36
+ const timer = exitTimers.get(id);
37
+ if (timer) {
38
+ clearTimeout(timer);
39
+ exitTimers.delete(id);
40
+ }
41
+ }
42
+
43
+ /** A live `ax-intent` frame: (re)place the ghost in its forming state. */
44
+ export function upsertIntent(intent: PmxAxIntent): void {
45
+ clearExitTimer(intent.id);
46
+ const next = new Map(intents.value);
47
+ next.set(intent.id, { ...intent, phase: 'forming' });
48
+ writeIntents(next);
49
+ }
50
+
51
+ export function removeIntent(id: string): void {
52
+ clearExitTimer(id);
53
+ if (!intents.value.has(id)) return;
54
+ const next = new Map(intents.value);
55
+ next.delete(id);
56
+ writeIntents(next);
57
+ }
58
+
59
+ function setPhase(id: string, phase: IntentPhase, ms: number, settledNodeId?: string): void {
60
+ const current = intents.value.get(id);
61
+ if (!current || current.phase === phase) return;
62
+ const next = new Map(intents.value);
63
+ next.set(id, { ...current, phase, ...(settledNodeId ? { settledNodeId } : {}) });
64
+ writeIntents(next);
65
+ clearExitTimer(id);
66
+ exitTimers.set(id, setTimeout(() => removeIntent(id), ms));
67
+ }
68
+
69
+ /** Resolve a ghost into a real node — the settle morph, then removal. */
70
+ export function settleIntent(id: string, settledNodeId?: string): void {
71
+ setPhase(id, 'settling', SETTLE_MS, settledNodeId);
72
+ }
73
+
74
+ /** Dissolve a ghost (expired / vetoed / evicted / abandoned), then remove it. */
75
+ export function dissolveIntent(id: string): void {
76
+ setPhase(id, 'dissolving', DISSOLVE_MS);
77
+ }
78
+
79
+ export function resetIntents(): void {
80
+ for (const timer of exitTimers.values()) clearTimeout(timer);
81
+ exitTimers.clear();
82
+ if (pruneTimer) {
83
+ clearInterval(pruneTimer);
84
+ pruneTimer = null;
85
+ }
86
+ hoveredIntentId.value = null;
87
+ intents.value = new Map();
88
+ }
89
+
90
+ // SSE backstop: if a clear frame is dropped, expired forming ghosts still go
91
+ // away on their own TTL. Runs only while ghosts are present.
92
+ function ensurePrune(): void {
93
+ if (pruneTimer || intents.value.size === 0) return;
94
+ pruneTimer = setInterval(() => {
95
+ const now = Date.now();
96
+ for (const intent of intents.value.values()) {
97
+ if (intent.phase === 'forming' && intent.expiresAt <= now) {
98
+ dissolveIntent(intent.id);
99
+ }
100
+ }
101
+ if (intents.value.size === 0 && pruneTimer) {
102
+ clearInterval(pruneTimer);
103
+ pruneTimer = null;
104
+ }
105
+ }, 1000);
106
+ (pruneTimer as { unref?: () => void }).unref?.();
107
+ }
@@ -28,6 +28,8 @@ import {
28
28
  import { fetchAxSurfaceState } from './intent-bridge';
29
29
  import { invalidateTokenCache } from '../theme/tokens';
30
30
  import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
31
+ import { dissolveIntent, resetIntents, settleIntent, upsertIntent } from './intent-store';
32
+ import type { PmxAxIntent } from '../../shared/ax-intent.js';
31
33
 
32
34
  let eventSource: EventSource | null = null;
33
35
  let savedLayout: Map<string, Partial<CanvasNodeState>> | null = null;
@@ -941,6 +943,32 @@ function handleAxStateChanged(): void {
941
943
  }, 150);
942
944
  }
943
945
 
946
+ // ── Ghost Cursor of Intent ────────────────────────────────────
947
+ function handleAxIntent(data: Record<string, unknown>): void {
948
+ const intent = data.intent as PmxAxIntent | undefined;
949
+ // Require a numeric `expiresAt`: the client-side TTL prune backstop
950
+ // (intent-store) compares `expiresAt <= now`, so a frame missing it would never
951
+ // be pruned if its `clear` frame were dropped. The server always sets it, so this
952
+ // only rejects a malformed frame — keeping the backstop's guarantee real.
953
+ if (
954
+ !intent
955
+ || typeof intent.id !== 'string'
956
+ || typeof intent.kind !== 'string'
957
+ || typeof intent.expiresAt !== 'number'
958
+ ) return;
959
+ upsertIntent(intent);
960
+ }
961
+
962
+ function handleAxIntentClear(data: Record<string, unknown>): void {
963
+ const id = typeof data.id === 'string' ? data.id : '';
964
+ if (!id) return;
965
+ if (data.settled === true) {
966
+ settleIntent(id, typeof data.nodeId === 'string' ? data.nodeId : undefined);
967
+ } else {
968
+ dissolveIntent(id);
969
+ }
970
+ }
971
+
944
972
  // ── SSE connection ────────────────────────────────────────────
945
973
  /** @internal — exported for testing */
946
974
  export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => void> = {
@@ -976,6 +1004,8 @@ export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => v
976
1004
  'canvas-response-complete': handleCanvasResponseComplete,
977
1005
  'ax-state-changed': handleAxStateChanged,
978
1006
  'ax-event-created': handleAxStateChanged,
1007
+ 'ax-intent': handleAxIntent,
1008
+ 'ax-intent-clear': handleAxIntentClear,
979
1009
  };
980
1010
 
981
1011
  export function connectSSE(): () => void {
@@ -983,6 +1013,7 @@ export function connectSSE(): () => void {
983
1013
  ensureStatusNode();
984
1014
  hasInitialServerLayout.value = false;
985
1015
  resetAttentionBridge();
1016
+ resetIntents();
986
1017
  if (reconnectTimer) {
987
1018
  clearTimeout(reconnectTimer);
988
1019
  reconnectTimer = null;
@@ -3484,3 +3484,263 @@ button.welcome-hint:hover {
3484
3484
  .image-node-zoom-reset:hover {
3485
3485
  background: var(--c-surface-hover);
3486
3486
  }
3487
+
3488
+ /* ── Ghost Cursor of Intent ─────────────────────────────────────
3489
+ Pre-commit presence: faint placeholders for the move the agent is about to
3490
+ make. Lives inside the canvas world transform (positions are world coords).
3491
+ The layer sits above nodes so remove/edit overlays read on top of their
3492
+ target, but is pointer-transparent except the info card + veto control. */
3493
+ .intent-layer {
3494
+ display: contents;
3495
+ }
3496
+
3497
+ .intent-ghost {
3498
+ position: absolute;
3499
+ pointer-events: none;
3500
+ z-index: 100000;
3501
+ transition:
3502
+ left 480ms ease,
3503
+ top 480ms ease,
3504
+ width 480ms ease,
3505
+ height 480ms ease,
3506
+ opacity 180ms ease;
3507
+ }
3508
+
3509
+ /* create / move destination — a dashed ghost node */
3510
+ .intent-ghost-box {
3511
+ border: 1.5px dashed var(--c-accent);
3512
+ border-radius: 10px;
3513
+ background: var(--c-accent-10);
3514
+ box-shadow: 0 0 0 1px var(--c-accent-10), 0 6px 22px var(--c-accent-10);
3515
+ animation: intent-breathe 2.1s ease-in-out infinite;
3516
+ overflow: visible;
3517
+ }
3518
+
3519
+ .intent-ghost-titlebar {
3520
+ display: flex;
3521
+ align-items: center;
3522
+ gap: 6px;
3523
+ padding: 8px 10px;
3524
+ color: var(--c-accent);
3525
+ }
3526
+
3527
+ .intent-ghost-icon {
3528
+ display: inline-flex;
3529
+ align-items: center;
3530
+ color: var(--c-accent);
3531
+ }
3532
+
3533
+ .intent-ghost-badge {
3534
+ font-size: 11px;
3535
+ font-weight: 600;
3536
+ letter-spacing: 0.02em;
3537
+ text-transform: uppercase;
3538
+ color: var(--c-accent);
3539
+ opacity: 0.85;
3540
+ }
3541
+
3542
+ @keyframes intent-breathe {
3543
+ 0%, 100% { box-shadow: 0 0 0 1px var(--c-accent-10), 0 6px 22px var(--c-accent-10); }
3544
+ 50% { box-shadow: 0 0 0 1px var(--c-accent-25), 0 8px 30px var(--c-accent-25); }
3545
+ }
3546
+
3547
+ /* remove — a red crosshatch tombstone over the target */
3548
+ .intent-ghost-remove {
3549
+ border: 1.5px dashed var(--c-danger);
3550
+ border-radius: 10px;
3551
+ background-color: color-mix(in srgb, var(--c-danger) 8%, transparent);
3552
+ background-image: repeating-linear-gradient(
3553
+ 45deg,
3554
+ color-mix(in srgb, var(--c-danger) 22%, transparent) 0,
3555
+ color-mix(in srgb, var(--c-danger) 22%, transparent) 2px,
3556
+ transparent 2px,
3557
+ transparent 9px
3558
+ );
3559
+ }
3560
+
3561
+ /* edit — a shimmer bar over the target */
3562
+ .intent-ghost-edit {
3563
+ border: 1.5px dashed var(--c-accent);
3564
+ border-radius: 10px;
3565
+ background: var(--c-accent-10);
3566
+ overflow: visible;
3567
+ }
3568
+
3569
+ .intent-edit-bar {
3570
+ position: absolute;
3571
+ top: 0;
3572
+ left: 0;
3573
+ height: 3px;
3574
+ width: 100%;
3575
+ background: linear-gradient(90deg, transparent, var(--c-accent), transparent);
3576
+ animation: response-stream-pulse 1.5s ease-in-out infinite;
3577
+ }
3578
+
3579
+ /* connect — info card anchored at the bezier midpoint */
3580
+ .intent-ghost-connect {
3581
+ display: flex;
3582
+ justify-content: center;
3583
+ }
3584
+
3585
+ /* the info treatment: label + confidence chip, reason, seq, veto */
3586
+ .intent-info {
3587
+ position: absolute;
3588
+ top: 100%;
3589
+ left: 0;
3590
+ margin-top: 6px;
3591
+ display: flex;
3592
+ flex-direction: column;
3593
+ gap: 4px;
3594
+ pointer-events: auto;
3595
+ max-width: 280px;
3596
+ }
3597
+
3598
+ .intent-ghost-connect .intent-info {
3599
+ position: static;
3600
+ margin-top: 0;
3601
+ align-items: center;
3602
+ }
3603
+
3604
+ .intent-chip {
3605
+ display: inline-flex;
3606
+ align-items: center;
3607
+ gap: 6px;
3608
+ align-self: flex-start;
3609
+ padding: 3px 6px 3px 8px;
3610
+ border-radius: 999px;
3611
+ background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
3612
+ backdrop-filter: blur(10px);
3613
+ border: 1px solid var(--c-accent-25);
3614
+ box-shadow: 0 4px 14px var(--c-shadow);
3615
+ color: var(--c-text);
3616
+ font-size: 12px;
3617
+ line-height: 1.2;
3618
+ }
3619
+
3620
+ .intent-seq {
3621
+ display: inline-flex;
3622
+ align-items: center;
3623
+ justify-content: center;
3624
+ min-width: 16px;
3625
+ height: 16px;
3626
+ padding: 0 4px;
3627
+ border-radius: 999px;
3628
+ background: var(--c-accent);
3629
+ color: var(--c-bg);
3630
+ font-size: 10px;
3631
+ font-weight: 700;
3632
+ }
3633
+
3634
+ .intent-chip-icon {
3635
+ display: inline-flex;
3636
+ align-items: center;
3637
+ color: var(--c-accent);
3638
+ }
3639
+
3640
+ .intent-chip-label {
3641
+ font-weight: 600;
3642
+ white-space: nowrap;
3643
+ overflow: hidden;
3644
+ text-overflow: ellipsis;
3645
+ max-width: 180px;
3646
+ }
3647
+
3648
+ .intent-confidence {
3649
+ font-size: 10px;
3650
+ font-variant-numeric: tabular-nums;
3651
+ color: var(--c-muted);
3652
+ }
3653
+
3654
+ .intent-veto {
3655
+ display: inline-flex;
3656
+ align-items: center;
3657
+ justify-content: center;
3658
+ width: 16px;
3659
+ height: 16px;
3660
+ margin-left: 2px;
3661
+ padding: 0;
3662
+ border: none;
3663
+ border-radius: 999px;
3664
+ background: transparent;
3665
+ color: var(--c-muted);
3666
+ font-size: 11px;
3667
+ cursor: pointer;
3668
+ transition: background 120ms ease, color 120ms ease;
3669
+ }
3670
+
3671
+ .intent-veto:hover {
3672
+ background: color-mix(in srgb, var(--c-danger) 18%, transparent);
3673
+ color: var(--c-danger);
3674
+ }
3675
+
3676
+ .intent-reason {
3677
+ align-self: flex-start;
3678
+ padding: 3px 8px;
3679
+ border-radius: 7px;
3680
+ background: color-mix(in srgb, var(--c-panel-glass) 92%, transparent);
3681
+ border: 1px solid var(--c-line);
3682
+ color: var(--c-muted);
3683
+ font-size: 11px;
3684
+ line-height: 1.35;
3685
+ }
3686
+
3687
+ /* connect bezier + move trail (SVG) */
3688
+ .intent-line-layer path {
3689
+ fill: none;
3690
+ }
3691
+
3692
+ .intent-line-layer {
3693
+ z-index: 99999;
3694
+ }
3695
+
3696
+ .intent-edge {
3697
+ stroke-width: 2;
3698
+ stroke-dasharray: 6 5;
3699
+ animation: intent-dash 0.9s linear infinite;
3700
+ }
3701
+
3702
+ .intent-edge.type-flow { stroke: var(--c-accent); }
3703
+ .intent-edge.type-depends-on { stroke: var(--c-warn); }
3704
+ .intent-edge.type-relation { stroke: var(--c-muted); }
3705
+ .intent-edge.type-references { stroke: var(--c-dim); }
3706
+
3707
+ .intent-trail {
3708
+ stroke: var(--c-accent);
3709
+ stroke-width: 2;
3710
+ stroke-dasharray: 5 5;
3711
+ animation: intent-dash 0.9s linear infinite;
3712
+ }
3713
+
3714
+ .intent-arrow-head {
3715
+ fill: var(--c-accent);
3716
+ }
3717
+
3718
+ @keyframes intent-dash {
3719
+ to { stroke-dashoffset: -22; }
3720
+ }
3721
+
3722
+ /* settle — the ghost becomes real, then clears */
3723
+ .intent-ghost.is-settling {
3724
+ animation: intent-settle 480ms ease forwards;
3725
+ }
3726
+
3727
+ @keyframes intent-settle {
3728
+ 0% { transform: scale(1); }
3729
+ 45% { transform: scale(1.04); border-style: solid; opacity: 1; }
3730
+ 100% { transform: scale(1); opacity: 0; }
3731
+ }
3732
+
3733
+ /* dissolve — abandoned / vetoed / expired */
3734
+ .intent-ghost.is-dissolving {
3735
+ animation: intent-dissolve 320ms ease forwards;
3736
+ }
3737
+
3738
+ @keyframes intent-dissolve {
3739
+ to { transform: scale(0.96); opacity: 0; filter: blur(2px); }
3740
+ }
3741
+
3742
+ /* keep ghosts calm while a node is being dragged */
3743
+ html.is-node-dragging .intent-ghost,
3744
+ html.is-node-dragging .intent-line-layer {
3745
+ opacity: 0.5;
3746
+ }
@@ -118,6 +118,11 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
118
118
  const [autoHeight, setAutoHeight] = useState(fallbackHeight);
119
119
  const [autoWidth, setAutoWidth] = useState(0);
120
120
 
121
+ // Standalone "Open as site" tab (#65): fill the full browser viewport — there is no
122
+ // card chrome below the chart, so drop the ~44px reserve and use a larger floor.
123
+ const isSite = typeof window !== 'undefined'
124
+ && (window as { __PMX_CANVAS_JSON_RENDER_DISPLAY__?: string }).__PMX_CANVAS_JSON_RENDER_DISPLAY__ === 'site';
125
+
121
126
  useEffect(() => {
122
127
  const frame = frameRef.current;
123
128
  if (!frame) return;
@@ -136,7 +141,11 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
136
141
  // across node sizes). rect.top already accounts for everything above. With
137
142
  // too small a reserve a filled chart spills ~17px past the viewport and the
138
143
  // iframe document shows a needless scrollbar.
139
- const available = Math.max(220, Math.round(window.innerHeight - rect.top - 44));
144
+ // Keep the ~44px reserve in BOTH modes — it covers the chart frame's own
145
+ // non-plot chrome (title + .pmx-chart padding), which exists in site mode too.
146
+ // Dropping it pushed the frame past the viewport and reintroduced a scrollbar.
147
+ // Site mode differs only in the floor (300 vs 220) and the fill selection below.
148
+ const available = Math.max(isSite ? 300 : 220, Math.round(window.innerHeight - rect.top - 44));
140
149
  const nextWidth = Math.round(rect.width);
141
150
  // Dead-band: ignore sub-threshold churn so a stray re-measure (e.g. a
142
151
  // scrollbar toggling) can't ping-pong state and repaint.
@@ -162,9 +171,14 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
162
171
  // content-fit (strictSize / user-resized nodes), it fills the frame down as before.
163
172
  const fitContent = typeof window !== 'undefined'
164
173
  && (window as { __PMX_CANVAS_FIT_CONTENT__?: boolean }).__PMX_CANVAS_FIT_CONTENT__ === true;
165
- const height = fitContent
166
- ? (typeof explicitHeight === 'number' ? explicitHeight : fallbackHeight)
167
- : (typeof explicitHeight === 'number' ? Math.min(explicitHeight, autoHeight) : autoHeight);
174
+ // Site mode (#65): fill the viewport (autoHeight), ignoring an explicit/configured
175
+ // chart height that would otherwise cap it to a shallow card. Content-fit is off in
176
+ // site mode (the server skips it), so site never takes the intrinsic-height branch.
177
+ const height = isSite
178
+ ? autoHeight
179
+ : fitContent
180
+ ? (typeof explicitHeight === 'number' ? explicitHeight : fallbackHeight)
181
+ : (typeof explicitHeight === 'number' ? Math.min(explicitHeight, autoHeight) : autoHeight);
168
182
  return {
169
183
  frameRef,
170
184
  height,
@@ -180,8 +180,15 @@ function App() {
180
180
  ? { ...(spec.state ?? {}), ax: axState }
181
181
  : spec.state ?? undefined;
182
182
 
183
+ // Standalone "Open as site" tab (#65): fill the browser viewport instead of the
184
+ // in-canvas card height. The chart child flex-grows; useChartFrameHeight measures
185
+ // the full viewport in this mode. Embedded/expanded keep the padded min-height box.
186
+ const isSite = window.__PMX_CANVAS_JSON_RENDER_DISPLAY__ === 'site';
187
+ const containerStyle = isSite
188
+ ? { display: 'flex', flexDirection: 'column' as const, height: '100dvh', minHeight: '100dvh', padding: 0, boxSizing: 'border-box' as const }
189
+ : { minHeight: '100vh', padding: 16, boxSizing: 'border-box' as const };
183
190
  return (
184
- <div style={{ minHeight: '100vh', padding: 16, boxSizing: 'border-box' }}>
191
+ <div style={containerStyle}>
185
192
  <JSONUIProvider
186
193
  registry={registry}
187
194
  initialState={initialState}
@@ -189,7 +196,9 @@ function App() {
189
196
  handlers={buildAxHandlers()}
190
197
  >
191
198
  <AxStateSync />
192
- <Renderer spec={spec} registry={registry} loading={false} />
199
+ <div style={isSite ? { flex: 1, minHeight: 0 } : undefined}>
200
+ <Renderer spec={spec} registry={registry} loading={false} />
201
+ </div>
193
202
  {window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ ? (
194
203
  <JsonRenderDevtools position="right" />
195
204
  ) : null}
@@ -940,7 +940,7 @@ export async function buildJsonRenderViewerHtml(options: {
940
940
  title: string;
941
941
  spec: JsonRenderSpec;
942
942
  theme?: 'dark' | 'light' | 'high-contrast';
943
- display?: 'expanded';
943
+ display?: 'expanded' | 'site';
944
944
  devtools?: boolean;
945
945
  nodeId?: string;
946
946
  axToken?: string;
@@ -79,10 +79,17 @@ export function buildCanvasAxContext(consumer?: string): PmxAxContext {
79
79
  const focusNodes = ax.focus.nodeIds
80
80
  .map((id) => canvasState.getNode(id))
81
81
  .filter((node): node is CanvasNodeState => node !== undefined);
82
+ // Report #57: surface the NEWEST undelivered steering (so a fresh steer is visible
83
+ // even behind a long backlog) + counts so the agent can detect an omitted backlog.
84
+ // The FIFO claim/ack queue (getPendingSteering) stays oldest-first for processing.
85
+ const pendingSteering = canvasState.getPendingSteeringForContext({ consumer, limit: AX_CONTEXT_STEERING_LIMIT });
86
+ const totalPending = canvasState.getPendingSteeringCount(consumer);
82
87
  return buildAxContext({
83
88
  layout,
84
89
  delivery: {
85
- pendingSteering: canvasState.getPendingSteering({ consumer, limit: AX_CONTEXT_STEERING_LIMIT }),
90
+ pendingSteering,
91
+ totalPending,
92
+ omittedPending: Math.max(0, totalPending - pendingSteering.length),
86
93
  pendingActivity: buildPendingAxActivity(ax, consumer),
87
94
  },
88
95
  pinned: buildCanvasAxPinnedContext(),
@@ -29,6 +29,8 @@ import {
29
29
  loadAxEvidenceFromDB,
30
30
  loadAxSteeringFromDB,
31
31
  loadPendingAxSteeringFromDB,
32
+ loadNewestPendingAxSteeringFromDB,
33
+ countPendingAxSteeringFromDB,
32
34
  loadAxTimelineSummaryFromDB,
33
35
  upsertAxHostCapabilityToDB,
34
36
  loadAxHostCapabilityFromDB,
@@ -790,6 +792,22 @@ export class AxStateManager {
790
792
  return db ? loadPendingAxSteeringFromDB(db, options) : [];
791
793
  }
792
794
 
795
+ /**
796
+ * NEWEST undelivered steering first, for the compact AX context lead block (report
797
+ * #57) — so a fresh steer is visible even behind a long backlog. Loop-safe like
798
+ * getPendingSteering, but ordered DESC instead of the FIFO ASC delivery queue.
799
+ */
800
+ getPendingSteeringForContext(options: { consumer?: string; limit?: number } = {}): PmxAxSteeringMessage[] {
801
+ const db = this.deps.getDb();
802
+ return db ? loadNewestPendingAxSteeringFromDB(db, options) : [];
803
+ }
804
+
805
+ /** Total undelivered steering for a consumer (loop-safe), for the context backlog counts. */
806
+ getPendingSteeringCount(consumer?: string): number {
807
+ const db = this.deps.getDb();
808
+ return db ? countPendingAxSteeringFromDB(db, consumer) : 0;
809
+ }
810
+
793
811
  getAxTimelineSummary(): PmxAxTimelineSummary {
794
812
  const db = this.deps.getDb();
795
813
  return db
@@ -168,8 +168,16 @@ export interface PendingAxActivityItem {
168
168
  }
169
169
 
170
170
  // ── Delivery lead block (compact, un-truncated; for per-turn injection) ──
171
+ // `pendingSteering` here is NEWEST-first (most recent at index 0), capped at
172
+ // AX_CONTEXT_STEERING_LIMIT, so a fresh steer is always visible even behind a long
173
+ // backlog (report #57). This is "what's new?" awareness — distinct from the FIFO
174
+ // claim/ack delivery queue (`/api/canvas/ax/delivery/pending`, getPendingSteering),
175
+ // which stays OLDEST-first for ordered processing. The counts let an agent detect a
176
+ // backlog the compact block omits.
171
177
  export interface PmxAxDeliveryContext {
172
178
  pendingSteering: PmxAxSteeringMessage[];
179
+ totalPending: number;
180
+ omittedPending: number;
173
181
  pendingActivity: PendingAxActivityItem[];
174
182
  }
175
183
 
@@ -921,6 +921,41 @@ export function loadPendingAxSteeringFromDB(
921
921
  .filter((s): s is PmxAxSteeringMessage => s !== null);
922
922
  }
923
923
 
924
+ /**
925
+ * NEWEST undelivered steering first (report #57) for the compact AX context lead
926
+ * block — so a fresh steer is visible even behind a long backlog. Loop-safe: excludes
927
+ * the consumer's own steering in SQL so the LIMIT applies after loop-prevention.
928
+ * Distinct from loadPendingAxSteeringFromDB (FIFO oldest-first) which the claim/ack
929
+ * delivery queue uses for ordered processing.
930
+ */
931
+ export function loadNewestPendingAxSteeringFromDB(
932
+ db: Database,
933
+ options: { consumer?: string; limit?: number } = {},
934
+ ): PmxAxSteeringMessage[] {
935
+ interface Row { seq: number; id: string; message: string; delivered: number; created_at: string; source: string | null }
936
+ const limit = clampTimelineLimit(options.limit);
937
+ const rows = options.consumer
938
+ ? db.query<Row, [string, number]>(
939
+ 'SELECT * FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?) ORDER BY seq DESC LIMIT ?',
940
+ ).all(options.consumer, limit)
941
+ : db.query<Row, [number]>(
942
+ 'SELECT * FROM ax_steering WHERE delivered = 0 ORDER BY seq DESC LIMIT ?',
943
+ ).all(limit);
944
+ return rows
945
+ .map((r) => normalizeAxSteeringMessage({ ...r, createdAt: r.created_at, delivered: r.delivered === 1 }))
946
+ .filter((s): s is PmxAxSteeringMessage => s !== null);
947
+ }
948
+
949
+ /** Total undelivered steering for a consumer (loop-safe — excludes the consumer's own). */
950
+ export function countPendingAxSteeringFromDB(db: Database, consumer?: string): number {
951
+ const n = consumer
952
+ ? db.query<{ n: number }, [string]>(
953
+ 'SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0 AND (source IS NULL OR source != ?)',
954
+ ).get(consumer)?.n
955
+ : db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM ax_steering WHERE delivered = 0').get()?.n;
956
+ return Number(n ?? 0);
957
+ }
958
+
924
959
  function countRows(db: Database, table: 'ax_events' | 'ax_evidence' | 'ax_steering'): number {
925
960
  return Number(db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n ?? 0);
926
961
  }
@@ -1957,6 +1957,14 @@ class CanvasStateManager {
1957
1957
  return this.ax.getPendingSteering(options);
1958
1958
  }
1959
1959
 
1960
+ getPendingSteeringForContext(options: { consumer?: string; limit?: number } = {}): PmxAxSteeringMessage[] {
1961
+ return this.ax.getPendingSteeringForContext(options);
1962
+ }
1963
+
1964
+ getPendingSteeringCount(consumer?: string): number {
1965
+ return this.ax.getPendingSteeringCount(consumer);
1966
+ }
1967
+
1960
1968
  getAxTimelineSummary(): PmxAxTimelineSummary {
1961
1969
  return this.ax.getAxTimelineSummary();
1962
1970
  }