@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,53 @@
|
|
|
1
|
+
// github/token.ts — fetch the GitHub access token from the Tauri shell's
|
|
2
|
+
// loopback bridge at request time (Phase 28 / DDR-108).
|
|
3
|
+
//
|
|
4
|
+
// The token lives ONLY in the OS keychain, owned by the Tauri shell. The dev-server
|
|
5
|
+
// is a separate process and can't read the keychain, so it fetches the token over
|
|
6
|
+
// loopback (keychain.rs → GET /_tauri/github-token, 127.0.0.1-only, per-launch key).
|
|
7
|
+
// The endpoint + key arrive as env from the shell at spawn (sidecar.rs):
|
|
8
|
+
// MAUDE_TOKEN_ENDPOINT=http://127.0.0.1:<port>/_tauri/github-token
|
|
9
|
+
// MAUDE_TOKEN_KEY=<per-launch hex>
|
|
10
|
+
//
|
|
11
|
+
// In non-Tauri mode (`maude design serve` in a browser) those envs are absent →
|
|
12
|
+
// this returns null → the /_api/github/* endpoints degrade to "sign in via the
|
|
13
|
+
// desktop app". The token is never logged, persisted, or returned to the canvas.
|
|
14
|
+
|
|
15
|
+
let warnedNoBridge = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the current GitHub token from the loopback bridge, or null when not
|
|
19
|
+
* running under the Tauri shell / not signed in. NEVER cache the token (it can be
|
|
20
|
+
* revoked / rotated; the keychain is the single source of truth).
|
|
21
|
+
*/
|
|
22
|
+
export async function getGithubToken(): Promise<string | null> {
|
|
23
|
+
const endpoint = process.env.MAUDE_TOKEN_ENDPOINT;
|
|
24
|
+
const key = process.env.MAUDE_TOKEN_KEY;
|
|
25
|
+
if (!endpoint || !key) {
|
|
26
|
+
if (!warnedNoBridge) {
|
|
27
|
+
warnedNoBridge = true;
|
|
28
|
+
// One-time, non-secret: explains why github endpoints 401 outside the app.
|
|
29
|
+
console.error(
|
|
30
|
+
'[maude:github] no token bridge (not the desktop app) — GitHub features disabled'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(endpoint, {
|
|
37
|
+
headers: { 'X-Maude-Token-Key': key },
|
|
38
|
+
// The bridge is local + instant; don't hang a request on it.
|
|
39
|
+
signal: AbortSignal.timeout(3000),
|
|
40
|
+
});
|
|
41
|
+
if (res.status !== 200) return null; // 404 = not signed in; 403 = bad key
|
|
42
|
+
const token = (await res.text()).trim();
|
|
43
|
+
return token.length > 0 ? token : null;
|
|
44
|
+
} catch {
|
|
45
|
+
// Bridge unreachable (shell quit mid-request, etc.) — treat as signed out.
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether the token bridge is configured at all (i.e. running under the shell). */
|
|
51
|
+
export function tokenBridgeAvailable(): boolean {
|
|
52
|
+
return Boolean(process.env.MAUDE_TOKEN_ENDPOINT && process.env.MAUDE_TOKEN_KEY);
|
|
53
|
+
}
|
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
//
|
|
7
7
|
// - `mode: "css"` → swap <link> href with a cache-bust query (no module
|
|
8
8
|
// reload, no React state loss). Sub-150ms latency.
|
|
9
|
-
// - `mode: "module"` →
|
|
10
|
-
//
|
|
9
|
+
// - `mode: "module"` → in-place hot-swap of the canvas module when the canvas
|
|
10
|
+
// runtime is mounted (re-import + remount only the canvas
|
|
11
|
+
// content; the iframe — and the live CollabProvider +
|
|
12
|
+
// presence — stay mounted, so a cross-peer synced edit
|
|
13
|
+
// updates seamlessly without a presence blink — Phase 30
|
|
14
|
+
// / F4). Falls back to location.reload() with no runtime
|
|
15
|
+
// (gallery thumbnails) or on a re-import with no usable
|
|
16
|
+
// default export. State (scroll / tool / undo) survives
|
|
17
|
+
// the hot-swap path.
|
|
11
18
|
// - `mode: "hard"` → location.reload() (used when canvas-lib.tsx or any
|
|
12
19
|
// `_lib/**` file changes — every open canvas needs to
|
|
13
20
|
// re-bundle).
|
package/apps/studio/http.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
8
|
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
9
|
-
import { dirname, join, posix, resolve, sep } from 'node:path';
|
|
9
|
+
import { dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
10
10
|
|
|
11
|
+
import { probeAcpAvailability } from './acp/probe.ts';
|
|
12
|
+
import { deleteChat, listChats, readChatMessages } from './acp/transcript.ts';
|
|
11
13
|
import type { Api } from './api.ts';
|
|
12
14
|
import { buildCanvasModule } from './canvas-build.ts';
|
|
13
15
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
@@ -16,11 +18,16 @@ import type { AiActivity } from './collab/ai-activity.ts';
|
|
|
16
18
|
import type { Context } from './context.ts';
|
|
17
19
|
import { isFormat, isScope, runExport } from './exporters/index.ts';
|
|
18
20
|
import type { ActiveJsonShape } from './exporters/scope.ts';
|
|
21
|
+
import { createGitEndpoints } from './git/endpoints.ts';
|
|
22
|
+
import { gitShowFile } from './git/service.ts';
|
|
23
|
+
import { createGitHubEndpoints } from './github/endpoints.ts';
|
|
19
24
|
import type { Inspect } from './inspect.ts';
|
|
20
25
|
import { canvasSlug, writeLocator } from './locator.ts';
|
|
21
26
|
import { DEV_SERVER_ROOT } from './paths.ts';
|
|
22
27
|
import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
|
|
28
|
+
import { linkHub } from './sync/hub-link.ts';
|
|
23
29
|
import { loadWhatsNew } from './whats-new.ts';
|
|
30
|
+
import { isLoopbackHost } from './ws.ts';
|
|
24
31
|
|
|
25
32
|
// Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
|
|
26
33
|
// See paths.ts for the resolution logic + Phase 19.1 / v0.18.1 rationale.
|
|
@@ -246,6 +253,15 @@ async function serveCanvasTsx(
|
|
|
246
253
|
ctx: Context,
|
|
247
254
|
locatorAbsPath: string
|
|
248
255
|
): Promise<Response> {
|
|
256
|
+
// Phase 27 (E2) — DiffView "before" pane. `?sha=<ref>` builds the canvas from
|
|
257
|
+
// its source AT a past version (git show) instead of the working-tree file, so
|
|
258
|
+
// the rendered before/after is real. Isolated additive branch — the normal
|
|
259
|
+
// (no-sha) serve below is byte-identical to before. The historical build still
|
|
260
|
+
// resolves sibling imports against the CURRENT on-disk files (an accepted
|
|
261
|
+
// approximation: the canvas code at the sha, with today's DS/lib).
|
|
262
|
+
const shaParam = new URL(req.url).searchParams.get('sha');
|
|
263
|
+
if (shaParam) return serveHistoricalCanvas(absPath, shaParam, req, ctx);
|
|
264
|
+
|
|
249
265
|
const file = Bun.file(absPath);
|
|
250
266
|
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
|
251
267
|
|
|
@@ -313,6 +329,101 @@ async function serveCanvasTsx(
|
|
|
313
329
|
});
|
|
314
330
|
}
|
|
315
331
|
|
|
332
|
+
// Phase 27 (E2) — build + serve a canvas at a past git ref. Immutable per
|
|
333
|
+
// (path, sha) — historical content never changes, so the cache lives for the
|
|
334
|
+
// process (folding the boot id + chrome epoch into the etag so a chrome change
|
|
335
|
+
// still busts the browser copy, since the build inlines today's chrome).
|
|
336
|
+
// LRU-capped so a flood of distinct ?sha values (each minting a permanent entry)
|
|
337
|
+
// can't grow the heap unbounded — the route is reachable from the UNTRUSTED
|
|
338
|
+
// canvas origin (it's on the canvas-serve path), so a hub-pushed canvas could
|
|
339
|
+
// otherwise OOM the sidecar (security review — ethical-hacker, Finding 1).
|
|
340
|
+
const historicalCanvasCache = new Map<string, { js: string; etag: string }>();
|
|
341
|
+
const HIST_MAX_CACHE = 96;
|
|
342
|
+
// Rate-limit the EXPENSIVE miss path (git show + Bun.build, or a non-resolving
|
|
343
|
+
// git lookup) so a distinct-sha spray from the canvas origin can't starve the
|
|
344
|
+
// event loop. Cache HITS are free; only a NEW (path,sha) build consumes budget.
|
|
345
|
+
// Legit use (DiffView opens ~1–2 historical iframes per compare) is far under.
|
|
346
|
+
//
|
|
347
|
+
// TWO LOAD-BEARING INVARIANTS (security re-review — do not "optimize" away):
|
|
348
|
+
// 1. The limiter is DELIBERATELY GLOBAL, not per-origin/per-key — the attacker
|
|
349
|
+
// controls the canvas (hence any origin/key), so a keyed limiter is
|
|
350
|
+
// trivially defeated. The cost (a legit user 429s during an active flood) is
|
|
351
|
+
// accepted; it only bites under attack.
|
|
352
|
+
// 2. `historicalBuildAllowed()` MUST stay ABOVE `gitShowFile` so the
|
|
353
|
+
// non-resolving-sha spray (cheap git lookup, no build) is capped too —
|
|
354
|
+
// moving it below would re-open the CPU vector. Guarded by the
|
|
355
|
+
// "rate-limited — DoS guard" test in test/git-api.test.ts.
|
|
356
|
+
const HIST_WINDOW_MS = 10_000;
|
|
357
|
+
const HIST_MAX_BUILDS = 24;
|
|
358
|
+
let histWindowStart = Date.now();
|
|
359
|
+
let histBuilds = 0;
|
|
360
|
+
function historicalBuildAllowed(): boolean {
|
|
361
|
+
const now = Date.now();
|
|
362
|
+
if (now - histWindowStart >= HIST_WINDOW_MS) {
|
|
363
|
+
histWindowStart = now;
|
|
364
|
+
histBuilds = 0;
|
|
365
|
+
}
|
|
366
|
+
if (histBuilds >= HIST_MAX_BUILDS) return false;
|
|
367
|
+
histBuilds++;
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
async function serveHistoricalCanvas(
|
|
371
|
+
absPath: string,
|
|
372
|
+
sha: string,
|
|
373
|
+
req: Request,
|
|
374
|
+
ctx: Context
|
|
375
|
+
): Promise<Response> {
|
|
376
|
+
const repoRel = relative(ctx.paths.repoRoot, absPath).replace(/\\/g, '/');
|
|
377
|
+
const key = `${absPath}\0${sha}\0${RUNTIME_BOOT_ID}\0${CHROME_EPOCH}`;
|
|
378
|
+
let cached = historicalCanvasCache.get(key);
|
|
379
|
+
if (cached) {
|
|
380
|
+
// LRU touch — re-insert so the hot entry isn't the next eviction victim.
|
|
381
|
+
historicalCanvasCache.delete(key);
|
|
382
|
+
historicalCanvasCache.set(key, cached);
|
|
383
|
+
} else {
|
|
384
|
+
if (!historicalBuildAllowed()) {
|
|
385
|
+
return new Response('Too many version previews — try again in a moment.', {
|
|
386
|
+
status: 429,
|
|
387
|
+
headers: { 'Retry-After': '10', 'Cache-Control': 'no-store' },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const source = await gitShowFile(ctx.paths.repoRoot, sha, repoRel);
|
|
391
|
+
if (source == null) return new Response('No saved version of this canvas', { status: 404 });
|
|
392
|
+
try {
|
|
393
|
+
const result = await buildCanvasModule(absPath, source, { designRoot: ctx.paths.designRoot });
|
|
394
|
+
cached = {
|
|
395
|
+
js: result.js,
|
|
396
|
+
etag: `${result.etag}-${sha}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
|
|
397
|
+
};
|
|
398
|
+
historicalCanvasCache.set(key, cached);
|
|
399
|
+
if (historicalCanvasCache.size > HIST_MAX_CACHE) {
|
|
400
|
+
const oldest = historicalCanvasCache.keys().next().value;
|
|
401
|
+
if (oldest !== undefined) historicalCanvasCache.delete(oldest);
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
405
|
+
return new Response(`Canvas build error: ${msg}`, {
|
|
406
|
+
status: 500,
|
|
407
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (req.headers.get('if-none-match') === cached.etag) {
|
|
412
|
+
return new Response(null, {
|
|
413
|
+
status: 304,
|
|
414
|
+
headers: { ETag: cached.etag, 'Cache-Control': 'no-cache' },
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return new Response(cached.js, {
|
|
418
|
+
status: 200,
|
|
419
|
+
headers: {
|
|
420
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
421
|
+
ETag: cached.etag,
|
|
422
|
+
'Cache-Control': 'no-cache',
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
316
427
|
async function serveFile(absPath: string, headers: Record<string, string> = {}): Promise<Response> {
|
|
317
428
|
const file = Bun.file(absPath);
|
|
318
429
|
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
|
@@ -427,6 +538,19 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
427
538
|
}
|
|
428
539
|
}
|
|
429
540
|
|
|
541
|
+
// Phase 27 (E2) — `/_api/git/*` orchestration. MAIN-ORIGIN ONLY: every git
|
|
542
|
+
// route is intentionally absent from CANVAS_SAFE_API + startCanvasServer's
|
|
543
|
+
// `routes` map (the dual-allowlist rule), so the untrusted canvas iframe origin
|
|
544
|
+
// can never reach status/commit/publish/get-latest. http.ts owns the gating;
|
|
545
|
+
// git/endpoints.ts owns the orchestration.
|
|
546
|
+
const gitApi = createGitEndpoints(ctx);
|
|
547
|
+
// Phase 28 (E3) — `/_api/github/*`. Same dual-allowlist rule: main-origin only,
|
|
548
|
+
// and every route is token-bearing (server-held keychain token via the loopback
|
|
549
|
+
// bridge), so all four also carry a loopback-Host check.
|
|
550
|
+
const githubApi = createGitHubEndpoints(ctx);
|
|
551
|
+
const gitJson = (r: { status: number; json: unknown }) =>
|
|
552
|
+
Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
|
|
553
|
+
|
|
430
554
|
const routes = {
|
|
431
555
|
'/_health': () =>
|
|
432
556
|
Response.json({
|
|
@@ -438,6 +562,47 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
438
562
|
|
|
439
563
|
'/_active': () => Response.json(inspect.state),
|
|
440
564
|
|
|
565
|
+
// Phase 31 (DDR-123) — ACP chat readiness. Cheap, side-effect-free probe
|
|
566
|
+
// (is the adapter present + is `claude` on PATH); no subprocess spawned.
|
|
567
|
+
// MAIN-ORIGIN ONLY — absent from CANVAS_SAFE_API + startCanvasServer routes,
|
|
568
|
+
// so the untrusted canvas iframe is 403'd. The native shell reads this to
|
|
569
|
+
// decide between the enabled panel and the not-connected explainer.
|
|
570
|
+
'/_api/acp/status': () =>
|
|
571
|
+
Response.json(probeAcpAvailability(), { headers: { 'Cache-Control': 'no-store' } }),
|
|
572
|
+
|
|
573
|
+
// Phase 31 (DDR-123) — `/design:chat` focus hook. `maude design chat-open`
|
|
574
|
+
// POSTs here; we emit a bus event the shell turns into "open the Assistant
|
|
575
|
+
// panel" (app.jsx, native-only). MAIN-ORIGIN ONLY (off the canvas allowlist).
|
|
576
|
+
'/_api/acp/focus': (req: Request) => {
|
|
577
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
578
|
+
// CSRF parity with the other POST routes — the loopback `maude design
|
|
579
|
+
// chat-open` driver omits Origin (allowed); a browser drive-by can't forge it.
|
|
580
|
+
if (!sameOriginWrite(req))
|
|
581
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
582
|
+
ctx.bus.emit('acp-focus', {});
|
|
583
|
+
return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
// Phase 31 — repo-level chat list + history (for the chat switcher +
|
|
587
|
+
// hydration). MAIN-ORIGIN ONLY. Read-only; ids are sanitized before disk.
|
|
588
|
+
'/_api/acp/chats': () =>
|
|
589
|
+
Response.json(listChats(ctx.paths.designRoot), {
|
|
590
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
591
|
+
}),
|
|
592
|
+
'/_api/acp/chat': (req: Request) => {
|
|
593
|
+
const id = (new URL(req.url).searchParams.get('id') ?? '')
|
|
594
|
+
.replace(/[^a-z0-9_-]/gi, '')
|
|
595
|
+
.slice(0, 64);
|
|
596
|
+
if (req.method === 'DELETE') {
|
|
597
|
+
const removed = id ? deleteChat(ctx.paths.designRoot, id) : false;
|
|
598
|
+
return Response.json({ ok: removed }, { headers: { 'Cache-Control': 'no-store' } });
|
|
599
|
+
}
|
|
600
|
+
if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
|
|
601
|
+
return Response.json(readChatMessages(ctx.paths.designRoot, id), {
|
|
602
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
603
|
+
});
|
|
604
|
+
},
|
|
605
|
+
|
|
441
606
|
// Phase 9 Task 8 — offline-mode banner poll fallback. The linked-mode sync
|
|
442
607
|
// runtime writes `_sync.json`; browser tabs also get live pushes over the
|
|
443
608
|
// WS ('sync:status'). Returns `{ linked: false }` in solo mode.
|
|
@@ -550,6 +715,12 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
550
715
|
// Claude work) POSTs here when work begins. body = { file, author }.
|
|
551
716
|
// Replaces any prior entry for the file.
|
|
552
717
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
718
|
+
// CSRF guard: phase-30 bridges ai-activity onto room awareness, which
|
|
719
|
+
// crosses the hub — a forged cross-origin POST would inject a fake
|
|
720
|
+
// "<x> is editing <file>" presence to every connected peer. The loopback
|
|
721
|
+
// slash-command driver omits Origin (→ allowed); a browser drive-by can't.
|
|
722
|
+
if (!sameOriginWrite(req))
|
|
723
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
553
724
|
const body = await readJson<{ file?: string; author?: string }>(req);
|
|
554
725
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
555
726
|
return new Response('body.file required', { status: 400 });
|
|
@@ -566,6 +737,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
566
737
|
// Refresh the lastHeartbeat. Returns 404 if no entry — slash command
|
|
567
738
|
// can treat that as "the server bounced; re-issue /start".
|
|
568
739
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
740
|
+
if (!sameOriginWrite(req))
|
|
741
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
569
742
|
const body = await readJson<{ file?: string }>(req);
|
|
570
743
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
571
744
|
return new Response('body.file required', { status: 400 });
|
|
@@ -578,6 +751,8 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
578
751
|
'/_api/ai/end': async (req: Request) => {
|
|
579
752
|
// Explicit completion (normal or error). Banner clears immediately.
|
|
580
753
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
754
|
+
if (!sameOriginWrite(req))
|
|
755
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
581
756
|
const body = await readJson<{ file?: string }>(req);
|
|
582
757
|
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
583
758
|
return new Response('body.file required', { status: 400 });
|
|
@@ -668,6 +843,214 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
668
843
|
);
|
|
669
844
|
},
|
|
670
845
|
|
|
846
|
+
// ── Phase 27 (E2) — in-UI git layer. Save version / Publish / Get latest /
|
|
847
|
+
// History / visual diff. All MAIN-ORIGIN ONLY (see gitApi comment above).
|
|
848
|
+
// POST routes add the sameOriginWrite CSRF guard (cross-site forged POST);
|
|
849
|
+
// the token-bearing publish/get-latest routes add a loopback Host check so a
|
|
850
|
+
// request carrying a GitHub token can only originate on this machine.
|
|
851
|
+
'/_api/git/status': async (req: Request) => {
|
|
852
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
853
|
+
// Local status only — fast, no network, no credentials. The remote
|
|
854
|
+
// ahead/behind probe (the "Get latest" nudge) needs a token, which a GET
|
|
855
|
+
// query string must NOT carry (it leaks via logs / Referer / history —
|
|
856
|
+
// security review A3). That probe lands in phase-28 over the server-held
|
|
857
|
+
// keychain token (server-side, never client-supplied), not here.
|
|
858
|
+
const checkRemote = new URL(req.url).searchParams.get('remote') === '1';
|
|
859
|
+
return gitJson(await gitApi.status({ checkRemote }));
|
|
860
|
+
},
|
|
861
|
+
|
|
862
|
+
'/_api/git/log': async (req: Request) => {
|
|
863
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
864
|
+
// Optional `?path=` scopes History to one canvas (phase-27.1). MAIN-ORIGIN
|
|
865
|
+
// ONLY (this route is absent from CANVAS_SAFE_API) — the path is
|
|
866
|
+
// containment-validated in the endpoint before it reaches git.
|
|
867
|
+
const u = new URL(req.url).searchParams;
|
|
868
|
+
return gitJson(await gitApi.log(u.get('limit'), u.get('path')));
|
|
869
|
+
},
|
|
870
|
+
|
|
871
|
+
'/_api/git/diff': async (req: Request) => {
|
|
872
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
873
|
+
return gitJson(await gitApi.diff(new URL(req.url).searchParams.get('sha')));
|
|
874
|
+
},
|
|
875
|
+
|
|
876
|
+
// ── Phase 29 (E4) — drafts (branches). MAIN-ORIGIN ONLY (absent from
|
|
877
|
+
// CANVAS_SAFE_API + startCanvasServer routes); branch + checkout are POST/CSRF-
|
|
878
|
+
// gated source mutations. Switching a draft moves HEAD, which the git-lifecycle
|
|
879
|
+
// watcher turns into a Yjs flush + reload (DDR-051) — no logic duplicated here.
|
|
880
|
+
'/_api/git/branches': async (req: Request) => {
|
|
881
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
882
|
+
return gitJson(await gitApi.branches());
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
'/_api/git/branch': async (req: Request) => {
|
|
886
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
887
|
+
if (!sameOriginWrite(req))
|
|
888
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
889
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
890
|
+
return gitJson(await gitApi.createBranch(body));
|
|
891
|
+
},
|
|
892
|
+
|
|
893
|
+
'/_api/git/checkout': async (req: Request) => {
|
|
894
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
895
|
+
if (!sameOriginWrite(req))
|
|
896
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
897
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
898
|
+
return gitJson(await gitApi.checkout(body));
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
// "Add this draft to the Shared version" — merges + PUBLISHES, so it is token-
|
|
902
|
+
// bearing: same main-origin + loopback gate as /_api/git/push.
|
|
903
|
+
'/_api/git/fold': async (req: Request) => {
|
|
904
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
905
|
+
if (!sameOriginWrite(req))
|
|
906
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
907
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
908
|
+
return new Response('adding a draft requires a local request', { status: 403 });
|
|
909
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
910
|
+
return gitJson(await gitApi.fold(body));
|
|
911
|
+
},
|
|
912
|
+
|
|
913
|
+
'/_api/git/commit': async (req: Request) => {
|
|
914
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
915
|
+
if (!sameOriginWrite(req))
|
|
916
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
917
|
+
const body = await readJson<unknown>(req, 256 * 1024);
|
|
918
|
+
return gitJson(await gitApi.commit(body));
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
'/_api/git/discard': async (req: Request) => {
|
|
922
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
923
|
+
if (!sameOriginWrite(req))
|
|
924
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
925
|
+
const body = await readJson<unknown>(req, 256 * 1024);
|
|
926
|
+
return gitJson(await gitApi.discard(body));
|
|
927
|
+
},
|
|
928
|
+
|
|
929
|
+
'/_api/git/push': async (req: Request) => {
|
|
930
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
931
|
+
if (!sameOriginWrite(req))
|
|
932
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
933
|
+
// Token-bearing: refuse anything not from a loopback Host (the server binds
|
|
934
|
+
// 127.0.0.1, so this is belt-and-suspenders against a forwarded/rebound Host).
|
|
935
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
936
|
+
return new Response('publish requires a local request', { status: 403 });
|
|
937
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
938
|
+
return gitJson(await gitApi.push(body));
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
'/_api/git/pull': async (req: Request) => {
|
|
942
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
943
|
+
if (!sameOriginWrite(req))
|
|
944
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
945
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
946
|
+
return new Response('get latest requires a local request', { status: 403 });
|
|
947
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
948
|
+
return gitJson(await gitApi.pull(body));
|
|
949
|
+
},
|
|
950
|
+
|
|
951
|
+
// Finish a Get-latest merge that hit a conflict (DiffView "Keep mine/theirs/
|
|
952
|
+
// both"). Token-bearing (server-held; resolve completes the two-parent merge
|
|
953
|
+
// commit) → same main-origin + loopback gate as pull. MAIN-ORIGIN ONLY:
|
|
954
|
+
// absent from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist).
|
|
955
|
+
'/_api/git/resolve': async (req: Request) => {
|
|
956
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
957
|
+
if (!sameOriginWrite(req))
|
|
958
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
959
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
960
|
+
return new Response('resolve requires a local request', { status: 403 });
|
|
961
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
962
|
+
return gitJson(await gitApi.resolve(body));
|
|
963
|
+
},
|
|
964
|
+
|
|
965
|
+
// ── Phase 28 (E3) — GitHub identity & remote. Sign-in/out + keychain live in
|
|
966
|
+
// the Tauri shell (oauth.rs/keychain.rs commands); these endpoints use the
|
|
967
|
+
// server-held token (loopback bridge → token.ts) for the REST calls. MAIN-ORIGIN
|
|
968
|
+
// ONLY (absent from CANVAS_SAFE_API + startCanvasServer) and loopback-Host gated
|
|
969
|
+
// since every one is token-bearing. (Sign-out is the `github_sign_out` Tauri
|
|
970
|
+
// command — the dev-server can't touch the OS keychain — so there is no
|
|
971
|
+
// DELETE /_api/github/identity here; see DDR-114.)
|
|
972
|
+
'/_api/github/identity': async (req: Request) => {
|
|
973
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
974
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
975
|
+
return new Response('local request required', { status: 403 });
|
|
976
|
+
return gitJson(await githubApi.identity());
|
|
977
|
+
},
|
|
978
|
+
|
|
979
|
+
'/_api/github/repos': async (req: Request) => {
|
|
980
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
981
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
982
|
+
return new Response('local request required', { status: 403 });
|
|
983
|
+
return gitJson(await githubApi.repos());
|
|
984
|
+
},
|
|
985
|
+
|
|
986
|
+
'/_api/github/create-repo': async (req: Request) => {
|
|
987
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
988
|
+
if (!sameOriginWrite(req))
|
|
989
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
990
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
991
|
+
return new Response('local request required', { status: 403 });
|
|
992
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
993
|
+
return gitJson(await githubApi.createRepo(body));
|
|
994
|
+
},
|
|
995
|
+
|
|
996
|
+
'/_api/github/invite': async (req: Request) => {
|
|
997
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
998
|
+
if (!sameOriginWrite(req))
|
|
999
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1000
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1001
|
+
return new Response('local request required', { status: 403 });
|
|
1002
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1003
|
+
return gitJson(await githubApi.invite(body));
|
|
1004
|
+
},
|
|
1005
|
+
|
|
1006
|
+
'/_api/github/clone': async (req: Request) => {
|
|
1007
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1008
|
+
if (!sameOriginWrite(req))
|
|
1009
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1010
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1011
|
+
return new Response('local request required', { status: 403 });
|
|
1012
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1013
|
+
return gitJson(await githubApi.clone(body));
|
|
1014
|
+
},
|
|
1015
|
+
|
|
1016
|
+
'/_api/github/create-project': async (req: Request) => {
|
|
1017
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1018
|
+
if (!sameOriginWrite(req))
|
|
1019
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1020
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1021
|
+
return new Response('local request required', { status: 403 });
|
|
1022
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1023
|
+
return gitJson(await githubApi.createProject(body));
|
|
1024
|
+
},
|
|
1025
|
+
|
|
1026
|
+
// Scaffold a bootable .design/ into an existing folder (the "open a non-Maude
|
|
1027
|
+
// repo → set it up?" fallback). No token / no GitHub — local FS only, but still
|
|
1028
|
+
// main-origin + loopback gated (it writes to disk).
|
|
1029
|
+
'/_api/design/init': async (req: Request) => {
|
|
1030
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1031
|
+
if (!sameOriginWrite(req))
|
|
1032
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1033
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1034
|
+
return new Response('local request required', { status: 403 });
|
|
1035
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1036
|
+
return gitJson(await githubApi.initDesign(body));
|
|
1037
|
+
},
|
|
1038
|
+
|
|
1039
|
+
// Phase 29 (E4) Door C — connect to a team hub: validate + probe + save the hub
|
|
1040
|
+
// credential to the global ~/.config/maude/hubs.json (sync/hub-link.ts). MAIN
|
|
1041
|
+
// ORIGIN ONLY (omitted from CANVAS_SAFE_API + startCanvasServer routes) + loopback
|
|
1042
|
+
// + POST CSRF, mirroring /_api/github/*. The lean in-app counterpart to the CLI
|
|
1043
|
+
// `maude design link`; the in-UI Connect is the explicit trust grant (DDR-054 F2).
|
|
1044
|
+
'/_api/hub/link': async (req: Request) => {
|
|
1045
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1046
|
+
if (!sameOriginWrite(req))
|
|
1047
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1048
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1049
|
+
return new Response('local request required', { status: 403 });
|
|
1050
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1051
|
+
return gitJson(await linkHub(body));
|
|
1052
|
+
},
|
|
1053
|
+
|
|
671
1054
|
'/_api/edit-css': async (req: Request) => {
|
|
672
1055
|
// Phase 12 (DDR-103) — single-property inline CSS edit. POST body
|
|
673
1056
|
// { canvas, id, property, value } → writes one key into the element's
|
|
@@ -139,6 +139,35 @@ const CHROME_CSS = `
|
|
|
139
139
|
white-space: normal;
|
|
140
140
|
max-width: 200px;
|
|
141
141
|
}
|
|
142
|
+
/* Phase 30 / DDR-120 — soft editing-presence. A peer (or a bridged agent)
|
|
143
|
+
actively editing this canvas gets a gently-pulsing ring + a ✎ marker in the
|
|
144
|
+
peer's OWN identity hue (--dc-edit-ring, set inline from peer.color) so the
|
|
145
|
+
agent pulses violet, not the indigo accent. A heads-up, never a lock. */
|
|
146
|
+
.dc-participant--editing { animation: dc-participant-edit-pulse 1.8s ease-in-out infinite; }
|
|
147
|
+
@keyframes dc-participant-edit-pulse {
|
|
148
|
+
0%, 100% { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 2px var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); }
|
|
149
|
+
50% { box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 4px color-mix(in oklab, var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))) 45%, transparent); }
|
|
150
|
+
}
|
|
151
|
+
.dc-participant-edit-marker {
|
|
152
|
+
position: absolute;
|
|
153
|
+
right: -3px;
|
|
154
|
+
top: -3px;
|
|
155
|
+
width: 13px;
|
|
156
|
+
height: 13px;
|
|
157
|
+
border-radius: 50%;
|
|
158
|
+
background: var(--maude-chrome-bg-0, #fff);
|
|
159
|
+
color: var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50)));
|
|
160
|
+
display: inline-flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
justify-content: center;
|
|
163
|
+
font-size: 8px;
|
|
164
|
+
line-height: 1;
|
|
165
|
+
box-shadow: 0 0 0 1px rgba(0,0,0,0.14);
|
|
166
|
+
pointer-events: none;
|
|
167
|
+
}
|
|
168
|
+
@media (prefers-reduced-motion: reduce) {
|
|
169
|
+
.dc-participant--editing { animation: none; box-shadow: 0 1px 2px rgba(0,0,0,0.08), 0 0 0 2px var(--dc-edit-ring, var(--maude-hud-accent, oklch(56% 0.170 50))); }
|
|
170
|
+
}
|
|
142
171
|
`.trim();
|
|
143
172
|
|
|
144
173
|
function ensureChromeStyles(): void {
|
|
@@ -150,6 +179,17 @@ function ensureChromeStyles(): void {
|
|
|
150
179
|
document.head.appendChild(s);
|
|
151
180
|
}
|
|
152
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Phase 30 / DDR-120 — true when a foreign peer is THIS machine's own
|
|
184
|
+
* server-side editing echo: a peer carrying my own name with editing set but
|
|
185
|
+
* no cursor/selection. That's the room's projection of my own agent/inspector
|
|
186
|
+
* edit (which I already see via the local ai-activity avatar), so I skip it. A
|
|
187
|
+
* remote peer never matches (different name), so they still get the heads-up.
|
|
188
|
+
*/
|
|
189
|
+
function isOwnEditingEcho(peer: ForeignAwareness, myName: string | undefined): boolean {
|
|
190
|
+
return peer.name === myName && !!peer.editing && !peer.cursor && !peer.selection;
|
|
191
|
+
}
|
|
192
|
+
|
|
153
193
|
function initialsFor(name: string): string {
|
|
154
194
|
const cleaned = name.trim();
|
|
155
195
|
if (!cleaned) return '?';
|
|
@@ -169,6 +209,7 @@ interface AvatarProps {
|
|
|
169
209
|
function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element {
|
|
170
210
|
const [open, setOpen] = useState(false);
|
|
171
211
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
212
|
+
const editing = !!peer.editing;
|
|
172
213
|
|
|
173
214
|
useEffect(() => {
|
|
174
215
|
if (!open) return;
|
|
@@ -180,11 +221,16 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
|
|
|
180
221
|
return () => document.removeEventListener('mousedown', onDocClick);
|
|
181
222
|
}, [open]);
|
|
182
223
|
|
|
224
|
+
const cls = `dc-participant${isFollowing ? ' dc-participant--following' : ''}${editing ? ' dc-participant--editing' : ''}`;
|
|
225
|
+
// `--dc-edit-ring` drives the editing pulse/marker off the peer's own hue.
|
|
226
|
+
const style = (
|
|
227
|
+
editing ? { background: peer.color, '--dc-edit-ring': peer.color } : { background: peer.color }
|
|
228
|
+
) as React.CSSProperties;
|
|
183
229
|
return (
|
|
184
230
|
<div
|
|
185
231
|
ref={rootRef}
|
|
186
|
-
className={
|
|
187
|
-
style={
|
|
232
|
+
className={cls}
|
|
233
|
+
style={style}
|
|
188
234
|
onClick={() => setOpen((v) => !v)}
|
|
189
235
|
onKeyDown={(e) => {
|
|
190
236
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
@@ -194,14 +240,20 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
|
|
|
194
240
|
}}
|
|
195
241
|
role="button"
|
|
196
242
|
tabIndex={0}
|
|
197
|
-
title={peer.name}
|
|
198
|
-
aria-label={peer.name}
|
|
243
|
+
title={editing ? `${peer.name} — editing this canvas` : peer.name}
|
|
244
|
+
aria-label={editing ? `${peer.name}, editing this canvas` : peer.name}
|
|
199
245
|
aria-expanded={open}
|
|
200
246
|
>
|
|
201
247
|
{initialsFor(peer.name)}
|
|
248
|
+
{editing && (
|
|
249
|
+
<span className="dc-participant-edit-marker" aria-hidden="true">
|
|
250
|
+
✎
|
|
251
|
+
</span>
|
|
252
|
+
)}
|
|
202
253
|
{open && (
|
|
203
254
|
<div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
|
|
204
255
|
<div className="dc-participant-popover__name">{peer.name}</div>
|
|
256
|
+
{editing && <div className="dc-participant-popover__sub">Editing this canvas</div>}
|
|
205
257
|
<button
|
|
206
258
|
type="button"
|
|
207
259
|
className={`dc-participant-popover__btn${isFollowing ? ' dc-participant-popover__btn--stop' : ''}`}
|
|
@@ -318,13 +370,21 @@ export function ParticipantsChrome(): JSX.Element | null {
|
|
|
318
370
|
controller.setViewport(v);
|
|
319
371
|
}, [controller, followTarget, peers]);
|
|
320
372
|
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
373
|
+
// Phase 30 / DDR-120 — drop the authoring machine's own server-side editing
|
|
374
|
+
// echo: a foreign peer carrying MY name with editing set but no cursor/
|
|
375
|
+
// selection is the room's projection of my own agent/inspector edit, which I
|
|
376
|
+
// already see via the local ai-activity avatar. A remote peer never matches
|
|
377
|
+
// (different name), so they still get the "X is editing" heads-up.
|
|
378
|
+
const myName = collab?.myName;
|
|
379
|
+
const visiblePeers = peers.filter((p) => !isOwnEditingEcho(p, myName));
|
|
380
|
+
|
|
381
|
+
// Render whenever there's a visible human peer OR an active agent (so an agent
|
|
382
|
+
// shows even with no human collaborators connected).
|
|
383
|
+
if (visiblePeers.length === 0 && !agent) return null;
|
|
324
384
|
|
|
325
385
|
return (
|
|
326
386
|
<div className="dc-participants" aria-label="Active collaborators">
|
|
327
|
-
{
|
|
387
|
+
{visiblePeers.map((p) => (
|
|
328
388
|
<Avatar
|
|
329
389
|
key={p.clientID}
|
|
330
390
|
peer={p}
|
|
@@ -337,4 +397,4 @@ export function ParticipantsChrome(): JSX.Element | null {
|
|
|
337
397
|
);
|
|
338
398
|
}
|
|
339
399
|
|
|
340
|
-
export { initialsFor };
|
|
400
|
+
export { initialsFor, isOwnEditingEcho };
|