lavish-axi 0.1.74 → 0.1.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -181,6 +181,10 @@ pnpm link
181
181
  `Resolved` requires a newer successful artifact load plus a complete check at the same viewport that no longer detects it; it then leaves the count but keeps a bounded history.
182
182
  `Still present` (recurring) means a queued issue survived a newer revision, so it is selectable again with its earlier attempt retained. `Unverified` means a reload or check failed or was incomplete, so the prior issue was preserved rather than cleared. `Returned` means a resolved issue came back on a later revision.
183
183
  Dismissal applies only to the current artifact revision; a later revision surfaces the issue again if it is still detected. A check at one viewport never clears an issue found at another, and a viewport removed from the configured diagnostic set (`LAVISH_AXI_DIAGNOSTIC_VIEWPORTS`, default all) is marked obsolete with an explicit reason rather than reading as fixed.
184
+ - **Revisions legend** - Opt-in. After a round of feedback the agent regenerates the whole file, and a one-sentence edit is invisible on a long artifact. The agent can declare what it changed in the artifact itself: one `<script type="application/json" data-lavish-revisions>` array of `{ id, label, timestamp, summary }` entries, plus `data-lavish-revision="<id>"` on each block it edited or added. Run `lavish-axi design` for the snippet.
185
+ A **Revisions** button then appears in the top bar with a legend listing each round - colour swatch, label, time, and the agent's summary - and **Reveal** steps through that round's changed blocks, flashing them one at a time.
186
+ Each revision's swatch carries a distinct border style and fill pattern as well as a colour, so the rounds stay distinguishable without colour. Artifacts with no registry show no button and are otherwise unaffected, a malformed registry is ignored rather than surfaced as an error, and at most 6 revisions are listed - one per distinguishable swatch.
187
+ Lavish never marks up the artifact for this: the legend lives in the browser chrome, so the served page stays identical to the saved file, and the registry and marks travel with the file through export.
184
188
  - **Local assets** - Copy local images, CSS, fonts, and scripts next to the HTML artifact and reference them with relative paths from that directory; root-prefixed paths such as `/assets/logo.png` will not resolve through Lavish's artifact route.
185
189
  - **Export and sharing** - `lavish-axi export` writes `<name>.export.html` by inlining local assets only, stripping the annotation SDK, and leaving remote CDN/font references as links that still need network access.
186
190
  `lavish-axi share` publishes the same local-inlined HTML to [ht-ml.app](https://ht-ml.app), a third-party hosting service not part of Lavish.
@@ -205,7 +209,7 @@ pnpm link
205
209
  - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
206
210
  In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately. Escape closes the card, same as Cancel, but only while it is empty (no text, no attachment); with unsent text or an attachment present, Escape does nothing rather than risk discarding it.
207
211
  Cmd+I or Ctrl+I toggles between annotate and explore mode from either the browser chrome or the artifact iframe, including while focus is in a textarea or control.
208
- - **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and keeps human feedback actions available while the agent is working because the server queues them for the next poll. An agent reply concludes delivered work and returns presence to waiting.
212
+ - **Agent presence** - The browser shows when no agent is listening, keeps queued feedback for the next successful `lavish-axi poll` send even across reloads, and keeps human feedback actions available while the agent is working because the server queues them for the next poll. An agent reply concludes delivered work and returns presence to waiting. A supervisor-owned process listener is shown as an external listener rather than an idle captain turn.
209
213
  In a Herdr-managed pane, set `LAVISH_AXI_HERDR_CHIME=1` to show a Herdr request notification and play its chime once an indefinite poll enters the waiting state. Immediate queued feedback and timed debug polls do not trigger a notification. The integration is off unless both `LAVISH_AXI_HERDR_CHIME` and Herdr's `HERDR_ENV` session marker equal `1`; leaving either unset or using any other value opts out. Notification delivery runs without blocking feedback: if Herdr is unavailable, rejects the notification, stalls, or fails, the poll continues unchanged.
210
214
  Closing the last review tab while a poll is active starts a 10-second reconnect grace period. A reload or quick reconnect keeps an active poll waiting; if every review stays disconnected, the poll returns `browser_disconnected` without ending the resumable session, and the agent asks whether to reopen or end it instead of acting uninvited.
211
215
  The no-timeout poll always writes an immediate stderr banner so it is visibly not hung; it adds the periodic stderr wait ticks only in an interactive terminal, so when stderr is piped (as under agent harnesses) the captured output carries no tick noise. Stdout always stays reserved for the final response; if the poll is interrupted or times out before feedback arrives, re-run it because feedback remains queued until delivery. Poll delivery consumes the response, so read the complete response before truncating or filtering it.
@@ -286,6 +290,8 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
286
290
  | `lavish-axi share` | `--update-key <key>` | The secret returned when the page was published; required to republish or unpublish it. |
287
291
  | `lavish-axi share` | `--unpublish` | Replace a published page with a locked placeholder (ht-ml.app cannot delete); takes `--site` and `--update-key` and no file. |
288
292
  | `lavish-axi share` | `--token <t>` | Attach an optional bearer token (`LAVISH_AXI_HTML_APP_TOKEN`) when creating a page; never required, and rejected on a republish or `--unpublish`, where the `update_key` is the credential. |
293
+ | `lavish-axi poll` | `--owner <label>` | Identify the process listening for this session; the non-empty label appears in the home session listing. `none` is reserved for sessions without a listener. A second concurrent poll is refused instead of silently returning `waiting`. |
294
+ | `lavish-axi poll` | `--takeover` | Explicitly displace the current poll listener. The displaced poll exits with typed `LISTENER_REPLACED`; without this flag, the new poll fails with typed `LISTENER_ACTIVE`. |
289
295
  | `lavish-axi poll` | `--agent-reply "..."` | Show a concise agent reply in the existing browser chat, conclude delivered work, and return presence to waiting before polling again. Keep replies concise by default. |
290
296
  | `lavish-axi poll` | `--agent-reply-file <path>` | When a longer reply is genuinely necessary, read Markdown from a file (`-` for stdin) so newlines survive quoting. Use blank-line paragraphs, `- ` / `1. ` lists, and `## ` headings so the Conversation panel renders it scannably. The Markdown subset is under Feedback controls. Cannot be combined with `--agent-reply`. |
291
297
  | `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
@@ -178,6 +178,12 @@ const warningsSelectAll = /** @type {HTMLInputElement} */ (document.getElementBy
178
178
  const warningsSelected = /** @type {HTMLSpanElement} */ (document.getElementById("warningsSelected"));
179
179
  const warningsList = /** @type {HTMLDivElement} */ (document.getElementById("warningsList"));
180
180
  const warningsQueueButton = /** @type {HTMLButtonElement} */ (document.getElementById("warningsQueueButton"));
181
+ const revisionsWrap = /** @type {HTMLDivElement} */ (document.getElementById("revisionsWrap"));
182
+ const revisionsButton = /** @type {HTMLButtonElement} */ (document.getElementById("revisionsButton"));
183
+ const revisionsCount = /** @type {HTMLSpanElement} */ (document.getElementById("revisionsCount"));
184
+ const revisionsDrawer = /** @type {HTMLDivElement} */ (document.getElementById("revisionsDrawer"));
185
+ const revisionsSummary = /** @type {HTMLParagraphElement} */ (document.getElementById("revisionsSummary"));
186
+ const revisionsList = /** @type {HTMLDivElement} */ (document.getElementById("revisionsList"));
181
187
  const sendHint = /** @type {HTMLDivElement} */ (document.getElementById("sendHint"));
182
188
  const whiteboardOverlay = /** @type {HTMLDivElement} */ (document.getElementById("whiteboardOverlay"));
183
189
  const whiteboardFrame = /** @type {HTMLIFrameElement} */ (document.getElementById("whiteboardFrame"));
@@ -988,12 +994,14 @@ function syncChat(chat, revision) {
988
994
  }
989
995
 
990
996
  function setAgentPresence(state) {
991
- agentPresence = state === "listening" || state === "working" ? state : "waiting";
997
+ agentPresence = state === "listening" || state === "external" || state === "working" ? state : "waiting";
992
998
  updateSendState();
993
999
  renderSheetSummary();
994
1000
  if (presenceBanner) presenceBanner.hidden = ended || agentPresence !== "waiting";
995
1001
 
996
- if (agentPresence !== "working") {
1002
+ // A supervisor-owned process-only listener is busy on the agent's behalf. It must not make the
1003
+ // composer look like an idle captain turn while the owning agent is offline.
1004
+ if (agentPresence !== "working" && agentPresence !== "external") {
997
1005
  if (workingBubble) workingBubble.remove();
998
1006
  workingBubble = null;
999
1007
  return;
@@ -1209,6 +1217,7 @@ function sheetSummary() {
1209
1217
  }
1210
1218
  if (unreadAgentReply) return { text: unreadAgentReply, accent: false, unread: true };
1211
1219
  if (agentPresence === "working") return { text: "Agent is working…", accent: false, unread: false };
1220
+ if (agentPresence === "external") return { text: "External listener active", accent: false, unread: false };
1212
1221
  if (agentPresence === "listening") return { text: "Agent listening", accent: false, unread: false };
1213
1222
  return { text: "Agent not listening", accent: false, unread: false };
1214
1223
  }
@@ -2428,6 +2437,265 @@ function revealWarning(warning) {
2428
2437
  postToFrame({ type: "lavish:revealElement", selector: warning.selector });
2429
2438
  }
2430
2439
 
2440
+ // ---------------------------------------------------------------------------
2441
+ // Revision legend
2442
+ //
2443
+ // The agent declares what it changed in the artifact HTML itself (a
2444
+ // `data-lavish-revisions` registry plus `data-lavish-revision` marks); the SDK
2445
+ // reads it and posts it here. The legend lives entirely in the chrome, so the
2446
+ // served artifact still matches the file on disk - Lavish never paints the
2447
+ // change indicator into the page. Reveal borrows the same transient marker the
2448
+ // layout-issues drawer uses, and only when the reader asks for it.
2449
+
2450
+ // The payload crosses postMessage from a sandboxed frame rendering author
2451
+ // content, so it is untrusted here whatever built it. These bound what is
2452
+ // examined, not what is accepted: capping accepted rows alone would let a
2453
+ // registry of ten thousand records walk the whole array on the chrome's main
2454
+ // thread before yielding its handful of rows.
2455
+ // Server-owned, injected into the session JSON from src/artifact-revisions.js.
2456
+ // The swatch is presentation the chrome owes the reader, not something the
2457
+ // artifact gets a say in, so nothing about it is read from the message.
2458
+ const REVISION_PALETTE = (Array.isArray(sessionData.revisionPalette) ? sessionData.revisionPalette : []).filter(
2459
+ (entry) => entry && /^#[0-9a-fA-F]{6}$/.test(String(entry.hex)),
2460
+ );
2461
+ const REVISION_LIMITS = {
2462
+ // Derived from the server-injected palette (src/artifact-revisions.js) rather than a
2463
+ // second hardcoded number, so the two can never drift; falls back to 6 only if the
2464
+ // palette itself came through empty.
2465
+ entries: REVISION_PALETTE.length > 0 ? REVISION_PALETTE.length : 6,
2466
+ rawEntries: 256,
2467
+ marks: 200,
2468
+ rawMarks: 2000,
2469
+ id: 60,
2470
+ label: 80,
2471
+ summary: 400,
2472
+ timestamp: 40,
2473
+ excerpt: 120,
2474
+ selector: 400,
2475
+ };
2476
+ const REVISION_BORDER_STYLES = ["solid", "dashed", "dotted", "double"];
2477
+
2478
+ /** @type {any[]} */
2479
+ let revisionEntries = [];
2480
+ /** @type {any[]} */
2481
+ let revisionMarks = [];
2482
+ /** @type {Map<string, number>} */
2483
+ const revisionRevealCursor = new Map();
2484
+ let revisionsDrawerOpen = false;
2485
+
2486
+ // Display-only fields. Anything the legend merely shows can be shortened.
2487
+ function revisionText(value, max) {
2488
+ if (typeof value !== "string" && typeof value !== "number") return "";
2489
+ const text = String(value).trim();
2490
+ return text.length > max ? text.slice(0, max) : text;
2491
+ }
2492
+
2493
+ // Identity fields, mirroring `exactRevisionText` in src/artifact-revisions.js.
2494
+ // The message is untrusted here, so shortening an id or a selector is worse
2495
+ // than dropping it: two distinct overlong ids collapse into one legend row, and
2496
+ // the first 400 characters of a long selector are a different valid selector
2497
+ // that Reveal would resolve to some other block.
2498
+ function revisionExact(value, max) {
2499
+ if (typeof value !== "string" && typeof value !== "number") return "";
2500
+ const text = String(value).trim();
2501
+ return text.length > 0 && text.length <= max ? text : "";
2502
+ }
2503
+
2504
+ function revisionPatternFill(pattern) {
2505
+ if (pattern === "diagonal") return "repeating-linear-gradient(45deg, currentColor 0 2px, transparent 2px 5px)";
2506
+ if (pattern === "dots") return "radial-gradient(currentColor 1px, transparent 1px)";
2507
+ return "";
2508
+ }
2509
+
2510
+ // The swatch for a revision's position in the registry. Position is the only
2511
+ // thing the artifact influences, and it cannot repeat one: ids are deduplicated
2512
+ // before this runs, so each accepted revision gets a distinct index and
2513
+ // therefore a distinct swatch.
2514
+ function revisionPresentation(index) {
2515
+ const entry = REVISION_PALETTE.length > 0 ? REVISION_PALETTE[index % REVISION_PALETTE.length] : null;
2516
+ return {
2517
+ color: entry ? String(entry.hex) : "#0072b2",
2518
+ borderStyle: REVISION_BORDER_STYLES.includes(String(entry && entry.borderStyle))
2519
+ ? String(entry.borderStyle)
2520
+ : "solid",
2521
+ pattern: revisionPatternFill(entry && entry.pattern),
2522
+ };
2523
+ }
2524
+
2525
+ // Only text crosses from the artifact into the legend, and it crosses as
2526
+ // textContent. Nothing the message carries reaches a style property.
2527
+ function normalizeRevisionMessage(msg) {
2528
+ const rawRevisions = Array.isArray(msg && msg.revisions) ? msg.revisions : [];
2529
+ const revisions = [];
2530
+ const byId = new Map();
2531
+ let examined = 0;
2532
+ for (const raw of rawRevisions) {
2533
+ if (examined >= REVISION_LIMITS.rawEntries || revisions.length >= REVISION_LIMITS.entries) break;
2534
+ examined += 1;
2535
+ if (!raw || typeof raw !== "object") continue;
2536
+ const id = revisionExact(raw.id, REVISION_LIMITS.id);
2537
+ if (!id || /\s/.test(id) || byId.has(id)) continue;
2538
+ const entry = {
2539
+ id,
2540
+ label: revisionText(raw.label, REVISION_LIMITS.label) || id,
2541
+ timestamp: revisionText(raw.timestamp, REVISION_LIMITS.timestamp),
2542
+ summary: revisionText(raw.summary, REVISION_LIMITS.summary),
2543
+ ...revisionPresentation(revisions.length),
2544
+ marks: [],
2545
+ };
2546
+ byId.set(id, entry);
2547
+ revisions.push(entry);
2548
+ }
2549
+
2550
+ const rawMarks = Array.isArray(msg && msg.marks) ? msg.marks : [];
2551
+ const marks = [];
2552
+ examined = 0;
2553
+ for (const raw of rawMarks) {
2554
+ if (examined >= REVISION_LIMITS.rawMarks || marks.length >= REVISION_LIMITS.marks) break;
2555
+ examined += 1;
2556
+ if (!raw || typeof raw !== "object") continue;
2557
+ const revisionId = revisionExact(raw.revision_id, REVISION_LIMITS.id);
2558
+ const selector = revisionExact(raw.selector, REVISION_LIMITS.selector);
2559
+ const owner = byId.get(revisionId);
2560
+ if (!owner || !selector) continue;
2561
+ const mark = {
2562
+ revisionId,
2563
+ selector,
2564
+ tag: revisionText(raw.tag, 40).toLowerCase(),
2565
+ excerpt: revisionText(raw.excerpt, REVISION_LIMITS.excerpt),
2566
+ };
2567
+ owner.marks.push(mark);
2568
+ marks.push(mark);
2569
+ }
2570
+
2571
+ return { revisions, marks };
2572
+ }
2573
+
2574
+ function resetRevisionLegend() {
2575
+ revisionEntries = [];
2576
+ revisionMarks = [];
2577
+ revisionRevealCursor.clear();
2578
+ renderRevisionLegend();
2579
+ }
2580
+
2581
+ function applyRevisionMessage(msg) {
2582
+ const normalized = normalizeRevisionMessage(msg);
2583
+ revisionEntries = normalized.revisions;
2584
+ revisionMarks = normalized.marks;
2585
+ for (const id of [...revisionRevealCursor.keys()]) {
2586
+ if (!revisionEntries.some((entry) => entry.id === id)) revisionRevealCursor.delete(id);
2587
+ }
2588
+ renderRevisionLegend();
2589
+ }
2590
+
2591
+ function revisionMarkSummary(entry) {
2592
+ if (entry.marks.length === 0) return "no marked blocks";
2593
+ return entry.marks.length === 1 ? "1 marked block" : entry.marks.length + " marked blocks";
2594
+ }
2595
+
2596
+ function buildRevisionRow(entry) {
2597
+ const row = document.createElement("div");
2598
+ row.className = "revision-row";
2599
+
2600
+ const swatch = document.createElement("span");
2601
+ swatch.className = "revision-swatch";
2602
+ swatch.setAttribute("aria-hidden", "true");
2603
+ swatch.style.color = entry.color;
2604
+ swatch.style.borderColor = entry.color;
2605
+ swatch.style.borderStyle = entry.borderStyle;
2606
+ if (entry.pattern) swatch.style.backgroundImage = entry.pattern;
2607
+ row.appendChild(swatch);
2608
+
2609
+ const body = document.createElement("div");
2610
+ body.className = "revision-body";
2611
+
2612
+ const head = document.createElement("div");
2613
+ head.className = "revision-head";
2614
+ const label = document.createElement("span");
2615
+ label.className = "revision-label";
2616
+ label.textContent = entry.label;
2617
+ head.appendChild(label);
2618
+ if (entry.timestamp) {
2619
+ const time = document.createElement("span");
2620
+ time.className = "revision-time";
2621
+ time.textContent = entry.timestamp;
2622
+ head.appendChild(time);
2623
+ }
2624
+ body.appendChild(head);
2625
+
2626
+ if (entry.summary) {
2627
+ const summary = document.createElement("p");
2628
+ summary.className = "revision-summary";
2629
+ summary.textContent = entry.summary;
2630
+ body.appendChild(summary);
2631
+ }
2632
+
2633
+ const foot = document.createElement("div");
2634
+ foot.className = "revision-foot";
2635
+ const count = document.createElement("span");
2636
+ count.className = "revision-marks";
2637
+ count.textContent = revisionMarkSummary(entry);
2638
+ foot.appendChild(count);
2639
+ if (entry.marks.length > 0) {
2640
+ const reveal = document.createElement("button");
2641
+ reveal.type = "button";
2642
+ reveal.className = "revision-reveal";
2643
+ const position = (revisionRevealCursor.get(entry.id) || 0) % entry.marks.length;
2644
+ reveal.textContent = entry.marks.length === 1 ? "Reveal" : "Reveal " + (position + 1) + "/" + entry.marks.length;
2645
+ reveal.setAttribute("aria-label", "Reveal the next block changed in " + entry.label);
2646
+ // Bound per row rather than delegated from the list: the rows are rebuilt
2647
+ // on every render anyway, so there is no listener to accumulate, and the
2648
+ // button carries the revision it belongs to without a data attribute round
2649
+ // trip through the DOM.
2650
+ reveal.onclick = () => revealNextRevisionMark(entry.id);
2651
+ foot.appendChild(reveal);
2652
+ }
2653
+ body.appendChild(foot);
2654
+
2655
+ row.appendChild(body);
2656
+ return row;
2657
+ }
2658
+
2659
+ function renderRevisionLegend() {
2660
+ if (!revisionsWrap) return;
2661
+ const count = revisionEntries.length;
2662
+ revisionsWrap.hidden = count === 0 || ended;
2663
+ if (revisionsWrap.hidden && revisionsDrawerOpen) setRevisionsDrawerOpen(false);
2664
+ revisionsCount.textContent = String(count);
2665
+ revisionsButton.setAttribute("aria-label", count === 1 ? "1 revision" : count + " revisions");
2666
+ revisionsSummary.textContent =
2667
+ (count === 1 ? "1 revision" : count + " revisions") +
2668
+ " · " +
2669
+ (revisionMarks.length === 1 ? "1 marked block" : revisionMarks.length + " marked blocks");
2670
+
2671
+ revisionsList.replaceChildren(...revisionEntries.map(buildRevisionRow));
2672
+ }
2673
+
2674
+ function setRevisionsDrawerOpen(open) {
2675
+ revisionsDrawerOpen = open && !ended;
2676
+ revisionsDrawer.hidden = !revisionsDrawerOpen;
2677
+ revisionsButton.setAttribute("aria-expanded", String(revisionsDrawerOpen));
2678
+ if (revisionsDrawerOpen) closeMenus();
2679
+ }
2680
+
2681
+ function closeRevisionsDrawer({ restoreFocus = false } = {}) {
2682
+ if (!revisionsDrawerOpen) return;
2683
+ setRevisionsDrawerOpen(false);
2684
+ if (restoreFocus) revisionsButton.focus();
2685
+ }
2686
+
2687
+ // Step through a revision's marked blocks one at a time. The existing reveal
2688
+ // path flashes one element and clears the previous marker, so a revision that
2689
+ // touched several blocks is walked rather than lit up all at once.
2690
+ function revealNextRevisionMark(id) {
2691
+ const entry = revisionEntries.find((candidate) => candidate.id === id);
2692
+ if (!entry || entry.marks.length === 0) return;
2693
+ const position = (revisionRevealCursor.get(id) || 0) % entry.marks.length;
2694
+ postToFrame({ type: "lavish:revealElement", selector: entry.marks[position].selector });
2695
+ revisionRevealCursor.set(id, (position + 1) % entry.marks.length);
2696
+ renderRevisionLegend();
2697
+ }
2698
+
2431
2699
  async function dismissWarning(id) {
2432
2700
  try {
2433
2701
  const response = await fetch("/api/" + key + "/layout-warnings/dismiss", {
@@ -2527,6 +2795,8 @@ function markSessionEnded() {
2527
2795
  closeShareDialog();
2528
2796
  closeWarningsDrawer();
2529
2797
  renderWarnings();
2798
+ closeRevisionsDrawer();
2799
+ renderRevisionLegend();
2530
2800
  closeWhiteboard();
2531
2801
  annotationSwitch.disabled = true;
2532
2802
  moreButton.disabled = true;
@@ -2834,6 +3104,11 @@ async function replaceArtifactFrame({ recoveryRetry = false } = {}) {
2834
3104
  clearTimeout(artifactSilenceTimer);
2835
3105
  // The iframe is sandboxed, so reload by resetting the iframe URL from chrome.
2836
3106
  if (!artifactSrc) {
3107
+ // The next document reports its own registry once it loads; until then the
3108
+ // previous revision's legend would point at blocks that may no longer exist.
3109
+ // Only clear it here, right before the frame is actually replaced - a preserved
3110
+ // load (superseded/out-of-order/exhausted retries below) must leave it intact.
3111
+ resetRevisionLegend();
2837
3112
  startLayoutGateCycle();
2838
3113
  const currentSrc = frame.src || "about:blank";
2839
3114
  frame.src = currentSrc + (currentSrc.includes("?") ? "&" : "?") + "lavish_reload=" + Date.now();
@@ -2950,6 +3225,9 @@ async function replaceArtifactFrame({ recoveryRetry = false } = {}) {
2950
3225
  inlineWhiteboardChannels.clear();
2951
3226
  setHandoffSuperseded(false);
2952
3227
  startLayoutGateCycle();
3228
+ // The next document reports its own registry once it loads; until then the
3229
+ // previous revision's legend would point at blocks that may no longer exist.
3230
+ resetRevisionLegend();
2953
3231
  frame.src = artifactFrameSrcForLoad({ revision, token });
2954
3232
  return true;
2955
3233
  }
@@ -3685,6 +3963,7 @@ window.addEventListener("message", (event) => {
3685
3963
  // chrome cannot see every live reference, so reclamation is the sweeper's job.
3686
3964
  if (msg.type === "lavish:sendQueuedPrompts") sendQueued();
3687
3965
  if (msg.type === "lavish:endSession") endSession();
3966
+ if (msg.type === "lavish:revisions") applyRevisionMessage(msg);
3688
3967
  if (msg.type === "lavish:toggleAnnotationMode") toggleAnnotationMode();
3689
3968
  });
3690
3969
 
@@ -3828,9 +4107,14 @@ sendButton.onclick = () => sendQueued(false);
3828
4107
  sendAndEndButton.onclick = () => sendQueued(true);
3829
4108
  moreButton.onclick = () => {
3830
4109
  closeWarningsDrawer();
4110
+ closeRevisionsDrawer();
3831
4111
  toggleMenu(moreButton, moreMenu);
3832
4112
  };
3833
4113
  warningsButton.onclick = toggleWarningsDrawer;
4114
+ revisionsButton.onclick = () => {
4115
+ closeWarningsDrawer();
4116
+ setRevisionsDrawerOpen(revisionsDrawer.hidden);
4117
+ };
3834
4118
  warningsSelectAll.onchange = toggleSelectAllWarnings;
3835
4119
  warningsQueueButton.onclick = queueSelectedWarningFixes;
3836
4120
  chatAttachButton.onclick = () => chatAttachInput.click();
@@ -3960,6 +4244,7 @@ document.addEventListener("mousedown", (event) => {
3960
4244
  const target = /** @type {Node} */ (event.target);
3961
4245
  if (!moreMenu.hidden && !moreWrap.contains(target)) setMenuOpen(moreButton, moreMenu, false);
3962
4246
  if (warningsDrawerOpen && !warningsWrap.contains(target)) closeWarningsDrawer();
4247
+ if (revisionsDrawerOpen && !revisionsWrap.contains(target)) closeRevisionsDrawer();
3963
4248
  });
3964
4249
  // A non-modal popover closes when focus leaves it, so keyboard users are never stranded inside a
3965
4250
  // panel they cannot see the end of.
@@ -3967,6 +4252,10 @@ warningsWrap.addEventListener("focusout", (event) => {
3967
4252
  const next = /** @type {Node | null} */ (event.relatedTarget);
3968
4253
  if (warningsDrawerOpen && next && !warningsWrap.contains(next)) closeWarningsDrawer();
3969
4254
  });
4255
+ revisionsWrap.addEventListener("focusout", (event) => {
4256
+ const next = /** @type {Node | null} */ (event.relatedTarget);
4257
+ if (revisionsDrawerOpen && next && !revisionsWrap.contains(next)) closeRevisionsDrawer();
4258
+ });
3970
4259
  whiteboardCloseButton.onclick = closeWhiteboard;
3971
4260
  document.addEventListener("keydown", (event) => {
3972
4261
  if (event.key === "Escape") {
@@ -3974,6 +4263,8 @@ document.addEventListener("keydown", (event) => {
3974
4263
  closeWhiteboard();
3975
4264
  } else if (!shareDialog.hidden) {
3976
4265
  closeShareDialog();
4266
+ } else if (revisionsDrawerOpen) {
4267
+ closeRevisionsDrawer({ restoreFocus: true });
3977
4268
  } else if (warningsDrawerOpen) {
3978
4269
  closeWarningsDrawer({ restoreFocus: true });
3979
4270
  } else if (!moreMenu.hidden) {
@@ -4075,7 +4366,7 @@ events.set("chat-sync", (data) => {
4075
4366
  rememberChatAckIds(data.ack_ids);
4076
4367
  syncChat(data.chat || [], data.chat_revision);
4077
4368
  });
4078
- events.set("agent-presence", (data) => setAgentPresence(data.state));
4369
+ events.set("agent-presence", (data) => setAgentPresence(data.mode === "external-listener" ? "external" : data.state));
4079
4370
  events.set("layout-warnings", (data) => setLayoutWarnings(data.warnings || []));
4080
4371
  events.set("ended", () => markSessionEnded());
4081
4372
  connectLiveEvents();
package/dist/chrome.css CHANGED
@@ -635,6 +635,158 @@ body.lavish {
635
635
  opacity: 0.8;
636
636
  flex-shrink: 0;
637
637
  }
638
+ .revisions-wrap {
639
+ position: relative;
640
+ display: flex;
641
+ align-items: center;
642
+ }
643
+ .revisions-wrap[hidden] {
644
+ display: none;
645
+ }
646
+
647
+ .revisions-button {
648
+ display: inline-flex;
649
+ align-items: center;
650
+ gap: 6px;
651
+ height: 34px;
652
+ padding: 0 10px;
653
+ border-radius: var(--radius-md);
654
+ border: 1px solid var(--border);
655
+ background: var(--bg-panel);
656
+ color: var(--fg);
657
+ font: 600 12px/1 var(--font-sans);
658
+ cursor: pointer;
659
+ }
660
+
661
+ .revisions-button:hover {
662
+ background: var(--bg-elevated);
663
+ }
664
+
665
+ .revisions-count {
666
+ min-width: 18px;
667
+ padding: 0 5px;
668
+ border-radius: 9px;
669
+ background: var(--accent);
670
+ color: var(--brass-ink);
671
+ font: 700 11px/18px var(--font-sans);
672
+ text-align: center;
673
+ }
674
+
675
+ .revisions-drawer {
676
+ right: 0;
677
+ top: 42px;
678
+ width: min(360px, calc(100vw - 24px));
679
+ padding: 0;
680
+ }
681
+
682
+ .revisions-head {
683
+ padding: 12px 14px 8px;
684
+ }
685
+
686
+ .revisions-title {
687
+ margin: 0;
688
+ font-size: 13px;
689
+ font-weight: 700;
690
+ }
691
+
692
+ .revisions-summary {
693
+ margin: 4px 0 0;
694
+ font-size: 11px;
695
+ color: var(--fg-faint);
696
+ }
697
+
698
+ .revisions-list {
699
+ max-height: 320px;
700
+ overflow-y: auto;
701
+ padding: 0 14px;
702
+ }
703
+
704
+ .revision-row {
705
+ display: flex;
706
+ gap: 10px;
707
+ padding: 10px 0;
708
+ border-top: 1px solid var(--border);
709
+ }
710
+
711
+ /* Colour is never the only signal: the swatch also carries the revision's
712
+ border style and fill pattern, so the legend still separates revisions in
713
+ greyscale or with a colour vision deficiency. */
714
+ .revision-swatch {
715
+ flex: 0 0 auto;
716
+ width: 16px;
717
+ height: 16px;
718
+ margin-top: 2px;
719
+ border-width: 2px;
720
+ border-radius: 4px;
721
+ }
722
+
723
+ .revision-body {
724
+ min-width: 0;
725
+ flex: 1 1 auto;
726
+ }
727
+
728
+ .revision-head {
729
+ display: flex;
730
+ align-items: baseline;
731
+ gap: 8px;
732
+ }
733
+
734
+ .revision-label {
735
+ font-size: 12px;
736
+ font-weight: 700;
737
+ }
738
+
739
+ .revision-time {
740
+ font-size: 11px;
741
+ color: var(--fg-faint);
742
+ }
743
+
744
+ .revision-summary {
745
+ margin: 4px 0 0;
746
+ font-size: 11px;
747
+ line-height: 1.45;
748
+ color: var(--fg-faint);
749
+ }
750
+
751
+ .revision-foot {
752
+ display: flex;
753
+ align-items: center;
754
+ justify-content: space-between;
755
+ gap: 8px;
756
+ margin-top: 6px;
757
+ }
758
+
759
+ .revision-marks {
760
+ font-size: 11px;
761
+ color: var(--fg-faint);
762
+ }
763
+
764
+ .revision-reveal {
765
+ border: 1px solid var(--border);
766
+ border-radius: 8px;
767
+ background: var(--bg-elevated);
768
+ color: var(--fg);
769
+ padding: 4px 8px;
770
+ font: 700 11px/1 var(--font-sans);
771
+ cursor: pointer;
772
+ }
773
+
774
+ .revision-reveal:hover {
775
+ background: var(--steel-700);
776
+ }
777
+
778
+ .revisions-foot {
779
+ padding: 10px 14px 12px;
780
+ border-top: 1px solid var(--border);
781
+ }
782
+
783
+ .revisions-note {
784
+ margin: 0;
785
+ font-size: 11px;
786
+ line-height: 1.45;
787
+ color: var(--fg-faint);
788
+ }
789
+
638
790
  .menu-item.danger {
639
791
  color: var(--danger);
640
792
  }