@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.
- package/README.md +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +732 -38
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +468 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +783 -245
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +36 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1594 -1562
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +300 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +172 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1174 -21
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +315 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +83 -1
- package/apps/studio/sync/transaction-client.ts +486 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +137 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +95 -7
- 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')`,
|
package/apps/studio/server.ts
CHANGED
|
@@ -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.
|