@1agh/maude 0.30.0 → 0.32.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 (93) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +788 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +796 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
  23. package/apps/studio/client/panels/ReadinessList.jsx +189 -0
  24. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  25. package/apps/studio/client/panels/acp-runtime.js +286 -0
  26. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  27. package/apps/studio/client/panels/git-grouping.js +86 -0
  28. package/apps/studio/client/styles/0-reset.css +4 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +625 -3
  30. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +821 -0
  32. package/apps/studio/client/styles/_index.css +2 -0
  33. package/apps/studio/client/tour/collab-tour.js +61 -0
  34. package/apps/studio/client/tour/overlay.jsx +11 -1
  35. package/apps/studio/client/whats-new.jsx +25 -10
  36. package/apps/studio/collab/registry.ts +13 -0
  37. package/apps/studio/collab/room.ts +36 -0
  38. package/apps/studio/cursors-overlay.tsx +17 -1
  39. package/apps/studio/dist/client.bundle.js +29097 -1534
  40. package/apps/studio/dist/comment-mount.js +4 -2
  41. package/apps/studio/dist/styles.css +5816 -1614
  42. package/apps/studio/git/endpoints.ts +338 -0
  43. package/apps/studio/git/service.ts +1334 -0
  44. package/apps/studio/git/watch.ts +97 -0
  45. package/apps/studio/github/endpoints.ts +358 -0
  46. package/apps/studio/github/service.ts +231 -0
  47. package/apps/studio/github/token.ts +53 -0
  48. package/apps/studio/hmr-broadcast.ts +9 -2
  49. package/apps/studio/http.ts +399 -1
  50. package/apps/studio/participants-chrome.tsx +69 -9
  51. package/apps/studio/paths.ts +12 -0
  52. package/apps/studio/readiness.ts +220 -0
  53. package/apps/studio/scaffold-design.ts +57 -0
  54. package/apps/studio/server.ts +65 -2
  55. package/apps/studio/sync/agent.ts +81 -1
  56. package/apps/studio/sync/codec.ts +24 -0
  57. package/apps/studio/sync/cold-start.ts +40 -0
  58. package/apps/studio/sync/hub-link.ts +137 -0
  59. package/apps/studio/test/acp-bridge.test.ts +127 -0
  60. package/apps/studio/test/acp-env.test.ts +65 -0
  61. package/apps/studio/test/acp-origin-gate.test.ts +95 -0
  62. package/apps/studio/test/acp-transcript.test.ts +112 -0
  63. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  64. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  65. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  66. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  67. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  68. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  69. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  70. package/apps/studio/test/editing-presence.test.ts +103 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  72. package/apps/studio/test/git-api.test.ts +0 -0
  73. package/apps/studio/test/git-branches.test.ts +106 -0
  74. package/apps/studio/test/git-grouping.test.ts +106 -0
  75. package/apps/studio/test/git-watch.test.ts +97 -0
  76. package/apps/studio/test/github-api.test.ts +465 -0
  77. package/apps/studio/test/hub-link.test.ts +69 -0
  78. package/apps/studio/test/participants-chrome.test.ts +36 -1
  79. package/apps/studio/test/readiness.test.ts +127 -0
  80. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  81. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  82. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  83. package/apps/studio/tool-palette.tsx +18 -9
  84. package/apps/studio/use-chrome-visibility.tsx +66 -0
  85. package/apps/studio/use-collab.tsx +414 -187
  86. package/apps/studio/whats-new.json +82 -0
  87. package/apps/studio/ws.ts +44 -1
  88. package/cli/commands/design.mjs +1 -0
  89. package/cli/lib/gitignore-block.mjs +16 -3
  90. package/cli/lib/gitignore-block.test.mjs +13 -1
  91. package/package.json +11 -9
  92. package/plugins/design/dependencies.json +17 -0
  93. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,149 @@
1
+ // Repo-level chat transcripts (`<designRoot>/_chat/<chatId>.jsonl`). The bridge
2
+ // appends raw per-update lines; these readers turn them into the chat list (for
3
+ // the switcher) and clean per-turn messages (for hydrating the thread on open).
4
+
5
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ export interface ChatSummary {
9
+ id: string;
10
+ title: string;
11
+ updated: number; // mtime ms
12
+ }
13
+
14
+ export interface ChatMessagePart {
15
+ type: 'text' | 'tool';
16
+ text?: string;
17
+ toolName?: string;
18
+ done?: boolean;
19
+ }
20
+ export interface ChatMessage {
21
+ role: 'user' | 'assistant';
22
+ parts: ChatMessagePart[];
23
+ }
24
+
25
+ function chatDir(designRoot: string): string {
26
+ return join(designRoot, '_chat');
27
+ }
28
+
29
+ function readLines(file: string): Array<Record<string, unknown>> {
30
+ try {
31
+ return readFileSync(file, 'utf8')
32
+ .split('\n')
33
+ .filter(Boolean)
34
+ .map((l) => {
35
+ try {
36
+ return JSON.parse(l) as Record<string, unknown>;
37
+ } catch {
38
+ return null;
39
+ }
40
+ })
41
+ .filter((x): x is Record<string, unknown> => x !== null);
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ /** First user line, truncated — the chat's display title. */
48
+ function deriveTitle(lines: Array<Record<string, unknown>>): string {
49
+ const firstUser = lines.find((l) => l.role === 'user' && typeof l.text === 'string');
50
+ const text = (firstUser?.text as string) ?? '';
51
+ const trimmed = text.replace(/\s+/g, ' ').trim();
52
+ return trimmed ? trimmed.slice(0, 60) : 'New chat';
53
+ }
54
+
55
+ /** List chats newest-first. */
56
+ export function listChats(designRoot: string): ChatSummary[] {
57
+ const dir = chatDir(designRoot);
58
+ if (!existsSync(dir)) return [];
59
+ const out: ChatSummary[] = [];
60
+ for (const name of readdirSync(dir)) {
61
+ if (!name.endsWith('.jsonl')) continue;
62
+ const file = join(dir, name);
63
+ let updated = 0;
64
+ try {
65
+ updated = statSync(file).mtimeMs;
66
+ } catch {
67
+ /* skip unreadable */
68
+ }
69
+ const lines = readLines(file);
70
+ if (lines.length === 0) continue;
71
+ out.push({ id: name.replace(/\.jsonl$/, ''), title: deriveTitle(lines), updated });
72
+ }
73
+ return out.sort((a, b) => b.updated - a.updated);
74
+ }
75
+
76
+ /** Delete a chat's transcript. Returns true if a file was removed. */
77
+ export function deleteChat(designRoot: string, chatId: string): boolean {
78
+ const file = join(chatDir(designRoot), `${chatId}.jsonl`);
79
+ if (!existsSync(file)) return false;
80
+ try {
81
+ rmSync(file);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Convert the raw transcript into clean per-turn messages: user lines become
90
+ * user messages; the agent updates between them aggregate into one assistant
91
+ * message (text + tool parts; `available_commands_update` / `usage_update` /
92
+ * thoughts dropped — chrome noise, not the conversation).
93
+ */
94
+ export function readChatMessages(designRoot: string, chatId: string): ChatMessage[] {
95
+ const file = join(chatDir(designRoot), `${chatId}.jsonl`);
96
+ if (!existsSync(file)) return [];
97
+ const lines = readLines(file);
98
+ const messages: ChatMessage[] = [];
99
+ let assistant: ChatMessage | null = null;
100
+ const toolIndex = new Map<string, number>();
101
+
102
+ const flush = () => {
103
+ if (assistant?.parts.length) messages.push(assistant);
104
+ assistant = null;
105
+ toolIndex.clear();
106
+ };
107
+
108
+ for (const line of lines) {
109
+ if (line.role === 'user' && typeof line.text === 'string') {
110
+ flush();
111
+ messages.push({ role: 'user', parts: [{ type: 'text', text: line.text }] });
112
+ continue;
113
+ }
114
+ if (line.role === 'stop') {
115
+ flush();
116
+ continue;
117
+ }
118
+ if (line.role !== 'agent') continue;
119
+ const update = line.update as Record<string, unknown> | undefined;
120
+ if (!update) continue;
121
+ if (!assistant) assistant = { role: 'assistant', parts: [] };
122
+ const kind = update.sessionUpdate;
123
+ if (kind === 'agent_message_chunk') {
124
+ const content = update.content as { type?: string; text?: string } | undefined;
125
+ if (content?.type !== 'text' || typeof content.text !== 'string') continue;
126
+ const last = assistant.parts[assistant.parts.length - 1];
127
+ if (last && last.type === 'text') last.text = (last.text ?? '') + content.text;
128
+ else assistant.parts.push({ type: 'text', text: content.text });
129
+ } else if (kind === 'tool_call') {
130
+ const id = String(update.toolCallId ?? '');
131
+ toolIndex.set(id, assistant.parts.length);
132
+ assistant.parts.push({
133
+ type: 'tool',
134
+ toolName: String(update.title ?? update.kind ?? 'tool'),
135
+ done: false,
136
+ });
137
+ } else if (kind === 'tool_call_update') {
138
+ const id = String(update.toolCallId ?? '');
139
+ const idx = toolIndex.get(id);
140
+ const status = update.status;
141
+ if (idx != null && (status === 'completed' || status === 'failed')) {
142
+ const part = assistant.parts[idx];
143
+ if (part) part.done = true;
144
+ }
145
+ }
146
+ }
147
+ flush();
148
+ return messages;
149
+ }
@@ -161,6 +161,7 @@ import {
161
161
  uploadAsset,
162
162
  useCanvasMediaDrop,
163
163
  } from './use-canvas-media-drop.tsx';
164
+ import { useChromeVisibility } from './use-chrome-visibility.tsx';
164
165
  import { useCollab } from './use-collab.tsx';
165
166
  import { useSelectionSetOptional } from './use-selection-set.tsx';
166
167
  import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
@@ -720,7 +721,11 @@ export function AnnotationsLayer() {
720
721
  const vpRef = useRef(vp);
721
722
  vpRef.current = vp;
722
723
  const visibilityCtx = useAnnotationsVisibility();
723
- const visible = visibilityCtx?.visible ?? true;
724
+ const chrome = useChromeVisibility();
725
+ // Presentation Mode hides annotations without mutating the user's own
726
+ // visibility toggle — render/input gate folds `present` in, the stored value
727
+ // (visibilityCtx.visible) is left untouched so exiting restores it.
728
+ const visible = (visibilityCtx?.visible ?? true) && !(chrome?.present ?? false);
724
729
  const setVisible = useCallback(
725
730
  (next: boolean | ((cur: boolean) => boolean)) => {
726
731
  if (!visibilityCtx) return;
@@ -3,7 +3,7 @@
3
3
 
4
4
  import crypto from 'node:crypto';
5
5
  import type { Dirent } from 'node:fs';
6
- import { mkdir, readdir, readFile, rename, stat as statp } from 'node:fs/promises';
6
+ import { mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
 
9
9
  import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
@@ -15,7 +15,10 @@ import {
15
15
  } from './canvas-edit.ts';
16
16
  import type { Context } from './context.ts';
17
17
 
18
- const SKIP_DIRS = new Set([
18
+ // Directories that never hold user-facing canvases. Exported so the
19
+ // external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
20
+ // a hand-synced copy. (activity.ts still carries its own historical mirror.)
21
+ export const SKIP_DIRS = new Set([
19
22
  'node_modules',
20
23
  '.git',
21
24
  '.next',
@@ -62,6 +65,31 @@ export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): P
62
65
  return out;
63
66
  }
64
67
 
68
+ /**
69
+ * Canonical canvas slug from a (repo- or design-root-relative) canvas path.
70
+ * Pure — the `fileSlug` closure inside `createApi` delegates here, and the
71
+ * external-canvas watcher (`canvas-list-watch.ts`) imports it so both creation
72
+ * paths derive identical `canvas-list-update` slugs. Strips an optional
73
+ * `<designRel>/` prefix, then `/`→`-`, whitespace→`_`, drops the `.tsx`/`.html`
74
+ * extension, and lowercases.
75
+ */
76
+ export function canvasSlugFromRel(file: string, designRel: string): string {
77
+ let p = String(file).replace(/^\/+|\/+$/g, '');
78
+ try {
79
+ p = decodeURIComponent(p);
80
+ } catch {
81
+ /* ignore */
82
+ }
83
+ const prefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
84
+ if (p.startsWith(prefix)) p = p.slice(prefix.length);
85
+ return p
86
+ .replace(/\//g, '-')
87
+ .replace(/\s+/g, '_')
88
+ .replace(/\.(tsx|html)$/i, '')
89
+ .replace(/^\.+/, '')
90
+ .toLowerCase();
91
+ }
92
+
65
93
  async function findFiles(absRoot: string, prefix: string, exts: string[]): Promise<string[]> {
66
94
  const out: string[] = [];
67
95
  let entries: Dirent[];
@@ -328,20 +356,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
328
356
  const { paths, cfg } = ctx;
329
357
 
330
358
  function fileSlug(file: string): string {
331
- let p = String(file).replace(/^\/+|\/+$/g, '');
332
- try {
333
- p = decodeURIComponent(p);
334
- } catch {
335
- /* ignore */
336
- }
337
- const prefix = `${paths.designRel.replace(/^\/+|\/+$/g, '')}/`;
338
- if (p.startsWith(prefix)) p = p.slice(prefix.length);
339
- return p
340
- .replace(/\//g, '-')
341
- .replace(/\s+/g, '_')
342
- .replace(/\.(tsx|html)$/i, '')
343
- .replace(/^\.+/, '')
344
- .toLowerCase();
359
+ return canvasSlugFromRel(file, paths.designRel);
345
360
  }
346
361
 
347
362
  async function fileForSlug(slug: string): Promise<string | null> {
@@ -637,21 +652,28 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
637
652
  }
638
653
  }
639
654
 
640
- // ---------- Canvas meta sidecar (Phase 4 T5) ----------
655
+ // ---------- Canvas meta sidecar (Phase 4 T5; split DDR-115) ----------
641
656
  //
642
657
  // Each canvas under `<designRoot>/ui/<name>.tsx` has a sibling
643
- // `<name>.meta.json`. Phase 4 stores `layout` (per-artboard world-coord
644
- // rects) and `viewport` (last pan/zoom) inside that file so the canvas
645
- // runtime can restore state on reload. The PATCH path is intentionally
646
- // merge-shallow on top-level keys never clobber `title`, `sections`,
647
- // `ai_context`, or any other authoring metadata.
658
+ // `<name>.meta.json` the SHARED, versioned document (title, sections,
659
+ // `layout` per-artboard world-coord rects, css_mode, …). The PATCH path is
660
+ // intentionally merge-shallow on top-level keys never clobber `title`,
661
+ // `sections`, `ai_context`, or any other authoring metadata.
662
+ //
663
+ // DDR-115 — the PER-USER camera (`viewport` pan/zoom) NO LONGER lives in
664
+ // `.meta.json`. It churns on every mouse pan/zoom, so persisting it inline
665
+ // dirtied a tracked file. It now lives in a gitignored per-machine view file
666
+ // (`canvasViewPath` below). PATCH splits the lanes (viewport → view file,
667
+ // layout → meta); GET merges them back so the client (`window.__canvas_meta__`)
668
+ // is unchanged. `last_modified` is stamped into meta ONLY on a real shared
669
+ // (layout) change — never on a viewport-only patch.
648
670
 
649
671
  /**
650
- * Resolve `file` (a path relative to repoRoot like `.design/ui/Foo.tsx`)
651
- * into the absolute path of its sibling `.meta.json` sidecar. Refuses
652
- * paths that escape repoRoot.
672
+ * Resolve `file` (a path relative to repoRoot like `.design/ui/Foo.tsx`) into
673
+ * the absolute path of the canvas SOURCE file. Refuses traversal, paths that
674
+ * escape repoRoot, and non-canvas extensions. Returns null on rejection.
653
675
  */
654
- function canvasMetaPath(file: string): string | null {
676
+ function canvasSourceAbs(file: string): string | null {
655
677
  let p = String(file).replace(/^\/+/, '');
656
678
  try {
657
679
  p = decodeURIComponent(p);
@@ -663,29 +685,133 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
663
685
  if (!abs.startsWith(`${paths.repoRoot}/`)) return null;
664
686
  const ext = path.extname(abs).toLowerCase();
665
687
  if (ext !== '.tsx' && ext !== '.html') return null;
666
- return abs.replace(/\.(tsx|html)$/i, '.meta.json');
688
+ return abs;
689
+ }
690
+
691
+ /**
692
+ * Resolve `file` into the absolute path of its sibling `.meta.json` sidecar.
693
+ * Same containment guard as `canvasSourceAbs` (refuses paths that escape
694
+ * repoRoot / non-canvas extensions).
695
+ */
696
+ function canvasMetaPath(file: string): string | null {
697
+ const abs = canvasSourceAbs(file);
698
+ return abs ? abs.replace(/\.(tsx|html)$/i, '.meta.json') : null;
699
+ }
700
+
701
+ // ---------- Per-machine canvas view / camera (DDR-115) ----------
702
+ //
703
+ // The canvas pan/zoom ("camera") is PER-USER runtime state, separate from the
704
+ // shared `.meta.json` document. It lives in `_canvas-state/<slug>.view.json`
705
+ // ({ viewport }) — gitignored, swept on delete, export-excluded. DISTINCT from
706
+ // the legacy `_canvas-state/<slug>.json` ({ sections, viewport:{x,y,scale} })
707
+ // store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.1–4, so
708
+ // overloading one file would let the two writers clobber each other's shape.
709
+
710
+ /** Validate a candidate viewport — finite x/y, zoom clamped [0.1, 4] (the
711
+ * Phase 4 rule). Returns the normalized viewport, or null when invalid. */
712
+ function normalizeViewport(v: unknown): { x: number; y: number; zoom: number } | null {
713
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
714
+ const vv = v as { x?: unknown; y?: unknown; zoom?: unknown };
715
+ if (
716
+ Number.isFinite(vv.x as number) &&
717
+ Number.isFinite(vv.y as number) &&
718
+ Number.isFinite(vv.zoom as number)
719
+ ) {
720
+ const zoom = Math.min(4, Math.max(0.1, vv.zoom as number));
721
+ return { x: vv.x as number, y: vv.y as number, zoom };
722
+ }
723
+ return null;
724
+ }
725
+
726
+ /** Per-machine view file for a canvas: `_canvas-state/<slug>.view.json`. Gated
727
+ * by the same containment guard as the meta sidecar (traversal / repoRoot /
728
+ * canvas-ext). Returns null when `file` is not a valid canvas path. */
729
+ function canvasViewPath(file: string): string | null {
730
+ if (!canvasMetaPath(file)) return null; // reuse the containment + ext gate
731
+ return path.join(paths.canvasStateDir, `${fileSlug(file)}.view.json`);
732
+ }
733
+
734
+ async function loadCanvasView(
735
+ file: string
736
+ ): Promise<{ viewport?: { x: number; y: number; zoom: number } } | null> {
737
+ const viewAbs = canvasViewPath(file);
738
+ if (!viewAbs) return null;
739
+ try {
740
+ const obj = JSON.parse(await Bun.file(viewAbs).text());
741
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
742
+ const vp = normalizeViewport((obj as { viewport?: unknown }).viewport);
743
+ return vp ? { viewport: vp } : {};
744
+ }
745
+ return null;
746
+ } catch {
747
+ return null;
748
+ }
749
+ }
750
+
751
+ /** Persist the per-user camera. Validates + clamps; best-effort (mkdir the
752
+ * bucket if absent). Returns the normalized viewport on write, null when the
753
+ * path is rejected or the viewport is invalid (no write). */
754
+ async function saveCanvasView(
755
+ file: string,
756
+ viewport: unknown
757
+ ): Promise<{ x: number; y: number; zoom: number } | null> {
758
+ const viewAbs = canvasViewPath(file);
759
+ if (!viewAbs) return null;
760
+ const vp = normalizeViewport(viewport);
761
+ if (!vp) return null;
762
+ try {
763
+ await mkdir(paths.canvasStateDir, { recursive: true });
764
+ await Bun.write(viewAbs, `${JSON.stringify({ viewport: vp }, null, 2)}\n`);
765
+ return vp;
766
+ } catch {
767
+ return null;
768
+ }
667
769
  }
668
770
 
669
771
  async function loadCanvasMeta(file: string): Promise<Record<string, unknown> | null> {
670
772
  const metaAbs = canvasMetaPath(file);
671
773
  if (!metaAbs) return null;
774
+ let obj: Record<string, unknown> = {};
775
+ let hadMeta = false;
672
776
  try {
673
- const raw = await Bun.file(metaAbs).text();
674
- const obj = JSON.parse(raw);
675
- return obj && typeof obj === 'object' && !Array.isArray(obj)
676
- ? (obj as Record<string, unknown>)
677
- : null;
777
+ const parsed = JSON.parse(await Bun.file(metaAbs).text());
778
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
779
+ obj = parsed as Record<string, unknown>;
780
+ hadMeta = true;
781
+ }
678
782
  } catch {
679
- return null;
783
+ // No meta on disk — fall through to a possible view-only result.
680
784
  }
785
+ // DDR-115 — never surface the per-user camera or the local write-timestamp
786
+ // from the on-disk meta: `viewport` is stale (the live camera lives in the
787
+ // view file), `last_modified` is local bookkeeping. Strip both, then overlay
788
+ // the current camera so the shell's `window.__canvas_meta__.viewport` still
789
+ // restores on reload — the client stays unchanged. (`= undefined` over
790
+ // `delete` — JSON.stringify/Response.json drop undefined keys, matching the
791
+ // codebase convention + biome's noDelete.)
792
+ obj.viewport = undefined;
793
+ obj.last_modified = undefined;
794
+ const view = await loadCanvasView(file);
795
+ if (view?.viewport) obj.viewport = view.viewport;
796
+ // Preserve the historic contract: no meta AND no camera → null (GET → {},
797
+ // PATCH-on-rejected-path → 404). A view-only canvas still returns its camera.
798
+ if (!hadMeta && !view?.viewport) return null;
799
+ return obj;
681
800
  }
682
801
 
683
802
  /**
684
- * Shallow-merge `patch` onto the existing meta sidecar and write back. Only
685
- * the Phase 4 keys `layout` + `viewport` are accepted from untrusted clients;
686
- * the rest of meta (title, sections, brief, ai_context, …) is preserved.
687
- * Returns the merged meta on success, null when the canvas has no meta or
688
- * the patch is rejected.
803
+ * Apply a `patch` from the (untrusted) client, splitting the two lanes
804
+ * (DDR-115):
805
+ * - `viewport` → the per-machine view file (`saveCanvasView`); NEVER the
806
+ * versioned meta. A viewport-only patch leaves `.meta.json` byte-unchanged
807
+ * (no `last_modified` bump) — this is the mouse-move churn killer.
808
+ * - `layout` → the shared `.meta.json`, shallow-merged so `title`,
809
+ * `sections`, `ai_context`, … are preserved; `last_modified` is stamped
810
+ * ONLY here (a real shared change the user wants committable).
811
+ * Returns the same coherent object a GET would produce (shared meta + camera),
812
+ * or null only when the path itself is rejected (traversal / bad ext) so the
813
+ * route maps it to 404. A viewport-only patch on a canvas that has no meta yet
814
+ * still succeeds (writes only the view file) and returns `{ viewport }`.
689
815
  */
690
816
  async function patchCanvasMeta(
691
817
  file: string,
@@ -694,43 +820,63 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
694
820
  const metaAbs = canvasMetaPath(file);
695
821
  if (!metaAbs) return null;
696
822
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return null;
697
- let current: Record<string, unknown> = {};
698
- try {
699
- const raw = await Bun.file(metaAbs).text();
700
- const parsed = JSON.parse(raw);
701
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
702
- current = parsed as Record<string, unknown>;
823
+
824
+ // DDR-115 security (F-A2) — the PATCH lanes are reachable from the untrusted
825
+ // canvas origin (DDR-054). Refuse to mint per-canvas state (view file or
826
+ // `.meta.json`) for a canvas that doesn't exist, so a malicious origin can't
827
+ // spray arbitrary-slug files/inodes via fabricated `file` paths. A valid
828
+ // patch only ever targets a canvas the user actually has; `.meta.json` may
829
+ // still be absent (first layout/viewport write), but the source must exist.
830
+ const srcAbs = canvasSourceAbs(file);
831
+ if (!srcAbs || !(await Bun.file(srcAbs).exists())) return null;
832
+
833
+ // --- Per-user camera lane: viewport → view file, never the versioned meta.
834
+ if (patch.viewport !== undefined) {
835
+ if (patch.viewport === null) {
836
+ // Explicit clear — best-effort remove the view file.
837
+ const viewAbs = canvasViewPath(file);
838
+ if (viewAbs) {
839
+ try {
840
+ await rm(viewAbs);
841
+ } catch {
842
+ /* absent / unreadable — nothing to clear */
843
+ }
844
+ }
845
+ } else {
846
+ // saveCanvasView validates + clamps; an invalid viewport is a silent no-op.
847
+ await saveCanvasView(file, patch.viewport);
703
848
  }
704
- } catch {
705
- // No existing meta — create one with just the Phase 4 keys.
706
849
  }
707
- const next = { ...current };
708
- // Whitelist of patchable top-level keys.
850
+
851
+ // --- Shared document lane: layout → versioned meta, stamps last_modified. ---
709
852
  if (patch.layout !== undefined) {
853
+ let current: Record<string, unknown> = {};
854
+ try {
855
+ const parsed = JSON.parse(await Bun.file(metaAbs).text());
856
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
857
+ current = parsed as Record<string, unknown>;
858
+ }
859
+ } catch {
860
+ // No existing meta — create one with just the layout key.
861
+ }
862
+ const next = { ...current };
710
863
  if (patch.layout === null) {
711
864
  next.layout = undefined;
712
865
  } else if (typeof patch.layout === 'object' && !Array.isArray(patch.layout)) {
713
866
  next.layout = patch.layout;
714
867
  }
868
+ // Defensive: a stale inline viewport must never persist in the versioned
869
+ // file (the camera lane owns it now). JSON.stringify drops undefined keys.
870
+ next.viewport = undefined;
871
+ next.last_modified = new Date().toISOString();
872
+ // Trailing newline — consistent with canvas-create.ts + sync/codec.ts
873
+ // (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline.
874
+ await Bun.write(metaAbs, `${JSON.stringify(next, null, 2)}\n`);
715
875
  }
716
- if (patch.viewport !== undefined) {
717
- if (patch.viewport === null) {
718
- next.viewport = undefined;
719
- } else if (typeof patch.viewport === 'object' && !Array.isArray(patch.viewport)) {
720
- const v = patch.viewport as { x?: unknown; y?: unknown; zoom?: unknown };
721
- if (
722
- Number.isFinite(v.x as number) &&
723
- Number.isFinite(v.y as number) &&
724
- Number.isFinite(v.zoom as number)
725
- ) {
726
- const zoom = Math.min(4, Math.max(0.1, v.zoom as number));
727
- next.viewport = { x: v.x as number, y: v.y as number, zoom };
728
- }
729
- }
730
- }
731
- next.last_modified = new Date().toISOString();
732
- await Bun.write(metaAbs, JSON.stringify(next, null, 2));
733
- return next;
876
+
877
+ // Return the merged view (shared meta + camera) — identical to GET, so the
878
+ // client gets a coherent object regardless of which lane(s) the patch hit.
879
+ return await loadCanvasMeta(file);
734
880
  }
735
881
 
736
882
  // ---------- Annotations sidecar (Phase 5) ----------
@@ -930,6 +1076,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
930
1076
  path.join(groupAbs, `${v.name}.meta.json`),
931
1077
  `${JSON.stringify(meta, null, 2)}\n`
932
1078
  );
1079
+ // Phase 30 — same-machine live tree refresh. Other tabs on THIS dev-server
1080
+ // re-read the (branch-scoped, on-disk) canvas list so a freshly-created
1081
+ // canvas appears without a reload. Cross-machine peers get the new canvas
1082
+ // via git "Get latest" — the file travels through git, this event is only a
1083
+ // "refresh your list" nudge for online local tabs (loopback inspector WS).
1084
+ ctx.bus.emit('canvas-list-update', { action: 'added', rel, slug });
933
1085
  // designRel-prefixed path — matches the file-tree `file.path` shape so the
934
1086
  // client can open it directly after reloadTree().
935
1087
  return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
@@ -1031,6 +1183,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1031
1183
  path.join(paths.canvasStateDir, `${slug}.json`),
1032
1184
  `_canvas-state__${slug}.json`
1033
1185
  );
1186
+ // DDR-115 — the per-machine camera view file.
1187
+ await moveIfExists(
1188
+ path.join(paths.canvasStateDir, `${slug}.view.json`),
1189
+ `_canvas-state__${slug}.view.json`
1190
+ );
1034
1191
  await moveIfExists(path.join(paths.commentsDir, `${slug}.json`), `_comments__${slug}.json`);
1035
1192
 
1036
1193
  await Bun.write(
@@ -1038,6 +1195,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1038
1195
  `${JSON.stringify({ canvas: rel, slug, deletedAt: new Date().toISOString(), trashed }, null, 2)}\n`
1039
1196
  );
1040
1197
 
1198
+ // Phase 30 — live tree refresh for other local tabs (see createCanvas).
1199
+ ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
1041
1200
  return {
1042
1201
  ok: true,
1043
1202
  rel,
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # chat-open.sh — Phase 31 (DDR-123). Backs `/design:chat` (via `maude design
3
+ # chat-open`): asks the running dev-server to surface the native ACP chat
4
+ # sidepanel by POSTing /_api/acp/focus. The server emits a bus event the shell
5
+ # (app.jsx, native-only) turns into "open the Assistant panel".
6
+ #
7
+ # Reads: $DESIGN_ROOT/_server.json (port the running server wrote)
8
+ # Usage: chat-open.sh [--root <repo>]
9
+ # Exits: 0 on success, 1 if no live server / focus request failed.
10
+ set -euo pipefail
11
+
12
+ REPO="${CLAUDE_PROJECT_DIR:-$(pwd)}"
13
+ while [ $# -gt 0 ]; do
14
+ case "$1" in
15
+ --root) REPO="$2"; shift 2 ;;
16
+ *) shift ;;
17
+ esac
18
+ done
19
+
20
+ DESIGN_ROOT="$REPO/.design"
21
+ STATE="$DESIGN_ROOT/_server.json"
22
+
23
+ if [ ! -f "$STATE" ]; then
24
+ echo "chat-open: no running dev server ($STATE missing). Open Maude first." >&2
25
+ exit 1
26
+ fi
27
+
28
+ if command -v jq >/dev/null 2>&1; then
29
+ PORT="$(jq -r '.port // empty' "$STATE" 2>/dev/null)"
30
+ else
31
+ PORT="$(sed -nE 's/.*"port"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p' "$STATE" 2>/dev/null | head -n1)"
32
+ fi
33
+
34
+ if [ -z "${PORT:-}" ]; then
35
+ echo "chat-open: could not read the server port from $STATE." >&2
36
+ exit 1
37
+ fi
38
+
39
+ if curl -fsS -X POST "http://127.0.0.1:${PORT}/_api/acp/focus" >/dev/null 2>&1; then
40
+ echo "→ opened the Assistant panel in Maude (port ${PORT})."
41
+ else
42
+ echo "chat-open: focus request to the dev server failed (port ${PORT})." >&2
43
+ exit 1
44
+ fi