pmx-canvas 0.1.15 → 0.1.16

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.
@@ -1,4 +1,4 @@
1
- import type { CanvasNodeState } from '../types';
1
+ import { type CanvasNodeState } from '../types';
2
2
  interface ContextCard {
3
3
  key?: string;
4
4
  title?: string;
@@ -25,10 +25,19 @@ export interface ContextNodeFallbackDisplay {
25
25
  summary: string;
26
26
  path: string;
27
27
  }
28
+ export interface PinnedContextDisplay {
29
+ id: string;
30
+ title: string;
31
+ summary: string;
32
+ kind: string;
33
+ path: string;
34
+ }
28
35
  export declare function normalizeContextCardDisplay(card: ContextCard): ContextCardDisplay;
29
36
  export declare function normalizeContextNodeFallback(nodeData: Record<string, unknown>): ContextNodeFallbackDisplay | null;
30
- export declare function ContextNode({ node, expanded, }: {
37
+ export declare function normalizePinnedContextDisplay(node: CanvasNodeState): PinnedContextDisplay;
38
+ export declare function ContextNode({ node, expanded, pinnedNodes, }: {
31
39
  node: CanvasNodeState;
32
40
  expanded?: boolean;
41
+ pinnedNodes?: CanvasNodeState[];
33
42
  }): import("preact/src").JSX.Element;
34
43
  export {};
@@ -1,4 +1,5 @@
1
1
  import type { CanvasNodeState } from '../types';
2
+ export declare function getStatusDisplayPhase(node: CanvasNodeState): string;
2
3
  export declare function StatusNode({ node }: {
3
4
  node: CanvasNodeState;
4
5
  }): import("preact/src").JSX.Element;
@@ -64,7 +64,9 @@ export declare function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'node
64
64
  * Cancels any in-flight animation. Direct manipulation (pan/zoom gestures)
65
65
  * should use setViewport() instead for instant response.
66
66
  */
67
- export declare function animateViewport(target: ViewportState, duration?: number): void;
67
+ export declare function animateViewport(target: ViewportState, duration?: number, options?: {
68
+ recordHistory?: boolean;
69
+ }): void;
68
70
  /** Cancel any in-flight viewport animation (e.g. when user starts dragging). */
69
71
  export declare function cancelViewportAnimation(): void;
70
72
  export declare function persistLayout(options?: {
@@ -72,7 +74,9 @@ export declare function persistLayout(options?: {
72
74
  }): void;
73
75
  export declare function restoreLayout(): Map<string, Partial<CanvasNodeState>> | null;
74
76
  export declare function fitAll(containerW: number, containerH: number): void;
75
- export declare function focusNode(id: string): void;
77
+ export declare function focusNode(id: string, options?: {
78
+ recordHistory?: boolean;
79
+ }): void;
76
80
  export declare function cycleActiveNode(direction?: 1 | -1): void;
77
81
  export declare function walkGraph(direction: 'up' | 'down' | 'left' | 'right'): void;
78
82
  export declare function expandNode(id: string): void;
@@ -112,6 +112,8 @@ export declare function updateViewportFromClient(viewport: {
112
112
  x: number;
113
113
  y: number;
114
114
  scale: number;
115
+ }, options?: {
116
+ recordHistory?: boolean;
115
117
  }): Promise<{
116
118
  ok: boolean;
117
119
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -2,10 +2,11 @@ import {
2
2
  clearContextPins,
3
3
  contextPinnedNodeIds,
4
4
  } from '../state/canvas-store';
5
+ import { attentionHistoryOpen } from '../state/attention-store';
5
6
 
6
7
  export function ContextPinBar() {
7
8
  const count = contextPinnedNodeIds.value.size;
8
- if (count === 0) return null;
9
+ if (count === 0 || attentionHistoryOpen.value) return null;
9
10
 
10
11
  return (
11
12
  <div class="context-pin-bar">
@@ -3,7 +3,7 @@ import { LedgerNode } from '../nodes/LedgerNode';
3
3
  import { StatusNode } from '../nodes/StatusNode';
4
4
  import { StatusSummary } from '../nodes/StatusSummary';
5
5
  import { attentionHistoryOpen, closeAttentionHistory } from '../state/attention-store';
6
- import { toggleCollapsed, undockNode } from '../state/canvas-store';
6
+ import { getContextPinnedNodes, toggleCollapsed, undockNode } from '../state/canvas-store';
7
7
  import { TYPE_LABELS } from '../types';
8
8
  import type { CanvasNodeState } from '../types';
9
9
 
@@ -27,7 +27,8 @@ function getContextItemCount(node: CanvasNodeState): number {
27
27
  }
28
28
 
29
29
  function ContextDockedNode({ node }: { node: CanvasNodeState }) {
30
- const count = getContextItemCount(node);
30
+ const pinnedNodes = getContextPinnedNodes();
31
+ const count = pinnedNodes.length > 0 ? pinnedNodes.length : getContextItemCount(node);
31
32
  const hasItems = count > 0;
32
33
  const collapsed = node.collapsed === true;
33
34
 
@@ -117,7 +118,7 @@ function ContextDockedNode({ node }: { node: CanvasNodeState }) {
117
118
  </div>
118
119
  </div>
119
120
  <div class="context-dock-body">
120
- <ContextNode node={node} />
121
+ <ContextNode node={node} pinnedNodes={pinnedNodes} />
121
122
  </div>
122
123
  </aside>
123
124
  );
@@ -1,5 +1,5 @@
1
1
  import { openWorkbenchFile } from '../state/intent-bridge';
2
- import type { CanvasNodeState } from '../types';
2
+ import { TYPE_LABELS, type CanvasNodeState } from '../types';
3
3
 
4
4
  interface ContextCard {
5
5
  key?: string;
@@ -30,6 +30,14 @@ export interface ContextNodeFallbackDisplay {
30
30
  path: string;
31
31
  }
32
32
 
33
+ export interface PinnedContextDisplay {
34
+ id: string;
35
+ title: string;
36
+ summary: string;
37
+ kind: string;
38
+ path: string;
39
+ }
40
+
33
41
  export function normalizeContextCardDisplay(card: ContextCard): ContextCardDisplay {
34
42
  const title = card.title || card.label || card.key || 'Context';
35
43
  const summary = card.summary?.trim() || 'Available in startup context.';
@@ -90,6 +98,24 @@ export function normalizeContextNodeFallback(
90
98
  };
91
99
  }
92
100
 
101
+ export function normalizePinnedContextDisplay(node: CanvasNodeState): PinnedContextDisplay {
102
+ const title = asTrimmedString(node.data.title) || node.id;
103
+ const summary =
104
+ asTrimmedString(node.data.content) ||
105
+ asTrimmedString(node.data.excerpt) ||
106
+ asTrimmedString(node.data.description) ||
107
+ asTrimmedString(node.data.pageTitle) ||
108
+ '';
109
+ const path = asTrimmedString(node.data.path) || asTrimmedString(node.data.url);
110
+ return {
111
+ id: node.id,
112
+ title,
113
+ summary,
114
+ kind: TYPE_LABELS[node.type] ?? node.type,
115
+ path,
116
+ };
117
+ }
118
+
93
119
  function formatTokens(n: number | null): string {
94
120
  if (n === null || !Number.isFinite(n) || n < 0) return '0';
95
121
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`;
@@ -107,9 +133,12 @@ function usageBarColor(utilization: number): string {
107
133
  export function ContextNode({
108
134
  node,
109
135
  expanded = false,
110
- }: { node: CanvasNodeState; expanded?: boolean }) {
136
+ pinnedNodes = [],
137
+ }: { node: CanvasNodeState; expanded?: boolean; pinnedNodes?: CanvasNodeState[] }) {
111
138
  const cards = (node.data.cards as ContextCard[]) ?? [];
112
139
  const auxTabs = (node.data.auxTabs as Array<{ id: string; url: string; reason?: string }>) ?? [];
140
+ const pinnedContext = pinnedNodes.map(normalizePinnedContextDisplay);
141
+ const hasPinnedContext = pinnedContext.length > 0;
113
142
  const currentTokens =
114
143
  typeof node.data.currentTokens === 'number' ? node.data.currentTokens : null;
115
144
  const tokenLimit = typeof node.data.tokenLimit === 'number' ? node.data.tokenLimit : null;
@@ -120,7 +149,9 @@ export function ContextNode({
120
149
  utilization !== null ? Math.max(0, Math.min(100, Math.round(utilization * 100))) : null;
121
150
  const barColor = usageBarColor(utilization ?? 0);
122
151
  const fallback =
123
- cards.length === 0 && auxTabs.length === 0 ? normalizeContextNodeFallback(node.data) : null;
152
+ !hasPinnedContext && cards.length === 0 && auxTabs.length === 0
153
+ ? normalizeContextNodeFallback(node.data)
154
+ : null;
124
155
 
125
156
  const openCard = async (card: ContextCard): Promise<void> => {
126
157
  const path = typeof card.path === 'string' ? card.path.trim() : '';
@@ -181,7 +212,98 @@ export function ContextNode({
181
212
  </div>
182
213
  )}
183
214
 
184
- {cards.length > 0 && (
215
+ {hasPinnedContext && (
216
+ <div>
217
+ <div
218
+ style={{
219
+ fontSize: '10px',
220
+ fontWeight: 600,
221
+ color: 'var(--c-muted)',
222
+ textTransform: 'uppercase',
223
+ letterSpacing: '0.04em',
224
+ marginBottom: '6px',
225
+ }}
226
+ >
227
+ Pinned Context ({pinnedContext.length})
228
+ </div>
229
+ {pinnedContext.map((display) => (
230
+ <div
231
+ key={display.id}
232
+ style={{
233
+ padding: '6px 8px',
234
+ background: 'var(--c-surface-subtle)',
235
+ borderRadius: '6px',
236
+ marginBottom: '4px',
237
+ borderLeft: '2px solid var(--c-accent)',
238
+ }}
239
+ >
240
+ <div style={{ fontWeight: 600, color: 'var(--c-text)', marginBottom: '2px' }}>
241
+ {display.title}
242
+ </div>
243
+ {display.summary && (
244
+ <div
245
+ style={{
246
+ color: 'var(--c-muted)',
247
+ fontSize: '10px',
248
+ lineHeight: 1.45,
249
+ marginBottom: '4px',
250
+ whiteSpace: 'pre-wrap',
251
+ }}
252
+ >
253
+ {display.summary}
254
+ </div>
255
+ )}
256
+ <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginTop: '4px' }}>
257
+ <span
258
+ style={{
259
+ fontSize: '9px',
260
+ padding: '1px 4px',
261
+ background: 'var(--c-surface-hover)',
262
+ color: 'var(--c-text-soft)',
263
+ borderRadius: '3px',
264
+ display: 'inline-block',
265
+ }}
266
+ >
267
+ {display.kind}
268
+ </span>
269
+ </div>
270
+ {display.path && (
271
+ <div style={{ marginTop: '6px' }}>
272
+ <div
273
+ style={{
274
+ color: 'var(--c-dim)',
275
+ fontSize: '10px',
276
+ wordBreak: 'break-all',
277
+ marginBottom: '6px',
278
+ }}
279
+ >
280
+ {display.path}
281
+ </div>
282
+ {display.path.startsWith('/') && (
283
+ <button
284
+ type="button"
285
+ onClick={() => void openWorkbenchFile(display.path)}
286
+ style={{
287
+ padding: '4px 8px',
288
+ fontSize: '10px',
289
+ background: 'var(--c-accent-12)',
290
+ border: '1px solid var(--c-accent-25)',
291
+ borderRadius: '4px',
292
+ color: 'var(--c-text-soft)',
293
+ cursor: 'pointer',
294
+ }}
295
+ >
296
+ Open in canvas
297
+ </button>
298
+ )}
299
+ </div>
300
+ )}
301
+ </div>
302
+ ))}
303
+ </div>
304
+ )}
305
+
306
+ {!hasPinnedContext && cards.length > 0 && (
185
307
  <div>
186
308
  <div
187
309
  style={{
@@ -305,7 +427,7 @@ export function ContextNode({
305
427
  </div>
306
428
  )}
307
429
 
308
- {auxTabs.length > 0 && (
430
+ {!hasPinnedContext && auxTabs.length > 0 && (
309
431
  <div>
310
432
  <div
311
433
  style={{
@@ -404,7 +526,7 @@ export function ContextNode({
404
526
  </div>
405
527
  )}
406
528
 
407
- {!fallback && cards.length === 0 && auxTabs.length === 0 && (
529
+ {!hasPinnedContext && !fallback && cards.length === 0 && auxTabs.length === 0 && (
408
530
  <div style={{ color: 'var(--c-dim)', fontStyle: 'italic' }}>No context loaded</div>
409
531
  )}
410
532
  </div>
@@ -1,8 +1,23 @@
1
1
  import { PHASE_COLORS } from '../theme/tokens';
2
2
  import type { CanvasNodeState } from '../types';
3
3
 
4
+ export function getStatusDisplayPhase(node: CanvasNodeState): string {
5
+ const phase = typeof node.data.phase === 'string' && node.data.phase.trim().length > 0
6
+ ? node.data.phase.trim()
7
+ : '';
8
+ if (phase) return phase;
9
+ const content = typeof node.data.content === 'string' && node.data.content.trim().length > 0
10
+ ? node.data.content.trim()
11
+ : '';
12
+ if (content) return content;
13
+ const status = typeof node.data.status === 'string' && node.data.status.trim().length > 0
14
+ ? node.data.status.trim()
15
+ : '';
16
+ return status || 'idle';
17
+ }
18
+
4
19
  export function StatusNode({ node }: { node: CanvasNodeState }) {
5
- const phase = (node.data.phase as string) || 'idle';
20
+ const phase = getStatusDisplayPhase(node);
6
21
  const detail = (node.data.detail as string) || '';
7
22
  const message = (node.data.message as string) || '';
8
23
  const level = (node.data.level as string) || 'ok';
@@ -1,8 +1,9 @@
1
1
  import { PHASE_COLORS } from '../theme/tokens';
2
2
  import type { CanvasNodeState } from '../types';
3
+ import { getStatusDisplayPhase } from './StatusNode';
3
4
 
4
5
  export function StatusSummary({ node }: { node: CanvasNodeState }) {
5
- const phase = (node.data.phase as string) || 'idle';
6
+ const phase = getStatusDisplayPhase(node);
6
7
  const activeTool = node.data.activeTool as string | null;
7
8
  const subagent = node.data.subagent as { state: string; name: string } | undefined;
8
9
  const phaseColor = PHASE_COLORS[phase] ?? 'var(--c-muted)';
@@ -330,9 +330,16 @@ export function replaceViewport(next: ViewportState): void {
330
330
  }
331
331
 
332
332
  export function commitViewport(next: ViewportState): void {
333
+ commitViewportWithOptions(next);
334
+ }
335
+
336
+ function commitViewportWithOptions(
337
+ next: ViewportState,
338
+ options: { recordHistory?: boolean } = {},
339
+ ): void {
333
340
  viewport.value = next;
334
- persistLayout();
335
- void updateViewportFromClient(next);
341
+ persistLayout(options);
342
+ void updateViewportFromClient(next, options);
336
343
  }
337
344
 
338
345
  export function applyServerCanvasLayout(
@@ -394,6 +401,7 @@ function easeOutCubic(t: number): number {
394
401
  export function animateViewport(
395
402
  target: ViewportState,
396
403
  duration = 300,
404
+ options: { recordHistory?: boolean } = {},
397
405
  ): void {
398
406
  if (animationId !== null) cancelAnimationFrame(animationId);
399
407
 
@@ -415,7 +423,7 @@ export function animateViewport(
415
423
  animationId = requestAnimationFrame(tick);
416
424
  } else {
417
425
  animationId = null;
418
- commitViewport(target);
426
+ commitViewportWithOptions(target, options);
419
427
  }
420
428
  }
421
429
 
@@ -540,7 +548,7 @@ export function fitAll(containerW: number, containerH: number): void {
540
548
  }
541
549
 
542
550
  // ── Focus node ────────────────────────────────────────────────
543
- export function focusNode(id: string): void {
551
+ export function focusNode(id: string, options: { recordHistory?: boolean } = {}): void {
544
552
  const node = nodes.value.get(id);
545
553
  if (!node) return;
546
554
  const v = viewport.value;
@@ -550,7 +558,7 @@ export function focusNode(id: string): void {
550
558
  x: window.innerWidth / 2 - cx * v.scale,
551
559
  y: window.innerHeight / 2 - cy * v.scale,
552
560
  scale: v.scale,
553
- });
561
+ }, 300, options);
554
562
  bringToFront(id);
555
563
  }
556
564
 
@@ -231,11 +231,15 @@ export async function removeNodeFromClient(id: string): Promise<{ ok: boolean; r
231
231
  /** Commit the current viewport to the authoritative server state. */
232
232
  export async function updateViewportFromClient(
233
233
  viewport: { x: number; y: number; scale: number },
234
+ options: { recordHistory?: boolean } = {},
234
235
  ): Promise<{ ok: boolean }> {
235
236
  return requestJson('updateViewportFromClient', '/api/canvas/viewport', { ok: false }, {
236
237
  method: 'POST',
237
238
  headers: { 'Content-Type': 'application/json' },
238
- body: JSON.stringify(viewport),
239
+ body: JSON.stringify({
240
+ ...viewport,
241
+ ...(options.recordHistory === false ? { recordHistory: false } : {}),
242
+ }),
239
243
  });
240
244
  }
241
245
 
@@ -229,7 +229,7 @@ function ensureExtAppNode(data: Record<string, unknown>): void {
229
229
  });
230
230
  addNode(node);
231
231
  if (!node.dockPosition) {
232
- focusNode(id);
232
+ focusNode(id, { recordHistory: false });
233
233
  }
234
234
  }
235
235
 
@@ -1216,7 +1216,13 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
1216
1216
  y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
1217
1217
  scale: typeof body.scale === 'number' ? body.scale : canvasState.viewport.scale,
1218
1218
  };
1219
- canvasState.setViewport(next);
1219
+ if (body.recordHistory === false) {
1220
+ canvasState.withSuppressedRecording(() => {
1221
+ canvasState.setViewport(next);
1222
+ });
1223
+ } else {
1224
+ canvasState.setViewport(next);
1225
+ }
1220
1226
  emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1221
1227
  return responseJson({ ok: true });
1222
1228
  }
@@ -1338,6 +1344,14 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1338
1344
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1339
1345
  ? body.data as Record<string, unknown>
1340
1346
  : undefined;
1347
+ if (type === 'html') {
1348
+ if ('html' in body && typeof body.html !== 'string') {
1349
+ return responseJson({ ok: false, error: 'HTML node field "html" must be a string.' }, 400);
1350
+ }
1351
+ if (extraData && 'html' in extraData && typeof extraData.html !== 'string') {
1352
+ return responseJson({ ok: false, error: 'HTML node field "data.html" must be a string.' }, 400);
1353
+ }
1354
+ }
1341
1355
  const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1342
1356
  ? body.path
1343
1357
  : body.content;
@@ -1395,6 +1409,15 @@ async function handleCanvasCreateGroup(req: Request): Promise<Response> {
1395
1409
  body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
1396
1410
  ? body.childLayout
1397
1411
  : undefined;
1412
+ if (childIds.length > 0) {
1413
+ const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
1414
+ if (missingChildIds.length > 0) {
1415
+ return responseJson({
1416
+ ok: false,
1417
+ error: `Cannot create group: missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`,
1418
+ }, 400);
1419
+ }
1420
+ }
1398
1421
 
1399
1422
  const { node } = createCanvasGroup({ title, childIds, color, x, y, width, height, ...(childLayout ? { childLayout } : {}) });
1400
1423