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 +7 -1
- package/dist/chrome-client.js +294 -3
- package/dist/chrome.css +152 -0
- package/dist/cli.mjs +474 -57
- package/package.json +1 -1
- package/plugin.json +1 -1
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. |
|
package/dist/chrome-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|