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.
Files changed (210) 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 +139 -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/GroupNode.d.ts +6 -1
  38. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  39. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  40. package/dist/types/client/state/canvas-store.d.ts +49 -5
  41. package/dist/types/client/state/human-store.d.ts +26 -0
  42. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  43. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  44. package/dist/types/client/state/node-factory.d.ts +0 -1
  45. package/dist/types/client/state/presence-store.d.ts +78 -0
  46. package/dist/types/client/state/session-store.d.ts +176 -0
  47. package/dist/types/client/types.d.ts +2 -1
  48. package/dist/types/client/utils/platform.d.ts +2 -0
  49. package/dist/types/server/agent-presence.d.ts +192 -0
  50. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  51. package/dist/types/server/ax-interaction.d.ts +4 -10
  52. package/dist/types/server/ax-state-manager.d.ts +21 -2
  53. package/dist/types/server/ax-state.d.ts +49 -3
  54. package/dist/types/server/ax-wait.d.ts +13 -0
  55. package/dist/types/server/canvas-db.d.ts +9 -1
  56. package/dist/types/server/canvas-operations.d.ts +1 -0
  57. package/dist/types/server/canvas-state.d.ts +27 -13
  58. package/dist/types/server/canvas-validation.d.ts +0 -14
  59. package/dist/types/server/html-surface.d.ts +1 -1
  60. package/dist/types/server/human-presence.d.ts +40 -0
  61. package/dist/types/server/index.d.ts +29 -3
  62. package/dist/types/server/intent-registry.d.ts +7 -0
  63. package/dist/types/server/mutation-history.d.ts +15 -2
  64. package/dist/types/server/operations/invoker.d.ts +13 -2
  65. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  66. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  67. package/dist/types/server/operations/registry.d.ts +11 -0
  68. package/dist/types/server/operations/types.d.ts +1 -1
  69. package/dist/types/server/scope-fence.d.ts +62 -0
  70. package/dist/types/server/server.d.ts +0 -12
  71. package/dist/types/shared/agent-presence.d.ts +130 -0
  72. package/dist/types/shared/approval-gates.d.ts +19 -0
  73. package/dist/types/shared/auto-arrange.d.ts +0 -1
  74. package/dist/types/shared/ax-kinds.d.ts +11 -0
  75. package/dist/types/shared/human-presence.d.ts +31 -0
  76. package/dist/types/shared/iframe-probe.d.ts +8 -0
  77. package/dist/types/shared/scope-fence.d.ts +21 -0
  78. package/docs/RELEASE.md +6 -0
  79. package/docs/ax-host-adapter-contract.md +30 -14
  80. package/docs/cli.md +37 -1
  81. package/docs/environment.md +3 -0
  82. package/docs/http-api.md +222 -5
  83. package/docs/mcp.md +6 -5
  84. package/docs/node-types.md +3 -1
  85. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  86. package/docs/screenshot.png +0 -0
  87. package/docs/screenshots/multi-agent-live.png +0 -0
  88. package/docs/screenshots/workbench-live-dark.png +0 -0
  89. package/docs/screenshots/workbench-live-light.png +0 -0
  90. package/docs/sdk.md +1 -1
  91. package/package.json +1 -1
  92. package/skills/pmx-canvas/SKILL.md +103 -16
  93. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  94. package/skills/pmx-canvas/references/full-reference.md +55 -16
  95. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  96. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  97. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  98. package/src/cli/agent.ts +1 -0
  99. package/src/cli/commands/copilot.ts +15 -8
  100. package/src/cli/commands/nodes.ts +1 -19
  101. package/src/cli/commands/pump.ts +252 -0
  102. package/src/cli/index.ts +1 -5
  103. package/src/cli/shared.ts +1 -1
  104. package/src/client/App.tsx +209 -585
  105. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  106. package/src/client/canvas/AttentionHistory.tsx +0 -10
  107. package/src/client/canvas/BarHint.tsx +49 -0
  108. package/src/client/canvas/CanvasNode.tsx +488 -136
  109. package/src/client/canvas/CanvasViewport.tsx +190 -94
  110. package/src/client/canvas/CommandBar.tsx +140 -0
  111. package/src/client/canvas/CommandPalette.tsx +133 -58
  112. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  113. package/src/client/canvas/ContextMenu.tsx +88 -27
  114. package/src/client/canvas/ContextPinBar.tsx +2 -2
  115. package/src/client/canvas/EdgeLayer.tsx +121 -38
  116. package/src/client/canvas/EmptyState.tsx +93 -0
  117. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  118. package/src/client/canvas/ExternalWriters.tsx +308 -0
  119. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  120. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  121. package/src/client/canvas/IntentLayer.tsx +1 -1
  122. package/src/client/canvas/Minimap.tsx +117 -190
  123. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  124. package/src/client/canvas/SelectionBar.tsx +175 -23
  125. package/src/client/canvas/SessionPanel.tsx +481 -0
  126. package/src/client/canvas/SessionReceipt.tsx +141 -0
  127. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  128. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  129. package/src/client/canvas/TextPrompt.tsx +128 -0
  130. package/src/client/canvas/ToolRail.tsx +447 -0
  131. package/src/client/canvas/TopBar.tsx +308 -0
  132. package/src/client/canvas/auto-fit.ts +1 -5
  133. package/src/client/canvas/canvas-area.ts +41 -0
  134. package/src/client/canvas/create-in-view.ts +48 -0
  135. package/src/client/canvas/import-files.ts +64 -0
  136. package/src/client/canvas/kind-colors.ts +22 -0
  137. package/src/client/canvas/snap-guides.ts +1 -1
  138. package/src/client/canvas/use-focus-trap.ts +54 -0
  139. package/src/client/canvas/use-now.ts +17 -0
  140. package/src/client/canvas/use-pan-zoom.ts +13 -2
  141. package/src/client/icons.tsx +57 -18
  142. package/src/client/nodes/GroupNode.tsx +9 -28
  143. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  144. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  145. package/src/client/nodes/MarkdownNode.tsx +59 -24
  146. package/src/client/nodes/MdFormatBar.tsx +2 -4
  147. package/src/client/nodes/inline-editor-commands.ts +17 -6
  148. package/src/client/nodes/use-surface-frame.ts +3 -1
  149. package/src/client/state/attention-bridge.ts +3 -0
  150. package/src/client/state/canvas-store.ts +273 -84
  151. package/src/client/state/human-store.ts +175 -0
  152. package/src/client/state/iframe-mode.ts +33 -9
  153. package/src/client/state/intent-bridge.ts +75 -13
  154. package/src/client/state/node-factory.ts +2 -8
  155. package/src/client/state/presence-store.ts +168 -0
  156. package/src/client/state/session-store.ts +419 -0
  157. package/src/client/state/sse-bridge.ts +93 -30
  158. package/src/client/theme/global.css +3069 -796
  159. package/src/client/types.ts +3 -1
  160. package/src/client/utils/platform.ts +6 -1
  161. package/src/mcp/canvas-access.ts +2 -2
  162. package/src/server/agent-presence.ts +755 -0
  163. package/src/server/ax-context.ts +45 -0
  164. package/src/server/ax-gate-ttl.ts +61 -0
  165. package/src/server/ax-interaction.ts +29 -1
  166. package/src/server/ax-state-manager.ts +78 -6
  167. package/src/server/ax-state.ts +82 -15
  168. package/src/server/ax-wait.ts +33 -0
  169. package/src/server/canvas-db.ts +103 -33
  170. package/src/server/canvas-operations.ts +17 -8
  171. package/src/server/canvas-state.ts +131 -58
  172. package/src/server/canvas-validation.ts +3 -53
  173. package/src/server/demo-state.json +2 -70
  174. package/src/server/html-surface.ts +11 -2
  175. package/src/server/human-presence.ts +160 -0
  176. package/src/server/index.ts +107 -4
  177. package/src/server/intent-registry.ts +13 -0
  178. package/src/server/mutation-history.ts +47 -2
  179. package/src/server/operations/composites.ts +6 -3
  180. package/src/server/operations/http.ts +10 -1
  181. package/src/server/operations/index.ts +4 -0
  182. package/src/server/operations/invoker.ts +33 -6
  183. package/src/server/operations/ops/ax-presence.ts +62 -0
  184. package/src/server/operations/ops/ax-read.ts +8 -0
  185. package/src/server/operations/ops/ax-state.ts +106 -1
  186. package/src/server/operations/ops/ax-timeline.ts +54 -5
  187. package/src/server/operations/ops/ax-work.ts +46 -0
  188. package/src/server/operations/ops/batch.ts +3 -1
  189. package/src/server/operations/ops/edges.ts +88 -2
  190. package/src/server/operations/ops/groups.ts +4 -3
  191. package/src/server/operations/ops/human-presence.ts +35 -0
  192. package/src/server/operations/ops/json-render.ts +11 -1
  193. package/src/server/operations/ops/nodes.ts +65 -16
  194. package/src/server/operations/ops/query.ts +1 -0
  195. package/src/server/operations/ops/snapshots.ts +16 -0
  196. package/src/server/operations/registry.ts +196 -1
  197. package/src/server/operations/types.ts +1 -1
  198. package/src/server/scope-fence.ts +207 -0
  199. package/src/server/server.ts +177 -79
  200. package/src/server/trace-manager.ts +0 -2
  201. package/src/server/web-artifacts.ts +0 -1
  202. package/src/shared/agent-presence.ts +162 -0
  203. package/src/shared/approval-gates.ts +35 -0
  204. package/src/shared/auto-arrange.ts +121 -13
  205. package/src/shared/ax-kinds.ts +23 -0
  206. package/src/shared/human-presence.ts +30 -0
  207. package/src/shared/iframe-probe.ts +8 -0
  208. package/src/shared/scope-fence.ts +31 -0
  209. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  210. package/src/client/canvas/DockedNode.tsx +0 -165
@@ -1,11 +1,31 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import {
3
+ type CanvasSnapshotInfo,
4
+ deleteSnapshot,
3
5
  listSnapshots,
4
- saveSnapshot,
6
+ requestJson,
5
7
  restoreSnapshot,
6
- deleteSnapshot,
7
- type CanvasSnapshotInfo,
8
+ saveSnapshot,
8
9
  } from '../state/intent-bridge';
10
+ import { type DiffSummary, summarizeDiff } from './SessionReceipt';
11
+
12
+ /**
13
+ * History drawer (rail-chrome-v2 phase 7, design item 8): snapshots and
14
+ * session receipts in one reverse-chronological list. A session's entry IS
15
+ * the snapshot the server took when it attached ("Before session · <label> ·
16
+ * HH:MM"), so the two histories are one timeline: session entries carry View
17
+ * diff (the session's own changes) and Restore pre-state; manual snapshots
18
+ * keep Restore / Delete. Opened from the rail's Snapshots button or the
19
+ * receipt's Full log. The root keeps the `.snapshot-panel` hook.
20
+ */
21
+
22
+ const SESSION_PREFIX = 'Before session · ';
23
+
24
+ function timeLabel(iso: string): string {
25
+ const date = new Date(iso);
26
+ if (Number.isNaN(date.getTime())) return '';
27
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
28
+ }
9
29
 
10
30
  function timeAgo(iso: string): string {
11
31
  const diff = Date.now() - new Date(iso).getTime();
@@ -18,6 +38,14 @@ function timeAgo(iso: string): string {
18
38
  return `${days}d ago`;
19
39
  }
20
40
 
41
+ /** "Before session · Copilot · 14:00" → "Copilot". */
42
+ function sessionLabel(name: string): string | null {
43
+ if (!name.startsWith(SESSION_PREFIX)) return null;
44
+ const rest = name.slice(SESSION_PREFIX.length);
45
+ const cut = rest.lastIndexOf(' · ');
46
+ return cut > 0 ? rest.slice(0, cut) : rest;
47
+ }
48
+
21
49
  export function SnapshotPanel({
22
50
  open,
23
51
  onClose,
@@ -33,10 +61,10 @@ export function SnapshotPanel({
33
61
  const [restoringId, setRestoringId] = useState<string | null>(null);
34
62
  const [nameInput, setNameInput] = useState('');
35
63
  const [confirming, setConfirming] = useState<{ id: string; action: 'restore' | 'delete' } | null>(null);
64
+ const [diffs, setDiffs] = useState<Record<string, DiffSummary | 'loading'>>({});
36
65
  const panelRef = useRef<HTMLDivElement>(null);
37
66
  const inputRef = useRef<HTMLInputElement>(null);
38
67
 
39
- // Load snapshots when panel opens
40
68
  useEffect(() => {
41
69
  if (!open) return;
42
70
  setLoading(true);
@@ -46,7 +74,6 @@ export function SnapshotPanel({
46
74
  });
47
75
  }, [open]);
48
76
 
49
- // Focus input on open
50
77
  useEffect(() => {
51
78
  if (open) setTimeout(() => inputRef.current?.focus(), 50);
52
79
  }, [open]);
@@ -65,7 +92,6 @@ export function SnapshotPanel({
65
92
  return () => document.removeEventListener('mousedown', handler);
66
93
  }, [open, onClose]);
67
94
 
68
- // Escape to close
69
95
  useEffect(() => {
70
96
  if (!open) return;
71
97
  const handler = (e: KeyboardEvent) => {
@@ -100,30 +126,79 @@ export function SnapshotPanel({
100
126
 
101
127
  const handleDelete = useCallback(async (id: string) => {
102
128
  const result = await deleteSnapshot(id);
103
- if (result.ok) {
104
- setSnapshots((prev) => prev.filter((s) => s.id !== id));
105
- }
129
+ if (result.ok) setSnapshots((prev) => prev.filter((s) => s.id !== id));
106
130
  setConfirming(null);
107
131
  }, []);
108
132
 
133
+ const viewDiff = useCallback(async (id: string) => {
134
+ setDiffs((prev) => ({ ...prev, [id]: 'loading' }));
135
+ const body = await requestJson<{ diff?: unknown }>(
136
+ 'snapshotDiff',
137
+ `/api/canvas/snapshots/${encodeURIComponent(id)}/diff`,
138
+ {},
139
+ );
140
+ setDiffs((prev) => ({ ...prev, [id]: summarizeDiff(body.diff) ?? { added: 0, removed: 0, modified: 0 } }));
141
+ }, []);
142
+
109
143
  if (!open) return null;
110
144
 
111
- // Position below toolbar button
112
- const anchorRect = anchorRef.current?.getBoundingClientRect();
113
- const left = anchorRect ? Math.max(8, anchorRect.left - 120) : 100;
114
- const top = anchorRect ? anchorRect.bottom + 8 : 48;
145
+ const actions = (snap: CanvasSnapshotInfo, restoreLabel: string) =>
146
+ confirming?.id === snap.id ? (
147
+ <>
148
+ <button
149
+ type="button"
150
+ class={`snapshot-action-btn ${confirming.action === 'delete' ? 'snapshot-action-confirm' : 'snapshot-action-restore'}`}
151
+ onClick={() => (confirming.action === 'delete' ? handleDelete(snap.id) : handleRestore(snap.id))}
152
+ title={confirming.action === 'delete' ? 'Confirm delete' : 'Confirm restore'}
153
+ disabled={restoringId !== null}
154
+ >
155
+ {confirming.action === 'delete' ? 'Delete' : restoringId === snap.id ? 'Restoring…' : 'Confirm'}
156
+ </button>
157
+ <button
158
+ type="button"
159
+ class="snapshot-action-btn"
160
+ onClick={() => setConfirming(null)}
161
+ title="Cancel"
162
+ disabled={restoringId !== null}
163
+ >
164
+ Cancel
165
+ </button>
166
+ </>
167
+ ) : (
168
+ <>
169
+ <button
170
+ type="button"
171
+ class="snapshot-action-btn snapshot-action-restore"
172
+ onClick={() => setConfirming({ id: snap.id, action: 'restore' })}
173
+ title="Restore this snapshot — replaces the current canvas (undoable)"
174
+ disabled={restoringId !== null}
175
+ >
176
+ {restoringId === snap.id ? 'Restoring…' : restoreLabel}
177
+ </button>
178
+ <button
179
+ type="button"
180
+ class="snapshot-action-btn snapshot-action-delete"
181
+ onClick={() => setConfirming({ id: snap.id, action: 'delete' })}
182
+ title="Delete this snapshot"
183
+ aria-label="Delete snapshot"
184
+ disabled={restoringId !== null}
185
+ >
186
+ ✕
187
+ </button>
188
+ </>
189
+ );
115
190
 
116
191
  return (
117
- <div ref={panelRef} class="snapshot-panel" style={{ left: `${left}px`, top: `${top}px` }}>
118
- {/* Header */}
192
+ <div ref={panelRef} class="snapshot-panel" data-testid="history-drawer" role="dialog" aria-label="History">
119
193
  <div class="snapshot-panel-header">
120
- <span class="snapshot-panel-title">Snapshots</span>
121
- <button type="button" class="snapshot-panel-close" onClick={onClose} title="Close">
122
- {'\u00d7'}
194
+ <span class="snapshot-panel-title">History</span>
195
+ <span class="snapshot-panel-sub">snapshots + sessions</span>
196
+ <span class="snapshot-panel-spacer" />
197
+ <button type="button" class="snapshot-panel-close" onClick={onClose} title="Close" aria-label="Close history">
198
+ ×
123
199
  </button>
124
200
  </div>
125
201
 
126
- {/* Save form */}
127
202
  <div class="snapshot-save-form">
128
203
  <input
129
204
  ref={inputRef}
@@ -134,85 +209,96 @@ export function SnapshotPanel({
134
209
  onKeyDown={(e) => {
135
210
  if (e.key === 'Enter') handleSave();
136
211
  }}
137
- placeholder="Snapshot name..."
212
+ placeholder="Save a snapshot as…"
213
+ aria-label="Snapshot name"
138
214
  maxLength={80}
139
215
  disabled={saving}
140
216
  />
141
217
  <button type="button" class="snapshot-save-btn" onClick={handleSave} disabled={!nameInput.trim() || saving}>
142
- {saving ? '...' : 'Save'}
218
+ {saving ? '…' : 'Save'}
143
219
  </button>
144
220
  </div>
145
-
146
221
  <div class="snapshot-restore-note">Restoring replaces the current canvas. You can undo it if needed.</div>
147
222
 
148
- {/* Snapshot list */}
149
223
  <div class="snapshot-list">
150
- {loading && <div class="snapshot-empty">Loading...</div>}
151
-
224
+ {loading && <div class="snapshot-empty">Loading…</div>}
152
225
  {!loading && snapshots.length === 0 && (
153
- <div class="snapshot-empty">No snapshots yet. Save one to capture the current canvas state.</div>
226
+ <div class="snapshot-empty">
227
+ No history yet. Save a snapshot, or start an agent session — it snapshots for you.
228
+ </div>
154
229
  )}
155
-
230
+ {/* Boards the human saved on purpose come first; the automatic
231
+ pre-session snapshots live under their own header — mixing them
232
+ buried the deliberate saves under session churn. */}
156
233
  {!loading &&
157
- snapshots.map((snap) => (
158
- <div key={snap.id} class="snapshot-item">
159
- <div class="snapshot-item-info">
160
- <span class="snapshot-item-name">{snap.name}</span>
161
- <span class="snapshot-item-meta">
162
- {snap.nodeCount} node{snap.nodeCount !== 1 ? 's' : ''}
163
- {snap.edgeCount > 0 ? ` \u00b7 ${snap.edgeCount} edge${snap.edgeCount !== 1 ? 's' : ''}` : ''}
164
- {' \u00b7 '}
165
- {timeAgo(snap.createdAt)}
166
- </span>
167
- </div>
168
- <div class="snapshot-item-actions">
169
- {confirming?.id === snap.id ? (
170
- <>
234
+ [
235
+ ...snapshots.filter((snap) => sessionLabel(snap.name) === null),
236
+ ...(snapshots.some((snap) => sessionLabel(snap.name) !== null)
237
+ ? [{ id: '__sessions-header__' } as (typeof snapshots)[number]]
238
+ : []),
239
+ ...snapshots.filter((snap) => sessionLabel(snap.name) !== null),
240
+ ].map((snap) => {
241
+ if (snap.id === '__sessions-header__') {
242
+ return (
243
+ <div key={snap.id} class="snapshot-section-header" data-testid="history-sessions-header">
244
+ Sessions
245
+ </div>
246
+ );
247
+ }
248
+ const label = sessionLabel(snap.name);
249
+ const diff = diffs[snap.id];
250
+ if (label !== null) {
251
+ return (
252
+ <div key={snap.id} class="snapshot-item is-session" data-testid="history-session">
253
+ <div class="snapshot-item-head">
254
+ <span class="snapshot-session-dot" aria-hidden="true" />
255
+ <span class="snapshot-item-name">Agent session — {label}</span>
256
+ <span class="snapshot-item-time">{timeLabel(snap.createdAt)}</span>
257
+ </div>
258
+ <div class="snapshot-item-meta">
259
+ snapshot at session start · {snap.nodeCount} node{snap.nodeCount !== 1 ? 's' : ''}
260
+ {diff && diff !== 'loading' && (
261
+ <span class="snapshot-item-diff">
262
+ {' '}
263
+ · since: {diff.added} added · {diff.removed} removed · {diff.modified} modified
264
+ </span>
265
+ )}
266
+ </div>
267
+ <div class="snapshot-item-actions is-session">
171
268
  <button
172
269
  type="button"
173
- class={`snapshot-action-btn ${confirming.action === 'delete' ? 'snapshot-action-confirm' : 'snapshot-action-restore'}`}
174
- onClick={() => (confirming.action === 'delete' ? handleDelete(snap.id) : handleRestore(snap.id))}
175
- title={confirming.action === 'delete' ? 'Confirm delete' : 'Confirm restore'}
176
- disabled={restoringId !== null}
270
+ class="snapshot-action-btn snapshot-action-diff"
271
+ disabled={diff === 'loading'}
272
+ onClick={() => void viewDiff(snap.id)}
177
273
  >
178
- {confirming.action === 'delete' ? 'Delete' : restoringId === snap.id ? 'Restoring...' : 'Confirm'}
274
+ {diff === 'loading' ? 'Comparing…' : 'View diff'}
179
275
  </button>
180
- <button
181
- type="button"
182
- class="snapshot-action-btn"
183
- onClick={() => setConfirming(null)}
184
- title="Cancel"
185
- disabled={restoringId !== null}
186
- >
187
- Cancel
188
- </button>
189
- </>
190
- ) : (
191
- <>
192
- <button
193
- type="button"
194
- class="snapshot-action-btn snapshot-action-restore"
195
- onClick={() => setConfirming({ id: snap.id, action: 'restore' })}
196
- title="Restore this snapshot"
197
- disabled={restoringId !== null}
198
- >
199
- {restoringId === snap.id ? 'Restoring...' : 'Restore'}
200
- </button>
201
- <button
202
- type="button"
203
- class="snapshot-action-btn snapshot-action-delete"
204
- onClick={() => setConfirming({ id: snap.id, action: 'delete' })}
205
- title="Delete this snapshot"
206
- disabled={restoringId !== null}
207
- >
208
- {'\u2715'}
209
- </button>
210
- </>
211
- )}
276
+ {actions(snap, 'Restore pre-state')}
277
+ </div>
278
+ </div>
279
+ );
280
+ }
281
+ return (
282
+ <div key={snap.id} class="snapshot-item" data-testid="history-snapshot">
283
+ <div class="snapshot-item-head">
284
+ <span class="snapshot-item-glyph" aria-hidden="true">
285
+ ◫
286
+ </span>
287
+ <span class="snapshot-item-name">{snap.name}</span>
288
+ <span class="snapshot-item-time">{timeLabel(snap.createdAt)}</span>
289
+ </div>
290
+ <div class="snapshot-item-meta">
291
+ {snap.nodeCount} node{snap.nodeCount !== 1 ? 's' : ''}
292
+ {snap.edgeCount > 0 ? ` · ${snap.edgeCount} edge${snap.edgeCount !== 1 ? 's' : ''}` : ''}
293
+ {' · '}
294
+ {timeAgo(snap.createdAt)}
295
+ </div>
296
+ <div class="snapshot-item-actions">{actions(snap, 'Restore')}</div>
212
297
  </div>
213
- </div>
214
- ))}
298
+ );
299
+ })}
215
300
  </div>
301
+ <div class="snapshot-panel-foot">Every agent session start writes a snapshot automatically.</div>
216
302
  </div>
217
303
  );
218
304
  }
@@ -0,0 +1,128 @@
1
+ import { signal } from '@preact/signals';
2
+ import { useEffect, useRef } from 'preact/hooks';
3
+
4
+ /**
5
+ * In-canvas replacement for `window.prompt`, which embedded browser panes
6
+ * silently no-op (a Shift+F that "does nothing") and no test can drive. One
7
+ * request at a time; a new ask cancels the previous one.
8
+ */
9
+ interface TextPromptRequest {
10
+ title: string;
11
+ placeholder: string;
12
+ /** Prefilled (and pre-selected) value — edit flows start from the current text. */
13
+ initial: string;
14
+ /** Submitting an empty field resolves '' instead of null — "clear the value" flows. */
15
+ allowEmpty: boolean;
16
+ confirm: string;
17
+ /** Bumped per ask so consecutive requests get a FRESH input element. */
18
+ seq: number;
19
+ resolve: (value: string | null) => void;
20
+ }
21
+
22
+ export const textPromptRequest = signal<TextPromptRequest | null>(null);
23
+ let promptSeq = 0;
24
+
25
+ export function askText(
26
+ title: string,
27
+ placeholder: string,
28
+ opts: { initial?: string; allowEmpty?: boolean; confirm?: string } = {},
29
+ ): Promise<string | null> {
30
+ return new Promise((resolve) => {
31
+ textPromptRequest.value?.resolve(null);
32
+ promptSeq += 1;
33
+ textPromptRequest.value = {
34
+ seq: promptSeq,
35
+ title,
36
+ placeholder,
37
+ initial: opts.initial ?? '',
38
+ allowEmpty: opts.allowEmpty ?? false,
39
+ confirm: opts.confirm ?? 'Add',
40
+ resolve,
41
+ };
42
+ });
43
+ }
44
+
45
+ function settle(value: string | null): void {
46
+ const request = textPromptRequest.value;
47
+ textPromptRequest.value = null;
48
+ request?.resolve(value);
49
+ }
50
+
51
+ export function TextPrompt() {
52
+ const request = textPromptRequest.value;
53
+ const inputRef = useRef<HTMLInputElement>(null);
54
+
55
+ useEffect(() => {
56
+ if (!request) return;
57
+ // Esc closes the dialog wherever focus sits (a click can move focus off the
58
+ // input, and the global shortcut layer must never see this Esc). Focus and
59
+ // the prefill are NOT here: an effect runs a frame after the dialog paints,
60
+ // and a key (or a test's fill) inside that gap lands on the wrong target.
61
+ const onKey = (e: KeyboardEvent) => {
62
+ if (e.key !== 'Escape') return;
63
+ e.preventDefault();
64
+ e.stopPropagation();
65
+ settle(null);
66
+ };
67
+ window.addEventListener('keydown', onKey, { capture: true });
68
+ return () => window.removeEventListener('keydown', onKey, { capture: true });
69
+ }, [request]);
70
+
71
+ if (!request) return null;
72
+
73
+ const submit = () => {
74
+ const value = inputRef.current?.value.trim() ?? '';
75
+ settle(value ? value : request.allowEmpty ? '' : null);
76
+ };
77
+
78
+ return (
79
+ <div class="text-prompt-backdrop" onPointerDown={() => settle(null)}>
80
+ <div
81
+ class="text-prompt"
82
+ role="dialog"
83
+ aria-label={request.title}
84
+ data-testid="text-prompt"
85
+ onPointerDown={(e) => e.stopPropagation()}
86
+ >
87
+ <div class="text-prompt-title">{request.title}</div>
88
+ <input
89
+ // A fresh element per ask (so defaultValue applies) focused in the
90
+ // ref callback, which Preact runs synchronously during commit — the
91
+ // dialog is never on screen unfocused.
92
+ key={request.seq}
93
+ ref={(el) => {
94
+ inputRef.current = el;
95
+ if (!el) return;
96
+ el.focus();
97
+ // Edit flows prefill — selecting it lets a retype replace in one stroke.
98
+ if (request.initial) el.select();
99
+ }}
100
+ class="text-prompt-input"
101
+ type="text"
102
+ defaultValue={request.initial}
103
+ placeholder={request.placeholder}
104
+ onKeyDown={(e) => {
105
+ // Contained: the global shortcut layer must not see these.
106
+ if (e.key === 'Enter') {
107
+ e.preventDefault();
108
+ e.stopPropagation();
109
+ submit();
110
+ } else if (e.key === 'Escape') {
111
+ e.preventDefault();
112
+ e.stopPropagation();
113
+ settle(null);
114
+ }
115
+ }}
116
+ />
117
+ <div class="text-prompt-actions">
118
+ <button type="button" class="text-prompt-cancel" onClick={() => settle(null)}>
119
+ Cancel
120
+ </button>
121
+ <button type="button" class="text-prompt-confirm" onClick={submit}>
122
+ {request.confirm}
123
+ </button>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+ }