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 +3 -1
- package/dist/chrome-client.js +227 -1
- package/dist/chrome.css +234 -8
- package/dist/cli.mjs +57 -20
- package/package.json +1 -1
- package/plugin.json +1 -1
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
|
-
|
|
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.
|
package/dist/chrome-client.js
CHANGED
|
@@ -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) =>
|
|
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(--
|
|
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
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
1355
|
-
grid-template-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
9257
|
-
|
|
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
|
-
|
|
9261
|
-
|
|
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
|
-
|
|
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 & 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 & 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.
|
|
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
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.
|
|
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",
|