Live Markdown, stop, rewind, custom picker, dialogs
Seven things. **Reasoning starts closed.** The answer is what the reader is waiting for; the thinking is one click away. **Image borders.** .attachments__image was a block-level <a>, so its border stretched the full column around a narrow picture. inline-block, and the frame is the picture. Same fix for the composer thumbnail. **Markdown now renders during the stream.** The generator re-renders the answer so far and sends it as a `render` event at most every 100ms, swapped with innerHTML, instead of appending escaped tokens and formatting everything at the end. Re-rendering whole rather than appending is the point: a list or a code fence is only correct once its context exists, and partial syntax resolves itself as more arrives. Measured against a live model: 29 render events, formatting visible from the first content token. **Stop button.** A stop request goes into an in-process set the generator checks between chunks; whatever arrived is kept, because a half-written answer the reader chose to cut short is still worth having. Measured: stream ended 0.2s after the request, 1155 characters preserved, message marked stopped rather than errored. Navigating away does the same thing via CancelledError. **Rewind and edit.** Edit one of your own turns and everything after it is deleted, then the conversation runs on from there. Deliberately not branching: that needs a UI for choosing between versions, and "go back and try again from here" is what was asked for. The form states how many messages will be discarded before you confirm. **Custom model picker.** A <select> renders only text in an <option>, so it can never show an avatar. Built from buttons and a hidden input, with descriptions, capability tags, a filter box past eight models, and arrow-key navigation written out by hand since there is no native widget doing it. **Notification system.** lembas.notify/confirm/prompt in ui.js, built on <dialog> so focus trapping, Escape and page inertness come from the browser. htmx:confirm is intercepted, so every existing hx-confirm gets the themed dialog with no change at the call site; the browser's grey confirm() is gone from every template. 230 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,8 +89,7 @@
|
||||
}
|
||||
/* User turns and mid-stream assistant text are plain text, so newlines and
|
||||
runs of spaces have to survive. */
|
||||
.msg__body--plain,
|
||||
.msg__body--streaming { white-space: pre-wrap; }
|
||||
.msg__body--plain { white-space: pre-wrap; }
|
||||
|
||||
.msg--user .msg__body--plain {
|
||||
background: var(--bubble-user);
|
||||
@@ -106,7 +105,6 @@
|
||||
/* Shown until the first token arrives, then hidden by the sibling selector
|
||||
below -- no JavaScript involved in either direction. */
|
||||
.msg__waiting { padding: var(--sp-2) 0; }
|
||||
.msg__body--streaming:not(:empty) + .msg__waiting { display: none; }
|
||||
|
||||
.dots { display: inline-flex; gap: 0.25rem; align-items: center; }
|
||||
.dots i {
|
||||
@@ -124,8 +122,9 @@
|
||||
30% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
/* A caret trailing the text while it streams. */
|
||||
.msg__body--streaming::after {
|
||||
/* A caret trailing the text while it streams. Attached to the last block so it
|
||||
sits at the end of the prose rather than on a line of its own. */
|
||||
.msg__body--live:not(:empty) > :last-child::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.45rem;
|
||||
@@ -198,6 +197,34 @@
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* --- Stop, notes and editing ---------------------------------------------- */
|
||||
.msg__waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
/* Once the answer has content the caret carries the "still going" signal, so
|
||||
the dots go, but Stop must stay reachable until the stream ends. */
|
||||
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; }
|
||||
|
||||
.msg__stop { color: var(--ink-muted); }
|
||||
.msg__stop:hover { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
.msg__note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin: var(--sp-2) 0 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.msg--editing .msg__main { width: 100%; }
|
||||
.edit-form { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.edit-form .textarea { min-height: 4rem; }
|
||||
|
||||
/* --- Message actions ------------------------------------------------------ */
|
||||
.msg__actions {
|
||||
display: flex;
|
||||
@@ -416,6 +443,7 @@
|
||||
.chip--error .chip__icon { color: var(--danger); }
|
||||
|
||||
.chip__thumb {
|
||||
display: block;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -455,19 +483,22 @@
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
/* inline-block, not block: as a block the anchor filled the column and drew its
|
||||
border at full width around a narrow image. Now the frame is the picture. */
|
||||
.attachments__image {
|
||||
display: block;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
line-height: 0;
|
||||
}
|
||||
.attachments__image img {
|
||||
display: block;
|
||||
max-width: min(22rem, 100%);
|
||||
max-height: 20rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
.attachments__doc {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user