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.
- package/CHANGELOG.md +124 -0
- package/Readme.md +2 -2
- package/dist/canvas/global.css +260 -0
- package/dist/canvas/index.js +76 -76
- package/dist/json-render/index.js +2 -2
- package/dist/types/client/canvas/IntentLayer.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +10 -0
- package/dist/types/client/state/intent-store.d.ts +25 -0
- package/dist/types/json-render/server.d.ts +1 -1
- package/dist/types/server/ax-state-manager.d.ts +11 -0
- package/dist/types/server/ax-state.d.ts +2 -0
- package/dist/types/server/canvas-db.d.ts +13 -0
- package/dist/types/server/canvas-state.d.ts +5 -0
- package/dist/types/server/index.d.ts +34 -4
- package/dist/types/server/intent-registry.d.ts +45 -0
- package/dist/types/server/operations/ops/intent.d.ts +2 -0
- package/dist/types/shared/ax-intent.d.ts +58 -0
- package/docs/ax-host-adapter-contract.md +19 -1
- package/docs/http-api.md +4 -0
- package/docs/mcp.md +22 -3
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +197 -1283
- package/skills/pmx-canvas/evals/evals.json +199 -0
- package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
- package/skills/pmx-canvas/references/full-reference.md +1441 -0
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +23 -7
- package/src/cli/index.ts +21 -4
- package/src/client/canvas/CanvasNode.tsx +13 -13
- package/src/client/canvas/CanvasViewport.tsx +2 -0
- package/src/client/canvas/ContextMenu.tsx +25 -19
- package/src/client/canvas/IntentLayer.tsx +278 -0
- package/src/client/nodes/ExtAppFrame.tsx +31 -22
- package/src/client/state/intent-bridge.ts +31 -0
- package/src/client/state/intent-store.ts +107 -0
- package/src/client/state/sse-bridge.ts +31 -0
- package/src/client/theme/global.css +260 -0
- package/src/json-render/charts/components.tsx +18 -4
- package/src/json-render/renderer/index.tsx +11 -2
- package/src/json-render/server.ts +1 -1
- package/src/server/ax-context.ts +8 -1
- package/src/server/ax-state-manager.ts +18 -0
- package/src/server/ax-state.ts +8 -0
- package/src/server/canvas-db.ts +35 -0
- package/src/server/canvas-state.ts +8 -0
- package/src/server/index.ts +240 -158
- package/src/server/intent-registry.ts +324 -0
- package/src/server/operations/composites.ts +11 -0
- package/src/server/operations/index.ts +2 -0
- package/src/server/operations/ops/edges.ts +1 -0
- package/src/server/operations/ops/groups.ts +3 -0
- package/src/server/operations/ops/intent.ts +132 -0
- package/src/server/operations/ops/json-render.ts +3 -0
- package/src/server/operations/ops/nodes.ts +3 -0
- package/src/server/operations/registry.ts +68 -3
- package/src/server/server.ts +40 -12
- package/src/shared/ax-intent.ts +64 -0
- 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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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={
|
|
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
|
-
<
|
|
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;
|
package/src/server/ax-context.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/server/ax-state.ts
CHANGED
|
@@ -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
|
|
package/src/server/canvas-db.ts
CHANGED
|
@@ -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
|
}
|