pmx-canvas 0.4.8 → 0.5.0
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 +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +139 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3069 -796
- package/dist/canvas/index.js +104 -104
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/pump.d.ts +9 -0
- package/dist/types/client/App.d.ts +1 -1
- package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
- package/dist/types/client/canvas/BarHint.d.ts +22 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
- package/dist/types/client/canvas/CommandBar.d.ts +10 -0
- package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
- package/dist/types/client/canvas/EmptyState.d.ts +11 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
- package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/Minimap.d.ts +12 -1
- package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
- package/dist/types/client/canvas/ToolRail.d.ts +21 -0
- package/dist/types/client/canvas/TopBar.d.ts +7 -0
- package/dist/types/client/canvas/canvas-area.d.ts +25 -0
- package/dist/types/client/canvas/create-in-view.d.ts +18 -0
- package/dist/types/client/canvas/import-files.d.ts +8 -0
- package/dist/types/client/canvas/kind-colors.d.ts +3 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
- package/dist/types/client/canvas/use-now.d.ts +6 -0
- package/dist/types/client/icons.d.ts +11 -4
- package/dist/types/client/nodes/GroupNode.d.ts +6 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
- package/dist/types/client/state/canvas-store.d.ts +49 -5
- package/dist/types/client/state/human-store.d.ts +26 -0
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +37 -5
- package/dist/types/client/state/node-factory.d.ts +0 -1
- package/dist/types/client/state/presence-store.d.ts +78 -0
- package/dist/types/client/state/session-store.d.ts +176 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/server/agent-presence.d.ts +192 -0
- package/dist/types/server/ax-gate-ttl.d.ts +6 -0
- package/dist/types/server/ax-interaction.d.ts +4 -10
- package/dist/types/server/ax-state-manager.d.ts +21 -2
- package/dist/types/server/ax-state.d.ts +49 -3
- package/dist/types/server/ax-wait.d.ts +13 -0
- package/dist/types/server/canvas-db.d.ts +9 -1
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +27 -13
- package/dist/types/server/canvas-validation.d.ts +0 -14
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/human-presence.d.ts +40 -0
- package/dist/types/server/index.d.ts +29 -3
- package/dist/types/server/intent-registry.d.ts +7 -0
- package/dist/types/server/mutation-history.d.ts +15 -2
- package/dist/types/server/operations/invoker.d.ts +13 -2
- package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
- package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
- package/dist/types/server/operations/registry.d.ts +11 -0
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/scope-fence.d.ts +62 -0
- package/dist/types/server/server.d.ts +0 -12
- package/dist/types/shared/agent-presence.d.ts +130 -0
- package/dist/types/shared/approval-gates.d.ts +19 -0
- package/dist/types/shared/auto-arrange.d.ts +0 -1
- package/dist/types/shared/ax-kinds.d.ts +11 -0
- package/dist/types/shared/human-presence.d.ts +31 -0
- package/dist/types/shared/iframe-probe.d.ts +8 -0
- package/dist/types/shared/scope-fence.d.ts +21 -0
- package/docs/RELEASE.md +6 -0
- package/docs/ax-host-adapter-contract.md +30 -14
- package/docs/cli.md +37 -1
- package/docs/environment.md +3 -0
- package/docs/http-api.md +222 -5
- package/docs/mcp.md +6 -5
- package/docs/node-types.md +3 -1
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/multi-agent-live.png +0 -0
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +103 -16
- package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
- package/skills/pmx-canvas/references/full-reference.md +55 -16
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
- package/skills/pmx-canvas-testing/SKILL.md +17 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/copilot.ts +15 -8
- package/src/cli/commands/nodes.ts +1 -19
- package/src/cli/commands/pump.ts +252 -0
- package/src/cli/index.ts +1 -5
- package/src/cli/shared.ts +1 -1
- package/src/client/App.tsx +209 -585
- package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
- package/src/client/canvas/AttentionHistory.tsx +0 -10
- package/src/client/canvas/BarHint.tsx +49 -0
- package/src/client/canvas/CanvasNode.tsx +488 -136
- package/src/client/canvas/CanvasViewport.tsx +190 -94
- package/src/client/canvas/CommandBar.tsx +140 -0
- package/src/client/canvas/CommandPalette.tsx +133 -58
- package/src/client/canvas/ConnectionBanner.tsx +65 -0
- package/src/client/canvas/ContextMenu.tsx +88 -27
- package/src/client/canvas/ContextPinBar.tsx +2 -2
- package/src/client/canvas/EdgeLayer.tsx +121 -38
- package/src/client/canvas/EmptyState.tsx +93 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
- package/src/client/canvas/ExternalWriters.tsx +308 -0
- package/src/client/canvas/FocusFieldLayer.tsx +12 -1
- package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
- package/src/client/canvas/IntentLayer.tsx +1 -1
- package/src/client/canvas/Minimap.tsx +117 -190
- package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
- package/src/client/canvas/SelectionBar.tsx +175 -23
- package/src/client/canvas/SessionPanel.tsx +481 -0
- package/src/client/canvas/SessionReceipt.tsx +141 -0
- package/src/client/canvas/ShortcutOverlay.tsx +21 -6
- package/src/client/canvas/SnapshotPanel.tsx +166 -80
- package/src/client/canvas/TextPrompt.tsx +128 -0
- package/src/client/canvas/ToolRail.tsx +447 -0
- package/src/client/canvas/TopBar.tsx +308 -0
- package/src/client/canvas/auto-fit.ts +1 -5
- package/src/client/canvas/canvas-area.ts +41 -0
- package/src/client/canvas/create-in-view.ts +48 -0
- package/src/client/canvas/import-files.ts +64 -0
- package/src/client/canvas/kind-colors.ts +22 -0
- package/src/client/canvas/snap-guides.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +54 -0
- package/src/client/canvas/use-now.ts +17 -0
- package/src/client/canvas/use-pan-zoom.ts +13 -2
- package/src/client/icons.tsx +57 -18
- package/src/client/nodes/GroupNode.tsx +9 -28
- package/src/client/nodes/InlineFormatBar.tsx +4 -3
- package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
- package/src/client/nodes/MarkdownNode.tsx +59 -24
- package/src/client/nodes/MdFormatBar.tsx +2 -4
- package/src/client/nodes/inline-editor-commands.ts +17 -6
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/state/attention-bridge.ts +3 -0
- package/src/client/state/canvas-store.ts +273 -84
- package/src/client/state/human-store.ts +175 -0
- package/src/client/state/iframe-mode.ts +33 -9
- package/src/client/state/intent-bridge.ts +75 -13
- package/src/client/state/node-factory.ts +2 -8
- package/src/client/state/presence-store.ts +168 -0
- package/src/client/state/session-store.ts +419 -0
- package/src/client/state/sse-bridge.ts +93 -30
- package/src/client/theme/global.css +3069 -796
- package/src/client/types.ts +3 -1
- package/src/client/utils/platform.ts +6 -1
- package/src/mcp/canvas-access.ts +2 -2
- package/src/server/agent-presence.ts +755 -0
- package/src/server/ax-context.ts +45 -0
- package/src/server/ax-gate-ttl.ts +61 -0
- package/src/server/ax-interaction.ts +29 -1
- package/src/server/ax-state-manager.ts +78 -6
- package/src/server/ax-state.ts +82 -15
- package/src/server/ax-wait.ts +33 -0
- package/src/server/canvas-db.ts +103 -33
- package/src/server/canvas-operations.ts +17 -8
- package/src/server/canvas-state.ts +131 -58
- package/src/server/canvas-validation.ts +3 -53
- package/src/server/demo-state.json +2 -70
- package/src/server/html-surface.ts +11 -2
- package/src/server/human-presence.ts +160 -0
- package/src/server/index.ts +107 -4
- package/src/server/intent-registry.ts +13 -0
- package/src/server/mutation-history.ts +47 -2
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/http.ts +10 -1
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/invoker.ts +33 -6
- package/src/server/operations/ops/ax-presence.ts +62 -0
- package/src/server/operations/ops/ax-read.ts +8 -0
- package/src/server/operations/ops/ax-state.ts +106 -1
- package/src/server/operations/ops/ax-timeline.ts +54 -5
- package/src/server/operations/ops/ax-work.ts +46 -0
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/edges.ts +88 -2
- package/src/server/operations/ops/groups.ts +4 -3
- package/src/server/operations/ops/human-presence.ts +35 -0
- package/src/server/operations/ops/json-render.ts +11 -1
- package/src/server/operations/ops/nodes.ts +65 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +16 -0
- package/src/server/operations/registry.ts +196 -1
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +207 -0
- package/src/server/server.ts +177 -79
- package/src/server/trace-manager.ts +0 -2
- package/src/server/web-artifacts.ts +0 -1
- package/src/shared/agent-presence.ts +162 -0
- package/src/shared/approval-gates.ts +35 -0
- package/src/shared/auto-arrange.ts +121 -13
- package/src/shared/ax-kinds.ts +23 -0
- package/src/shared/human-presence.ts +30 -0
- package/src/shared/iframe-probe.ts +8 -0
- package/src/shared/scope-fence.ts +31 -0
- package/dist/types/client/canvas/DockedNode.d.ts +0 -4
- package/src/client/canvas/DockedNode.tsx +0 -165
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
2
|
+
import { useNow } from './use-now';
|
|
3
|
+
import { agentIdentityHue, agentPhaseLabel } from '../../shared/agent-presence.js';
|
|
4
|
+
import { formatCountdown, gateRemainingMs } from '../../shared/approval-gates.js';
|
|
5
|
+
import { focusNode, selectedNodeIds } from '../state/canvas-store';
|
|
6
|
+
import { activeSession, agentPresences } from '../state/presence-store';
|
|
7
|
+
import {
|
|
8
|
+
type ApprovalGateView,
|
|
9
|
+
endSession,
|
|
10
|
+
heldGates,
|
|
11
|
+
pendingGates,
|
|
12
|
+
refreshTimeline,
|
|
13
|
+
reopenGate,
|
|
14
|
+
resolveGate,
|
|
15
|
+
scopeFence,
|
|
16
|
+
setScopeFence,
|
|
17
|
+
sessionWorkItems,
|
|
18
|
+
type TimelineEntry,
|
|
19
|
+
timelineCategory,
|
|
20
|
+
type TimelineEntryKind,
|
|
21
|
+
type TimelineFilter,
|
|
22
|
+
timelineEntries,
|
|
23
|
+
timelineFilter,
|
|
24
|
+
undoAgentEdit,
|
|
25
|
+
undoneActivityIds,
|
|
26
|
+
type WorkItemStatus,
|
|
27
|
+
type WorkItemView,
|
|
28
|
+
} from '../state/session-store';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Session panel (rail-chrome-v2 phase 4): the attached agent's work items,
|
|
32
|
+
* approval gates, and timeline — the human's supervisory surface. Mounted
|
|
33
|
+
* only while a session is attached (App gates it on sessionActive). Panel and
|
|
34
|
+
* canvas are one system: a gate resolved here resolves the same AX gate the
|
|
35
|
+
* on-canvas node controls see.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Design status glyphs: queued · running · awaiting · done · vetoed. */
|
|
39
|
+
const WORK_GLYPH: Record<WorkItemStatus, 'queued' | 'running' | 'awaiting' | 'done' | 'vetoed'> = {
|
|
40
|
+
todo: 'queued',
|
|
41
|
+
'in-progress': 'running',
|
|
42
|
+
blocked: 'awaiting',
|
|
43
|
+
done: 'done',
|
|
44
|
+
cancelled: 'vetoed',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Chip text per status. Deliberately NOT the glyph name: a cancelled item
|
|
48
|
+
* (withdrawn, duplicate) was not "vetoed" by anyone — saying so misreads
|
|
49
|
+
* the board's history. The glyph class keeps the design's visual tone. */
|
|
50
|
+
const WORK_STATUS_TEXT: Record<WorkItemStatus, string> = {
|
|
51
|
+
todo: 'queued',
|
|
52
|
+
'in-progress': 'running',
|
|
53
|
+
blocked: 'awaiting',
|
|
54
|
+
done: 'done',
|
|
55
|
+
cancelled: 'cancelled',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Each chip explains itself on hover — the row vocabulary is jargon otherwise.
|
|
59
|
+
const TIMELINE_FILTERS: Array<{ id: TimelineFilter; label: string; hint: string }> = [
|
|
60
|
+
{ id: 'all', label: 'All', hint: 'Everything below in one feed, newest first' },
|
|
61
|
+
{ id: 'update', label: 'Updates', hint: 'Board edits agents made — nodes, edges, pins, work items' },
|
|
62
|
+
{ id: 'steer', label: 'Steer', hint: 'Instructions sent to agents — by you from the composer, or agent-to-agent' },
|
|
63
|
+
{ id: 'assistant', label: 'Assistant', hint: 'Messages agents posted for you to read' },
|
|
64
|
+
{ id: 'event', label: 'Events', hint: 'Run telemetry — tool runs, approvals, failures, policy notes' },
|
|
65
|
+
{ id: 'evidence', label: 'Evidence', hint: 'Proof agents recorded — test results, artifacts, links' },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const TIMELINE_FILTER_HINTS: Record<TimelineFilter, string> = Object.fromEntries(
|
|
69
|
+
TIMELINE_FILTERS.map((chip) => [chip.id, chip.hint]),
|
|
70
|
+
) as Record<TimelineFilter, string>;
|
|
71
|
+
|
|
72
|
+
const TIMELINE_TONE: Record<TimelineEntryKind, string> = {
|
|
73
|
+
policy: 'warn',
|
|
74
|
+
prompt: 'accent',
|
|
75
|
+
'assistant-message': 'muted',
|
|
76
|
+
'tool-start': 'accent',
|
|
77
|
+
'tool-result': 'ok',
|
|
78
|
+
failure: 'danger',
|
|
79
|
+
approval: 'warn',
|
|
80
|
+
steering: 'purple',
|
|
81
|
+
command: 'accent',
|
|
82
|
+
note: 'muted',
|
|
83
|
+
evidence: 'ok',
|
|
84
|
+
steer: 'purple',
|
|
85
|
+
update: 'purple',
|
|
86
|
+
yield: 'warn',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function clock(iso: string): string {
|
|
90
|
+
const date = new Date(iso);
|
|
91
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
92
|
+
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function WorkItemRow({ item }: { item: WorkItemView }) {
|
|
96
|
+
const glyph = WORK_GLYPH[item.status] ?? 'queued';
|
|
97
|
+
const nodeId = item.nodeIds[0];
|
|
98
|
+
return (
|
|
99
|
+
<li class={`session-item status-${glyph}`} data-work-item-id={item.id}>
|
|
100
|
+
<span class="session-glyph" aria-hidden="true" />
|
|
101
|
+
<div class="session-item-main">
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
class="session-item-title"
|
|
105
|
+
title={nodeId ? 'Focus the linked node' : undefined}
|
|
106
|
+
disabled={!nodeId}
|
|
107
|
+
onClick={() => nodeId && focusNode(nodeId)}
|
|
108
|
+
>
|
|
109
|
+
{item.title}
|
|
110
|
+
</button>
|
|
111
|
+
{item.detail && <div class="session-item-detail">{item.detail}</div>}
|
|
112
|
+
</div>
|
|
113
|
+
<span class="session-item-status">{WORK_STATUS_TEXT[item.status] ?? glyph}</span>
|
|
114
|
+
<span class="session-item-time">{clock(item.updatedAt)}</span>
|
|
115
|
+
</li>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function GateRow({ gate, now }: { gate: ApprovalGateView; now: number }) {
|
|
120
|
+
const [busy, setBusy] = useState<'approved' | 'rejected' | null>(null);
|
|
121
|
+
const decide = async (decision: 'approved' | 'rejected') => {
|
|
122
|
+
setBusy(decision);
|
|
123
|
+
await resolveGate(gate, decision);
|
|
124
|
+
setBusy(null);
|
|
125
|
+
};
|
|
126
|
+
const nodeId = gate.nodeIds[0];
|
|
127
|
+
const remaining = gateRemainingMs(gate, now);
|
|
128
|
+
return (
|
|
129
|
+
<li class="session-item status-awaiting session-gate" data-gate-id={gate.id}>
|
|
130
|
+
<span class="session-glyph" aria-hidden="true" />
|
|
131
|
+
<div class="session-item-main">
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
class="session-item-title"
|
|
135
|
+
title={nodeId ? 'Focus the linked node' : undefined}
|
|
136
|
+
disabled={!nodeId}
|
|
137
|
+
onClick={() => nodeId && focusNode(nodeId)}
|
|
138
|
+
>
|
|
139
|
+
{gate.title}
|
|
140
|
+
</button>
|
|
141
|
+
{gate.detail && <div class="session-item-detail">{gate.detail}</div>}
|
|
142
|
+
{remaining !== null && (
|
|
143
|
+
<div class="session-gate-ttl" data-testid="gate-countdown">
|
|
144
|
+
auto-holds in {formatCountdown(remaining)} if unanswered
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
<div class="session-gate-actions">
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
class="session-gate-approve"
|
|
151
|
+
disabled={busy !== null}
|
|
152
|
+
onClick={() => void decide('approved')}
|
|
153
|
+
>
|
|
154
|
+
{busy === 'approved' ? 'Approving…' : 'Approve'}
|
|
155
|
+
</button>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
class="session-gate-reject"
|
|
159
|
+
disabled={busy !== null}
|
|
160
|
+
onClick={() => void decide('rejected')}
|
|
161
|
+
>
|
|
162
|
+
{busy === 'rejected' ? 'Rejecting…' : 'Reject'}
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
<span class="session-item-time">{clock(gate.createdAt)}</span>
|
|
167
|
+
</li>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function HeldGateRow({ gate }: { gate: ApprovalGateView }) {
|
|
172
|
+
const [busy, setBusy] = useState(false);
|
|
173
|
+
return (
|
|
174
|
+
<li class="session-item status-vetoed session-gate-held" data-gate-id={gate.id}>
|
|
175
|
+
<span class="session-glyph" aria-hidden="true" />
|
|
176
|
+
<div class="session-item-main">
|
|
177
|
+
<span class="session-item-title">{gate.title}</span>
|
|
178
|
+
<div class="session-item-detail">Auto-held — no answer in time, the action did not proceed.</div>
|
|
179
|
+
<div class="session-gate-actions">
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
class="session-gate-reject"
|
|
183
|
+
disabled={busy}
|
|
184
|
+
onClick={() => {
|
|
185
|
+
setBusy(true);
|
|
186
|
+
void reopenGate(gate).finally(() => setBusy(false));
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{busy ? 'Reopening…' : 'Reopen'}
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
<span class="session-item-status">held</span>
|
|
194
|
+
</li>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Scope row under the header: the fence the human granted, or the affordance
|
|
200
|
+
* to grant one from the current selection. The server refuses agent writes
|
|
201
|
+
* outside it; reads stay open.
|
|
202
|
+
*/
|
|
203
|
+
function ScopeRow() {
|
|
204
|
+
const fence = scopeFence.value;
|
|
205
|
+
const selected = selectedNodeIds.value.size;
|
|
206
|
+
const [busy, setBusy] = useState(false);
|
|
207
|
+
const apply = (nodeIds: string[] | null) => {
|
|
208
|
+
setBusy(true);
|
|
209
|
+
void setScopeFence(nodeIds).finally(() => setBusy(false));
|
|
210
|
+
};
|
|
211
|
+
return (
|
|
212
|
+
<div class={`session-scope${fence ? ' is-fenced' : ''}`} data-testid="session-scope">
|
|
213
|
+
<span class="session-scope-dot" aria-hidden="true" />
|
|
214
|
+
{fence ? (
|
|
215
|
+
<>
|
|
216
|
+
<span class="session-scope-text">
|
|
217
|
+
Scoped to <strong>{fence.nodeIds.length}</strong> node{fence.nodeIds.length === 1 ? '' : 's'} · writes
|
|
218
|
+
outside are blocked
|
|
219
|
+
</span>
|
|
220
|
+
<button type="button" class="session-scope-action" disabled={busy} onClick={() => apply(null)}>
|
|
221
|
+
Clear
|
|
222
|
+
</button>
|
|
223
|
+
</>
|
|
224
|
+
) : (
|
|
225
|
+
<>
|
|
226
|
+
<span class="session-scope-text">Unscoped — the agent may write anywhere</span>
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
class="session-scope-action"
|
|
230
|
+
disabled={busy || selected === 0}
|
|
231
|
+
title={selected === 0 ? 'Select nodes on the canvas first' : 'Fence the agent to the selected nodes'}
|
|
232
|
+
onClick={() => apply([...selectedNodeIds.value])}
|
|
233
|
+
>
|
|
234
|
+
Fence to selection{selected > 0 ? ` (${selected})` : ''}
|
|
235
|
+
</button>
|
|
236
|
+
</>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The row writer's display name: roster label when the key is a known writer, 'you' for the human. */
|
|
243
|
+
function writerDisplay(who: string): string {
|
|
244
|
+
if (who === 'browser') return 'you';
|
|
245
|
+
const match = agentPresences.value.find((presence) => presence.sessionId === who || presence.source === who);
|
|
246
|
+
return match?.label ?? who;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function TimelineRow({ entry }: { entry: TimelineEntry }) {
|
|
250
|
+
const undone = undoneActivityIds.value.has(entry.id);
|
|
251
|
+
return (
|
|
252
|
+
<li class={`session-timeline-row tone-${TIMELINE_TONE[entry.kind] ?? 'muted'}`}>
|
|
253
|
+
<span class="session-timeline-dot" aria-hidden="true" />
|
|
254
|
+
<div class="session-timeline-main">
|
|
255
|
+
<div class="session-timeline-head">
|
|
256
|
+
<span class="session-timeline-label" title={TIMELINE_FILTER_HINTS[timelineCategory(entry.kind)]}>
|
|
257
|
+
{entry.label}
|
|
258
|
+
</span>
|
|
259
|
+
{/* With several assistants on one board, "Assistant · 23:11" answers
|
|
260
|
+
nothing — every row names its writer in that writer's identity hue. */}
|
|
261
|
+
{entry.who && (
|
|
262
|
+
<span
|
|
263
|
+
class="session-timeline-who"
|
|
264
|
+
style={{ '--identity-color': `hsl(${agentIdentityHue(entry.who)} 65% 62%)` }}
|
|
265
|
+
>
|
|
266
|
+
{writerDisplay(entry.who)}
|
|
267
|
+
</span>
|
|
268
|
+
)}
|
|
269
|
+
<span class="session-item-time">{clock(entry.createdAt)}</span>
|
|
270
|
+
</div>
|
|
271
|
+
<div class="session-timeline-body">{entry.body}</div>
|
|
272
|
+
{/* Item 10: one shared undo stack — the agent's latest edit can be
|
|
273
|
+
undone from here; the agent hears about it as steering. */}
|
|
274
|
+
{entry.undoable && !undone && (
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
class="session-timeline-undo"
|
|
278
|
+
data-testid="timeline-undo"
|
|
279
|
+
onClick={() => void undoAgentEdit(entry)}
|
|
280
|
+
>
|
|
281
|
+
↩ undo this edit
|
|
282
|
+
</button>
|
|
283
|
+
)}
|
|
284
|
+
{undone && <div class="session-timeline-undone">undone · steering sent</div>}
|
|
285
|
+
</div>
|
|
286
|
+
</li>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function SessionPanel() {
|
|
291
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
292
|
+
const session = activeSession.value;
|
|
293
|
+
const items = sessionWorkItems.value;
|
|
294
|
+
const gates = pendingGates.value;
|
|
295
|
+
const held = heldGates.value;
|
|
296
|
+
const entries = timelineEntries.value;
|
|
297
|
+
const [workOpen, setWorkOpen] = useState(false);
|
|
298
|
+
// Which filter chip the pointer/focus is on — drives the caption under the
|
|
299
|
+
// row. Native `title` never renders in embedded panes, and an anchored
|
|
300
|
+
// tooltip would clip against the row's own overflow-x scroll container.
|
|
301
|
+
const [hoveredFilter, setHoveredFilter] = useState<TimelineFilter | null>(null);
|
|
302
|
+
const now = useNow();
|
|
303
|
+
|
|
304
|
+
// The timeline is read on mount; the SSE bridge refreshes it on every
|
|
305
|
+
// ax-event-created while a session is attached.
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
void refreshTimeline();
|
|
308
|
+
}, []);
|
|
309
|
+
|
|
310
|
+
if (collapsed) {
|
|
311
|
+
return (
|
|
312
|
+
<aside class="session-panel is-collapsed" aria-label="Session (collapsed)">
|
|
313
|
+
<button type="button" class="session-collapse" onClick={() => setCollapsed(false)} title="Expand session panel">
|
|
314
|
+
‹
|
|
315
|
+
</button>
|
|
316
|
+
<span class="session-collapsed-label">Session</span>
|
|
317
|
+
</aside>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<aside class="session-panel" aria-label="Session">
|
|
323
|
+
<header class="session-panel-header">
|
|
324
|
+
<span class="session-panel-title">Session</span>
|
|
325
|
+
{session && (
|
|
326
|
+
<span class={`session-live phase-${session.phase}`} title={agentPhaseLabel(session)}>
|
|
327
|
+
<span class="session-live-dot" aria-hidden="true" />
|
|
328
|
+
live
|
|
329
|
+
</span>
|
|
330
|
+
)}
|
|
331
|
+
<span class="top-bar-spacer" />
|
|
332
|
+
{session && (
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
class="session-end"
|
|
336
|
+
title="End this session — a receipt and a snapshot of the board follow"
|
|
337
|
+
onClick={() => void endSession(session)}
|
|
338
|
+
>
|
|
339
|
+
End
|
|
340
|
+
</button>
|
|
341
|
+
)}
|
|
342
|
+
<button
|
|
343
|
+
type="button"
|
|
344
|
+
class="session-collapse"
|
|
345
|
+
onClick={() => setCollapsed(true)}
|
|
346
|
+
title="Collapse session panel"
|
|
347
|
+
>
|
|
348
|
+
›
|
|
349
|
+
</button>
|
|
350
|
+
</header>
|
|
351
|
+
|
|
352
|
+
<ScopeRow />
|
|
353
|
+
{(() => {
|
|
354
|
+
// Fixed-position undo: the timeline row's "↩ undo this edit" chip
|
|
355
|
+
// proved unfindable in live testing (three rounds of a user hunting
|
|
356
|
+
// it). Whenever the agent's edit tops the shared stack, the same
|
|
357
|
+
// affordance also sits here — always in the same place.
|
|
358
|
+
const undoable = entries.find((entry) => entry.undoable && !undoneActivityIds.value.has(entry.id));
|
|
359
|
+
if (!undoable) return null;
|
|
360
|
+
return (
|
|
361
|
+
<div class="session-undo-row" data-testid="session-undo-row">
|
|
362
|
+
<span class="session-undo-text" title={undoable.body}>
|
|
363
|
+
Agent edit on top: {undoable.body}
|
|
364
|
+
</span>
|
|
365
|
+
<button type="button" class="session-undo-btn" onClick={() => void undoAgentEdit(undoable)}>
|
|
366
|
+
↩ Undo
|
|
367
|
+
</button>
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
})()}
|
|
371
|
+
|
|
372
|
+
<div class="session-panel-body">
|
|
373
|
+
<section class="session-section">
|
|
374
|
+
{(() => {
|
|
375
|
+
// Collapsed by default — the timeline is the panel's main feed and
|
|
376
|
+
// a stack of done cards buried it. The header keeps the live
|
|
377
|
+
// signal (pulsing dot + running count); pending gates FORCE the
|
|
378
|
+
// list open, their approve/reject buttons live here.
|
|
379
|
+
const running = items.filter((item) => item.status === 'in-progress' || item.status === 'todo').length;
|
|
380
|
+
// Cancelled items are not outstanding work — "18/20 done" over a
|
|
381
|
+
// board with 2 cancelled duplicates reads as work remaining when
|
|
382
|
+
// none does. Count done against the non-cancelled set and name the
|
|
383
|
+
// cancelled rest.
|
|
384
|
+
const active = items.filter((item) => item.status !== 'cancelled');
|
|
385
|
+
const done = active.filter((item) => item.status === 'done').length;
|
|
386
|
+
const cancelled = items.length - active.length;
|
|
387
|
+
// Only PENDING gates force the list open — they need a decision.
|
|
388
|
+
// A held gate is settled (its Reopen is optional) and was pinning
|
|
389
|
+
// the whole panel un-collapsible (0.5.0 readiness, human report).
|
|
390
|
+
const mustShow = gates.length > 0;
|
|
391
|
+
const showList = workOpen || mustShow;
|
|
392
|
+
const summary =
|
|
393
|
+
running > 0
|
|
394
|
+
? `${running} running`
|
|
395
|
+
: active.length > 0
|
|
396
|
+
? `${done}/${active.length} done${cancelled > 0 ? ` · ${cancelled} cancelled` : ''}`
|
|
397
|
+
: items.length > 0
|
|
398
|
+
? `${items.length} cancelled`
|
|
399
|
+
: 'none yet';
|
|
400
|
+
return (
|
|
401
|
+
<>
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
class="session-section-toggle"
|
|
405
|
+
aria-expanded={showList}
|
|
406
|
+
data-testid="work-items-toggle"
|
|
407
|
+
onClick={() => setWorkOpen((open) => !open)}
|
|
408
|
+
>
|
|
409
|
+
<span class="session-section-title">Work items</span>
|
|
410
|
+
<span class="session-work-summary">
|
|
411
|
+
{running > 0 && <span class="session-work-dot" aria-hidden="true" />}
|
|
412
|
+
{summary}
|
|
413
|
+
</span>
|
|
414
|
+
<span class={`session-section-chevron${showList ? ' is-open' : ''}`} aria-hidden="true">
|
|
415
|
+
›
|
|
416
|
+
</span>
|
|
417
|
+
</button>
|
|
418
|
+
{showList &&
|
|
419
|
+
(gates.length === 0 && held.length === 0 && items.length === 0 ? (
|
|
420
|
+
<div class="session-empty">No work items yet — the agent's tasks and gates appear here.</div>
|
|
421
|
+
) : (
|
|
422
|
+
<ul class="session-list" aria-live="polite" aria-label="Work items and gates">
|
|
423
|
+
{gates.map((gate) => (
|
|
424
|
+
<GateRow key={gate.id} gate={gate} now={now} />
|
|
425
|
+
))}
|
|
426
|
+
{held.map((gate) => (
|
|
427
|
+
<HeldGateRow key={gate.id} gate={gate} />
|
|
428
|
+
))}
|
|
429
|
+
{items.map((item) => (
|
|
430
|
+
<WorkItemRow key={item.id} item={item} />
|
|
431
|
+
))}
|
|
432
|
+
</ul>
|
|
433
|
+
))}
|
|
434
|
+
</>
|
|
435
|
+
);
|
|
436
|
+
})()}
|
|
437
|
+
</section>
|
|
438
|
+
|
|
439
|
+
<section class="session-section">
|
|
440
|
+
<h3 class="session-section-title">Timeline</h3>
|
|
441
|
+
<div class="session-timeline-filters" role="group" aria-label="Filter timeline">
|
|
442
|
+
{TIMELINE_FILTERS.map((chip) => (
|
|
443
|
+
<button
|
|
444
|
+
key={chip.id}
|
|
445
|
+
type="button"
|
|
446
|
+
class={`activity-filter${timelineFilter.value === chip.id ? ' is-active' : ''}`}
|
|
447
|
+
aria-pressed={timelineFilter.value === chip.id}
|
|
448
|
+
title={chip.hint}
|
|
449
|
+
onMouseEnter={() => setHoveredFilter(chip.id)}
|
|
450
|
+
onMouseLeave={() => setHoveredFilter((current) => (current === chip.id ? null : current))}
|
|
451
|
+
onFocus={() => setHoveredFilter(chip.id)}
|
|
452
|
+
onBlur={() => setHoveredFilter((current) => (current === chip.id ? null : current))}
|
|
453
|
+
onClick={() => {
|
|
454
|
+
timelineFilter.value = chip.id;
|
|
455
|
+
}}
|
|
456
|
+
>
|
|
457
|
+
{chip.label}
|
|
458
|
+
</button>
|
|
459
|
+
))}
|
|
460
|
+
</div>
|
|
461
|
+
{/* The hovered (else active) chip's meaning, always in normal flow —
|
|
462
|
+
visible on every surface, including ones without native tooltips. */}
|
|
463
|
+
<div class="session-timeline-hint" data-testid="timeline-filter-hint">
|
|
464
|
+
{TIMELINE_FILTER_HINTS[hoveredFilter ?? timelineFilter.value]}
|
|
465
|
+
</div>
|
|
466
|
+
{entries.length === 0 ? (
|
|
467
|
+
<div class="session-empty">
|
|
468
|
+
{timelineFilter.value === 'all' ? 'Nothing recorded yet.' : 'Nothing of this kind yet.'}
|
|
469
|
+
</div>
|
|
470
|
+
) : (
|
|
471
|
+
<ul class="session-list session-timeline" aria-live="polite" aria-label="Timeline">
|
|
472
|
+
{entries.map((entry) => (
|
|
473
|
+
<TimelineRow key={entry.id} entry={entry} />
|
|
474
|
+
))}
|
|
475
|
+
</ul>
|
|
476
|
+
)}
|
|
477
|
+
</section>
|
|
478
|
+
</div>
|
|
479
|
+
</aside>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks';
|
|
2
|
+
import { dismissSessionReceipt, sessionReceipt } from '../state/session-store';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Session receipt (rail-chrome-v2 phase 5, design item 2): a dismissible card
|
|
6
|
+
* at the canvas region's top-right after a session ends — what the session did
|
|
7
|
+
* (items / done / vetoed), the pre-session snapshot (taken at attach, so View
|
|
8
|
+
* diff shows the session's changes and a restore undoes them), and History
|
|
9
|
+
* (the snapshots panel). Client-side state, cleared on dismiss.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface DiffSummary {
|
|
13
|
+
added: number;
|
|
14
|
+
removed: number;
|
|
15
|
+
modified: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The wire shape is SnapshotDiffResult (addedNodes/removedNodes/modifiedNodes/addedEdges/removedEdges). */
|
|
19
|
+
export function summarizeDiff(diff: unknown): DiffSummary | null {
|
|
20
|
+
if (!diff || typeof diff !== 'object') return null;
|
|
21
|
+
const d = diff as Record<string, unknown>;
|
|
22
|
+
const count = (value: unknown): number => (Array.isArray(value) ? value.length : 0);
|
|
23
|
+
return {
|
|
24
|
+
added: count(d.addedNodes) + count(d.addedEdges),
|
|
25
|
+
removed: count(d.removedNodes) + count(d.removedEdges),
|
|
26
|
+
modified: count(d.modifiedNodes),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function SessionReceipt({ onOpenSnapshots }: { onOpenSnapshots: () => void }) {
|
|
31
|
+
const receipt = sessionReceipt.value;
|
|
32
|
+
const [diff, setDiff] = useState<DiffSummary | null>(null);
|
|
33
|
+
const [loadingDiff, setLoadingDiff] = useState(false);
|
|
34
|
+
if (!receipt) return null;
|
|
35
|
+
|
|
36
|
+
const viewDiff = async () => {
|
|
37
|
+
if (!receipt.snapshot) return;
|
|
38
|
+
setLoadingDiff(true);
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(`/api/canvas/snapshots/${encodeURIComponent(receipt.snapshot.id)}/diff`, {
|
|
41
|
+
headers: { 'x-pmx-workbench': '1' },
|
|
42
|
+
});
|
|
43
|
+
if (response.ok) {
|
|
44
|
+
const body = (await response.json()) as { diff?: unknown };
|
|
45
|
+
setDiff(summarizeDiff(body.diff) ?? { added: 0, removed: 0, modified: 0 });
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
setLoadingDiff(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ended = new Date(receipt.endedAt);
|
|
53
|
+
const endedLabel = Number.isNaN(ended.getTime())
|
|
54
|
+
? ''
|
|
55
|
+
: `${String(ended.getHours()).padStart(2, '0')}:${String(ended.getMinutes()).padStart(2, '0')}`;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div class="session-receipt" data-testid="session-receipt" role="status">
|
|
59
|
+
<div class="session-receipt-head">
|
|
60
|
+
<span class="session-receipt-dot" aria-hidden="true" />
|
|
61
|
+
<span class="session-receipt-title">
|
|
62
|
+
{receipt.endedBy === 'human'
|
|
63
|
+
? 'Session ended by you'
|
|
64
|
+
: receipt.endedBy === 'idle-timeout'
|
|
65
|
+
? 'Session ended — idle timeout'
|
|
66
|
+
: receipt.endedBy === 'agent'
|
|
67
|
+
? 'Session ended by the agent'
|
|
68
|
+
: 'Session ended'}
|
|
69
|
+
{endedLabel ? ` · ${endedLabel}` : ''}
|
|
70
|
+
</span>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
class="session-receipt-close"
|
|
74
|
+
onClick={dismissSessionReceipt}
|
|
75
|
+
aria-label="Dismiss receipt"
|
|
76
|
+
>
|
|
77
|
+
×
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="session-receipt-tiles">
|
|
81
|
+
<div class="session-receipt-tile">
|
|
82
|
+
<span class="session-receipt-tile-label">Items</span>
|
|
83
|
+
<span class="session-receipt-tile-value">{receipt.counts.items}</span>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="session-receipt-tile">
|
|
86
|
+
<span class="session-receipt-tile-label">Done</span>
|
|
87
|
+
<span class="session-receipt-tile-value tone-ok">{receipt.counts.done}</span>
|
|
88
|
+
</div>
|
|
89
|
+
{/* Three different outcomes, shown only when present — a cancelled
|
|
90
|
+
duplicate is not a rejection, and neither is an unanswered gate. */}
|
|
91
|
+
{receipt.counts.cancelled > 0 && (
|
|
92
|
+
<div class="session-receipt-tile">
|
|
93
|
+
<span class="session-receipt-tile-label">Cancelled</span>
|
|
94
|
+
<span class="session-receipt-tile-value tone-muted">{receipt.counts.cancelled}</span>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
{receipt.counts.rejected > 0 && (
|
|
98
|
+
<div class="session-receipt-tile">
|
|
99
|
+
<span class="session-receipt-tile-label">Rejected</span>
|
|
100
|
+
<span class="session-receipt-tile-value tone-danger">{receipt.counts.rejected}</span>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
{receipt.counts.held > 0 && (
|
|
104
|
+
<div class="session-receipt-tile">
|
|
105
|
+
<span class="session-receipt-tile-label">Held</span>
|
|
106
|
+
<span class="session-receipt-tile-value tone-warn">{receipt.counts.held}</span>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
<div class="session-receipt-note">
|
|
111
|
+
{receipt.snapshot
|
|
112
|
+
? 'A snapshot of the board from before this session is saved — restore it to undo the session.'
|
|
113
|
+
: receipt.unchanged
|
|
114
|
+
? 'This session changed nothing on the board — no snapshot kept.'
|
|
115
|
+
: 'The board was empty when the session started — nothing to restore.'}
|
|
116
|
+
</div>
|
|
117
|
+
{diff && (
|
|
118
|
+
<div class="session-receipt-diff" data-testid="session-receipt-diff">
|
|
119
|
+
This session: {diff.added} added · {diff.removed} removed · {diff.modified} modified
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
<div class="session-receipt-actions">
|
|
123
|
+
{/* No snapshot → no diff will ever exist for this receipt; a forever-
|
|
124
|
+
disabled button is noise (the note above says why). */}
|
|
125
|
+
{receipt.snapshot && (
|
|
126
|
+
<button type="button" class="session-receipt-primary" disabled={loadingDiff} onClick={() => void viewDiff()}>
|
|
127
|
+
{loadingDiff ? 'Comparing…' : 'View diff'}
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
class="session-receipt-secondary"
|
|
133
|
+
title="Open the History drawer — saved boards and past sessions"
|
|
134
|
+
onClick={onOpenSnapshots}
|
|
135
|
+
>
|
|
136
|
+
History
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isMac, modChord } from '../utils/platform';
|
|
2
2
|
|
|
3
3
|
interface ShortcutGroup {
|
|
4
4
|
title: string;
|
|
@@ -6,19 +6,32 @@ interface ShortcutGroup {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
const GROUPS: ShortcutGroup[] = [
|
|
9
|
+
{
|
|
10
|
+
title: 'Tools',
|
|
11
|
+
shortcuts: [
|
|
12
|
+
{ keys: 'V', desc: 'Select tool — drag empty space to lasso' },
|
|
13
|
+
{ keys: 'Space (hold)', desc: 'Pan — drag anywhere moves the board' },
|
|
14
|
+
{ keys: 'A', desc: 'Annotate — draw on the canvas' },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
9
17
|
{
|
|
10
18
|
title: 'Navigation',
|
|
11
19
|
shortcuts: [
|
|
12
|
-
{ keys:
|
|
20
|
+
{ keys: modChord('K'), desc: 'Command palette — search nodes & actions' },
|
|
13
21
|
{ keys: 'Tab / Shift+Tab', desc: 'Cycle through nodes' },
|
|
14
22
|
{ keys: '\u2190 \u2191 \u2192 \u2193', desc: 'Walk graph along edges (when node focused)' },
|
|
15
|
-
{ keys:
|
|
16
|
-
{ keys:
|
|
23
|
+
{ keys: 'F', desc: 'Fit all nodes on screen' },
|
|
24
|
+
{ keys: modChord('0'), desc: 'Reset viewport to origin' },
|
|
25
|
+
{ keys: `${modChord('+')} / ${modChord('\u2212')}`, desc: 'Zoom in / out' },
|
|
17
26
|
],
|
|
18
27
|
},
|
|
19
28
|
{
|
|
20
29
|
title: 'Creation',
|
|
21
30
|
shortcuts: [
|
|
31
|
+
{ keys: 'M', desc: 'New markdown note' },
|
|
32
|
+
{ keys: 'I / W / H', desc: 'New image / webpage / HTML surface' },
|
|
33
|
+
{ keys: 'Shift+F', desc: 'New file node (workspace path)' },
|
|
34
|
+
{ keys: 'G / Shift+G', desc: 'Group the selection (new frame when empty) / ungroup' },
|
|
22
35
|
{ keys: 'Double-click', desc: 'Create new markdown note on canvas' },
|
|
23
36
|
{ keys: 'Drag port \u2192 node', desc: 'Connect two nodes (hover to reveal ports)' },
|
|
24
37
|
],
|
|
@@ -28,7 +41,9 @@ const GROUPS: ShortcutGroup[] = [
|
|
|
28
41
|
shortcuts: [
|
|
29
42
|
{ keys: 'Click', desc: 'Focus node (highlights neighbors & edges)' },
|
|
30
43
|
{ keys: 'Shift+Click', desc: 'Toggle node in multi-selection' },
|
|
31
|
-
{ keys: '
|
|
44
|
+
{ keys: 'Drag empty space', desc: 'Lasso select multiple nodes' },
|
|
45
|
+
{ keys: isMac ? 'Delete / \u232b' : 'Delete / Backspace', desc: 'Delete the selection (or the focused node)' },
|
|
46
|
+
{ keys: isMac ? '\u2318Z / \u21e7\u2318Z' : 'Ctrl+Z / Ctrl+Y', desc: 'Undo / redo — agent edits included' },
|
|
32
47
|
{ keys: 'Esc', desc: 'Clear selection / close overlay' },
|
|
33
48
|
],
|
|
34
49
|
},
|
|
@@ -36,7 +51,7 @@ const GROUPS: ShortcutGroup[] = [
|
|
|
36
51
|
title: 'View',
|
|
37
52
|
shortcuts: [
|
|
38
53
|
{ keys: '?', desc: 'Toggle this shortcut overlay' },
|
|
39
|
-
{ keys: 'Minimap', desc: 'Click/drag to navigate (toggle in
|
|
54
|
+
{ keys: 'Minimap', desc: 'Click/drag to navigate (toggle in the rail)' },
|
|
40
55
|
{ keys: 'Right-click', desc: 'Context menu — dock, focus, connect' },
|
|
41
56
|
],
|
|
42
57
|
},
|