pmx-canvas 0.1.12 → 0.1.13

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.
@@ -0,0 +1,9 @@
1
+ export interface TraceDisplayModel {
2
+ toolName: string;
3
+ category: string;
4
+ status: string;
5
+ duration: string;
6
+ resultSummary: string;
7
+ error: string;
8
+ }
9
+ export declare function buildTraceDisplayModel(data: Record<string, unknown>): TraceDisplayModel;
@@ -54,6 +54,8 @@ export declare function replaceViewport(next: ViewportState): void;
54
54
  export declare function commitViewport(next: ViewportState): void;
55
55
  export declare function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'nodes' | 'edges'> & {
56
56
  viewport?: ViewportState;
57
+ }, options?: {
58
+ applyViewport?: boolean;
57
59
  }): void;
58
60
  /**
59
61
  * Smoothly animate the viewport to a target state.
@@ -83,5 +83,6 @@ export interface CanvasAccess {
83
83
  resizeAutomationWebView(width: number, height: number): Promise<AutomationWebViewStatus>;
84
84
  screenshotAutomationWebView(options?: AutomationScreenshotOptions): Promise<Uint8Array>;
85
85
  }
86
+ export declare function refreshCanvasAccess(access: CanvasAccess): Promise<CanvasAccess>;
86
87
  export declare function createCanvasAccess(): Promise<CanvasAccess>;
87
88
  export {};
@@ -38,6 +38,7 @@ export interface WebArtifactCanvasBuildResult extends WebArtifactBuildOutput {
38
38
  openedInCanvas: boolean;
39
39
  nodeId?: string;
40
40
  url?: string;
41
+ completedAt: string;
41
42
  }
42
43
  export declare function resolveWorkspacePath(pathLike: string, cwd?: string): string;
43
44
  export declare function resolveWebArtifactScriptPath(kind: 'init' | 'bundle'): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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",
@@ -115,12 +115,13 @@ else
115
115
  echo "✅ Using Vite $VITE_VERSION (Node 18 compatible)"
116
116
  fi
117
117
 
118
- # Detect OS and set sed syntax
119
- if [[ "$OSTYPE" == "darwin"* ]]; then
120
- SED_INPLACE="sed -i ''"
121
- else
122
- SED_INPLACE="sed -i"
123
- fi
118
+ function sed_in_place() {
119
+ if [[ "$OSTYPE" == "darwin"* ]]; then
120
+ sed -i '' "$@"
121
+ else
122
+ sed -i "$@"
123
+ fi
124
+ }
124
125
 
125
126
  declare -a PNPM_CMD
126
127
  configure_pnpm
@@ -158,8 +159,8 @@ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
158
159
  "
159
160
 
160
161
  echo "🧹 Cleaning up Vite template..."
161
- $SED_INPLACE '/<link rel="icon".*/d' index.html
162
- $SED_INPLACE 's/<title>.*<\/title>/<title>'"$PROJECT_NAME"'<\/title>/' index.html
162
+ sed_in_place '/<link rel="icon".*/d' index.html
163
+ sed_in_place 's/<title>.*<\/title>/<title>'"$PROJECT_NAME"'<\/title>/' index.html
163
164
 
164
165
  echo "📦 Installing base dependencies..."
165
166
  run_pnpm_quiet install
@@ -1,4 +1,5 @@
1
1
  import type { CanvasNodeState } from '../types';
2
+ import { buildTraceDisplayModel } from './trace-model';
2
3
 
3
4
  const CATEGORY_COLORS: Record<string, string> = {
4
5
  mcp: 'var(--c-accent)',
@@ -20,12 +21,7 @@ const STATUS_COLORS: Record<string, string> = {
20
21
  };
21
22
 
22
23
  export function TraceNode({ node }: { node: CanvasNodeState }) {
23
- const toolName = (node.data.toolName as string) || 'unknown';
24
- const category = (node.data.category as string) || 'other';
25
- const status = (node.data.status as string) || 'running';
26
- const duration = (node.data.duration as string) || '';
27
- const resultSummary = (node.data.resultSummary as string) || '';
28
- const error = (node.data.error as string) || '';
24
+ const { toolName, category, status, duration, resultSummary, error } = buildTraceDisplayModel(node.data);
29
25
 
30
26
  const catColor = CATEGORY_COLORS[category] ?? CATEGORY_COLORS.other;
31
27
  const statusIcon = STATUS_ICONS[status] ?? '◌';
@@ -0,0 +1,19 @@
1
+ export interface TraceDisplayModel {
2
+ toolName: string;
3
+ category: string;
4
+ status: string;
5
+ duration: string;
6
+ resultSummary: string;
7
+ error: string;
8
+ }
9
+
10
+ export function buildTraceDisplayModel(data: Record<string, unknown>): TraceDisplayModel {
11
+ return {
12
+ toolName: (data.toolName as string) || (data.title as string) || 'unknown',
13
+ category: (data.category as string) || 'other',
14
+ status: (data.status as string) || 'running',
15
+ duration: (data.duration as string) || '',
16
+ resultSummary: (data.resultSummary as string) || (data.content as string) || '',
17
+ error: (data.error as string) || '',
18
+ };
19
+ }
@@ -313,7 +313,10 @@ export function commitViewport(next: ViewportState): void {
313
313
  void updateViewportFromClient(next);
314
314
  }
315
315
 
316
- export function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState }): void {
316
+ export function applyServerCanvasLayout(
317
+ layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState },
318
+ options: { applyViewport?: boolean } = {},
319
+ ): void {
317
320
  const nextNodes = new Map<string, CanvasNodeState>();
318
321
  let nextMaxZ = 1;
319
322
  for (const node of layout.nodes) {
@@ -337,7 +340,7 @@ export function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'nodes' | 'ed
337
340
  const nextContextPinnedNodeIds = filterNodeIdSet(contextPinnedNodeIds.value, nextNodes);
338
341
 
339
342
  batch(() => {
340
- if (layout.viewport) {
343
+ if (options.applyViewport === true && layout.viewport) {
341
344
  viewport.value = layout.viewport;
342
345
  }
343
346
  maxZ = nextMaxZ;
@@ -803,6 +803,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
803
803
  }
804
804
  | undefined;
805
805
  if (!layout?.nodes) return;
806
+ const shouldApplyViewport = !hasInitialServerLayout.value;
806
807
  hasInitialServerLayout.value = true;
807
808
 
808
809
  const serverNodes = layout.nodes
@@ -824,7 +825,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
824
825
  ...(nextViewport ? { viewport: nextViewport } : {}),
825
826
  nodes: serverNodes,
826
827
  edges: serverEdges,
827
- });
828
+ }, { applyViewport: shouldApplyViewport });
828
829
 
829
830
  syncAttentionFromSse({ event: 'canvas-layout-update', data });
830
831
  }
@@ -135,7 +135,11 @@ export interface CanvasAccess {
135
135
  class LocalCanvasAccess implements CanvasAccess {
136
136
  readonly remoteBaseUrl = null;
137
137
 
138
- constructor(private readonly canvas: PmxCanvas) {}
138
+ constructor(
139
+ private readonly canvas: PmxCanvas,
140
+ readonly workspaceRoot: string,
141
+ readonly targetPort: number,
142
+ ) {}
139
143
 
140
144
  get port(): number {
141
145
  return this.canvas.port;
@@ -335,6 +339,9 @@ class RemoteCanvasAccess implements CanvasAccess {
335
339
  const error = parsed && typeof parsed === 'object' && 'error' in parsed
336
340
  ? String((parsed as { error?: unknown }).error)
337
341
  : `HTTP ${response.status}`;
342
+ if (path === '/api/canvas/batch' && parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
343
+ return parsed as T;
344
+ }
338
345
  throw new Error(error);
339
346
  }
340
347
  return parsed as T;
@@ -617,6 +624,10 @@ function candidateBaseUrls(port: number): string[] {
617
624
  return urls;
618
625
  }
619
626
 
627
+ function localBaseUrls(port: number): string[] {
628
+ return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
629
+ }
630
+
620
631
  async function readHealth(baseUrl: string): Promise<HealthResponse | null> {
621
632
  try {
622
633
  const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(400) });
@@ -627,9 +638,15 @@ async function readHealth(baseUrl: string): Promise<HealthResponse | null> {
627
638
  }
628
639
  }
629
640
 
630
- async function findExistingCanvasServer(workspaceRoot: string, port: number): Promise<string | null> {
641
+ async function findExistingCanvasServer(
642
+ workspaceRoot: string,
643
+ port: number,
644
+ options: { excludeBaseUrls?: string[] } = {},
645
+ ): Promise<string | null> {
631
646
  const canonicalWorkspaceRoot = canonicalWorkspacePath(workspaceRoot);
647
+ const excluded = new Set((options.excludeBaseUrls ?? []).map((baseUrl) => baseUrl.replace(/\/$/, '')));
632
648
  for (const baseUrl of candidateBaseUrls(port)) {
649
+ if (excluded.has(baseUrl)) continue;
633
650
  const health = await readHealth(baseUrl);
634
651
  if (health?.ok !== true) continue;
635
652
  const healthWorkspace = typeof health.workspace === 'string' ? canonicalWorkspacePath(health.workspace) : '';
@@ -639,6 +656,14 @@ async function findExistingCanvasServer(workspaceRoot: string, port: number): Pr
639
656
  return null;
640
657
  }
641
658
 
659
+ export async function refreshCanvasAccess(access: CanvasAccess): Promise<CanvasAccess> {
660
+ if (!(access instanceof LocalCanvasAccess)) return access;
661
+ const remoteBaseUrl = await findExistingCanvasServer(access.workspaceRoot, access.targetPort, {
662
+ excludeBaseUrls: localBaseUrls(access.port),
663
+ });
664
+ return remoteBaseUrl ? new RemoteCanvasAccess(remoteBaseUrl) : access;
665
+ }
666
+
642
667
  export async function createCanvasAccess(): Promise<CanvasAccess> {
643
668
  const workspaceRoot = resolve(process.cwd());
644
669
  const port = targetPort();
@@ -647,5 +672,5 @@ export async function createCanvasAccess(): Promise<CanvasAccess> {
647
672
 
648
673
  const canvas = createCanvas({ port });
649
674
  await canvas.start({ open: true });
650
- return new LocalCanvasAccess(canvas);
675
+ return new LocalCanvasAccess(canvas, workspaceRoot, port);
651
676
  }
package/src/mcp/server.ts CHANGED
@@ -25,7 +25,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
25
25
  import { isAbsolute, relative, resolve } from 'node:path';
26
26
  import { z } from 'zod';
27
27
  import { canvasState, describeCanvasSchema, validateStructuredCanvasPayload } from '../server/index.js';
28
- import { createCanvasAccess, type CanvasAccess } from './canvas-access.js';
28
+ import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
29
29
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
30
30
  import { wrapCanvasAutomationScript } from '../server/server.js';
31
31
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
@@ -34,7 +34,8 @@ import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js
34
34
 
35
35
  let canvas: CanvasAccess | null = null;
36
36
  let resourceNotificationServer: McpServer | null = null;
37
- let resourceNotificationsStarted = false;
37
+ let localResourceNotificationsStarted = false;
38
+ let remoteResourceNotificationsBaseUrl: string | null = null;
38
39
 
39
40
  const jsonRenderSpecSchema = z.union([
40
41
  z.object({
@@ -78,6 +79,8 @@ function safeWorkspacePath(pathLike: string): string {
78
79
  async function ensureCanvas(): Promise<CanvasAccess> {
79
80
  if (!canvas) {
80
81
  canvas = await createCanvasAccess();
82
+ } else {
83
+ canvas = await refreshCanvasAccess(canvas);
81
84
  }
82
85
  startResourceNotifications(canvas);
83
86
  return canvas;
@@ -139,16 +142,20 @@ async function watchRemoteCanvasEvents(baseUrl: string): Promise<void> {
139
142
  }
140
143
 
141
144
  function startResourceNotifications(c: CanvasAccess): void {
142
- if (resourceNotificationsStarted) return;
143
145
  const server = resourceNotificationServer;
144
146
  if (!server) return;
145
- resourceNotificationsStarted = true;
146
147
 
147
148
  if (c.remoteBaseUrl) {
148
- void watchRemoteCanvasEvents(c.remoteBaseUrl);
149
+ if (remoteResourceNotificationsBaseUrl !== c.remoteBaseUrl) {
150
+ remoteResourceNotificationsBaseUrl = c.remoteBaseUrl;
151
+ void watchRemoteCanvasEvents(c.remoteBaseUrl);
152
+ }
149
153
  return;
150
154
  }
151
155
 
156
+ if (localResourceNotificationsStarted) return;
157
+ localResourceNotificationsStarted = true;
158
+
152
159
  canvasState.onChange((type) => {
153
160
  sendCanvasResourceNotifications(type);
154
161
  });
@@ -184,6 +191,19 @@ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayo
184
191
  };
185
192
  }
186
193
 
194
+ function buildSnapshotRestoreSummary(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
195
+ const nodesByType: Record<string, number> = {};
196
+ for (const node of layout.nodes) {
197
+ nodesByType[node.type] = (nodesByType[node.type] ?? 0) + 1;
198
+ }
199
+ return {
200
+ nodeCount: layout.nodes.length,
201
+ edgeCount: layout.edges.length,
202
+ nodesByType,
203
+ viewport: layout.viewport,
204
+ };
205
+ }
206
+
187
207
  export async function startMcpServer(): Promise<void> {
188
208
  const server = new McpServer({
189
209
  name: 'pmx-canvas',
@@ -463,7 +483,7 @@ export async function startMcpServer(): Promise<void> {
463
483
  // ── canvas_build_web_artifact ───────────────────────────────
464
484
  server.tool(
465
485
  'canvas_build_web_artifact',
466
- 'Build a bundled single-file HTML web artifact from React/Tailwind source files using the bundled web-artifacts-builder skill scripts. MCP callers pass source content in appTsx (the CLI app-file flag reads a file before calling this path). Builds commonly take 45-60s on cold workspaces; use a long client timeout. Optionally opens the generated artifact as an embedded node on the canvas. Read canvas://skills/web-artifacts-builder for the full workflow, stack, and anti-slop design guidelines before calling.',
486
+ 'Build a bundled single-file HTML web artifact from React/Tailwind source files using the bundled web-artifacts-builder skill scripts. MCP callers pass source content in appTsx (the CLI app-file flag reads a file before calling this path). Builds can exceed default 60s MCP client timeouts on cold workspaces; set a long client timeout or retry with the same projectPath/outputPath if the client times out. Optionally opens the generated artifact as an embedded node on the canvas. Read canvas://skills/web-artifacts-builder for the full workflow, stack, and anti-slop design guidelines before calling.',
467
487
  {
468
488
  title: z.string().describe('Artifact title used for default project and output paths'),
469
489
  appTsx: z.string().describe('Contents for src/App.tsx'),
@@ -514,6 +534,7 @@ export async function startMcpServer(): Promise<void> {
514
534
  bytes: result.fileSize,
515
535
  projectPath: result.projectPath,
516
536
  openedInCanvas: result.openedInCanvas,
537
+ completedAt: result.completedAt,
517
538
  // `id` only present when a canvas node was actually created.
518
539
  // See the matching block in src/server/server.ts handleCanvasBuildWebArtifact.
519
540
  ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
@@ -1453,7 +1474,7 @@ export async function startMcpServer(): Promise<void> {
1453
1474
 
1454
1475
  server.tool(
1455
1476
  'canvas_batch',
1456
- 'Run a batch of canvas operations with optional assigned references. Supports node.add, node.update, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
1477
+ 'Run a non-atomic batch of canvas operations with optional assigned references. Use assign to name a result, then reference it later as "$name" for the created node id or "$name.id" for a specific result field. On failure, earlier successful operations remain applied and the response includes ok:false, failedIndex, error, results, and refs. Supports node.add, node.update, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
1457
1478
  {
1458
1479
  operations: z.array(z.object({
1459
1480
  op: z.string().describe('Operation name, e.g. "node.add" or "edge.add"'),
@@ -1568,8 +1589,9 @@ export async function startMcpServer(): Promise<void> {
1568
1589
  if (!result.ok) {
1569
1590
  return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: 'Snapshot not found' }) }] };
1570
1591
  }
1592
+ const layout = await c.getLayout();
1571
1593
  return {
1572
- content: [{ type: 'text', text: JSON.stringify({ ok: true, layout: serializeCanvasLayout(await c.getLayout()) }) }],
1594
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, restored: input.id, summary: buildSnapshotRestoreSummary(layout) }, null, 2) }],
1573
1595
  };
1574
1596
  },
1575
1597
  );
@@ -1373,6 +1373,7 @@ function resolveBatchRefs(value: unknown, refs: Record<string, unknown>): unknow
1373
1373
  if (typeof value === 'string' && value.startsWith('$')) {
1374
1374
  const path = value.slice(1).split('.');
1375
1375
  let current: unknown = refs[path[0] ?? ''];
1376
+ if (path.length === 1 && isPlainRecord(current) && typeof current.id === 'string') return current.id;
1376
1377
  for (const segment of path.slice(1)) {
1377
1378
  if (!isPlainRecord(current) && !Array.isArray(current)) return undefined;
1378
1379
  current = (current as Record<string, unknown>)[segment];
@@ -140,11 +140,18 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
140
140
  fields: [
141
141
  { name: 'title', type: 'string', required: false, description: 'Optional title.' },
142
142
  { name: 'content', type: 'string', required: false, description: 'Trace summary.' },
143
+ { name: 'toolName', type: 'string', required: false, description: 'Tool or operation label shown in the trace pill; defaults to title.' },
144
+ { name: 'category', type: 'string', required: false, description: 'Trace category color key: mcp, file, subagent, or other.' },
145
+ { name: 'status', type: 'string', required: false, description: 'Trace status: running, success, or failed.' },
146
+ { name: 'duration', type: 'string', required: false, description: 'Optional duration badge text.' },
147
+ { name: 'resultSummary', type: 'string', required: false, description: 'Short trace result summary; defaults to content.' },
148
+ { name: 'error', type: 'string', required: false, description: 'Short error message shown in failed traces.' },
143
149
  ],
144
150
  example: {
145
151
  type: 'trace',
146
152
  title: 'Execution Trace',
147
153
  content: 'Canvas actions and tool events.',
154
+ status: 'success',
148
155
  },
149
156
  },
150
157
  {
@@ -378,12 +385,16 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
378
385
  { name: 'openInCanvas', type: 'boolean', required: false, description: 'Open the built artifact on the canvas (default true).' },
379
386
  { name: 'includeLogs', type: 'boolean', required: false, description: 'Include raw build stdout/stderr in the response (default false).' },
380
387
  { name: 'deps', type: 'string[]', required: false, description: 'Optional npm dependencies to add before bundling, e.g. recharts.', aliases: ['deps'] },
388
+ { name: 'timeoutMs', type: 'number', required: false, description: 'Build command timeout in milliseconds. This controls subprocess timeout, not the MCP client request timeout.' },
381
389
  ],
382
390
  example: {
383
391
  title: 'Dashboard Artifact',
384
392
  appTsx: 'export default function App() { return <main>Artifact</main>; }',
385
393
  indexCss: 'body { background: #123456; color: white; }',
386
394
  },
395
+ notes: [
396
+ 'Cold builds can exceed default 60s MCP client timeouts; configure a longer MCP call timeout or retry with the same projectPath/outputPath if the first call times out.',
397
+ ],
387
398
  },
388
399
  ];
389
400
 
@@ -135,8 +135,6 @@ function normalizeExcalidrawBoundText(elements: Array<Record<string, unknown>>):
135
135
 
136
136
  let changed = false;
137
137
  const boundElementIdsByContainer = new Map<string, Set<string>>();
138
- const labelByContainer = new Map<string, Record<string, unknown>>();
139
- const textIdsConvertedToLabels = new Set<string>();
140
138
 
141
139
  for (const element of elements) {
142
140
  if (element.type !== 'text' || typeof element.id !== 'string' || typeof element.containerId !== 'string') continue;
@@ -145,48 +143,28 @@ function normalizeExcalidrawBoundText(elements: Array<Record<string, unknown>>):
145
143
  const ids = boundElementIdsByContainer.get(element.containerId) ?? new Set<string>();
146
144
  ids.add(element.id);
147
145
  boundElementIdsByContainer.set(element.containerId, ids);
148
- const text = typeof element.text === 'string' ? element.text.trim() : '';
149
- if (!isRecord(container.label) && text.length > 0) {
150
- labelByContainer.set(element.containerId, {
151
- text,
152
- ...(typeof element.fontSize === 'number' && Number.isFinite(element.fontSize) ? { fontSize: element.fontSize } : {}),
153
- });
154
- textIdsConvertedToLabels.add(element.id);
155
- }
156
146
  }
157
147
 
158
- const normalized = elements.flatMap<Record<string, unknown>>((element) => {
159
- if (typeof element.id === 'string' && textIdsConvertedToLabels.has(element.id)) {
160
- changed = true;
161
- return [];
162
- }
148
+ const normalized = elements.map((element) => {
163
149
  if (typeof element.id !== 'string') return element;
164
150
  const boundTextIds = boundElementIdsByContainer.get(element.id);
165
- const label = labelByContainer.get(element.id);
166
- if ((!boundTextIds || boundTextIds.size === 0) && !label) return element;
151
+ if (!boundTextIds || boundTextIds.size === 0) return element;
167
152
 
168
153
  const existing = Array.isArray(element.boundElements)
169
154
  ? element.boundElements.filter(isRecord)
170
155
  : [];
171
- const remainingExisting = existing.filter((boundElement) => {
172
- return !(boundElement.type === 'text' && typeof boundElement.id === 'string' && textIdsConvertedToLabels.has(boundElement.id));
173
- });
174
156
  const existingTextIds = new Set(
175
- remainingExisting
157
+ existing
176
158
  .filter((boundElement) => boundElement.type === 'text' && typeof boundElement.id === 'string')
177
159
  .map((boundElement) => boundElement.id as string),
178
160
  );
179
- const missing = [...(boundTextIds ?? [])]
180
- .filter((id) => !textIdsConvertedToLabels.has(id) && !existingTextIds.has(id));
181
- if (missing.length === 0 && !label && remainingExisting.length === existing.length) return element;
161
+ const missing = [...boundTextIds].filter((id) => !existingTextIds.has(id));
162
+ if (missing.length === 0) return element;
182
163
 
183
164
  changed = true;
184
165
  return {
185
166
  ...element,
186
- ...(label ? { label } : {}),
187
- ...(remainingExisting.length > 0 || missing.length > 0
188
- ? { boundElements: [...remainingExisting, ...missing.map((id) => ({ type: 'text', id }))] }
189
- : {}),
167
+ boundElements: [...existing, ...missing.map((id) => ({ type: 'text', id }))],
190
168
  };
191
169
  });
192
170
 
@@ -1639,6 +1639,7 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
1639
1639
  bytes: result.fileSize,
1640
1640
  projectPath: result.projectPath,
1641
1641
  openedInCanvas: result.openedInCanvas,
1642
+ completedAt: result.completedAt,
1642
1643
  // `id` is the canvas node id alias used by every other add-style
1643
1644
  // response. It is only present when a canvas node was actually
1644
1645
  // created (i.e. openInCanvas was not explicitly disabled). When
@@ -116,12 +116,13 @@ else
116
116
  echo "✅ Using Vite $VITE_VERSION (Node 18 compatible)"
117
117
  fi
118
118
 
119
- # Detect OS and set sed syntax
120
- if [[ "$OSTYPE" == "darwin"* ]]; then
121
- SED_INPLACE="sed -i ''"
122
- else
123
- SED_INPLACE="sed -i"
124
- fi
119
+ function sed_in_place() {
120
+ if [[ "$OSTYPE" == "darwin"* ]]; then
121
+ sed -i '' "$@"
122
+ else
123
+ sed -i "$@"
124
+ fi
125
+ }
125
126
 
126
127
  declare -a PNPM_CMD
127
128
  configure_pnpm
@@ -159,8 +160,8 @@ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
159
160
  "
160
161
 
161
162
  echo "🧹 Cleaning up Vite template..."
162
- $SED_INPLACE '/<link rel="icon".*/d' index.html
163
- $SED_INPLACE 's/<title>.*<\/title>/<title>'"$PROJECT_NAME"'<\/title>/' index.html
163
+ sed_in_place '/<link rel="icon".*/d' index.html
164
+ sed_in_place 's/<title>.*<\/title>/<title>'"$PROJECT_NAME"'<\/title>/' index.html
164
165
 
165
166
  echo "📦 Installing base dependencies..."
166
167
  run_pnpm_quiet install
@@ -2,9 +2,11 @@ import { spawn } from 'node:child_process';
2
2
  import {
3
3
  copyFileSync,
4
4
  existsSync,
5
+ readdirSync,
5
6
  mkdirSync,
6
7
  readFileSync,
7
8
  statSync,
9
+ unlinkSync,
8
10
  writeFileSync,
9
11
  } from 'node:fs';
10
12
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
@@ -71,6 +73,7 @@ export interface WebArtifactCanvasBuildResult extends WebArtifactBuildOutput {
71
73
  openedInCanvas: boolean;
72
74
  nodeId?: string;
73
75
  url?: string;
76
+ completedAt: string;
74
77
  }
75
78
 
76
79
  function currentWorkspaceRoot(): string {
@@ -300,6 +303,14 @@ function writeProjectFiles(
300
303
  }
301
304
  }
302
305
 
306
+ function removeLiteralSedBackupFiles(projectPath: string): void {
307
+ for (const entry of readdirSync(projectPath, { withFileTypes: true })) {
308
+ if (entry.isFile() && entry.name.endsWith("''")) {
309
+ unlinkSync(join(projectPath, entry.name));
310
+ }
311
+ }
312
+ }
313
+
303
314
  function ensurePackageManagerBoundary(dirPath: string): void {
304
315
  const packageJsonPath = join(dirPath, 'package.json');
305
316
  mkdirSync(dirPath, { recursive: true });
@@ -401,6 +412,7 @@ export async function executeWebArtifactBuild(
401
412
  });
402
413
  stdout = [stdout, initResult.stdout].filter(Boolean).join('\n');
403
414
  stderr = [stderr, initResult.stderr].filter(Boolean).join('\n');
415
+ removeLiteralSedBackupFiles(projectPath);
404
416
  }
405
417
 
406
418
  writeProjectFiles(projectPath, input);
@@ -508,7 +520,7 @@ export async function buildWebArtifactOnCanvas(input: WebArtifactBuildInput & {
508
520
  }): Promise<WebArtifactCanvasBuildResult> {
509
521
  const build = await executeWebArtifactBuild(input);
510
522
  if (input.openInCanvas === false) {
511
- return { ...build, openedInCanvas: false };
523
+ return { ...build, openedInCanvas: false, completedAt: new Date().toISOString() };
512
524
  }
513
525
  const opened = openWebArtifactInCanvas({
514
526
  title: input.title,
@@ -519,5 +531,6 @@ export async function buildWebArtifactOnCanvas(input: WebArtifactBuildInput & {
519
531
  openedInCanvas: true,
520
532
  nodeId: opened.nodeId,
521
533
  url: opened.url,
534
+ completedAt: new Date().toISOString(),
522
535
  };
523
536
  }