lavish-axi 0.1.55 → 0.1.57

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
@@ -194,8 +194,9 @@ pnpm link
194
194
  - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) are interactive automatically, so they do not need `data-lavish-action`.
195
195
  For reversible choices, let option clicks update local state, then queue exactly one final answer from a per-question submit or Queue answer button with `window.lavish.queuePrompt()`.
196
196
  Mark only custom (non-native) clickable elements with `data-lavish-action` so Lavish does not annotate them, and use `data-lavish-question` or `queueKey` when pre-send updates for the same question should replace each other.
197
- Queued annotation preview pills and chat history share a scrollable Conversation panel above a sticky composer, so long feedback queues do not push the text box or send controls off screen.
197
+ On wider screens, queued annotation preview pills and chat history share a scrollable Conversation panel above a sticky composer, so long feedback queues do not push the text box or send controls off screen.
198
198
  The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session), while the composer exposes **Send & End** beside **Send to Agent** to submit queued prompts and user-ended attribution together.
199
+ - **Reviewing on a phone** - Below 860px wide, the artifact takes the whole screen above a **Conversation** dock, and the conversation opens as a bottom sheet over it: tap the dock, swipe it up, or press the chevron to raise it; tap the dimmed artifact, swipe the sheet down, press the chevron, or press Escape to lower it. The dock reports what matters while the sheet is down - how many prompts are queued, a reply that arrived while you were reading, or whether the agent is listening - and the sheet stays open across a reload of the review page. The sheet sizes itself to the visible viewport and respects safe-area insets; if the keyboard or attachments leave little room, conversation content yields or scrolls while the send actions remain pinned above the bottom edge. In landscape the sheet covers the top bar as well. Wider screens keep the side-by-side layout.
199
200
  - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
200
201
  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.
201
202
  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.
@@ -203,6 +204,7 @@ pnpm link
203
204
  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.
204
205
  Codex-specific guidance keeps that poll attached to the active turn instead of hiding it in a background task, because completed background tasks may not resume the agent.
205
206
  - **Session end etiquette** - Lavish tracks who ended a session: a human clicking **End session** (or **Send & end session**) in the browser is a user-initiated end, while `lavish-axi end <html-file>` is agent-initiated.
207
+ When either side ends the session, every open review tab becomes visibly read-only and disables its feedback controls; feedback submitted after the end is refused instead of being accepted without an agent to receive it.
206
208
  A plain `lavish-axi <html-file>` after a user-initiated end refuses to reopen the browser and returns guidance instead; pass `--reopen` only when the user asks for further review or something important needs their visual attention.
207
209
  Agent-initiated ends keep reopening normally, same as before.
208
210
  `lavish-axi poll`'s `ended` response and the `feedback` response for the final batch before an end both carry `next_step` guidance telling the agent to stop polling and deliver remaining updates in chat instead of reopening.
@@ -108,6 +108,11 @@ const chatAttachments = /** @type {HTMLDivElement} */ (document.getElementById("
108
108
  const chatAttachButton = /** @type {HTMLButtonElement} */ (document.getElementById("chatAttach"));
109
109
  const chatAttachInput = /** @type {HTMLInputElement} */ (document.getElementById("chatAttachInput"));
110
110
  const chatAttachmentNotice = /** @type {HTMLSpanElement} */ (document.getElementById("chatAttachmentNotice"));
111
+ const panel = /** @type {HTMLElement} */ (document.getElementById("panel"));
112
+ const panelHead = /** @type {HTMLDivElement} */ (document.getElementById("panelHead"));
113
+ const panelSummary = /** @type {HTMLSpanElement} */ (document.getElementById("panelSummary"));
114
+ const panelToggle = /** @type {HTMLButtonElement} */ (document.getElementById("panelToggle"));
115
+ const panelScrim = /** @type {HTMLDivElement} */ (document.getElementById("panelScrim"));
111
116
  const sendButton = /** @type {HTMLButtonElement} */ (document.getElementById("send"));
112
117
  const sendAndEndButton = /** @type {HTMLButtonElement} */ (document.getElementById("sendAndEnd"));
113
118
  const annotationSwitch = /** @type {HTMLButtonElement} */ (document.getElementById("annotation"));
@@ -400,6 +405,7 @@ function render() {
400
405
  }
401
406
  updateSendState();
402
407
  scrollPanelToBottom();
408
+ renderSheetSummary();
403
409
  }
404
410
 
405
411
  function updateSendState() {
@@ -540,6 +546,7 @@ function syncChat(chat) {
540
546
  function setAgentPresence(state) {
541
547
  agentPresence = state === "listening" || state === "working" ? state : "waiting";
542
548
  updateSendState();
549
+ renderSheetSummary();
543
550
  if (presenceBanner) presenceBanner.hidden = ended || agentPresence !== "waiting";
544
551
 
545
552
  if (agentPresence !== "working") {
@@ -691,6 +698,198 @@ function scrollPanelToBottom() {
691
698
  panelScroll.scrollTop = panelScroll.scrollHeight;
692
699
  }
693
700
 
701
+ // ---- Phone-width conversation sheet ----
702
+ // Below this width chrome.css turns the conversation panel into a dock the user raises as a
703
+ // bottom sheet over the artifact. This controller owns intent, accessibility state, gestures,
704
+ // visual-viewport measurements, and the dock summary; CSS owns the geometry. The query must match
705
+ // the one chrome.css lays the sheet out under.
706
+ const MOBILE_SHEET_MEDIA = "(max-width: 860px)";
707
+ // How far a drag on the dock must travel before it counts as a gesture rather than a tap.
708
+ const SHEET_DRAG_THRESHOLD_PX = 48;
709
+ const sheetStorageKey = "lavish-axi:sheet-open:" + key;
710
+ const sheetMedia = typeof window.matchMedia === "function" ? window.matchMedia(MOBILE_SHEET_MEDIA) : null;
711
+ // The user's intent, kept across a chrome reload so a live-reload or server upgrade does not drop
712
+ // them back onto a closed dock mid-conversation.
713
+ let sheetOpen = readSheetOpen();
714
+ // The latest agent reply that landed while the sheet was closed: the dock previews it until the
715
+ // user opens the sheet, so a reply never arrives silently behind the artifact.
716
+ let unreadAgentReply = "";
717
+ /** @type {{ pointerId: any, startY: number, moved: boolean } | null} */
718
+ let sheetDrag = null;
719
+ let suppressSheetClick = false;
720
+
721
+ function readSheetOpen() {
722
+ try {
723
+ return sessionStorage.getItem(sheetStorageKey) === "1";
724
+ } catch {
725
+ return false;
726
+ }
727
+ }
728
+
729
+ function isMobileSheet() {
730
+ return Boolean(sheetMedia && sheetMedia.matches);
731
+ }
732
+
733
+ function setSheetOpen(open) {
734
+ const next = Boolean(open);
735
+ const changed = next !== sheetOpen;
736
+ sheetOpen = next;
737
+ try {
738
+ if (sheetOpen) sessionStorage.setItem(sheetStorageKey, "1");
739
+ else sessionStorage.removeItem(sheetStorageKey);
740
+ } catch {
741
+ // Storage refused is not worth a broken sheet: the state just stops surviving a reload.
742
+ }
743
+ if (sheetOpen) unreadAgentReply = "";
744
+ applySheetState();
745
+ if (!changed || !isMobileSheet()) return;
746
+ if (sheetOpen) scrollPanelToBottom();
747
+ }
748
+
749
+ // Re-derives every sheet attribute from the phone layout, sheet-open, and session-ended state so a
750
+ // viewport crossing the breakpoint in either direction cannot make an ended panel interactive or
751
+ // leave a closed dock trapping focus.
752
+ function applySheetState() {
753
+ const mobile = isMobileSheet();
754
+ const open = mobile && sheetOpen;
755
+ document.body.classList.toggle("sheet-open", open);
756
+ const docked = mobile && !open;
757
+ panelScroll.inert = ended || docked;
758
+ chatComposer.inert = ended || docked;
759
+ const activeElement = document.activeElement;
760
+ if (docked && activeElement && (panelScroll.contains(activeElement) || chatComposer.contains(activeElement))) {
761
+ panelToggle.focus();
762
+ }
763
+ panelToggle.setAttribute("aria-expanded", open ? "true" : "false");
764
+ panelToggle.setAttribute("aria-label", open ? "Hide conversation" : "Show conversation");
765
+ renderSheetSummary();
766
+ }
767
+
768
+ // What the closed dock says. One line, most actionable state first: work the user has queued,
769
+ // then a reply they have not seen, then whether the agent is there to receive a send.
770
+ function sheetSummary() {
771
+ if (ended) return { text: "Session ended", accent: false, unread: false };
772
+ if (queued.length > 0) {
773
+ return { text: queued.length === 1 ? "1 queued" : queued.length + " queued", accent: true, unread: false };
774
+ }
775
+ if (unreadAgentReply) return { text: unreadAgentReply, accent: false, unread: true };
776
+ if (agentPresence === "working") return { text: "Agent is working…", accent: false, unread: false };
777
+ if (agentPresence === "listening") return { text: "Agent listening", accent: false, unread: false };
778
+ return { text: "Agent not listening", accent: false, unread: false };
779
+ }
780
+
781
+ function renderSheetSummary() {
782
+ const summary = sheetSummary();
783
+ panelSummary.textContent = summary.text;
784
+ panelSummary.classList.toggle("is-accent", summary.accent);
785
+ panelSummary.classList.toggle("is-unread", summary.unread);
786
+ }
787
+
788
+ // A brief pulse on the dock when something the user should notice lands while the sheet is
789
+ // closed: a prompt they queued from the artifact, or an agent reply.
790
+ function pulseSheetDock() {
791
+ if (!isMobileSheet() || sheetOpen) return;
792
+ panelHead.classList.remove("is-fresh");
793
+ // Restart the animation even when the previous pulse is still running.
794
+ void panelHead.offsetWidth;
795
+ panelHead.classList.add("is-fresh");
796
+ }
797
+
798
+ function noteAgentReply(text) {
799
+ if (!isMobileSheet() || sheetOpen) return;
800
+ unreadAgentReply = String(text || "");
801
+ renderSheetSummary();
802
+ pulseSheetDock();
803
+ }
804
+
805
+ // The phone keyboard shrinks the visual viewport without touching the layout viewport on iOS, so
806
+ // the sheet reads its height and offset from here; chrome.css consumes these only under the phone
807
+ // breakpoint. Android reports the same numbers through the layout viewport thanks to the
808
+ // `interactive-widget=resizes-content` viewport meta, which makes this a no-op there.
809
+ function syncVisualViewport() {
810
+ const root = document.documentElement;
811
+ if (!root || !root.style || typeof root.style.setProperty !== "function") return;
812
+ const viewport = window.visualViewport;
813
+ const height = viewport ? viewport.height : window.innerHeight;
814
+ const top = viewport ? viewport.offsetTop : 0;
815
+ if (!(height > 0)) return;
816
+ root.style.setProperty("--vv-height", Math.round(height) + "px");
817
+ root.style.setProperty("--vv-top", Math.round(Math.max(0, top || 0)) + "px");
818
+ }
819
+
820
+ function sheetDragOffset(event) {
821
+ return sheetDrag ? Number(event.clientY) - sheetDrag.startY : 0;
822
+ }
823
+
824
+ function clearSheetDrag() {
825
+ sheetDrag = null;
826
+ panel.classList.remove("is-dragging");
827
+ panel.style.transform = "";
828
+ }
829
+
830
+ function finishSheetDrag(event) {
831
+ if (!sheetDrag || event.pointerId !== sheetDrag.pointerId) return;
832
+ const offset = sheetDragOffset(event);
833
+ const moved = sheetDrag.moved;
834
+ clearSheetDrag();
835
+ if (!moved) return;
836
+ // The click that follows a completed drag must not undo what the drag decided.
837
+ suppressSheetClick = true;
838
+ if (sheetOpen && offset > SHEET_DRAG_THRESHOLD_PX) setSheetOpen(false);
839
+ else if (!sheetOpen && offset < -SHEET_DRAG_THRESHOLD_PX) setSheetOpen(true);
840
+ }
841
+
842
+ panelHead.addEventListener("click", () => {
843
+ if (!isMobileSheet()) return;
844
+ if (suppressSheetClick) {
845
+ suppressSheetClick = false;
846
+ return;
847
+ }
848
+ setSheetOpen(!sheetOpen);
849
+ });
850
+ panelScrim.addEventListener("click", () => setSheetOpen(false));
851
+ panelHead.addEventListener("pointerdown", (event) => {
852
+ if (!isMobileSheet() || event.button) return;
853
+ sheetDrag = { pointerId: event.pointerId, startY: Number(event.clientY), moved: false };
854
+ if (typeof panelHead.setPointerCapture === "function") panelHead.setPointerCapture(event.pointerId);
855
+ });
856
+ panelHead.addEventListener("pointermove", (event) => {
857
+ if (!sheetDrag || event.pointerId !== sheetDrag.pointerId) return;
858
+ const offset = sheetDragOffset(event);
859
+ if (Math.abs(offset) > 6) sheetDrag.moved = true;
860
+ if (!sheetDrag.moved) return;
861
+ panel.classList.add("is-dragging");
862
+ // Follow the finger: an open sheet only moves down, a closed dock only up.
863
+ panel.style.transform = sheetOpen
864
+ ? "translateY(" + Math.max(0, offset) + "px)"
865
+ : "translateY(calc(100% - var(--dock-h) - env(safe-area-inset-bottom, 0px) + " + Math.min(0, offset) + "px))";
866
+ });
867
+ panelHead.addEventListener("pointerup", finishSheetDrag);
868
+ panelHead.addEventListener("pointercancel", (event) => {
869
+ if (!sheetDrag || event.pointerId !== sheetDrag.pointerId) return;
870
+ clearSheetDrag();
871
+ suppressSheetClick = false;
872
+ });
873
+ if (sheetMedia && typeof sheetMedia.addEventListener === "function") {
874
+ sheetMedia.addEventListener("change", (event) => {
875
+ if (!event.matches) {
876
+ sheetOpen = false;
877
+ try {
878
+ sessionStorage.removeItem(sheetStorageKey);
879
+ } catch {
880
+ // Storage refusal only prevents persistence; the in-memory state is already reset.
881
+ }
882
+ }
883
+ applySheetState();
884
+ });
885
+ }
886
+ if (window.visualViewport && typeof window.visualViewport.addEventListener === "function") {
887
+ window.visualViewport.addEventListener("resize", syncVisualViewport);
888
+ window.visualViewport.addEventListener("scroll", syncVisualViewport);
889
+ }
890
+ window.addEventListener("resize", syncVisualViewport);
891
+ syncVisualViewport();
892
+
694
893
  function scrollElementIntoView(el) {
695
894
  el.scrollIntoView({ block: "nearest", inline: "nearest" });
696
895
  }
@@ -1018,6 +1217,14 @@ async function submitQueuedOnce() {
1018
1217
  if (!response.ok) {
1019
1218
  if (response.status === 409) {
1020
1219
  const data = await response.json().catch(() => null);
1220
+ // The session already ended before this batch arrived - most likely this chrome missed the
1221
+ // SSE `ended` event (a dropped connection). Go read-only now instead of leaving Send enabled
1222
+ // for another attempt that will be refused the same way.
1223
+ if (data?.status === "ended") {
1224
+ endAfterSubmit = false;
1225
+ markSessionEnded();
1226
+ return false;
1227
+ }
1021
1228
  if (Array.isArray(data?.warnings)) setLayoutWarnings(data.warnings);
1022
1229
  endAfterSubmit = false;
1023
1230
  return false;
@@ -1579,6 +1786,7 @@ function markSessionEnded() {
1579
1786
  ended = true;
1580
1787
  cancelArtifactLoadRecovery();
1581
1788
  closeMenus();
1789
+ closeShareDialog();
1582
1790
  closeWarningsDrawer();
1583
1791
  renderWarnings();
1584
1792
  closeWhiteboard();
@@ -1586,6 +1794,7 @@ function markSessionEnded() {
1586
1794
  moreButton.disabled = true;
1587
1795
  chatInput.disabled = true;
1588
1796
  updateSendState();
1797
+ applySheetState();
1589
1798
  if (presenceBanner) presenceBanner.hidden = true;
1590
1799
  if (handoffBanner) handoffBanner.hidden = true;
1591
1800
  if (outdatedBanner) outdatedBanner.hidden = true;
@@ -2253,6 +2462,8 @@ async function queueWhiteboardFeedback(index, message, mode) {
2253
2462
  // earlier unsent prompt instead of stacking duplicates.
2254
2463
  [internalQueueKeyField]: "whiteboard:" + index,
2255
2464
  });
2465
+ // Queued from the whiteboard inside the artifact, like any other in-artifact prompt.
2466
+ pulseSheetDock();
2256
2467
  postToWhiteboard(index, mode, { type: "lavish-whiteboard:queueResult", ok: true });
2257
2468
  if (mode === "overlay") closeWhiteboard();
2258
2469
  } catch (error) {
@@ -2556,6 +2767,8 @@ window.addEventListener("message", (event) => {
2556
2767
  // The artifact spoke, so it rendered and ran its SDK - there is nothing fatal to probe for.
2557
2768
  if (msg.type === "lavish:queuePrompt") {
2558
2769
  enqueuePrompt(msg.prompt);
2770
+ // Queued from inside the artifact, where the closed dock is the only sign it landed.
2771
+ pulseSheetDock();
2559
2772
  }
2560
2773
  if (msg.type === "lavish:snapshot") {
2561
2774
  const snapshotAction = snapshotRequests.shift() || "submit";
@@ -2867,6 +3080,10 @@ document.addEventListener("keydown", (event) => {
2867
3080
  closeShareDialog();
2868
3081
  } else if (warningsDrawerOpen) {
2869
3082
  closeWarningsDrawer({ restoreFocus: true });
3083
+ } else if (!moreMenu.hidden) {
3084
+ closeMenus();
3085
+ } else if (sheetOpen && isMobileSheet()) {
3086
+ setSheetOpen(false);
2870
3087
  } else {
2871
3088
  closeMenus();
2872
3089
  }
@@ -2907,13 +3124,19 @@ events.addEventListener("chrome-reload", (event) => reloadAfterServerRestart(shu
2907
3124
  // The replacement server serves a different artifact's review. This page keeps working against
2908
3125
  // it; it is only running the previous version of the chrome, which is the user's to act on.
2909
3126
  events.addEventListener("chrome-outdated", (event) => setChromeOutdated(true, shutdownEventReason(event)));
2910
- events.addEventListener("agent-reply", (event) => addChat("agent", JSON.parse(event.data).text));
3127
+ events.addEventListener("agent-reply", (event) => {
3128
+ const text = JSON.parse(event.data).text;
3129
+ addChat("agent", text);
3130
+ noteAgentReply(text);
3131
+ });
2911
3132
  events.addEventListener("chat-sync", (event) => syncChat(JSON.parse(event.data).chat || []));
2912
3133
  events.addEventListener("agent-presence", (event) => setAgentPresence(JSON.parse(event.data).state));
2913
3134
  events.addEventListener("layout-warnings", (event) => setLayoutWarnings(JSON.parse(event.data).warnings || []));
3135
+ events.addEventListener("ended", () => markSessionEnded());
2914
3136
  // A reconnecting stream means this chrome may have missed updates while it was away.
2915
3137
  events.addEventListener("open", () => refreshLayoutWarnings());
2916
3138
 
3139
+ applySheetState();
2917
3140
  render();
2918
3141
  setChromeOutdated(false);
2919
3142
  setWarningsDrawerOpen(false);
@@ -2921,6 +3144,9 @@ renderWarnings();
2921
3144
  initialChat.forEach((item) => addChat(item.role, item.text));
2922
3145
  retiredDrafts.forEach((text) => renderRetiredDraft(text));
2923
3146
  setAgentPresence("waiting");
3147
+ // The session already ended before this page (re)loaded, so there is no future SSE `ended` event
3148
+ // to wait for - start read-only instead of looking live until a Send gets silently refused.
3149
+ if (sessionData.initialEnded) markSessionEnded();
2924
3150
 
2925
3151
  // Reaching this line is the only proof that this file parsed and ran to completion. The page it
2926
3152
  // bootstraps ships with the layout-gate overlay already covering the artifact, and only this
package/dist/chrome.css CHANGED
@@ -96,6 +96,7 @@
96
96
  --shadow-tooltip: 0 16px 44px rgba(0, 0, 0, 0.35);
97
97
  --shadow-floating: 0 20px 70px rgba(0, 0, 0, 0.35);
98
98
  --bar-h: 56px;
99
+ --bar-pad: var(--space-8);
99
100
  --panel-w: 360px;
100
101
  --ease: cubic-bezier(0.2, 0.6, 0.2, 1);
101
102
  --dur-fast: 120ms;
@@ -135,7 +136,7 @@ body.lavish {
135
136
  display: flex;
136
137
  align-items: center;
137
138
  gap: 14px;
138
- padding: 0 var(--space-8);
139
+ padding: 0 var(--bar-pad);
139
140
  background: var(--bg-bar);
140
141
  border-bottom: var(--hairline-subtle);
141
142
  box-sizing: border-box;
@@ -891,6 +892,18 @@ body.lavish {
891
892
  min-width: 0;
892
893
  min-height: 0;
893
894
  }
895
+ /* Desktop keeps the panel's original flex children: the head wrappers exist only so the phone
896
+ layout can turn the heading into a dock, and they are transparent to layout everywhere else. */
897
+ .panel-head,
898
+ .panel-head-row {
899
+ display: contents;
900
+ }
901
+ .panel-handle,
902
+ .panel-summary,
903
+ .panel-toggle,
904
+ .panel-scrim {
905
+ display: none;
906
+ }
894
907
  .panel h2 {
895
908
  font-size: 15px;
896
909
  line-height: 1.3;
@@ -1331,7 +1344,7 @@ body.layout-gate-active iframe#artifact {
1331
1344
  @media (max-width: 640px) {
1332
1345
  .bar {
1333
1346
  gap: 10px;
1334
- padding: 0 var(--space-6);
1347
+ --bar-pad: var(--space-6);
1335
1348
  }
1336
1349
  .brand-support {
1337
1350
  display: none;
@@ -1345,23 +1358,202 @@ body.layout-gate-active iframe#artifact {
1345
1358
  max-height: min(70vh, 460px);
1346
1359
  }
1347
1360
  }
1361
+ /* Phone-width chrome: the artifact takes the whole viewport above a persistent conversation dock,
1362
+ and the conversation itself is a bottom sheet the dock raises over a scrim. A side-by-side or
1363
+ stacked split leaves neither half usable on a phone - the composer alone is taller than the
1364
+ strip a split gives the panel - so the two surfaces take turns owning the height instead.
1365
+ The sheet is sized from the visual viewport (`--vv-top`/`--vv-height`, kept current by
1366
+ chrome-client.js) rather than the layout viewport, so an on-screen keyboard shrinks it instead
1367
+ of covering the composer, and every edge that can meet a notch or home indicator pads by the
1368
+ matching safe-area inset. */
1348
1369
  @media (max-width: 860px) {
1349
- body {
1350
- overflow: auto;
1370
+ :root {
1371
+ --bar-h: calc(56px + env(safe-area-inset-top, 0px));
1372
+ --dock-h: 60px;
1373
+ --sheet-peek: 16px;
1374
+ --composer-reserve: calc(1px + var(--space-4) + 34px + var(--space-6) + env(safe-area-inset-bottom, 0px));
1375
+ --sheet-top: max(var(--bar-h) + var(--sheet-peek) - var(--vv-top, 0px), var(--sheet-peek));
1376
+ }
1377
+ .bar {
1378
+ padding-top: env(safe-area-inset-top, 0px);
1379
+ padding-right: calc(var(--bar-pad) + env(safe-area-inset-right, 0px));
1380
+ padding-left: calc(var(--bar-pad) + env(safe-area-inset-left, 0px));
1351
1381
  }
1352
1382
  .layout {
1353
1383
  height: calc(100vh - var(--bar-h));
1354
- grid-template-columns: 1fr;
1355
- grid-template-rows: minmax(0, 1fr) min(42vh, 360px);
1384
+ height: calc(100dvh - var(--bar-h));
1385
+ grid-template-columns: minmax(0, 1fr);
1386
+ /* The second row only reserves the dock's footprint so the artifact is never underneath it;
1387
+ the panel itself is fixed. */
1388
+ grid-template-rows: minmax(0, 1fr) calc(var(--dock-h) + env(safe-area-inset-bottom, 0px));
1389
+ }
1390
+ .frame {
1391
+ padding: 0 env(safe-area-inset-right, 0px) 0 env(safe-area-inset-left, 0px);
1392
+ background: var(--bg-bar);
1356
1393
  }
1357
1394
  .panel {
1358
- width: 100%;
1395
+ position: fixed;
1396
+ z-index: 30;
1397
+ top: calc(var(--vv-top, 0px) + var(--sheet-top));
1398
+ right: 0;
1399
+ left: 0;
1400
+ width: auto;
1401
+ height: calc(var(--vv-height, 100dvh) - var(--sheet-top));
1402
+ padding: 0 env(safe-area-inset-right, 0px) 0 env(safe-area-inset-left, 0px);
1359
1403
  border-left: 0;
1360
- border-top: var(--hairline-subtle);
1404
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
1405
+ /* The hairline is a shadow, not a border: a border would add to the sheet's box and push
1406
+ the docked head one pixel past the viewport edge. */
1407
+ box-shadow:
1408
+ 0 -1px 0 var(--border-subtle),
1409
+ var(--shadow-floating);
1410
+ /* Collapsed: slide everything but the dock below the viewport. The sheet keeps its full
1411
+ height, so opening it is one transform and the chat never reflows mid-gesture. */
1412
+ transform: translateY(calc(100% - var(--dock-h) - env(safe-area-inset-bottom, 0px)));
1413
+ transition: transform var(--dur-slow) var(--ease);
1414
+ will-change: transform;
1415
+ }
1416
+ body.sheet-open .panel {
1417
+ transform: none;
1418
+ }
1419
+ .panel.is-dragging {
1420
+ transition: none;
1421
+ }
1422
+ .panel-head {
1423
+ display: flex;
1424
+ flex: 0 0 auto;
1425
+ flex-direction: column;
1426
+ height: var(--dock-h);
1427
+ padding: 6px 16px 0;
1428
+ cursor: pointer;
1429
+ user-select: none;
1430
+ -webkit-user-select: none;
1431
+ touch-action: none;
1432
+ }
1433
+ body:not(.sheet-open) .panel-head {
1434
+ height: calc(var(--dock-h) + env(safe-area-inset-bottom, 0px));
1435
+ padding-bottom: env(safe-area-inset-bottom, 0px);
1436
+ }
1437
+ .panel-handle {
1438
+ display: block;
1439
+ width: 36px;
1440
+ height: 4px;
1441
+ margin: 0 auto 6px;
1442
+ border-radius: var(--radius-pill);
1443
+ background: var(--border-strong);
1444
+ }
1445
+ .panel-head-row {
1446
+ display: flex;
1447
+ flex: 1 1 auto;
1448
+ align-items: center;
1449
+ gap: 10px;
1450
+ min-width: 0;
1451
+ }
1452
+ .panel h2 {
1453
+ flex: 0 0 auto;
1454
+ margin: 0;
1455
+ }
1456
+ .panel-summary {
1457
+ display: block;
1458
+ flex: 1 1 auto;
1459
+ min-width: 0;
1460
+ color: var(--fg-faint);
1461
+ font-size: 12px;
1462
+ line-height: 1.35;
1463
+ text-align: right;
1464
+ white-space: nowrap;
1465
+ overflow: hidden;
1466
+ text-overflow: ellipsis;
1467
+ }
1468
+ .panel-summary:empty {
1469
+ display: none;
1470
+ }
1471
+ .panel-summary.is-accent {
1472
+ color: var(--accent);
1473
+ font-weight: var(--w-semi);
1474
+ }
1475
+ .panel-summary.is-unread::before {
1476
+ content: "";
1477
+ display: inline-block;
1478
+ width: 6px;
1479
+ height: 6px;
1480
+ margin-right: 6px;
1481
+ border-radius: var(--radius-pill);
1482
+ background: var(--accent);
1483
+ vertical-align: 1px;
1484
+ }
1485
+ .panel-toggle {
1486
+ display: flex;
1487
+ flex: 0 0 auto;
1488
+ align-items: center;
1489
+ justify-content: center;
1490
+ width: 32px;
1491
+ height: 32px;
1492
+ padding: 0;
1493
+ border: 1px solid var(--border);
1494
+ border-radius: var(--radius-pill);
1495
+ background: var(--bg-elevated);
1496
+ color: var(--fg-muted);
1497
+ cursor: pointer;
1498
+ }
1499
+ .panel-toggle svg {
1500
+ display: block;
1501
+ transition: transform var(--dur) var(--ease);
1502
+ }
1503
+ body.sheet-open .panel-toggle svg {
1504
+ transform: rotate(180deg);
1505
+ }
1506
+ .panel-head.is-fresh .panel-toggle {
1507
+ animation: dock-pulse 700ms var(--ease);
1508
+ }
1509
+ .panel-scroll {
1510
+ flex: 1 1 56px;
1511
+ min-height: clamp(
1512
+ 0px,
1513
+ calc(var(--vv-height, 100dvh) - var(--sheet-top) - var(--dock-h) - var(--composer-reserve)),
1514
+ 56px
1515
+ );
1516
+ overscroll-behavior: contain;
1361
1517
  }
1362
1518
  .chat {
1363
1519
  padding-bottom: 10px;
1364
1520
  }
1521
+ .composer {
1522
+ position: static;
1523
+ min-height: min(
1524
+ var(--composer-reserve),
1525
+ max(0px, calc(var(--vv-height, 100dvh) - var(--sheet-top) - var(--dock-h)))
1526
+ );
1527
+ flex: 0 1 auto;
1528
+ overflow-y: auto;
1529
+ overscroll-behavior: contain;
1530
+ padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
1531
+ }
1532
+ .chat-attachments {
1533
+ max-height: min(176px, 25dvh);
1534
+ }
1535
+ .composer .actions {
1536
+ position: sticky;
1537
+ bottom: 0;
1538
+ z-index: 1;
1539
+ padding-top: 8px;
1540
+ background: var(--bg-panel);
1541
+ box-shadow: 0 -1px 0 var(--border-subtle);
1542
+ }
1543
+ .panel-scrim {
1544
+ display: block;
1545
+ position: fixed;
1546
+ inset: var(--bar-h) 0 0 0;
1547
+ z-index: 29;
1548
+ background: rgba(15, 17, 21, 0.72);
1549
+ opacity: 0;
1550
+ pointer-events: none;
1551
+ transition: opacity var(--dur-slow) var(--ease);
1552
+ }
1553
+ body.sheet-open .panel-scrim {
1554
+ opacity: 1;
1555
+ pointer-events: auto;
1556
+ }
1365
1557
  .pill-preview {
1366
1558
  max-width: min(220px, 70vw);
1367
1559
  }
@@ -1373,6 +1565,40 @@ body.layout-gate-active iframe#artifact {
1373
1565
  flex-direction: column;
1374
1566
  }
1375
1567
  }
1568
+ /* A phone on its side has no height to share: the raised sheet covers the bar as well (the sheet
1569
+ carries its own close control) and the composer gives up its tall default. */
1570
+ @media (max-width: 860px) and (max-height: 520px) {
1571
+ :root {
1572
+ --sheet-top: var(--sheet-peek);
1573
+ }
1574
+ .panel-scrim {
1575
+ inset: 0;
1576
+ }
1577
+ .composer textarea {
1578
+ min-height: 48px;
1579
+ }
1580
+ .chat-attachments {
1581
+ max-height: 44px;
1582
+ }
1583
+ }
1584
+ @media (max-width: 860px) and (prefers-reduced-motion: reduce) {
1585
+ .panel,
1586
+ .panel-scrim,
1587
+ .panel-toggle svg {
1588
+ transition: none;
1589
+ }
1590
+ .panel-head.is-fresh .panel-toggle {
1591
+ animation: none;
1592
+ }
1593
+ }
1594
+ @keyframes dock-pulse {
1595
+ 0% {
1596
+ box-shadow: 0 0 0 0 rgba(244, 201, 93, 0.55);
1597
+ }
1598
+ 100% {
1599
+ box-shadow: 0 0 0 10px rgba(244, 201, 93, 0);
1600
+ }
1601
+ }
1376
1602
 
1377
1603
  /* Whiteboard overlay: a full-viewport layer above the chrome hosting the
1378
1604
  sandboxed Excalidraw frame. The frame owns all whiteboard UI; this layer
package/dist/cli.mjs CHANGED
@@ -7379,6 +7379,9 @@ var SessionStore = class {
7379
7379
  const shouldEndSession = Boolean(payload.endSession || payload.end_session);
7380
7380
  const restoring = options.restore === true;
7381
7381
  const alreadyEnded = session.status === "ended";
7382
+ if (alreadyEnded && !restoring) {
7383
+ return { ended: true, ended_by: session.ended_by };
7384
+ }
7382
7385
  const normalized = prompts.map(normalizePrompt);
7383
7386
  const normalizedPrompts = normalized.map((entry) => entry.prompt);
7384
7387
  const rejected = boundAttachmentRefs(normalized, options);
@@ -8881,6 +8884,10 @@ async function serve({
8881
8884
  res.status(404).json({ error: "session not found" });
8882
8885
  return;
8883
8886
  }
8887
+ if (result.ended) {
8888
+ res.status(409).json({ status: "ended", error: "session already ended", ended_by: result.ended_by });
8889
+ return;
8890
+ }
8884
8891
  if (result.rejected) {
8885
8892
  res.status(400).json({
8886
8893
  error: "some attachments could not be delivered",
@@ -8904,7 +8911,7 @@ async function serve({
8904
8911
  await syncOutstandingRepairs(req.params.key);
8905
8912
  events.emit("layout-warnings", req.params.key, serializeLayoutWarnings(session.layout_warnings));
8906
8913
  }
8907
- events.emit(shouldEndSession ? "ended" : "feedback", req.params.key);
8914
+ events.emit(shouldEndSession ? "ended" : "feedback", req.params.key, session.ended_by);
8908
8915
  res.json({ status: "queued", pending_prompts: session.pending_prompts });
8909
8916
  if (shouldEndSession) await shutdownIfNoLiveSessions();
8910
8917
  } catch (error) {
@@ -8991,9 +8998,9 @@ async function serve({
8991
8998
  });
8992
8999
  app.post("/api/:key/end", async (req, res, next) => {
8993
9000
  try {
8994
- await store.endSession(req.params.key, "user");
9001
+ const session = await store.endSession(req.params.key, "user");
8995
9002
  clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
8996
- events.emit("ended", req.params.key);
9003
+ events.emit("ended", req.params.key, session?.ended_by);
8997
9004
  res.json({ status: "ended" });
8998
9005
  await shutdownIfNoLiveSessions();
8999
9006
  } catch (error) {
@@ -9080,9 +9087,9 @@ async function serve({
9080
9087
  try {
9081
9088
  const file = await canonicalFile(req.body.file);
9082
9089
  const key = sessionKey(file);
9083
- await store.endSession(key, "agent");
9090
+ const session = await store.endSession(key, "agent");
9084
9091
  clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
9085
- events.emit("ended", key);
9092
+ events.emit("ended", key, session?.ended_by);
9086
9093
  res.json({ status: "ended" });
9087
9094
  await shutdownIfNoLiveSessions();
9088
9095
  } catch (error) {
@@ -9215,6 +9222,8 @@ async function serve({
9215
9222
  }
9216
9223
  });
9217
9224
  app.get("/events/:key", async (req, res, next) => {
9225
+ let cleanup = () => {
9226
+ };
9218
9227
  try {
9219
9228
  res.writeHead(200, {
9220
9229
  "content-type": "text/event-stream",
@@ -9223,7 +9232,6 @@ async function serve({
9223
9232
  });
9224
9233
  sseClients.set(res, String(req.params.key || ""));
9225
9234
  refreshIdleTimer();
9226
- const session = await store.findByKey(req.params.key);
9227
9235
  const sendReload = (key) => {
9228
9236
  if (key === req.params.key) {
9229
9237
  res.write("event: reload\ndata: {}\n\n");
@@ -9253,29 +9261,51 @@ data: ${JSON.stringify({ warnings })}
9253
9261
  `);
9254
9262
  }
9255
9263
  };
9256
- res.write(`event: chat-sync
9257
- data: ${JSON.stringify({ chat: session?.chat || [] })}
9264
+ const sendEnded = (key, endedBy) => {
9265
+ if (key === req.params.key) {
9266
+ res.write(`event: ended
9267
+ data: ${JSON.stringify({ ended_by: endedBy || null })}
9258
9268
 
9259
9269
  `);
9260
- res.write(
9261
- `event: agent-presence
9262
- data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, deliveredFeedback) })}
9263
-
9264
- `
9265
- );
9270
+ }
9271
+ };
9266
9272
  events.on("reload", sendReload);
9267
9273
  events.on("agent-reply", sendAgentReply);
9268
9274
  events.on("agent-presence", sendPresence);
9269
9275
  events.on("layout-warnings", sendLayoutWarnings);
9270
- req.on("close", () => {
9276
+ events.on("ended", sendEnded);
9277
+ let cleanedUp = false;
9278
+ cleanup = () => {
9279
+ if (cleanedUp) return;
9280
+ cleanedUp = true;
9281
+ req.off("close", cleanup);
9271
9282
  sseClients.delete(res);
9272
9283
  events.off("reload", sendReload);
9273
9284
  events.off("agent-reply", sendAgentReply);
9274
9285
  events.off("agent-presence", sendPresence);
9275
9286
  events.off("layout-warnings", sendLayoutWarnings);
9287
+ events.off("ended", sendEnded);
9276
9288
  refreshIdleTimer();
9277
- });
9289
+ };
9290
+ req.once("close", cleanup);
9291
+ const session = await store.findByKey(req.params.key);
9292
+ if (req.destroyed || res.writableEnded) {
9293
+ cleanup();
9294
+ return;
9295
+ }
9296
+ res.write(`event: chat-sync
9297
+ data: ${JSON.stringify({ chat: session?.chat || [] })}
9298
+
9299
+ `);
9300
+ res.write(
9301
+ `event: agent-presence
9302
+ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, deliveredFeedback) })}
9303
+
9304
+ `
9305
+ );
9306
+ if (session?.status === "ended") sendEnded(req.params.key, session.ended_by);
9278
9307
  } catch (error) {
9308
+ cleanup();
9279
9309
  next(error);
9280
9310
  }
9281
9311
  });
@@ -9920,7 +9950,9 @@ var chromeIcons = {
9920
9950
  16
9921
9951
  ),
9922
9952
  reveal: chromeIcon('<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/>', 13),
9923
- dismiss: chromeIcon('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>', 13)
9953
+ dismiss: chromeIcon('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>', 13),
9954
+ // The mobile conversation sheet's only chevron: CSS rotates it when the sheet is open.
9955
+ chevronUp: chromeIcon('<polyline points="6 15 12 9 18 15"/>', 18, 2)
9924
9956
  };
9925
9957
  function displayPathParts(file, home = homedir()) {
9926
9958
  const normalizedFile = file.replaceAll("\\", "/");
@@ -10033,6 +10065,11 @@ function createChromeHtml(session, {
10033
10065
  const sessionJson = jsonScript({
10034
10066
  key: session.key,
10035
10067
  file: session.file,
10068
+ // A page loaded (or reloaded) after the session already ended has no future SSE `ended`
10069
+ // event to wait for - it must start read-only instead of looking live until the user tries
10070
+ // to send and gets refused (#171).
10071
+ initialEnded: session.status === "ended",
10072
+ initialEndedBy: session.ended_by || null,
10036
10073
  initialChat: session.chat || [],
10037
10074
  // Bootstrapping the inbox from the server is what makes it survive a browser refresh or a
10038
10075
  // reconnect: the chrome never owns warning state, it only renders it.
@@ -10056,14 +10093,14 @@ function createChromeHtml(session, {
10056
10093
  <html>
10057
10094
  <head>
10058
10095
  <meta charset="utf-8">
10059
- <meta name="viewport" content="width=device-width, initial-scale=1">
10096
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
10060
10097
  <title>${escapeHtml(title)}</title>
10061
10098
  ${faviconTag}
10062
10099
  <link rel="stylesheet" href="/chrome.css">
10063
10100
  </head>
10064
10101
  <body class="${bodyClass}">
10065
10102
  <div class="bar"><div class="brand"><span class="brand-mark">Lavish</span><span class="brand-support">Editor</span></div><div class="spacer" aria-hidden="true"></div><div class="warnings-wrap" id="warningsWrap" hidden><button class="warnings-button" id="warningsButton" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="warningsDrawer">${chromeIcons.warning}<span class="warnings-count" id="warningsCount">0</span></button><div class="menu warnings-drawer" id="warningsDrawer" role="dialog" aria-labelledby="warningsTitle" aria-describedby="warningsSummary" hidden><div class="warnings-head"><h2 class="warnings-title" id="warningsTitle">Layout issues</h2><p class="warnings-summary" id="warningsSummary"></p></div><div class="warnings-toolbar"><label class="warnings-selectall"><input type="checkbox" id="warningsSelectAll"><span>Select all</span></label><span class="warnings-selected" id="warningsSelected" role="status" aria-live="polite"></span></div><div class="warnings-list" id="warningsList"></div><div class="warnings-foot"><p class="warnings-note">Queueing sends a repair request with your next feedback. An issue is marked resolved only after a newer artifact load and a complete check at the same viewport no longer finds it.</p><button class="button" id="warningsQueueButton" type="button" disabled>Queue selected fixes</button></div></div></div><button class="annotate-switch" id="annotation" type="button" aria-pressed="true" title="${escapeHtml(modeToggleHint)}"><span class="switch-track" aria-hidden="true"><span class="switch-knob"></span></span><span>Annotate</span></button><div class="more-wrap" id="moreWrap"><button class="more-button" id="moreButton" type="button" title="More" aria-haspopup="menu" aria-expanded="false">${chromeIcons.more}</button><div class="menu more-menu" id="moreMenu" hidden><div class="menu-head"><div class="menu-label">Editing</div><button class="menu-file" id="copyPath" type="button" title="Copy path \xB7 ${escapeHtml(session.file)}">${chromeIcons.file}<span class="menu-file-text"><span class="path-head">${escapeHtml(pathHead)}</span><span class="path-tail">${escapeHtml(pathTail)}</span></span><span class="copy-hint" id="copyHint"><span class="icon-copy">${chromeIcons.copy}</span><span class="icon-check">${chromeIcons.check}</span><span id="copyHintText">Copy</span></span></button></div><div class="menu-rule"></div><button class="menu-item" id="reloadArtifact" type="button">${chromeIcons.refresh}<span>Reload artifact</span></button><button class="menu-item" id="copySnapshot" type="button">${chromeIcons.camera}<span>Copy DOM snapshot</span></button><button class="menu-item" id="exportArtifact" type="button">${chromeIcons.download}<span>Export standalone HTML</span></button><button class="menu-item" id="shareArtifact" type="button">${chromeIcons.globe}<span>Publish link</span></button><div class="menu-rule"></div><button class="menu-item danger" id="end" type="button">${chromeIcons.exit}<span>End session</span></button></div></div></div>
10066
- <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads" data-artifact-src="/artifact/${session.key}/index.html"></iframe></div><aside class="panel"><h2>Conversation</h2><div class="panel-scroll" id="panelScroll"><div class="chat" id="chatLog"></div><div class="annotation-pills" id="annotationPills"></div></div><div class="composer" id="chatComposer"><div class="presence-banner handoff-banner" id="handoffBanner" hidden><span>This review is open in another Lavish tab.</span><button class="handoff-takeover" id="handoffTakeover" type="button">Take over here</button></div><div class="presence-banner handoff-banner" id="outdatedBanner" hidden><span id="outdatedText">The Lavish server this page was connected to is no longer running. Reloading will work once it is running again.</span><span class="outdated-actions"><button class="handoff-takeover" id="outdatedReload" type="button">Check and reload</button><button class="handoff-takeover" id="outdatedDismiss" type="button">Dismiss</button></span></div><div class="presence-banner" id="presenceBanner" hidden>Your agent is not listening. If this persists, ask your agent to poll for updates from Lavish.</div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="chat-attachments" id="chatAttachments"></div><div class="chat-attachment-toolbar"><button class="chat-attach" id="chatAttach" type="button">Attach images</button><input id="chatAttachInput" type="file" accept="${escapeHtml(acceptedMime.join(","))}" multiple hidden><span class="chat-attachment-notice" id="chatAttachmentNotice" role="status"></span></div><div class="send-hint" id="sendHint" hidden>Write a message or annotate an element first.</div><div class="actions" id="sendActions"><button class="button button-danger" id="sendAndEnd" type="button">${chromeIcons.exit}<span>Send &amp; End</span></button><button class="button" id="send">Send to Agent</button></div></div></aside></div>
10103
+ <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads" data-artifact-src="/artifact/${session.key}/index.html"></iframe></div><div class="panel-scrim" id="panelScrim"></div><aside class="panel" id="panel"><div class="panel-head" id="panelHead"><span class="panel-handle" aria-hidden="true"></span><div class="panel-head-row"><h2>Conversation</h2><span class="panel-summary" id="panelSummary" role="status" aria-live="polite"></span><button class="panel-toggle" id="panelToggle" type="button" aria-expanded="false" aria-controls="panel" aria-label="Show conversation">${chromeIcons.chevronUp}</button></div></div><div class="panel-scroll" id="panelScroll"><div class="chat" id="chatLog"></div><div class="annotation-pills" id="annotationPills"></div></div><div class="composer" id="chatComposer"><div class="presence-banner handoff-banner" id="handoffBanner" hidden><span>This review is open in another Lavish tab.</span><button class="handoff-takeover" id="handoffTakeover" type="button">Take over here</button></div><div class="presence-banner handoff-banner" id="outdatedBanner" hidden><span id="outdatedText">The Lavish server this page was connected to is no longer running. Reloading will work once it is running again.</span><span class="outdated-actions"><button class="handoff-takeover" id="outdatedReload" type="button">Check and reload</button><button class="handoff-takeover" id="outdatedDismiss" type="button">Dismiss</button></span></div><div class="presence-banner" id="presenceBanner" hidden>Your agent is not listening. If this persists, ask your agent to poll for updates from Lavish.</div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="chat-attachments" id="chatAttachments"></div><div class="chat-attachment-toolbar"><button class="chat-attach" id="chatAttach" type="button">Attach images</button><input id="chatAttachInput" type="file" accept="${escapeHtml(acceptedMime.join(","))}" multiple hidden><span class="chat-attachment-notice" id="chatAttachmentNotice" role="status"></span></div><div class="send-hint" id="sendHint" hidden>Write a message or annotate an element first.</div><div class="actions" id="sendActions"><button class="button button-danger" id="sendAndEnd" type="button">${chromeIcons.exit}<span>Send &amp; End</span></button><button class="button" id="send">Send to Agent</button></div></div></aside></div>
10067
10104
  <div class="share-overlay" id="shareDialog" role="dialog" aria-modal="true" aria-labelledby="shareTitleText" hidden><form class="share-card" id="shareForm"><div class="share-head"><div><div class="share-kicker">Publish to <a class="share-link" href="https://ht-ml.app" target="_blank" rel="noopener noreferrer">ht-ml.app</a></div><h2 id="shareTitleText">Publish artifact</h2></div><button class="share-close" id="shareClose" type="button" aria-label="Close publish dialog"><svg width="14" height="14" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></div><p class="share-note">ht-ml.app is a separate, third-party hosting service, not part of Lavish. Publishing sends this artifact to its servers.</p><p class="share-copy">This uploads this artifact to ht-ml.app with local assets inlined. Without a password, the page is PUBLIC and anyone with the link can open it. With a password, the page is PRIVATE and viewers must supply the password to view.</p><p class="share-note">Do not publish secrets. The Lavish annotation SDK is not included.</p><div class="share-grid"><label>Password (optional)<input id="sharePassword" name="password" type="password" autocomplete="new-password" placeholder="Leave blank for a public page"></label></div><div class="share-status" id="shareStatus" role="status"></div><div class="share-result" id="shareResult" hidden><label>Share URL<div class="share-copy-row"><input id="shareUrl" readonly><button class="share-copy-btn" id="copyShareUrl" type="button">Copy URL</button></div></label><label>Update key (secret)<div class="share-copy-row"><input id="shareUpdateKey" readonly><button class="share-copy-btn" id="copyUpdateKey" type="button">Copy key</button></div></label><p class="share-note">Keep the update key private. ht-ml.app returns it once and it is the only way to update or delete this page later.</p></div><div class="share-actions"><button class="share-cancel" id="shareCancel" type="button">Cancel</button><button class="button" id="sharePublish" type="submit">Publish</button></div></form></div>
10068
10105
  <div class="ended-overlay layout-gate-overlay" id="layoutGateOverlay"${layoutGateHidden}><div class="ended-card"><div class="ended-title" id="layoutGateTitle">Checking layout.<br>One moment.</div><p class="ended-copy" id="layoutGateCopy">Lavish is waiting for fonts and final geometry before revealing this artifact.</p><button class="button ended-action" id="layoutGateAction" type="button">Show anyway</button></div></div>
10069
10106
  <div class="ended-overlay" id="endedOverlay" hidden><div class="ended-card"><div class="ended-title">Session ended.<br>Return to your agent to continue.</div><p class="ended-copy">${escapeHtml(session.file)}</p></div></div>
@@ -10332,7 +10369,7 @@ var POLL_WAKE_PATH_RULES = Object.freeze([
10332
10369
  ]);
10333
10370
  var POLL_SEND_AND_END_RULE = "`Send & End` ends the session. Its final feedback is still delivered once. After that response, polling stops, and the agent must not reopen the session uninvited.";
10334
10371
  var CODEX_POLL_WAKE_PATH_GUIDANCE = "Codex detected: completed background tasks may not resume Codex automatically, so keep the poll attached to the active turn.";
10335
- var VERSION = "0.1.55";
10372
+ var VERSION = "0.1.57";
10336
10373
  function detectInvokingAgent(env = process.env) {
10337
10374
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
10338
10375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "packageManager": "pnpm@11.1.1",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "type": "module",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "lavish-axi",
4
- "version": "0.1.55",
4
+ "version": "0.1.57",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "author": {
7
7
  "name": "Kun Chen",