@1agh/maude 0.30.0 → 0.31.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.
- package/README.md +5 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +781 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +613 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +28780 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5633 -1617
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +384 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +73 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- 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
|
|
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;
|
package/apps/studio/api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
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
|
-
*
|
|
652
|
-
*
|
|
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
|
|
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
|
|
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
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
685
|
-
*
|
|
686
|
-
* the
|
|
687
|
-
*
|
|
688
|
-
*
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
708
|
-
//
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|