@1agh/maude 0.30.0 → 0.32.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 +788 -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 +796 -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 +598 -0
- package/apps/studio/client/panels/ReadinessList.jsx +189 -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 +625 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +821 -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 +29097 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5816 -1614
- 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 +399 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/readiness.ts +220 -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 +95 -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/readiness.test.ts +127 -0
- 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 +82 -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,127 @@
|
|
|
1
|
+
// Readiness probe (DDR-128) — plugin-registry scan + PATH resolution. The
|
|
2
|
+
// login-shell fallback's full correctness is proven on the packaged `.app`
|
|
3
|
+
// (native-verification ceiling); here we cover the registry-scan branches, the
|
|
4
|
+
// no-throw contracts, and the report shape. The probe is async (the fallback shells
|
|
5
|
+
// out off the event loop — DDR-128 hardening), so everything awaits.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { probeReadiness, resolveOnPath } from '../readiness.ts';
|
|
13
|
+
|
|
14
|
+
const MARKET_OK = {
|
|
15
|
+
maude: { source: { source: 'github', repo: '1aGh/maude' }, installLocation: '/x' },
|
|
16
|
+
};
|
|
17
|
+
const INSTALLED_BOTH = {
|
|
18
|
+
version: 2,
|
|
19
|
+
plugins: {
|
|
20
|
+
'design@maude': [{ scope: 'user', version: '0.29.0' }],
|
|
21
|
+
'flow@maude': [{ scope: 'user', version: '0.29.0' }],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const INSTALLED_DESIGN_ONLY = { version: 2, plugins: { 'design@maude': [{ scope: 'user' }] } };
|
|
25
|
+
|
|
26
|
+
function fixtureClaudeDir(opts: { markets?: unknown; installed?: unknown }): string {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'maude-readiness-'));
|
|
28
|
+
const pluginsDir = join(dir, 'plugins');
|
|
29
|
+
mkdirSync(pluginsDir, { recursive: true });
|
|
30
|
+
if (opts.markets !== undefined)
|
|
31
|
+
writeFileSync(join(pluginsDir, 'known_marketplaces.json'), JSON.stringify(opts.markets));
|
|
32
|
+
if (opts.installed !== undefined)
|
|
33
|
+
writeFileSync(join(pluginsDir, 'installed_plugins.json'), JSON.stringify(opts.installed));
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function withClaudeDir<T>(dir: string, fn: () => Promise<T> | T): Promise<T> {
|
|
38
|
+
const saved = process.env.CLAUDE_CONFIG_DIR;
|
|
39
|
+
process.env.CLAUDE_CONFIG_DIR = dir;
|
|
40
|
+
try {
|
|
41
|
+
return await fn();
|
|
42
|
+
} finally {
|
|
43
|
+
if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
44
|
+
else process.env.CLAUDE_CONFIG_DIR = saved;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pluginsItem = async (dir: string) =>
|
|
49
|
+
(await withClaudeDir(dir, () => probeReadiness())).items.find((i) => i.id === 'plugins')!;
|
|
50
|
+
|
|
51
|
+
describe('readiness — plugin registry scan', () => {
|
|
52
|
+
test('both plugins + marketplace present → plugins item is present, no remediation', async () => {
|
|
53
|
+
const item = await pluginsItem(
|
|
54
|
+
fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_BOTH })
|
|
55
|
+
);
|
|
56
|
+
expect(item.status).toBe('present');
|
|
57
|
+
expect(item.remediation).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('only design@maude installed → missing, names the absent flow@maude + offers the fix', async () => {
|
|
61
|
+
const item = await pluginsItem(
|
|
62
|
+
fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_DESIGN_ONLY })
|
|
63
|
+
);
|
|
64
|
+
expect(item.status).toBe('missing');
|
|
65
|
+
expect(item.detail).toContain('flow@maude');
|
|
66
|
+
expect(item.remediation).toContain('/plugin install');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('registry absent → unknown (Claude Code internal contract; never throws)', async () => {
|
|
70
|
+
const item = await pluginsItem(fixtureClaudeDir({}));
|
|
71
|
+
expect(item.status).toBe('unknown');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('plugin presence is the gate — a foreign-repo marketplace does not change a both-installed verdict', async () => {
|
|
75
|
+
const item = await pluginsItem(
|
|
76
|
+
fixtureClaudeDir({
|
|
77
|
+
markets: { other: { source: { repo: 'someone/else' } } },
|
|
78
|
+
installed: INSTALLED_BOTH,
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
expect(item.status).toBe('present');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('readiness — resolveOnPath', () => {
|
|
86
|
+
test('finds a binary that is on PATH', async () => {
|
|
87
|
+
const hit = await resolveOnPath('sh');
|
|
88
|
+
expect(hit).not.toBeNull();
|
|
89
|
+
expect(existsSync(hit!)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('returns null for a bogus binary without throwing', async () => {
|
|
93
|
+
expect(await resolveOnPath('maude-definitely-not-real-xyz')).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('login-shell fallback recovers a binary hidden from the app PATH', async () => {
|
|
97
|
+
const saved = process.env.PATH;
|
|
98
|
+
try {
|
|
99
|
+
// Simulate the truncated/empty GUI env so Bun.which misses and the fallback fires.
|
|
100
|
+
process.env.PATH = '';
|
|
101
|
+
const hit = await resolveOnPath('sh');
|
|
102
|
+
// When a login shell is usable it must recover an absolute, real path. In a
|
|
103
|
+
// sandbox with no usable login shell the fallback returns null — we don't fail
|
|
104
|
+
// on that, but a non-null result must be a real absolute path.
|
|
105
|
+
if (hit !== null) {
|
|
106
|
+
expect(hit.startsWith('/')).toBe(true);
|
|
107
|
+
expect(existsSync(hit)).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
if (saved === undefined) delete process.env.PATH;
|
|
111
|
+
else process.env.PATH = saved;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('readiness — report shape', () => {
|
|
117
|
+
test('always returns the four items with stable ids and a boolean ready', async () => {
|
|
118
|
+
const report = await probeReadiness();
|
|
119
|
+
expect(report.items.map((i) => i.id)).toEqual(['claude', 'maude', 'plugins', 'agent-browser']);
|
|
120
|
+
expect(typeof report.ready).toBe('boolean');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('agent-browser is optional and never gates ready', async () => {
|
|
124
|
+
const ab = (await probeReadiness()).items.find((i) => i.id === 'agent-browser')!;
|
|
125
|
+
expect(ab.required).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Concurrent cold-seed de-duplication — F1 regression.
|
|
2
|
+
//
|
|
3
|
+
// RCA: `.ai/logs/rca/issue-collab-cold-seed-tsx-duplication.md`. When TWO
|
|
4
|
+
// dev-servers seed the SAME canvas slug into an EMPTY hub at the SAME instant,
|
|
5
|
+
// each reads `docBody === empty` before the other's `seed-local-up` write has
|
|
6
|
+
// propagated, so both apply their (identical) body. Two `applyHtmlToDoc`
|
|
7
|
+
// insertions from two Yjs clientIDs into one Y.Text are NOT content-deduped by
|
|
8
|
+
// the CRDT — the body doubled (two `export default`), failing the canvas build.
|
|
9
|
+
//
|
|
10
|
+
// The fix elects a single writer (`syncMeta.seededBy` — Y.Map LWW converges it
|
|
11
|
+
// to ONE clientID on every peer) which, inside a bounded post-seed window,
|
|
12
|
+
// re-applies the canonical body so `applyHtmlToDoc`'s diff deletes the trailing
|
|
13
|
+
// duplicate. These tests pin: (1) simultaneous seed → one copy, (2) a fresh peer
|
|
14
|
+
// booting against an already-doubled hub recovers, (3) the sequential control
|
|
15
|
+
// stays clean (no over-eager collapse of legitimately-adopted state).
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import * as Y from 'yjs';
|
|
22
|
+
|
|
23
|
+
import { type CanvasSyncAgent, createCanvasSyncAgent } from '../sync/agent.ts';
|
|
24
|
+
import { htmlFromDoc } from '../sync/codec.ts';
|
|
25
|
+
import { createEchoGuard } from '../sync/echo-guard.ts';
|
|
26
|
+
|
|
27
|
+
// A realistic canvas body — the un-buildable failure is `export default` × 2.
|
|
28
|
+
const BODY = [
|
|
29
|
+
'import { DesignCanvas } from "@maude/canvas-lib";',
|
|
30
|
+
'export default function Screen() {',
|
|
31
|
+
' return <DesignCanvas><h1>Shared draft</h1></DesignCanvas>;',
|
|
32
|
+
'}',
|
|
33
|
+
'',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
const TRANSPORT = Symbol('transport');
|
|
37
|
+
|
|
38
|
+
let dirA: string;
|
|
39
|
+
let dirB: string;
|
|
40
|
+
let docA: Y.Doc;
|
|
41
|
+
let docB: Y.Doc;
|
|
42
|
+
let agentA: CanvasSyncAgent | undefined;
|
|
43
|
+
let agentB: CanvasSyncAgent | undefined;
|
|
44
|
+
|
|
45
|
+
function pathsFor(dir: string) {
|
|
46
|
+
return {
|
|
47
|
+
html: join(dir, 'screen.tsx'),
|
|
48
|
+
comments: join(dir, '_comments', 'screen.json'),
|
|
49
|
+
annotations: join(dir, 'screen.annotations.svg'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeLocalBody(dir: string, body: string): void {
|
|
54
|
+
// Seed disk before the agent reconciles (the local side of the cold start).
|
|
55
|
+
writeFileSync(pathsFor(dir).html, body);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeAgent(doc: Y.Doc, dir: string): CanvasSyncAgent {
|
|
59
|
+
const a = createCanvasSyncAgent({
|
|
60
|
+
slug: 'screen',
|
|
61
|
+
doc,
|
|
62
|
+
paths: pathsFor(dir),
|
|
63
|
+
echoGuard: createEchoGuard(),
|
|
64
|
+
flushMs: 0, // synchronous flush via queueMicrotask
|
|
65
|
+
});
|
|
66
|
+
a.start();
|
|
67
|
+
return a;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Bidirectional full-state exchange, looped until the de-dup repair (which
|
|
71
|
+
* fires synchronously inside applyUpdate) has propagated + settled. */
|
|
72
|
+
function syncDocs(d1: Y.Doc, d2: Y.Doc, rounds = 6): void {
|
|
73
|
+
for (let i = 0; i < rounds; i++) {
|
|
74
|
+
Y.applyUpdate(d2, Y.encodeStateAsUpdate(d1), TRANSPORT);
|
|
75
|
+
Y.applyUpdate(d1, Y.encodeStateAsUpdate(d2), TRANSPORT);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
dirA = mkdtempSync(join(tmpdir(), 'cold-seed-a-'));
|
|
81
|
+
dirB = mkdtempSync(join(tmpdir(), 'cold-seed-b-'));
|
|
82
|
+
docA = new Y.Doc();
|
|
83
|
+
docB = new Y.Doc();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
agentA?.stop();
|
|
88
|
+
agentB?.stop();
|
|
89
|
+
agentA = undefined;
|
|
90
|
+
agentB = undefined;
|
|
91
|
+
rmSync(dirA, { recursive: true, force: true });
|
|
92
|
+
rmSync(dirB, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('concurrent cold-seed → single body', () => {
|
|
96
|
+
test('two peers seed identical bodies into an empty hub at once → ONE copy', async () => {
|
|
97
|
+
writeLocalBody(dirA, BODY);
|
|
98
|
+
writeLocalBody(dirB, BODY);
|
|
99
|
+
agentA = makeAgent(docA, dirA);
|
|
100
|
+
agentB = makeAgent(docB, dirB);
|
|
101
|
+
|
|
102
|
+
// The race: BOTH reconcile against an empty doc before either propagates.
|
|
103
|
+
await agentA.reconcile();
|
|
104
|
+
await agentB.reconcile();
|
|
105
|
+
|
|
106
|
+
// Hub merges both seeds — the moment the duplication would surface.
|
|
107
|
+
syncDocs(docA, docB);
|
|
108
|
+
|
|
109
|
+
// Both docs converge to exactly ONE copy (no doubled `export default`).
|
|
110
|
+
expect(htmlFromDoc(docA)).toBe(BODY);
|
|
111
|
+
expect(htmlFromDoc(docB)).toBe(BODY);
|
|
112
|
+
// And the count of `export default` is 1 — the build-breaking signature gone.
|
|
113
|
+
expect(htmlFromDoc(docA).match(/export default/g)?.length).toBe(1);
|
|
114
|
+
|
|
115
|
+
// Disk on both peers mirrors the single copy.
|
|
116
|
+
await agentA.flush();
|
|
117
|
+
await agentB.flush();
|
|
118
|
+
expect(readFileSync(pathsFor(dirA).html, 'utf8')).toBe(BODY);
|
|
119
|
+
expect(readFileSync(pathsFor(dirB).html, 'utf8')).toBe(BODY);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('adopt-mode concurrent seed also self-heals to one copy', async () => {
|
|
123
|
+
writeLocalBody(dirA, BODY);
|
|
124
|
+
writeLocalBody(dirB, BODY);
|
|
125
|
+
agentA = createCanvasSyncAgent({
|
|
126
|
+
slug: 'screen',
|
|
127
|
+
doc: docA,
|
|
128
|
+
paths: pathsFor(dirA),
|
|
129
|
+
echoGuard: createEchoGuard(),
|
|
130
|
+
flushMs: 0,
|
|
131
|
+
adopt: true,
|
|
132
|
+
});
|
|
133
|
+
agentB = createCanvasSyncAgent({
|
|
134
|
+
slug: 'screen',
|
|
135
|
+
doc: docB,
|
|
136
|
+
paths: pathsFor(dirB),
|
|
137
|
+
echoGuard: createEchoGuard(),
|
|
138
|
+
flushMs: 0,
|
|
139
|
+
adopt: true,
|
|
140
|
+
});
|
|
141
|
+
agentA.start();
|
|
142
|
+
agentB.start();
|
|
143
|
+
|
|
144
|
+
await agentA.reconcile();
|
|
145
|
+
await agentB.reconcile();
|
|
146
|
+
syncDocs(docA, docB);
|
|
147
|
+
|
|
148
|
+
expect(htmlFromDoc(docA)).toBe(BODY);
|
|
149
|
+
expect(htmlFromDoc(docB)).toBe(BODY);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('recovery of an already-duplicated hub (requirement #3)', () => {
|
|
154
|
+
test('a peer booting against a doubled doc collapses it on reconcile', async () => {
|
|
155
|
+
writeLocalBody(dirA, BODY);
|
|
156
|
+
// Simulate a hub that already concatenated two identical seeds.
|
|
157
|
+
const t = docA.getText('html');
|
|
158
|
+
t.insert(0, BODY);
|
|
159
|
+
t.insert(BODY.length, BODY);
|
|
160
|
+
expect(htmlFromDoc(docA)).toBe(BODY + BODY);
|
|
161
|
+
|
|
162
|
+
agentA = makeAgent(docA, dirA);
|
|
163
|
+
await agentA.reconcile();
|
|
164
|
+
|
|
165
|
+
expect(htmlFromDoc(docA)).toBe(BODY);
|
|
166
|
+
expect(htmlFromDoc(docA).match(/export default/g)?.length).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('sequential control — no over-eager collapse', () => {
|
|
171
|
+
test('B joining after A seeded adopts the single copy (no duplication)', async () => {
|
|
172
|
+
writeLocalBody(dirA, BODY);
|
|
173
|
+
writeLocalBody(dirB, BODY);
|
|
174
|
+
agentA = makeAgent(docA, dirA);
|
|
175
|
+
agentB = makeAgent(docB, dirB);
|
|
176
|
+
|
|
177
|
+
// A seeds first and FULLY propagates before B reconciles.
|
|
178
|
+
await agentA.reconcile();
|
|
179
|
+
syncDocs(docA, docB);
|
|
180
|
+
// B now sees the hub already holds the body → noop (adopt), no second seed.
|
|
181
|
+
await agentB.reconcile();
|
|
182
|
+
syncDocs(docA, docB);
|
|
183
|
+
|
|
184
|
+
expect(htmlFromDoc(docA)).toBe(BODY);
|
|
185
|
+
expect(htmlFromDoc(docB)).toBe(BODY);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from 'bun:test';
|
|
10
10
|
|
|
11
|
-
import { type ColdStartInput, decideColdStart } from '../sync/cold-start.ts';
|
|
11
|
+
import { type ColdStartInput, decideColdStart, isExactRepeat } from '../sync/cold-start.ts';
|
|
12
12
|
import { hashBytes } from '../sync/echo-guard.ts';
|
|
13
13
|
|
|
14
14
|
const BODY_LOCAL = '<div>local work — a day of mascot edits</div>';
|
|
@@ -82,6 +82,66 @@ describe('decideColdStart — identical sides', () => {
|
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
describe('decideColdStart — concurrent cold-seed duplication (F1)', () => {
|
|
86
|
+
const BODY = '<div>export default fn — one copy</div>';
|
|
87
|
+
|
|
88
|
+
test('doc == local repeated twice → recover-seed-dup (collapse, not conflict)', () => {
|
|
89
|
+
const d = decideColdStart(input({ localBody: BODY, docBody: BODY + BODY }));
|
|
90
|
+
expect(d.action).toBe('recover-seed-dup');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('doc == local repeated three times (3-way seed) → recover-seed-dup', () => {
|
|
94
|
+
const d = decideColdStart(input({ localBody: BODY, docBody: BODY.repeat(3) }));
|
|
95
|
+
expect(d.action).toBe('recover-seed-dup');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('duplication recovery beats the conflict path even with divergent timestamps', () => {
|
|
99
|
+
// A doubled body is NOT new content — newest-wins must not get to pick it.
|
|
100
|
+
const d = decideColdStart(
|
|
101
|
+
input({
|
|
102
|
+
localBody: BODY,
|
|
103
|
+
docBody: BODY + BODY,
|
|
104
|
+
localMtimeMs: 1_000,
|
|
105
|
+
docBodyEditAtMs: 9_999,
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
expect(d.action).toBe('recover-seed-dup');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('duplication recovery fires even with a stale journal', () => {
|
|
112
|
+
const d = decideColdStart(
|
|
113
|
+
input({ localBody: BODY, docBody: BODY + BODY, journalHash: 'stale' })
|
|
114
|
+
);
|
|
115
|
+
expect(d.action).toBe('recover-seed-dup');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('doc = local + genuinely different bytes is NOT a repeat → conflict (snapshots both)', () => {
|
|
119
|
+
const d = decideColdStart(
|
|
120
|
+
input({
|
|
121
|
+
localBody: BODY,
|
|
122
|
+
docBody: `${BODY}<div>a real concurrent edit</div>`,
|
|
123
|
+
localMtimeMs: 2_000,
|
|
124
|
+
docBodyEditAtMs: 1_000,
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
expect(d.action).toBe('conflict');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('exact equality (×1) stays a noop, never recover-seed-dup', () => {
|
|
131
|
+
expect(decideColdStart(input({ localBody: BODY, docBody: BODY })).action).toBe('noop');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('isExactRepeat — exact multiples only', () => {
|
|
135
|
+
expect(isExactRepeat(BODY + BODY, BODY)).toBe(true);
|
|
136
|
+
expect(isExactRepeat(BODY.repeat(4), BODY)).toBe(true);
|
|
137
|
+
expect(isExactRepeat(BODY, BODY)).toBe(false); // ×1 is equality, not duplication
|
|
138
|
+
expect(isExactRepeat(`${BODY}x`, BODY)).toBe(false); // not a clean multiple
|
|
139
|
+
expect(isExactRepeat(`${BODY}${BODY}x`, BODY)).toBe(false);
|
|
140
|
+
expect(isExactRepeat('', BODY)).toBe(false);
|
|
141
|
+
expect(isExactRepeat(BODY, '')).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
85
145
|
describe('decideColdStart — journal-gated fast-forward', () => {
|
|
86
146
|
test('local ≠ doc, journal matches hash(local) → fast-forward-hub (no conflict)', () => {
|
|
87
147
|
const d = decideColdStart(
|
|
@@ -43,6 +43,24 @@ describe('TourOverlay', () => {
|
|
|
43
43
|
expect(html).not.toContain('mdcc-tour__back'); // i === 0
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
test('a step with a render graphic widens the card and draws the graphic (Phase 29 / E4)', () => {
|
|
47
|
+
function Graphic() {
|
|
48
|
+
return <div className="cm-info">TWO-LAYER-DIAGRAM</div>;
|
|
49
|
+
}
|
|
50
|
+
const html = renderToStaticMarkup(
|
|
51
|
+
<TourOverlay
|
|
52
|
+
open
|
|
53
|
+
steps={[{ render: Graphic, title: 'Working together', body: 'Two layers.' }]}
|
|
54
|
+
onClose={() => {}}
|
|
55
|
+
onComplete={() => {}}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
expect(html).toContain('mdcc-tour__card--graphic');
|
|
59
|
+
expect(html).toContain('mdcc-tour__graphic');
|
|
60
|
+
expect(html).toContain('TWO-LAYER-DIAGRAM');
|
|
61
|
+
expect(html).toContain('Working together');
|
|
62
|
+
});
|
|
63
|
+
|
|
46
64
|
test('closed or empty renders nothing', () => {
|
|
47
65
|
expect(
|
|
48
66
|
renderToStaticMarkup(
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
SHAPE_KIND_ICONS,
|
|
21
21
|
TOOL_ICONS,
|
|
22
22
|
} from './canvas-icons.tsx';
|
|
23
|
-
import {
|
|
23
|
+
import { useChromeVisibility } from './use-chrome-visibility.tsx';
|
|
24
24
|
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
25
25
|
|
|
26
26
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -256,7 +256,7 @@ const SHAPE_KINDS: ReadonlyArray<{ kind: ShapeKind; label: string }> = [
|
|
|
256
256
|
export function ToolPalette() {
|
|
257
257
|
ensurePaletteStyles();
|
|
258
258
|
const { tool, setTool, tools, sticky, toggleSticky, shapeKind, setShapeKind } = useToolMode();
|
|
259
|
-
const
|
|
259
|
+
const chrome = useChromeVisibility();
|
|
260
260
|
const [mounted, setMounted] = useState(false);
|
|
261
261
|
const [shapeOpen, setShapeOpen] = useState(false);
|
|
262
262
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -287,11 +287,12 @@ export function ToolPalette() {
|
|
|
287
287
|
}, [tool]);
|
|
288
288
|
|
|
289
289
|
if (!mounted) return null;
|
|
290
|
+
// Presentation Mode hides the whole tool palette (clean artboards-only view).
|
|
291
|
+
if (chrome?.present) return null;
|
|
290
292
|
|
|
291
293
|
const byId = new Map(tools.map((t) => [t.id, t]));
|
|
292
294
|
const navList = NAV_TOOLS.map((id) => byId.get(id)).filter(Boolean);
|
|
293
295
|
const drawList = DRAW_TOOLS.map((id) => byId.get(id)).filter(Boolean);
|
|
294
|
-
const annotationsHidden = visibilityCtx ? !visibilityCtx.visible : false;
|
|
295
296
|
|
|
296
297
|
const renderToolButton = (id: string, label: string, shortcut: string) => {
|
|
297
298
|
const Icon = TOOL_ICONS[id];
|
|
@@ -412,12 +413,20 @@ export function ToolPalette() {
|
|
|
412
413
|
</button>
|
|
413
414
|
<button
|
|
414
415
|
type="button"
|
|
415
|
-
aria-label=
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
416
|
+
aria-label="Presentation mode — hide all chrome"
|
|
417
|
+
title="Presentation mode — hide all chrome (Esc to exit)"
|
|
418
|
+
onClick={() => {
|
|
419
|
+
// Present Mode is a SHELL-level state (it hides the menubar /
|
|
420
|
+
// sidebar / panels too), so the canvas iframe requests it from the
|
|
421
|
+
// parent shell, which flips on `.is-present` + broadcasts
|
|
422
|
+
// dgn:'view-chrome' back down. Enter-only — the palette is hidden
|
|
423
|
+
// while presenting; exit is Esc or the floating pill.
|
|
424
|
+
try {
|
|
425
|
+
window.parent.postMessage({ dgn: 'present-enter' }, '*');
|
|
426
|
+
} catch {
|
|
427
|
+
/* detached / cross-origin */
|
|
428
|
+
}
|
|
429
|
+
}}
|
|
421
430
|
>
|
|
422
431
|
<IconPresentation />
|
|
423
432
|
</button>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-chrome-visibility.tsx — canvas-chrome visibility store
|
|
3
|
+
* @scope apps/studio/use-chrome-visibility.tsx
|
|
4
|
+
* @purpose Tiny store shared by the in-canvas floating chrome — DCMiniMap +
|
|
5
|
+
* DCZoomToolbar (canvas-lib), ToolPalette, and AnnotationsLayer —
|
|
6
|
+
* so the dev-server menubar can hide/show the minimap and zoom
|
|
7
|
+
* controls, and drive a non-destructive "Presentation Mode" that
|
|
8
|
+
* overlays-hides ALL chrome (minimap, zoom, tool palette,
|
|
9
|
+
* annotations) without mutating the user's individual toggles.
|
|
10
|
+
* Mounted by CanvasShell (next to AnnotationsVisibilityProvider) so
|
|
11
|
+
* the user canvas (which renders the minimap/zoom) AND its overlay
|
|
12
|
+
* siblings under CanvasRouter all consume the same value. Updated
|
|
13
|
+
* by the `dgn:'view-chrome'` postMessage handler in CanvasRouter.
|
|
14
|
+
*
|
|
15
|
+
* `present` is a master overlay-hide that supersedes `minimap`/
|
|
16
|
+
* `zoom`: while it is on, every chrome consumer renders nothing,
|
|
17
|
+
* and exiting restores whatever the individual toggles were.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react';
|
|
21
|
+
|
|
22
|
+
export interface ChromeVisibilityState {
|
|
23
|
+
/** Floating world mini-map (DCMiniMap). */
|
|
24
|
+
minimap: boolean;
|
|
25
|
+
/** Bottom zoom pill (DCZoomToolbar). */
|
|
26
|
+
zoom: boolean;
|
|
27
|
+
/** Presentation Mode — hides ALL chrome regardless of the two above. */
|
|
28
|
+
present: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ChromeVisibilityValue extends ChromeVisibilityState {
|
|
32
|
+
/** Merge a partial patch (only the provided keys change). */
|
|
33
|
+
setChrome: (patch: Partial<ChromeVisibilityState>) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ChromeVisibilityContext = createContext<ChromeVisibilityValue | null>(null);
|
|
37
|
+
|
|
38
|
+
export function ChromeVisibilityProvider({
|
|
39
|
+
children,
|
|
40
|
+
initial,
|
|
41
|
+
}: {
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
initial?: Partial<ChromeVisibilityState>;
|
|
44
|
+
}) {
|
|
45
|
+
const [state, setState] = useState<ChromeVisibilityState>({
|
|
46
|
+
minimap: initial?.minimap ?? true,
|
|
47
|
+
zoom: initial?.zoom ?? true,
|
|
48
|
+
present: initial?.present ?? false,
|
|
49
|
+
});
|
|
50
|
+
const setChrome = useCallback((patch: Partial<ChromeVisibilityState>) => {
|
|
51
|
+
setState((prev) => ({ ...prev, ...patch }));
|
|
52
|
+
}, []);
|
|
53
|
+
const value = useMemo<ChromeVisibilityValue>(() => ({ ...state, setChrome }), [state, setChrome]);
|
|
54
|
+
return (
|
|
55
|
+
<ChromeVisibilityContext.Provider value={value}>{children}</ChromeVisibilityContext.Provider>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the chrome-visibility value, or null when rendered outside a
|
|
61
|
+
* provider (e.g. a bare DS specimen). Consumers treat null as "all visible"
|
|
62
|
+
* so the default behaviour is unchanged.
|
|
63
|
+
*/
|
|
64
|
+
export function useChromeVisibility(): ChromeVisibilityValue | null {
|
|
65
|
+
return useContext(ChromeVisibilityContext);
|
|
66
|
+
}
|