lavish-axi 0.1.20 → 0.1.22

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
@@ -138,7 +138,9 @@ Lavish also works fully as a plain CLI - just tell your agent to `npx lavish-axi
138
138
  - **Portable artifacts** - The artifact runs in an iframe while Lavish injects a small SDK for annotations, snapshots, and feedback controls. Lavish does not inject any design system, so the saved HTML file renders identically whether you open it through `lavish-axi` or directly in a browser. Choose a design system in priority order: follow a user-requested look first, match the current project's design system or conventions next, and otherwise run `lavish-axi design` for a copy-pasteable Tailwind CSS v4 + DaisyUI v5 CDN fallback.
139
139
  - **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.
140
140
  - **Live reload** - Lavish watches the HTML artifact file by default and preserves the artifact iframe scroll position across reloads. To also reload on sibling asset changes, add `data-lavish-live-reload-root` to the root element or `<meta name="lavish-live-reload" content="root">`.
141
- - **Feedback controls** - Native form controls (radios, checkboxes, inputs, selects, buttons, labels, contenteditable) are interactive automatically, so they do not need `data-lavish-action`; wire their handlers to `window.lavish.queuePrompt()` or `window.lavish.sendQueuedPrompts()` to send feedback. Mark only custom (non-native) clickable elements with `data-lavish-action` so Lavish does not annotate them.
141
+ - **Feedback controls** - Native form controls (radios, checkboxes, inputs, selects, buttons, labels, contenteditable) are interactive automatically, so they do not need `data-lavish-action`; wire their handlers to `window.lavish.queuePrompt()` or `window.lavish.sendQueuedPrompts()` to send feedback.
142
+ Mark only custom (non-native) clickable elements with `data-lavish-action` so Lavish does not annotate them.
143
+ The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, end session) and can submit queued prompts with **Send & end session**, which delivers the prompts before ending the session.
142
144
  - **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 only blocks sending while the agent is working on delivered feedback.
143
145
  - **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
144
146
  - **Server cleanup** - The detached server stops after the last session ends when nothing is connected, or after `LAVISH_AXI_IDLE_TIMEOUT_MS` (default 30 minutes) with no browser or poll connections.
@@ -3,6 +3,7 @@
3
3
  const sessionDataElement = document.getElementById("lavish-session");
4
4
  const sessionData = JSON.parse(sessionDataElement?.textContent || "{}");
5
5
  const key = String(sessionData.key || "");
6
+ const filePath = String(sessionData.file || "");
6
7
  const queueStorageKey = "lavish-axi:queued:" + key;
7
8
  const initialChat = Array.isArray(sessionData.initialChat) ? sessionData.initialChat : [];
8
9
 
@@ -11,20 +12,40 @@ const annotationPills = /** @type {HTMLDivElement} */ (document.getElementById("
11
12
  const chatLog = /** @type {HTMLDivElement} */ (document.getElementById("chatLog"));
12
13
  const chatInput = /** @type {HTMLTextAreaElement} */ (document.getElementById("chatInput"));
13
14
  const sendButton = /** @type {HTMLButtonElement} */ (document.getElementById("send"));
14
- const annotationButton = /** @type {HTMLButtonElement} */ (document.getElementById("annotation"));
15
+ const sendCaret = /** @type {HTMLButtonElement} */ (document.getElementById("sendCaret"));
16
+ const sendActions = /** @type {HTMLDivElement} */ (document.getElementById("sendActions"));
17
+ const sendMenu = /** @type {HTMLDivElement} */ (document.getElementById("sendMenu"));
18
+ const sendFromMenuButton = /** @type {HTMLButtonElement} */ (document.getElementById("sendFromMenu"));
19
+ const sendAndEndButton = /** @type {HTMLButtonElement} */ (document.getElementById("sendAndEnd"));
20
+ const annotationSwitch = /** @type {HTMLButtonElement} */ (document.getElementById("annotation"));
21
+ const moreWrap = /** @type {HTMLDivElement} */ (document.getElementById("moreWrap"));
22
+ const moreButton = /** @type {HTMLButtonElement} */ (document.getElementById("moreButton"));
23
+ const moreMenu = /** @type {HTMLDivElement} */ (document.getElementById("moreMenu"));
24
+ const reloadArtifactButton = /** @type {HTMLButtonElement} */ (document.getElementById("reloadArtifact"));
25
+ const copySnapshotButton = /** @type {HTMLButtonElement} */ (document.getElementById("copySnapshot"));
15
26
  const endButton = /** @type {HTMLButtonElement} */ (document.getElementById("end"));
16
- const filePathInput = /** @type {HTMLInputElement} */ (document.getElementById("filePath"));
17
27
  const copyPathButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyPath"));
28
+ const copyHint = /** @type {HTMLSpanElement} */ (document.getElementById("copyHint"));
29
+ const copyHintText = /** @type {HTMLSpanElement} */ (document.getElementById("copyHintText"));
18
30
  const presenceBanner = /** @type {HTMLDivElement} */ (document.getElementById("presenceBanner"));
31
+ const endedOverlay = /** @type {HTMLDivElement} */ (document.getElementById("endedOverlay"));
32
+ const sendHint = /** @type {HTMLSpanElement} */ (document.getElementById("sendHint"));
19
33
 
20
34
  const queued = loadQueuedPrompts();
21
35
  let annotation = true;
36
+ let ended = false;
22
37
  let agentPresence = "waiting";
23
38
  let pendingSnapshot = "";
39
+ const snapshotRequests = [];
40
+ let endAfterSubmit = false;
24
41
  let workingBubble = null;
25
42
  let submitQueuedPromise = null;
26
43
  let submitQueuedAgain = false;
27
44
  let lastScroll = { x: 0, y: 0 };
45
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
46
+ let copyHintTimer;
47
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
48
+ let sendHintTimer;
28
49
 
29
50
  function escapeHtml(value) {
30
51
  return String(value).replace(
@@ -85,6 +106,63 @@ function render() {
85
106
  const closeButton = /** @type {HTMLButtonElement} */ (button);
86
107
  closeButton.addEventListener("click", (event) => removeQueuedPrompt(Number(closeButton.dataset.index), event));
87
108
  }
109
+ updateSendState();
110
+ }
111
+
112
+ function updateSendState() {
113
+ sendButton.disabled = ended || agentPresence === "working";
114
+ sendCaret.disabled = ended || agentPresence === "working";
115
+ sendFromMenuButton.disabled = sendButton.disabled;
116
+ }
117
+
118
+ function showSendHint() {
119
+ sendHint.hidden = false;
120
+ clearTimeout(sendHintTimer);
121
+ sendHintTimer = setTimeout(() => {
122
+ sendHint.hidden = true;
123
+ }, 2600);
124
+ chatInput.focus();
125
+ }
126
+
127
+ function hideSendHint() {
128
+ clearTimeout(sendHintTimer);
129
+ sendHint.hidden = true;
130
+ }
131
+
132
+ function setMenuOpen(button, menu, open) {
133
+ menu.hidden = !open;
134
+ button.setAttribute("aria-expanded", String(open));
135
+ }
136
+
137
+ function closeMenus() {
138
+ setMenuOpen(moreButton, moreMenu, false);
139
+ setMenuOpen(sendCaret, sendMenu, false);
140
+ }
141
+
142
+ function toggleMenu(button, menu) {
143
+ const open = menu.hidden;
144
+ closeMenus();
145
+ setMenuOpen(button, menu, open);
146
+ }
147
+
148
+ async function copyText(text) {
149
+ try {
150
+ if (navigator.clipboard && navigator.clipboard.writeText) {
151
+ await navigator.clipboard.writeText(text);
152
+ return true;
153
+ }
154
+ } catch {
155
+ // Fall through to the textarea-based fallback below.
156
+ }
157
+ const helper = document.createElement("textarea");
158
+ helper.value = text;
159
+ helper.style.position = "fixed";
160
+ helper.style.opacity = "0";
161
+ document.body.appendChild(helper);
162
+ helper.select();
163
+ document.execCommand("copy");
164
+ helper.remove();
165
+ return true;
88
166
  }
89
167
 
90
168
  function addChat(role, text) {
@@ -109,8 +187,8 @@ function syncChat(chat) {
109
187
 
110
188
  function setAgentPresence(state) {
111
189
  agentPresence = state === "listening" || state === "working" ? state : "waiting";
112
- sendButton.disabled = agentPresence === "working";
113
- if (presenceBanner) presenceBanner.hidden = agentPresence !== "waiting";
190
+ updateSendState();
191
+ if (presenceBanner) presenceBanner.hidden = ended || agentPresence !== "waiting";
114
192
 
115
193
  if (agentPresence !== "working") {
116
194
  if (workingBubble) workingBubble.remove();
@@ -138,8 +216,14 @@ function postToFrame(message) {
138
216
  if (frame.contentWindow) frame.contentWindow.postMessage(message, "*");
139
217
  }
140
218
 
141
- function sendQueued() {
142
- if (agentPresence === "working") return;
219
+ function requestSnapshot(action) {
220
+ snapshotRequests.push(action);
221
+ postToFrame({ type: "lavish:requestSnapshot" });
222
+ }
223
+
224
+ function sendQueued(endAfter) {
225
+ if (ended || agentPresence === "working") return;
226
+ closeMenus();
143
227
 
144
228
  const text = chatInput.value.trim();
145
229
  if (text) {
@@ -149,9 +233,18 @@ function sendQueued() {
149
233
  chatInput.value = "";
150
234
  render();
151
235
  }
152
- if (!queued.length) return;
236
+ if (!queued.length) {
237
+ if (endAfter) {
238
+ endSession();
239
+ } else {
240
+ showSendHint();
241
+ }
242
+ return;
243
+ }
244
+ hideSendHint();
153
245
 
154
- postToFrame({ type: "lavish:requestSnapshot" });
246
+ if (endAfter) endAfterSubmit = true;
247
+ requestSnapshot("submit");
155
248
  }
156
249
 
157
250
  async function submitQueued() {
@@ -170,7 +263,14 @@ async function submitQueued() {
170
263
  submitQueuedPromise = null;
171
264
  const shouldSubmitAgain = submitQueuedAgain;
172
265
  submitQueuedAgain = false;
173
- if (succeeded && shouldSubmitAgain && queued.length) submitQueued();
266
+ if (!succeeded) {
267
+ endAfterSubmit = false;
268
+ } else if (shouldSubmitAgain && queued.length) {
269
+ submitQueued();
270
+ } else if (endAfterSubmit) {
271
+ endAfterSubmit = false;
272
+ await endSession();
273
+ }
174
274
  }
175
275
  }
176
276
 
@@ -192,23 +292,45 @@ async function submitQueuedOnce() {
192
292
  }
193
293
 
194
294
  async function endSession() {
195
- await fetch("/api/" + key + "/end", { method: "POST" });
196
- document.body.innerHTML =
197
- '<div class="bar"><div class="brand"><span class="brand-mark">Lavish</span><span class="brand-support">Editor</span></div></div><main class="ended-view"><section class="ended-card"><div class="ended-title">Session ended.</div><p class="ended-copy">Return to your agent to continue.</p></section></main>';
295
+ if (ended) return;
296
+ const response = await fetch("/api/" + key + "/end", { method: "POST" });
297
+ if (!response.ok) throw new Error("failed to end session");
298
+ ended = true;
299
+ closeMenus();
300
+ annotationSwitch.disabled = true;
301
+ moreButton.disabled = true;
302
+ chatInput.disabled = true;
303
+ updateSendState();
304
+ if (presenceBanner) presenceBanner.hidden = true;
305
+ postToFrame({ type: "lavish:setAnnotationMode", enabled: false });
306
+ endedOverlay.hidden = false;
198
307
  }
199
308
 
200
- async function copyFilePath() {
201
- try {
202
- await navigator.clipboard.writeText(filePathInput.value);
203
- } catch {
204
- filePathInput.select();
205
- document.execCommand("copy");
206
- }
309
+ function copyFilePath() {
310
+ copyText(filePath);
311
+ copyHint.classList.add("copied");
312
+ copyHintText.textContent = "Copied";
313
+ clearTimeout(copyHintTimer);
314
+ copyHintTimer = setTimeout(() => {
315
+ copyHint.classList.remove("copied");
316
+ copyHintText.textContent = "Copy";
317
+ }, 1600);
318
+ }
319
+
320
+ function copyDomSnapshot() {
321
+ closeMenus();
322
+ requestSnapshot("copy");
323
+ }
324
+
325
+ function resetFrame() {
326
+ // The iframe is sandboxed, so reload by resetting the iframe URL from chrome.
327
+ // eslint-disable-next-line no-self-assign
328
+ frame.src = frame.src;
329
+ }
207
330
 
208
- copyPathButton.textContent = "Copied";
209
- setTimeout(() => {
210
- copyPathButton.textContent = "Copy Path";
211
- }, 1200);
331
+ function reloadArtifact() {
332
+ closeMenus();
333
+ resetFrame();
212
334
  }
213
335
 
214
336
  async function reloadAfterServerRestart() {
@@ -242,8 +364,13 @@ window.addEventListener("message", (event) => {
242
364
  render();
243
365
  }
244
366
  if (msg.type === "lavish:snapshot") {
245
- pendingSnapshot = msg.snapshot || "";
246
- submitQueued();
367
+ const snapshotAction = snapshotRequests.shift() || "submit";
368
+ if (snapshotAction === "copy") {
369
+ copyText(msg.snapshot || "");
370
+ } else {
371
+ pendingSnapshot = msg.snapshot || "";
372
+ submitQueued();
373
+ }
247
374
  }
248
375
  if (msg.type === "lavish:scroll") {
249
376
  lastScroll = { x: Number(msg.x) || 0, y: Number(msg.y) || 0 };
@@ -252,34 +379,47 @@ window.addEventListener("message", (event) => {
252
379
  if (msg.type === "lavish:endSession") endSession();
253
380
  });
254
381
 
255
- annotationButton.onclick = () => {
382
+ annotationSwitch.onclick = () => {
256
383
  annotation = !annotation;
257
- annotationButton.textContent = "Annotation: " + (annotation ? "On" : "Off");
258
- annotationButton.classList.toggle("annotation-on", annotation);
384
+ annotationSwitch.setAttribute("aria-pressed", String(annotation));
259
385
  postToFrame({ type: "lavish:setAnnotationMode", enabled: annotation });
260
386
  };
261
387
 
262
- sendButton.onclick = sendQueued;
388
+ sendButton.onclick = () => sendQueued(false);
389
+ sendFromMenuButton.onclick = () => sendQueued(false);
390
+ sendAndEndButton.onclick = () => sendQueued(true);
391
+ sendCaret.onclick = () => toggleMenu(sendCaret, sendMenu);
392
+ moreButton.onclick = () => toggleMenu(moreButton, moreMenu);
263
393
  chatInput.addEventListener("keydown", (event) => {
264
394
  if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
265
395
  event.preventDefault();
266
- sendQueued();
396
+ sendQueued(false);
267
397
  }
268
398
  });
399
+ chatInput.addEventListener("input", hideSendHint);
269
400
  copyPathButton.onclick = copyFilePath;
270
- endButton.onclick = endSession;
401
+ reloadArtifactButton.onclick = reloadArtifact;
402
+ copySnapshotButton.onclick = copyDomSnapshot;
403
+ endButton.onclick = () => {
404
+ closeMenus();
405
+ endSession();
406
+ };
407
+ document.addEventListener("mousedown", (event) => {
408
+ const target = /** @type {Node} */ (event.target);
409
+ if (!moreMenu.hidden && !moreWrap.contains(target)) setMenuOpen(moreButton, moreMenu, false);
410
+ if (!sendMenu.hidden && !sendActions.contains(target)) setMenuOpen(sendCaret, sendMenu, false);
411
+ });
412
+ document.addEventListener("keydown", (event) => {
413
+ if (event.key === "Escape") closeMenus();
414
+ });
271
415
  frame.addEventListener("load", () => {
272
- postToFrame({ type: "lavish:setAnnotationMode", enabled: annotation });
416
+ postToFrame({ type: "lavish:setAnnotationMode", enabled: annotation && !ended });
273
417
  // Replay the pre-reload scroll position so hot reloads don't jump the artifact to the top.
274
418
  postToFrame({ type: "lavish:restoreScroll", x: lastScroll.x, y: lastScroll.y });
275
419
  });
276
420
 
277
421
  const events = new EventSource("/events/" + key);
278
- events.addEventListener("reload", () => {
279
- // The iframe is sandboxed, so reload by resetting the iframe URL from chrome.
280
- // eslint-disable-next-line no-self-assign
281
- frame.src = frame.src;
282
- });
422
+ events.addEventListener("reload", () => resetFrame());
283
423
  events.addEventListener("chrome-reload", () => reloadAfterServerRestart());
284
424
  events.addEventListener("agent-reply", (event) => addChat("agent", JSON.parse(event.data).text));
285
425
  events.addEventListener("chat-sync", (event) => syncChat(JSON.parse(event.data).chat || []));
package/dist/chrome.css CHANGED
@@ -165,56 +165,217 @@ body.lavish {
165
165
  position: relative;
166
166
  top: 1px;
167
167
  }
168
- .divider {
169
- width: 1px;
170
- height: 22px;
171
- background: var(--border);
172
- flex: 0 0 auto;
168
+ .spacer {
169
+ flex: 1;
173
170
  }
174
- .file-wrap {
175
- display: flex;
171
+ .annotate-switch {
172
+ display: inline-flex;
176
173
  align-items: center;
177
- height: 22px;
178
174
  gap: 8px;
179
- flex: 1 1 auto;
180
- min-width: 0;
181
175
  color: var(--fg-muted);
182
- font-family: var(--font-mono);
183
- font-size: 12px;
176
+ font-family: inherit;
177
+ font-size: var(--text-sm);
178
+ white-space: nowrap;
179
+ cursor: pointer;
180
+ user-select: none;
181
+ background: none;
182
+ border: 0;
183
+ padding: 0;
184
+ flex-shrink: 0;
184
185
  }
185
- .file-input {
186
- display: block;
187
- min-width: 0;
188
- width: auto;
189
- max-width: 100%;
186
+ .annotate-switch:disabled {
187
+ cursor: not-allowed;
188
+ opacity: 0.55;
189
+ }
190
+ .switch-track {
191
+ width: 34px;
192
+ height: 20px;
193
+ border-radius: var(--radius-pill);
194
+ background: var(--border);
195
+ position: relative;
196
+ flex-shrink: 0;
197
+ transition: background 150ms var(--ease);
198
+ }
199
+ .switch-knob {
200
+ position: absolute;
201
+ top: 2px;
202
+ left: 2px;
203
+ width: 16px;
204
+ height: 16px;
205
+ border-radius: var(--radius-pill);
206
+ background: var(--fg-faint);
207
+ transition:
208
+ left 150ms var(--ease),
209
+ background 150ms var(--ease);
210
+ }
211
+ .annotate-switch[aria-pressed="true"] .switch-track {
212
+ background: var(--accent);
213
+ }
214
+ .annotate-switch[aria-pressed="true"] .switch-knob {
215
+ left: 16px;
216
+ background: var(--accent-ink);
217
+ }
218
+ .more-wrap {
219
+ position: relative;
220
+ flex-shrink: 0;
221
+ }
222
+ .more-button {
223
+ width: 34px;
224
+ height: 34px;
225
+ display: grid;
226
+ place-items: center;
190
227
  border: 1px solid var(--border-subtle);
191
- border-radius: var(--radius-sm);
192
228
  background: transparent;
193
- color: var(--fg-muted);
194
- font: inherit;
229
+ color: var(--fg-dim);
230
+ border-radius: 9px;
231
+ cursor: pointer;
232
+ padding: 0;
233
+ }
234
+ .more-button:hover:not(:disabled),
235
+ .more-button[aria-expanded="true"] {
236
+ border-color: var(--border-strong);
237
+ background: var(--steel-700);
238
+ color: var(--fg);
239
+ }
240
+ .more-button:disabled {
241
+ cursor: not-allowed;
242
+ opacity: 0.55;
243
+ }
244
+ .menu {
245
+ position: absolute;
246
+ background: var(--bg-bar);
247
+ border: 1px solid var(--border);
248
+ border-radius: var(--radius-lg);
249
+ box-shadow: var(--shadow-tooltip);
250
+ padding: 6px;
251
+ z-index: 70;
252
+ }
253
+ .menu[hidden] {
254
+ display: none;
255
+ }
256
+ .more-menu {
257
+ right: 0;
258
+ top: 42px;
259
+ width: min(300px, calc(100vw - 24px));
260
+ }
261
+ .menu-head {
262
+ padding: 7px 10px 9px;
263
+ }
264
+ .menu-label {
265
+ font-size: 9px;
266
+ font-weight: var(--w-bold);
267
+ letter-spacing: var(--track-label);
268
+ text-transform: uppercase;
269
+ color: var(--fg-faint);
270
+ margin-bottom: 5px;
271
+ }
272
+ .menu-file {
273
+ display: flex;
274
+ align-items: center;
275
+ gap: 7px;
276
+ width: calc(100% + 8px);
277
+ text-align: left;
278
+ border: 0;
279
+ background: transparent;
280
+ padding: 3px 4px;
281
+ margin: 0 -4px;
282
+ border-radius: 7px;
283
+ cursor: pointer;
195
284
  font-family: var(--font-mono);
196
285
  font-size: 12px;
197
- line-height: 1;
198
- padding: 4px 7px;
286
+ color: var(--fg-muted);
287
+ }
288
+ .menu-file:hover {
289
+ background: var(--steel-700);
290
+ }
291
+ .menu-file > svg {
292
+ opacity: 0.7;
293
+ flex-shrink: 0;
294
+ }
295
+ .menu-file-text {
296
+ display: flex;
297
+ flex: 1;
298
+ min-width: 0;
299
+ }
300
+ .path-head {
301
+ overflow: hidden;
302
+ text-overflow: ellipsis;
303
+ white-space: nowrap;
304
+ flex: 0 1 auto;
305
+ min-width: 0;
306
+ }
307
+ .path-tail {
308
+ flex: 0 0 auto;
309
+ max-width: 100%;
310
+ overflow: hidden;
199
311
  text-overflow: ellipsis;
312
+ white-space: nowrap;
200
313
  }
201
- .copy-button {
202
- border: 1px solid var(--border);
203
- border-radius: var(--radius-sm);
204
- background: transparent;
205
- color: var(--fg-faint);
206
- font: inherit;
314
+ .copy-hint {
207
315
  font-family: var(--font-sans);
208
- font-size: 11px;
209
- font-weight: 600;
210
- line-height: 1;
211
- padding: 4px 7px;
212
- white-space: nowrap;
213
- cursor: pointer;
316
+ font-size: 10px;
317
+ font-weight: var(--w-semi);
318
+ color: var(--fg-faint);
319
+ flex-shrink: 0;
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 4px;
214
323
  }
215
- .copy-button:hover {
324
+ .copy-hint .icon-copy,
325
+ .copy-hint .icon-check {
326
+ display: flex;
327
+ }
328
+ .copy-hint .icon-check {
329
+ display: none;
330
+ }
331
+ .copy-hint.copied {
332
+ color: var(--accent-hover);
333
+ }
334
+ .copy-hint.copied .icon-copy {
335
+ display: none;
336
+ }
337
+ .copy-hint.copied .icon-check {
338
+ display: flex;
339
+ }
340
+ .menu-rule {
341
+ height: 1px;
342
+ background: var(--border);
343
+ margin: 4px 0;
344
+ }
345
+ .menu-item {
346
+ display: flex;
347
+ align-items: center;
348
+ gap: 10px;
349
+ width: 100%;
350
+ text-align: left;
351
+ padding: 9px 10px;
352
+ border-radius: var(--radius-sm);
353
+ font-family: inherit;
354
+ font-size: var(--text-sm);
355
+ border: 0;
356
+ cursor: pointer;
357
+ background: transparent;
216
358
  color: var(--fg);
217
- border-color: var(--border-strong);
359
+ }
360
+ .menu-item:hover:not(:disabled) {
361
+ background: var(--steel-700);
362
+ }
363
+ .menu-item:disabled {
364
+ cursor: not-allowed;
365
+ opacity: 0.5;
366
+ }
367
+ .menu-item > svg {
368
+ opacity: 0.8;
369
+ flex-shrink: 0;
370
+ }
371
+ .menu-item.danger {
372
+ color: var(--danger);
373
+ }
374
+ .menu-item.danger > svg {
375
+ opacity: 1;
376
+ }
377
+ .menu-item.danger:hover:not(:disabled) {
378
+ background: rgba(240, 100, 100, 0.1);
218
379
  }
219
380
  .button {
220
381
  border: 0;
@@ -243,26 +404,25 @@ body.lavish {
243
404
  opacity: 0.55;
244
405
  cursor: not-allowed;
245
406
  }
246
- .button.secondary {
247
- background: var(--border-subtle);
248
- color: var(--fg);
249
- border: 1px solid transparent;
250
- font-weight: var(--w-semi);
407
+ .split {
408
+ display: inline-flex;
409
+ align-items: stretch;
251
410
  }
252
- .button.secondary:hover:not(:disabled) {
253
- background: var(--border);
411
+ .send-main {
412
+ border-radius: var(--radius-md) 0 0 var(--radius-md);
254
413
  }
255
- .button.annotation-on {
256
- border: 1px solid var(--accent);
257
- }
258
- .button.danger {
259
- background: transparent;
260
- color: var(--danger);
261
- border: 1px solid var(--danger);
262
- font-weight: var(--w-semi);
414
+ .send-caret {
415
+ border-radius: 0 var(--radius-md) var(--radius-md) 0;
416
+ padding: 9px;
417
+ border-left: 1px solid rgba(23, 19, 10, 0.22);
418
+ display: grid;
419
+ place-items: center;
263
420
  }
264
- .button.danger:hover:not(:disabled) {
265
- background: rgba(240, 100, 100, 0.1);
421
+ .send-menu {
422
+ right: 0;
423
+ bottom: 44px;
424
+ width: 208px;
425
+ border-radius: 11px;
266
426
  }
267
427
  .layout {
268
428
  height: calc(100vh - var(--bar-h));
@@ -475,18 +635,34 @@ body.lavish {
475
635
  }
476
636
  .actions {
477
637
  display: flex;
638
+ align-items: center;
478
639
  gap: 8px;
479
640
  justify-content: flex-end;
641
+ position: relative;
480
642
  }
481
- .ended-view {
482
- height: calc(100vh - var(--bar-h));
483
- display: grid;
484
- place-items: center;
485
- padding: 24px;
486
- background: var(--bg);
643
+ .send-hint {
644
+ margin-right: auto;
645
+ color: var(--fg-faint);
646
+ font-size: 11px;
647
+ line-height: 1.35;
648
+ }
649
+ .send-hint[hidden] {
650
+ display: none;
651
+ }
652
+ .ended-overlay {
653
+ position: fixed;
654
+ inset: var(--bar-h) 0 0 0;
655
+ background: rgba(15, 17, 21, 0.86);
656
+ display: flex;
657
+ align-items: center;
658
+ justify-content: center;
659
+ z-index: 60;
660
+ }
661
+ .ended-overlay[hidden] {
662
+ display: none;
487
663
  }
488
664
  .ended-card {
489
- width: min(360px, 100%);
665
+ width: min(360px, calc(100vw - 48px));
490
666
  border: 1px solid var(--border);
491
667
  border-radius: var(--radius-xl);
492
668
  background: var(--bg-panel);
@@ -496,8 +672,8 @@ body.lavish {
496
672
  .ended-title {
497
673
  font-family: var(--font-serif);
498
674
  font-style: italic;
499
- font-size: 26px;
500
- line-height: 1.2;
675
+ font-size: 22px;
676
+ line-height: 1.3;
501
677
  color: var(--fg);
502
678
  margin-bottom: 8px;
503
679
  }
@@ -507,6 +683,7 @@ body.lavish {
507
683
  font-family: var(--font-mono);
508
684
  font-size: 12px;
509
685
  line-height: 1.45;
686
+ overflow-wrap: anywhere;
510
687
  }
511
688
  iframe {
512
689
  width: 100%;
@@ -518,21 +695,6 @@ iframe {
518
695
  body {
519
696
  overflow: auto;
520
697
  }
521
- .bar {
522
- min-height: var(--bar-h);
523
- height: auto;
524
- align-items: flex-start;
525
- flex-wrap: wrap;
526
- padding: 10px 12px;
527
- }
528
- .divider {
529
- display: none;
530
- }
531
- .file-wrap {
532
- order: 3;
533
- flex-basis: 100%;
534
- font-size: 11px;
535
- }
536
698
  .layout {
537
699
  height: calc(100vh - var(--bar-h));
538
700
  grid-template-columns: 1fr;
package/dist/cli.mjs CHANGED
@@ -706,6 +706,7 @@ function playbookIds() {
706
706
  // src/server.js
707
707
  import { EventEmitter } from "node:events";
708
708
  import { readFile as readFile2 } from "node:fs/promises";
709
+ import { homedir } from "node:os";
709
710
  import path3 from "node:path";
710
711
  import chokidar from "chokidar";
711
712
  import express from "express";
@@ -1107,12 +1108,9 @@ var SessionStore = class {
1107
1108
  if (!session) {
1108
1109
  return { status: "missing" };
1109
1110
  }
1110
- if (session.status === "ended") {
1111
- return { status: "ended" };
1112
- }
1113
1111
  const prompts = session.prompts || [];
1114
1112
  if (prompts.length === 0) {
1115
- return { status: "waiting" };
1113
+ return session.status === "ended" ? { status: "ended" } : { status: "waiting" };
1116
1114
  }
1117
1115
  const result = {
1118
1116
  status: "feedback",
@@ -1122,7 +1120,9 @@ var SessionStore = class {
1122
1120
  session.prompts = [];
1123
1121
  session.pending_prompts = 0;
1124
1122
  session.dom_snapshot = "";
1125
- session.status = "open";
1123
+ if (session.status !== "ended") {
1124
+ session.status = "open";
1125
+ }
1126
1126
  session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1127
1127
  await this.writeState(state);
1128
1128
  return result;
@@ -1697,9 +1697,47 @@ function computePresence(key, activePolls, deliveredFeedback) {
1697
1697
  if (deliveredFeedback.has(key)) return "working";
1698
1698
  return "waiting";
1699
1699
  }
1700
+ function chromeIcon(paths, size = 16, strokeWidth = 1.7) {
1701
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
1702
+ }
1703
+ var chromeIcons = {
1704
+ more: chromeIcon(
1705
+ '<circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="12" cy="19" r="1.4"/>'
1706
+ ),
1707
+ file: chromeIcon(
1708
+ '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
1709
+ 13
1710
+ ),
1711
+ copy: chromeIcon(
1712
+ '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
1713
+ 12
1714
+ ),
1715
+ check: chromeIcon('<polyline points="20 6 9 17 4 12"/>', 12),
1716
+ refresh: chromeIcon(
1717
+ '<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/>',
1718
+ 15
1719
+ ),
1720
+ camera: chromeIcon(
1721
+ '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3z"/><circle cx="12" cy="13" r="3"/>',
1722
+ 15
1723
+ ),
1724
+ exit: chromeIcon(
1725
+ '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
1726
+ 15
1727
+ ),
1728
+ send: chromeIcon('<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4Z"/>', 14),
1729
+ caret: chromeIcon('<path d="m6 9 6 6 6-6"/>', 13, 2)
1730
+ };
1731
+ function displayPathParts(file, home = homedir()) {
1732
+ const normalizedFile = file.replaceAll("\\", "/");
1733
+ const normalizedHome = home.replaceAll("\\", "/");
1734
+ const display = normalizedHome && normalizedFile.startsWith(`${normalizedHome}/`) ? `~/${normalizedFile.slice(normalizedHome.length + 1)}` : normalizedFile;
1735
+ const tailStart = display.lastIndexOf("/") + 1;
1736
+ return { head: display.slice(0, tailStart), tail: display.slice(tailStart) };
1737
+ }
1700
1738
  function createChromeHtml(session) {
1701
- const fileInputSize = Math.max(1, session.file.length);
1702
- const sessionJson = jsonScript({ key: session.key, initialChat: session.chat || [] });
1739
+ const sessionJson = jsonScript({ key: session.key, file: session.file, initialChat: session.chat || [] });
1740
+ const { head: pathHead, tail: pathTail } = displayPathParts(session.file);
1703
1741
  return `<!doctype html>
1704
1742
  <html>
1705
1743
  <head>
@@ -1709,8 +1747,9 @@ function createChromeHtml(session) {
1709
1747
  <link rel="stylesheet" href="/chrome.css">
1710
1748
  </head>
1711
1749
  <body class="lavish">
1712
- <div class="bar"><div class="brand"><span class="brand-mark">Lavish</span><span class="brand-support">Editor</span></div><div class="divider" aria-hidden="true"></div><div class="file-wrap" title="${escapeHtml(session.file)}"><input class="file-input" id="filePath" readonly size="${fileInputSize}" value="${escapeHtml(session.file)}"><button class="copy-button" id="copyPath" type="button">Copy Path</button></div><button class="button secondary annotation-on" id="annotation">Annotation: On</button><button class="button danger" id="end">End Session</button></div>
1713
- <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-downloads" src="/artifact/${session.key}/index.html"></iframe></div><aside class="panel"><h2>Conversation</h2><div class="chat" id="chatLog"></div><div class="composer"><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><div class="annotation-pills" id="annotationPills"></div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="actions"><button class="button" id="send">Send to Agent</button></div></div></aside></div>
1750
+ <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><button class="annotate-switch" id="annotation" type="button" aria-pressed="true"><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><div class="menu-rule"></div><button class="menu-item danger" id="end" type="button">${chromeIcons.exit}<span>End session</span></button></div></div></div>
1751
+ <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-downloads" src="/artifact/${session.key}/index.html"></iframe></div><aside class="panel"><h2>Conversation</h2><div class="chat" id="chatLog"></div><div class="composer"><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><div class="annotation-pills" id="annotationPills"></div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="actions" id="sendActions"><span class="send-hint" id="sendHint" hidden>Write a message or annotate an element first.</span><div class="split"><button class="button send-main" id="send">Send to Agent</button><button class="button send-caret" id="sendCaret" type="button" title="Send options" aria-haspopup="menu" aria-expanded="false">${chromeIcons.caret}</button></div><div class="menu send-menu" id="sendMenu" hidden><button class="menu-item" id="sendFromMenu" type="button">${chromeIcons.send}<span>Send to Agent</span></button><button class="menu-item danger" id="sendAndEnd" type="button">${chromeIcons.exit}<span>Send &amp; end session</span></button></div></div></div></aside></div>
1752
+ <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>
1714
1753
  <script id="lavish-session" type="application/json">${sessionJson}</script>
1715
1754
  <script src="/chrome-client.js"></script>
1716
1755
  </body>
@@ -1905,7 +1944,7 @@ function normalizePagePath(path5) {
1905
1944
  // src/cli.js
1906
1945
  var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "stop", "server", "playbook", "design", "setup"]);
1907
1946
  var DESCRIPTION = "Lavish Editor helps agents turn rich HTML artifacts into collaborative human review surfaces. Whenever you are about to give user a complex response that will be easier to understand via a rich / interactive page, consider using Lavish Editor. First generate an interactive HTML artifact according to user request, then run `lavish-axi <html-file>` so the user can visually review it, annotate elements or selected text, queue prompts, and send feedback back through `lavish-axi poll`.";
1908
- var VERSION = "0.1.20";
1947
+ var VERSION = "0.1.22";
1909
1948
  async function run(argv) {
1910
1949
  await ensureStateDir();
1911
1950
  const normalizedArgv = normalizeArgv(argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",