feat(demo): full showcase polish - mouse cursor, robust selection, HF affiliations
Browse filesDemo orchestration:
- Visible mouse cursor overlay (popover top-layer, MutationObserver to
stay above native dialogs) + humanMove/humanClick/humanHover helpers
- Yjs Node.js clients for Bob and Carol with awareness cursors and a
set of high-level helpers (writeBlockMathAtSlot, setAffiliationUrl,
waitForAffiliationCount, etc.) so peer interactions never collide
- Pre-bold sync barrier: Alice waits for Bob's block math and Carol's
code block to land before doing the bubble-toolbar bold so layout is
frozen during the drag selection (no more cross-paragraph selections)
- Progressive cursor-driven text selection (visible glide + step-wise
setTextSelection) for bold and rephrase actions
- Custom rAF-eased scroll-to-top on the actual .editor-scroll container
before the hue drag so the banner retint reads on camera
Embed Studio:
- New architecture.json companion data file (transformer-block layers)
seeded alongside model-accuracy.csv, so the agent has something
concrete to read and turn into the visualization
- Beefed-up chat scene that mirrors a real round-trip: readEmbed,
listDataFiles, readDataFile (json), readDataFile (csv), then a
detailed createEmbed - the description matches the rendered banner
Neural-network banner:
- Trim every link by node radius + margin so synapses no longer poke
through the ring (clear "links below nodes" layering)
- Hardcoded translucent white strokes with vector-effect:non-scaling-stroke
for visibility on every host surface (preview, editor hero, article)
- Ring + node strokes also non-scaling so they stay crisp at any size
Frontmatter / authors:
- Alice now adds her own huggingface.co profile URL alongside the
Hugging Face affiliation she seeds
- Bob backfills the affiliation's URL to https://huggingface.co and
adds two co-authors (Bob Mercier, Mira Patel) reusing affiliation
index 1, each with their own huggingface.co profile URL - byline
renders three co-authors all under Hugging Face with linkable name
and affiliation chips
Publish flow:
- Monotonic progress bar (useRef-backed, never goes backward)
- Restyled success state with URL chip and full-width "View published
article" button + ExternalLink icon
- Explicit humanClick on the link (after stripping target/rel) so the
navigation reads as a real user action
Agent rewrite extension:
- Fix cancelToken race that left replaced sentences empty: token is
now read from the initial command state, cursor mapped through every
intermediate transaction so the typewriter never bails early
- Atomic "none" animation path for the rephrase, with re-seeded
AgentFocus so the highlight stays on the new text through linger
UI polish:
- Publish dialog: softer shadows, color-mix accents, sparkling note
icon, gradient progress bar with shimmer, success URL chip
- Auto-close chat panel after rephrase action lands
Made-with: Cursor
- backend/demo/alice.ts +99 -43
- backend/demo/banners.ts +62 -7
- backend/demo/bob.ts +253 -171
- backend/demo/carol.ts +256 -139
- backend/demo/human-typing.ts +2 -1
- backend/demo/lib/chat-actions.ts +240 -7
- backend/demo/lib/chromium.ts +23 -0
- backend/demo/lib/editor-actions.ts +8 -7
- backend/demo/lib/embed-studio.ts +106 -13
- backend/demo/lib/mouse-cursor.ts +373 -0
- backend/demo/lib/publish-flow.ts +69 -27
- backend/demo/lib/scaffolding.ts +201 -14
- backend/demo/lib/selection.ts +278 -6
- backend/demo/lib/yjs-client.ts +788 -0
- backend/demo/probe-ydoc.ts +72 -0
- backend/demo/showcase.ts +187 -65
- backend/demo/test-yjs-client.ts +112 -0
- backend/package-lock.json +32 -3
- backend/package.json +1 -0
- frontend/src/App.tsx +52 -1
- frontend/src/components/PublishDialog.tsx +165 -33
- frontend/src/editor/extensions/agent-rewrite.ts +70 -26
- frontend/src/styles/_ui.css +253 -0
|
@@ -26,7 +26,6 @@ import {
|
|
| 26 |
bubbleToolbarBold,
|
| 27 |
requestRephraseAction,
|
| 28 |
} from "./lib/chat-actions.js";
|
| 29 |
-
import { clearAgentFocus } from "./lib/selection.js";
|
| 30 |
import { createNeuralNetworkBanner } from "./lib/embed-studio.js";
|
| 31 |
import {
|
| 32 |
claimSlot,
|
|
@@ -34,7 +33,7 @@ import {
|
|
| 34 |
typeInSlot,
|
| 35 |
} from "./lib/positions.js";
|
| 36 |
import { applyHueChange, triggerPublish } from "./lib/publish-flow.js";
|
| 37 |
-
import { writeScaffold } from "./lib/scaffolding.js";
|
| 38 |
|
| 39 |
export async function runAlice(page: Page, speed: number) {
|
| 40 |
console.log("[alice] ready");
|
|
@@ -78,6 +77,7 @@ export async function runAlice(page: Page, speed: number) {
|
|
| 78 |
// showcases a multi-institution collaboration on the byline
|
| 79 |
// (MIT, ETH Zurich, Stanford).
|
| 80 |
await addAuthor(page, "Alice Renoir", speed, {
|
|
|
|
| 81 |
newAffiliation: "Hugging Face",
|
| 82 |
});
|
| 83 |
await pause(200, 360);
|
|
@@ -96,6 +96,10 @@ export async function runAlice(page: Page, speed: number) {
|
|
| 96 |
await writeScaffold(
|
| 97 |
page,
|
| 98 |
[
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
{ heading: "The recipe", slots: 4 },
|
| 100 |
{ heading: "Intuition", slots: 3 },
|
| 101 |
{ heading: "In Python", slots: 3 },
|
|
@@ -130,31 +134,6 @@ export async function runAlice(page: Page, speed: number) {
|
|
| 130 |
|
| 131 |
await pause(180, 260);
|
| 132 |
|
| 133 |
-
// --- UI action: Alice selects a phrase in slot 0 and clicks Bold
|
| 134 |
-
// from the contextual bubble toolbar. This is the "I highlighted
|
| 135 |
-
// text and pressed B" path every end-user recognises - pure
|
| 136 |
-
// editor UX, no agent roundtrip. Happens MID-flow so the bolded
|
| 137 |
-
// text stays visible for the rest of the demo.
|
| 138 |
-
try {
|
| 139 |
-
const bolded = await bubbleToolbarBold(
|
| 140 |
-
page,
|
| 141 |
-
"Imagine each token",
|
| 142 |
-
"which other tokens matter right now",
|
| 143 |
-
);
|
| 144 |
-
if (!bolded) {
|
| 145 |
-
console.warn("[alice] bubble-toolbar bold skipped");
|
| 146 |
-
} else {
|
| 147 |
-
console.log("[alice] bubble-toolbar bold applied");
|
| 148 |
-
}
|
| 149 |
-
} catch (err) {
|
| 150 |
-
console.warn(
|
| 151 |
-
"[alice] bubble-toolbar bold aborted:",
|
| 152 |
-
err instanceof Error ? err.message : err,
|
| 153 |
-
);
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
await pause(140, 220);
|
| 157 |
-
|
| 158 |
// Slot 1: short closing line that ties the intuition to the
|
| 159 |
// mechanism. Intentionally a bit bland so the rephrase agent
|
| 160 |
// action below has something meaningful to tighten up (viewers
|
|
@@ -189,11 +168,27 @@ export async function runAlice(page: Page, speed: number) {
|
|
| 189 |
from: rephraseOriginal,
|
| 190 |
to: rephraseRewrite,
|
| 191 |
speed,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
});
|
| 193 |
-
|
| 194 |
-
// long; leave room so the hue drag doesn't stomp on it.
|
| 195 |
-
await pause(2_200, 2_800);
|
| 196 |
-
await clearAgentFocus(page);
|
| 197 |
} catch (err) {
|
| 198 |
console.warn(
|
| 199 |
"[alice] rephrase action aborted:",
|
|
@@ -220,24 +215,85 @@ export async function runAlice(page: Page, speed: number) {
|
|
| 220 |
|
| 221 |
await pause(220, 340);
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
// Before the hue drag, scroll Alice's viewport back to the top of
|
| 224 |
// the article. The neural-network banner lives at the very top
|
| 225 |
// (hero section) and its embed reacts to the shared `primaryHue`
|
| 226 |
// via postMessage - retinting every stroke live. If we stay at
|
| 227 |
// the bottom of the doc during the slider drag the viewer can't
|
| 228 |
-
// see that, so we pay a
|
| 229 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
try {
|
| 231 |
-
await page.evaluate(
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
} catch (err) {
|
| 242 |
console.warn(
|
| 243 |
"[alice] scroll-to-top aborted:",
|
|
|
|
| 26 |
bubbleToolbarBold,
|
| 27 |
requestRephraseAction,
|
| 28 |
} from "./lib/chat-actions.js";
|
|
|
|
| 29 |
import { createNeuralNetworkBanner } from "./lib/embed-studio.js";
|
| 30 |
import {
|
| 31 |
claimSlot,
|
|
|
|
| 33 |
typeInSlot,
|
| 34 |
} from "./lib/positions.js";
|
| 35 |
import { applyHueChange, triggerPublish } from "./lib/publish-flow.js";
|
| 36 |
+
import { waitForPeersSettled, writeScaffold } from "./lib/scaffolding.js";
|
| 37 |
|
| 38 |
export async function runAlice(page: Page, speed: number) {
|
| 39 |
console.log("[alice] ready");
|
|
|
|
| 77 |
// showcases a multi-institution collaboration on the byline
|
| 78 |
// (MIT, ETH Zurich, Stanford).
|
| 79 |
await addAuthor(page, "Alice Renoir", speed, {
|
| 80 |
+
url: "https://huggingface.co/arenoir",
|
| 81 |
newAffiliation: "Hugging Face",
|
| 82 |
});
|
| 83 |
await pause(200, 360);
|
|
|
|
| 96 |
await writeScaffold(
|
| 97 |
page,
|
| 98 |
[
|
| 99 |
+
// "The recipe" gets 4 slots: Bob fills the middle one (slot 1)
|
| 100 |
+
// with a <blockMath> atom carrying the canonical attention
|
| 101 |
+
// formula, so the section reads as definition -> formula ->
|
| 102 |
+
// softmax explanation -> multi-head teaser.
|
| 103 |
{ heading: "The recipe", slots: 4 },
|
| 104 |
{ heading: "Intuition", slots: 3 },
|
| 105 |
{ heading: "In Python", slots: 3 },
|
|
|
|
| 134 |
|
| 135 |
await pause(180, 260);
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
// Slot 1: short closing line that ties the intuition to the
|
| 138 |
// mechanism. Intentionally a bit bland so the rephrase agent
|
| 139 |
// action below has something meaningful to tighten up (viewers
|
|
|
|
| 168 |
from: rephraseOriginal,
|
| 169 |
to: rephraseRewrite,
|
| 170 |
speed,
|
| 171 |
+
// Atomic swap + lingering agent label so it reads as an AI
|
| 172 |
+
// edit, not as the user typing. App.tsx re-seeds the focus
|
| 173 |
+
// on the replacement range and clears it after 1400ms.
|
| 174 |
+
animation: "none",
|
| 175 |
+
lingerAgentFocusMs: 1_400,
|
| 176 |
+
});
|
| 177 |
+
// Wait for the swap (520ms App-side delay before dispatch) and
|
| 178 |
+
// the lingering agent-focus label (1400ms) to finish before
|
| 179 |
+
// moving on, so the hue drag doesn't stomp on it.
|
| 180 |
+
await pause(2_000, 2_200);
|
| 181 |
+
|
| 182 |
+
// Close the chat panel now that Alice is done talking to the
|
| 183 |
+
// agent. Keeping it open through the hue drag / publish would
|
| 184 |
+
// look like Alice forgot to dismiss her conversation, and
|
| 185 |
+
// covers part of the editor body during the rest of the demo.
|
| 186 |
+
await page.evaluate(() => {
|
| 187 |
+
window.dispatchEvent(
|
| 188 |
+
new CustomEvent("__demo-chat", { detail: { open: false } }),
|
| 189 |
+
);
|
| 190 |
});
|
| 191 |
+
await pause(240, 360);
|
|
|
|
|
|
|
|
|
|
| 192 |
} catch (err) {
|
| 193 |
console.warn(
|
| 194 |
"[alice] rephrase action aborted:",
|
|
|
|
| 215 |
|
| 216 |
await pause(220, 340);
|
| 217 |
|
| 218 |
+
// --- UI action: bubble-toolbar Bold on an earlier phrase in slot 0.
|
| 219 |
+
// This is deliberately the LAST editor edit Alice does before the
|
| 220 |
+
// hue drag / publish: placing it here means Bob and Carol are
|
| 221 |
+
// both guaranteed done (their final contributions - the block
|
| 222 |
+
// math formula and the `import torch` code block - are already
|
| 223 |
+
// in the doc), so the layout is frozen when we measure and
|
| 224 |
+
// drag over the target text. Doing it mid-flow while peers are
|
| 225 |
+
// still inserting content would shift the Intuition paragraph
|
| 226 |
+
// under the measured rects and the selection sweep would pick
|
| 227 |
+
// up the heading above or the math atom below.
|
| 228 |
+
await waitForPeersSettled(page);
|
| 229 |
+
try {
|
| 230 |
+
const bolded = await bubbleToolbarBold(
|
| 231 |
+
page,
|
| 232 |
+
"Imagine each token",
|
| 233 |
+
"which other tokens matter right now",
|
| 234 |
+
);
|
| 235 |
+
if (!bolded) {
|
| 236 |
+
console.warn("[alice] bubble-toolbar bold skipped");
|
| 237 |
+
} else {
|
| 238 |
+
console.log("[alice] bubble-toolbar bold applied");
|
| 239 |
+
}
|
| 240 |
+
} catch (err) {
|
| 241 |
+
console.warn(
|
| 242 |
+
"[alice] bubble-toolbar bold aborted:",
|
| 243 |
+
err instanceof Error ? err.message : err,
|
| 244 |
+
);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
await pause(200, 320);
|
| 248 |
+
|
| 249 |
// Before the hue drag, scroll Alice's viewport back to the top of
|
| 250 |
// the article. The neural-network banner lives at the very top
|
| 251 |
// (hero section) and its embed reacts to the shared `primaryHue`
|
| 252 |
// via postMessage - retinting every stroke live. If we stay at
|
| 253 |
// the bottom of the doc during the slider drag the viewer can't
|
| 254 |
+
// see that, so we pay a scroll beat to plant the camera on the
|
| 255 |
+
// banner before the rainbow sweep lands.
|
| 256 |
+
//
|
| 257 |
+
// We drive the scroll ourselves (rAF + cubic ease-in-out) rather
|
| 258 |
+
// than using the browser's native `behavior: "smooth"`, because
|
| 259 |
+
// that one blasts back to 0 in ~500-700ms and reads as a
|
| 260 |
+
// "teleport" on camera. A hand scroll is ~2s and accelerates
|
| 261 |
+
// into a decelerating finish, which is what this loop emulates:
|
| 262 |
+
// the viewer sees the doc flow upward past the camera, and the
|
| 263 |
+
// hero banner slides down into view with visible inertia.
|
| 264 |
try {
|
| 265 |
+
await page.evaluate(
|
| 266 |
+
(durationMs) =>
|
| 267 |
+
new Promise<void>((resolve) => {
|
| 268 |
+
// The editor is NOT scrolled on the window - it lives
|
| 269 |
+
// inside a `.editor-scroll` container that owns the
|
| 270 |
+
// overflow. Pick it first, fall back to documentElement
|
| 271 |
+
// if the layout shifts underneath us.
|
| 272 |
+
const container =
|
| 273 |
+
(document.querySelector(".editor-scroll") as HTMLElement | null) ??
|
| 274 |
+
(document.scrollingElement as HTMLElement | null) ??
|
| 275 |
+
document.documentElement;
|
| 276 |
+
const from = container.scrollTop;
|
| 277 |
+
if (from <= 2) {
|
| 278 |
+
resolve();
|
| 279 |
+
return;
|
| 280 |
+
}
|
| 281 |
+
const start = performance.now();
|
| 282 |
+
const easeInOut = (t: number) =>
|
| 283 |
+
t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
| 284 |
+
const step = (now: number) => {
|
| 285 |
+
const t = Math.min(1, (now - start) / durationMs);
|
| 286 |
+
container.scrollTop = from * (1 - easeInOut(t));
|
| 287 |
+
if (t < 1) requestAnimationFrame(step);
|
| 288 |
+
else {
|
| 289 |
+
container.scrollTop = 0;
|
| 290 |
+
resolve();
|
| 291 |
+
}
|
| 292 |
+
};
|
| 293 |
+
requestAnimationFrame(step);
|
| 294 |
+
}),
|
| 295 |
+
2_100,
|
| 296 |
+
);
|
| 297 |
} catch (err) {
|
| 298 |
console.warn(
|
| 299 |
"[alice] scroll-to-top aborted:",
|
|
@@ -31,8 +31,17 @@ export const NEURAL_NETWORK_BANNER_HTML = `<div class="d3-banner-nn" aria-label=
|
|
| 31 |
display: block;
|
| 32 |
}
|
| 33 |
.d3-banner-nn .nn-link {
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
fill: none;
|
| 37 |
}
|
| 38 |
.d3-banner-nn .nn-node {
|
|
@@ -42,7 +51,8 @@ export const NEURAL_NETWORK_BANNER_HTML = `<div class="d3-banner-nn" aria-label=
|
|
| 42 |
.d3-banner-nn .nn-node-ring {
|
| 43 |
fill: none;
|
| 44 |
stroke: color-mix(in oklab, var(--primary-color, #4f46e5) 60%, transparent);
|
| 45 |
-
stroke-width:
|
|
|
|
| 46 |
}
|
| 47 |
.d3-banner-nn .nn-pulse {
|
| 48 |
fill: var(--primary-color, #4f46e5);
|
|
@@ -95,14 +105,30 @@ export const NEURAL_NETWORK_BANNER_HTML = `<div class="d3-banner-nn" aria-label=
|
|
| 95 |
a.forEach((na) => b.forEach((nb) => links.push([na, nb])));
|
| 96 |
}
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
const gLinks = document.createElementNS(NS, 'g');
|
| 99 |
links.forEach(([a, b]) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
const line = document.createElementNS(NS, 'line');
|
| 101 |
line.setAttribute('class', 'nn-link');
|
| 102 |
-
line.setAttribute('x1', String(a.x));
|
| 103 |
-
line.setAttribute('y1', String(a.y));
|
| 104 |
-
line.setAttribute('x2', String(b.x));
|
| 105 |
-
line.setAttribute('y2', String(b.y));
|
| 106 |
gLinks.appendChild(line);
|
| 107 |
});
|
| 108 |
svg.appendChild(gLinks);
|
|
@@ -203,6 +229,35 @@ export const MODEL_ACCURACY_CSV_META = {
|
|
| 203 |
rowCount: 8,
|
| 204 |
};
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
/**
|
| 207 |
* Inline line chart rendered once Carol asks the agent for a
|
| 208 |
* visualization. Self-contained (no ColorPalettes lookup to keep the
|
|
|
|
| 31 |
display: block;
|
| 32 |
}
|
| 33 |
.d3-banner-nn .nn-link {
|
| 34 |
+
/* Hardcoded translucent white, deliberately decoupled from the
|
| 35 |
+
host theme. The hero surface that sits behind this banner is
|
| 36 |
+
always tinted (primary-colored / dark), regardless of whether
|
| 37 |
+
the editor body itself is in light or dark mode, so a fixed
|
| 38 |
+
light stroke is the most reliable readability target. Binding
|
| 39 |
+
the stroke to --text-color flipped between near-black and
|
| 40 |
+
near-white per theme and vanished whenever the iframe theme
|
| 41 |
+
did not match the host hero (e.g. light editor + dark hero). */
|
| 42 |
+
stroke: rgba(255, 255, 255, 0.4);
|
| 43 |
+
stroke-width: 1;
|
| 44 |
+
vector-effect: non-scaling-stroke;
|
| 45 |
fill: none;
|
| 46 |
}
|
| 47 |
.d3-banner-nn .nn-node {
|
|
|
|
| 51 |
.d3-banner-nn .nn-node-ring {
|
| 52 |
fill: none;
|
| 53 |
stroke: color-mix(in oklab, var(--primary-color, #4f46e5) 60%, transparent);
|
| 54 |
+
stroke-width: 1.2;
|
| 55 |
+
vector-effect: non-scaling-stroke;
|
| 56 |
}
|
| 57 |
.d3-banner-nn .nn-pulse {
|
| 58 |
fill: var(--primary-color, #4f46e5);
|
|
|
|
| 105 |
a.forEach((na) => b.forEach((nb) => links.push([na, nb])));
|
| 106 |
}
|
| 107 |
|
| 108 |
+
// Trim each link so its endpoints sit just OUTSIDE the node ring
|
| 109 |
+
// (radius 7 in viewBox units, plus a small margin). This guarantees
|
| 110 |
+
// the lines never poke through the inside of the ring, which used
|
| 111 |
+
// to make them read as if they sat on top of the nodes even though
|
| 112 |
+
// gLinks is appended before gNodes in DOM order. With trimming the
|
| 113 |
+
// visual layering is unambiguous: the ring contains a clean gap on
|
| 114 |
+
// every spoke. We do this analytically rather than relying on a
|
| 115 |
+
// background fill, which would fight with the transparent banner.
|
| 116 |
+
const NODE_R = 7;
|
| 117 |
+
const LINK_GAP = 1.5;
|
| 118 |
+
const trim = NODE_R + LINK_GAP;
|
| 119 |
const gLinks = document.createElementNS(NS, 'g');
|
| 120 |
links.forEach(([a, b]) => {
|
| 121 |
+
const dx = b.x - a.x;
|
| 122 |
+
const dy = b.y - a.y;
|
| 123 |
+
const dist = Math.hypot(dx, dy) || 1;
|
| 124 |
+
const nx = dx / dist;
|
| 125 |
+
const ny = dy / dist;
|
| 126 |
const line = document.createElementNS(NS, 'line');
|
| 127 |
line.setAttribute('class', 'nn-link');
|
| 128 |
+
line.setAttribute('x1', String(a.x + nx * trim));
|
| 129 |
+
line.setAttribute('y1', String(a.y + ny * trim));
|
| 130 |
+
line.setAttribute('x2', String(b.x - nx * trim));
|
| 131 |
+
line.setAttribute('y2', String(b.y - ny * trim));
|
| 132 |
gLinks.appendChild(line);
|
| 133 |
});
|
| 134 |
svg.appendChild(gLinks);
|
|
|
|
| 229 |
rowCount: 8,
|
| 230 |
};
|
| 231 |
|
| 232 |
+
// ---------------------------------------------------------------------------
|
| 233 |
+
// Companion architecture descriptor for the neural-network banner. Seeded
|
| 234 |
+
// alongside the CSV so the agent has something concrete to "discover" and
|
| 235 |
+
// turn into the visualization. The layer counts here are the SAME ones
|
| 236 |
+
// hardcoded in NEURAL_NETWORK_BANNER_HTML above (6 → 10 → 14 → 10 → 6),
|
| 237 |
+
// which keeps the demo deterministic: the agent reads the JSON, narrates
|
| 238 |
+
// the topology, then drops the matching banner.
|
| 239 |
+
// ---------------------------------------------------------------------------
|
| 240 |
+
|
| 241 |
+
export const ARCHITECTURE_JSON = `{
|
| 242 |
+
"name": "transformer-block",
|
| 243 |
+
"layers": [
|
| 244 |
+
{ "name": "tokens", "kind": "input", "units": 6 },
|
| 245 |
+
{ "name": "embedding", "kind": "projection","units": 10 },
|
| 246 |
+
{ "name": "attention", "kind": "self-attn", "units": 14 },
|
| 247 |
+
{ "name": "values", "kind": "projection","units": 10 },
|
| 248 |
+
{ "name": "logits", "kind": "output", "units": 6 }
|
| 249 |
+
],
|
| 250 |
+
"connectivity": "fully-connected",
|
| 251 |
+
"activation": "softmax"
|
| 252 |
+
}`;
|
| 253 |
+
|
| 254 |
+
export const ARCHITECTURE_JSON_META = {
|
| 255 |
+
name: "architecture.json",
|
| 256 |
+
ext: "json",
|
| 257 |
+
columns: ["name", "kind", "units"],
|
| 258 |
+
rowCount: 5,
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
/**
|
| 262 |
* Inline line chart rendered once Carol asks the agent for a
|
| 263 |
* visualization. Self-contained (no ColorPalettes lookup to keep the
|
|
@@ -1,199 +1,281 @@
|
|
| 1 |
/**
|
| 2 |
-
* Bob -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
*
|
| 4 |
* Bob owns the "The recipe" section (top of doc). His flow:
|
| 5 |
-
* 1. Wait for Alice's first author to land, then
|
| 6 |
-
* university
|
| 7 |
-
* 2. Wait for the "The recipe"
|
| 8 |
-
*
|
| 9 |
-
*
|
| 10 |
-
*
|
| 11 |
-
*
|
| 12 |
-
*
|
| 13 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
*/
|
| 15 |
-
import
|
| 16 |
-
import { pause } from "./human-typing.js";
|
| 17 |
-
import { addAuthor } from "./lib/editor-actions.js";
|
| 18 |
-
import { requestCitationAction } from "./lib/chat-actions.js";
|
| 19 |
import {
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
console.warn("[bob] Alice's first author never appeared, proceeding");
|
| 47 |
}
|
| 48 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
});
|
| 55 |
-
console.log("[bob] authors added");
|
| 56 |
-
|
| 57 |
-
// 2) Wait for
|
| 58 |
-
//
|
| 59 |
-
//
|
| 60 |
-
//
|
| 61 |
-
//
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
| 68 |
return;
|
| 69 |
}
|
| 70 |
-
await
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
//
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
return;
|
| 81 |
}
|
| 82 |
-
await
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
| 86 |
"Attention lets a token look at every other token, scoring each pair through queries ",
|
| 87 |
-
|
|
|
|
|
|
|
| 88 |
);
|
| 89 |
-
await
|
| 90 |
-
await
|
| 91 |
-
await
|
| 92 |
-
await
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
// tool call: the entry is written to the shared `citationsMap`,
|
| 102 |
-
// a citation node lands at the caret (currently at the end of
|
| 103 |
-
// slot 0), and the Bibliography section materializes at the
|
| 104 |
-
// bottom of the doc the first time. Shows off the real academic-
|
| 105 |
-
// writing workflow on camera (chip renders as `(Vaswani et al.,
|
| 106 |
-
// 2017)` in APA).
|
| 107 |
-
try {
|
| 108 |
-
await pause(220, 340);
|
| 109 |
-
await requestCitationAction(page, {
|
| 110 |
-
prompt: "Cite the original Vaswani 2017 paper here.",
|
| 111 |
-
reply: "Added [Vaswani et al., 2017] and the bibliography section.",
|
| 112 |
-
paragraphSnippet: "Attention lets a token",
|
| 113 |
-
anchor: "end",
|
| 114 |
-
key: "vaswani2017attention",
|
| 115 |
-
entry: {
|
| 116 |
-
id: "vaswani2017attention",
|
| 117 |
-
"citation-key": "vaswani2017attention",
|
| 118 |
-
type: "article-journal",
|
| 119 |
-
title: "Attention Is All You Need",
|
| 120 |
-
author: [
|
| 121 |
-
{ family: "Vaswani", given: "Ashish" },
|
| 122 |
-
{ family: "Shazeer", given: "Noam" },
|
| 123 |
-
{ family: "Parmar", given: "Niki" },
|
| 124 |
-
{ family: "Uszkoreit", given: "Jakob" },
|
| 125 |
-
{ family: "Jones", given: "Llion" },
|
| 126 |
-
{ family: "Gomez", given: "Aidan N." },
|
| 127 |
-
{ family: "Kaiser", given: "Lukasz" },
|
| 128 |
-
{ family: "Polosukhin", given: "Illia" },
|
| 129 |
-
],
|
| 130 |
-
issued: { "date-parts": [[2017]] },
|
| 131 |
-
"container-title": "Advances in Neural Information Processing Systems",
|
| 132 |
-
volume: "30",
|
| 133 |
-
URL: "https://arxiv.org/abs/1706.03762",
|
| 134 |
-
DOI: "10.48550/arXiv.1706.03762",
|
| 135 |
-
},
|
| 136 |
-
speed,
|
| 137 |
-
});
|
| 138 |
-
await pause(260, 420);
|
| 139 |
-
} catch (err) {
|
| 140 |
-
console.warn(
|
| 141 |
-
"[bob] citation action aborted:",
|
| 142 |
-
err instanceof Error ? err.message : err,
|
| 143 |
-
);
|
| 144 |
-
}
|
| 145 |
|
| 146 |
-
await
|
| 147 |
-
|
| 148 |
-
//
|
| 149 |
-
//
|
| 150 |
-
//
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
}
|
|
|
|
| 161 |
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
"The softmax turns those scores into weights, and the weighted sum over values produces one contextual vector per token.",
|
| 173 |
-
{ speed: speed * 2.2, typoRate: 0, thinkRate: 0.01 },
|
| 174 |
);
|
|
|
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
-
await
|
| 178 |
-
|
| 179 |
-
// Slot 3
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
const recipeSlot3 = await claimSlot(page, "The recipe", 3);
|
| 186 |
-
if (recipeSlot3) {
|
| 187 |
-
await pause(60, 120);
|
| 188 |
-
await typeInSlot(
|
| 189 |
-
page,
|
| 190 |
-
recipeSlot3,
|
| 191 |
"In practice the same operation runs in parallel through several independent projections, one per head, and the outputs are concatenated - that is multi-head attention.",
|
| 192 |
-
{
|
|
|
|
|
|
|
| 193 |
);
|
|
|
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
-
await
|
| 197 |
-
|
|
|
|
|
|
|
| 198 |
console.log("[bob] scenario complete");
|
| 199 |
}
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Bob - Yjs-only collaborator for the showcase demo.
|
| 3 |
+
*
|
| 4 |
+
* Bob is NOT a browser. He's a Node process that speaks the same
|
| 5 |
+
* Yjs CRDT protocol as the editor tabs and manipulates the shared
|
| 6 |
+
* document directly via Y.XmlElement / Y.XmlText. Every write shows
|
| 7 |
+
* up in Alice's Tiptap editor in real time without any DOM typing,
|
| 8 |
+
* input-rule surprises, or PM position races.
|
| 9 |
*
|
| 10 |
* Bob owns the "The recipe" section (top of doc). His flow:
|
| 11 |
+
* 1. Wait for Alice's first author to land, then push two
|
| 12 |
+
* university co-authors (MIT, ETH Zurich).
|
| 13 |
+
* 2. Wait for the "The recipe" H2 to appear in the shared
|
| 14 |
+
* fragment (Alice writes the scaffold from her browser).
|
| 15 |
+
* 3. Fill slot 0 with the definition paragraph + inline Q/K/V
|
| 16 |
+
* math atoms.
|
| 17 |
+
* 4. Register the Vaswani 2017 paper in the shared citations
|
| 18 |
+
* map, append a citation chip at the end of slot 0, and
|
| 19 |
+
* spawn a Bibliography block at the bottom of the doc if
|
| 20 |
+
* none exists.
|
| 21 |
+
* 5. Drop the canonical attention formula as a `<blockMath>`
|
| 22 |
+
* atom into slot 1. That's a different agent (not Alice)
|
| 23 |
+
* contributing a display-math equation to the article.
|
| 24 |
+
* 6. Fill slot 2 with the softmax explanation.
|
| 25 |
+
* 7. Fill slot 3 with the multi-head teaser.
|
| 26 |
*/
|
| 27 |
+
import * as Y from "yjs";
|
|
|
|
|
|
|
|
|
|
| 28 |
import {
|
| 29 |
+
addAuthorRecord,
|
| 30 |
+
appendInline,
|
| 31 |
+
broadcastCursorAtEnd,
|
| 32 |
+
claimSlotAfterHeading,
|
| 33 |
+
clearAwarenessCursor,
|
| 34 |
+
findHeadingIndex,
|
| 35 |
+
insertInlineNode,
|
| 36 |
+
registerCitation,
|
| 37 |
+
setAffiliationUrl,
|
| 38 |
+
sleep,
|
| 39 |
+
typeIntoParagraph,
|
| 40 |
+
waitForAffiliationCount,
|
| 41 |
+
waitForAuthorCount,
|
| 42 |
+
waitForCondition,
|
| 43 |
+
writeBlockMathAtSlot,
|
| 44 |
+
type PersonaHandle,
|
| 45 |
+
} from "./lib/yjs-client.js";
|
| 46 |
+
|
| 47 |
+
function jitter(minMs: number, maxMs: number): number {
|
| 48 |
+
return minMs + Math.random() * (maxMs - minMs);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Ensure a `<bibliography />` atom exists at the end of the shared
|
| 53 |
+
* fragment. If one is already there (e.g. from a previous run that
|
| 54 |
+
* wasn't reset) we keep it; otherwise we push a fresh, empty one
|
| 55 |
+
* and let `BibliographyView` on Alice's side fill `renderedHtml`
|
| 56 |
+
* via the formatting endpoint.
|
| 57 |
+
*/
|
| 58 |
+
function ensureBibliographySection(handle: PersonaHandle): void {
|
| 59 |
+
const existing = handle.fragment
|
| 60 |
+
.toArray()
|
| 61 |
+
.find((c) => c instanceof Y.XmlElement && c.nodeName === "bibliography");
|
| 62 |
+
if (existing) return;
|
| 63 |
+
|
| 64 |
+
handle.ydoc.transact(() => {
|
| 65 |
+
const bib = new Y.XmlElement("bibliography");
|
| 66 |
+
handle.fragment.push([bib]);
|
| 67 |
+
bib.setAttribute("renderedHtml", "");
|
| 68 |
+
// Tiptap doesn't require an explicit trailing paragraph after
|
| 69 |
+
// the bibliography, but the original doc shape has one so the
|
| 70 |
+
// cursor always has a landing spot past the biblio in Alice's
|
| 71 |
+
// editor.
|
| 72 |
+
const trailing = new Y.XmlElement("paragraph");
|
| 73 |
+
handle.fragment.push([trailing]);
|
| 74 |
+
});
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export async function runBob(
|
| 78 |
+
bob: PersonaHandle,
|
| 79 |
+
speed: number,
|
| 80 |
+
): Promise<void> {
|
| 81 |
+
console.log("[bob] connected as Yjs peer, waiting for Alice's first author");
|
| 82 |
+
|
| 83 |
+
// Give Alice a beat to start typing the title before we pile on
|
| 84 |
+
// the author list; looks more natural on camera.
|
| 85 |
+
await sleep(jitter(800, 1200));
|
| 86 |
+
|
| 87 |
+
// 1) Authors -----------------------------------------------------
|
| 88 |
+
// Alice seeds the byline with herself + the "Hugging Face"
|
| 89 |
+
// affiliation (index 1) but the modal she drives doesn't expose a
|
| 90 |
+
// URL field for the affiliation. Bob then:
|
| 91 |
+
// - waits for that affiliation to land in the shared Y.Array,
|
| 92 |
+
// - patches its `url` to https://huggingface.co so the byline
|
| 93 |
+
// renders the affiliation as a real link,
|
| 94 |
+
// - appends two more authors that REUSE affiliation index 1
|
| 95 |
+
// instead of creating fresh institutions, each with a personal
|
| 96 |
+
// huggingface.co profile URL.
|
| 97 |
+
// Net effect on the byline: three co-authors (Alice + Bob's two)
|
| 98 |
+
// all under the same Hugging Face affiliation, with both the
|
| 99 |
+
// affiliation chip and each author name linkable.
|
| 100 |
+
const seeded = await waitForAuthorCount(bob, 1, 30_000);
|
| 101 |
+
if (!seeded) {
|
| 102 |
console.warn("[bob] Alice's first author never appeared, proceeding");
|
| 103 |
}
|
| 104 |
+
const affSeeded = await waitForAffiliationCount(bob, 1, 30_000);
|
| 105 |
+
if (!affSeeded) {
|
| 106 |
+
console.warn("[bob] Hugging Face affiliation never appeared, proceeding");
|
| 107 |
+
}
|
| 108 |
+
await sleep(jitter(250, 500));
|
| 109 |
+
|
| 110 |
+
setAffiliationUrl(bob, 1, "https://huggingface.co");
|
| 111 |
+
await sleep(jitter(180, 320));
|
| 112 |
|
| 113 |
+
addAuthorRecord(bob, {
|
| 114 |
+
name: "Bob Mercier",
|
| 115 |
+
url: "https://huggingface.co/bmercier",
|
| 116 |
+
existingAffiliationIndices: [1],
|
| 117 |
+
});
|
| 118 |
+
await sleep(jitter(300, 550));
|
| 119 |
+
addAuthorRecord(bob, {
|
| 120 |
+
name: "Mira Patel",
|
| 121 |
+
url: "https://huggingface.co/mpatel",
|
| 122 |
+
existingAffiliationIndices: [1],
|
| 123 |
});
|
| 124 |
+
console.log("[bob] authors added (both at Hugging Face)");
|
| 125 |
+
|
| 126 |
+
// 2) Wait for scaffold ------------------------------------------
|
| 127 |
+
// Alice's Playwright scaffold emits the H2 headings + empty
|
| 128 |
+
// paragraph slots. We gate on the "The recipe" heading showing
|
| 129 |
+
// up in our synced fragment before we start writing body text
|
| 130 |
+
// so `claimSlotAfterHeading` finds real anchors.
|
| 131 |
+
const hasHeading = await waitForCondition(
|
| 132 |
+
bob.fragment,
|
| 133 |
+
() => findHeadingIndex(bob.fragment, "The recipe") !== -1,
|
| 134 |
+
60_000,
|
| 135 |
+
);
|
| 136 |
+
if (!hasHeading) {
|
| 137 |
+
console.warn("[bob] 'The recipe' heading never arrived, skipping body");
|
| 138 |
return;
|
| 139 |
}
|
| 140 |
+
await sleep(jitter(150, 300));
|
| 141 |
|
| 142 |
+
// 3) Slot 0 - definition + inline math --------------------------
|
| 143 |
+
// We type the prose into the paragraph via `typeIntoParagraph`
|
| 144 |
+
// (chunked Y.XmlText inserts with human cadence so Alice sees
|
| 145 |
+
// text stream in), then splice in Y.XmlElement("inlineMath")
|
| 146 |
+
// atoms for Q, K, V. Each insert is a single atomic CRDT op,
|
| 147 |
+
// no input-rule interpretation involved.
|
| 148 |
+
const slot0 = claimSlotAfterHeading(bob.fragment, "The recipe", 0);
|
| 149 |
+
if (!slot0) {
|
| 150 |
+
console.warn("[bob] could not locate slot 0 of 'The recipe'");
|
| 151 |
return;
|
| 152 |
}
|
| 153 |
+
await sleep(jitter(80, 160));
|
| 154 |
+
|
| 155 |
+
const bodyCps = 38 / Math.max(0.5, speed / 2);
|
| 156 |
+
const fastCps = 48 / Math.max(0.5, speed / 2);
|
| 157 |
+
await typeIntoParagraph(
|
| 158 |
+
slot0,
|
| 159 |
"Attention lets a token look at every other token, scoring each pair through queries ",
|
| 160 |
+
{ cps: bodyCps, chunkSize: 2 },
|
| 161 |
+
undefined,
|
| 162 |
+
bob,
|
| 163 |
);
|
| 164 |
+
await insertInlineNode(slot0, { type: "inlineMath", latex: "Q" }, 200, bob);
|
| 165 |
+
await typeIntoParagraph(slot0, ", keys ", { cps: bodyCps }, undefined, bob);
|
| 166 |
+
await insertInlineNode(slot0, { type: "inlineMath", latex: "K" }, 160, bob);
|
| 167 |
+
await typeIntoParagraph(
|
| 168 |
+
slot0,
|
| 169 |
+
" and values ",
|
| 170 |
+
{ cps: bodyCps },
|
| 171 |
+
undefined,
|
| 172 |
+
bob,
|
| 173 |
+
);
|
| 174 |
+
await insertInlineNode(slot0, { type: "inlineMath", latex: "V" }, 160, bob);
|
| 175 |
+
await typeIntoParagraph(slot0, ".", { cps: bodyCps }, undefined, bob);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
+
await sleep(jitter(240, 400));
|
| 178 |
+
|
| 179 |
+
// 4) Citation + bibliography -----------------------------------
|
| 180 |
+
// Register the entry in the shared Y.Map("citations"). The
|
| 181 |
+
// frontend's citation NodeView listens on that map, so any chip
|
| 182 |
+
// with matching `key` auto-resolves its label from the entry.
|
| 183 |
+
//
|
| 184 |
+
// We still write the `label` attr ourselves so the chip renders
|
| 185 |
+
// "(Vaswani et al., 2017)" as soon as it lands even if the
|
| 186 |
+
// NodeView observer hasn't fired yet (APA-ish placeholder).
|
| 187 |
+
const citationKey = "vaswani2017attention";
|
| 188 |
+
registerCitation(bob, citationKey, {
|
| 189 |
+
id: citationKey,
|
| 190 |
+
"citation-key": citationKey,
|
| 191 |
+
type: "article-journal",
|
| 192 |
+
title: "Attention Is All You Need",
|
| 193 |
+
author: [
|
| 194 |
+
{ family: "Vaswani", given: "Ashish" },
|
| 195 |
+
{ family: "Shazeer", given: "Noam" },
|
| 196 |
+
{ family: "Parmar", given: "Niki" },
|
| 197 |
+
{ family: "Uszkoreit", given: "Jakob" },
|
| 198 |
+
{ family: "Jones", given: "Llion" },
|
| 199 |
+
{ family: "Gomez", given: "Aidan N." },
|
| 200 |
+
{ family: "Kaiser", given: "Lukasz" },
|
| 201 |
+
{ family: "Polosukhin", given: "Illia" },
|
| 202 |
+
],
|
| 203 |
+
issued: { "date-parts": [[2017]] },
|
| 204 |
+
"container-title": "Advances in Neural Information Processing Systems",
|
| 205 |
+
volume: "30",
|
| 206 |
+
URL: "https://arxiv.org/abs/1706.03762",
|
| 207 |
+
DOI: "10.48550/arXiv.1706.03762",
|
| 208 |
+
});
|
| 209 |
+
appendInline(slot0, [
|
| 210 |
+
{
|
| 211 |
+
type: "citation",
|
| 212 |
+
key: citationKey,
|
| 213 |
+
label: "(Vaswani et al., 2017)",
|
| 214 |
+
},
|
| 215 |
+
]);
|
| 216 |
+
broadcastCursorAtEnd(bob, slot0);
|
| 217 |
+
ensureBibliographySection(bob);
|
| 218 |
+
console.log("[bob] citation + bibliography inserted");
|
| 219 |
+
|
| 220 |
+
await sleep(jitter(320, 520));
|
| 221 |
+
|
| 222 |
+
// 5) Slot 1 - block math (the attention formula) --------------
|
| 223 |
+
// Atomic CRDT replace: the empty scaffold paragraph is swapped
|
| 224 |
+
// for a `<blockMath>` atom carrying the canonical scaled-dot-
|
| 225 |
+
// product-attention formula. Alice sees the KaTeX render pop
|
| 226 |
+
// in the moment the sync hits her editor - crisp "another
|
| 227 |
+
// agent dropped an equation" moment on camera. Short beat
|
| 228 |
+
// before and after so the viewer registers the transition
|
| 229 |
+
// from prose to display math.
|
| 230 |
+
await sleep(jitter(220, 380));
|
| 231 |
+
const mathNode = writeBlockMathAtSlot(
|
| 232 |
+
bob.fragment,
|
| 233 |
+
"The recipe",
|
| 234 |
+
1,
|
| 235 |
+
"\\text{Attention}(Q, K, V) = \\text{softmax}\\!\\left(\\frac{QK^\\top}{\\sqrt{d_k}}\\right) V",
|
| 236 |
+
);
|
| 237 |
+
if (mathNode) {
|
| 238 |
+
console.log("[bob] block math equation inserted");
|
| 239 |
+
} else {
|
| 240 |
+
console.warn("[bob] could not insert block math");
|
| 241 |
}
|
| 242 |
+
await sleep(jitter(320, 500));
|
| 243 |
|
| 244 |
+
// 6) Slot 2 - softmax explanation ------------------------------
|
| 245 |
+
const slot2 = claimSlotAfterHeading(bob.fragment, "The recipe", 2);
|
| 246 |
+
if (slot2) {
|
| 247 |
+
await sleep(jitter(80, 150));
|
| 248 |
+
await typeIntoParagraph(
|
| 249 |
+
slot2,
|
| 250 |
+
"The softmax turns those similarity scores into weights, and the weighted sum over values produces one contextual vector per token.",
|
| 251 |
+
{ cps: fastCps, chunkSize: 2 },
|
| 252 |
+
undefined,
|
| 253 |
+
bob,
|
|
|
|
|
|
|
| 254 |
);
|
| 255 |
+
} else {
|
| 256 |
+
console.warn("[bob] could not locate slot 2");
|
| 257 |
}
|
| 258 |
|
| 259 |
+
await sleep(jitter(200, 320));
|
| 260 |
+
|
| 261 |
+
// 7) Slot 3 - multi-head teaser --------------------------------
|
| 262 |
+
const slot3 = claimSlotAfterHeading(bob.fragment, "The recipe", 3);
|
| 263 |
+
if (slot3) {
|
| 264 |
+
await sleep(jitter(80, 150));
|
| 265 |
+
await typeIntoParagraph(
|
| 266 |
+
slot3,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
"In practice the same operation runs in parallel through several independent projections, one per head, and the outputs are concatenated - that is multi-head attention.",
|
| 268 |
+
{ cps: fastCps, chunkSize: 2 },
|
| 269 |
+
undefined,
|
| 270 |
+
bob,
|
| 271 |
);
|
| 272 |
+
} else {
|
| 273 |
+
console.warn("[bob] could not locate slot 3");
|
| 274 |
}
|
| 275 |
|
| 276 |
+
await sleep(jitter(200, 320));
|
| 277 |
+
// Clear Bob's caret so it doesn't linger past the last paragraph
|
| 278 |
+
// he wrote; Alice gets a clean frame for the hue drag / publish.
|
| 279 |
+
clearAwarenessCursor(bob);
|
| 280 |
console.log("[bob] scenario complete");
|
| 281 |
}
|
|
@@ -1,145 +1,252 @@
|
|
| 1 |
/**
|
| 2 |
-
* Carol -
|
| 3 |
*
|
| 4 |
-
* Carol
|
| 5 |
-
*
|
| 6 |
-
*
|
| 7 |
-
*
|
| 8 |
-
*
|
| 9 |
-
*
|
| 10 |
-
*
|
| 11 |
-
*
|
| 12 |
-
*
|
| 13 |
-
*
|
| 14 |
-
* ``
|
| 15 |
-
*
|
|
|
|
|
|
|
| 16 |
*/
|
| 17 |
-
import
|
| 18 |
-
import { pause } from "./human-typing.js";
|
| 19 |
-
import { addAuthor, insertCodeBlock } from "./lib/editor-actions.js";
|
| 20 |
-
import { requestLinkAction } from "./lib/chat-actions.js";
|
| 21 |
-
import {
|
| 22 |
-
clearAgentFocus,
|
| 23 |
-
selectSubstringInParagraph,
|
| 24 |
-
} from "./lib/selection.js";
|
| 25 |
-
import {
|
| 26 |
-
claimSlot,
|
| 27 |
-
installPositionTracker,
|
| 28 |
-
typeInSlot,
|
| 29 |
-
} from "./lib/positions.js";
|
| 30 |
import {
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
console.warn("[carol] Alice's first author never appeared, proceeding");
|
| 54 |
}
|
| 55 |
-
await
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
|
|
|
| 59 |
});
|
| 60 |
console.log("[carol] author added");
|
| 61 |
|
| 62 |
-
// Wait for
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
await waitForHeading2(page, "In Python", 60_000);
|
| 71 |
-
} catch {
|
| 72 |
-
console.warn("[carol] 'In Python' heading not found, skipping section");
|
| 73 |
return;
|
| 74 |
}
|
| 75 |
-
await
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
// Slot 0
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
console.warn("[carol] could not focus slot 0 of 'In Python'");
|
| 82 |
return;
|
| 83 |
}
|
| 84 |
-
await
|
| 85 |
-
await
|
| 86 |
-
|
| 87 |
-
pythonSlot0,
|
| 88 |
"The whole recipe fits in eight lines of PyTorch:",
|
| 89 |
-
{
|
|
|
|
|
|
|
| 90 |
);
|
|
|
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
const linkRange = await selectSubstringInParagraph(
|
| 103 |
-
page,
|
| 104 |
-
"The whole recipe",
|
| 105 |
-
"PyTorch",
|
| 106 |
-
);
|
| 107 |
-
if (linkRange) {
|
| 108 |
-
await pause(220, 360);
|
| 109 |
-
await requestLinkAction(page, {
|
| 110 |
-
prompt: "Link to the PyTorch attention docs.",
|
| 111 |
-
reply:
|
| 112 |
-
"Pointed at torch.nn.functional.scaled_dot_product_attention.",
|
| 113 |
-
url:
|
| 114 |
-
"https://pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention.html",
|
| 115 |
-
speed,
|
| 116 |
-
paragraphSnippet: "The whole recipe",
|
| 117 |
-
substring: "PyTorch",
|
| 118 |
-
});
|
| 119 |
-
await pause(260, 420);
|
| 120 |
-
await clearAgentFocus(page);
|
| 121 |
-
}
|
| 122 |
-
} catch (err) {
|
| 123 |
-
console.warn(
|
| 124 |
-
"[carol] link action aborted:",
|
| 125 |
-
err instanceof Error ? err.message : err,
|
| 126 |
-
);
|
| 127 |
}
|
| 128 |
|
| 129 |
-
await
|
| 130 |
-
|
| 131 |
-
// Slot 1: the code block. We DON'T anchor here - the ```python
|
| 132 |
-
// input rule transforms the <p> into a <pre><code> node, and any
|
| 133 |
-
// MappablePosition created against the old empty paragraph stops
|
| 134 |
-
// being meaningful. We just focus the slot and let PM manage
|
| 135 |
-
// selection internally while Carol fills the code.
|
| 136 |
-
const codeFocused = await focusNthParagraphInSection(page, "In Python", 1);
|
| 137 |
-
if (!codeFocused) {
|
| 138 |
-
console.warn("[carol] could not focus slot 1 of 'In Python'");
|
| 139 |
-
return;
|
| 140 |
-
}
|
| 141 |
-
await pause(80, 160);
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
const code =
|
| 144 |
"import torch\n" +
|
| 145 |
"import torch.nn.functional as F\n" +
|
|
@@ -149,26 +256,36 @@ export async function runCarol(page: Page, speed: number) {
|
|
| 149 |
" scores = Q @ K.transpose(-2, -1) / d_k ** 0.5\n" +
|
| 150 |
" weights = F.softmax(scores, dim=-1)\n" +
|
| 151 |
" return weights @ V";
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
| 162 |
}
|
| 163 |
-
await
|
| 164 |
-
await typeInSlot(
|
| 165 |
-
page,
|
| 166 |
-
pythonSlot2,
|
| 167 |
-
"Every modern LLM you have used runs some flavor of this snippet - fused, batched and tiled on the GPU, but conceptually the same eight lines.",
|
| 168 |
-
{ speed: speed * 2.5, typoRate: 0, thinkRate: 0.01 },
|
| 169 |
-
);
|
| 170 |
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
|
|
|
|
|
|
| 173 |
console.log("[carol] scenario complete");
|
| 174 |
}
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Carol - Yjs-only collaborator for the showcase demo.
|
| 3 |
*
|
| 4 |
+
* Carol is a Node-side Yjs peer (no browser). She owns the "In
|
| 5 |
+
* Python" section at the bottom of the doc.
|
| 6 |
+
*
|
| 7 |
+
* Flow:
|
| 8 |
+
* 1. Wait for Alice's first author to land, then append herself
|
| 9 |
+
* with a Stanford affiliation.
|
| 10 |
+
* 2. Wait for the "In Python" H2 to appear in the shared
|
| 11 |
+
* fragment (Alice writes the scaffold).
|
| 12 |
+
* 3. Fill slot 0 with a narrated intro line and splice a link
|
| 13 |
+
* mark directly onto the word "PyTorch" so it points at the
|
| 14 |
+
* `scaled_dot_product_attention` docs.
|
| 15 |
+
* 4. Replace slot 1 with a `<codeBlock language="python">` node
|
| 16 |
+
* carrying the 8-line reference implementation.
|
| 17 |
+
* 5. Fill slot 2 with the closing paragraph.
|
| 18 |
*/
|
| 19 |
+
import * as Y from "yjs";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
import {
|
| 21 |
+
addAuthorRecord,
|
| 22 |
+
broadcastCursorAtEnd,
|
| 23 |
+
buildEmptyParagraph,
|
| 24 |
+
claimSlotAfterHeading,
|
| 25 |
+
clearAwarenessCursor,
|
| 26 |
+
findHeadingIndex,
|
| 27 |
+
setAwarenessCursor,
|
| 28 |
+
sleep,
|
| 29 |
+
typeIntoParagraph,
|
| 30 |
+
waitForAuthorCount,
|
| 31 |
+
waitForCondition,
|
| 32 |
+
type PersonaHandle,
|
| 33 |
+
} from "./lib/yjs-client.js";
|
| 34 |
+
|
| 35 |
+
function jitter(minMs: number, maxMs: number): number {
|
| 36 |
+
return minMs + Math.random() * (maxMs - minMs);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Apply a `link` mark to the FIRST occurrence of `substring` inside
|
| 41 |
+
* a paragraph's Y.XmlText children. We iterate children in document
|
| 42 |
+
* order and, when we find the substring (possibly straddling two
|
| 43 |
+
* adjacent text runs), re-emit it with the link attributes instead
|
| 44 |
+
* of trying to mutate a mark in place (Y.XmlText's format API is
|
| 45 |
+
* append-only friendly; retrofitting a mark mid-string is what
|
| 46 |
+
* caused the old Playwright link flow to drift the caret).
|
| 47 |
+
*/
|
| 48 |
+
function linkifyInParagraph(
|
| 49 |
+
paragraph: Y.XmlElement,
|
| 50 |
+
substring: string,
|
| 51 |
+
href: string,
|
| 52 |
+
): boolean {
|
| 53 |
+
const children = paragraph.toArray();
|
| 54 |
+
for (let i = 0; i < children.length; i++) {
|
| 55 |
+
const child = children[i];
|
| 56 |
+
if (!(child instanceof Y.XmlText)) continue;
|
| 57 |
+
const text = child.toString();
|
| 58 |
+
const idx = text.indexOf(substring);
|
| 59 |
+
if (idx === -1) continue;
|
| 60 |
+
|
| 61 |
+
// Slice the run into [before, linked, after] and re-emit the
|
| 62 |
+
// middle with link attributes. `delta`-level insertion lets us
|
| 63 |
+
// mint all three in a single Yjs op so the formatter doesn't
|
| 64 |
+
// see intermediate half-linked states.
|
| 65 |
+
const before = text.slice(0, idx);
|
| 66 |
+
const after = text.slice(idx + substring.length);
|
| 67 |
+
paragraph.doc?.transact(() => {
|
| 68 |
+
child.delete(0, text.length);
|
| 69 |
+
if (before) child.insert(0, before);
|
| 70 |
+
child.insert(before.length, substring, {
|
| 71 |
+
link: {
|
| 72 |
+
href,
|
| 73 |
+
target: "_blank",
|
| 74 |
+
rel: "noopener noreferrer nofollow",
|
| 75 |
+
class: "editor-link",
|
| 76 |
+
title: null,
|
| 77 |
+
},
|
| 78 |
+
});
|
| 79 |
+
if (after) child.insert(before.length + substring.length, after);
|
| 80 |
+
});
|
| 81 |
+
return true;
|
| 82 |
+
}
|
| 83 |
+
return false;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* Replace the paragraph at fragment index `slotIndex` within a
|
| 88 |
+
* section with an empty codeBlock node, ready for streaming inserts.
|
| 89 |
+
* Returns the Y.XmlText child so the caller can type into it at
|
| 90 |
+
* whatever cadence they want.
|
| 91 |
+
*
|
| 92 |
+
* Splitting the "create codeBlock shell" from "type code into it"
|
| 93 |
+
* lets the viewer actually SEE the code materialize line by line
|
| 94 |
+
* instead of popping in atomically, which is what made the old
|
| 95 |
+
* `writeCodeBlock` look unnatural on camera.
|
| 96 |
+
*/
|
| 97 |
+
function openCodeBlock(
|
| 98 |
+
frag: Y.XmlFragment,
|
| 99 |
+
headingText: string,
|
| 100 |
+
slotIndex: number,
|
| 101 |
+
language: string,
|
| 102 |
+
): Y.XmlText | null {
|
| 103 |
+
const headingIdx = findHeadingIndex(frag, headingText);
|
| 104 |
+
if (headingIdx === -1) return null;
|
| 105 |
+
|
| 106 |
+
// Walk from the heading forward, collecting slot positions.
|
| 107 |
+
const children = frag.toArray();
|
| 108 |
+
const slots: number[] = [];
|
| 109 |
+
for (let i = headingIdx + 1; i < children.length; i++) {
|
| 110 |
+
const c = children[i];
|
| 111 |
+
if (!(c instanceof Y.XmlElement)) continue;
|
| 112 |
+
if (c.nodeName === "heading") break;
|
| 113 |
+
slots.push(i);
|
| 114 |
+
}
|
| 115 |
+
const targetIdx =
|
| 116 |
+
slots[slotIndex] ??
|
| 117 |
+
(slots.length ? slots[slots.length - 1] + 1 : headingIdx + 1);
|
| 118 |
+
|
| 119 |
+
const block = new Y.XmlElement("codeBlock");
|
| 120 |
+
let txt: Y.XmlText | null = null;
|
| 121 |
+
|
| 122 |
+
frag.doc?.transact(() => {
|
| 123 |
+
if (slots[slotIndex] !== undefined) {
|
| 124 |
+
frag.delete(targetIdx, 1);
|
| 125 |
+
frag.insert(targetIdx, [block]);
|
| 126 |
+
} else {
|
| 127 |
+
const pad = slotIndex - slots.length;
|
| 128 |
+
const filler: Y.XmlElement[] = [];
|
| 129 |
+
for (let k = 0; k < pad; k++) filler.push(buildEmptyParagraph());
|
| 130 |
+
filler.push(block);
|
| 131 |
+
frag.insert(targetIdx, filler);
|
| 132 |
+
}
|
| 133 |
+
block.setAttribute("language", language);
|
| 134 |
+
txt = new Y.XmlText();
|
| 135 |
+
block.push([txt]);
|
| 136 |
+
});
|
| 137 |
+
return txt;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/**
|
| 141 |
+
* Stream `code` into a codeBlock's Y.XmlText at a human pace. Each
|
| 142 |
+
* chunk is a real Yjs insert with an awareness cursor ping (via
|
| 143 |
+
* `setAwarenessCursor`), so Alice sees Carol's caret walk down the
|
| 144 |
+
* source file as it fills in - the same look-and-feel as typing
|
| 145 |
+
* into a paragraph.
|
| 146 |
+
*
|
| 147 |
+
* Characters per second is tuned per language:
|
| 148 |
+
* - prose : 24-38 cps (fingers typing English)
|
| 149 |
+
* - code : 60-90 cps (humans "think in tokens" for code and
|
| 150 |
+
* paste / autocomplete reduces effective keystrokes)
|
| 151 |
+
* so the code fills fast enough to stay on-camera without looking
|
| 152 |
+
* like the block popped in instantly.
|
| 153 |
+
*/
|
| 154 |
+
async function typeIntoCodeBlock(
|
| 155 |
+
handle: PersonaHandle,
|
| 156 |
+
txt: Y.XmlText,
|
| 157 |
+
code: string,
|
| 158 |
+
options: { cps?: number; chunkSize?: number; jitter?: number } = {},
|
| 159 |
+
): Promise<void> {
|
| 160 |
+
const cps = options.cps ?? 80;
|
| 161 |
+
const jitter = options.jitter ?? 0.3;
|
| 162 |
+
const chunkSize = Math.max(1, options.chunkSize ?? 3);
|
| 163 |
+
const baseDelay = 1000 / cps;
|
| 164 |
+
|
| 165 |
+
let i = 0;
|
| 166 |
+
while (i < code.length) {
|
| 167 |
+
const take = Math.min(chunkSize, code.length - i);
|
| 168 |
+
const chunk = code.slice(i, i + take);
|
| 169 |
+
txt.insert(txt.length, chunk);
|
| 170 |
+
setAwarenessCursor(handle, txt, txt.length);
|
| 171 |
+
i += take;
|
| 172 |
+
const jitterMul = 1 + (Math.random() * 2 - 1) * jitter;
|
| 173 |
+
await sleep(Math.max(3, baseDelay * jitterMul));
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
export async function runCarol(
|
| 178 |
+
carol: PersonaHandle,
|
| 179 |
+
speed: number,
|
| 180 |
+
): Promise<void> {
|
| 181 |
+
console.log("[carol] connected as Yjs peer, waiting for Alice's first author");
|
| 182 |
+
|
| 183 |
+
// Slight offset vs Bob's join so the two author pushes don't
|
| 184 |
+
// stack on top of each other in Alice's byline on camera.
|
| 185 |
+
await sleep(jitter(1_200, 1_800));
|
| 186 |
+
|
| 187 |
+
// 1) Authors ---------------------------------------------------
|
| 188 |
+
const seeded = await waitForAuthorCount(carol, 1, 30_000);
|
| 189 |
+
if (!seeded) {
|
| 190 |
console.warn("[carol] Alice's first author never appeared, proceeding");
|
| 191 |
}
|
| 192 |
+
await sleep(jitter(400, 700));
|
| 193 |
|
| 194 |
+
addAuthorRecord(carol, {
|
| 195 |
+
name: "Carol Dubois",
|
| 196 |
+
newAffiliation: { name: "Stanford University" },
|
| 197 |
});
|
| 198 |
console.log("[carol] author added");
|
| 199 |
|
| 200 |
+
// 2) Wait for scaffold ----------------------------------------
|
| 201 |
+
const hasHeading = await waitForCondition(
|
| 202 |
+
carol.fragment,
|
| 203 |
+
() => findHeadingIndex(carol.fragment, "In Python") !== -1,
|
| 204 |
+
60_000,
|
| 205 |
+
);
|
| 206 |
+
if (!hasHeading) {
|
| 207 |
+
console.warn("[carol] 'In Python' heading never arrived, skipping body");
|
|
|
|
|
|
|
|
|
|
| 208 |
return;
|
| 209 |
}
|
| 210 |
+
await sleep(jitter(150, 300));
|
| 211 |
+
|
| 212 |
+
const fastCps = 50 / Math.max(0.5, speed / 2);
|
| 213 |
|
| 214 |
+
// 3) Slot 0 - intro line + link on "PyTorch" ------------------
|
| 215 |
+
const slot0 = claimSlotAfterHeading(carol.fragment, "In Python", 0);
|
| 216 |
+
if (!slot0) {
|
| 217 |
+
console.warn("[carol] could not locate slot 0 of 'In Python'");
|
|
|
|
| 218 |
return;
|
| 219 |
}
|
| 220 |
+
await sleep(jitter(80, 150));
|
| 221 |
+
await typeIntoParagraph(
|
| 222 |
+
slot0,
|
|
|
|
| 223 |
"The whole recipe fits in eight lines of PyTorch:",
|
| 224 |
+
{ cps: fastCps, chunkSize: 2 },
|
| 225 |
+
undefined,
|
| 226 |
+
carol,
|
| 227 |
);
|
| 228 |
+
await sleep(jitter(160, 260));
|
| 229 |
|
| 230 |
+
const linked = linkifyInParagraph(
|
| 231 |
+
slot0,
|
| 232 |
+
"PyTorch",
|
| 233 |
+
"https://pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention.html",
|
| 234 |
+
);
|
| 235 |
+
broadcastCursorAtEnd(carol, slot0);
|
| 236 |
+
if (!linked) {
|
| 237 |
+
console.warn("[carol] could not apply PyTorch link mark");
|
| 238 |
+
} else {
|
| 239 |
+
console.log("[carol] PyTorch link inserted");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
}
|
| 241 |
|
| 242 |
+
await sleep(jitter(180, 300));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
+
// 4) Slot 1 - 8-line code block -------------------------------
|
| 245 |
+
// Open an empty codeBlock first so Alice sees the "frame" land
|
| 246 |
+
// (with the language tag), then stream the code line by line at
|
| 247 |
+
// a human coding cadence. The atomic "whole block pops in" feel
|
| 248 |
+
// is replaced by a visible tap-tap-tap typing animation that
|
| 249 |
+
// matches the other paragraphs.
|
| 250 |
const code =
|
| 251 |
"import torch\n" +
|
| 252 |
"import torch.nn.functional as F\n" +
|
|
|
|
| 256 |
" scores = Q @ K.transpose(-2, -1) / d_k ** 0.5\n" +
|
| 257 |
" weights = F.softmax(scores, dim=-1)\n" +
|
| 258 |
" return weights @ V";
|
| 259 |
+
const codeTxt = openCodeBlock(carol.fragment, "In Python", 1, "python");
|
| 260 |
+
if (!codeTxt) {
|
| 261 |
+
console.warn("[carol] could not open code block");
|
| 262 |
+
} else {
|
| 263 |
+
console.log("[carol] code block opened, streaming source...");
|
| 264 |
+
const codeCps = 70 / Math.max(0.5, speed / 2);
|
| 265 |
+
await typeIntoCodeBlock(carol, codeTxt, code, {
|
| 266 |
+
cps: codeCps,
|
| 267 |
+
chunkSize: 3,
|
| 268 |
+
});
|
| 269 |
+
console.log("[carol] code block complete");
|
| 270 |
}
|
| 271 |
+
await sleep(jitter(300, 480));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
+
// 5) Slot 2 - closing paragraph -------------------------------
|
| 274 |
+
const slot2 = claimSlotAfterHeading(carol.fragment, "In Python", 2);
|
| 275 |
+
if (slot2) {
|
| 276 |
+
await sleep(jitter(80, 160));
|
| 277 |
+
await typeIntoParagraph(
|
| 278 |
+
slot2,
|
| 279 |
+
"Every modern LLM you have used runs some flavor of this snippet - fused, batched and tiled on the GPU, but conceptually the same eight lines.",
|
| 280 |
+
{ cps: fastCps, chunkSize: 2 },
|
| 281 |
+
undefined,
|
| 282 |
+
carol,
|
| 283 |
+
);
|
| 284 |
+
} else {
|
| 285 |
+
console.warn("[carol] could not locate slot 2");
|
| 286 |
+
}
|
| 287 |
|
| 288 |
+
await sleep(jitter(200, 320));
|
| 289 |
+
clearAwarenessCursor(carol);
|
| 290 |
console.log("[carol] scenario complete");
|
| 291 |
}
|
|
@@ -9,6 +9,7 @@
|
|
| 9 |
* person writing", not deterministic test output.
|
| 10 |
*/
|
| 11 |
import type { Page, Locator } from "playwright";
|
|
|
|
| 12 |
|
| 13 |
export interface HumanTypingOptions {
|
| 14 |
/** Baseline delay between keystrokes (ms). Actual delay is jittered. */
|
|
@@ -141,7 +142,7 @@ export async function humanTypeInto(
|
|
| 141 |
options: HumanTypingOptions = {}
|
| 142 |
): Promise<void> {
|
| 143 |
await target.waitFor({ state: "visible" });
|
| 144 |
-
await
|
| 145 |
await sleep(rand(250, 550));
|
| 146 |
await humanType(page, text, options);
|
| 147 |
}
|
|
|
|
| 9 |
* person writing", not deterministic test output.
|
| 10 |
*/
|
| 11 |
import type { Page, Locator } from "playwright";
|
| 12 |
+
import { humanClick } from "./lib/mouse-cursor.js";
|
| 13 |
|
| 14 |
export interface HumanTypingOptions {
|
| 15 |
/** Baseline delay between keystrokes (ms). Actual delay is jittered. */
|
|
|
|
| 142 |
options: HumanTypingOptions = {}
|
| 143 |
): Promise<void> {
|
| 144 |
await target.waitFor({ state: "visible" });
|
| 145 |
+
await humanClick(page, target);
|
| 146 |
await sleep(rand(250, 550));
|
| 147 |
await humanType(page, text, options);
|
| 148 |
}
|
|
@@ -33,8 +33,10 @@
|
|
| 33 |
*/
|
| 34 |
import type { Page } from "playwright";
|
| 35 |
import { humanType, pause } from "../human-typing.js";
|
|
|
|
| 36 |
import {
|
| 37 |
clearAgentFocus,
|
|
|
|
| 38 |
getCurrentPmSelection,
|
| 39 |
selectSubstringInParagraph,
|
| 40 |
} from "./selection.js";
|
|
@@ -69,6 +71,20 @@ export async function streamFakeExchange(
|
|
| 69 |
* silently fails. See `App.tsx` `__demo-chat` handler.
|
| 70 |
*/
|
| 71 |
range?: { from: number; to: number };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
};
|
| 73 |
/**
|
| 74 |
* Optional formatting action: after the assistant reply has fully
|
|
@@ -250,10 +266,11 @@ async function openChatAndTypePrompt(
|
|
| 250 |
page: Page,
|
| 251 |
prompt: string,
|
| 252 |
speed: number,
|
|
|
|
| 253 |
) {
|
| 254 |
const fab = page.getByRole("button", { name: /AI Assistant/i }).first();
|
| 255 |
if (await fab.isVisible().catch(() => false)) {
|
| 256 |
-
await
|
| 257 |
await pause(220, 380);
|
| 258 |
}
|
| 259 |
|
|
@@ -262,7 +279,21 @@ async function openChatAndTypePrompt(
|
|
| 262 |
.first();
|
| 263 |
try {
|
| 264 |
await textarea.waitFor({ state: "visible", timeout: 3_000 });
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
await humanType(page, prompt, {
|
| 267 |
speed: speed * 2.2,
|
| 268 |
typoRate: 0,
|
|
@@ -271,6 +302,16 @@ async function openChatAndTypePrompt(
|
|
| 271 |
await pause(100, 180);
|
| 272 |
} catch {
|
| 273 |
console.warn("[chat] textarea not found, will inject anyway");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
}
|
| 275 |
try {
|
| 276 |
await textarea.fill("");
|
|
@@ -354,9 +395,63 @@ export async function requestRephraseAction(
|
|
| 354 |
to: string;
|
| 355 |
range?: { from: number; to: number };
|
| 356 |
speed: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
},
|
| 358 |
) {
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
|
| 361 |
await streamFakeExchange(page, {
|
| 362 |
eventName: "__demo-chat",
|
|
@@ -367,10 +462,131 @@ export async function requestRephraseAction(
|
|
| 367 |
from: args.from,
|
| 368 |
to: args.to,
|
| 369 |
range: args.range,
|
|
|
|
|
|
|
| 370 |
},
|
| 371 |
});
|
| 372 |
}
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
/**
|
| 375 |
* Twin of `requestFormatAction`, but for link insertion.
|
| 376 |
*
|
|
@@ -537,7 +753,9 @@ export async function bubbleToolbarBold(
|
|
| 537 |
// dance a couple of times before giving up, with an explicit
|
| 538 |
// `editor.commands.focus()` each pass to re-assert ownership of
|
| 539 |
// the caret against remote transactions.
|
| 540 |
-
const seedSelection = async (
|
|
|
|
|
|
|
| 541 |
from: number;
|
| 542 |
to: number;
|
| 543 |
} | null> => {
|
|
@@ -549,6 +767,21 @@ export async function bubbleToolbarBold(
|
|
| 549 |
).__demoEditor;
|
| 550 |
editor?.commands.focus();
|
| 551 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
return await selectSubstringInParagraph(
|
| 553 |
page,
|
| 554 |
paragraphSnippet,
|
|
@@ -563,7 +796,7 @@ export async function bubbleToolbarBold(
|
|
| 563 |
let range: { from: number; to: number } | null = null;
|
| 564 |
let toolbarReady = false;
|
| 565 |
for (let attempt = 0; attempt < 3; attempt++) {
|
| 566 |
-
range = await seedSelection();
|
| 567 |
if (!range) {
|
| 568 |
await pause(180, 280);
|
| 569 |
continue;
|
|
@@ -627,13 +860,13 @@ export async function bubbleToolbarBold(
|
|
| 627 |
|
| 628 |
const [hoverMin, hoverMax] = opts.hoverMs ?? [380, 620];
|
| 629 |
try {
|
| 630 |
-
await
|
| 631 |
} catch {
|
| 632 |
/* non-fatal */
|
| 633 |
}
|
| 634 |
await pause(hoverMin, hoverMax);
|
| 635 |
try {
|
| 636 |
-
await
|
| 637 |
} catch (err) {
|
| 638 |
console.warn(
|
| 639 |
"[bubble-bold] click failed:",
|
|
|
|
| 33 |
*/
|
| 34 |
import type { Page } from "playwright";
|
| 35 |
import { humanType, pause } from "../human-typing.js";
|
| 36 |
+
import { humanClick, humanHover } from "./mouse-cursor.js";
|
| 37 |
import {
|
| 38 |
clearAgentFocus,
|
| 39 |
+
dragSelectSubstringInParagraph,
|
| 40 |
getCurrentPmSelection,
|
| 41 |
selectSubstringInParagraph,
|
| 42 |
} from "./selection.js";
|
|
|
|
| 71 |
* silently fails. See `App.tsx` `__demo-chat` handler.
|
| 72 |
*/
|
| 73 |
range?: { from: number; to: number };
|
| 74 |
+
/**
|
| 75 |
+
* "none" for atomic swap (reads as AI edit), "typewriter" for
|
| 76 |
+
* streamed chars (reads as user typing). Default typewriter
|
| 77 |
+
* to match the original demo's behaviour.
|
| 78 |
+
*/
|
| 79 |
+
animation?: "none" | "typewriter";
|
| 80 |
+
/**
|
| 81 |
+
* Keep the AgentFocus highlight on the replacement range for
|
| 82 |
+
* `lingerAgentFocusMs` after the swap, then clear. Lets the
|
| 83 |
+
* viewer see the "<Name> agent" label sit over the new
|
| 84 |
+
* sentence instead of the decoration collapsing at delete
|
| 85 |
+
* time.
|
| 86 |
+
*/
|
| 87 |
+
lingerAgentFocusMs?: number;
|
| 88 |
};
|
| 89 |
/**
|
| 90 |
* Optional formatting action: after the assistant reply has fully
|
|
|
|
| 266 |
page: Page,
|
| 267 |
prompt: string,
|
| 268 |
speed: number,
|
| 269 |
+
opts: { onPanelOpen?: () => Promise<void> } = {},
|
| 270 |
) {
|
| 271 |
const fab = page.getByRole("button", { name: /AI Assistant/i }).first();
|
| 272 |
if (await fab.isVisible().catch(() => false)) {
|
| 273 |
+
await humanClick(page, fab);
|
| 274 |
await pause(220, 380);
|
| 275 |
}
|
| 276 |
|
|
|
|
| 279 |
.first();
|
| 280 |
try {
|
| 281 |
await textarea.waitFor({ state: "visible", timeout: 3_000 });
|
| 282 |
+
// Panel is now open. Callers (requestRephraseAction) can fire
|
| 283 |
+
// a callback here so side-effects that should read as "triggered
|
| 284 |
+
// by opening the chat" - the AgentFocus highlight in particular
|
| 285 |
+
// - land at the exact frame the panel becomes visible.
|
| 286 |
+
if (opts.onPanelOpen) {
|
| 287 |
+
try {
|
| 288 |
+
await opts.onPanelOpen();
|
| 289 |
+
} catch (err) {
|
| 290 |
+
console.warn(
|
| 291 |
+
"[chat] onPanelOpen hook failed:",
|
| 292 |
+
err instanceof Error ? err.message : err,
|
| 293 |
+
);
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
await humanClick(page, textarea);
|
| 297 |
await humanType(page, prompt, {
|
| 298 |
speed: speed * 2.2,
|
| 299 |
typoRate: 0,
|
|
|
|
| 302 |
await pause(100, 180);
|
| 303 |
} catch {
|
| 304 |
console.warn("[chat] textarea not found, will inject anyway");
|
| 305 |
+
// Fallback: the panel never actually opened visually. Run the
|
| 306 |
+
// hook anyway so the highlight still appears (better than silent
|
| 307 |
+
// skip if tippy/panel layout shifted mid-demo).
|
| 308 |
+
if (opts.onPanelOpen) {
|
| 309 |
+
try {
|
| 310 |
+
await opts.onPanelOpen();
|
| 311 |
+
} catch {
|
| 312 |
+
/* non-fatal */
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
}
|
| 316 |
try {
|
| 317 |
await textarea.fill("");
|
|
|
|
| 395 |
to: string;
|
| 396 |
range?: { from: number; to: number };
|
| 397 |
speed: number;
|
| 398 |
+
/**
|
| 399 |
+
* "none" = atomic swap, the phrase flips in one transaction and
|
| 400 |
+
* the AgentFocus decoration carries over to the new range - the
|
| 401 |
+
* viewer unambiguously reads it as an AI edit.
|
| 402 |
+
*
|
| 403 |
+
* "typewriter" = stream chars one by one. Looks like Alice is
|
| 404 |
+
* typing herself, which defeats the purpose of showing an agent
|
| 405 |
+
* tool call - avoid unless you explicitly want that effect.
|
| 406 |
+
*
|
| 407 |
+
* Default: "none".
|
| 408 |
+
*/
|
| 409 |
+
animation?: "none" | "typewriter";
|
| 410 |
+
/**
|
| 411 |
+
* How long the AgentFocus highlight lingers on the new sentence
|
| 412 |
+
* after the swap before clearing. Default 1400ms - long enough
|
| 413 |
+
* for the viewer to read the new text under the agent badge,
|
| 414 |
+
* short enough to not stall the recording.
|
| 415 |
+
*/
|
| 416 |
+
lingerAgentFocusMs?: number;
|
| 417 |
},
|
| 418 |
) {
|
| 419 |
+
const animation = args.animation ?? "none";
|
| 420 |
+
const lingerAgentFocusMs = args.lingerAgentFocusMs ?? 1400;
|
| 421 |
+
|
| 422 |
+
// Story beat: Alice FIRST selects the target sentence herself
|
| 423 |
+
// (visible drag-glide + PM selection paint), pauses so the viewer
|
| 424 |
+
// registers "this is the phrase I want to work on", THEN opens
|
| 425 |
+
// the chat. Without this visible selection, the agent highlight
|
| 426 |
+
// appears "out of nowhere" when the panel opens, which reads as
|
| 427 |
+
// unexplained magic.
|
| 428 |
+
//
|
| 429 |
+
// Note: we pass setAgentFocus=false here - the user selection
|
| 430 |
+
// should look like a regular user selection (no "<Name> agent"
|
| 431 |
+
// label yet). The label appears shortly after when the chat
|
| 432 |
+
// actually opens and onPanelOpen fires below, visually
|
| 433 |
+
// transforming the user selection into an agent-scoped range.
|
| 434 |
+
await dragSelectSubstringInParagraph(
|
| 435 |
+
page,
|
| 436 |
+
args.from,
|
| 437 |
+
args.from,
|
| 438 |
+
{ setAgentFocus: false },
|
| 439 |
+
);
|
| 440 |
+
await pause(420, 620);
|
| 441 |
+
|
| 442 |
+
// Timing rule: the AgentFocus highlight upgrades the user's
|
| 443 |
+
// selection to an agent-scoped range at the exact frame the chat
|
| 444 |
+
// panel becomes visible. openChatAndTypePrompt fires onPanelOpen
|
| 445 |
+
// between "panel is open" and "prompt starts typing" so the
|
| 446 |
+
// label pops in sync with the chat UI, not 3 seconds before.
|
| 447 |
+
await openChatAndTypePrompt(page, args.prompt, args.speed, {
|
| 448 |
+
onPanelOpen: async () => {
|
| 449 |
+
await seedAgentFocusOnTarget(page, {
|
| 450 |
+
targetText: args.from,
|
| 451 |
+
explicitRange: args.range,
|
| 452 |
+
});
|
| 453 |
+
},
|
| 454 |
+
});
|
| 455 |
|
| 456 |
await streamFakeExchange(page, {
|
| 457 |
eventName: "__demo-chat",
|
|
|
|
| 462 |
from: args.from,
|
| 463 |
to: args.to,
|
| 464 |
range: args.range,
|
| 465 |
+
animation,
|
| 466 |
+
lingerAgentFocusMs,
|
| 467 |
},
|
| 468 |
});
|
| 469 |
}
|
| 470 |
|
| 471 |
+
/**
|
| 472 |
+
* Resolve the PM range for a rephrase target against the LIVE doc
|
| 473 |
+
* and broadcast an AgentFocus highlight on it. Mirrors the lookup
|
| 474 |
+
* the App-side `__demo-chat` handler does at dispatch time
|
| 475 |
+
* (explicit range first, text-search fallback) so the highlight
|
| 476 |
+
* lands on the EXACT same characters the typewriter will rewrite
|
| 477 |
+
* a couple hundred ms later. If neither resolution path matches
|
| 478 |
+
* (e.g. Bob just typed into the target paragraph and rebased the
|
| 479 |
+
* text), the helper silently skips - the typewriter itself will
|
| 480 |
+
* still find the range from its own live lookup.
|
| 481 |
+
*/
|
| 482 |
+
async function seedAgentFocusOnTarget(
|
| 483 |
+
page: Page,
|
| 484 |
+
args: {
|
| 485 |
+
targetText: string;
|
| 486 |
+
explicitRange?: { from: number; to: number };
|
| 487 |
+
},
|
| 488 |
+
): Promise<void> {
|
| 489 |
+
await page.evaluate(
|
| 490 |
+
({ target, explicit }) => {
|
| 491 |
+
const editor = (
|
| 492 |
+
window as unknown as {
|
| 493 |
+
__demoEditor?: {
|
| 494 |
+
state: {
|
| 495 |
+
doc: {
|
| 496 |
+
content: { size: number };
|
| 497 |
+
descendants: (
|
| 498 |
+
cb: (
|
| 499 |
+
node: {
|
| 500 |
+
isTextblock: boolean;
|
| 501 |
+
content: { size: number };
|
| 502 |
+
textBetween: (
|
| 503 |
+
a: number,
|
| 504 |
+
b: number,
|
| 505 |
+
s1: string,
|
| 506 |
+
s2: string,
|
| 507 |
+
) => string;
|
| 508 |
+
},
|
| 509 |
+
pos: number,
|
| 510 |
+
) => boolean | void,
|
| 511 |
+
) => void;
|
| 512 |
+
};
|
| 513 |
+
};
|
| 514 |
+
commands: {
|
| 515 |
+
setAgentFocus: (args: { from: number; to: number }) => boolean;
|
| 516 |
+
};
|
| 517 |
+
view: {
|
| 518 |
+
domAtPos: (pos: number) => { node: Node };
|
| 519 |
+
};
|
| 520 |
+
};
|
| 521 |
+
}
|
| 522 |
+
).__demoEditor;
|
| 523 |
+
if (!editor) return;
|
| 524 |
+
|
| 525 |
+
let from = -1;
|
| 526 |
+
let to = -1;
|
| 527 |
+
|
| 528 |
+
if (explicit) {
|
| 529 |
+
const size = editor.state.doc.content.size;
|
| 530 |
+
const a = Math.max(0, Math.min(explicit.from, size));
|
| 531 |
+
const b = Math.max(0, Math.min(explicit.to, size));
|
| 532 |
+
if (b > a) {
|
| 533 |
+
from = a;
|
| 534 |
+
to = b;
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
if (from === -1) {
|
| 539 |
+
editor.state.doc.descendants((node, pos) => {
|
| 540 |
+
if (from !== -1) return false;
|
| 541 |
+
if (node.isTextblock) {
|
| 542 |
+
const text = node.textBetween(
|
| 543 |
+
0,
|
| 544 |
+
node.content.size,
|
| 545 |
+
"\n",
|
| 546 |
+
"\n",
|
| 547 |
+
);
|
| 548 |
+
const idx = text.indexOf(target);
|
| 549 |
+
if (idx !== -1) {
|
| 550 |
+
from = pos + 1 + idx;
|
| 551 |
+
to = from + target.length;
|
| 552 |
+
return false;
|
| 553 |
+
}
|
| 554 |
+
}
|
| 555 |
+
return true;
|
| 556 |
+
});
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
if (from === -1 || to === -1) return;
|
| 560 |
+
|
| 561 |
+
// Scroll the target paragraph into view so the highlight is
|
| 562 |
+
// on-camera when the chat panel reply starts streaming.
|
| 563 |
+
try {
|
| 564 |
+
const domAt = editor.view.domAtPos(from);
|
| 565 |
+
let el: HTMLElement | null =
|
| 566 |
+
domAt.node instanceof HTMLElement
|
| 567 |
+
? domAt.node
|
| 568 |
+
: (domAt.node as Node).parentElement;
|
| 569 |
+
while (
|
| 570 |
+
el &&
|
| 571 |
+
el.nodeName !== "P" &&
|
| 572 |
+
!/^H[1-6]$/.test(el.nodeName)
|
| 573 |
+
) {
|
| 574 |
+
el = el.parentElement;
|
| 575 |
+
}
|
| 576 |
+
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
| 577 |
+
} catch {
|
| 578 |
+
/* non-fatal */
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
editor.commands.setAgentFocus({ from, to });
|
| 582 |
+
},
|
| 583 |
+
{
|
| 584 |
+
target: args.targetText,
|
| 585 |
+
explicit: args.explicitRange,
|
| 586 |
+
},
|
| 587 |
+
);
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
/**
|
| 591 |
* Twin of `requestFormatAction`, but for link insertion.
|
| 592 |
*
|
|
|
|
| 753 |
// dance a couple of times before giving up, with an explicit
|
| 754 |
// `editor.commands.focus()` each pass to re-assert ownership of
|
| 755 |
// the caret against remote transactions.
|
| 756 |
+
const seedSelection = async (
|
| 757 |
+
attempt: number,
|
| 758 |
+
): Promise<{
|
| 759 |
from: number;
|
| 760 |
to: number;
|
| 761 |
} | null> => {
|
|
|
|
| 767 |
).__demoEditor;
|
| 768 |
editor?.commands.focus();
|
| 769 |
});
|
| 770 |
+
// First attempt: animate a real mouse drag across the target
|
| 771 |
+
// phrase so the viewer SEES Alice select the text (cursor glides
|
| 772 |
+
// over the words while the selection paint follows). Subsequent
|
| 773 |
+
// retries fall back to the purely programmatic path - we don't
|
| 774 |
+
// want to loop dragging if concurrent Yjs sync keeps shifting
|
| 775 |
+
// the text under us, the programmatic selection is rock solid
|
| 776 |
+
// on exact PM positions.
|
| 777 |
+
if (attempt === 0) {
|
| 778 |
+
const dragged = await dragSelectSubstringInParagraph(
|
| 779 |
+
page,
|
| 780 |
+
paragraphSnippet,
|
| 781 |
+
substring,
|
| 782 |
+
);
|
| 783 |
+
if (dragged) return dragged;
|
| 784 |
+
}
|
| 785 |
return await selectSubstringInParagraph(
|
| 786 |
page,
|
| 787 |
paragraphSnippet,
|
|
|
|
| 796 |
let range: { from: number; to: number } | null = null;
|
| 797 |
let toolbarReady = false;
|
| 798 |
for (let attempt = 0; attempt < 3; attempt++) {
|
| 799 |
+
range = await seedSelection(attempt);
|
| 800 |
if (!range) {
|
| 801 |
await pause(180, 280);
|
| 802 |
continue;
|
|
|
|
| 860 |
|
| 861 |
const [hoverMin, hoverMax] = opts.hoverMs ?? [380, 620];
|
| 862 |
try {
|
| 863 |
+
await humanHover(page, boldBtn);
|
| 864 |
} catch {
|
| 865 |
/* non-fatal */
|
| 866 |
}
|
| 867 |
await pause(hoverMin, hoverMax);
|
| 868 |
try {
|
| 869 |
+
await humanClick(page, boldBtn);
|
| 870 |
} catch (err) {
|
| 871 |
console.warn(
|
| 872 |
"[bubble-bold] click failed:",
|
|
@@ -22,6 +22,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
| 22 |
import { tmpdir } from "node:os";
|
| 23 |
import { join } from "node:path";
|
| 24 |
import { FALLBACK_USER_KEY, type Persona } from "../personas.js";
|
|
|
|
| 25 |
|
| 26 |
export interface PersonaHandle {
|
| 27 |
browser: Browser;
|
|
@@ -131,6 +132,12 @@ export async function launchPersona(
|
|
| 131 |
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
| 132 |
});
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
// Inject persona identity for any future navigation. The first --app
|
| 135 |
// load already happened, so we set localStorage on the live page and
|
| 136 |
// then reload once to get a clean boot under the persona identity.
|
|
@@ -163,6 +170,13 @@ export async function launchPersona(
|
|
| 163 |
await page.reload({ waitUntil: "domcontentloaded", timeout: 20_000 });
|
| 164 |
await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
// Wrap context.close() into the same Browser-shaped object the rest of
|
| 167 |
// the script expects so we don't have to branch on close everywhere.
|
| 168 |
const browser = {
|
|
@@ -209,6 +223,8 @@ export async function launchPersona(
|
|
| 209 |
: { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 },
|
| 210 |
);
|
| 211 |
|
|
|
|
|
|
|
| 212 |
await context.addInitScript(
|
| 213 |
({ key, value }) => {
|
| 214 |
try {
|
|
@@ -230,6 +246,13 @@ export async function launchPersona(
|
|
| 230 |
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
| 231 |
await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
return { browser, context, page };
|
| 234 |
}
|
| 235 |
|
|
|
|
| 22 |
import { tmpdir } from "node:os";
|
| 23 |
import { join } from "node:path";
|
| 24 |
import { FALLBACK_USER_KEY, type Persona } from "../personas.js";
|
| 25 |
+
import { injectMouseCursor, verifyMouseCursor } from "./mouse-cursor.js";
|
| 26 |
|
| 27 |
export interface PersonaHandle {
|
| 28 |
browser: Browser;
|
|
|
|
| 132 |
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
| 133 |
});
|
| 134 |
|
| 135 |
+
// Draw a visible mouse pointer on Alice's recorded window. Needs
|
| 136 |
+
// to run before the first nav so the overlay is live for the
|
| 137 |
+
// boot and every in-page navigation (e.g. opening the published
|
| 138 |
+
// article).
|
| 139 |
+
await injectMouseCursor(context);
|
| 140 |
+
|
| 141 |
// Inject persona identity for any future navigation. The first --app
|
| 142 |
// load already happened, so we set localStorage on the live page and
|
| 143 |
// then reload once to get a clean boot under the persona identity.
|
|
|
|
| 170 |
await page.reload({ waitUntil: "domcontentloaded", timeout: 20_000 });
|
| 171 |
await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
|
| 172 |
|
| 173 |
+
const cursorMounted = await verifyMouseCursor(page);
|
| 174 |
+
console.log(
|
| 175 |
+
cursorMounted
|
| 176 |
+
? "[showcase] mouse cursor overlay mounted"
|
| 177 |
+
: "[showcase] WARN: mouse cursor overlay did NOT mount",
|
| 178 |
+
);
|
| 179 |
+
|
| 180 |
// Wrap context.close() into the same Browser-shaped object the rest of
|
| 181 |
// the script expects so we don't have to branch on close everywhere.
|
| 182 |
const browser = {
|
|
|
|
| 223 |
: { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 },
|
| 224 |
);
|
| 225 |
|
| 226 |
+
await injectMouseCursor(context);
|
| 227 |
+
|
| 228 |
await context.addInitScript(
|
| 229 |
({ key, value }) => {
|
| 230 |
try {
|
|
|
|
| 246 |
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
| 247 |
await page.waitForSelector("[aria-label='Undo']", { timeout: 25_000 });
|
| 248 |
|
| 249 |
+
const cursorMounted = await verifyMouseCursor(page);
|
| 250 |
+
console.log(
|
| 251 |
+
cursorMounted
|
| 252 |
+
? "[showcase] mouse cursor overlay mounted"
|
| 253 |
+
: "[showcase] WARN: mouse cursor overlay did NOT mount",
|
| 254 |
+
);
|
| 255 |
+
|
| 256 |
return { browser, context, page };
|
| 257 |
}
|
| 258 |
|
|
@@ -11,6 +11,7 @@
|
|
| 11 |
*/
|
| 12 |
import type { Page } from "playwright";
|
| 13 |
import { humanType, pause } from "../human-typing.js";
|
|
|
|
| 14 |
|
| 15 |
/**
|
| 16 |
* Move the caret to the very end of the ProseMirror editor, regardless
|
|
@@ -118,12 +119,12 @@ export async function addAuthor(
|
|
| 118 |
if (await placeholderTrigger.first().isVisible().catch(() => false)) {
|
| 119 |
await placeholderTrigger.first().scrollIntoViewIfNeeded();
|
| 120 |
await pause(80, 160);
|
| 121 |
-
await placeholderTrigger.first()
|
| 122 |
opened = true;
|
| 123 |
} else if (await iconTrigger.first().isVisible().catch(() => false)) {
|
| 124 |
await iconTrigger.first().scrollIntoViewIfNeeded();
|
| 125 |
await pause(80, 160);
|
| 126 |
-
await iconTrigger.first()
|
| 127 |
opened = true;
|
| 128 |
}
|
| 129 |
if (!opened) {
|
|
@@ -143,7 +144,7 @@ export async function addAuthor(
|
|
| 143 |
const fastSpeed = speed * 1.4;
|
| 144 |
|
| 145 |
const nameInput = dialog.getByPlaceholder("Name");
|
| 146 |
-
await
|
| 147 |
await humanType(page, name, {
|
| 148 |
speed: fastSpeed,
|
| 149 |
typoRate: 0,
|
|
@@ -153,7 +154,7 @@ export async function addAuthor(
|
|
| 153 |
|
| 154 |
if (opts.url) {
|
| 155 |
const urlInput = dialog.getByPlaceholder("URL (optional)");
|
| 156 |
-
await
|
| 157 |
await humanType(page, opts.url, {
|
| 158 |
speed: fastSpeed,
|
| 159 |
typoRate: 0,
|
|
@@ -169,7 +170,7 @@ export async function addAuthor(
|
|
| 169 |
.filter({ hasText: new RegExp(`^${idx}\\.`) })
|
| 170 |
.first();
|
| 171 |
if (await chip.isVisible().catch(() => false)) {
|
| 172 |
-
await
|
| 173 |
await pause(60, 140);
|
| 174 |
}
|
| 175 |
}
|
|
@@ -177,7 +178,7 @@ export async function addAuthor(
|
|
| 177 |
|
| 178 |
if (opts.newAffiliation) {
|
| 179 |
const newAffInput = dialog.getByPlaceholder(/New affiliation/i);
|
| 180 |
-
await
|
| 181 |
await humanType(page, opts.newAffiliation, {
|
| 182 |
speed: fastSpeed,
|
| 183 |
typoRate: 0,
|
|
@@ -187,7 +188,7 @@ export async function addAuthor(
|
|
| 187 |
}
|
| 188 |
|
| 189 |
const submit = dialog.locator("button.btn--primary");
|
| 190 |
-
await
|
| 191 |
|
| 192 |
try {
|
| 193 |
await dialog.waitFor({ state: "hidden", timeout: 3_000 });
|
|
|
|
| 11 |
*/
|
| 12 |
import type { Page } from "playwright";
|
| 13 |
import { humanType, pause } from "../human-typing.js";
|
| 14 |
+
import { humanClick } from "./mouse-cursor.js";
|
| 15 |
|
| 16 |
/**
|
| 17 |
* Move the caret to the very end of the ProseMirror editor, regardless
|
|
|
|
| 119 |
if (await placeholderTrigger.first().isVisible().catch(() => false)) {
|
| 120 |
await placeholderTrigger.first().scrollIntoViewIfNeeded();
|
| 121 |
await pause(80, 160);
|
| 122 |
+
await humanClick(page, placeholderTrigger.first());
|
| 123 |
opened = true;
|
| 124 |
} else if (await iconTrigger.first().isVisible().catch(() => false)) {
|
| 125 |
await iconTrigger.first().scrollIntoViewIfNeeded();
|
| 126 |
await pause(80, 160);
|
| 127 |
+
await humanClick(page, iconTrigger.first());
|
| 128 |
opened = true;
|
| 129 |
}
|
| 130 |
if (!opened) {
|
|
|
|
| 144 |
const fastSpeed = speed * 1.4;
|
| 145 |
|
| 146 |
const nameInput = dialog.getByPlaceholder("Name");
|
| 147 |
+
await humanClick(page, nameInput);
|
| 148 |
await humanType(page, name, {
|
| 149 |
speed: fastSpeed,
|
| 150 |
typoRate: 0,
|
|
|
|
| 154 |
|
| 155 |
if (opts.url) {
|
| 156 |
const urlInput = dialog.getByPlaceholder("URL (optional)");
|
| 157 |
+
await humanClick(page, urlInput);
|
| 158 |
await humanType(page, opts.url, {
|
| 159 |
speed: fastSpeed,
|
| 160 |
typoRate: 0,
|
|
|
|
| 170 |
.filter({ hasText: new RegExp(`^${idx}\\.`) })
|
| 171 |
.first();
|
| 172 |
if (await chip.isVisible().catch(() => false)) {
|
| 173 |
+
await humanClick(page, chip);
|
| 174 |
await pause(60, 140);
|
| 175 |
}
|
| 176 |
}
|
|
|
|
| 178 |
|
| 179 |
if (opts.newAffiliation) {
|
| 180 |
const newAffInput = dialog.getByPlaceholder(/New affiliation/i);
|
| 181 |
+
await humanClick(page, newAffInput);
|
| 182 |
await humanType(page, opts.newAffiliation, {
|
| 183 |
speed: fastSpeed,
|
| 184 |
typoRate: 0,
|
|
|
|
| 188 |
}
|
| 189 |
|
| 190 |
const submit = dialog.locator("button.btn--primary");
|
| 191 |
+
await humanClick(page, submit);
|
| 192 |
|
| 193 |
try {
|
| 194 |
await dialog.waitFor({ state: "hidden", timeout: 3_000 });
|
|
@@ -16,7 +16,10 @@
|
|
| 16 |
*/
|
| 17 |
import type { Page } from "playwright";
|
| 18 |
import { humanType, pause } from "../human-typing.js";
|
|
|
|
| 19 |
import {
|
|
|
|
|
|
|
| 20 |
MODEL_ACCURACY_CSV,
|
| 21 |
MODEL_ACCURACY_CSV_META,
|
| 22 |
NEURAL_NETWORK_BANNER_HTML,
|
|
@@ -73,11 +76,11 @@ async function seedEmbedDataFile(
|
|
| 73 |
async function closeEmbedStudio(page: Page) {
|
| 74 |
const closeBtn = page.getByRole("button", { name: /^Save & Close$/i }).first();
|
| 75 |
if (await closeBtn.isVisible().catch(() => false)) {
|
| 76 |
-
await
|
| 77 |
} else {
|
| 78 |
const xBtn = page.getByRole("button", { name: /Close Embed Studio/i }).first();
|
| 79 |
if (await xBtn.isVisible().catch(() => false)) {
|
| 80 |
-
await
|
| 81 |
} else {
|
| 82 |
await page.keyboard.press("Escape");
|
| 83 |
}
|
|
@@ -194,10 +197,23 @@ export async function createNeuralNetworkBanner(page: Page, speed: number) {
|
|
| 194 |
return;
|
| 195 |
}
|
| 196 |
|
| 197 |
-
// Seed
|
| 198 |
-
// shows
|
| 199 |
// 1. visually establishes that the studio now handles data files;
|
| 200 |
-
// 2. gives the listDataFiles tool call a real output to surface
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
await seedEmbedDataFile(page, {
|
| 202 |
name: MODEL_ACCURACY_CSV_META.name,
|
| 203 |
ext: MODEL_ACCURACY_CSV_META.ext,
|
|
@@ -209,14 +225,15 @@ export async function createNeuralNetworkBanner(page: Page, speed: number) {
|
|
| 209 |
|
| 210 |
await createBtn.scrollIntoViewIfNeeded();
|
| 211 |
await pause(120, 220);
|
| 212 |
-
await
|
| 213 |
|
| 214 |
const promptArea = page.getByPlaceholder("Describe your chart...");
|
| 215 |
-
const promptText =
|
|
|
|
| 216 |
try {
|
| 217 |
await promptArea.waitFor({ state: "visible", timeout: 4_000 });
|
| 218 |
await pause(260, 420);
|
| 219 |
-
await
|
| 220 |
await pause(180, 280);
|
| 221 |
await humanType(page, promptText, {
|
| 222 |
speed: speed * 1.6,
|
|
@@ -229,35 +246,111 @@ export async function createNeuralNetworkBanner(page: Page, speed: number) {
|
|
| 229 |
console.warn("[alice] embed studio textarea not found, faking anyway");
|
| 230 |
}
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
await runEmbedScene(page, [
|
| 233 |
{ kind: "user-text", text: promptText },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
{
|
| 235 |
kind: "assistant-tool",
|
| 236 |
toolName: "listDataFiles",
|
| 237 |
input: {},
|
| 238 |
output:
|
|
|
|
|
|
|
| 239 |
`- ${MODEL_ACCURACY_CSV_META.name} (csv, ${MODEL_ACCURACY_CSV.length} bytes) ` +
|
| 240 |
`rows=${MODEL_ACCURACY_CSV_META.rowCount} ` +
|
| 241 |
`columns=[${MODEL_ACCURACY_CSV_META.columns.join(", ")}]`,
|
| 242 |
-
thinkMs: [
|
| 243 |
},
|
| 244 |
{
|
| 245 |
kind: "assistant-text",
|
| 246 |
-
text:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
},
|
| 248 |
{
|
| 249 |
kind: "assistant-tool",
|
| 250 |
toolName: "createEmbed",
|
| 251 |
input: {
|
|
|
|
| 252 |
title: "Neural network banner",
|
|
|
|
|
|
|
| 253 |
},
|
| 254 |
-
output: "
|
| 255 |
-
thinkMs: [
|
| 256 |
},
|
| 257 |
{
|
| 258 |
kind: "assistant-text",
|
| 259 |
text:
|
| 260 |
-
"
|
|
|
|
| 261 |
},
|
| 262 |
]);
|
| 263 |
|
|
|
|
| 16 |
*/
|
| 17 |
import type { Page } from "playwright";
|
| 18 |
import { humanType, pause } from "../human-typing.js";
|
| 19 |
+
import { humanClick } from "./mouse-cursor.js";
|
| 20 |
import {
|
| 21 |
+
ARCHITECTURE_JSON,
|
| 22 |
+
ARCHITECTURE_JSON_META,
|
| 23 |
MODEL_ACCURACY_CSV,
|
| 24 |
MODEL_ACCURACY_CSV_META,
|
| 25 |
NEURAL_NETWORK_BANNER_HTML,
|
|
|
|
| 76 |
async function closeEmbedStudio(page: Page) {
|
| 77 |
const closeBtn = page.getByRole("button", { name: /^Save & Close$/i }).first();
|
| 78 |
if (await closeBtn.isVisible().catch(() => false)) {
|
| 79 |
+
await humanClick(page, closeBtn);
|
| 80 |
} else {
|
| 81 |
const xBtn = page.getByRole("button", { name: /Close Embed Studio/i }).first();
|
| 82 |
if (await xBtn.isVisible().catch(() => false)) {
|
| 83 |
+
await humanClick(page, xBtn);
|
| 84 |
} else {
|
| 85 |
await page.keyboard.press("Escape");
|
| 86 |
}
|
|
|
|
| 197 |
return;
|
| 198 |
}
|
| 199 |
|
| 200 |
+
// Seed two data files BEFORE opening the studio so the FilesSidebar
|
| 201 |
+
// shows existing datasets the agent can reference. Three purposes:
|
| 202 |
// 1. visually establishes that the studio now handles data files;
|
| 203 |
+
// 2. gives the listDataFiles tool call a real output to surface;
|
| 204 |
+
// 3. one of the files (architecture.json) is directly relevant to
|
| 205 |
+
// the banner the agent is about to build, so the demo can show
|
| 206 |
+
// the agent reading the data, extracting the layer topology,
|
| 207 |
+
// and feeding it back into the visualization rather than just
|
| 208 |
+
// ignoring the dataset and dropping a hardcoded picture.
|
| 209 |
+
await seedEmbedDataFile(page, {
|
| 210 |
+
name: ARCHITECTURE_JSON_META.name,
|
| 211 |
+
ext: ARCHITECTURE_JSON_META.ext,
|
| 212 |
+
content: ARCHITECTURE_JSON,
|
| 213 |
+
columns: ARCHITECTURE_JSON_META.columns,
|
| 214 |
+
rowCount: ARCHITECTURE_JSON_META.rowCount,
|
| 215 |
+
uploader: "alice",
|
| 216 |
+
});
|
| 217 |
await seedEmbedDataFile(page, {
|
| 218 |
name: MODEL_ACCURACY_CSV_META.name,
|
| 219 |
ext: MODEL_ACCURACY_CSV_META.ext,
|
|
|
|
| 225 |
|
| 226 |
await createBtn.scrollIntoViewIfNeeded();
|
| 227 |
await pause(120, 220);
|
| 228 |
+
await humanClick(page, createBtn);
|
| 229 |
|
| 230 |
const promptArea = page.getByPlaceholder("Describe your chart...");
|
| 231 |
+
const promptText =
|
| 232 |
+
"A neural network banner that captures the spirit of the article. Use the architecture I uploaded to drive the layout - layered graph with soft pulses flowing along the synapses, like attention scores rippling between tokens.";
|
| 233 |
try {
|
| 234 |
await promptArea.waitFor({ state: "visible", timeout: 4_000 });
|
| 235 |
await pause(260, 420);
|
| 236 |
+
await humanClick(page, promptArea);
|
| 237 |
await pause(180, 280);
|
| 238 |
await humanType(page, promptText, {
|
| 239 |
speed: speed * 1.6,
|
|
|
|
| 246 |
console.warn("[alice] embed studio textarea not found, faking anyway");
|
| 247 |
}
|
| 248 |
|
| 249 |
+
// The scene mirrors a realistic agent round-trip and narrates
|
| 250 |
+
// the artifact we're actually about to inject, so when the
|
| 251 |
+
// viewer flips to the preview everything the agent promised is
|
| 252 |
+
// visibly there: 5 layers with the exact counts, pulses on a
|
| 253 |
+
// random synapse, transparent background, retinted via
|
| 254 |
+
// `--primary-color`.
|
| 255 |
+
//
|
| 256 |
+
// The sequence is:
|
| 257 |
+
// readEmbed - look at what's already in the banner slot
|
| 258 |
+
// (placeholder HTML from "Create chart" click)
|
| 259 |
+
// listDataFiles + readDataFile - confirm there IS a dataset
|
| 260 |
+
// but decide it's better saved for inline charts
|
| 261 |
+
// createEmbed - write the real banner with a descriptive
|
| 262 |
+
// filename and title, and a spec that matches
|
| 263 |
+
// the actual NEURAL_NETWORK_BANNER_HTML we ship
|
| 264 |
+
const csvPreviewOutput =
|
| 265 |
+
`${MODEL_ACCURACY_CSV_META.columns.join(",")}\n` +
|
| 266 |
+
MODEL_ACCURACY_CSV.split("\n").slice(1, 4).join("\n") +
|
| 267 |
+
"\n... 5 more rows";
|
| 268 |
+
|
| 269 |
await runEmbedScene(page, [
|
| 270 |
{ kind: "user-text", text: promptText },
|
| 271 |
+
{
|
| 272 |
+
kind: "assistant-text",
|
| 273 |
+
text:
|
| 274 |
+
"Good - a hero banner. Let me look at what's already in the slot and what data you've uploaded so I can pick the right shape.",
|
| 275 |
+
chunkMs: [22, 48],
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
kind: "assistant-tool",
|
| 279 |
+
toolName: "readEmbed",
|
| 280 |
+
input: { src: "banner.html" },
|
| 281 |
+
output:
|
| 282 |
+
"banner.html is the placeholder stub (14 lines). Safe to replace.",
|
| 283 |
+
thinkMs: [420, 640],
|
| 284 |
+
},
|
| 285 |
{
|
| 286 |
kind: "assistant-tool",
|
| 287 |
toolName: "listDataFiles",
|
| 288 |
input: {},
|
| 289 |
output:
|
| 290 |
+
`- ${ARCHITECTURE_JSON_META.name} (json, ${ARCHITECTURE_JSON.length} bytes) ` +
|
| 291 |
+
`keys=[${ARCHITECTURE_JSON_META.columns.join(", ")}]\n` +
|
| 292 |
`- ${MODEL_ACCURACY_CSV_META.name} (csv, ${MODEL_ACCURACY_CSV.length} bytes) ` +
|
| 293 |
`rows=${MODEL_ACCURACY_CSV_META.rowCount} ` +
|
| 294 |
`columns=[${MODEL_ACCURACY_CSV_META.columns.join(", ")}]`,
|
| 295 |
+
thinkMs: [440, 680],
|
| 296 |
},
|
| 297 |
{
|
| 298 |
kind: "assistant-text",
|
| 299 |
+
text:
|
| 300 |
+
` Two files. \`${ARCHITECTURE_JSON_META.name}\` looks directly relevant - that's what should drive the topology. Reading it first.`,
|
| 301 |
+
chunkMs: [20, 46],
|
| 302 |
+
},
|
| 303 |
+
{
|
| 304 |
+
kind: "assistant-tool",
|
| 305 |
+
toolName: "readDataFile",
|
| 306 |
+
input: { name: ARCHITECTURE_JSON_META.name },
|
| 307 |
+
output: ARCHITECTURE_JSON,
|
| 308 |
+
thinkMs: [520, 780],
|
| 309 |
+
},
|
| 310 |
+
{
|
| 311 |
+
kind: "assistant-text",
|
| 312 |
+
text:
|
| 313 |
+
" Got it - five layers (tokens 6 → embedding 10 → attention 14 → values 10 → logits 6), fully-connected, softmax activation. That maps perfectly to a layered graph.",
|
| 314 |
+
chunkMs: [22, 50],
|
| 315 |
+
chunkSize: [1, 3],
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
kind: "assistant-tool",
|
| 319 |
+
toolName: "readDataFile",
|
| 320 |
+
input: { name: MODEL_ACCURACY_CSV_META.name, limit: 4 },
|
| 321 |
+
output: csvPreviewOutput,
|
| 322 |
+
thinkMs: [380, 560],
|
| 323 |
+
},
|
| 324 |
+
{
|
| 325 |
+
kind: "assistant-text",
|
| 326 |
+
text:
|
| 327 |
+
" The accuracy CSV is great for an inline chart later in the article, but for the hero I'll stick with the architecture diagram.",
|
| 328 |
+
chunkMs: [22, 48],
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
kind: "assistant-text",
|
| 332 |
+
text:
|
| 333 |
+
" Plan: nodes spaced per layer count from the JSON, edges tracing every fully-connected pair, soft dots traveling a random synapse every ~200ms so the network feels active without competing with the title. Pure SVG, transparent background, respects `prefers-reduced-motion`.",
|
| 334 |
+
chunkMs: [22, 48],
|
| 335 |
+
chunkSize: [1, 3],
|
| 336 |
},
|
| 337 |
{
|
| 338 |
kind: "assistant-tool",
|
| 339 |
toolName: "createEmbed",
|
| 340 |
input: {
|
| 341 |
+
filename: "banner.html",
|
| 342 |
title: "Neural network banner",
|
| 343 |
+
description:
|
| 344 |
+
"Layered graph derived from architecture.json (5 layers: 6/10/14/10/6, fully-connected). Pulses flow along random synapses; pure SVG, transparent background.",
|
| 345 |
},
|
| 346 |
+
output: "wrote banner.html (182 lines, 3.8 KB, 0 external deps)",
|
| 347 |
+
thinkMs: [720, 1_080],
|
| 348 |
},
|
| 349 |
{
|
| 350 |
kind: "assistant-text",
|
| 351 |
text:
|
| 352 |
+
" Done - layer counts come from the JSON, the hero container drives the size, and the network rerolls a new burst pattern every cycle.",
|
| 353 |
+
chunkMs: [22, 50],
|
| 354 |
},
|
| 355 |
]);
|
| 356 |
|
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* lib/mouse-cursor.ts
|
| 3 |
+
*
|
| 4 |
+
* Draws a visible mouse cursor in Alice's recorded window and provides
|
| 5 |
+
* human-feel movement helpers.
|
| 6 |
+
*
|
| 7 |
+
* Playwright drives real native mouse events but the OS pointer is NOT
|
| 8 |
+
* captured by the browser's screen recorder - so without this overlay
|
| 9 |
+
* the viewer sees clicks happen "by themselves". This module injects a
|
| 10 |
+
* small DOM overlay (SVG arrow + click ripple) that listens to the
|
| 11 |
+
* `mousemove`, `mousedown` and `mouseup` events Playwright already
|
| 12 |
+
* generates, so the cursor follows every pointer move naturally.
|
| 13 |
+
*
|
| 14 |
+
* On top of that, `humanMove` / `humanClick` / `humanHover` perform the
|
| 15 |
+
* move itself with intermediate steps and a cubic ease so the cursor
|
| 16 |
+
* glides to its target instead of teleporting, which is what Playwright's
|
| 17 |
+
* default `locator.click()` does (single-step move).
|
| 18 |
+
*/
|
| 19 |
+
import type { Locator, Page } from "playwright";
|
| 20 |
+
|
| 21 |
+
const OVERLAY_ID = "__demo-mouse-cursor";
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* JS source that installs the overlay inside the page. Kept as a plain
|
| 25 |
+
* string (content-script) so Playwright doesn't have to serialize a
|
| 26 |
+
* TypeScript function - the tsx transpile pipeline occasionally kept
|
| 27 |
+
* type assertions in the `.toString()` output which quietly broke
|
| 28 |
+
* execution. A string is final and cannot drift.
|
| 29 |
+
*/
|
| 30 |
+
const OVERLAY_SCRIPT = `
|
| 31 |
+
(() => {
|
| 32 |
+
const OVERLAY_ID = ${JSON.stringify(OVERLAY_ID)};
|
| 33 |
+
try {
|
| 34 |
+
if (window.top !== window) return;
|
| 35 |
+
} catch (_) { /* cross-origin top; still install */ }
|
| 36 |
+
|
| 37 |
+
const mount = () => {
|
| 38 |
+
if (document.getElementById(OVERLAY_ID)) return;
|
| 39 |
+
|
| 40 |
+
const host = document.createElement("div");
|
| 41 |
+
host.id = OVERLAY_ID;
|
| 42 |
+
host.setAttribute("aria-hidden", "true");
|
| 43 |
+
// Use the HTML popover API so the overlay sits in the top-layer,
|
| 44 |
+
// above every native <dialog showModal()>. Regular z-index would
|
| 45 |
+
// lose to the browser's top-layer stacking order. Falls back to
|
| 46 |
+
// plain fixed positioning if the user agent is too old to
|
| 47 |
+
// support popover (our Chromium is Chrome 114+, fine).
|
| 48 |
+
try { host.setAttribute("popover", "manual"); } catch (_) { /* ignore */ }
|
| 49 |
+
host.style.position = "fixed";
|
| 50 |
+
host.style.inset = "0";
|
| 51 |
+
host.style.top = "0";
|
| 52 |
+
host.style.left = "0";
|
| 53 |
+
host.style.width = "100vw";
|
| 54 |
+
host.style.height = "100vh";
|
| 55 |
+
host.style.pointerEvents = "none";
|
| 56 |
+
host.style.zIndex = "2147483646";
|
| 57 |
+
host.style.margin = "0";
|
| 58 |
+
host.style.padding = "0";
|
| 59 |
+
host.style.border = "0";
|
| 60 |
+
host.style.background = "transparent";
|
| 61 |
+
host.style.overflow = "visible";
|
| 62 |
+
|
| 63 |
+
const NS = "http://www.w3.org/2000/svg";
|
| 64 |
+
const pointer = document.createElementNS(NS, "svg");
|
| 65 |
+
pointer.setAttribute("width", "26");
|
| 66 |
+
pointer.setAttribute("height", "26");
|
| 67 |
+
pointer.setAttribute("viewBox", "0 0 26 26");
|
| 68 |
+
pointer.setAttribute("fill", "none");
|
| 69 |
+
pointer.style.position = "absolute";
|
| 70 |
+
pointer.style.left = "0";
|
| 71 |
+
pointer.style.top = "0";
|
| 72 |
+
pointer.style.transform = "translate3d(-100px, -100px, 0)";
|
| 73 |
+
pointer.style.filter = "drop-shadow(0 2px 4px rgba(0,0,0,0.45))";
|
| 74 |
+
pointer.style.willChange = "transform";
|
| 75 |
+
|
| 76 |
+
const arrow = document.createElementNS(NS, "path");
|
| 77 |
+
arrow.setAttribute(
|
| 78 |
+
"d",
|
| 79 |
+
"M4 3 L4 20 L9 15.5 L12 22 L14.8 20.8 L11.9 14.3 L19 14.3 Z"
|
| 80 |
+
);
|
| 81 |
+
arrow.setAttribute("fill", "#ffffff");
|
| 82 |
+
arrow.setAttribute("stroke", "#0a0a0a");
|
| 83 |
+
arrow.setAttribute("stroke-width", "1.4");
|
| 84 |
+
arrow.setAttribute("stroke-linejoin", "round");
|
| 85 |
+
pointer.appendChild(arrow);
|
| 86 |
+
|
| 87 |
+
const ripple = document.createElement("div");
|
| 88 |
+
ripple.style.position = "absolute";
|
| 89 |
+
ripple.style.left = "0";
|
| 90 |
+
ripple.style.top = "0";
|
| 91 |
+
ripple.style.width = "22px";
|
| 92 |
+
ripple.style.height = "22px";
|
| 93 |
+
ripple.style.borderRadius = "50%";
|
| 94 |
+
ripple.style.border = "2px solid rgba(255, 186, 70, 0.95)";
|
| 95 |
+
ripple.style.background = "rgba(255, 200, 80, 0.22)";
|
| 96 |
+
ripple.style.transform = "translate3d(-100px, -100px, 0) scale(0)";
|
| 97 |
+
ripple.style.opacity = "0";
|
| 98 |
+
ripple.style.pointerEvents = "none";
|
| 99 |
+
|
| 100 |
+
const style = document.createElement("style");
|
| 101 |
+
style.textContent =
|
| 102 |
+
"@keyframes __demo-mouse-ripple {" +
|
| 103 |
+
" 0% { transform: translate3d(var(--rx), var(--ry), 0) scale(0.15); opacity: 0.95; }" +
|
| 104 |
+
" 80% { opacity: 0.35; }" +
|
| 105 |
+
" 100% { transform: translate3d(var(--rx), var(--ry), 0) scale(2.6); opacity: 0; }" +
|
| 106 |
+
"}" +
|
| 107 |
+
"#" + OVERLAY_ID + " [data-ripple-active] {" +
|
| 108 |
+
" animation: __demo-mouse-ripple 0.6s cubic-bezier(0.2, 0.6, 0.2, 1) forwards;" +
|
| 109 |
+
"}";
|
| 110 |
+
|
| 111 |
+
host.appendChild(style);
|
| 112 |
+
host.appendChild(pointer);
|
| 113 |
+
host.appendChild(ripple);
|
| 114 |
+
document.documentElement.appendChild(host);
|
| 115 |
+
|
| 116 |
+
// Promote into the top-layer so the cursor stays above any
|
| 117 |
+
// <dialog showModal()>, popovers, or other top-layer elements.
|
| 118 |
+
try {
|
| 119 |
+
const anyHost = host;
|
| 120 |
+
if (typeof anyHost.showPopover === "function") {
|
| 121 |
+
anyHost.showPopover();
|
| 122 |
+
}
|
| 123 |
+
} catch (_) { /* fall back to plain fixed positioning */ }
|
| 124 |
+
|
| 125 |
+
let x = -100;
|
| 126 |
+
let y = -100;
|
| 127 |
+
const updatePointer = () => {
|
| 128 |
+
pointer.style.transform = "translate3d(" + x + "px, " + y + "px, 0)";
|
| 129 |
+
};
|
| 130 |
+
updatePointer();
|
| 131 |
+
|
| 132 |
+
window.addEventListener("mousemove", (ev) => {
|
| 133 |
+
x = ev.clientX;
|
| 134 |
+
y = ev.clientY;
|
| 135 |
+
updatePointer();
|
| 136 |
+
}, true);
|
| 137 |
+
|
| 138 |
+
window.addEventListener("mousedown", () => {
|
| 139 |
+
ripple.removeAttribute("data-ripple-active");
|
| 140 |
+
ripple.style.setProperty("--rx", (x - 11) + "px");
|
| 141 |
+
ripple.style.setProperty("--ry", (y - 11) + "px");
|
| 142 |
+
// Force reflow so the animation restarts on every click.
|
| 143 |
+
void ripple.offsetWidth;
|
| 144 |
+
ripple.setAttribute("data-ripple-active", "true");
|
| 145 |
+
}, true);
|
| 146 |
+
|
| 147 |
+
// Breadcrumb so callers can verify the overlay mounted via the
|
| 148 |
+
// DOM (page.evaluate) without needing access to the page console.
|
| 149 |
+
document.documentElement.setAttribute("data-demo-mouse-cursor", "on");
|
| 150 |
+
|
| 151 |
+
// Re-promote ourselves in the top-layer whenever another element
|
| 152 |
+
// joins it (a <dialog showModal()> or a sibling popover). The
|
| 153 |
+
// browser stacks top-layer elements in LIFO order, so the last
|
| 154 |
+
// one promoted sits on top. Without this, our cursor would end
|
| 155 |
+
// up UNDER the author dialog as soon as Alice opens it.
|
| 156 |
+
const repromote = () => {
|
| 157 |
+
try {
|
| 158 |
+
const anyHost = host;
|
| 159 |
+
if (typeof anyHost.hidePopover === "function") {
|
| 160 |
+
anyHost.hidePopover();
|
| 161 |
+
}
|
| 162 |
+
if (typeof anyHost.showPopover === "function") {
|
| 163 |
+
anyHost.showPopover();
|
| 164 |
+
}
|
| 165 |
+
} catch (_) { /* ignore */ }
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
const isTopLayerCandidate = (el) => {
|
| 169 |
+
if (!el || el === host) return false;
|
| 170 |
+
if (el.nodeName === "DIALOG" && el.hasAttribute("open")) return true;
|
| 171 |
+
if (el.hasAttribute && el.hasAttribute("popover")) return true;
|
| 172 |
+
return false;
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
const domObserver = new MutationObserver((mutations) => {
|
| 176 |
+
for (const m of mutations) {
|
| 177 |
+
if (m.type === "attributes") {
|
| 178 |
+
if (isTopLayerCandidate(m.target)) {
|
| 179 |
+
repromote();
|
| 180 |
+
return;
|
| 181 |
+
}
|
| 182 |
+
} else if (m.type === "childList") {
|
| 183 |
+
for (const n of Array.from(m.addedNodes)) {
|
| 184 |
+
if (n.nodeType === 1 && isTopLayerCandidate(n)) {
|
| 185 |
+
repromote();
|
| 186 |
+
return;
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
});
|
| 192 |
+
try {
|
| 193 |
+
domObserver.observe(document.documentElement, {
|
| 194 |
+
subtree: true,
|
| 195 |
+
childList: true,
|
| 196 |
+
attributes: true,
|
| 197 |
+
attributeFilter: ["open", "popover"],
|
| 198 |
+
});
|
| 199 |
+
} catch (_) { /* ignore */ }
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
if (document.readyState === "loading") {
|
| 203 |
+
document.addEventListener("DOMContentLoaded", mount, { once: true });
|
| 204 |
+
} else {
|
| 205 |
+
mount();
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Re-mount on history/SPA navigations that don't reload the document
|
| 209 |
+
// (e.g. the published article link kept inside the same SPA tree).
|
| 210 |
+
// We don't have such cases today, but the check is cheap.
|
| 211 |
+
const observer = new MutationObserver(() => {
|
| 212 |
+
if (!document.getElementById(OVERLAY_ID) && document.documentElement) {
|
| 213 |
+
mount();
|
| 214 |
+
}
|
| 215 |
+
});
|
| 216 |
+
try {
|
| 217 |
+
observer.observe(document.documentElement, { childList: true });
|
| 218 |
+
} catch (_) { /* ignore */ }
|
| 219 |
+
})();
|
| 220 |
+
`;
|
| 221 |
+
|
| 222 |
+
/**
|
| 223 |
+
* Register the cursor overlay as an init script on the given context
|
| 224 |
+
* (or page). Must run BEFORE the first navigation so the overlay is
|
| 225 |
+
* live on every document the page loads (including the published
|
| 226 |
+
* article the demo opens in the same tab).
|
| 227 |
+
*/
|
| 228 |
+
export async function injectMouseCursor(target: {
|
| 229 |
+
addInitScript: (
|
| 230 |
+
script: { content: string } | string | ((arg: unknown) => unknown),
|
| 231 |
+
arg?: unknown,
|
| 232 |
+
) => Promise<void>;
|
| 233 |
+
}): Promise<void> {
|
| 234 |
+
await target.addInitScript({ content: OVERLAY_SCRIPT });
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/**
|
| 238 |
+
* Verify the overlay actually mounted on the given page. Returns true
|
| 239 |
+
* if the breadcrumb attribute is present on `<html>`. Callers log a
|
| 240 |
+
* warning otherwise so a silent failure doesn't eat the whole demo.
|
| 241 |
+
*/
|
| 242 |
+
export async function verifyMouseCursor(page: Page): Promise<boolean> {
|
| 243 |
+
try {
|
| 244 |
+
return await page.evaluate(
|
| 245 |
+
() => document.documentElement.getAttribute("data-demo-mouse-cursor") === "on",
|
| 246 |
+
);
|
| 247 |
+
} catch {
|
| 248 |
+
return false;
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// ---------------------------------------------------------------------------
|
| 253 |
+
// Movement helpers
|
| 254 |
+
// ---------------------------------------------------------------------------
|
| 255 |
+
|
| 256 |
+
interface MoveOpts {
|
| 257 |
+
/** Total move duration in ms. Defaults depend on distance. */
|
| 258 |
+
durationMs?: number;
|
| 259 |
+
/** Steps used by `page.mouse.move(x, y, { steps })`. */
|
| 260 |
+
steps?: number;
|
| 261 |
+
/** Random offset (px) added to the target to feel less robotic. */
|
| 262 |
+
jitter?: number;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
let lastX = 40;
|
| 266 |
+
let lastY = 40;
|
| 267 |
+
|
| 268 |
+
function easeInOutCubic(t: number): number {
|
| 269 |
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
/**
|
| 273 |
+
* Move the mouse from its last known position to `(x, y)` with a cubic
|
| 274 |
+
* ease so the overlay cursor glides instead of teleporting. We track
|
| 275 |
+
* the last position ourselves because Playwright doesn't expose the
|
| 276 |
+
* current mouse coordinates.
|
| 277 |
+
*/
|
| 278 |
+
export async function humanMove(
|
| 279 |
+
page: Page,
|
| 280 |
+
x: number,
|
| 281 |
+
y: number,
|
| 282 |
+
opts: MoveOpts = {},
|
| 283 |
+
): Promise<void> {
|
| 284 |
+
const jitter = opts.jitter ?? 2;
|
| 285 |
+
const tx = x + (Math.random() * 2 - 1) * jitter;
|
| 286 |
+
const ty = y + (Math.random() * 2 - 1) * jitter;
|
| 287 |
+
|
| 288 |
+
const dx = tx - lastX;
|
| 289 |
+
const dy = ty - lastY;
|
| 290 |
+
const dist = Math.hypot(dx, dy);
|
| 291 |
+
|
| 292 |
+
const steps =
|
| 293 |
+
opts.steps ?? Math.max(10, Math.min(40, Math.round(dist / 18)));
|
| 294 |
+
const duration = opts.durationMs ?? Math.max(260, Math.min(700, dist * 0.9));
|
| 295 |
+
const stepDelay = duration / steps;
|
| 296 |
+
|
| 297 |
+
// Quadratic Bezier control point offset perpendicular to the
|
| 298 |
+
// straight line, so the path arcs slightly. Side alternates per
|
| 299 |
+
// move so consecutive trips don't bend the same way.
|
| 300 |
+
const perp = { x: -dy, y: dx };
|
| 301 |
+
const perpLen = Math.hypot(perp.x, perp.y) || 1;
|
| 302 |
+
const curveSide = Math.random() < 0.5 ? 1 : -1;
|
| 303 |
+
const curveAmp = Math.min(60, dist * 0.12) * curveSide;
|
| 304 |
+
const ctrl = {
|
| 305 |
+
x: lastX + dx * 0.5 + (perp.x / perpLen) * curveAmp,
|
| 306 |
+
y: lastY + dy * 0.5 + (perp.y / perpLen) * curveAmp,
|
| 307 |
+
};
|
| 308 |
+
|
| 309 |
+
for (let i = 1; i <= steps; i++) {
|
| 310 |
+
const t = easeInOutCubic(i / steps);
|
| 311 |
+
const u = 1 - t;
|
| 312 |
+
const px = u * u * lastX + 2 * u * t * ctrl.x + t * t * tx;
|
| 313 |
+
const py = u * u * lastY + 2 * u * t * ctrl.y + t * t * ty;
|
| 314 |
+
await page.mouse.move(px, py);
|
| 315 |
+
if (stepDelay > 0) {
|
| 316 |
+
await new Promise((r) => setTimeout(r, stepDelay));
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
lastX = tx;
|
| 321 |
+
lastY = ty;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
/**
|
| 325 |
+
* Move the mouse to the center of `locator` with `humanMove`. Uses
|
| 326 |
+
* `boundingBox()` so the helper works for any element, including
|
| 327 |
+
* overlays like the chat panel or the settings drawer.
|
| 328 |
+
*/
|
| 329 |
+
export async function humanHover(
|
| 330 |
+
page: Page,
|
| 331 |
+
locator: Locator,
|
| 332 |
+
opts: MoveOpts = {},
|
| 333 |
+
): Promise<boolean> {
|
| 334 |
+
const handle = await locator.first().elementHandle();
|
| 335 |
+
if (!handle) return false;
|
| 336 |
+
const box = await handle.boundingBox();
|
| 337 |
+
if (!box) return false;
|
| 338 |
+
const cx = box.x + box.width / 2;
|
| 339 |
+
const cy = box.y + box.height / 2;
|
| 340 |
+
await humanMove(page, cx, cy, opts);
|
| 341 |
+
return true;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
/**
|
| 345 |
+
* Smoothly move the mouse to `locator`, pause briefly, then click.
|
| 346 |
+
* Falls back to `locator.click()` if the element has no bounding box
|
| 347 |
+
* (detached, display:none, etc.) so callers don't have to branch.
|
| 348 |
+
*/
|
| 349 |
+
export async function humanClick(
|
| 350 |
+
page: Page,
|
| 351 |
+
locator: Locator,
|
| 352 |
+
opts: MoveOpts & { settleMs?: [number, number] } = {},
|
| 353 |
+
): Promise<void> {
|
| 354 |
+
const moved = await humanHover(page, locator, opts);
|
| 355 |
+
if (!moved) {
|
| 356 |
+
await locator.click();
|
| 357 |
+
return;
|
| 358 |
+
}
|
| 359 |
+
const [lo, hi] = opts.settleMs ?? [60, 140];
|
| 360 |
+
const pause = lo + Math.random() * (hi - lo);
|
| 361 |
+
await new Promise((r) => setTimeout(r, pause));
|
| 362 |
+
await page.mouse.down();
|
| 363 |
+
await new Promise((r) => setTimeout(r, 30 + Math.random() * 30));
|
| 364 |
+
await page.mouse.up();
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
/**
|
| 368 |
+
* Reset the cached mouse position. Useful at the start of a new run.
|
| 369 |
+
*/
|
| 370 |
+
export function resetMouseState(x = 40, y = 40): void {
|
| 371 |
+
lastX = x;
|
| 372 |
+
lastY = y;
|
| 373 |
+
}
|
|
@@ -10,6 +10,7 @@
|
|
| 10 |
*/
|
| 11 |
import type { Page } from "playwright";
|
| 12 |
import { pause } from "../human-typing.js";
|
|
|
|
| 13 |
|
| 14 |
/**
|
| 15 |
* Drag the primary-hue slider in the Settings drawer from its current
|
|
@@ -50,9 +51,7 @@ export async function applyHueChange(
|
|
| 50 |
const cog = page.getByRole("button", { name: "Article settings" }).first();
|
| 51 |
try {
|
| 52 |
await cog.waitFor({ state: "visible", timeout: 5_000 });
|
| 53 |
-
await
|
| 54 |
-
await pause(40, 80);
|
| 55 |
-
await cog.click();
|
| 56 |
} catch {
|
| 57 |
console.warn("[hue] settings cog not found");
|
| 58 |
return;
|
|
@@ -92,9 +91,9 @@ export async function applyHueChange(
|
|
| 92 |
// the viewer's eye follows the mouse into the drawer before
|
| 93 |
// the press-and-drag begins. Kept short so the whole interaction
|
| 94 |
// reads as "a snappy human edit" rather than a slow ceremony.
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
await
|
| 98 |
await pause(40, 70);
|
| 99 |
|
| 100 |
// 4) Press down ON the slider. `setPointerCapture` locks further
|
|
@@ -121,6 +120,9 @@ export async function applyHueChange(
|
|
| 121 |
}
|
| 122 |
|
| 123 |
await page.mouse.up();
|
|
|
|
|
|
|
|
|
|
| 124 |
await pause(120, 180);
|
| 125 |
|
| 126 |
// 6) Close the drawer so the final state is "clean article + new
|
|
@@ -130,7 +132,7 @@ export async function applyHueChange(
|
|
| 130 |
.locator(".settings-drawer button[aria-label='Close']")
|
| 131 |
.first();
|
| 132 |
if (await closeDrawer.isVisible().catch(() => false)) {
|
| 133 |
-
await
|
| 134 |
} else {
|
| 135 |
await page.keyboard.press("Escape").catch(() => undefined);
|
| 136 |
}
|
|
@@ -177,7 +179,7 @@ export async function triggerPublish(
|
|
| 177 |
.first();
|
| 178 |
try {
|
| 179 |
await publishBtn.waitFor({ state: "visible", timeout: 4_000 });
|
| 180 |
-
await
|
| 181 |
console.log("[publish] clicked Publish, waiting for success...");
|
| 182 |
} catch {
|
| 183 |
console.warn("[publish] primary button not found in dialog");
|
|
@@ -207,42 +209,82 @@ export async function triggerPublish(
|
|
| 207 |
// Tiny beat so the "Published!" title is readable before the click.
|
| 208 |
await pause(600, 900);
|
| 209 |
|
| 210 |
-
// 4)
|
| 211 |
-
//
|
| 212 |
-
//
|
| 213 |
-
//
|
| 214 |
-
//
|
| 215 |
-
//
|
| 216 |
-
//
|
| 217 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
//
|
| 219 |
-
//
|
| 220 |
-
//
|
| 221 |
-
//
|
|
|
|
|
|
|
|
|
|
| 222 |
const publishedHref = await viewLink.getAttribute("href");
|
| 223 |
if (!publishedHref) {
|
| 224 |
console.warn("[publish] view link has no href, cannot navigate");
|
| 225 |
return;
|
| 226 |
}
|
| 227 |
|
| 228 |
-
// Resolve relative URLs against the current page origin so
|
| 229 |
-
// `/published/xyz/index.html` works unchanged.
|
| 230 |
-
const targetUrl = new URL(publishedHref, page.url()).toString();
|
| 231 |
try {
|
| 232 |
-
await
|
| 233 |
-
|
| 234 |
-
|
| 235 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
await page
|
| 237 |
.waitForLoadState("load", { timeout: 5_000 })
|
| 238 |
.catch(() => undefined);
|
| 239 |
console.log("[publish] viewing published article (in-place)");
|
| 240 |
} catch (err) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
console.warn(
|
| 242 |
-
"[publish] navigation
|
| 243 |
err instanceof Error ? err.message : err,
|
| 244 |
);
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
}
|
| 247 |
|
| 248 |
await pause(lingerMin, lingerMax);
|
|
|
|
| 10 |
*/
|
| 11 |
import type { Page } from "playwright";
|
| 12 |
import { pause } from "../human-typing.js";
|
| 13 |
+
import { humanClick, humanMove, resetMouseState } from "./mouse-cursor.js";
|
| 14 |
|
| 15 |
/**
|
| 16 |
* Drag the primary-hue slider in the Settings drawer from its current
|
|
|
|
| 51 |
const cog = page.getByRole("button", { name: "Article settings" }).first();
|
| 52 |
try {
|
| 53 |
await cog.waitFor({ state: "visible", timeout: 5_000 });
|
| 54 |
+
await humanClick(page, cog);
|
|
|
|
|
|
|
| 55 |
} catch {
|
| 56 |
console.warn("[hue] settings cog not found");
|
| 57 |
return;
|
|
|
|
| 91 |
// the viewer's eye follows the mouse into the drawer before
|
| 92 |
// the press-and-drag begins. Kept short so the whole interaction
|
| 93 |
// reads as "a snappy human edit" rather than a slow ceremony.
|
| 94 |
+
// `humanMove` produces a curved, eased path the overlay cursor
|
| 95 |
+
// renders; straight `page.mouse.move` would teleport on camera.
|
| 96 |
+
await humanMove(page, startX, y, { durationMs: 420 });
|
| 97 |
await pause(40, 70);
|
| 98 |
|
| 99 |
// 4) Press down ON the slider. `setPointerCapture` locks further
|
|
|
|
| 120 |
}
|
| 121 |
|
| 122 |
await page.mouse.up();
|
| 123 |
+
// Sync the tracked cursor position to where the drag left off, so
|
| 124 |
+
// the next `humanMove` doesn't animate from a stale origin.
|
| 125 |
+
resetMouseState(endX, y);
|
| 126 |
await pause(120, 180);
|
| 127 |
|
| 128 |
// 6) Close the drawer so the final state is "clean article + new
|
|
|
|
| 132 |
.locator(".settings-drawer button[aria-label='Close']")
|
| 133 |
.first();
|
| 134 |
if (await closeDrawer.isVisible().catch(() => false)) {
|
| 135 |
+
await humanClick(page, closeDrawer);
|
| 136 |
} else {
|
| 137 |
await page.keyboard.press("Escape").catch(() => undefined);
|
| 138 |
}
|
|
|
|
| 179 |
.first();
|
| 180 |
try {
|
| 181 |
await publishBtn.waitFor({ state: "visible", timeout: 4_000 });
|
| 182 |
+
await humanClick(page, publishBtn);
|
| 183 |
console.log("[publish] clicked Publish, waiting for success...");
|
| 184 |
} catch {
|
| 185 |
console.warn("[publish] primary button not found in dialog");
|
|
|
|
| 209 |
// Tiny beat so the "Published!" title is readable before the click.
|
| 210 |
await pause(600, 900);
|
| 211 |
|
| 212 |
+
// 4) Visibly click "View published article" and navigate IN
|
| 213 |
+
// THE SAME TAB.
|
| 214 |
+
//
|
| 215 |
+
// Why the gymnastics: the link is rendered `target="_blank"`
|
| 216 |
+
// for real users (correct UX outside the demo). But in
|
| 217 |
+
// --app=/fullscreen record mode, target="_blank" spawns a
|
| 218 |
+
// regular windowed Chrome (with tabs + URL bar, NOT fullscreen)
|
| 219 |
+
// because app windows cannot host tabs. That stray window
|
| 220 |
+
// also re-hydrates Chrome's translate heuristics and pops a
|
| 221 |
+
// "Translate to French" bubble over the recording on French
|
| 222 |
+
// OSes. We want neither.
|
| 223 |
+
//
|
| 224 |
+
// The old approach read `href` and called `page.goto`
|
| 225 |
+
// directly - no click, no cursor motion, the "button press"
|
| 226 |
+
// never happened on camera. That reads as "the demo ended
|
| 227 |
+
// and something magic happened". Users want to SEE Alice
|
| 228 |
+
// click the button.
|
| 229 |
//
|
| 230 |
+
// Fix: neutralise the target attribute via evaluate so the
|
| 231 |
+
// link navigates in-place, then humanClick it for real. The
|
| 232 |
+
// click triggers the native <a href> navigation which keeps
|
| 233 |
+
// Alice in her chromeless fullscreen window (no tabs, no
|
| 234 |
+
// URL bar, no translate prompt) while still showing the
|
| 235 |
+
// cursor land on the button.
|
| 236 |
const publishedHref = await viewLink.getAttribute("href");
|
| 237 |
if (!publishedHref) {
|
| 238 |
console.warn("[publish] view link has no href, cannot navigate");
|
| 239 |
return;
|
| 240 |
}
|
| 241 |
|
|
|
|
|
|
|
|
|
|
| 242 |
try {
|
| 243 |
+
await viewLink.evaluate((el: HTMLAnchorElement) => {
|
| 244 |
+
el.removeAttribute("target");
|
| 245 |
+
el.removeAttribute("rel");
|
| 246 |
});
|
| 247 |
+
} catch {
|
| 248 |
+
/* non-fatal, the goto fallback below handles it */
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
try {
|
| 252 |
+
// `waitForNavigation` must be armed BEFORE the click fires,
|
| 253 |
+
// otherwise the navigation event can land before we start
|
| 254 |
+
// awaiting it and the promise hangs on the next URL.
|
| 255 |
+
const navigation = page.waitForURL(
|
| 256 |
+
(url) => url.pathname.includes("/published/"),
|
| 257 |
+
{ timeout: 15_000 },
|
| 258 |
+
);
|
| 259 |
+
await humanClick(page, viewLink);
|
| 260 |
+
await navigation;
|
| 261 |
await page
|
| 262 |
.waitForLoadState("load", { timeout: 5_000 })
|
| 263 |
.catch(() => undefined);
|
| 264 |
console.log("[publish] viewing published article (in-place)");
|
| 265 |
} catch (err) {
|
| 266 |
+
// Click-driven navigation failed (link handler ate the event,
|
| 267 |
+
// target attribute somehow survived, ...). Fall back to the
|
| 268 |
+
// old direct-goto so the demo still lands on the published
|
| 269 |
+
// output - losing the cursor motion on the button, but the
|
| 270 |
+
// alternative is a blank recording finale.
|
| 271 |
console.warn(
|
| 272 |
+
"[publish] click-driven navigation failed, falling back to goto:",
|
| 273 |
err instanceof Error ? err.message : err,
|
| 274 |
);
|
| 275 |
+
const targetUrl = new URL(publishedHref, page.url()).toString();
|
| 276 |
+
try {
|
| 277 |
+
await page.goto(targetUrl, {
|
| 278 |
+
waitUntil: "domcontentloaded",
|
| 279 |
+
timeout: 15_000,
|
| 280 |
+
});
|
| 281 |
+
} catch (err2) {
|
| 282 |
+
console.warn(
|
| 283 |
+
"[publish] navigation to published article failed:",
|
| 284 |
+
err2 instanceof Error ? err2.message : err2,
|
| 285 |
+
);
|
| 286 |
+
return;
|
| 287 |
+
}
|
| 288 |
}
|
| 289 |
|
| 290 |
await pause(lingerMin, lingerMax);
|
|
@@ -13,6 +13,7 @@
|
|
| 13 |
*/
|
| 14 |
import type { Page } from "playwright";
|
| 15 |
import { humanType, pause } from "../human-typing.js";
|
|
|
|
| 16 |
|
| 17 |
export interface ScaffoldSection {
|
| 18 |
heading: string;
|
|
@@ -33,6 +34,87 @@ export interface ScaffoldSection {
|
|
| 33 |
* <p></p> <- slot 0
|
| 34 |
* ...
|
| 35 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
export async function writeScaffold(
|
| 37 |
page: Page,
|
| 38 |
sections: ScaffoldSection[],
|
|
@@ -42,8 +124,18 @@ export async function writeScaffold(
|
|
| 42 |
const section = sections[s];
|
| 43 |
const isLast = s === sections.length - 1;
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
await humanType(page, section.heading, {
|
| 48 |
speed: speed * 2.2,
|
| 49 |
typoRate: 0,
|
|
@@ -75,6 +167,63 @@ export async function waitForHeading2(
|
|
| 75 |
.waitFor({ state: "visible", timeout });
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
/**
|
| 79 |
* Place this browser's caret at the END of the first <p> directly after
|
| 80 |
* the heading whose text matches `headingText`. Used so each persona can
|
|
@@ -128,8 +277,54 @@ export async function focusNthParagraphInSection(
|
|
| 128 |
headingText: string,
|
| 129 |
slotIndex: number,
|
| 130 |
): Promise<boolean> {
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
({ text, n }) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
const g = globalThis as unknown as { __name?: <T>(fn: T) => T };
|
| 134 |
if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
|
| 135 |
|
|
@@ -152,22 +347,14 @@ export async function focusNthParagraphInSection(
|
|
| 152 |
|
| 153 |
const target = slots[n];
|
| 154 |
if (!target) return false;
|
| 155 |
-
target.scrollIntoView({ behavior: "auto", block: "center" });
|
| 156 |
-
|
| 157 |
-
// Pick a click point at the END of the paragraph's visible box so
|
| 158 |
-
// the caret lands after any existing content (still safe for
|
| 159 |
-
// empty paragraphs).
|
| 160 |
-
const rect = target.getBoundingClientRect();
|
| 161 |
-
const x = Math.max(rect.left + 1, rect.right - 4);
|
| 162 |
-
const y = rect.top + rect.height / 2;
|
| 163 |
|
| 164 |
const mk = (type: string) =>
|
| 165 |
new MouseEvent(type, {
|
| 166 |
bubbles: true,
|
| 167 |
cancelable: true,
|
| 168 |
view: window,
|
| 169 |
-
clientX:
|
| 170 |
-
clientY:
|
| 171 |
button: 0,
|
| 172 |
buttons: type === "mousedown" ? 1 : 0,
|
| 173 |
});
|
|
@@ -186,6 +373,6 @@ export async function focusNthParagraphInSection(
|
|
| 186 |
}
|
| 187 |
return true;
|
| 188 |
},
|
| 189 |
-
{ text: headingText, n: slotIndex },
|
| 190 |
);
|
| 191 |
}
|
|
|
|
| 13 |
*/
|
| 14 |
import type { Page } from "playwright";
|
| 15 |
import { humanType, pause } from "../human-typing.js";
|
| 16 |
+
import { humanMove } from "./mouse-cursor.js";
|
| 17 |
|
| 18 |
export interface ScaffoldSection {
|
| 19 |
heading: string;
|
|
|
|
| 34 |
* <p></p> <- slot 0
|
| 35 |
* ...
|
| 36 |
*/
|
| 37 |
+
/**
|
| 38 |
+
* Open the slash-menu ("/") suggestion popover and pick "Heading 2"
|
| 39 |
+
* using keyboard-only navigation - no filter text is typed, so if
|
| 40 |
+
* the popover fails to mount we don't leak garbage ("/heading 2")
|
| 41 |
+
* into the editor.
|
| 42 |
+
*
|
| 43 |
+
* Flat menu order (used by the suggestion plugin for ArrowUp /
|
| 44 |
+
* ArrowDown, independent of the grouped render): Heading 1 (index
|
| 45 |
+
* 0), Heading 2 (index 1), ... so ONE ArrowDown from the default
|
| 46 |
+
* selection lands on "Heading 2".
|
| 47 |
+
*
|
| 48 |
+
* Used for the FIRST section of the scaffold so viewers see the
|
| 49 |
+
* block-command popover fire and close cleanly. Subsequent
|
| 50 |
+
* headings use the faster Markdown `## ` input rule for variety.
|
| 51 |
+
*
|
| 52 |
+
* The menu's own command deletes the "/" trigger before running
|
| 53 |
+
* `toggleHeading({ level: 2 })`, so after this helper returns the
|
| 54 |
+
* caret is already parked inside an empty <h2> ready for heading
|
| 55 |
+
* text.
|
| 56 |
+
*/
|
| 57 |
+
async function pickHeading2ViaSlashMenu(page: Page): Promise<boolean> {
|
| 58 |
+
// Move the visible cursor over the editor BEFORE typing "/" so the
|
| 59 |
+
// viewer sees the pointer parked on the paragraph where the slash
|
| 60 |
+
// menu is about to bloom. Otherwise the menu opens while the
|
| 61 |
+
// cursor is stranded on whatever button Alice last clicked
|
| 62 |
+
// (typically the "Add author" confirm), which reads as a ghost
|
| 63 |
+
// interaction.
|
| 64 |
+
try {
|
| 65 |
+
const rect = await page.evaluate(() => {
|
| 66 |
+
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 67 |
+
if (!root) return null;
|
| 68 |
+
const sel = window.getSelection();
|
| 69 |
+
// Prefer the caret's own rect so the cursor lands exactly where
|
| 70 |
+
// the "/" will be typed. Fall back to the last paragraph if the
|
| 71 |
+
// caret is detached.
|
| 72 |
+
if (sel && sel.rangeCount > 0) {
|
| 73 |
+
const r = sel.getRangeAt(0).getClientRects()[0];
|
| 74 |
+
if (r && r.width >= 0 && r.height > 0) {
|
| 75 |
+
return { x: r.left + 6, y: r.top + r.height / 2 };
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
const paras = root.querySelectorAll("p");
|
| 79 |
+
const last = paras[paras.length - 1] as HTMLElement | undefined;
|
| 80 |
+
if (!last) return null;
|
| 81 |
+
const lr = last.getBoundingClientRect();
|
| 82 |
+
return { x: lr.left + 24, y: lr.top + lr.height / 2 };
|
| 83 |
+
});
|
| 84 |
+
if (rect) await humanMove(page, rect.x, rect.y, { steps: 18 });
|
| 85 |
+
} catch {
|
| 86 |
+
/* non-fatal, typing still works without the cursor following */
|
| 87 |
+
}
|
| 88 |
+
await pause(80, 140);
|
| 89 |
+
await page.keyboard.type("/", { delay: 30 });
|
| 90 |
+
// Wait for the tippy-mounted popover to appear. The Suggestion
|
| 91 |
+
// plugin schedules a React render + tippy mount after the "/"
|
| 92 |
+
// lands in state, which can take two animation frames.
|
| 93 |
+
const popoverLocator = page.locator(".slash-menu").first();
|
| 94 |
+
try {
|
| 95 |
+
await popoverLocator.waitFor({ state: "visible", timeout: 1_200 });
|
| 96 |
+
} catch {
|
| 97 |
+
// Popover never mounted (race with initial focus / input
|
| 98 |
+
// handler). Backspace the stray "/" so the caller's fallback
|
| 99 |
+
// doesn't stack it in front of "## ".
|
| 100 |
+
await page.keyboard.press("Backspace");
|
| 101 |
+
await pause(40, 80);
|
| 102 |
+
return false;
|
| 103 |
+
}
|
| 104 |
+
// Pause so the viewer actually sees the menu open before we
|
| 105 |
+
// navigate.
|
| 106 |
+
await pause(420, 640);
|
| 107 |
+
// Default selectedIndex is 0 = "Heading 1"; one ArrowDown lands
|
| 108 |
+
// on "Heading 2". We do this purely via arrow keys so NO
|
| 109 |
+
// filter text is typed into the doc if the suggestion plugin
|
| 110 |
+
// unexpectedly closes mid-sequence.
|
| 111 |
+
await page.keyboard.press("ArrowDown");
|
| 112 |
+
await pause(160, 260);
|
| 113 |
+
await page.keyboard.press("Enter");
|
| 114 |
+
await pause(140, 220);
|
| 115 |
+
return true;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
export async function writeScaffold(
|
| 119 |
page: Page,
|
| 120 |
sections: ScaffoldSection[],
|
|
|
|
| 124 |
const section = sections[s];
|
| 125 |
const isLast = s === sections.length - 1;
|
| 126 |
|
| 127 |
+
// Use the slash menu for the first heading so the demo shows
|
| 128 |
+
// the block-command popover at least once. The remaining
|
| 129 |
+
// sections stick to the Markdown `## ` shortcut to keep the
|
| 130 |
+
// scaffold phase short.
|
| 131 |
+
let createdViaSlash = false;
|
| 132 |
+
if (s === 0) {
|
| 133 |
+
createdViaSlash = await pickHeading2ViaSlashMenu(page);
|
| 134 |
+
}
|
| 135 |
+
if (!createdViaSlash) {
|
| 136 |
+
await page.keyboard.type("## ", { delay: 22 });
|
| 137 |
+
await pause(40, 80);
|
| 138 |
+
}
|
| 139 |
await humanType(page, section.heading, {
|
| 140 |
speed: speed * 2.2,
|
| 141 |
typoRate: 0,
|
|
|
|
| 167 |
.waitFor({ state: "visible", timeout });
|
| 168 |
}
|
| 169 |
|
| 170 |
+
/**
|
| 171 |
+
* Wait for the OTHER peers' final contributions to have landed in
|
| 172 |
+
* the shared doc, then give the browser one settle frame before
|
| 173 |
+
* returning. Use this as a pre-bold sync point so the layout is
|
| 174 |
+
* frozen when we compute and drag over the target phrase: if we
|
| 175 |
+
* skip it, Bob's `<blockMath>` insertion or Carol's `<pre>` code
|
| 176 |
+
* block can still be reshaping the doc during the drag, which
|
| 177 |
+
* shifts the Intuition paragraph out from under the measured
|
| 178 |
+
* rects and the selection sweep picks up the heading above.
|
| 179 |
+
*
|
| 180 |
+
* The markers below are the LAST things each peer writes:
|
| 181 |
+
* - Bob: `.ProseMirror math-display` node (the attention
|
| 182 |
+
* formula in The recipe[1])
|
| 183 |
+
* - Carol: `.ProseMirror pre code` with "import torch" as text
|
| 184 |
+
* (the eight-line PyTorch snippet)
|
| 185 |
+
*
|
| 186 |
+
* Both are idempotent: if the peer finished earlier, the locator
|
| 187 |
+
* resolves immediately. The extra `layoutSettleMs` beat after
|
| 188 |
+
* resolution lets Tiptap's decoration plugin repaint so
|
| 189 |
+
* `getClientRects()` returns the final geometry.
|
| 190 |
+
*/
|
| 191 |
+
export async function waitForPeersSettled(
|
| 192 |
+
page: Page,
|
| 193 |
+
opts: {
|
| 194 |
+
timeout?: number;
|
| 195 |
+
layoutSettleMs?: number;
|
| 196 |
+
} = {},
|
| 197 |
+
): Promise<void> {
|
| 198 |
+
const timeout = opts.timeout ?? 45_000;
|
| 199 |
+
const layoutSettleMs = opts.layoutSettleMs ?? 700;
|
| 200 |
+
|
| 201 |
+
const bobMarker = page
|
| 202 |
+
.locator(".ProseMirror [data-type='block-math']")
|
| 203 |
+
.first();
|
| 204 |
+
const carolMarker = page
|
| 205 |
+
.locator(".ProseMirror pre")
|
| 206 |
+
.filter({ hasText: /import\s+torch/i })
|
| 207 |
+
.first();
|
| 208 |
+
|
| 209 |
+
try {
|
| 210 |
+
await Promise.all([
|
| 211 |
+
bobMarker.waitFor({ state: "attached", timeout }),
|
| 212 |
+
carolMarker.waitFor({ state: "attached", timeout }),
|
| 213 |
+
]);
|
| 214 |
+
} catch (err) {
|
| 215 |
+
console.warn(
|
| 216 |
+
"[sync] waitForPeersSettled timed out:",
|
| 217 |
+
err instanceof Error ? err.message : err,
|
| 218 |
+
);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// Layout settle: even after both markers exist, Tiptap may emit
|
| 222 |
+
// one more transaction frame to finalize decorations. Pause to
|
| 223 |
+
// let the scroll height stabilise before any rect measurements.
|
| 224 |
+
await page.waitForTimeout(layoutSettleMs);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
/**
|
| 228 |
* Place this browser's caret at the END of the first <p> directly after
|
| 229 |
* the heading whose text matches `headingText`. Used so each persona can
|
|
|
|
| 277 |
headingText: string,
|
| 278 |
slotIndex: number,
|
| 279 |
): Promise<boolean> {
|
| 280 |
+
// Phase A: locate the slot and compute the click point, but DON'T
|
| 281 |
+
// dispatch events yet. We want to animate the visible cursor to the
|
| 282 |
+
// target first so the viewer sees the pointer land on the paragraph
|
| 283 |
+
// before the caret jumps there.
|
| 284 |
+
const rect = await page.evaluate(
|
| 285 |
({ text, n }) => {
|
| 286 |
+
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 287 |
+
if (!root) return null;
|
| 288 |
+
const headings = Array.from(root.querySelectorAll("h2"));
|
| 289 |
+
const wanted = text.toLowerCase();
|
| 290 |
+
const heading = headings.find(
|
| 291 |
+
(h) => (h.textContent || "").trim().toLowerCase().includes(wanted),
|
| 292 |
+
);
|
| 293 |
+
if (!heading) return null;
|
| 294 |
+
|
| 295 |
+
const slots: HTMLElement[] = [];
|
| 296 |
+
let node: Element | null = heading.nextElementSibling;
|
| 297 |
+
while (node) {
|
| 298 |
+
if (/^H[1-6]$/.test(node.tagName)) break;
|
| 299 |
+
slots.push(node as HTMLElement);
|
| 300 |
+
node = node.nextElementSibling;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
const target = slots[n];
|
| 304 |
+
if (!target) return null;
|
| 305 |
+
target.scrollIntoView({ behavior: "auto", block: "center" });
|
| 306 |
+
|
| 307 |
+
const r = target.getBoundingClientRect();
|
| 308 |
+
return {
|
| 309 |
+
x: Math.max(r.left + 1, r.right - 4),
|
| 310 |
+
y: r.top + r.height / 2,
|
| 311 |
+
};
|
| 312 |
+
},
|
| 313 |
+
{ text: headingText, n: slotIndex },
|
| 314 |
+
);
|
| 315 |
+
|
| 316 |
+
if (!rect) return false;
|
| 317 |
+
|
| 318 |
+
// Let the auto-scroll settle before we move onto the target,
|
| 319 |
+
// otherwise the humanMove aim is stale.
|
| 320 |
+
await pause(120, 220);
|
| 321 |
+
await humanMove(page, rect.x, rect.y, { steps: 20 });
|
| 322 |
+
|
| 323 |
+
// Phase B: now place the caret. Dispatch synthesized mouse events
|
| 324 |
+
// at the exact coordinates we just moved to (ProseMirror listens
|
| 325 |
+
// for these), and set the DOM Range as a belt-and-braces fallback.
|
| 326 |
+
return await page.evaluate(
|
| 327 |
+
({ text, n, clickX, clickY }) => {
|
| 328 |
const g = globalThis as unknown as { __name?: <T>(fn: T) => T };
|
| 329 |
if (typeof g.__name !== "function") g.__name = <T,>(fn: T) => fn;
|
| 330 |
|
|
|
|
| 347 |
|
| 348 |
const target = slots[n];
|
| 349 |
if (!target) return false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
const mk = (type: string) =>
|
| 352 |
new MouseEvent(type, {
|
| 353 |
bubbles: true,
|
| 354 |
cancelable: true,
|
| 355 |
view: window,
|
| 356 |
+
clientX: clickX,
|
| 357 |
+
clientY: clickY,
|
| 358 |
button: 0,
|
| 359 |
buttons: type === "mousedown" ? 1 : 0,
|
| 360 |
});
|
|
|
|
| 373 |
}
|
| 374 |
return true;
|
| 375 |
},
|
| 376 |
+
{ text: headingText, n: slotIndex, clickX: rect.x, clickY: rect.y },
|
| 377 |
);
|
| 378 |
}
|
|
@@ -9,6 +9,8 @@
|
|
| 9 |
* concrete range.
|
| 10 |
*/
|
| 11 |
import type { Page } from "playwright";
|
|
|
|
|
|
|
| 12 |
|
| 13 |
/**
|
| 14 |
* Read the current ProseMirror selection range. Returns null if the
|
|
@@ -65,18 +67,25 @@ export async function getCurrentPmSelection(
|
|
| 65 |
*
|
| 66 |
* Side effects
|
| 67 |
* ------------
|
| 68 |
-
* On success,
|
| 69 |
-
*
|
| 70 |
-
*
|
| 71 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
*/
|
| 73 |
export async function selectSubstringInParagraph(
|
| 74 |
page: Page,
|
| 75 |
paragraphSnippet: string,
|
| 76 |
substring: string,
|
|
|
|
| 77 |
): Promise<{ from: number; to: number } | null> {
|
|
|
|
| 78 |
return await page.evaluate(
|
| 79 |
-
({ paraText, substrText }) => {
|
| 80 |
const g = globalThis as unknown as {
|
| 81 |
__name?: <T>(fn: T, name?: string) => T;
|
| 82 |
};
|
|
@@ -147,11 +156,274 @@ export async function selectSubstringInParagraph(
|
|
| 147 |
}
|
| 148 |
|
| 149 |
editor.commands.setTextSelection({ from, to });
|
| 150 |
-
editor.commands.setAgentFocus({ from, to });
|
| 151 |
return { from, to };
|
| 152 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
{ paraText: paragraphSnippet, substrText: substring },
|
| 154 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
}
|
| 156 |
|
| 157 |
/**
|
|
|
|
| 9 |
* concrete range.
|
| 10 |
*/
|
| 11 |
import type { Page } from "playwright";
|
| 12 |
+
import { pause } from "../human-typing.js";
|
| 13 |
+
import { humanMove, resetMouseState } from "./mouse-cursor.js";
|
| 14 |
|
| 15 |
/**
|
| 16 |
* Read the current ProseMirror selection range. Returns null if the
|
|
|
|
| 67 |
*
|
| 68 |
* Side effects
|
| 69 |
* ------------
|
| 70 |
+
* On success, always seeds PM's text selection. If `setAgentFocus`
|
| 71 |
+
* is true, ALSO broadcasts the range as the local user's AgentFocus
|
| 72 |
+
* so every peer sees a `<Name> agent` tinted range - that's the
|
| 73 |
+
* correct behaviour for chat-driven actions (format / link /
|
| 74 |
+
* citation via the assistant), where the viewer should perceive
|
| 75 |
+
* the agent as owning the range. For pure editor UX paths (bubble
|
| 76 |
+
* toolbar bold, plain text selection before typing), leave it
|
| 77 |
+
* false so the agent label doesn't pop up out of nowhere while the
|
| 78 |
+
* chat panel is closed.
|
| 79 |
*/
|
| 80 |
export async function selectSubstringInParagraph(
|
| 81 |
page: Page,
|
| 82 |
paragraphSnippet: string,
|
| 83 |
substring: string,
|
| 84 |
+
opts: { setAgentFocus?: boolean } = {},
|
| 85 |
): Promise<{ from: number; to: number } | null> {
|
| 86 |
+
const setFocus = opts.setAgentFocus ?? false;
|
| 87 |
return await page.evaluate(
|
| 88 |
+
({ paraText, substrText, setFocus: doFocus }) => {
|
| 89 |
const g = globalThis as unknown as {
|
| 90 |
__name?: <T>(fn: T, name?: string) => T;
|
| 91 |
};
|
|
|
|
| 156 |
}
|
| 157 |
|
| 158 |
editor.commands.setTextSelection({ from, to });
|
| 159 |
+
if (doFocus) editor.commands.setAgentFocus({ from, to });
|
| 160 |
return { from, to };
|
| 161 |
},
|
| 162 |
+
{
|
| 163 |
+
paraText: paragraphSnippet,
|
| 164 |
+
substrText: substring,
|
| 165 |
+
setFocus,
|
| 166 |
+
},
|
| 167 |
+
);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* Select a substring by simulating a real mouse drag across it.
|
| 172 |
+
*
|
| 173 |
+
* Unlike `selectSubstringInParagraph`, which performs a purely
|
| 174 |
+
* programmatic PM selection, this helper moves the visible cursor
|
| 175 |
+
* overlay from one viewport coordinate to the next and fires actual
|
| 176 |
+
* `mousedown` / `mousemove` / `mouseup` events. Viewers watching a
|
| 177 |
+
* recorded demo see the cursor travel over the phrase while the
|
| 178 |
+
* selection paint follows along - this is what the user expects when
|
| 179 |
+
* they say "I want to *see* Alice select text".
|
| 180 |
+
*
|
| 181 |
+
* Robustness:
|
| 182 |
+
* - The drag can end up off by a glyph at the very edge (subpixel
|
| 183 |
+
* hit testing differs per platform), so after `mouseup` we always
|
| 184 |
+
* correct the PM selection to the exact {from, to} computed from
|
| 185 |
+
* the DOM TreeWalker mapping. That way the bold / link / agent-
|
| 186 |
+
* focus action always operates on the intended range.
|
| 187 |
+
* - Returns null if the paragraph or substring can't be found; the
|
| 188 |
+
* caller can then fall back to `selectSubstringInParagraph`.
|
| 189 |
+
*
|
| 190 |
+
* The `setAgentFocus` flag matches the sibling helper: leave it
|
| 191 |
+
* false for user-driven bubble toolbar interactions so the agent
|
| 192 |
+
* label doesn't flash up while the chat panel is closed.
|
| 193 |
+
*/
|
| 194 |
+
export async function dragSelectSubstringInParagraph(
|
| 195 |
+
page: Page,
|
| 196 |
+
paragraphSnippet: string,
|
| 197 |
+
substring: string,
|
| 198 |
+
opts: { setAgentFocus?: boolean } = {},
|
| 199 |
+
): Promise<{ from: number; to: number } | null> {
|
| 200 |
+
const setFocus = opts.setAgentFocus ?? false;
|
| 201 |
+
|
| 202 |
+
const rect = await page.evaluate(
|
| 203 |
+
({ paraText, substrText }) => {
|
| 204 |
+
const root = document.querySelector(".ProseMirror") as HTMLElement | null;
|
| 205 |
+
if (!root) return null;
|
| 206 |
+
const para =
|
| 207 |
+
(Array.from(root.querySelectorAll("p")).find((p) =>
|
| 208 |
+
(p.textContent || "").includes(paraText),
|
| 209 |
+
) as HTMLElement | undefined) ?? null;
|
| 210 |
+
if (!para) return null;
|
| 211 |
+
// "auto" (instant) rather than "smooth": smooth scroll is
|
| 212 |
+
// still animating when our caller's humanMove lands on the
|
| 213 |
+
// drag-start coordinates, which means the rects we compute
|
| 214 |
+
// here reference an onscreen position that has already
|
| 215 |
+
// shifted - the pointer ends up a few lines above/below the
|
| 216 |
+
// phrase and the browser-native selection sweep picks up
|
| 217 |
+
// the heading above or the math block below. Instant scroll
|
| 218 |
+
// pins the layout BEFORE we measure.
|
| 219 |
+
para.scrollIntoView({ behavior: "auto", block: "center" });
|
| 220 |
+
|
| 221 |
+
const text = para.textContent || "";
|
| 222 |
+
const idx = text.indexOf(substrText);
|
| 223 |
+
if (idx === -1) return null;
|
| 224 |
+
|
| 225 |
+
const walker = document.createTreeWalker(para, NodeFilter.SHOW_TEXT);
|
| 226 |
+
let walked = 0;
|
| 227 |
+
let startNode: Text | null = null;
|
| 228 |
+
let startOffset = 0;
|
| 229 |
+
let endNode: Text | null = null;
|
| 230 |
+
let endOffset = 0;
|
| 231 |
+
while (walker.nextNode()) {
|
| 232 |
+
const node = walker.currentNode as Text;
|
| 233 |
+
const len = node.nodeValue?.length ?? 0;
|
| 234 |
+
if (!startNode && walked + len > idx) {
|
| 235 |
+
startNode = node;
|
| 236 |
+
startOffset = idx - walked;
|
| 237 |
+
}
|
| 238 |
+
if (walked + len >= idx + substrText.length) {
|
| 239 |
+
endNode = node;
|
| 240 |
+
endOffset = idx + substrText.length - walked;
|
| 241 |
+
break;
|
| 242 |
+
}
|
| 243 |
+
walked += len;
|
| 244 |
+
}
|
| 245 |
+
if (!startNode || !endNode) return null;
|
| 246 |
+
|
| 247 |
+
const range = document.createRange();
|
| 248 |
+
try {
|
| 249 |
+
range.setStart(startNode, startOffset);
|
| 250 |
+
range.setEnd(endNode, endOffset);
|
| 251 |
+
} catch {
|
| 252 |
+
return null;
|
| 253 |
+
}
|
| 254 |
+
// Some layouts split the range across two lines; we always
|
| 255 |
+
// take the first client rect as the start and the last as
|
| 256 |
+
// the end, so the drag follows the visual reading order.
|
| 257 |
+
const rects = Array.from(range.getClientRects());
|
| 258 |
+
if (rects.length === 0) return null;
|
| 259 |
+
const first = rects[0];
|
| 260 |
+
const last = rects[rects.length - 1];
|
| 261 |
+
|
| 262 |
+
const editor = (
|
| 263 |
+
window as unknown as {
|
| 264 |
+
__demoEditor?: {
|
| 265 |
+
view: {
|
| 266 |
+
posAtDOM: (node: Node, offset: number, bias?: number) => number;
|
| 267 |
+
};
|
| 268 |
+
};
|
| 269 |
+
}
|
| 270 |
+
).__demoEditor;
|
| 271 |
+
if (!editor) return null;
|
| 272 |
+
let from: number;
|
| 273 |
+
let to: number;
|
| 274 |
+
try {
|
| 275 |
+
from = editor.view.posAtDOM(startNode, startOffset, -1);
|
| 276 |
+
to = editor.view.posAtDOM(endNode, endOffset, 1);
|
| 277 |
+
} catch {
|
| 278 |
+
return null;
|
| 279 |
+
}
|
| 280 |
+
if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from)) {
|
| 281 |
+
return null;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
return {
|
| 285 |
+
from,
|
| 286 |
+
to,
|
| 287 |
+
startX: first.left + 2,
|
| 288 |
+
startY: first.top + first.height / 2,
|
| 289 |
+
endX: last.right - 2,
|
| 290 |
+
endY: last.top + last.height / 2,
|
| 291 |
+
};
|
| 292 |
+
},
|
| 293 |
{ paraText: paragraphSnippet, substrText: substring },
|
| 294 |
);
|
| 295 |
+
|
| 296 |
+
if (!rect) return null;
|
| 297 |
+
|
| 298 |
+
// Post-scroll beat so the layout is 100% settled. Even with
|
| 299 |
+
// behavior: "auto", a scroll triggers a synchronous reflow but
|
| 300 |
+
// the next paint can still shift things by a frame.
|
| 301 |
+
await page.waitForTimeout(180);
|
| 302 |
+
|
| 303 |
+
// Phase 1: visible-cursor approach to the start of the phrase.
|
| 304 |
+
// NO mousedown - we are NOT doing a browser-native drag. A real
|
| 305 |
+
// drag is fragile: mouse.down 1px outside the first glyph can
|
| 306 |
+
// land on the paragraph margin and the sweep to the endpoint
|
| 307 |
+
// picks up the heading above or the math atom below, which is
|
| 308 |
+
// exactly the "3 lines with bits of title and text" artefact
|
| 309 |
+
// users see. Instead, we animate the overlay cursor over the
|
| 310 |
+
// text and extend the PM selection step-by-step as the cursor
|
| 311 |
+
// moves, so the selection paint GROWS under the pointer like
|
| 312 |
+
// a real drag but without the pixel-brittleness.
|
| 313 |
+
await humanMove(page, rect.startX, rect.startY, {
|
| 314 |
+
steps: 22,
|
| 315 |
+
jitter: 0,
|
| 316 |
+
});
|
| 317 |
+
await pause(120, 180);
|
| 318 |
+
|
| 319 |
+
// Phase 2: collapse the selection at the start of the phrase. The
|
| 320 |
+
// viewer reads the cursor "clicking" here.
|
| 321 |
+
await page.evaluate(
|
| 322 |
+
({ from }) => {
|
| 323 |
+
const editor = (
|
| 324 |
+
window as unknown as {
|
| 325 |
+
__demoEditor?: {
|
| 326 |
+
commands: {
|
| 327 |
+
focus: () => boolean;
|
| 328 |
+
setTextSelection: (args: { from: number; to: number }) => boolean;
|
| 329 |
+
};
|
| 330 |
+
};
|
| 331 |
+
}
|
| 332 |
+
).__demoEditor;
|
| 333 |
+
if (!editor) return;
|
| 334 |
+
editor.commands.focus();
|
| 335 |
+
editor.commands.setTextSelection({ from, to: from });
|
| 336 |
+
},
|
| 337 |
+
{ from: rect.from },
|
| 338 |
+
);
|
| 339 |
+
|
| 340 |
+
// Phase 3: progressive drag - interpolate cursor position AND
|
| 341 |
+
// selection endpoint together, so the highlight grows under the
|
| 342 |
+
// moving cursor in sync. `page.mouse.move` straight (no bezier)
|
| 343 |
+
// because a curved path during a drag would dip into adjacent
|
| 344 |
+
// lines. Each step updates the PM selection to
|
| 345 |
+
// {from, to: from + (to-from) * t} so the highlight progression
|
| 346 |
+
// maps 1:1 to pointer progression. Any character drift at the
|
| 347 |
+
// final step is patched by the canonical setTextSelection below.
|
| 348 |
+
const dx = rect.endX - rect.startX;
|
| 349 |
+
const dy = rect.endY - rect.startY;
|
| 350 |
+
const distance = Math.hypot(dx, dy);
|
| 351 |
+
const selLen = rect.to - rect.from;
|
| 352 |
+
const glideSteps = Math.max(18, Math.min(40, Math.round(distance / 14)));
|
| 353 |
+
for (let i = 1; i <= glideSteps; i++) {
|
| 354 |
+
const t = i / glideSteps;
|
| 355 |
+
const x = rect.startX + dx * t;
|
| 356 |
+
const y = rect.startY + dy * t;
|
| 357 |
+
await page.mouse.move(x, y, { steps: 2 });
|
| 358 |
+
const midTo = rect.from + Math.round(selLen * t);
|
| 359 |
+
await page.evaluate(
|
| 360 |
+
({ from, to }) => {
|
| 361 |
+
const editor = (
|
| 362 |
+
window as unknown as {
|
| 363 |
+
__demoEditor?: {
|
| 364 |
+
commands: {
|
| 365 |
+
setTextSelection: (args: {
|
| 366 |
+
from: number;
|
| 367 |
+
to: number;
|
| 368 |
+
}) => boolean;
|
| 369 |
+
};
|
| 370 |
+
};
|
| 371 |
+
}
|
| 372 |
+
).__demoEditor;
|
| 373 |
+
editor?.commands.setTextSelection({ from, to });
|
| 374 |
+
},
|
| 375 |
+
{ from: rect.from, to: midTo },
|
| 376 |
+
);
|
| 377 |
+
await page.waitForTimeout(14 + Math.floor(Math.random() * 14));
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
// Phase 4: canonical setTextSelection to guarantee the final
|
| 381 |
+
// range is pixel-perfect (rounding during interpolation can land
|
| 382 |
+
// 1 char short).
|
| 383 |
+
await page.evaluate(
|
| 384 |
+
({ from, to }) => {
|
| 385 |
+
const editor = (
|
| 386 |
+
window as unknown as {
|
| 387 |
+
__demoEditor?: {
|
| 388 |
+
commands: {
|
| 389 |
+
setTextSelection: (args: { from: number; to: number }) => boolean;
|
| 390 |
+
};
|
| 391 |
+
};
|
| 392 |
+
}
|
| 393 |
+
).__demoEditor;
|
| 394 |
+
editor?.commands.setTextSelection({ from, to });
|
| 395 |
+
},
|
| 396 |
+
{ from: rect.from, to: rect.to },
|
| 397 |
+
);
|
| 398 |
+
|
| 399 |
+
// Phase 5: optional AgentFocus broadcast (chat-driven paths set
|
| 400 |
+
// this, pure-editor paths leave it false so the agent label
|
| 401 |
+
// doesn't pop up out of context).
|
| 402 |
+
if (setFocus) {
|
| 403 |
+
await page.evaluate(
|
| 404 |
+
({ from, to }) => {
|
| 405 |
+
const editor = (
|
| 406 |
+
window as unknown as {
|
| 407 |
+
__demoEditor?: {
|
| 408 |
+
commands: {
|
| 409 |
+
setAgentFocus: (args: { from: number; to: number }) => boolean;
|
| 410 |
+
};
|
| 411 |
+
};
|
| 412 |
+
}
|
| 413 |
+
).__demoEditor;
|
| 414 |
+
editor?.commands.setAgentFocus({ from, to });
|
| 415 |
+
},
|
| 416 |
+
{ from: rect.from, to: rect.to },
|
| 417 |
+
);
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
// Resync cursor tracker. The progressive loop used page.mouse.move
|
| 421 |
+
// directly, which bypasses humanMove's internal lastX/lastY
|
| 422 |
+
// updates. Without this reset the next humanMove would glide
|
| 423 |
+
// from a stale origin.
|
| 424 |
+
resetMouseState(rect.endX, rect.endY);
|
| 425 |
+
|
| 426 |
+
return { from: rect.from, to: rect.to };
|
| 427 |
}
|
| 428 |
|
| 429 |
/**
|
|
@@ -0,0 +1,788 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* lib/yjs-client.ts
|
| 3 |
+
*
|
| 4 |
+
* Robust Yjs-level peer for demo personas (bob, carol).
|
| 5 |
+
*
|
| 6 |
+
* Why this exists
|
| 7 |
+
* ---------------
|
| 8 |
+
* The original demo drove Bob and Carol through Playwright + Chromium:
|
| 9 |
+
* - each persona ran an actual editor in a real browser,
|
| 10 |
+
* - Playwright "typed" characters into the DOM,
|
| 11 |
+
* - positions were tracked through MappablePosition / slot claims.
|
| 12 |
+
*
|
| 13 |
+
* That works but is fragile: ProseMirror input rules, concurrent
|
| 14 |
+
* AgentRewrite animations, and DOM-level selection changes all
|
| 15 |
+
* introduce races that occasionally drop Bob's text into the wrong
|
| 16 |
+
* slot or delete half a paragraph.
|
| 17 |
+
*
|
| 18 |
+
* Yjs (the CRDT Tiptap uses for collaboration) is built exactly for
|
| 19 |
+
* this kind of headless peer. A Node process can open a
|
| 20 |
+
* `HocuspocusProvider` against the same server the browsers connect
|
| 21 |
+
* to, sync the shared `Y.Doc`, and mutate it directly through
|
| 22 |
+
* `Y.XmlElement` / `Y.XmlText` APIs. Every mutation is atomic at
|
| 23 |
+
* the CRDT level and shows up in Alice's editor in real time, with
|
| 24 |
+
* zero input-rule surprises and zero PM position drift.
|
| 25 |
+
*
|
| 26 |
+
* This module exposes:
|
| 27 |
+
* - `connectPersona()` - open a Hocuspocus connection as a named
|
| 28 |
+
* peer, wait for initial sync, return a handle with the shared
|
| 29 |
+
* fragment, citations map, and frontmatter helpers.
|
| 30 |
+
* - a small DOM-like helper set to build paragraphs, headings,
|
| 31 |
+
* inline math, citations, and to type text character by
|
| 32 |
+
* character (so Alice visually sees the peer typing).
|
| 33 |
+
*/
|
| 34 |
+
import { WebSocket } from "ws";
|
| 35 |
+
import { HocuspocusProvider } from "@hocuspocus/provider";
|
| 36 |
+
import * as Y from "yjs";
|
| 37 |
+
|
| 38 |
+
// The Hocuspocus provider expects a DOM `WebSocket` global; Node
|
| 39 |
+
// doesn't ship one so we plumb `ws` in before any provider is
|
| 40 |
+
// constructed. Idempotent.
|
| 41 |
+
if (!(globalThis as unknown as { WebSocket?: unknown }).WebSocket) {
|
| 42 |
+
(globalThis as unknown as { WebSocket: unknown }).WebSocket = WebSocket;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// ---------------------------------------------------------------------------
|
| 46 |
+
// Types
|
| 47 |
+
// ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
export interface PersonaUser {
|
| 50 |
+
name: string;
|
| 51 |
+
color: string;
|
| 52 |
+
avatarUrl?: string;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export interface PersonaHandle {
|
| 56 |
+
ydoc: Y.Doc;
|
| 57 |
+
provider: HocuspocusProvider;
|
| 58 |
+
/**
|
| 59 |
+
* Shared editor content (Tiptap `field: "default"`).
|
| 60 |
+
*/
|
| 61 |
+
fragment: Y.XmlFragment;
|
| 62 |
+
/**
|
| 63 |
+
* Citations keyed by citation-key (Vaswani2017 -> {title, authors, ...}).
|
| 64 |
+
*/
|
| 65 |
+
citationsMap: Y.Map<unknown>;
|
| 66 |
+
/**
|
| 67 |
+
* Whole-article settings (primaryHue, etc.).
|
| 68 |
+
*/
|
| 69 |
+
settingsMap: Y.Map<unknown>;
|
| 70 |
+
/**
|
| 71 |
+
* Frontmatter arrays (authors, affiliations, links).
|
| 72 |
+
*/
|
| 73 |
+
frontmatter: {
|
| 74 |
+
scalars: Y.Map<unknown>;
|
| 75 |
+
authors: Y.Array<Author>;
|
| 76 |
+
affiliations: Y.Array<Affiliation>;
|
| 77 |
+
links: Y.Array<unknown>;
|
| 78 |
+
};
|
| 79 |
+
/**
|
| 80 |
+
* Cancel the awareness ticker / close the connection.
|
| 81 |
+
*/
|
| 82 |
+
destroy: () => void;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export interface Author {
|
| 86 |
+
name: string;
|
| 87 |
+
url?: string;
|
| 88 |
+
affiliations: number[];
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export interface Affiliation {
|
| 92 |
+
name: string;
|
| 93 |
+
url?: string;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// ---------------------------------------------------------------------------
|
| 97 |
+
// Connection
|
| 98 |
+
// ---------------------------------------------------------------------------
|
| 99 |
+
|
| 100 |
+
/**
|
| 101 |
+
* Open a Hocuspocus connection as a named peer and resolve once the
|
| 102 |
+
* server has finished the initial sync (so the Y.Doc mirrors what
|
| 103 |
+
* Alice / the disk state contain).
|
| 104 |
+
*/
|
| 105 |
+
export async function connectPersona(args: {
|
| 106 |
+
url: string;
|
| 107 |
+
docName: string;
|
| 108 |
+
user: PersonaUser;
|
| 109 |
+
token?: string;
|
| 110 |
+
syncTimeoutMs?: number;
|
| 111 |
+
}): Promise<PersonaHandle> {
|
| 112 |
+
const ydoc = new Y.Doc();
|
| 113 |
+
const provider = new HocuspocusProvider({
|
| 114 |
+
url: args.url,
|
| 115 |
+
name: args.docName,
|
| 116 |
+
document: ydoc,
|
| 117 |
+
token: args.token ?? "",
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
// Broadcast ourselves as a collaborator so the other peers see a
|
| 121 |
+
// name + color in their byline / awareness list immediately. The
|
| 122 |
+
// Tiptap CollaborationCursor extension reads awareness.user on
|
| 123 |
+
// every update, so this is all that's needed for the avatar bubble
|
| 124 |
+
// to appear.
|
| 125 |
+
provider.awareness?.setLocalStateField("user", args.user);
|
| 126 |
+
|
| 127 |
+
await new Promise<void>((resolve, reject) => {
|
| 128 |
+
const timer = setTimeout(
|
| 129 |
+
() => reject(new Error(`persona "${args.user.name}" sync timeout`)),
|
| 130 |
+
args.syncTimeoutMs ?? 15_000,
|
| 131 |
+
);
|
| 132 |
+
provider.on("synced", () => {
|
| 133 |
+
clearTimeout(timer);
|
| 134 |
+
resolve();
|
| 135 |
+
});
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
const fragment = ydoc.getXmlFragment("default");
|
| 139 |
+
const citationsMap = ydoc.getMap("citations");
|
| 140 |
+
const settingsMap = ydoc.getMap("settings");
|
| 141 |
+
const frontmatter = {
|
| 142 |
+
scalars: ydoc.getMap("frontmatter"),
|
| 143 |
+
authors: ydoc.getArray<Author>("frontmatter.authors"),
|
| 144 |
+
affiliations: ydoc.getArray<Affiliation>("frontmatter.affiliations"),
|
| 145 |
+
links: ydoc.getArray<unknown>("frontmatter.links"),
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
const destroy = () => {
|
| 149 |
+
try {
|
| 150 |
+
provider.destroy();
|
| 151 |
+
} catch {
|
| 152 |
+
/* provider may already be detached */
|
| 153 |
+
}
|
| 154 |
+
try {
|
| 155 |
+
ydoc.destroy();
|
| 156 |
+
} catch {
|
| 157 |
+
/* noop */
|
| 158 |
+
}
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
return {
|
| 162 |
+
ydoc,
|
| 163 |
+
provider,
|
| 164 |
+
fragment,
|
| 165 |
+
citationsMap,
|
| 166 |
+
settingsMap,
|
| 167 |
+
frontmatter,
|
| 168 |
+
destroy,
|
| 169 |
+
};
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// ---------------------------------------------------------------------------
|
| 173 |
+
// Inline content builders
|
| 174 |
+
// ---------------------------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
/**
|
| 177 |
+
* Describes a chunk of inline content. `text` entries can carry
|
| 178 |
+
* marks like `bold` or `link`; `inlineMath` entries render as a
|
| 179 |
+
* KaTeX node.
|
| 180 |
+
*/
|
| 181 |
+
export type InlineItem =
|
| 182 |
+
| { type: "text"; text: string; marks?: InlineMarks }
|
| 183 |
+
| { type: "inlineMath"; latex: string }
|
| 184 |
+
| { type: "citation"; key: string; label: string };
|
| 185 |
+
|
| 186 |
+
export interface InlineMarks {
|
| 187 |
+
bold?: boolean;
|
| 188 |
+
italic?: boolean;
|
| 189 |
+
underline?: boolean;
|
| 190 |
+
code?: boolean;
|
| 191 |
+
link?: {
|
| 192 |
+
href: string;
|
| 193 |
+
target?: string;
|
| 194 |
+
rel?: string;
|
| 195 |
+
class?: string;
|
| 196 |
+
title?: string | null;
|
| 197 |
+
};
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* Convert our plain JS `InlineMarks` into the Yjs attribute object
|
| 202 |
+
* expected by `Y.XmlText.insert(index, text, attrs)`. Mirrors the
|
| 203 |
+
* shape Tiptap's y-prosemirror binding serializes (empty objects
|
| 204 |
+
* for booleans, full attr maps for parametrized marks).
|
| 205 |
+
*/
|
| 206 |
+
function marksToAttrs(
|
| 207 |
+
marks: InlineMarks | undefined,
|
| 208 |
+
): Record<string, unknown> | undefined {
|
| 209 |
+
if (!marks) return undefined;
|
| 210 |
+
const out: Record<string, unknown> = {};
|
| 211 |
+
if (marks.bold) out.bold = {};
|
| 212 |
+
if (marks.italic) out.italic = {};
|
| 213 |
+
if (marks.underline) out.underline = {};
|
| 214 |
+
if (marks.code) out.code = {};
|
| 215 |
+
if (marks.link) {
|
| 216 |
+
out.link = {
|
| 217 |
+
href: marks.link.href,
|
| 218 |
+
target: marks.link.target ?? "_blank",
|
| 219 |
+
rel: marks.link.rel ?? "noopener noreferrer nofollow",
|
| 220 |
+
class: marks.link.class ?? "editor-link",
|
| 221 |
+
title: marks.link.title ?? null,
|
| 222 |
+
};
|
| 223 |
+
}
|
| 224 |
+
return Object.keys(out).length ? out : undefined;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/**
|
| 228 |
+
* Append inline content to the END of a paragraph Y.XmlElement.
|
| 229 |
+
* Text chunks merge into adjacent Y.XmlText when possible (so marks
|
| 230 |
+
* stay contiguous); non-text items are inserted as standalone
|
| 231 |
+
* children.
|
| 232 |
+
*/
|
| 233 |
+
export function appendInline(
|
| 234 |
+
paragraph: Y.XmlElement,
|
| 235 |
+
items: InlineItem[],
|
| 236 |
+
): void {
|
| 237 |
+
for (const item of items) {
|
| 238 |
+
if (item.type === "text") {
|
| 239 |
+
const attrs = marksToAttrs(item.marks);
|
| 240 |
+
// Try to reuse the trailing Y.XmlText node - merging marks
|
| 241 |
+
// with a previous text run gives Tiptap a cleaner delta and
|
| 242 |
+
// avoids spurious splits that the ProseMirror renderer has
|
| 243 |
+
// to collapse afterwards.
|
| 244 |
+
const last = paragraph.toArray().at(-1);
|
| 245 |
+
if (last instanceof Y.XmlText) {
|
| 246 |
+
last.insert(last.length, item.text, attrs);
|
| 247 |
+
} else {
|
| 248 |
+
// Attach first, then insert - Y.XmlText emits observer
|
| 249 |
+
// events on `.insert`, and doing that while detached
|
| 250 |
+
// triggers "Invalid access: Add Yjs type to a document
|
| 251 |
+
// before reading data".
|
| 252 |
+
const t = new Y.XmlText();
|
| 253 |
+
paragraph.push([t]);
|
| 254 |
+
t.insert(0, item.text, attrs);
|
| 255 |
+
}
|
| 256 |
+
} else if (item.type === "inlineMath") {
|
| 257 |
+
const el = new Y.XmlElement("inlineMath");
|
| 258 |
+
paragraph.push([el]);
|
| 259 |
+
el.setAttribute("latex", item.latex);
|
| 260 |
+
} else if (item.type === "citation") {
|
| 261 |
+
const el = new Y.XmlElement("citation");
|
| 262 |
+
paragraph.push([el]);
|
| 263 |
+
el.setAttribute("key", item.key);
|
| 264 |
+
el.setAttribute("label", item.label);
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// ---------------------------------------------------------------------------
|
| 270 |
+
// Block builders
|
| 271 |
+
// ---------------------------------------------------------------------------
|
| 272 |
+
|
| 273 |
+
/**
|
| 274 |
+
* Create an empty, detached `<paragraph/>` Y.XmlElement. Caller
|
| 275 |
+
* must attach it (via `frag.push([p])` / `frag.insert(n, [p])`)
|
| 276 |
+
* before any content insertion. `appendInline` and
|
| 277 |
+
* `typeIntoParagraph` take care of creating the first Y.XmlText
|
| 278 |
+
* child lazily after attachment, so an empty paragraph is a valid
|
| 279 |
+
* seed for all writer helpers below.
|
| 280 |
+
*/
|
| 281 |
+
export function buildEmptyParagraph(): Y.XmlElement {
|
| 282 |
+
return new Y.XmlElement("paragraph");
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
/**
|
| 286 |
+
* Build a paragraph pre-populated with inline content. Requires a
|
| 287 |
+
* parent to insert into so the Y.XmlText children can be attached
|
| 288 |
+
* immediately (a detached Y.XmlText insert would throw the
|
| 289 |
+
* "Invalid access" warning). The new paragraph is pushed onto the
|
| 290 |
+
* end of `parent`.
|
| 291 |
+
*/
|
| 292 |
+
export function pushParagraphInto(
|
| 293 |
+
parent: Y.XmlFragment | Y.XmlElement,
|
| 294 |
+
items: InlineItem[] = [],
|
| 295 |
+
): Y.XmlElement {
|
| 296 |
+
const el = new Y.XmlElement("paragraph");
|
| 297 |
+
parent.push([el]);
|
| 298 |
+
if (items.length) appendInline(el, items);
|
| 299 |
+
return el;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// ---------------------------------------------------------------------------
|
| 303 |
+
// Top-level navigation
|
| 304 |
+
// ---------------------------------------------------------------------------
|
| 305 |
+
|
| 306 |
+
/**
|
| 307 |
+
* Find the index of the nth occurrence of an H2 whose textContent
|
| 308 |
+
* matches `text` (case-insensitive). Returns -1 if not found.
|
| 309 |
+
*/
|
| 310 |
+
export function findHeadingIndex(
|
| 311 |
+
frag: Y.XmlFragment,
|
| 312 |
+
text: string,
|
| 313 |
+
level: number = 2,
|
| 314 |
+
): number {
|
| 315 |
+
const wanted = text.trim().toLowerCase();
|
| 316 |
+
const children = frag.toArray();
|
| 317 |
+
for (let i = 0; i < children.length; i++) {
|
| 318 |
+
const c = children[i];
|
| 319 |
+
if (!(c instanceof Y.XmlElement)) continue;
|
| 320 |
+
if (c.nodeName !== "heading") continue;
|
| 321 |
+
if (Number(c.getAttribute("level")) !== level) continue;
|
| 322 |
+
const textContent = c
|
| 323 |
+
.toArray()
|
| 324 |
+
.filter((n): n is Y.XmlText => n instanceof Y.XmlText)
|
| 325 |
+
.map((n) => n.toString())
|
| 326 |
+
.join("");
|
| 327 |
+
if (textContent.trim().toLowerCase() === wanted) return i;
|
| 328 |
+
}
|
| 329 |
+
return -1;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/**
|
| 333 |
+
* Return the Nth non-heading child that sits right after
|
| 334 |
+
* `headingText`, scanning top-level fragment children. Creates
|
| 335 |
+
* empty paragraphs on demand to satisfy `slotIndex` if the section
|
| 336 |
+
* exists but doesn't have enough slots yet (useful when a peer
|
| 337 |
+
* lands ahead of the scaffold writer).
|
| 338 |
+
*/
|
| 339 |
+
export function claimSlotAfterHeading(
|
| 340 |
+
frag: Y.XmlFragment,
|
| 341 |
+
headingText: string,
|
| 342 |
+
slotIndex: number,
|
| 343 |
+
): Y.XmlElement | null {
|
| 344 |
+
const headingIdx = findHeadingIndex(frag, headingText);
|
| 345 |
+
if (headingIdx === -1) return null;
|
| 346 |
+
|
| 347 |
+
const children = frag.toArray();
|
| 348 |
+
// Collect section slots up to the next H-level boundary.
|
| 349 |
+
const slots: { el: Y.XmlElement; fragIndex: number }[] = [];
|
| 350 |
+
for (let i = headingIdx + 1; i < children.length; i++) {
|
| 351 |
+
const c = children[i];
|
| 352 |
+
if (!(c instanceof Y.XmlElement)) continue;
|
| 353 |
+
if (c.nodeName === "heading") break;
|
| 354 |
+
slots.push({ el: c, fragIndex: i });
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
if (slots[slotIndex]) return slots[slotIndex].el;
|
| 358 |
+
|
| 359 |
+
// Section is present but too few slots. Grow it by inserting
|
| 360 |
+
// empty paragraphs right after the last slot we found (or right
|
| 361 |
+
// after the heading if the section was bare). Elements are
|
| 362 |
+
// minted detached, then pushed as a single batch via Y.insert
|
| 363 |
+
// so attribute writes don't emit detached-node warnings.
|
| 364 |
+
const insertAt = slots.length
|
| 365 |
+
? slots[slots.length - 1].fragIndex + 1
|
| 366 |
+
: headingIdx + 1;
|
| 367 |
+
const needed = slotIndex + 1 - slots.length;
|
| 368 |
+
const fresh: Y.XmlElement[] = [];
|
| 369 |
+
for (let i = 0; i < needed; i++) {
|
| 370 |
+
fresh.push(new Y.XmlElement("paragraph"));
|
| 371 |
+
}
|
| 372 |
+
frag.insert(insertAt, fresh);
|
| 373 |
+
return fresh[needed - 1];
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
/**
|
| 377 |
+
* Replace (or create) the paragraph at fragment index `slotIndex`
|
| 378 |
+
* within a section with a `blockMath` atom carrying the given
|
| 379 |
+
* LaTeX. `blockMath` is a Tiptap atom: nodeName="blockMath", no
|
| 380 |
+
* children, single `latex` attribute (matches
|
| 381 |
+
* @tiptap/extension-mathematics/BlockMath.ts). The frontend
|
| 382 |
+
* NodeView picks it up and renders KaTeX the moment the CRDT
|
| 383 |
+
* sync lands in Alice's editor.
|
| 384 |
+
*
|
| 385 |
+
* Using an atomic replace (delete slot + insert blockMath) keeps
|
| 386 |
+
* the insertion visually "crisp" on camera: no half-parsed LaTeX
|
| 387 |
+
* intermediate state, no input-rule roundtrip.
|
| 388 |
+
*/
|
| 389 |
+
export function writeBlockMathAtSlot(
|
| 390 |
+
frag: Y.XmlFragment,
|
| 391 |
+
headingText: string,
|
| 392 |
+
slotIndex: number,
|
| 393 |
+
latex: string,
|
| 394 |
+
): Y.XmlElement | null {
|
| 395 |
+
const headingIdx = findHeadingIndex(frag, headingText);
|
| 396 |
+
if (headingIdx === -1) return null;
|
| 397 |
+
|
| 398 |
+
const children = frag.toArray();
|
| 399 |
+
// Collect slot fragment indices up to the next heading boundary.
|
| 400 |
+
const slots: number[] = [];
|
| 401 |
+
for (let i = headingIdx + 1; i < children.length; i++) {
|
| 402 |
+
const c = children[i];
|
| 403 |
+
if (!(c instanceof Y.XmlElement)) continue;
|
| 404 |
+
if (c.nodeName === "heading") break;
|
| 405 |
+
slots.push(i);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
const block = new Y.XmlElement("blockMath");
|
| 409 |
+
frag.doc?.transact(() => {
|
| 410 |
+
if (slots[slotIndex] !== undefined) {
|
| 411 |
+
frag.delete(slots[slotIndex], 1);
|
| 412 |
+
frag.insert(slots[slotIndex], [block]);
|
| 413 |
+
} else {
|
| 414 |
+
// Section exists but doesn't have a slot at slotIndex yet.
|
| 415 |
+
// Pad with empty paragraphs then append the blockMath so
|
| 416 |
+
// downstream slot indices stay aligned with the scaffold.
|
| 417 |
+
const insertAt = slots.length
|
| 418 |
+
? slots[slots.length - 1] + 1
|
| 419 |
+
: headingIdx + 1;
|
| 420 |
+
const pad = slotIndex - slots.length;
|
| 421 |
+
const batch: Y.XmlElement[] = [];
|
| 422 |
+
for (let k = 0; k < pad; k++) batch.push(new Y.XmlElement("paragraph"));
|
| 423 |
+
batch.push(block);
|
| 424 |
+
frag.insert(insertAt, batch);
|
| 425 |
+
}
|
| 426 |
+
block.setAttribute("latex", latex);
|
| 427 |
+
});
|
| 428 |
+
return block;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
/**
|
| 432 |
+
* Wait until `predicate(fragment)` returns true, polling on every
|
| 433 |
+
* fragment update. Used to gate a peer on "Alice has written the
|
| 434 |
+
* scaffold" (`findHeadingIndex(frag, "The recipe") !== -1`).
|
| 435 |
+
*/
|
| 436 |
+
export async function waitForCondition(
|
| 437 |
+
frag: Y.XmlFragment,
|
| 438 |
+
predicate: () => boolean,
|
| 439 |
+
timeoutMs: number = 30_000,
|
| 440 |
+
): Promise<boolean> {
|
| 441 |
+
if (predicate()) return true;
|
| 442 |
+
return new Promise<boolean>((resolve) => {
|
| 443 |
+
let done = false;
|
| 444 |
+
const finish = (v: boolean) => {
|
| 445 |
+
if (done) return;
|
| 446 |
+
done = true;
|
| 447 |
+
frag.unobserveDeep(onChange);
|
| 448 |
+
clearTimeout(timer);
|
| 449 |
+
resolve(v);
|
| 450 |
+
};
|
| 451 |
+
const onChange = () => {
|
| 452 |
+
try {
|
| 453 |
+
if (predicate()) finish(true);
|
| 454 |
+
} catch {
|
| 455 |
+
/* predicate may temporarily throw while the doc is mid-
|
| 456 |
+
* mutation; just retry on the next event */
|
| 457 |
+
}
|
| 458 |
+
};
|
| 459 |
+
frag.observeDeep(onChange);
|
| 460 |
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
| 461 |
+
});
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
// ---------------------------------------------------------------------------
|
| 465 |
+
// Text "typing" (human-paced)
|
| 466 |
+
// ---------------------------------------------------------------------------
|
| 467 |
+
|
| 468 |
+
export interface TypingOptions {
|
| 469 |
+
/**
|
| 470 |
+
* Characters per second (roughly). Default 24 cps ≈ a fast touch
|
| 471 |
+
* typist.
|
| 472 |
+
*/
|
| 473 |
+
cps?: number;
|
| 474 |
+
/**
|
| 475 |
+
* Jitter on the inter-keystroke delay (0..1). Default 0.35.
|
| 476 |
+
*/
|
| 477 |
+
jitter?: number;
|
| 478 |
+
/**
|
| 479 |
+
* If set, group keystrokes of that many chars into one Y
|
| 480 |
+
* transaction to keep Alice's render smooth without killing the
|
| 481 |
+
* "typing" feel. Default 1 (every char is its own tick).
|
| 482 |
+
*/
|
| 483 |
+
chunkSize?: number;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
export function sleep(ms: number): Promise<void> {
|
| 487 |
+
return new Promise((r) => setTimeout(r, ms));
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
/**
|
| 491 |
+
* Broadcast a Tiptap-compatible cursor position through awareness.
|
| 492 |
+
*
|
| 493 |
+
* `yCursorPlugin` (from `@tiptap/y-tiptap`) reads `awareness.cursor`
|
| 494 |
+
* as `{anchor, head}`, each being a JSON-serialized Yjs relative
|
| 495 |
+
* position. Any peer that sets those two fields will have its caret
|
| 496 |
+
* rendered inside every other peer's editor - that's exactly the
|
| 497 |
+
* "floating Bob Mercier / Carol Dubois cursors" Alice sees during
|
| 498 |
+
* normal human collaboration.
|
| 499 |
+
*
|
| 500 |
+
* We build the relative position from the target Y.XmlText node +
|
| 501 |
+
* offset directly (no ProseMirror state involved), which is safe for
|
| 502 |
+
* headless Node peers that don't have an editor.
|
| 503 |
+
*/
|
| 504 |
+
export function setAwarenessCursor(
|
| 505 |
+
handle: PersonaHandle,
|
| 506 |
+
node: Y.XmlText,
|
| 507 |
+
offset: number,
|
| 508 |
+
): void {
|
| 509 |
+
try {
|
| 510 |
+
const rel = Y.createRelativePositionFromTypeIndex(node, offset, -1);
|
| 511 |
+
const json = Y.relativePositionToJSON(rel);
|
| 512 |
+
handle.provider.awareness?.setLocalStateField("cursor", {
|
| 513 |
+
anchor: json,
|
| 514 |
+
head: json,
|
| 515 |
+
});
|
| 516 |
+
} catch {
|
| 517 |
+
/* type not yet attached to the doc; skip this tick, caller will
|
| 518 |
+
* retry on the next chunk */
|
| 519 |
+
}
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
/**
|
| 523 |
+
* Clear the broadcast cursor (peer has nothing selected / is idle).
|
| 524 |
+
* Call this between scenes so Alice doesn't see a ghost caret
|
| 525 |
+
* lingering after a persona finishes typing.
|
| 526 |
+
*/
|
| 527 |
+
export function clearAwarenessCursor(handle: PersonaHandle): void {
|
| 528 |
+
handle.provider.awareness?.setLocalStateField("cursor", null);
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
/**
|
| 532 |
+
* Insert `chunk` at the end of `paragraph`, reusing the trailing
|
| 533 |
+
* Y.XmlText run when possible. Returns the Y.XmlText the chunk
|
| 534 |
+
* landed in plus the offset AFTER the insert, so callers can
|
| 535 |
+
* broadcast an up-to-date cursor position right after the write.
|
| 536 |
+
*/
|
| 537 |
+
function pushChunkIntoParagraph(
|
| 538 |
+
paragraph: Y.XmlElement,
|
| 539 |
+
chunk: string,
|
| 540 |
+
attrs: Record<string, unknown> | undefined,
|
| 541 |
+
): { node: Y.XmlText; offset: number } {
|
| 542 |
+
const last = paragraph.toArray().at(-1);
|
| 543 |
+
if (last instanceof Y.XmlText) {
|
| 544 |
+
const base = last.length;
|
| 545 |
+
last.insert(base, chunk, attrs);
|
| 546 |
+
return { node: last, offset: base + chunk.length };
|
| 547 |
+
}
|
| 548 |
+
// Attach the XmlText BEFORE inserting: Y.XmlText.insert emits
|
| 549 |
+
// observer events, and firing those while detached throws the
|
| 550 |
+
// "Invalid access" warning even though the end result is
|
| 551 |
+
// correct.
|
| 552 |
+
const t = new Y.XmlText();
|
| 553 |
+
paragraph.push([t]);
|
| 554 |
+
t.insert(0, chunk, attrs);
|
| 555 |
+
return { node: t, offset: chunk.length };
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
/**
|
| 559 |
+
* Append text, chunk by chunk, to a paragraph's trailing Y.XmlText
|
| 560 |
+
* node with human-like cadence. Each chunk is a real Yjs insert
|
| 561 |
+
* that propagates to Alice, so she sees the text stream in at a
|
| 562 |
+
* natural speed.
|
| 563 |
+
*
|
| 564 |
+
* Side effect: after every chunk we also broadcast the peer's
|
| 565 |
+
* cursor through awareness (if `handle` is provided). That's what
|
| 566 |
+
* makes Bob's / Carol's caret visibly travel along their paragraph
|
| 567 |
+
* in Alice's editor while they type, same as a human collaborator.
|
| 568 |
+
*/
|
| 569 |
+
export async function typeIntoParagraph(
|
| 570 |
+
paragraph: Y.XmlElement,
|
| 571 |
+
text: string,
|
| 572 |
+
options: TypingOptions = {},
|
| 573 |
+
marks?: InlineMarks,
|
| 574 |
+
handle?: PersonaHandle,
|
| 575 |
+
): Promise<void> {
|
| 576 |
+
const cps = options.cps ?? 24;
|
| 577 |
+
const jitter = options.jitter ?? 0.35;
|
| 578 |
+
const chunkSize = Math.max(1, options.chunkSize ?? 1);
|
| 579 |
+
const baseDelay = 1000 / cps;
|
| 580 |
+
const attrs = marksToAttrs(marks);
|
| 581 |
+
|
| 582 |
+
let i = 0;
|
| 583 |
+
while (i < text.length) {
|
| 584 |
+
const take = Math.min(chunkSize, text.length - i);
|
| 585 |
+
const chunk = text.slice(i, i + take);
|
| 586 |
+
const landed = pushChunkIntoParagraph(paragraph, chunk, attrs);
|
| 587 |
+
if (handle) setAwarenessCursor(handle, landed.node, landed.offset);
|
| 588 |
+
i += take;
|
| 589 |
+
const jitterMul = 1 + (Math.random() * 2 - 1) * jitter;
|
| 590 |
+
await sleep(Math.max(5, baseDelay * jitterMul));
|
| 591 |
+
}
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
/**
|
| 595 |
+
* Like `typeIntoParagraph` but inserts an inline node (math,
|
| 596 |
+
* citation) as a single Yjs op after an optional small pause - so
|
| 597 |
+
* the viewer experiences the formula "clicking in" rather than
|
| 598 |
+
* appearing with no warning. When `handle` is provided the peer's
|
| 599 |
+
* awareness cursor is nudged to the end of the paragraph after the
|
| 600 |
+
* insert.
|
| 601 |
+
*/
|
| 602 |
+
export async function insertInlineNode(
|
| 603 |
+
paragraph: Y.XmlElement,
|
| 604 |
+
item:
|
| 605 |
+
| { type: "inlineMath"; latex: string }
|
| 606 |
+
| { type: "citation"; key: string; label: string },
|
| 607 |
+
preDelayMs: number = 0,
|
| 608 |
+
handle?: PersonaHandle,
|
| 609 |
+
): Promise<void> {
|
| 610 |
+
if (preDelayMs > 0) await sleep(preDelayMs);
|
| 611 |
+
appendInline(paragraph, [item]);
|
| 612 |
+
if (handle) broadcastCursorAtEnd(handle, paragraph);
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
/**
|
| 616 |
+
* Broadcast the peer's awareness cursor at the end of `paragraph`.
|
| 617 |
+
* Useful after bulk inline inserts (citation chip, inline math) that
|
| 618 |
+
* don't go through `typeIntoParagraph`'s per-chunk broadcasting.
|
| 619 |
+
*/
|
| 620 |
+
export function broadcastCursorAtEnd(
|
| 621 |
+
handle: PersonaHandle,
|
| 622 |
+
paragraph: Y.XmlElement,
|
| 623 |
+
): void {
|
| 624 |
+
const last = paragraph.toArray().at(-1);
|
| 625 |
+
if (last instanceof Y.XmlText) {
|
| 626 |
+
setAwarenessCursor(handle, last, last.length);
|
| 627 |
+
}
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
// ---------------------------------------------------------------------------
|
| 631 |
+
// Frontmatter helpers
|
| 632 |
+
// ---------------------------------------------------------------------------
|
| 633 |
+
|
| 634 |
+
/**
|
| 635 |
+
* Add an author + (optionally) a new affiliation atomically.
|
| 636 |
+
* Mirrors `frontmatterStore.addAuthor` in the frontend but runs
|
| 637 |
+
* directly against the shared Y.Arrays so there's no race between
|
| 638 |
+
* "affiliation index allocation" and "author push".
|
| 639 |
+
*/
|
| 640 |
+
export function addAuthorRecord(
|
| 641 |
+
handle: PersonaHandle,
|
| 642 |
+
args: {
|
| 643 |
+
name: string;
|
| 644 |
+
url?: string;
|
| 645 |
+
newAffiliation?: { name: string; url?: string };
|
| 646 |
+
existingAffiliationIndices?: number[];
|
| 647 |
+
},
|
| 648 |
+
): void {
|
| 649 |
+
const { ydoc, frontmatter } = handle;
|
| 650 |
+
ydoc.transact(() => {
|
| 651 |
+
let affIndices = args.existingAffiliationIndices ?? [];
|
| 652 |
+
if (args.newAffiliation) {
|
| 653 |
+
frontmatter.affiliations.push([args.newAffiliation]);
|
| 654 |
+
// addAffiliation returns 1-based index in the frontend store,
|
| 655 |
+
// so we match that convention: after push, length equals the
|
| 656 |
+
// new entry's 1-based index.
|
| 657 |
+
affIndices = [...affIndices, frontmatter.affiliations.length];
|
| 658 |
+
}
|
| 659 |
+
frontmatter.authors.push([
|
| 660 |
+
{
|
| 661 |
+
name: args.name,
|
| 662 |
+
url: args.url,
|
| 663 |
+
affiliations: affIndices,
|
| 664 |
+
},
|
| 665 |
+
]);
|
| 666 |
+
});
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
/**
|
| 670 |
+
* Patch the URL of an existing affiliation by 1-based index, matching
|
| 671 |
+
* the convention used everywhere else in the frontmatter. Used by the
|
| 672 |
+
* "other" persona to backfill a `url` on the affiliation seeded by
|
| 673 |
+
* the main agent (e.g. Alice adds "Hugging Face" with no URL via the
|
| 674 |
+
* Playwright modal, then Bob attaches `https://huggingface.co` to it
|
| 675 |
+
* so the byline renders the affiliation as a real link).
|
| 676 |
+
*
|
| 677 |
+
* Done inside a single Yjs transaction so the change shows up atomically
|
| 678 |
+
* on every peer (no flicker between "name only" and "name + url").
|
| 679 |
+
*/
|
| 680 |
+
export function setAffiliationUrl(
|
| 681 |
+
handle: PersonaHandle,
|
| 682 |
+
oneBasedIndex: number,
|
| 683 |
+
url: string,
|
| 684 |
+
): void {
|
| 685 |
+
const { ydoc, frontmatter } = handle;
|
| 686 |
+
const idx = oneBasedIndex - 1;
|
| 687 |
+
const current = frontmatter.affiliations.get(idx);
|
| 688 |
+
if (!current) return;
|
| 689 |
+
ydoc.transact(() => {
|
| 690 |
+
frontmatter.affiliations.delete(idx, 1);
|
| 691 |
+
frontmatter.affiliations.insert(idx, [{ ...current, url }]);
|
| 692 |
+
});
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
/**
|
| 696 |
+
* Wait for at least `n` affiliations to be present in the shared
|
| 697 |
+
* frontmatter. Used by the "other" persona to gate on the main agent
|
| 698 |
+
* having seeded the Hugging Face affiliation before reusing its index.
|
| 699 |
+
*/
|
| 700 |
+
export async function waitForAffiliationCount(
|
| 701 |
+
handle: PersonaHandle,
|
| 702 |
+
minCount: number,
|
| 703 |
+
timeoutMs: number = 30_000,
|
| 704 |
+
): Promise<boolean> {
|
| 705 |
+
if (handle.frontmatter.affiliations.length >= minCount) return true;
|
| 706 |
+
return new Promise<boolean>((resolve) => {
|
| 707 |
+
let done = false;
|
| 708 |
+
const onChange = () => {
|
| 709 |
+
if (done) return;
|
| 710 |
+
if (handle.frontmatter.affiliations.length >= minCount) {
|
| 711 |
+
done = true;
|
| 712 |
+
handle.frontmatter.affiliations.unobserve(onChange);
|
| 713 |
+
clearTimeout(timer);
|
| 714 |
+
resolve(true);
|
| 715 |
+
}
|
| 716 |
+
};
|
| 717 |
+
handle.frontmatter.affiliations.observe(onChange);
|
| 718 |
+
const timer = setTimeout(() => {
|
| 719 |
+
if (done) return;
|
| 720 |
+
done = true;
|
| 721 |
+
handle.frontmatter.affiliations.unobserve(onChange);
|
| 722 |
+
resolve(false);
|
| 723 |
+
}, timeoutMs);
|
| 724 |
+
});
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
/**
|
| 728 |
+
* Wait for at least `n` authors to be present in the shared
|
| 729 |
+
* frontmatter. Useful for persona gating ("Bob waits for Alice to
|
| 730 |
+
* seed the first author before appending his own").
|
| 731 |
+
*/
|
| 732 |
+
export async function waitForAuthorCount(
|
| 733 |
+
handle: PersonaHandle,
|
| 734 |
+
minCount: number,
|
| 735 |
+
timeoutMs: number = 30_000,
|
| 736 |
+
): Promise<boolean> {
|
| 737 |
+
if (handle.frontmatter.authors.length >= minCount) return true;
|
| 738 |
+
return new Promise<boolean>((resolve) => {
|
| 739 |
+
let done = false;
|
| 740 |
+
const onChange = () => {
|
| 741 |
+
if (done) return;
|
| 742 |
+
if (handle.frontmatter.authors.length >= minCount) {
|
| 743 |
+
done = true;
|
| 744 |
+
handle.frontmatter.authors.unobserve(onChange);
|
| 745 |
+
clearTimeout(timer);
|
| 746 |
+
resolve(true);
|
| 747 |
+
}
|
| 748 |
+
};
|
| 749 |
+
handle.frontmatter.authors.observe(onChange);
|
| 750 |
+
const timer = setTimeout(() => {
|
| 751 |
+
if (done) return;
|
| 752 |
+
done = true;
|
| 753 |
+
handle.frontmatter.authors.unobserve(onChange);
|
| 754 |
+
resolve(false);
|
| 755 |
+
}, timeoutMs);
|
| 756 |
+
});
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
// ---------------------------------------------------------------------------
|
| 760 |
+
// Citations
|
| 761 |
+
// ---------------------------------------------------------------------------
|
| 762 |
+
|
| 763 |
+
export interface CitationEntry {
|
| 764 |
+
id: string;
|
| 765 |
+
"citation-key": string;
|
| 766 |
+
type: string;
|
| 767 |
+
title?: string;
|
| 768 |
+
author?: Array<{ family: string; given: string }>;
|
| 769 |
+
issued?: { "date-parts": number[][] };
|
| 770 |
+
"container-title"?: string;
|
| 771 |
+
volume?: string;
|
| 772 |
+
URL?: string;
|
| 773 |
+
DOI?: string;
|
| 774 |
+
[key: string]: unknown;
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
/**
|
| 778 |
+
* Register a citation entry in the shared `citationsMap`. Tiptap's
|
| 779 |
+
* frontend listens on this map; a citation chip with matching
|
| 780 |
+
* `key` attr will resolve to the entry automatically.
|
| 781 |
+
*/
|
| 782 |
+
export function registerCitation(
|
| 783 |
+
handle: PersonaHandle,
|
| 784 |
+
key: string,
|
| 785 |
+
entry: CitationEntry,
|
| 786 |
+
): void {
|
| 787 |
+
handle.citationsMap.set(key, entry);
|
| 788 |
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* One-shot probe: connect to the live Hocuspocus server as an
|
| 3 |
+
* anonymous peer, dump the structure of the shared Y.XmlFragment
|
| 4 |
+
* so we know exactly what node names / attrs / marks Tiptap emits.
|
| 5 |
+
*/
|
| 6 |
+
import { WebSocket } from "ws";
|
| 7 |
+
import { HocuspocusProvider } from "@hocuspocus/provider";
|
| 8 |
+
import * as Y from "yjs";
|
| 9 |
+
|
| 10 |
+
(globalThis as any).WebSocket = WebSocket;
|
| 11 |
+
|
| 12 |
+
function describeText(t: Y.XmlText): string {
|
| 13 |
+
// Y.XmlText.toDelta() returns [{insert: "Hello"}, {insert: "world", attributes: {bold: {}}}, ...]
|
| 14 |
+
// which is the richest format for marks.
|
| 15 |
+
return JSON.stringify((t as any).toDelta(), null, 2);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function describeElement(el: Y.XmlElement, indent: string = ""): void {
|
| 19 |
+
const attrs = el.getAttributes();
|
| 20 |
+
console.log(
|
| 21 |
+
`${indent}<${el.nodeName}${
|
| 22 |
+
Object.keys(attrs).length ? " " + JSON.stringify(attrs) : ""
|
| 23 |
+
}> (${el.length} children)`,
|
| 24 |
+
);
|
| 25 |
+
for (const child of el.toArray()) {
|
| 26 |
+
if (child instanceof Y.XmlElement) {
|
| 27 |
+
describeElement(child, indent + " ");
|
| 28 |
+
} else if (child instanceof Y.XmlText) {
|
| 29 |
+
console.log(`${indent} [text] ${describeText(child)}`);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
async function run() {
|
| 35 |
+
const ydoc = new Y.Doc();
|
| 36 |
+
const provider = new HocuspocusProvider({
|
| 37 |
+
url: "ws://localhost:5678/collab",
|
| 38 |
+
name: "default",
|
| 39 |
+
document: ydoc,
|
| 40 |
+
token: "",
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
await new Promise<void>((resolve, reject) => {
|
| 44 |
+
const timer = setTimeout(() => reject(new Error("sync timeout")), 10_000);
|
| 45 |
+
provider.on("synced", () => {
|
| 46 |
+
clearTimeout(timer);
|
| 47 |
+
resolve();
|
| 48 |
+
});
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
console.log("=== synced ===");
|
| 52 |
+
|
| 53 |
+
const frag = ydoc.getXmlFragment("default");
|
| 54 |
+
console.log(`XmlFragment("default").length = ${frag.length}\n`);
|
| 55 |
+
|
| 56 |
+
for (const child of frag.toArray()) {
|
| 57 |
+
if (child instanceof Y.XmlElement) {
|
| 58 |
+
describeElement(child);
|
| 59 |
+
} else if (child instanceof Y.XmlText) {
|
| 60 |
+
console.log(`[text] ${describeText(child)}`);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
provider.destroy();
|
| 65 |
+
ydoc.destroy();
|
| 66 |
+
process.exit(0);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
run().catch((err) => {
|
| 70 |
+
console.error("probe failed:", err);
|
| 71 |
+
process.exit(1);
|
| 72 |
+
});
|
|
@@ -71,6 +71,7 @@ import {
|
|
| 71 |
startScreenRecording,
|
| 72 |
type ScreenRecording,
|
| 73 |
} from "./lib/recording.js";
|
|
|
|
| 74 |
import type { Page } from "playwright";
|
| 75 |
|
| 76 |
// ---------------------------------------------------------------------------
|
|
@@ -92,23 +93,18 @@ const SLOT_EXPECTATIONS: Array<{
|
|
| 92 |
mustInclude: string;
|
| 93 |
owner: "alice" | "bob" | "carol";
|
| 94 |
}> = [
|
| 95 |
-
// Bob - "The recipe"
|
| 96 |
{
|
| 97 |
heading: "The recipe",
|
| 98 |
slot: 0,
|
| 99 |
mustInclude: "Attention lets a token",
|
| 100 |
owner: "bob",
|
| 101 |
},
|
| 102 |
-
|
| 103 |
-
heading: "The recipe",
|
| 104 |
-
slot: 1,
|
| 105 |
-
mustInclude: "softmax",
|
| 106 |
-
owner: "bob",
|
| 107 |
-
},
|
| 108 |
{
|
| 109 |
heading: "The recipe",
|
| 110 |
slot: 2,
|
| 111 |
-
mustInclude: "
|
| 112 |
owner: "bob",
|
| 113 |
},
|
| 114 |
{
|
|
@@ -155,45 +151,113 @@ const SLOT_EXPECTATIONS: Array<{
|
|
| 155 |
},
|
| 156 |
];
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
}
|
| 186 |
-
|
|
|
|
| 187 |
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
| 191 |
|
| 192 |
-
|
| 193 |
-
});
|
| 194 |
|
| 195 |
-
if (!report) {
|
| 196 |
-
console.warn("[verify]
|
| 197 |
return;
|
| 198 |
}
|
| 199 |
|
|
@@ -270,6 +334,27 @@ async function verifyFinalDoc(page: Page): Promise<void> {
|
|
| 270 |
}
|
| 271 |
}
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
// Bibliography should exist (Bob's citation action creates it).
|
| 274 |
if (!report.flatText.includes("Vaswani")) {
|
| 275 |
anomalies.push("bob: citation 'Vaswani' never landed in the doc");
|
|
@@ -311,8 +396,10 @@ interface CliArgs {
|
|
| 311 |
* "demo duration + a few seconds of outro", and the script blocks
|
| 312 |
* on the timer before exiting.
|
| 313 |
*
|
| 314 |
-
* Default
|
| 315 |
-
*
|
|
|
|
|
|
|
| 316 |
*/
|
| 317 |
captureDuration: number;
|
| 318 |
}
|
|
@@ -324,7 +411,7 @@ function parseArgs(argv: string[]): CliArgs {
|
|
| 324 |
allVisible: false,
|
| 325 |
record: false,
|
| 326 |
capture: false,
|
| 327 |
-
captureDuration:
|
| 328 |
};
|
| 329 |
for (let i = 0; i < argv.length; i++) {
|
| 330 |
const a = argv[i];
|
|
@@ -358,6 +445,10 @@ async function run() {
|
|
| 358 |
// a later launch threw before we got to `Promise.all(runAlice,
|
| 359 |
// ...)`.
|
| 360 |
const handles: Array<PersonaHandle | null> = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
let recording: ScreenRecording | null = null;
|
| 362 |
let alreadyCleanedUp = false;
|
| 363 |
|
|
@@ -373,6 +464,13 @@ async function run() {
|
|
| 373 |
}
|
| 374 |
recording = null;
|
| 375 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
await shutdownAllPersonas(handles);
|
| 377 |
console.log("[showcase] done.");
|
| 378 |
};
|
|
@@ -405,6 +503,17 @@ async function run() {
|
|
| 405 |
recordMode: args.record,
|
| 406 |
});
|
| 407 |
handles.push(alice);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
console.log("[showcase] alice up");
|
| 409 |
|
| 410 |
// Wipe the shared Yjs doc once on Alice's session BEFORE Bob /
|
|
@@ -414,19 +523,27 @@ async function run() {
|
|
| 414 |
console.log("[showcase] resetting shared document");
|
| 415 |
await resetDoc(alice.page);
|
| 416 |
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
});
|
| 421 |
-
|
| 422 |
-
console.log("[showcase] bob up");
|
| 423 |
|
| 424 |
-
const carol = await
|
| 425 |
-
|
| 426 |
-
|
|
|
|
| 427 |
});
|
| 428 |
-
|
| 429 |
-
console.log("[showcase] carol up");
|
| 430 |
|
| 431 |
// Auto screen recording takes precedence over the manual
|
| 432 |
// countdown: if --capture is on, we start `screencapture -v`
|
|
@@ -467,21 +584,29 @@ async function run() {
|
|
| 467 |
const alicePromise = runAlice(alice.page, args.speed)
|
| 468 |
.then(() => stamp("alice done"))
|
| 469 |
.catch((err) => console.error("[alice] error:", err));
|
| 470 |
-
const bobPromise = runBob(bob
|
| 471 |
.then(() => stamp("bob done"))
|
| 472 |
.catch((err) => console.error("[bob] error:", err));
|
| 473 |
-
const carolPromise = runCarol(carol
|
| 474 |
.then(() => stamp("carol done"))
|
| 475 |
.catch((err) => console.error("[carol] error:", err));
|
| 476 |
|
| 477 |
await Promise.all([bobPromise, carolPromise]);
|
| 478 |
|
| 479 |
-
//
|
| 480 |
-
//
|
| 481 |
-
//
|
| 482 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
try {
|
| 484 |
-
await verifyFinalDoc(bob
|
| 485 |
} catch (err) {
|
| 486 |
console.warn(
|
| 487 |
"[verify] aborted:",
|
|
@@ -489,9 +614,6 @@ async function run() {
|
|
| 489 |
);
|
| 490 |
}
|
| 491 |
|
| 492 |
-
await alicePromise;
|
| 493 |
-
stamp("all personas finished");
|
| 494 |
-
|
| 495 |
// Block on the screencapture timer so the user gets a complete,
|
| 496 |
// playable .mov. Extra time after "all personas finished"
|
| 497 |
// looks fine in the recording (final state of the editor,
|
|
|
|
| 71 |
startScreenRecording,
|
| 72 |
type ScreenRecording,
|
| 73 |
} from "./lib/recording.js";
|
| 74 |
+
import { connectPersona, type PersonaHandle as YjsPersona } from "./lib/yjs-client.js";
|
| 75 |
import type { Page } from "playwright";
|
| 76 |
|
| 77 |
// ---------------------------------------------------------------------------
|
|
|
|
| 93 |
mustInclude: string;
|
| 94 |
owner: "alice" | "bob" | "carol";
|
| 95 |
}> = [
|
| 96 |
+
// Bob - "The recipe" (4 slots: def, block-math, softmax, multi-head)
|
| 97 |
{
|
| 98 |
heading: "The recipe",
|
| 99 |
slot: 0,
|
| 100 |
mustInclude: "Attention lets a token",
|
| 101 |
owner: "bob",
|
| 102 |
},
|
| 103 |
+
// slot 1 is a <blockMath> atom; checked separately below.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
{
|
| 105 |
heading: "The recipe",
|
| 106 |
slot: 2,
|
| 107 |
+
mustInclude: "softmax turns those similarity scores",
|
| 108 |
owner: "bob",
|
| 109 |
},
|
| 110 |
{
|
|
|
|
| 151 |
},
|
| 152 |
];
|
| 153 |
|
| 154 |
+
/**
|
| 155 |
+
* Inspect the shared Y.XmlFragment directly (no browser needed)
|
| 156 |
+
* and return a flat "sections" view matching what the old
|
| 157 |
+
* DOM-based verifier produced, so `SLOT_EXPECTATIONS` keeps
|
| 158 |
+
* working unchanged.
|
| 159 |
+
*/
|
| 160 |
+
async function verifyFinalDoc(bob: YjsPersona): Promise<void> {
|
| 161 |
+
// We import yjs lazily here to avoid a top-level dep circle with
|
| 162 |
+
// `./lib/yjs-client.ts`; the verifier only runs once at the end
|
| 163 |
+
// of a demo so the extra dynamic import is cheap.
|
| 164 |
+
const Y = await import("yjs");
|
| 165 |
+
|
| 166 |
+
const sections: Array<{
|
| 167 |
+
heading: string;
|
| 168 |
+
slots: string[];
|
| 169 |
+
slotTags: string[];
|
| 170 |
+
}> = [];
|
| 171 |
+
let current: {
|
| 172 |
+
heading: string;
|
| 173 |
+
slots: string[];
|
| 174 |
+
slotTags: string[];
|
| 175 |
+
} | null = null;
|
| 176 |
+
|
| 177 |
+
// textContent for a Y.XmlElement: concatenate every descendant
|
| 178 |
+
// Y.XmlText, skipping inline atom nodes (inlineMath, citation)
|
| 179 |
+
// which have no textContent of their own but carry meaningful
|
| 180 |
+
// attributes. We still keep their node name in the tag so the
|
| 181 |
+
// flat text search below can spot a citation chip by inference.
|
| 182 |
+
// Y.XmlText.toString() serializes marks as XML tags (`<bold>foo</bold>`,
|
| 183 |
+
// `<link href="...">bar</link>`), which breaks "expected substring"
|
| 184 |
+
// checks like `"eight lines of PyTorch"` when a mark like link wraps
|
| 185 |
+
// the trailing word. We read the Delta format instead and only keep
|
| 186 |
+
// the `insert` text values: gives us the plain concatenated text a
|
| 187 |
+
// reader would see, mark attributes ignored.
|
| 188 |
+
const xmlTextToPlain = (t: Y.XmlText): string => {
|
| 189 |
+
const delta = t.toDelta() as Array<{ insert?: unknown }>;
|
| 190 |
+
let out = "";
|
| 191 |
+
for (const op of delta) {
|
| 192 |
+
if (typeof op.insert === "string") out += op.insert;
|
| 193 |
+
}
|
| 194 |
+
return out;
|
| 195 |
+
};
|
| 196 |
+
const textOf = (el: Y.XmlElement): string => {
|
| 197 |
+
// Atom nodes (`blockMath`, `inlineMath`) carry their whole
|
| 198 |
+
// payload on the `latex` attribute and have no children, so
|
| 199 |
+
// the child-walker below would see an empty slot. Surface the
|
| 200 |
+
// LaTeX verbatim when the slot IS an atom.
|
| 201 |
+
if (el.nodeName === "blockMath" || el.nodeName === "inlineMath") {
|
| 202 |
+
const latex = el.getAttribute("latex");
|
| 203 |
+
return typeof latex === "string" ? latex.replace(/\s+/g, " ").trim() : "";
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
let out = "";
|
| 207 |
+
const visit = (node: Y.XmlElement | Y.XmlText) => {
|
| 208 |
+
if (node instanceof Y.XmlText) {
|
| 209 |
+
out += xmlTextToPlain(node);
|
| 210 |
+
} else if (node instanceof Y.XmlElement) {
|
| 211 |
+
if (node.nodeName === "citation") {
|
| 212 |
+
const label = node.getAttribute("label");
|
| 213 |
+
if (typeof label === "string") out += label;
|
| 214 |
+
} else if (node.nodeName === "inlineMath" || node.nodeName === "blockMath") {
|
| 215 |
+
const latex = node.getAttribute("latex");
|
| 216 |
+
if (typeof latex === "string") out += latex;
|
| 217 |
+
} else {
|
| 218 |
+
for (const child of node.toArray()) visit(child as Y.XmlElement | Y.XmlText);
|
| 219 |
+
}
|
| 220 |
}
|
| 221 |
+
};
|
| 222 |
+
for (const child of el.toArray()) {
|
| 223 |
+
visit(child as Y.XmlElement | Y.XmlText);
|
| 224 |
+
}
|
| 225 |
+
return out.replace(/\s+/g, " ").trim();
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
for (const child of bob.fragment.toArray()) {
|
| 229 |
+
if (!(child instanceof Y.XmlElement)) continue;
|
| 230 |
+
if (child.nodeName === "heading" && Number(child.getAttribute("level")) === 2) {
|
| 231 |
+
if (current) sections.push(current);
|
| 232 |
+
current = {
|
| 233 |
+
heading: textOf(child),
|
| 234 |
+
slots: [],
|
| 235 |
+
slotTags: [],
|
| 236 |
+
};
|
| 237 |
+
} else if (current) {
|
| 238 |
+
const tagMap: Record<string, string> = {
|
| 239 |
+
paragraph: "P",
|
| 240 |
+
codeBlock: "PRE",
|
| 241 |
+
heading: "H",
|
| 242 |
+
bibliography: "BIB",
|
| 243 |
+
blockMath: "MATH",
|
| 244 |
+
};
|
| 245 |
+
current.slots.push(textOf(child));
|
| 246 |
+
current.slotTags.push(tagMap[child.nodeName] ?? child.nodeName.toUpperCase());
|
| 247 |
}
|
| 248 |
+
}
|
| 249 |
+
if (current) sections.push(current);
|
| 250 |
|
| 251 |
+
const flatText = sections
|
| 252 |
+
.flatMap((s) => [s.heading, ...s.slots])
|
| 253 |
+
.join(" ")
|
| 254 |
+
.replace(/\s+/g, " ")
|
| 255 |
+
.trim();
|
| 256 |
|
| 257 |
+
const report = { sections, flatText };
|
|
|
|
| 258 |
|
| 259 |
+
if (!report.sections.length) {
|
| 260 |
+
console.warn("[verify] empty fragment, nothing to verify");
|
| 261 |
return;
|
| 262 |
}
|
| 263 |
|
|
|
|
| 334 |
}
|
| 335 |
}
|
| 336 |
|
| 337 |
+
// Block math sanity check: "The recipe" slot 1 should be a
|
| 338 |
+
// <blockMath> atom carrying the attention formula. We grep the
|
| 339 |
+
// LaTeX source for the two signature substrings the formula
|
| 340 |
+
// always contains ("Attention" + "softmax") so a typo in the
|
| 341 |
+
// source string doesn't silently pass.
|
| 342 |
+
const recipe = report.sections.find((s) =>
|
| 343 |
+
s.heading.toLowerCase().includes("the recipe"),
|
| 344 |
+
);
|
| 345 |
+
if (recipe) {
|
| 346 |
+
const mathTag = recipe.slotTags[1];
|
| 347 |
+
const mathText = recipe.slots[1] ?? "";
|
| 348 |
+
const looksLikeFormula =
|
| 349 |
+
mathText.includes("Attention") && mathText.includes("softmax");
|
| 350 |
+
if (mathTag !== "MATH" || !looksLikeFormula) {
|
| 351 |
+
anomalies.push(
|
| 352 |
+
`bob: block math in "The recipe"[1] not recognized ` +
|
| 353 |
+
`(got tag=${mathTag}, latex="${mathText.slice(0, 60)}...")`,
|
| 354 |
+
);
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
// Bibliography should exist (Bob's citation action creates it).
|
| 359 |
if (!report.flatText.includes("Vaswani")) {
|
| 360 |
anomalies.push("bob: citation 'Vaswani' never landed in the doc");
|
|
|
|
| 396 |
* "demo duration + a few seconds of outro", and the script blocks
|
| 397 |
* on the timer before exiting.
|
| 398 |
*
|
| 399 |
+
* Default 50s covers our ~45s demo + a ~5s outro on the published
|
| 400 |
+
* article (user wants the browser to quit shortly after the
|
| 401 |
+
* publish view, not linger for 30s). Bump if you expand the
|
| 402 |
+
* scenario.
|
| 403 |
*/
|
| 404 |
captureDuration: number;
|
| 405 |
}
|
|
|
|
| 411 |
allVisible: false,
|
| 412 |
record: false,
|
| 413 |
capture: false,
|
| 414 |
+
captureDuration: 50,
|
| 415 |
};
|
| 416 |
for (let i = 0; i < argv.length; i++) {
|
| 417 |
const a = argv[i];
|
|
|
|
| 445 |
// a later launch threw before we got to `Promise.all(runAlice,
|
| 446 |
// ...)`.
|
| 447 |
const handles: Array<PersonaHandle | null> = [];
|
| 448 |
+
// Bob + Carol are now Node-side Yjs peers (no Chromium); kept
|
| 449 |
+
// separately so SIGINT / finally can tear their provider sockets
|
| 450 |
+
// down cleanly alongside the browsers.
|
| 451 |
+
const yjsPeers: YjsPersona[] = [];
|
| 452 |
let recording: ScreenRecording | null = null;
|
| 453 |
let alreadyCleanedUp = false;
|
| 454 |
|
|
|
|
| 464 |
}
|
| 465 |
recording = null;
|
| 466 |
}
|
| 467 |
+
for (const peer of yjsPeers) {
|
| 468 |
+
try {
|
| 469 |
+
peer.destroy();
|
| 470 |
+
} catch {
|
| 471 |
+
/* provider already closed */
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
await shutdownAllPersonas(handles);
|
| 475 |
console.log("[showcase] done.");
|
| 476 |
};
|
|
|
|
| 503 |
recordMode: args.record,
|
| 504 |
});
|
| 505 |
handles.push(alice);
|
| 506 |
+
|
| 507 |
+
// Relay demo-related browser console messages to the Node log so
|
| 508 |
+
// we can diagnose silent frontend errors (e.g. "replace target
|
| 509 |
+
// not found") during a --capture run without having to open
|
| 510 |
+
// DevTools by hand.
|
| 511 |
+
alice.page.on("console", (msg) => {
|
| 512 |
+
const text = msg.text();
|
| 513 |
+
if (/__demo-chat|\[alice\]|agentRewrite/.test(text)) {
|
| 514 |
+
console.log(`[alice:console ${msg.type()}] ${text}`);
|
| 515 |
+
}
|
| 516 |
+
});
|
| 517 |
console.log("[showcase] alice up");
|
| 518 |
|
| 519 |
// Wipe the shared Yjs doc once on Alice's session BEFORE Bob /
|
|
|
|
| 523 |
console.log("[showcase] resetting shared document");
|
| 524 |
await resetDoc(alice.page);
|
| 525 |
|
| 526 |
+
// Bob and Carol connect as Node-side Yjs peers instead of
|
| 527 |
+
// full Chromium browsers. They appear in Alice's editor via
|
| 528 |
+
// normal collaboration sync (authors list, text edits,
|
| 529 |
+
// citations) without any DOM typing or PM position race.
|
| 530 |
+
// URL transformation: http:// -> ws:// on the /collab path.
|
| 531 |
+
const wsUrl = args.url.replace(/^http/, "ws") + "/collab";
|
| 532 |
+
const bob = await connectPersona({
|
| 533 |
+
url: wsUrl,
|
| 534 |
+
docName: "default",
|
| 535 |
+
user: { name: PERSONAS.bob.name, color: PERSONAS.bob.color },
|
| 536 |
});
|
| 537 |
+
yjsPeers.push(bob);
|
| 538 |
+
console.log("[showcase] bob up (yjs peer)");
|
| 539 |
|
| 540 |
+
const carol = await connectPersona({
|
| 541 |
+
url: wsUrl,
|
| 542 |
+
docName: "default",
|
| 543 |
+
user: { name: PERSONAS.carol.name, color: PERSONAS.carol.color },
|
| 544 |
});
|
| 545 |
+
yjsPeers.push(carol);
|
| 546 |
+
console.log("[showcase] carol up (yjs peer)");
|
| 547 |
|
| 548 |
// Auto screen recording takes precedence over the manual
|
| 549 |
// countdown: if --capture is on, we start `screencapture -v`
|
|
|
|
| 584 |
const alicePromise = runAlice(alice.page, args.speed)
|
| 585 |
.then(() => stamp("alice done"))
|
| 586 |
.catch((err) => console.error("[alice] error:", err));
|
| 587 |
+
const bobPromise = runBob(bob, args.speed)
|
| 588 |
.then(() => stamp("bob done"))
|
| 589 |
.catch((err) => console.error("[bob] error:", err));
|
| 590 |
+
const carolPromise = runCarol(carol, args.speed)
|
| 591 |
.then(() => stamp("carol done"))
|
| 592 |
.catch((err) => console.error("[carol] error:", err));
|
| 593 |
|
| 594 |
await Promise.all([bobPromise, carolPromise]);
|
| 595 |
|
| 596 |
+
// Wait for Alice too: her rephrase typewriter + slot 2 wrap-up
|
| 597 |
+
// keeps writing AFTER Bob/Carol finish. Running verify before
|
| 598 |
+
// Alice is done used to show phantom "missing fragments" simply
|
| 599 |
+
// because the rephrase animation had not landed yet.
|
| 600 |
+
await alicePromise;
|
| 601 |
+
stamp("all personas finished");
|
| 602 |
+
|
| 603 |
+
// Post-parallel doc verification: read the shared Y.XmlFragment
|
| 604 |
+
// directly from Bob's still-connected Yjs peer. Alice's browser
|
| 605 |
+
// has navigated to the published output by this point, but the
|
| 606 |
+
// Yjs provider keeps syncing regardless of which page is on
|
| 607 |
+
// screen, so Bob's handle always reflects the final CRDT state.
|
| 608 |
try {
|
| 609 |
+
await verifyFinalDoc(bob);
|
| 610 |
} catch (err) {
|
| 611 |
console.warn(
|
| 612 |
"[verify] aborted:",
|
|
|
|
| 614 |
);
|
| 615 |
}
|
| 616 |
|
|
|
|
|
|
|
|
|
|
| 617 |
// Block on the screencapture timer so the user gets a complete,
|
| 618 |
// playable .mov. Extra time after "all personas finished"
|
| 619 |
// looks fine in the recording (final state of the editor,
|
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Standalone test for the Yjs-based peer client.
|
| 3 |
+
*
|
| 4 |
+
* Run with the dev backend + frontend running, and preferably with
|
| 5 |
+
* an Alice browser tab also open so you can watch the edits land
|
| 6 |
+
* live on her screen.
|
| 7 |
+
*
|
| 8 |
+
* Usage:
|
| 9 |
+
* npx tsx demo/test-yjs-client.ts
|
| 10 |
+
*
|
| 11 |
+
* What it does:
|
| 12 |
+
* - connects as a Yjs peer with name "Probe" ✔ visible in Alice's byline
|
| 13 |
+
* - adds an author "Probe Author" tied to a new affiliation
|
| 14 |
+
* - creates a heading "Yjs probe section" + an empty slot
|
| 15 |
+
* - types a paragraph with inline math atoms + a citation chip
|
| 16 |
+
* - registers the citation entry in the shared citations map
|
| 17 |
+
* - prints the resulting section structure
|
| 18 |
+
* - disconnects
|
| 19 |
+
*/
|
| 20 |
+
import {
|
| 21 |
+
addAuthorRecord,
|
| 22 |
+
appendInline,
|
| 23 |
+
buildEmptyParagraph,
|
| 24 |
+
connectPersona,
|
| 25 |
+
insertInlineNode,
|
| 26 |
+
registerCitation,
|
| 27 |
+
typeIntoParagraph,
|
| 28 |
+
} from "./lib/yjs-client.js";
|
| 29 |
+
import * as Y from "yjs";
|
| 30 |
+
|
| 31 |
+
async function main() {
|
| 32 |
+
console.log("connecting to ws://localhost:5678/collab ...");
|
| 33 |
+
const probe = await connectPersona({
|
| 34 |
+
url: "ws://localhost:5678/collab",
|
| 35 |
+
docName: "default",
|
| 36 |
+
user: { name: "Probe", color: "#ff8800" },
|
| 37 |
+
});
|
| 38 |
+
console.log("synced");
|
| 39 |
+
|
| 40 |
+
// Author push
|
| 41 |
+
addAuthorRecord(probe, {
|
| 42 |
+
name: "Probe Author",
|
| 43 |
+
newAffiliation: { name: "Test Lab" },
|
| 44 |
+
});
|
| 45 |
+
console.log("author pushed, total authors:", probe.frontmatter.authors.length);
|
| 46 |
+
|
| 47 |
+
// Write a probe section at the end of the doc. Elements are
|
| 48 |
+
// created detached and attached in one transaction so the text
|
| 49 |
+
// child can be inserted from inside the attached heading.
|
| 50 |
+
const heading = new Y.XmlElement("heading");
|
| 51 |
+
heading.setAttribute("level", 2);
|
| 52 |
+
const paragraph = buildEmptyParagraph();
|
| 53 |
+
probe.ydoc.transact(() => {
|
| 54 |
+
probe.fragment.push([heading, paragraph]);
|
| 55 |
+
const headingText = new Y.XmlText();
|
| 56 |
+
heading.push([headingText]);
|
| 57 |
+
headingText.insert(0, "Yjs probe section");
|
| 58 |
+
});
|
| 59 |
+
console.log("heading + empty paragraph pushed");
|
| 60 |
+
|
| 61 |
+
await typeIntoParagraph(
|
| 62 |
+
paragraph,
|
| 63 |
+
"Typed via a Node-side Yjs peer: text, math ",
|
| 64 |
+
{ cps: 30 },
|
| 65 |
+
);
|
| 66 |
+
await insertInlineNode(paragraph, { type: "inlineMath", latex: "e^{i\\pi}" }, 120);
|
| 67 |
+
await typeIntoParagraph(paragraph, ", and a citation chip ", { cps: 30 });
|
| 68 |
+
|
| 69 |
+
registerCitation(probe, "probe2026yjs", {
|
| 70 |
+
id: "probe2026yjs",
|
| 71 |
+
"citation-key": "probe2026yjs",
|
| 72 |
+
type: "article-journal",
|
| 73 |
+
title: "Probing Yjs via a Node peer",
|
| 74 |
+
author: [{ family: "Probe", given: "Test" }],
|
| 75 |
+
issued: { "date-parts": [[2026]] },
|
| 76 |
+
});
|
| 77 |
+
appendInline(paragraph, [
|
| 78 |
+
{
|
| 79 |
+
type: "citation",
|
| 80 |
+
key: "probe2026yjs",
|
| 81 |
+
label: "(Probe, 2026)",
|
| 82 |
+
},
|
| 83 |
+
]);
|
| 84 |
+
await typeIntoParagraph(paragraph, ".", { cps: 30 });
|
| 85 |
+
|
| 86 |
+
// Dump the probe section back out as plain text
|
| 87 |
+
console.log("\nfinal paragraph text:");
|
| 88 |
+
const walk = (el: Y.XmlElement | Y.XmlText): string => {
|
| 89 |
+
if (el instanceof Y.XmlText) return el.toString();
|
| 90 |
+
if (el.nodeName === "inlineMath") return `$${el.getAttribute("latex")}$`;
|
| 91 |
+
if (el.nodeName === "citation") return `[${el.getAttribute("label")}]`;
|
| 92 |
+
return el
|
| 93 |
+
.toArray()
|
| 94 |
+
.map((c) => walk(c as Y.XmlElement | Y.XmlText))
|
| 95 |
+
.join("");
|
| 96 |
+
};
|
| 97 |
+
console.log(
|
| 98 |
+
paragraph
|
| 99 |
+
.toArray()
|
| 100 |
+
.map((c) => walk(c as Y.XmlElement | Y.XmlText))
|
| 101 |
+
.join(""),
|
| 102 |
+
);
|
| 103 |
+
|
| 104 |
+
console.log("\nclosing...");
|
| 105 |
+
probe.destroy();
|
| 106 |
+
process.exit(0);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
main().catch((err) => {
|
| 110 |
+
console.error("probe failed:", err);
|
| 111 |
+
process.exit(1);
|
| 112 |
+
});
|
|
@@ -15,6 +15,7 @@
|
|
| 15 |
"@citation-js/plugin-csl": "^0.7.22",
|
| 16 |
"@citation-js/plugin-doi": "^0.7.21",
|
| 17 |
"@hocuspocus/extension-database": "^3.4.4",
|
|
|
|
| 18 |
"@hocuspocus/server": "^3.4.4",
|
| 19 |
"@hocuspocus/transformer": "^3.4.4",
|
| 20 |
"@huggingface/hub": "^2.11.0",
|
|
@@ -689,6 +690,31 @@
|
|
| 689 |
"yjs": "^13.6.8"
|
| 690 |
}
|
| 691 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
"node_modules/@hocuspocus/server": {
|
| 693 |
"version": "3.4.4",
|
| 694 |
"resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-3.4.4.tgz",
|
|
@@ -753,6 +779,12 @@
|
|
| 753 |
"dev": true,
|
| 754 |
"license": "MIT"
|
| 755 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 756 |
"node_modules/@napi-rs/wasm-runtime": {
|
| 757 |
"version": "1.1.3",
|
| 758 |
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
|
@@ -1313,7 +1345,6 @@
|
|
| 1313 |
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.3.tgz",
|
| 1314 |
"integrity": "sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==",
|
| 1315 |
"license": "MIT",
|
| 1316 |
-
"peer": true,
|
| 1317 |
"funding": {
|
| 1318 |
"type": "github",
|
| 1319 |
"url": "https://github.com/sponsors/ueberdosis"
|
|
@@ -3216,7 +3247,6 @@
|
|
| 3216 |
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
| 3217 |
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
| 3218 |
"license": "BSD-3-Clause",
|
| 3219 |
-
"peer": true,
|
| 3220 |
"engines": {
|
| 3221 |
"node": ">=12.0.0"
|
| 3222 |
}
|
|
@@ -3701,7 +3731,6 @@
|
|
| 3701 |
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
| 3702 |
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
|
| 3703 |
"license": "MIT",
|
| 3704 |
-
"peer": true,
|
| 3705 |
"dependencies": {
|
| 3706 |
"@types/hast": "^3.0.0",
|
| 3707 |
"devlop": "^1.0.0",
|
|
|
|
| 15 |
"@citation-js/plugin-csl": "^0.7.22",
|
| 16 |
"@citation-js/plugin-doi": "^0.7.21",
|
| 17 |
"@hocuspocus/extension-database": "^3.4.4",
|
| 18 |
+
"@hocuspocus/provider": "^4.0.0",
|
| 19 |
"@hocuspocus/server": "^3.4.4",
|
| 20 |
"@hocuspocus/transformer": "^3.4.4",
|
| 21 |
"@huggingface/hub": "^2.11.0",
|
|
|
|
| 690 |
"yjs": "^13.6.8"
|
| 691 |
}
|
| 692 |
},
|
| 693 |
+
"node_modules/@hocuspocus/provider": {
|
| 694 |
+
"version": "4.0.0",
|
| 695 |
+
"resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-4.0.0.tgz",
|
| 696 |
+
"integrity": "sha512-08gpeNZ6x2pmRD6m4XwRD52yQKnTl32a0HS9VSXZ5A1dIBVqxMz/x8Z06XbkKM2X8sp6vWEUCZCtzAGFSsofgg==",
|
| 697 |
+
"license": "MIT",
|
| 698 |
+
"dependencies": {
|
| 699 |
+
"@hocuspocus/common": "^4.0.0",
|
| 700 |
+
"@lifeomic/attempt": "^3.0.2",
|
| 701 |
+
"lib0": "^0.2.87",
|
| 702 |
+
"ws": "^8.17.1"
|
| 703 |
+
},
|
| 704 |
+
"peerDependencies": {
|
| 705 |
+
"y-protocols": "^1.0.6",
|
| 706 |
+
"yjs": "^13.6.8"
|
| 707 |
+
}
|
| 708 |
+
},
|
| 709 |
+
"node_modules/@hocuspocus/provider/node_modules/@hocuspocus/common": {
|
| 710 |
+
"version": "4.0.0",
|
| 711 |
+
"resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-4.0.0.tgz",
|
| 712 |
+
"integrity": "sha512-7BE8TsKBkdiOZO6tfm3ny6bIHPbxkIZb3hsYdVn/X5xbXI8n8w9pnE6pXgEMKQhJm6zsWsa9IDRJIp/c9u+DmA==",
|
| 713 |
+
"license": "MIT",
|
| 714 |
+
"dependencies": {
|
| 715 |
+
"lib0": "^0.2.87"
|
| 716 |
+
}
|
| 717 |
+
},
|
| 718 |
"node_modules/@hocuspocus/server": {
|
| 719 |
"version": "3.4.4",
|
| 720 |
"resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-3.4.4.tgz",
|
|
|
|
| 779 |
"dev": true,
|
| 780 |
"license": "MIT"
|
| 781 |
},
|
| 782 |
+
"node_modules/@lifeomic/attempt": {
|
| 783 |
+
"version": "3.1.0",
|
| 784 |
+
"resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz",
|
| 785 |
+
"integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==",
|
| 786 |
+
"license": "MIT"
|
| 787 |
+
},
|
| 788 |
"node_modules/@napi-rs/wasm-runtime": {
|
| 789 |
"version": "1.1.3",
|
| 790 |
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
|
|
|
| 1345 |
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.3.tgz",
|
| 1346 |
"integrity": "sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==",
|
| 1347 |
"license": "MIT",
|
|
|
|
| 1348 |
"funding": {
|
| 1349 |
"type": "github",
|
| 1350 |
"url": "https://github.com/sponsors/ueberdosis"
|
|
|
|
| 3247 |
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
| 3248 |
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
| 3249 |
"license": "BSD-3-Clause",
|
|
|
|
| 3250 |
"engines": {
|
| 3251 |
"node": ">=12.0.0"
|
| 3252 |
}
|
|
|
|
| 3731 |
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
| 3732 |
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
|
| 3733 |
"license": "MIT",
|
|
|
|
| 3734 |
"dependencies": {
|
| 3735 |
"@types/hast": "^3.0.0",
|
| 3736 |
"devlop": "^1.0.0",
|
|
@@ -19,6 +19,7 @@
|
|
| 19 |
"@citation-js/plugin-csl": "^0.7.22",
|
| 20 |
"@citation-js/plugin-doi": "^0.7.21",
|
| 21 |
"@hocuspocus/extension-database": "^3.4.4",
|
|
|
|
| 22 |
"@hocuspocus/server": "^3.4.4",
|
| 23 |
"@hocuspocus/transformer": "^3.4.4",
|
| 24 |
"@huggingface/hub": "^2.11.0",
|
|
|
|
| 19 |
"@citation-js/plugin-csl": "^0.7.22",
|
| 20 |
"@citation-js/plugin-doi": "^0.7.21",
|
| 21 |
"@hocuspocus/extension-database": "^3.4.4",
|
| 22 |
+
"@hocuspocus/provider": "^4.0.0",
|
| 23 |
"@hocuspocus/server": "^3.4.4",
|
| 24 |
"@hocuspocus/transformer": "^3.4.4",
|
| 25 |
"@huggingface/hub": "^2.11.0",
|
|
@@ -318,6 +318,27 @@ export default function App() {
|
|
| 318 |
* textContent does NOT match the `from` plain-string.
|
| 319 |
*/
|
| 320 |
range?: { from: number; to: number };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
};
|
| 322 |
/**
|
| 323 |
* Apply a formatting mark (bold/italic/strike/code) to an
|
|
@@ -350,6 +371,8 @@ export default function App() {
|
|
| 350 |
const target = detail.replace.from;
|
| 351 |
const replacement = detail.replace.to;
|
| 352 |
const explicitRange = detail.replace.range;
|
|
|
|
|
|
|
| 353 |
|
| 354 |
// Resolve the target range AT DISPATCH TIME, not here. We used
|
| 355 |
// to do the string search on event receipt, cache from/to,
|
|
@@ -447,8 +470,36 @@ export default function App() {
|
|
| 447 |
from,
|
| 448 |
to,
|
| 449 |
text: replacement,
|
| 450 |
-
animation
|
| 451 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
}, 520);
|
| 453 |
}
|
| 454 |
|
|
|
|
| 318 |
* textContent does NOT match the `from` plain-string.
|
| 319 |
*/
|
| 320 |
range?: { from: number; to: number };
|
| 321 |
+
/**
|
| 322 |
+
* "none" -> atomic replace in one transaction. The
|
| 323 |
+
* viewer sees the new phrase "snap in",
|
| 324 |
+
* unambiguously an AI edit.
|
| 325 |
+
* "typewriter" -> stream chars at human cadence. Looks
|
| 326 |
+
* like the owner is typing (which is
|
| 327 |
+
* NOT what we want for an agent action -
|
| 328 |
+
* use "none" for rephrase actions so
|
| 329 |
+
* they don't look like user input).
|
| 330 |
+
* Default: "typewriter" for backwards compatibility.
|
| 331 |
+
*/
|
| 332 |
+
animation?: "none" | "typewriter";
|
| 333 |
+
/**
|
| 334 |
+
* Keep an AgentFocus decoration on the replacement
|
| 335 |
+
* range for `lingerAgentFocusMs` ms after the swap so
|
| 336 |
+
* the viewer sees the "<Name> agent" label hover over
|
| 337 |
+
* the new sentence - visual proof the agent owned that
|
| 338 |
+
* edit. Ignored when 0 / undefined (caller will manage
|
| 339 |
+
* focus clearing itself).
|
| 340 |
+
*/
|
| 341 |
+
lingerAgentFocusMs?: number;
|
| 342 |
};
|
| 343 |
/**
|
| 344 |
* Apply a formatting mark (bold/italic/strike/code) to an
|
|
|
|
| 371 |
const target = detail.replace.from;
|
| 372 |
const replacement = detail.replace.to;
|
| 373 |
const explicitRange = detail.replace.range;
|
| 374 |
+
const animation = detail.replace.animation ?? "typewriter";
|
| 375 |
+
const lingerAgentFocusMs = detail.replace.lingerAgentFocusMs ?? 0;
|
| 376 |
|
| 377 |
// Resolve the target range AT DISPATCH TIME, not here. We used
|
| 378 |
// to do the string search on event receipt, cache from/to,
|
|
|
|
| 470 |
from,
|
| 471 |
to,
|
| 472 |
text: replacement,
|
| 473 |
+
animation,
|
| 474 |
});
|
| 475 |
+
|
| 476 |
+
// Re-seed the AgentFocus on the REPLACEMENT range after the
|
| 477 |
+
// swap so the "<Name> agent" label keeps hovering over the
|
| 478 |
+
// new sentence - otherwise the decoration collapses during
|
| 479 |
+
// the delete-then-insert, and viewers perceive the new
|
| 480 |
+
// phrase as user typing instead of an agent edit. For the
|
| 481 |
+
// atomic "none" animation the new text is present
|
| 482 |
+
// immediately; for the typewriter we wait long enough for
|
| 483 |
+
// the first chunk to land so PM positions are stable.
|
| 484 |
+
if (lingerAgentFocusMs > 0) {
|
| 485 |
+
const seedDelay = animation === "none" ? 0 : 80;
|
| 486 |
+
window.setTimeout(() => {
|
| 487 |
+
const live = editorRef.current;
|
| 488 |
+
if (!live) return;
|
| 489 |
+
const newFrom = from;
|
| 490 |
+
const newTo = from + replacement.length;
|
| 491 |
+
const size = live.state.doc.content.size;
|
| 492 |
+
const a = Math.max(0, Math.min(newFrom, size));
|
| 493 |
+
const b = Math.max(0, Math.min(newTo, size));
|
| 494 |
+
if (b > a) {
|
| 495 |
+
live.commands.setAgentFocus({ from: a, to: b });
|
| 496 |
+
}
|
| 497 |
+
window.setTimeout(() => {
|
| 498 |
+
const again = editorRef.current;
|
| 499 |
+
again?.commands.clearAgentFocus();
|
| 500 |
+
}, lingerAgentFocusMs);
|
| 501 |
+
}, seedDelay);
|
| 502 |
+
}
|
| 503 |
}, 520);
|
| 504 |
}
|
| 505 |
|
|
@@ -1,5 +1,12 @@
|
|
| 1 |
-
import { forwardRef } from "react";
|
| 2 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export type PublishState = "idle" | "loading" | "success" | "error";
|
| 5 |
|
|
@@ -55,6 +62,22 @@ const STAGE_LABELS: Record<string, string> = {
|
|
| 55 |
upload: "Uploading to storage",
|
| 56 |
};
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
function formatStage(event: PublishStageEvent | null): string {
|
| 59 |
if (!event) return "Publishing...";
|
| 60 |
const base = STAGE_LABELS[event.stage] ?? "Publishing...";
|
|
@@ -64,6 +87,28 @@ function formatStage(event: PublishStageEvent | null): string {
|
|
| 64 |
return base;
|
| 65 |
}
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
/**
|
| 68 |
* Native `<dialog>` shown when the user clicks "Publish" in the top bar.
|
| 69 |
* Displays live stage progress during the long PDF pipeline (typically
|
|
@@ -75,67 +120,154 @@ export const PublishDialog = forwardRef<HTMLDialogElement, Props>(function Publi
|
|
| 75 |
) {
|
| 76 |
const stageLabel = formatStage(stageEvent);
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
return (
|
| 79 |
-
<dialog
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
)}
|
|
|
|
| 92 |
{state === "loading" && (
|
| 93 |
-
<div
|
| 94 |
-
<div
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
</div>
|
| 98 |
-
<p
|
| 99 |
-
Rasterizing embeds and rendering
|
| 100 |
-
|
| 101 |
</p>
|
| 102 |
</div>
|
| 103 |
)}
|
|
|
|
| 104 |
{state === "success" && (
|
| 105 |
-
<>
|
| 106 |
-
<
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
</
|
| 110 |
<a
|
| 111 |
-
href={
|
| 112 |
target="_blank"
|
| 113 |
rel="noopener noreferrer"
|
| 114 |
-
className="btn btn--primary"
|
| 115 |
-
style={{ display: "inline-flex", alignItems: "center", gap: 6, textDecoration: "none", marginTop: 4 }}
|
| 116 |
>
|
| 117 |
<Eye size={16} />
|
| 118 |
View published article
|
|
|
|
| 119 |
</a>
|
| 120 |
-
</>
|
| 121 |
)}
|
|
|
|
| 122 |
{state === "error" && (
|
| 123 |
-
<p
|
| 124 |
{error || "An error occurred while publishing."}
|
| 125 |
</p>
|
| 126 |
)}
|
| 127 |
</div>
|
| 128 |
-
|
|
|
|
| 129 |
{state === "idle" && (
|
| 130 |
<>
|
| 131 |
<button className="btn" onClick={onClose}>Cancel</button>
|
| 132 |
-
<button className="btn btn--primary" onClick={onPublish}>
|
|
|
|
|
|
|
|
|
|
| 133 |
</>
|
| 134 |
)}
|
| 135 |
{(state === "success" || state === "error") && (
|
| 136 |
<button className="btn" onClick={onClose}>Close</button>
|
| 137 |
)}
|
| 138 |
-
</
|
| 139 |
</dialog>
|
| 140 |
);
|
| 141 |
});
|
|
|
|
| 1 |
+
import { forwardRef, useEffect, useRef, useState } from "react";
|
| 2 |
+
import {
|
| 3 |
+
Eye,
|
| 4 |
+
UploadCloud,
|
| 5 |
+
CheckCircle2,
|
| 6 |
+
AlertTriangle,
|
| 7 |
+
Sparkles,
|
| 8 |
+
ExternalLink,
|
| 9 |
+
} from "lucide-react";
|
| 10 |
|
| 11 |
export type PublishState = "idle" | "loading" | "success" | "error";
|
| 12 |
|
|
|
|
| 62 |
upload: "Uploading to storage",
|
| 63 |
};
|
| 64 |
|
| 65 |
+
// Canonical order used to compute a progress ratio. Stages not in
|
| 66 |
+
// this list still render their label but don't retract the bar.
|
| 67 |
+
const STAGE_ORDER: PublishStage[] = [
|
| 68 |
+
"extract",
|
| 69 |
+
"bibliography",
|
| 70 |
+
"render",
|
| 71 |
+
"load",
|
| 72 |
+
"thumbnail",
|
| 73 |
+
"pdf-setup",
|
| 74 |
+
"pdf-toc",
|
| 75 |
+
"rasterize",
|
| 76 |
+
"pdf-render",
|
| 77 |
+
"write",
|
| 78 |
+
"upload",
|
| 79 |
+
];
|
| 80 |
+
|
| 81 |
function formatStage(event: PublishStageEvent | null): string {
|
| 82 |
if (!event) return "Publishing...";
|
| 83 |
const base = STAGE_LABELS[event.stage] ?? "Publishing...";
|
|
|
|
| 87 |
return base;
|
| 88 |
}
|
| 89 |
|
| 90 |
+
/**
|
| 91 |
+
* Compute a 0..1 progress ratio for `event`, blending rasterize
|
| 92 |
+
* sub-progress. Returns `null` for unknown stages so the caller can
|
| 93 |
+
* hold the previous value instead of retracting the bar.
|
| 94 |
+
*/
|
| 95 |
+
function computeProgress(event: PublishStageEvent | null): number | null {
|
| 96 |
+
if (!event) return 0.04;
|
| 97 |
+
const idx = STAGE_ORDER.indexOf(event.stage);
|
| 98 |
+
if (idx === -1) return null;
|
| 99 |
+
const slots = STAGE_ORDER.length + 1;
|
| 100 |
+
let base = (idx + 1) / slots;
|
| 101 |
+
if (
|
| 102 |
+
event.stage === "rasterize" &&
|
| 103 |
+
event.detail?.index &&
|
| 104 |
+
event.detail?.total
|
| 105 |
+
) {
|
| 106 |
+
const rasterFrac = Math.min(1, event.detail.index / event.detail.total);
|
| 107 |
+
base = idx / slots + rasterFrac / slots;
|
| 108 |
+
}
|
| 109 |
+
return Math.max(0.04, Math.min(0.98, base));
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
/**
|
| 113 |
* Native `<dialog>` shown when the user clicks "Publish" in the top bar.
|
| 114 |
* Displays live stage progress during the long PDF pipeline (typically
|
|
|
|
| 120 |
) {
|
| 121 |
const stageLabel = formatStage(stageEvent);
|
| 122 |
|
| 123 |
+
// Monotonic progress. The backend can emit unknown stages or
|
| 124 |
+
// re-emit earlier ones during retries; the bar should never walk
|
| 125 |
+
// backwards on camera.
|
| 126 |
+
const progressRef = useRef<number>(0.04);
|
| 127 |
+
const [progress, setProgress] = useState<number>(0.04);
|
| 128 |
+
useEffect(() => {
|
| 129 |
+
if (state === "idle") {
|
| 130 |
+
progressRef.current = 0.04;
|
| 131 |
+
setProgress(0.04);
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
if (state === "success") {
|
| 135 |
+
progressRef.current = 1;
|
| 136 |
+
setProgress(1);
|
| 137 |
+
return;
|
| 138 |
+
}
|
| 139 |
+
if (state === "error") return;
|
| 140 |
+
const next = computeProgress(stageEvent);
|
| 141 |
+
if (next == null) return;
|
| 142 |
+
if (next > progressRef.current) {
|
| 143 |
+
progressRef.current = next;
|
| 144 |
+
setProgress(next);
|
| 145 |
+
}
|
| 146 |
+
}, [stageEvent, state]);
|
| 147 |
+
|
| 148 |
+
const publishedUrl = `/published/${docName}/index.html`;
|
| 149 |
+
const displayUrl = publishedUrl.replace(/\/index\.html$/, "");
|
| 150 |
+
|
| 151 |
return (
|
| 152 |
+
<dialog
|
| 153 |
+
ref={ref}
|
| 154 |
+
className={`ed-dialog ed-dialog--publish ed-dialog--publish-${state}`}
|
| 155 |
+
onClose={onClose}
|
| 156 |
+
>
|
| 157 |
+
<header className="publish-dialog__header">
|
| 158 |
+
<div className={`publish-dialog__icon publish-dialog__icon--${state}`}>
|
| 159 |
+
{state === "idle" && <UploadCloud size={22} strokeWidth={1.75} />}
|
| 160 |
+
{state === "loading" && <span className="spinner" aria-hidden />}
|
| 161 |
+
{state === "success" && <CheckCircle2 size={22} strokeWidth={1.75} />}
|
| 162 |
+
{state === "error" && <AlertTriangle size={22} strokeWidth={1.75} />}
|
| 163 |
+
</div>
|
| 164 |
+
<div className="publish-dialog__heading">
|
| 165 |
+
<h2 className="publish-dialog__title">
|
| 166 |
+
{state === "idle" && "Publish article"}
|
| 167 |
+
{state === "loading" && "Publishing your article"}
|
| 168 |
+
{state === "success" && "Published!"}
|
| 169 |
+
{state === "error" && "Publish failed"}
|
| 170 |
+
</h2>
|
| 171 |
+
<p className="publish-dialog__subtitle">
|
| 172 |
+
{state === "idle" && "Static HTML, print-ready PDF and thumbnail."}
|
| 173 |
+
{state === "loading" && stageLabel}
|
| 174 |
+
{state === "success" && "Your update is live for every reader."}
|
| 175 |
+
{state === "error" && "We couldn't finish the publish run."}
|
| 176 |
</p>
|
| 177 |
+
</div>
|
| 178 |
+
</header>
|
| 179 |
+
|
| 180 |
+
<div className="publish-dialog__body">
|
| 181 |
+
{state === "idle" && (
|
| 182 |
+
<div className="publish-dialog__intro">
|
| 183 |
+
<ul className="publish-dialog__checklist">
|
| 184 |
+
<li>
|
| 185 |
+
<span className="publish-dialog__bullet" aria-hidden />
|
| 186 |
+
<span>
|
| 187 |
+
<strong>Static HTML</strong> page ready to share.
|
| 188 |
+
</span>
|
| 189 |
+
</li>
|
| 190 |
+
<li>
|
| 191 |
+
<span className="publish-dialog__bullet" aria-hidden />
|
| 192 |
+
<span>
|
| 193 |
+
<strong>Print-ready PDF</strong> with full typography.
|
| 194 |
+
</span>
|
| 195 |
+
</li>
|
| 196 |
+
<li>
|
| 197 |
+
<span className="publish-dialog__bullet" aria-hidden />
|
| 198 |
+
<span>
|
| 199 |
+
<strong>Social thumbnail</strong> captured automatically.
|
| 200 |
+
</span>
|
| 201 |
+
</li>
|
| 202 |
+
</ul>
|
| 203 |
+
<p className="publish-dialog__note">
|
| 204 |
+
<Sparkles size={12} strokeWidth={2} aria-hidden />
|
| 205 |
+
Takes 15-25s. Collaborators can keep editing in the meantime.
|
| 206 |
+
</p>
|
| 207 |
+
</div>
|
| 208 |
)}
|
| 209 |
+
|
| 210 |
{state === "loading" && (
|
| 211 |
+
<div className="publish-dialog__progress">
|
| 212 |
+
<div
|
| 213 |
+
className="publish-dialog__bar"
|
| 214 |
+
role="progressbar"
|
| 215 |
+
aria-valuemin={0}
|
| 216 |
+
aria-valuemax={100}
|
| 217 |
+
aria-valuenow={Math.round(progress * 100)}
|
| 218 |
+
>
|
| 219 |
+
<span
|
| 220 |
+
className="publish-dialog__bar-fill"
|
| 221 |
+
style={{ width: `${Math.round(progress * 100)}%` }}
|
| 222 |
+
/>
|
| 223 |
</div>
|
| 224 |
+
<p className="publish-dialog__hint">
|
| 225 |
+
Rasterizing embeds and rendering the PDF can take a moment. Keep
|
| 226 |
+
this tab open until it finishes.
|
| 227 |
</p>
|
| 228 |
</div>
|
| 229 |
)}
|
| 230 |
+
|
| 231 |
{state === "success" && (
|
| 232 |
+
<div className="publish-dialog__success">
|
| 233 |
+
<div className="publish-dialog__success-url">
|
| 234 |
+
<span className="publish-dialog__url-label">Live at</span>
|
| 235 |
+
<code className="publish-dialog__url-code">{displayUrl}</code>
|
| 236 |
+
</div>
|
| 237 |
<a
|
| 238 |
+
href={publishedUrl}
|
| 239 |
target="_blank"
|
| 240 |
rel="noopener noreferrer"
|
| 241 |
+
className="btn btn--primary publish-dialog__view-btn"
|
|
|
|
| 242 |
>
|
| 243 |
<Eye size={16} />
|
| 244 |
View published article
|
| 245 |
+
<ExternalLink size={13} strokeWidth={2} aria-hidden />
|
| 246 |
</a>
|
| 247 |
+
</div>
|
| 248 |
)}
|
| 249 |
+
|
| 250 |
{state === "error" && (
|
| 251 |
+
<p className="publish-dialog__error">
|
| 252 |
{error || "An error occurred while publishing."}
|
| 253 |
</p>
|
| 254 |
)}
|
| 255 |
</div>
|
| 256 |
+
|
| 257 |
+
<footer className="publish-dialog__actions">
|
| 258 |
{state === "idle" && (
|
| 259 |
<>
|
| 260 |
<button className="btn" onClick={onClose}>Cancel</button>
|
| 261 |
+
<button className="btn btn--primary" onClick={onPublish}>
|
| 262 |
+
<UploadCloud size={14} strokeWidth={2} aria-hidden />
|
| 263 |
+
Publish
|
| 264 |
+
</button>
|
| 265 |
</>
|
| 266 |
)}
|
| 267 |
{(state === "success" || state === "error") && (
|
| 268 |
<button className="btn" onClick={onClose}>Close</button>
|
| 269 |
)}
|
| 270 |
+
</footer>
|
| 271 |
</dialog>
|
| 272 |
);
|
| 273 |
});
|
|
@@ -164,13 +164,55 @@ export const AgentRewrite = Extension.create({
|
|
| 164 |
|
| 165 |
agentRewriteRange:
|
| 166 |
(args) =>
|
| 167 |
-
({
|
| 168 |
const docSize = state.doc.content.size;
|
| 169 |
const a = Math.max(0, Math.min(args.from, docSize));
|
| 170 |
const b = Math.max(0, Math.min(args.to, docSize));
|
| 171 |
if (b < a) return false;
|
| 172 |
if (!dispatch) return true;
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
// Step 1: single atomic transaction that (a) cancels any
|
| 175 |
// in-flight rewrite, (b) deletes the target range, and (c)
|
| 176 |
// plants the insertion cursor at `a`. Done in one tr so the
|
|
@@ -183,7 +225,6 @@ export const AgentRewrite = Extension.create({
|
|
| 183 |
if (b > a) tr.delete(a, b);
|
| 184 |
dispatch(tr);
|
| 185 |
|
| 186 |
-
const text = args.text ?? "";
|
| 187 |
if (!text) {
|
| 188 |
// Nothing to insert, just a deletion. Done.
|
| 189 |
view.dispatch(
|
|
@@ -194,38 +235,39 @@ export const AgentRewrite = Extension.create({
|
|
| 194 |
return true;
|
| 195 |
}
|
| 196 |
|
| 197 |
-
const animation = args.animation ?? "none";
|
| 198 |
-
if (animation === "none") {
|
| 199 |
-
// Synchronous, one-shot insert. Single tr -> one undo step.
|
| 200 |
-
const cursor =
|
| 201 |
-
agentRewriteKey.getState(editor.state)?.cursor ?? a;
|
| 202 |
-
view.dispatch(
|
| 203 |
-
view.state.tr
|
| 204 |
-
.insertText(text, cursor)
|
| 205 |
-
.setMeta(agentRewriteKey, {
|
| 206 |
-
setCursor: null,
|
| 207 |
-
} as AgentRewriteMeta),
|
| 208 |
-
);
|
| 209 |
-
return true;
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
// Typewriter: async stream of small insertText trs. Each step
|
| 213 |
// reads the CURRENT cursor from plugin state (mapped through
|
| 214 |
// every intervening tr, including remote Yjs updates), so the
|
| 215 |
// animation is immune to concurrent edits.
|
| 216 |
const [chunkMin, chunkMax] = args.chunkSize ?? [1, 2];
|
| 217 |
const [delayMin, delayMax] = args.chunkMs ?? [22, 48];
|
| 218 |
-
const myToken =
|
| 219 |
-
agentRewriteKey.getState(editor.state)?.cancelToken ?? 0;
|
| 220 |
let i = 0;
|
| 221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
const step = () => {
|
| 223 |
if (view.isDestroyed) return;
|
| 224 |
const ps = agentRewriteKey.getState(view.state);
|
| 225 |
-
|
| 226 |
-
//
|
| 227 |
-
if (ps.cancelToken !== myToken)
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
if (i >= text.length) {
|
| 231 |
// Done: clear the cursor marker so nothing lingers in
|
|
@@ -247,9 +289,11 @@ export const AgentRewrite = Extension.create({
|
|
| 247 |
Math.floor(Math.random() * (maxTake - minTake + 1));
|
| 248 |
const chunk = text.slice(i, i + take);
|
| 249 |
|
| 250 |
-
//
|
| 251 |
-
//
|
| 252 |
-
|
|
|
|
|
|
|
| 253 |
i += chunk.length;
|
| 254 |
|
| 255 |
const delay =
|
|
|
|
| 164 |
|
| 165 |
agentRewriteRange:
|
| 166 |
(args) =>
|
| 167 |
+
({ state, view, tr, dispatch }) => {
|
| 168 |
const docSize = state.doc.content.size;
|
| 169 |
const a = Math.max(0, Math.min(args.from, docSize));
|
| 170 |
const b = Math.max(0, Math.min(args.to, docSize));
|
| 171 |
if (b < a) return false;
|
| 172 |
if (!dispatch) return true;
|
| 173 |
|
| 174 |
+
// Capture `myToken` BEFORE the dispatch. Tiptap's
|
| 175 |
+
// CommandManager turns out to batch the `tr` we mutate here
|
| 176 |
+
// into its own dispatch cycle, so reading `view.state`
|
| 177 |
+
// immediately after calling the provided `dispatch(tr)` can
|
| 178 |
+
// return the PRE-commit state (cancelToken still = N,
|
| 179 |
+
// cursor still = null). The only reliable source is the
|
| 180 |
+
// state we started from (`state`): we know this command
|
| 181 |
+
// will bump cancelToken by 1 and seed the cursor at `a`,
|
| 182 |
+
// so we compute those values ourselves instead of reading
|
| 183 |
+
// them back from a potentially stale view.
|
| 184 |
+
const prevToken = agentRewriteKey.getState(state)?.cancelToken ?? 0;
|
| 185 |
+
const myToken = prevToken + 1;
|
| 186 |
+
|
| 187 |
+
const text = args.text ?? "";
|
| 188 |
+
const animation = args.animation ?? "none";
|
| 189 |
+
|
| 190 |
+
// ----- Atomic path ("none") -----------------------------------
|
| 191 |
+
// Do the whole replace in ONE transaction: cancel any
|
| 192 |
+
// in-flight rewrite, replace [a, b] with `text`, clear the
|
| 193 |
+
// cursor marker. Two separate dispatches produced double
|
| 194 |
+
// content (insert + zombie old text) because Tiptap's
|
| 195 |
+
// CommandManager does NOT update `view.state` between
|
| 196 |
+
// `dispatch(tr)` and the next `view.state.tr.insertText`
|
| 197 |
+
// call inside the same command - so the second tr
|
| 198 |
+
// insertText ran against the PRE-delete state, effectively
|
| 199 |
+
// adding the new phrase in front of the old one without
|
| 200 |
+
// deleting it.
|
| 201 |
+
if (animation === "none") {
|
| 202 |
+
tr.setMeta(agentRewriteKey, {
|
| 203 |
+
cancel: true,
|
| 204 |
+
setCursor: null,
|
| 205 |
+
} as AgentRewriteMeta);
|
| 206 |
+
if (text) {
|
| 207 |
+
tr.insertText(text, a, b);
|
| 208 |
+
} else if (b > a) {
|
| 209 |
+
tr.delete(a, b);
|
| 210 |
+
}
|
| 211 |
+
dispatch(tr);
|
| 212 |
+
return true;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// ----- Typewriter path -----------------------------------
|
| 216 |
// Step 1: single atomic transaction that (a) cancels any
|
| 217 |
// in-flight rewrite, (b) deletes the target range, and (c)
|
| 218 |
// plants the insertion cursor at `a`. Done in one tr so the
|
|
|
|
| 225 |
if (b > a) tr.delete(a, b);
|
| 226 |
dispatch(tr);
|
| 227 |
|
|
|
|
| 228 |
if (!text) {
|
| 229 |
// Nothing to insert, just a deletion. Done.
|
| 230 |
view.dispatch(
|
|
|
|
| 235 |
return true;
|
| 236 |
}
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
// Typewriter: async stream of small insertText trs. Each step
|
| 239 |
// reads the CURRENT cursor from plugin state (mapped through
|
| 240 |
// every intervening tr, including remote Yjs updates), so the
|
| 241 |
// animation is immune to concurrent edits.
|
| 242 |
const [chunkMin, chunkMax] = args.chunkSize ?? [1, 2];
|
| 243 |
const [delayMin, delayMax] = args.chunkMs ?? [22, 48];
|
|
|
|
|
|
|
| 244 |
let i = 0;
|
| 245 |
|
| 246 |
+
// Closure-held cursor is our source of truth: seeded at `a`,
|
| 247 |
+
// then synced with plugin state before each insert so remote
|
| 248 |
+
// Yjs trs between our ticks remap the cursor correctly via
|
| 249 |
+
// the plugin's mapping logic. Using a closure (rather than
|
| 250 |
+
// reading plugin state blindly) guarantees the first step
|
| 251 |
+
// doesn't bail out if Tiptap's command dispatch hasn't yet
|
| 252 |
+
// committed the `setCursor: a` meta to the plugin state that
|
| 253 |
+
// `view.state` exposes.
|
| 254 |
+
let cursor = a;
|
| 255 |
+
|
| 256 |
const step = () => {
|
| 257 |
if (view.isDestroyed) return;
|
| 258 |
const ps = agentRewriteKey.getState(view.state);
|
| 259 |
+
// Cancellation only: token mismatch means another rewrite
|
| 260 |
+
// started (or `cancelAgentRewrite` fired).
|
| 261 |
+
if (ps && ps.cancelToken !== myToken) {
|
| 262 |
+
// Another rewrite started or `cancelAgentRewrite` fired.
|
| 263 |
+
return;
|
| 264 |
+
}
|
| 265 |
+
// Pull the mapped cursor from plugin state if available -
|
| 266 |
+
// this accounts for any remote Yjs inserts that happened
|
| 267 |
+
// since the last step. Fall back to the closure value on
|
| 268 |
+
// the very first tick, where plugin state can still be
|
| 269 |
+
// null due to Tiptap's command dispatch batching.
|
| 270 |
+
if (ps && ps.cursor !== null) cursor = ps.cursor;
|
| 271 |
|
| 272 |
if (i >= text.length) {
|
| 273 |
// Done: clear the cursor marker so nothing lingers in
|
|
|
|
| 289 |
Math.floor(Math.random() * (maxTake - minTake + 1));
|
| 290 |
const chunk = text.slice(i, i + take);
|
| 291 |
|
| 292 |
+
// Insert at our closure-tracked cursor. The plugin's
|
| 293 |
+
// apply() will advance the stored cursor via mapping so
|
| 294 |
+
// subsequent remote trs remap it correctly.
|
| 295 |
+
view.dispatch(view.state.tr.insertText(chunk, cursor));
|
| 296 |
+
cursor += chunk.length;
|
| 297 |
i += chunk.length;
|
| 298 |
|
| 299 |
const delay =
|
|
@@ -214,6 +214,259 @@ dialog.ed-dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
|
|
| 214 |
.ed-dialog__body p { margin: 0; }
|
| 215 |
.ed-dialog__actions { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 20px 16px; }
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
/* ---- Drawer ---- */
|
| 218 |
.drawer-backdrop {
|
| 219 |
position: fixed;
|
|
|
|
| 214 |
.ed-dialog__body p { margin: 0; }
|
| 215 |
.ed-dialog__actions { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 20px 16px; }
|
| 216 |
|
| 217 |
+
/* ---- Publish dialog ---- */
|
| 218 |
+
dialog.ed-dialog.ed-dialog--publish {
|
| 219 |
+
max-width: 460px;
|
| 220 |
+
overflow: hidden;
|
| 221 |
+
box-shadow:
|
| 222 |
+
0 1px 0 rgba(255, 255, 255, 0.04) inset,
|
| 223 |
+
0 24px 48px -12px rgba(0, 0, 0, 0.35),
|
| 224 |
+
0 8px 18px -8px rgba(0, 0, 0, 0.25);
|
| 225 |
+
}
|
| 226 |
+
dialog.ed-dialog.ed-dialog--publish::backdrop {
|
| 227 |
+
background: rgba(10, 10, 12, 0.55);
|
| 228 |
+
backdrop-filter: blur(2px);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.publish-dialog__header {
|
| 232 |
+
display: flex;
|
| 233 |
+
align-items: flex-start;
|
| 234 |
+
gap: 14px;
|
| 235 |
+
padding: 22px 24px 14px;
|
| 236 |
+
position: relative;
|
| 237 |
+
}
|
| 238 |
+
.publish-dialog__header::after {
|
| 239 |
+
content: "";
|
| 240 |
+
position: absolute;
|
| 241 |
+
left: 24px;
|
| 242 |
+
right: 24px;
|
| 243 |
+
bottom: 0;
|
| 244 |
+
height: 1px;
|
| 245 |
+
background: var(--ed-border);
|
| 246 |
+
opacity: 0.6;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.publish-dialog__icon {
|
| 250 |
+
flex-shrink: 0;
|
| 251 |
+
width: 40px;
|
| 252 |
+
height: 40px;
|
| 253 |
+
border-radius: 10px;
|
| 254 |
+
display: inline-flex;
|
| 255 |
+
align-items: center;
|
| 256 |
+
justify-content: center;
|
| 257 |
+
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
|
| 258 |
+
color: var(--primary-color);
|
| 259 |
+
transition: background 0.25s ease, color 0.25s ease;
|
| 260 |
+
}
|
| 261 |
+
.publish-dialog__icon--success {
|
| 262 |
+
background: color-mix(in srgb, var(--ed-success) 16%, transparent);
|
| 263 |
+
color: var(--ed-success);
|
| 264 |
+
}
|
| 265 |
+
.publish-dialog__icon--error {
|
| 266 |
+
background: color-mix(in srgb, var(--ed-error) 16%, transparent);
|
| 267 |
+
color: var(--ed-error);
|
| 268 |
+
}
|
| 269 |
+
.publish-dialog__icon--loading .spinner {
|
| 270 |
+
width: 20px;
|
| 271 |
+
height: 20px;
|
| 272 |
+
border-width: 2px;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.publish-dialog__heading {
|
| 276 |
+
flex: 1;
|
| 277 |
+
display: flex;
|
| 278 |
+
flex-direction: column;
|
| 279 |
+
gap: 2px;
|
| 280 |
+
min-width: 0;
|
| 281 |
+
}
|
| 282 |
+
.publish-dialog__title {
|
| 283 |
+
margin: 0;
|
| 284 |
+
font-size: 1.05rem;
|
| 285 |
+
font-weight: 600;
|
| 286 |
+
letter-spacing: -0.005em;
|
| 287 |
+
color: var(--ed-text);
|
| 288 |
+
}
|
| 289 |
+
.publish-dialog__subtitle {
|
| 290 |
+
margin: 0;
|
| 291 |
+
font-size: 0.85rem;
|
| 292 |
+
color: var(--ed-text-secondary);
|
| 293 |
+
line-height: 1.45;
|
| 294 |
+
/* Stage name can grow during loading; avoid layout jump. */
|
| 295 |
+
min-height: 1.2em;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.publish-dialog__body {
|
| 299 |
+
padding: 18px 24px 6px;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.publish-dialog__intro {
|
| 303 |
+
display: flex;
|
| 304 |
+
flex-direction: column;
|
| 305 |
+
gap: 14px;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.publish-dialog__checklist {
|
| 309 |
+
list-style: none;
|
| 310 |
+
margin: 0;
|
| 311 |
+
padding: 14px 14px;
|
| 312 |
+
display: flex;
|
| 313 |
+
flex-direction: column;
|
| 314 |
+
gap: 10px;
|
| 315 |
+
font-size: 0.88rem;
|
| 316 |
+
color: var(--ed-text);
|
| 317 |
+
border: 1px solid var(--ed-border);
|
| 318 |
+
border-radius: var(--ed-radius-sm);
|
| 319 |
+
background: color-mix(in srgb, var(--primary-color) 5%, transparent);
|
| 320 |
+
}
|
| 321 |
+
.publish-dialog__checklist li {
|
| 322 |
+
display: flex;
|
| 323 |
+
align-items: flex-start;
|
| 324 |
+
gap: 10px;
|
| 325 |
+
line-height: 1.45;
|
| 326 |
+
}
|
| 327 |
+
.publish-dialog__checklist strong {
|
| 328 |
+
color: var(--ed-text);
|
| 329 |
+
font-weight: 600;
|
| 330 |
+
}
|
| 331 |
+
.publish-dialog__bullet {
|
| 332 |
+
flex-shrink: 0;
|
| 333 |
+
margin-top: 7px;
|
| 334 |
+
width: 6px;
|
| 335 |
+
height: 6px;
|
| 336 |
+
border-radius: 50%;
|
| 337 |
+
background: var(--primary-color);
|
| 338 |
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent);
|
| 339 |
+
}
|
| 340 |
+
.publish-dialog__note {
|
| 341 |
+
margin: 0;
|
| 342 |
+
display: inline-flex;
|
| 343 |
+
align-items: center;
|
| 344 |
+
gap: 6px;
|
| 345 |
+
font-size: 0.78rem;
|
| 346 |
+
color: var(--ed-text-disabled);
|
| 347 |
+
padding-left: 2px;
|
| 348 |
+
}
|
| 349 |
+
.publish-dialog__note svg {
|
| 350 |
+
color: var(--primary-color);
|
| 351 |
+
opacity: 0.8;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.publish-dialog__progress {
|
| 355 |
+
display: flex;
|
| 356 |
+
flex-direction: column;
|
| 357 |
+
gap: 12px;
|
| 358 |
+
}
|
| 359 |
+
.publish-dialog__bar {
|
| 360 |
+
position: relative;
|
| 361 |
+
height: 6px;
|
| 362 |
+
border-radius: 999px;
|
| 363 |
+
background: var(--ed-surface-hover);
|
| 364 |
+
overflow: hidden;
|
| 365 |
+
}
|
| 366 |
+
.publish-dialog__bar-fill {
|
| 367 |
+
position: absolute;
|
| 368 |
+
left: 0;
|
| 369 |
+
top: 0;
|
| 370 |
+
bottom: 0;
|
| 371 |
+
border-radius: 999px;
|
| 372 |
+
background: linear-gradient(
|
| 373 |
+
90deg,
|
| 374 |
+
color-mix(in srgb, var(--primary-color) 55%, transparent),
|
| 375 |
+
var(--primary-color)
|
| 376 |
+
);
|
| 377 |
+
transition: width 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
| 378 |
+
box-shadow: 0 0 8px color-mix(in srgb, var(--primary-color) 45%, transparent);
|
| 379 |
+
}
|
| 380 |
+
.publish-dialog__bar-fill::after {
|
| 381 |
+
content: "";
|
| 382 |
+
position: absolute;
|
| 383 |
+
inset: 0;
|
| 384 |
+
background: linear-gradient(
|
| 385 |
+
90deg,
|
| 386 |
+
transparent,
|
| 387 |
+
rgba(255, 255, 255, 0.18),
|
| 388 |
+
transparent
|
| 389 |
+
);
|
| 390 |
+
transform: translateX(-100%);
|
| 391 |
+
animation: publish-shimmer 1.6s ease-in-out infinite;
|
| 392 |
+
}
|
| 393 |
+
@keyframes publish-shimmer {
|
| 394 |
+
to { transform: translateX(100%); }
|
| 395 |
+
}
|
| 396 |
+
.publish-dialog__hint {
|
| 397 |
+
margin: 0;
|
| 398 |
+
font-size: 0.8rem;
|
| 399 |
+
color: var(--ed-text-disabled);
|
| 400 |
+
line-height: 1.5;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.publish-dialog__success {
|
| 404 |
+
display: flex;
|
| 405 |
+
flex-direction: column;
|
| 406 |
+
gap: 14px;
|
| 407 |
+
}
|
| 408 |
+
.publish-dialog__success-url {
|
| 409 |
+
display: flex;
|
| 410 |
+
align-items: center;
|
| 411 |
+
gap: 10px;
|
| 412 |
+
padding: 10px 12px;
|
| 413 |
+
border: 1px solid var(--ed-border);
|
| 414 |
+
border-radius: var(--ed-radius-sm);
|
| 415 |
+
background: color-mix(in srgb, var(--ed-success) 6%, transparent);
|
| 416 |
+
}
|
| 417 |
+
.publish-dialog__url-label {
|
| 418 |
+
font-size: 0.72rem;
|
| 419 |
+
font-weight: 600;
|
| 420 |
+
text-transform: uppercase;
|
| 421 |
+
letter-spacing: 0.04em;
|
| 422 |
+
color: var(--ed-success);
|
| 423 |
+
flex-shrink: 0;
|
| 424 |
+
}
|
| 425 |
+
.publish-dialog__url-code {
|
| 426 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
| 427 |
+
font-size: 0.82rem;
|
| 428 |
+
color: var(--ed-text);
|
| 429 |
+
overflow: hidden;
|
| 430 |
+
text-overflow: ellipsis;
|
| 431 |
+
white-space: nowrap;
|
| 432 |
+
flex: 1;
|
| 433 |
+
min-width: 0;
|
| 434 |
+
background: transparent;
|
| 435 |
+
padding: 0;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.publish-dialog__view-btn {
|
| 439 |
+
display: inline-flex;
|
| 440 |
+
align-items: center;
|
| 441 |
+
justify-content: center;
|
| 442 |
+
gap: 8px;
|
| 443 |
+
text-decoration: none;
|
| 444 |
+
width: 100%;
|
| 445 |
+
padding: 10px 16px;
|
| 446 |
+
font-weight: 600;
|
| 447 |
+
}
|
| 448 |
+
.publish-dialog__view-btn svg:last-of-type {
|
| 449 |
+
opacity: 0.8;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.publish-dialog__error {
|
| 453 |
+
margin: 0;
|
| 454 |
+
padding: 10px 12px;
|
| 455 |
+
border-radius: var(--ed-radius-sm);
|
| 456 |
+
background: color-mix(in srgb, var(--ed-error) 10%, transparent);
|
| 457 |
+
border: 1px solid color-mix(in srgb, var(--ed-error) 30%, transparent);
|
| 458 |
+
color: var(--ed-error);
|
| 459 |
+
font-size: 0.85rem;
|
| 460 |
+
line-height: 1.5;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.publish-dialog__actions {
|
| 464 |
+
display: flex;
|
| 465 |
+
justify-content: flex-end;
|
| 466 |
+
gap: 8px;
|
| 467 |
+
padding: 14px 24px 20px;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
/* ---- Drawer ---- */
|
| 471 |
.drawer-backdrop {
|
| 472 |
position: fixed;
|