pmx-canvas 0.4.7 → 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.
Files changed (212) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3069 -796
  6. package/dist/canvas/index.js +104 -104
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +25 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +23 -1
  57. package/dist/types/server/canvas-operations.d.ts +1 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +54 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +1 -1
  93. package/skills/pmx-canvas/SKILL.md +109 -18
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +57 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +252 -0
  103. package/src/cli/index.ts +1 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +41 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +79 -2
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +273 -84
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3069 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +237 -61
  172. package/src/server/canvas-operations.ts +17 -8
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +107 -4
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +65 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +16 -0
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. 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 { MOD_KEY as MOD } from '../utils/platform';
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: `${MOD}+K`, desc: 'Command palette — search nodes & actions' },
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: `${MOD}+0`, desc: 'Reset viewport to origin' },
16
- { keys: `${MOD}++ / ${MOD}+\u2212`, desc: 'Zoom in / out' },
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: 'Shift+Drag', desc: 'Lasso select multiple nodes' },
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 toolbar)' },
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
  },