@1agh/maude 1.2.0 → 1.3.1

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 (91) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +732 -38
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-build.ts +49 -8
  10. package/apps/studio/canvas-edit.ts +468 -7
  11. package/apps/studio/canvas-lib.tsx +82 -18
  12. package/apps/studio/canvas-list-watch.ts +33 -2
  13. package/apps/studio/canvas-notice-message.ts +16 -0
  14. package/apps/studio/canvas-notifications.tsx +19 -0
  15. package/apps/studio/canvas-shell.tsx +57 -6
  16. package/apps/studio/client/app.jsx +783 -245
  17. package/apps/studio/client/apply-edit-request.ts +55 -0
  18. package/apps/studio/client/canvas-url.js +39 -1
  19. package/apps/studio/client/export-center.jsx +111 -61
  20. package/apps/studio/client/github.js +38 -1
  21. package/apps/studio/client/index-loader.ts +76 -0
  22. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  23. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  24. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  25. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  26. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  27. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  28. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  29. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  30. package/apps/studio/client/photo-knobs.jsx +3 -0
  31. package/apps/studio/client/share-dialog.jsx +91 -0
  32. package/apps/studio/client/share-link.js +94 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +36 -18
  34. package/apps/studio/client/styles/4-components.css +18 -1
  35. package/apps/studio/client/tree-row-menu.jsx +48 -3
  36. package/apps/studio/client/whats-new.jsx +26 -32
  37. package/apps/studio/cloud/endpoints.ts +132 -0
  38. package/apps/studio/collab/awareness-bridge.ts +65 -14
  39. package/apps/studio/collab/index.ts +4 -0
  40. package/apps/studio/collab/persistence.ts +5 -1
  41. package/apps/studio/collab/registry.ts +9 -3
  42. package/apps/studio/collab/room.ts +13 -2
  43. package/apps/studio/context.ts +59 -0
  44. package/apps/studio/dist/client.bundle.js +1594 -1562
  45. package/apps/studio/dist/comment-mount.js +2 -2
  46. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  47. package/apps/studio/dist/runtime/sonner.js +1 -0
  48. package/apps/studio/dist/styles.css +1 -1
  49. package/apps/studio/git/accepted-guard.ts +36 -0
  50. package/apps/studio/hmr-broadcast.ts +34 -1
  51. package/apps/studio/http.ts +300 -18
  52. package/apps/studio/inspect.ts +19 -0
  53. package/apps/studio/managed-projects.ts +172 -0
  54. package/apps/studio/notifications.tsx +292 -0
  55. package/apps/studio/runtime-bundle.ts +2 -0
  56. package/apps/studio/server.ts +59 -3
  57. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  58. package/apps/studio/sync/accepted-link.ts +320 -0
  59. package/apps/studio/sync/action-stage.ts +343 -0
  60. package/apps/studio/sync/agent.ts +12 -60
  61. package/apps/studio/sync/cell-file-events.ts +3 -0
  62. package/apps/studio/sync/codec.ts +73 -6
  63. package/apps/studio/sync/ctl-provider.ts +15 -2
  64. package/apps/studio/sync/document-discovery.ts +81 -0
  65. package/apps/studio/sync/file-membership.ts +10 -2
  66. package/apps/studio/sync/file-plane.ts +309 -26
  67. package/apps/studio/sync/index.ts +1174 -21
  68. package/apps/studio/sync/migrate-seed.ts +110 -19
  69. package/apps/studio/sync/poke.ts +4 -2
  70. package/apps/studio/sync/presentation.ts +315 -2
  71. package/apps/studio/sync/projection.ts +820 -23
  72. package/apps/studio/sync/repeated-module.ts +73 -0
  73. package/apps/studio/sync/revision-barrier.ts +129 -0
  74. package/apps/studio/sync/seed-repair.ts +46 -0
  75. package/apps/studio/sync/source-merge.ts +100 -0
  76. package/apps/studio/sync/source-ops.ts +289 -0
  77. package/apps/studio/sync/source-recovery.ts +70 -0
  78. package/apps/studio/sync/source-validation.ts +56 -0
  79. package/apps/studio/sync/status.ts +83 -1
  80. package/apps/studio/sync/transaction-client.ts +486 -0
  81. package/apps/studio/sync/writer-registry.ts +236 -0
  82. package/apps/studio/text-caret.ts +35 -0
  83. package/apps/studio/undo-hud.tsx +9 -87
  84. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  85. package/apps/studio/use-tool-mode.tsx +44 -0
  86. package/apps/studio/whats-new.json +137 -0
  87. package/cli/lib/harness/codex-runtime.mjs +4 -1
  88. package/package.json +9 -8
  89. package/plugins/design/dependencies.json +3 -3
  90. package/plugins/design/templates/_shell.html +95 -7
  91. package/plugins/flow/dependencies.json +3 -3
@@ -0,0 +1,172 @@
1
+ // Preparing a MANAGED project — plan T19–T22 (DDR-241 follow-on).
2
+ //
3
+ // A designer who was added to a project should be able to open it without
4
+ // choosing a folder, having Git, or pasting a token. The desktop does that in
5
+ // two halves:
6
+ //
7
+ // 1. HERE (the running studio, which holds the Maude Cloud device session or
8
+ // takes a team-server sign-in): obtain and store the hub credential, then
9
+ // ask the project what it looks like (its bootstrap) — name, canvas
10
+ // groups — so the local copy can be declared correctly before it exists.
11
+ // 2. The native shell (`managed_project_open`) creates the project's own
12
+ // copy under the app's data directory from exactly what this returns,
13
+ // and switches to it; the sync runtime then brings everything down.
14
+ //
15
+ // Nothing here writes into the folder this studio is serving.
16
+
17
+ import { createCloudEndpoints } from './cloud/endpoints.ts';
18
+ import type { Context } from './context.ts';
19
+ import { getHubRecord, normalizeUrl } from './sync/hubs-config.ts';
20
+ import { signInToWorkspace } from './sync/workspace-signin.ts';
21
+
22
+ export type PrepareInput =
23
+ | { kind: 'cloud'; projectId: string }
24
+ | { kind: 'handoff'; code: string; claimedProject?: string }
25
+ | { kind: 'hub'; url: string; email: string; password: string }
26
+ /** A team server this machine already holds a credential for. */
27
+ | { kind: 'known-hub'; url: string };
28
+
29
+ export interface PreparedProject {
30
+ ok: true;
31
+ serverUrl: string;
32
+ /** Stable id on that server — the managed copy is keyed by (server, id). */
33
+ projectId: string;
34
+ name: string;
35
+ role: string | null;
36
+ canvasGroups: string[];
37
+ /** The project's save mode, when the server says ('transactions' | 'legacy'). */
38
+ mode: string | null;
39
+ }
40
+
41
+ export type PrepareResult = PreparedProject | { ok: false; error: string; status: number };
42
+
43
+ const GROUP = /^[\p{L}\p{N}][\p{L}\p{N} _-]{0,59}$/u;
44
+
45
+ /** Canvas groups a project uses, from its manifest when it does not declare them. */
46
+ export function groupsFromBootstrap(boot: {
47
+ canvasGroups?: unknown;
48
+ docs?: { path: string | null; retired?: boolean }[];
49
+ dirs?: string[];
50
+ }): string[] {
51
+ const out = new Set<string>();
52
+ const add = (g: unknown) => {
53
+ if (typeof g === 'string' && GROUP.test(g)) out.add(g);
54
+ };
55
+ if (Array.isArray(boot.canvasGroups)) {
56
+ for (const g of boot.canvasGroups)
57
+ add(typeof g === 'string' ? g : (g as { path?: unknown })?.path);
58
+ }
59
+ for (const d of boot.docs ?? []) {
60
+ if (d.retired || !d.path) continue;
61
+ const first = d.path.split('/')[0];
62
+ if (d.path.includes('/')) add(first);
63
+ }
64
+ for (const dir of boot.dirs ?? []) add(dir.split('/')[0]);
65
+ if (out.size === 0) {
66
+ out.add('system');
67
+ out.add('ui');
68
+ }
69
+ return [...out].slice(0, 32);
70
+ }
71
+
72
+ function sameServer(a: string | undefined, b: string): boolean {
73
+ if (!a) return false;
74
+ try {
75
+ return normalizeUrl(a) === normalizeUrl(b);
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ async function bootstrapOf(url: string, token: string): Promise<Record<string, unknown> | null> {
82
+ try {
83
+ const res = await fetch(`${url}/api/projects/current/v1/bootstrap`, {
84
+ headers: { authorization: `Bearer ${token}` },
85
+ signal: AbortSignal.timeout(10_000),
86
+ });
87
+ if (!res.ok) return null;
88
+ return (await res.json()) as Record<string, unknown>;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ export async function prepareManagedProject(
95
+ ctx: Context,
96
+ input: PrepareInput
97
+ ): Promise<PrepareResult> {
98
+ let serverUrl: string;
99
+ let projectId: string | null = null;
100
+ let role: string | null = null;
101
+ let name: string | null = null;
102
+
103
+ if (input.kind === 'cloud' || input.kind === 'handoff') {
104
+ const cloud = createCloudEndpoints(ctx);
105
+ const r =
106
+ input.kind === 'cloud'
107
+ ? await cloud.openManaged(input.projectId)
108
+ : await cloud.openManagedCode(input.code, input.claimedProject);
109
+ const j = r.json as {
110
+ ok?: boolean;
111
+ url?: string;
112
+ role?: string;
113
+ project?: string;
114
+ error?: string;
115
+ };
116
+ if (!j.ok || !j.url)
117
+ return { ok: false, error: j.error ?? 'The project could not be opened.', status: r.status };
118
+ serverUrl = j.url;
119
+ projectId = j.project ?? (input.kind === 'cloud' ? input.projectId : null);
120
+ role = j.role ?? null;
121
+ name = projectId;
122
+ } else if (input.kind === 'hub') {
123
+ const r = await signInToWorkspace({
124
+ url: input.url,
125
+ email: input.email,
126
+ password: input.password,
127
+ });
128
+ if (!r.json.ok) return { ok: false, error: r.json.error, status: r.status };
129
+ serverUrl = r.json.url;
130
+ role = r.json.user.role;
131
+ } else {
132
+ try {
133
+ serverUrl = normalizeUrl(input.url);
134
+ } catch {
135
+ return { ok: false, error: "That doesn't look like a workspace address.", status: 400 };
136
+ }
137
+ if (!getHubRecord(serverUrl)?.token) {
138
+ return { ok: false, error: 'Sign in to that workspace first.', status: 401 };
139
+ }
140
+ }
141
+
142
+ const token = getHubRecord(serverUrl)?.token;
143
+ if (!token)
144
+ return { ok: false, error: 'The sign-in could not be saved on this computer.', status: 500 };
145
+ const boot = await bootstrapOf(serverUrl, token);
146
+ if (boot && typeof boot.projectId === 'string') projectId ??= boot.projectId;
147
+ // SIGN IN AGAIN (T20): a new credential for the server this studio is
148
+ // already syncing with. Reopening the same project attaches to this very
149
+ // process (the shell never starts a second one for a running project), and
150
+ // the running runtime still holds the credential the project refused — so
151
+ // cycle it onto the one just stored. The link is unchanged and never re-read
152
+ // from disk; only the credential is new, and this process wrote it.
153
+ if (input.kind !== 'known-hub' && sameServer(ctx.cfg?.linkedHub?.url, serverUrl)) {
154
+ try {
155
+ await ctx.syncControl?.restart();
156
+ } catch (err) {
157
+ console.error('[managed] could not restart sync on the new sign-in:', err);
158
+ }
159
+ }
160
+ const host = new URL(serverUrl).host;
161
+ projectId ??= host;
162
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(projectId)) projectId = host.replace(/[^A-Za-z0-9._-]/g, '-');
163
+ return {
164
+ ok: true,
165
+ serverUrl,
166
+ projectId,
167
+ name: name ?? (projectId === 'local' ? host : projectId),
168
+ role,
169
+ canvasGroups: groupsFromBootstrap((boot ?? {}) as Parameters<typeof groupsFromBootstrap>[0]),
170
+ mode: boot && typeof boot.mode === 'string' ? boot.mode : null,
171
+ };
172
+ }
@@ -0,0 +1,292 @@
1
+ /** Shared shell/canvas notifications. Sonner owns layout, gestures and announcements;
2
+ * our clock also pauses for app overlays, hidden queue entries and Save dialogs. */
3
+ import {
4
+ type CSSProperties,
5
+ type ReactNode,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ useSyncExternalStore,
10
+ } from 'react';
11
+ import { Toaster, toast } from 'sonner';
12
+
13
+ export type NoticeKind = 'info' | 'success' | 'error' | 'warning' | 'undo';
14
+ export interface Notice {
15
+ id?: string;
16
+ title: string;
17
+ description?: string;
18
+ content?: ReactNode;
19
+ kind?: NoticeKind;
20
+ group?: string;
21
+ duration?: number;
22
+ /** Change only for a new lifecycle phase, never for a progress update. */
23
+ timerKey?: string;
24
+ paused?: boolean;
25
+ action?: { label: string; onClick: () => void };
26
+ onDismiss?: () => void;
27
+ }
28
+ type Entry = Notice & { id: string };
29
+ let entries: Entry[] = [];
30
+ let sequence = 0;
31
+ const listeners = new Set<() => void>();
32
+ const publish = () => {
33
+ for (const listener of listeners) listener();
34
+ };
35
+ const subscribe = (listener: () => void) => {
36
+ listeners.add(listener);
37
+ return () => {
38
+ listeners.delete(listener);
39
+ };
40
+ };
41
+ const snapshot = () => entries;
42
+
43
+ export function notify(notice: Notice): string {
44
+ const id = notice.id ?? `notice-${++sequence}`;
45
+ const existing = entries.findIndex((entry) => entry.id === id);
46
+ if (existing < 0) entries = [...entries, { ...notice, id }];
47
+ else entries = entries.map((entry) => (entry.id === id ? { ...notice, id } : entry));
48
+ publish();
49
+ return id;
50
+ }
51
+
52
+ let canvasSlot = 0;
53
+ let canvasWindowStart = -Infinity;
54
+ let canvasWindowCount = 0;
55
+ /** Untrusted canvas traffic gets two reusable slots, leaving room for shell notices.
56
+ * Limit publications as well as retention; editing can emit dozens per second. */
57
+ export function notifyCanvasText(title: string, kind: NoticeKind = 'info'): string | null {
58
+ const now = Date.now();
59
+ if (now < canvasWindowStart || now - canvasWindowStart >= 1000) {
60
+ canvasWindowStart = now;
61
+ canvasWindowCount = 0;
62
+ }
63
+ if (canvasWindowCount >= 5) return null;
64
+ canvasWindowCount++;
65
+ const previousUndo =
66
+ kind === 'undo'
67
+ ? entries.find((entry) => entry.group === 'canvas' && entry.kind === 'undo')
68
+ : undefined;
69
+ const id = previousUndo?.id ?? `canvas-notice-${canvasSlot++ % 2}`;
70
+ return notify({
71
+ id,
72
+ title: title.slice(0, 4000),
73
+ kind,
74
+ group: 'canvas',
75
+ timerKey: `${++sequence}`,
76
+ });
77
+ }
78
+
79
+ /** Programmatic removal (unmount/navigation) does not acknowledge the notice. */
80
+ export function dismissNotice(id: string): void {
81
+ entries = entries.filter((entry) => entry.id !== id);
82
+ publish();
83
+ }
84
+ function acknowledge(id: string) {
85
+ const entry = entries.find((item) => item.id === id);
86
+ dismissNotice(id);
87
+ entry?.onDismiss?.();
88
+ }
89
+
90
+ export function noticeDuration(entry: Notice): number {
91
+ return (
92
+ entry.duration ??
93
+ (entry.action || ['error', 'warning', 'undo'].includes(entry.kind ?? '') ? 10_000 : 5_000)
94
+ );
95
+ }
96
+
97
+ /** Keep full diagnostics in their owning panel, including for screen readers. */
98
+ export function summarizeNoticeText(text: string): string {
99
+ const compact = text.trim().replace(/\s+/g, ' ');
100
+ return compact.length > 280 ? `${compact.slice(0, 279)}…` : compact;
101
+ }
102
+
103
+ function NoticeCard({ entry }: { entry: Entry }) {
104
+ return (
105
+ <div
106
+ className="maude-notice"
107
+ data-kind={entry.kind ?? 'info'}
108
+ data-testid={`notice-${entry.id}`}
109
+ >
110
+ <button
111
+ type="button"
112
+ className="maude-notice-close"
113
+ aria-label="Dismiss notification"
114
+ onClick={(event) => {
115
+ event.currentTarget.blur();
116
+ acknowledge(entry.id);
117
+ }}
118
+ >
119
+ ×
120
+ </button>
121
+ <div className="maude-notice-title">{summarizeNoticeText(entry.title)}</div>
122
+ {entry.description && (
123
+ <div className="maude-notice-summary">{summarizeNoticeText(entry.description)}</div>
124
+ )}
125
+ {entry.content}
126
+ {entry.action && (
127
+ <button
128
+ type="button"
129
+ className="maude-notice-action"
130
+ onClick={(event) => {
131
+ event.currentTarget.blur();
132
+ entry.action?.onClick();
133
+ acknowledge(entry.id);
134
+ }}
135
+ >
136
+ {entry.action.label}
137
+ </button>
138
+ )}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ function MountedNotice({
144
+ entry,
145
+ visible,
146
+ paused,
147
+ }: {
148
+ entry: Entry;
149
+ visible: boolean;
150
+ paused: boolean;
151
+ }) {
152
+ const duration = noticeDuration(entry);
153
+ const presentationId = useRef<string | null>(null);
154
+ const remaining = useRef(duration);
155
+ // biome-ignore lint/correctness/useExhaustiveDependencies: a new lifecycle phase gets a fresh duration.
156
+ useEffect(() => {
157
+ remaining.current = duration;
158
+ }, [duration, entry.timerKey]);
159
+ // Keep the clock outside Sonner's card: hiding a group must preserve its time.
160
+ // biome-ignore lint/correctness/useExhaustiveDependencies: restart the clock when its lifecycle phase changes.
161
+ useEffect(() => {
162
+ if (!visible || paused || entry.paused || duration === Infinity) return;
163
+ const started = Date.now();
164
+ const timer = setTimeout(() => acknowledge(entry.id), Math.max(0, remaining.current));
165
+ return () => {
166
+ clearTimeout(timer);
167
+ remaining.current = Math.max(0, remaining.current - (Date.now() - started));
168
+ };
169
+ }, [visible, paused, entry.paused, duration, entry.timerKey, entry.id]);
170
+ // Sonner invokes onDismiss even for programmatic removal. Invalidate the
171
+ // presentation before hiding so opening history never acknowledges a job.
172
+ useEffect(() => {
173
+ if (visible) {
174
+ const token = presentationId.current ?? `${entry.id}:view-${++sequence}`;
175
+ presentationId.current = token;
176
+ toast.custom(() => <NoticeCard entry={entry} />, {
177
+ id: token,
178
+ duration: Infinity,
179
+ onDismiss: () => {
180
+ if (presentationId.current === token) acknowledge(entry.id);
181
+ },
182
+ });
183
+ } else if (presentationId.current) {
184
+ const token = presentationId.current;
185
+ presentationId.current = null;
186
+ toast.dismiss(token);
187
+ }
188
+ }, [entry, visible]);
189
+ useEffect(
190
+ () => () => {
191
+ const token = presentationId.current;
192
+ presentationId.current = null;
193
+ if (token) toast.dismiss(token);
194
+ },
195
+ []
196
+ );
197
+ return null;
198
+ }
199
+
200
+ const NOTICE_CSS = `
201
+ .maude-notifications [data-sonner-toaster] { --width: min(360px, calc(100vw - 32px)); z-index: 8000; }
202
+ .maude-notifications [data-sonner-toast] { width: var(--width); }
203
+ .maude-notifications [data-sonner-toast][data-expanded=false][data-front=false] {
204
+ overflow: hidden; border-radius: var(--radius-md, 8px);
205
+ }
206
+ .maude-notifications[data-paused=true] { visibility: hidden; pointer-events: none; }
207
+ .maude-notice { box-sizing: border-box; position: relative; width: 100%; padding: 16px;
208
+ border: 1px solid var(--border-default, #45454c); border-left: 3px solid var(--accent, #d63b1f);
209
+ border-radius: var(--radius-md, 8px); background: var(--bg-2, #26262b); color: var(--fg-0, #fafafa);
210
+ box-shadow: 0 8px 28px #0004; font: 13px/1.5 var(--font-sans, system-ui, sans-serif);
211
+ display: flex; flex-direction: column; gap: 8px; overflow-wrap: anywhere; }
212
+ .maude-notice[data-kind=error], .maude-notice[data-kind=warning] { border-left-color: var(--status-error, #d65b4d); }
213
+ .maude-notice-title { font-weight: 600; padding-right: 20px; }
214
+ .maude-notice-summary, .maude-notice-title, .maude-notice .st-export-degraded {
215
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
216
+ overflow-wrap: anywhere; }
217
+ .maude-notice-summary { color: var(--fg-1, #ddd); }
218
+ .maude-notice-close { position: absolute; right: 8px; top: 8px; border: 0; border-radius: 4px;
219
+ background: transparent; color: inherit; cursor: pointer; font-size: 18px; padding: 0 6px; }
220
+ .maude-notice-action { align-self: flex-start; border: 0; background: transparent; color: inherit;
221
+ font: inherit; text-decoration: underline; cursor: pointer; padding: 2px 0; }
222
+ .maude-notice button:focus-visible { outline: 2px solid var(--accent, #d63b1f); outline-offset: 2px; }
223
+ .maude-notice .st-toast-actions { flex-wrap: wrap; }
224
+ @media (prefers-reduced-motion: reduce) {
225
+ .maude-notifications [data-sonner-toast], .maude-notifications [data-sonner-toaster] {
226
+ animation: none !important; transition: none !important;
227
+ }
228
+ }`;
229
+
230
+ const NO_GROUPS: string[] = [];
231
+ export function NotificationHost({
232
+ paused = false,
233
+ hiddenGroups = NO_GROUPS,
234
+ }: {
235
+ paused?: boolean;
236
+ hiddenGroups?: string[];
237
+ }) {
238
+ const notices = useSyncExternalStore(subscribe, snapshot, snapshot);
239
+ const hostRef = useRef<HTMLDivElement>(null);
240
+ const [hovered, setHovered] = useState(false);
241
+ const [focused, setFocused] = useState(false);
242
+ const [hidden, setHidden] = useState(() => typeof document !== 'undefined' && document.hidden);
243
+ useEffect(() => {
244
+ const change = () => setHidden(document.hidden);
245
+ document.addEventListener('visibilitychange', change);
246
+ return () => document.removeEventListener('visibilitychange', change);
247
+ }, []);
248
+ useEffect(() => {
249
+ if (!notices.length) {
250
+ setHovered(false);
251
+ setFocused(false);
252
+ }
253
+ // Removing a focused DOM node does not reliably emit blur in browsers.
254
+ if (focused && !hostRef.current?.contains(document.activeElement)) setFocused(false);
255
+ }, [notices, focused]);
256
+ const visible = notices.filter((entry) => !hiddenGroups.includes(entry.group ?? ''));
257
+ // Publish only the latest three; older entries keep their clocks while waiting.
258
+ // This also keeps visibility and clocks aligned when a hidden group returns.
259
+ const timedIds = new Set(visible.slice(-3).map((entry) => entry.id));
260
+ return (
261
+ <div
262
+ ref={hostRef}
263
+ className="maude-notifications"
264
+ data-paused={paused}
265
+ onPointerEnter={() => setHovered(true)}
266
+ onPointerLeave={() => setHovered(false)}
267
+ onFocusCapture={() => setFocused(true)}
268
+ onBlurCapture={(event) => {
269
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
270
+ }}
271
+ >
272
+ <style>{NOTICE_CSS}</style>
273
+ <Toaster
274
+ position="bottom-left"
275
+ offset={{ left: 16, bottom: 84 }}
276
+ mobileOffset={{ left: 16, right: 16, bottom: 84 }}
277
+ visibleToasts={3}
278
+ expand={focused}
279
+ style={{ '--width': 'min(360px, calc(100vw - 32px))' } as CSSProperties}
280
+ containerAriaLabel="Notifications"
281
+ />
282
+ {notices.map((entry) => (
283
+ <MountedNotice
284
+ key={entry.id}
285
+ entry={entry}
286
+ visible={timedIds.has(entry.id)}
287
+ paused={paused || hovered || focused || hidden || !timedIds.has(entry.id)}
288
+ />
289
+ ))}
290
+ </div>
291
+ );
292
+ }
@@ -47,6 +47,8 @@ export const RUNTIME_PACKAGES = [
47
47
  'react-dom/client',
48
48
  'react/jsx-runtime',
49
49
  'react/jsx-dev-runtime',
50
+ // Toasts in standalone canvases resolve without node_modules in packaged installs.
51
+ 'sonner',
50
52
  // Pixi.js v8 — per-iframe runtime bundle for the non-destructive photo editor
51
53
  // (feature-photo-editor). The canvas-lib `<PhotoLayer>` reaches the WebGL
52
54
  // compositor (`photo/pipeline.ts`) through a LAZY runtime `import('pixi.js')`,
@@ -14,6 +14,7 @@
14
14
  // else binds to a port. The orchestrator (slash commands) reads _server.json
15
15
  // to detect a live instance and avoid duplicate boots.
16
16
 
17
+ import path from 'node:path';
17
18
  import { createAcp } from './acp/index.ts';
18
19
  import { cancelInstall, cancelSignin } from './acp/login-state.ts';
19
20
  import { createActivity } from './activity.ts';
@@ -78,7 +79,23 @@ let collab: ReturnType<typeof createCollab> | null = null;
78
79
  let inspectHandle: ReturnType<typeof createInspectRegistry> | null = null;
79
80
 
80
81
  const api = createApi(ctx, {
81
- onCommentsChanged: (file, comments) => {
82
+ onCommentsChanged: (file, comments, base) => {
83
+ // Accepted-revisions mode (DDR-241): the change is PROPOSED; the room's
84
+ // document changes when the project publishes it. Fire-and-forget — the
85
+ // proposal is durable in the outbox before this returns, and the sidebar
86
+ // below already shows the author their own comment.
87
+ const runtime = ctx.syncControl?.current?.();
88
+ const proposed = runtime?.proposeLane?.(
89
+ api.fileSlug(file),
90
+ 'comments',
91
+ JSON.stringify(comments),
92
+ base ? { baseText: JSON.stringify(base) } : {}
93
+ );
94
+ if (proposed) {
95
+ void proposed.catch(() => {});
96
+ ctx.bus.emit('comments', { file, comments });
97
+ return;
98
+ }
82
99
  // Phase 8 Task 3 — bridge into the live Y.Array so collab peers see the
83
100
  // change without waiting for cold-open re-seeding. No-op when no room is
84
101
  // live for this canvas slug.
@@ -98,9 +115,18 @@ const api = createApi(ctx, {
98
115
  },
99
116
  // Phase 8 Task 5 — same bridge for annotations. PUT /_api/annotations writes
100
117
  // the SVG blob to disk; we mirror it into the live Y.Map for collab peers.
101
- onAnnotationsChanged: (file, svg) => {
118
+ onAnnotationsChanged: (file, svg, writeId, base) => {
119
+ const runtime = ctx.syncControl?.current?.();
120
+ const proposed = runtime?.proposeLane?.(api.fileSlug(file), 'annotations', svg, {
121
+ ...(writeId ? { writeId } : {}),
122
+ ...(base !== undefined ? { baseText: base } : {}),
123
+ });
124
+ if (proposed) {
125
+ void proposed.catch(() => {});
126
+ return;
127
+ }
102
128
  if (collab) {
103
- collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg);
129
+ collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg, writeId);
104
130
  }
105
131
  },
106
132
  // feature-file-tree-drag-drop-folders (Task 3) — moveCanvas's collab guard
@@ -120,6 +146,13 @@ const api = createApi(ctx, {
120
146
  return false;
121
147
  }
122
148
  },
149
+ proposeFolder: (op) => ctx.syncControl?.current?.()?.proposeFolder?.(op) ?? null,
150
+ onMetaChanged: (file, text, baseText) => {
151
+ const proposed = ctx.syncControl
152
+ ?.current?.()
153
+ ?.proposeLane?.(api.fileSlug(file), 'meta', text, baseText ? { baseText } : {});
154
+ if (proposed) void proposed.catch(() => {});
155
+ },
123
156
  flushAndDropRoom: async (slug) => {
124
157
  if (collab) await collab.registry.forceDrop(slug);
125
158
  },
@@ -136,6 +169,19 @@ inspectHandle = inspects;
136
169
  // arrives without a vouched session.
137
170
  await inspects.for('').load();
138
171
 
172
+ // Semantic structural notifications include remote sync operations as well as
173
+ // API actions. Keep every session's agent/inspector context on the live path.
174
+ ctx.bus.on('canvas-list-update', (change) => {
175
+ if (!change?.rel) return;
176
+ const file = path.posix.join(ctx.paths.designRel, change.rel);
177
+ if (change.action === 'moved' && change.fromRel) {
178
+ const fromFile = path.posix.join(ctx.paths.designRel, change.fromRel);
179
+ for (const inspect of inspects.all()) inspect.retarget(fromFile, file);
180
+ } else if (change.action === 'removed') {
181
+ for (const inspect of inspects.all()) inspect.remove(file);
182
+ }
183
+ });
184
+
139
185
  collab = createCollab(ctx, api);
140
186
  const aiActivity = createAiActivity(ctx);
141
187
 
@@ -152,6 +198,16 @@ ctx.bus.on('ai-activity', (payload: { file: string; entry: AiActivityEntry | nul
152
198
  payload.entry ? { name: payload.entry.author, since: payload.entry.startedAt } : null
153
199
  );
154
200
  });
201
+ // T16 — an AI edit whose heartbeat went silent (the driver crashed) did not
202
+ // finish: whatever it wrote stays unpublished for the person's decision. An
203
+ // explicit /end already closed its action, so this is a no-op then.
204
+ ctx.bus.on('ai-activity', (payload: { file: string; entry: AiActivityEntry | null }) => {
205
+ if (payload.entry) return;
206
+ void ctx.syncControl
207
+ ?.current?.()
208
+ ?.endAiAction?.(`edit:${payload.file}`, 'failed')
209
+ ?.catch(() => {});
210
+ });
155
211
  const gitLifecycle = createGitLifecycle(ctx, collab.registry);
156
212
  // Phase 13 / DDR-029 — fs-watch-driven canvas activity overlay. Subscribes to
157
213
  // `fs:any` and emits `activity:change`; ws.ts forwards it to canvas iframes.