@1agh/maude 0.26.0 → 0.28.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/cli/commands/design-link.test.mjs +46 -0
- package/cli/commands/doctor.mjs +110 -5
- package/cli/commands/doctor.test.mjs +52 -0
- package/cli/lib/design-link.mjs +38 -8
- package/package.json +8 -8
- package/plugins/design/dev-server/activity.ts +256 -0
- package/plugins/design/dev-server/ai-banner.tsx +4 -0
- package/plugins/design/dev-server/artboard-activity-overlay.tsx +67 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +164 -9
- package/plugins/design/dev-server/canvas-lib.tsx +62 -15
- package/plugins/design/dev-server/config.schema.json +2 -1
- package/plugins/design/dev-server/dist/client.bundle.js +18 -18
- package/plugins/design/dev-server/dist/comment-mount.js +82 -4
- package/plugins/design/dev-server/inspect.ts +31 -1
- package/plugins/design/dev-server/participants-chrome.tsx +86 -1
- package/plugins/design/dev-server/server.ts +10 -1
- package/plugins/design/dev-server/sync/agent.ts +23 -8
- package/plugins/design/dev-server/sync/index.ts +24 -19
- package/plugins/design/dev-server/test/activity.test.ts +195 -0
- package/plugins/design/dev-server/test/artboard-activity-overlay.test.tsx +56 -0
- package/plugins/design/dev-server/test/canvas-hmr-runtime.test.tsx +114 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +28 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +34 -6
- package/plugins/design/dev-server/test/use-agent-presence.test.tsx +114 -0
- package/plugins/design/dev-server/test/use-canvas-activity.test.tsx +206 -0
- package/plugins/design/dev-server/use-agent-presence.tsx +244 -0
- package/plugins/design/dev-server/use-canvas-activity.tsx +252 -0
- package/plugins/design/dev-server/ws.ts +21 -2
- package/plugins/design/templates/_shell.html +108 -3
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-agent-presence.tsx — Phase 13.2 / DDR-078 agent presence.
|
|
3
|
+
* @scope plugins/design/dev-server/use-agent-presence.tsx
|
|
4
|
+
* @purpose Turn the Phase 8 `ai-activity` heartbeat into a *presence* peer:
|
|
5
|
+
* when an agent (`/design:edit` / `/design:new`) is editing THIS
|
|
6
|
+
* canvas, surface it like another connected human — an avatar in the
|
|
7
|
+
* participants stack + the activity overlay (DDR-075) tinted with the
|
|
8
|
+
* agent's own color + funny name. Pure client-side synthesis: the
|
|
9
|
+
* agent is NOT injected into Yjs awareness (no protocol surgery), it
|
|
10
|
+
* is a virtual participant derived from `ai-activity`.
|
|
11
|
+
*
|
|
12
|
+
* MVP scope (DDR-078): ONE agent per canvas (matches `ai-activity`'s file key).
|
|
13
|
+
* Multiple agents across the project each show in their own canvas tab; multiple
|
|
14
|
+
* agents on the SAME file at once would need `ai-activity` multi-author and is
|
|
15
|
+
* deliberately deferred. No roaming cursor (avatar + overlay only).
|
|
16
|
+
*
|
|
17
|
+
* Subscription mirrors `ai-banner.tsx`: parent `dgn:'ai-activity'` postMessage
|
|
18
|
+
* relay when embedded, own inspector WS when standalone, plus a GET /_api/ai
|
|
19
|
+
* seed so a tab opened mid-edit shows the agent immediately. Provided ONCE per
|
|
20
|
+
* canvas (canvas-lib bundle) so every DCArtboard + the chrome read one context —
|
|
21
|
+
* same cross-bundle rule as the activity context (DDR-077 lesson).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { type ReactNode, createContext, useContext, useEffect, useMemo, useState } from 'react';
|
|
25
|
+
|
|
26
|
+
import { colorForName } from './use-collab.tsx';
|
|
27
|
+
|
|
28
|
+
/** Wire shape of an `ai-activity` entry (matches collab/ai-activity.ts). */
|
|
29
|
+
export interface AiEntry {
|
|
30
|
+
file: string;
|
|
31
|
+
author: string;
|
|
32
|
+
startedAt: number;
|
|
33
|
+
lastHeartbeat: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A virtual presence peer derived from an active agent. */
|
|
37
|
+
export interface AgentPresence {
|
|
38
|
+
/** Stable per-session id (`author:startedAt`) — the funny-name + color seed. */
|
|
39
|
+
id: string;
|
|
40
|
+
/** Funny display name, e.g. "Bouncy Otter". */
|
|
41
|
+
name: string;
|
|
42
|
+
/** Deterministic peer color (shared palette with human peers). */
|
|
43
|
+
color: string;
|
|
44
|
+
/** The real author string the slash command reported ("Claude acting for X"). */
|
|
45
|
+
author: string;
|
|
46
|
+
startedAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Funny-name generator — deterministic from the session seed so an agent keeps
|
|
51
|
+
// the same name + color across heartbeats. ~20×20 = 400 combinations.
|
|
52
|
+
|
|
53
|
+
const ADJECTIVES = [
|
|
54
|
+
'Bouncy',
|
|
55
|
+
'Zippy',
|
|
56
|
+
'Sleepy',
|
|
57
|
+
'Sneaky',
|
|
58
|
+
'Witty',
|
|
59
|
+
'Plucky',
|
|
60
|
+
'Mellow',
|
|
61
|
+
'Snappy',
|
|
62
|
+
'Cosmic',
|
|
63
|
+
'Fuzzy',
|
|
64
|
+
'Jolly',
|
|
65
|
+
'Nimble',
|
|
66
|
+
'Breezy',
|
|
67
|
+
'Quirky',
|
|
68
|
+
'Dapper',
|
|
69
|
+
'Wobbly',
|
|
70
|
+
'Spry',
|
|
71
|
+
'Peppy',
|
|
72
|
+
'Groovy',
|
|
73
|
+
'Sly',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const ANIMALS = [
|
|
77
|
+
'Otter',
|
|
78
|
+
'Walrus',
|
|
79
|
+
'Lynx',
|
|
80
|
+
'Heron',
|
|
81
|
+
'Badger',
|
|
82
|
+
'Falcon',
|
|
83
|
+
'Mantis',
|
|
84
|
+
'Newt',
|
|
85
|
+
'Quokka',
|
|
86
|
+
'Tapir',
|
|
87
|
+
'Gecko',
|
|
88
|
+
'Marten',
|
|
89
|
+
'Pangolin',
|
|
90
|
+
'Axolotl',
|
|
91
|
+
'Ferret',
|
|
92
|
+
'Puffin',
|
|
93
|
+
'Capybara',
|
|
94
|
+
'Narwhal',
|
|
95
|
+
'Wombat',
|
|
96
|
+
'Mongoose',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
function hash32(s: string): number {
|
|
100
|
+
let h = 5381;
|
|
101
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
102
|
+
return h >>> 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Deterministic "Adjective Animal" from a seed (e.g. `author:startedAt`). */
|
|
106
|
+
export function agentFunnyName(seed: string): string {
|
|
107
|
+
const h = hash32(seed || 'agent');
|
|
108
|
+
const adj = ADJECTIVES[h % ADJECTIVES.length] as string;
|
|
109
|
+
const animal = ANIMALS[(h >>> 8) % ANIMALS.length] as string;
|
|
110
|
+
return `${adj} ${animal}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Strip control chars + length-cap the author. The /_api/ai/start endpoint
|
|
115
|
+
* caps it server-side, but the postMessage relay path never round-trips the
|
|
116
|
+
* server, so we re-bound it here at the trust boundary (DDR-078 security
|
|
117
|
+
* follow-up) — keeps a spoofed `author` from bloating the badge even if the
|
|
118
|
+
* origin guard below ever regresses.
|
|
119
|
+
*/
|
|
120
|
+
function sanitizeAuthor(a: unknown): string {
|
|
121
|
+
// Keep printable chars only (drop C0/C1 controls + DEL), cap length — no
|
|
122
|
+
// control-char regex needed.
|
|
123
|
+
let out = '';
|
|
124
|
+
for (const ch of String(a ?? '')) {
|
|
125
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
126
|
+
if (code >= 0x20 && code !== 0x7f) out += ch;
|
|
127
|
+
if (out.length >= 120) break;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Build the virtual presence peer from an `ai-activity` entry. */
|
|
133
|
+
export function deriveAgent(entry: AiEntry): AgentPresence {
|
|
134
|
+
const author = sanitizeAuthor(entry.author);
|
|
135
|
+
const id = `${author}:${entry.startedAt}`;
|
|
136
|
+
const name = agentFunnyName(id);
|
|
137
|
+
return { id, name, color: colorForName(name), author, startedAt: entry.startedAt };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
function readMetaFile(explicit?: string): string {
|
|
143
|
+
if (explicit) return explicit;
|
|
144
|
+
if (typeof window !== 'undefined') {
|
|
145
|
+
const w = window as unknown as { __canvas_meta_file__?: string };
|
|
146
|
+
if (typeof w.__canvas_meta_file__ === 'string') return w.__canvas_meta_file__;
|
|
147
|
+
}
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const AgentPresenceContext = createContext<AgentPresence | null>(null);
|
|
152
|
+
|
|
153
|
+
export interface AgentPresenceProviderProps {
|
|
154
|
+
/** Canvas file key (designRel-prefixed, matching the `ai-activity` key). */
|
|
155
|
+
file?: string;
|
|
156
|
+
/** Test/seed override — when provided, the live subscription is skipped. */
|
|
157
|
+
initialAgent?: AgentPresence | null;
|
|
158
|
+
children: ReactNode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function AgentPresenceProvider({
|
|
162
|
+
file,
|
|
163
|
+
initialAgent,
|
|
164
|
+
children,
|
|
165
|
+
}: AgentPresenceProviderProps) {
|
|
166
|
+
const myFile = readMetaFile(file);
|
|
167
|
+
const [entry, setEntry] = useState<AiEntry | null>(null);
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (initialAgent !== undefined) return; // seeded (tests) — no live wiring
|
|
171
|
+
if (!myFile || typeof window === 'undefined') return;
|
|
172
|
+
let cancelled = false;
|
|
173
|
+
|
|
174
|
+
// Seed: a tab opened mid-edit should show the agent without waiting for the
|
|
175
|
+
// next heartbeat.
|
|
176
|
+
fetch('/_api/ai', { headers: { 'Cache-Control': 'no-store' } })
|
|
177
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
178
|
+
.then((j: { entries?: AiEntry[] } | null) => {
|
|
179
|
+
if (cancelled || !j?.entries) return;
|
|
180
|
+
const mine = j.entries.find((e) => e && e.file === myFile);
|
|
181
|
+
if (mine) setEntry(mine);
|
|
182
|
+
})
|
|
183
|
+
.catch(() => {
|
|
184
|
+
/* network blip — live messages will fill in */
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Embedded: parent relays ai-activity via postMessage (ai-banner pattern).
|
|
188
|
+
const onMessage = (e: MessageEvent) => {
|
|
189
|
+
// DDR-078 security follow-up: only the trusted embedding parent relays
|
|
190
|
+
// ai-activity. Reject same-window self-posts (a hostile canvas faking a
|
|
191
|
+
// "Claude is editing" identity) and any non-parent source. Standalone
|
|
192
|
+
// (no parent) receives ai-activity via its own WS, never via postMessage.
|
|
193
|
+
if (e.source !== window.parent || window.parent === window) return;
|
|
194
|
+
const m = e.data as { dgn?: string; file?: string; entry?: AiEntry | null } | null;
|
|
195
|
+
if (!m || typeof m !== 'object' || m.dgn !== 'ai-activity') return;
|
|
196
|
+
if (m.file !== myFile) return;
|
|
197
|
+
setEntry(m.entry ?? null);
|
|
198
|
+
};
|
|
199
|
+
window.addEventListener('message', onMessage);
|
|
200
|
+
|
|
201
|
+
// Standalone (no embedding parent): dial the inspector WS ourselves.
|
|
202
|
+
let ws: WebSocket | null = null;
|
|
203
|
+
let reconnect: ReturnType<typeof setTimeout> | null = null;
|
|
204
|
+
function connectStandalone() {
|
|
205
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
206
|
+
const sock = new WebSocket(`${proto}//${location.host}/_ws`);
|
|
207
|
+
ws = sock;
|
|
208
|
+
sock.addEventListener('message', (ev) => {
|
|
209
|
+
try {
|
|
210
|
+
if (typeof ev.data !== 'string') return;
|
|
211
|
+
const m = JSON.parse(ev.data) as { type?: string; file?: string; entry?: AiEntry | null };
|
|
212
|
+
if (m.type !== 'ai-activity' || m.file !== myFile) return;
|
|
213
|
+
setEntry(m.entry ?? null);
|
|
214
|
+
} catch {
|
|
215
|
+
/* binary collab frame — ignore */
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
sock.addEventListener('close', () => {
|
|
219
|
+
ws = null;
|
|
220
|
+
reconnect = setTimeout(connectStandalone, 2000);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (window.parent === window) connectStandalone();
|
|
224
|
+
|
|
225
|
+
return () => {
|
|
226
|
+
cancelled = true;
|
|
227
|
+
window.removeEventListener('message', onMessage);
|
|
228
|
+
if (reconnect) clearTimeout(reconnect);
|
|
229
|
+
ws?.close();
|
|
230
|
+
};
|
|
231
|
+
}, [myFile, initialAgent]);
|
|
232
|
+
|
|
233
|
+
const agent = useMemo<AgentPresence | null>(() => {
|
|
234
|
+
if (initialAgent !== undefined) return initialAgent;
|
|
235
|
+
return entry ? deriveAgent(entry) : null;
|
|
236
|
+
}, [entry, initialAgent]);
|
|
237
|
+
|
|
238
|
+
return <AgentPresenceContext.Provider value={agent}>{children}</AgentPresenceContext.Provider>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The agent currently editing this canvas, or null. Inert outside the provider. */
|
|
242
|
+
export function useAgentPresence(): AgentPresence | null {
|
|
243
|
+
return useContext(AgentPresenceContext);
|
|
244
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-canvas-activity.tsx — Phase 13 / DDR-029 activity context.
|
|
3
|
+
* @scope plugins/design/dev-server/use-canvas-activity.tsx
|
|
4
|
+
* @purpose Iframe-runtime React context fed by the server's `activity` WS
|
|
5
|
+
* messages. `DCArtboard` reads it to render the "agent works here"
|
|
6
|
+
* overlay on the artboards being edited right now.
|
|
7
|
+
*
|
|
8
|
+
* The bridge: the canvas-shell harness (`templates/_shell.html`) owns the WS
|
|
9
|
+
* connection and re-dispatches every `{ type:'activity', … }` message as a
|
|
10
|
+
* `maude:activity` CustomEvent on `document` (the same pattern used for
|
|
11
|
+
* `maude:meta-refreshed`). This provider subscribes to that event — no second
|
|
12
|
+
* socket, and trivially testable (dispatch the event in a test). In-memory only:
|
|
13
|
+
* activity is ephemeral, never persisted.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type ReactNode,
|
|
18
|
+
createContext,
|
|
19
|
+
useContext,
|
|
20
|
+
useEffect,
|
|
21
|
+
useMemo,
|
|
22
|
+
useRef,
|
|
23
|
+
useState,
|
|
24
|
+
} from 'react';
|
|
25
|
+
|
|
26
|
+
/** Cross-fade window after a file flips `idle` before its overlay is removed. */
|
|
27
|
+
export const ACTIVITY_FADE_MS = 200;
|
|
28
|
+
|
|
29
|
+
/** Wire shape of a server `activity` message (snake_case, matches activity.ts). */
|
|
30
|
+
export interface ActivityMessage {
|
|
31
|
+
type?: 'activity';
|
|
32
|
+
file: string;
|
|
33
|
+
status: 'active' | 'idle';
|
|
34
|
+
artboard_ids?: string[] | null;
|
|
35
|
+
ts: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ActivityEntry {
|
|
39
|
+
status: 'active' | 'idle';
|
|
40
|
+
artboardIds: string[] | null;
|
|
41
|
+
ts: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type ActivityMap = Record<string, ActivityEntry>;
|
|
45
|
+
|
|
46
|
+
/** What `useCanvasActivity()` returns for a given canvas file. */
|
|
47
|
+
export interface CanvasActivity {
|
|
48
|
+
/** An entry exists (file is active OR within the post-idle fade window). */
|
|
49
|
+
present: boolean;
|
|
50
|
+
/** The file is currently being edited (drives the pulse). */
|
|
51
|
+
active: boolean;
|
|
52
|
+
/** Scoped artboard ids, or null = file-level (every artboard lights up). */
|
|
53
|
+
artboardIds: string[] | null;
|
|
54
|
+
/** Basename of the canvas file, for the badge label. */
|
|
55
|
+
fileLabel: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ActivityContextValue {
|
|
59
|
+
map: ActivityMap;
|
|
60
|
+
/** This canvas's design-root-relative key (server-message keyspace). */
|
|
61
|
+
currentKey: string;
|
|
62
|
+
normalizeKey: (file: string) => string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ActivityContext = createContext<ActivityContextValue | null>(null);
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Pure helpers — exported for unit tests.
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalize a canvas path to the server's activity keyspace: design-root-
|
|
72
|
+
* relative, slash-normalized, no leading slash. Accepts the canonical form
|
|
73
|
+
* (`ui/Foo.tsx`) or the designRel-prefixed form (`.design/ui/Foo.tsx`) that
|
|
74
|
+
* `mountCanvas` passes — stripping `designRel` when known.
|
|
75
|
+
*/
|
|
76
|
+
export function activityKey(file: string, designRel?: string): string {
|
|
77
|
+
let s = (file ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
78
|
+
const dr = (designRel ?? '').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
|
|
79
|
+
if (dr && s.startsWith(`${dr}/`)) s = s.slice(dr.length + 1);
|
|
80
|
+
return s;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Pure reducer: fold one activity message into the map. */
|
|
84
|
+
export function applyActivityChange(prev: ActivityMap, change: ActivityMessage): ActivityMap {
|
|
85
|
+
if (!change || typeof change.file !== 'string' || !change.file) return prev;
|
|
86
|
+
return {
|
|
87
|
+
...prev,
|
|
88
|
+
[change.file]: {
|
|
89
|
+
status: change.status === 'active' ? 'active' : 'idle',
|
|
90
|
+
artboardIds: Array.isArray(change.artboard_ids) ? change.artboard_ids : null,
|
|
91
|
+
ts: typeof change.ts === 'string' ? change.ts : '',
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** True when an artboard id is in scope for a change (null scope = all). */
|
|
97
|
+
export function matchesArtboard(artboardIds: string[] | null, id: string): boolean {
|
|
98
|
+
return artboardIds === null || artboardIds.includes(id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function basename(key: string): string {
|
|
102
|
+
const i = key.lastIndexOf('/');
|
|
103
|
+
return i >= 0 ? key.slice(i + 1) : key;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readDesignRel(explicit?: string): string | undefined {
|
|
107
|
+
if (explicit) return explicit;
|
|
108
|
+
if (typeof window !== 'undefined') {
|
|
109
|
+
const w = window as unknown as { __canvas_design_rel__?: string };
|
|
110
|
+
if (typeof w.__canvas_design_rel__ === 'string') return w.__canvas_design_rel__;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Seed the map from `window.__maude_activity_seed__` — the WS-open snapshot the
|
|
117
|
+
* shell stashes (raw server `activity.state`, keyed design-root-relative with a
|
|
118
|
+
* camelCase `artboardIds`). Load-bearing after the HMR reload a canvas edit
|
|
119
|
+
* triggers: the snapshot can land before React mounts, so the dispatched event
|
|
120
|
+
* is missed and only this synchronous read recovers the in-flight overlay.
|
|
121
|
+
*/
|
|
122
|
+
function readActivitySeed(): ActivityMap {
|
|
123
|
+
if (typeof window === 'undefined') return {};
|
|
124
|
+
const w = window as unknown as {
|
|
125
|
+
__maude_activity_seed__?: Record<
|
|
126
|
+
string,
|
|
127
|
+
{ status?: 'active' | 'idle'; ts?: string; artboardIds?: string[] | null }
|
|
128
|
+
>;
|
|
129
|
+
};
|
|
130
|
+
const seed = w.__maude_activity_seed__;
|
|
131
|
+
if (!seed || typeof seed !== 'object') return {};
|
|
132
|
+
const out: ActivityMap = {};
|
|
133
|
+
for (const file of Object.keys(seed)) {
|
|
134
|
+
const e = seed[file];
|
|
135
|
+
if (!e || e.status !== 'active') continue; // only resurrect active overlays
|
|
136
|
+
out[file] = {
|
|
137
|
+
status: 'active',
|
|
138
|
+
artboardIds: Array.isArray(e.artboardIds) ? e.artboardIds : null,
|
|
139
|
+
ts: typeof e.ts === 'string' ? e.ts : '',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
export interface CanvasActivityProviderProps {
|
|
148
|
+
/** This canvas's file (designRel-prefixed or design-root-relative). */
|
|
149
|
+
file?: string;
|
|
150
|
+
/** designRel for key normalization; defaults to `window.__canvas_design_rel__`. */
|
|
151
|
+
designRel?: string;
|
|
152
|
+
/** Seed (snapshot / tests). Keyed by design-root-relative path. */
|
|
153
|
+
initialState?: ActivityMap;
|
|
154
|
+
children: ReactNode;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function CanvasActivityProvider({
|
|
158
|
+
file,
|
|
159
|
+
designRel,
|
|
160
|
+
initialState,
|
|
161
|
+
children,
|
|
162
|
+
}: CanvasActivityProviderProps) {
|
|
163
|
+
const dr = readDesignRel(designRel);
|
|
164
|
+
const currentKey = useMemo(() => {
|
|
165
|
+
if (file) return activityKey(file, dr);
|
|
166
|
+
// No explicit file (the canvas-lib mount path) → read the design-root-
|
|
167
|
+
// relative canvas path the shell stamps on window. It already matches the
|
|
168
|
+
// server's activity keyspace, so no normalization is needed.
|
|
169
|
+
if (typeof window !== 'undefined') {
|
|
170
|
+
const w = window as unknown as { __canvas_rel__?: string };
|
|
171
|
+
if (typeof w.__canvas_rel__ === 'string' && w.__canvas_rel__) return w.__canvas_rel__;
|
|
172
|
+
}
|
|
173
|
+
return '';
|
|
174
|
+
}, [file, dr]);
|
|
175
|
+
const [map, setMap] = useState<ActivityMap>(() => initialState ?? readActivitySeed());
|
|
176
|
+
// Per-file removal timers for the post-idle cross-fade.
|
|
177
|
+
const fadeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (typeof document === 'undefined') return;
|
|
181
|
+
const timers = fadeTimers.current;
|
|
182
|
+
|
|
183
|
+
function onActivity(ev: Event) {
|
|
184
|
+
const detail = (ev as CustomEvent<ActivityMessage>).detail;
|
|
185
|
+
if (!detail || typeof detail.file !== 'string') return;
|
|
186
|
+
setMap((prev) => applyActivityChange(prev, detail));
|
|
187
|
+
|
|
188
|
+
const pending = timers.get(detail.file);
|
|
189
|
+
if (pending) {
|
|
190
|
+
clearTimeout(pending);
|
|
191
|
+
timers.delete(detail.file);
|
|
192
|
+
}
|
|
193
|
+
if (detail.status === 'idle') {
|
|
194
|
+
// Keep the (now-idle) entry around briefly so the overlay cross-fades
|
|
195
|
+
// out instead of snapping off, then drop it.
|
|
196
|
+
const ts = detail.ts;
|
|
197
|
+
const t = setTimeout(() => {
|
|
198
|
+
timers.delete(detail.file);
|
|
199
|
+
setMap((prev) => {
|
|
200
|
+
const entry = prev[detail.file];
|
|
201
|
+
// Only remove if it's still the same idle entry (not re-activated).
|
|
202
|
+
if (!entry || entry.status !== 'idle' || entry.ts !== ts) return prev;
|
|
203
|
+
const { [detail.file]: _drop, ...rest } = prev;
|
|
204
|
+
return rest;
|
|
205
|
+
});
|
|
206
|
+
}, ACTIVITY_FADE_MS);
|
|
207
|
+
timers.set(detail.file, t);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
document.addEventListener('maude:activity', onActivity as EventListener);
|
|
212
|
+
return () => {
|
|
213
|
+
document.removeEventListener('maude:activity', onActivity as EventListener);
|
|
214
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
215
|
+
timers.clear();
|
|
216
|
+
};
|
|
217
|
+
}, []);
|
|
218
|
+
|
|
219
|
+
const value = useMemo<ActivityContextValue>(
|
|
220
|
+
() => ({ map, currentKey, normalizeKey: (f: string) => activityKey(f, dr) }),
|
|
221
|
+
[map, currentKey, dr]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return <ActivityContext.Provider value={value}>{children}</ActivityContext.Provider>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const EMPTY: CanvasActivity = {
|
|
228
|
+
present: false,
|
|
229
|
+
active: false,
|
|
230
|
+
artboardIds: null,
|
|
231
|
+
fileLabel: '',
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Activity state for a canvas. With no argument, returns the current canvas's
|
|
236
|
+
* activity (the provider knows its own file); pass a `file` to query another.
|
|
237
|
+
* Returns an inert value outside the provider (legacy / specimen mounts), so the
|
|
238
|
+
* overlay simply never renders there.
|
|
239
|
+
*/
|
|
240
|
+
export function useCanvasActivity(file?: string): CanvasActivity {
|
|
241
|
+
const ctx = useContext(ActivityContext);
|
|
242
|
+
if (!ctx) return EMPTY;
|
|
243
|
+
const key = file != null ? ctx.normalizeKey(file) : ctx.currentKey;
|
|
244
|
+
if (!key) return EMPTY;
|
|
245
|
+
const entry = ctx.map[key];
|
|
246
|
+
return {
|
|
247
|
+
present: !!entry,
|
|
248
|
+
active: entry?.status === 'active',
|
|
249
|
+
artboardIds: entry?.artboardIds ?? null,
|
|
250
|
+
fileLabel: basename(key),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { ServerWebSocket, WebSocketHandler } from 'bun';
|
|
5
5
|
|
|
6
|
+
import type { Activity } from './activity.ts';
|
|
6
7
|
import type { Api } from './api.ts';
|
|
7
8
|
import type { Collab, RoomConn } from './collab/index.ts';
|
|
8
9
|
import type { Context } from './context.ts';
|
|
@@ -76,7 +77,13 @@ export interface Ws {
|
|
|
76
77
|
clientCount(): number;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
export function createWs(
|
|
80
|
+
export function createWs(
|
|
81
|
+
ctx: Context,
|
|
82
|
+
api: Api,
|
|
83
|
+
inspect: Inspect,
|
|
84
|
+
collab: Collab,
|
|
85
|
+
activity: Activity
|
|
86
|
+
): Ws {
|
|
80
87
|
const clients = new Set<ServerWebSocket<WsData>>();
|
|
81
88
|
|
|
82
89
|
function send(ws: ServerWebSocket<WsData>, payload: unknown) {
|
|
@@ -141,6 +148,14 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
141
148
|
// Uses broadcastHmr so the segregated canvas origin's HMR-only sockets get it.
|
|
142
149
|
createHmrBroadcaster(ctx, (msg) => broadcastHmr(msg));
|
|
143
150
|
|
|
151
|
+
// Phase 13 / DDR-029 — canvas activity overlay. activity.ts emits
|
|
152
|
+
// `activity:change` per file as edits land + go idle. The overlay renders
|
|
153
|
+
// INSIDE the canvas iframe, which (with the default origin split, DDR-054)
|
|
154
|
+
// holds a `canvas-hmr` socket — so this MUST use broadcastHmr, not broadcast,
|
|
155
|
+
// to reach it. The payload is just a canvas path + status (no secrets); it's
|
|
156
|
+
// the same exposure class the iframe already gets from `canvas-hmr` file paths.
|
|
157
|
+
ctx.bus.on('activity:change', (payload) => broadcastHmr({ type: 'activity', ...payload }));
|
|
158
|
+
|
|
144
159
|
// Bind a connection to its room. Stored per-socket so close() can find the
|
|
145
160
|
// right room to disconnect from. Multiplexed via ws.data.id.
|
|
146
161
|
const collabConns = new Map<string, { roomSlug: string; conn: RoomConn }>();
|
|
@@ -175,7 +190,11 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
175
190
|
return;
|
|
176
191
|
}
|
|
177
192
|
clients.add(ws);
|
|
178
|
-
|
|
193
|
+
// Snapshot carries the inspector state AND the activity map so a client
|
|
194
|
+
// opening mid-edit seeds its overlay state (Phase 13). Inspector-origin
|
|
195
|
+
// sockets only — `canvas-hmr` sockets get no snapshot by design and rely
|
|
196
|
+
// on live `activity` broadcasts.
|
|
197
|
+
send(ws, { type: 'snapshot', state: inspect.state, activity: activity.state });
|
|
179
198
|
},
|
|
180
199
|
async close(ws) {
|
|
181
200
|
if (ws.data.kind === 'collab') {
|