phase: 123_chat_image_questions
All gates green. Verification complete. **Phase 123 — final verification pass (all 4 tasks already in `complete/`)** - Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer. - `uv run pytest` → **2796 passed**, exit 0 (unit + integration). - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%). - `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation. - `uv run ruff check . && uv run pyright` → clean (0 errors). **Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run). No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
This commit is contained in:
+262
-10
@@ -306,6 +306,14 @@ const sendBtn = document.querySelector("#send-btn");
|
||||
const sendLabel = document.querySelector("#send-label");
|
||||
const sendStatus = document.querySelector("#send-status");
|
||||
const turnLoader = document.querySelector("#turn-loader"); // phase 109 (D16): the persistent in-turn loader — ships hidden; setUiState is its sole visibility owner
|
||||
// Phase 123 (task 02, TODO L6): the attach control family — the
|
||||
// paperclip button (hidden until the boot /api/config says
|
||||
// `images: true`), its hidden file-input backend, the preview strip
|
||||
// (hidden until a pick), and the strip's remove button.
|
||||
const attachBtn = document.querySelector("#attach-btn");
|
||||
const attachFile = document.querySelector("#attach-file");
|
||||
const attachPreview = document.querySelector("#attach-preview");
|
||||
const attachRemove = document.querySelector("#attach-remove");
|
||||
const banner = document.querySelector("#kb-banner");
|
||||
const bannerText = document.querySelector("#kb-banner-text");
|
||||
const versionEl = document.querySelector("#app-version");
|
||||
@@ -927,8 +935,17 @@ const USER_AVATAR =
|
||||
* scrolls only when the caller passes `scroll = true` — the user submit
|
||||
* (reveal my message) and the phase-14 restore landing. The streaming
|
||||
* path (thinking / tool / delta) creates bubbles with the default
|
||||
* (scroll = false): the page never follows a turn. */
|
||||
function addMessage(who, html, scroll = false) {
|
||||
* (scroll = false): the page never follows a turn.
|
||||
*
|
||||
* Phase 123 (task 02, TODO L6): the optional `image` argument —
|
||||
* { src, alt } for a USER bubble carrying the question's attached
|
||||
* image (attachedImage → the live data URL; task 03's restore → the
|
||||
* stored path). The attachment is part of the question, so the img
|
||||
* lands at the TOP of the bubble (above the text) through
|
||||
* attachBubbleImage — ONE renderer for live + restore + shared. A
|
||||
* null image (every text-only message, every brain message) leaves
|
||||
* the bubble byte-identical to pre-phase. */
|
||||
function addMessage(who, html, scroll = false, image = null) {
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `msg ${who}`;
|
||||
@@ -937,11 +954,45 @@ function addMessage(who, html, scroll = false) {
|
||||
<div class="msg-body">
|
||||
<div class="bubble">${html}</div>
|
||||
</div>`;
|
||||
if (who === "user" && image) {
|
||||
attachBubbleImage(wrap.querySelector(".bubble"), image.src, image.alt);
|
||||
}
|
||||
messagesEl.appendChild(wrap);
|
||||
if (scroll) scrollReveal(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): the question's image in a user bubble
|
||||
* — the ONE renderer (task 03 reuses it for the restore and the shared
|
||||
* page): the img is built createElement-style (no HTML strings, the
|
||||
* house rule), capped height + full-width safe (a tall portrait must
|
||||
* not blow the chat column — .msg-image in styles.css), lazy-loaded
|
||||
* (the restore's stored paths re-fetch on demand), alt = the
|
||||
* accessible name (the filename live, task 03's restore choice). The
|
||||
* image PREPENDS the text: the attachment is part of the question. */
|
||||
function attachBubbleImage(bubble, src, alt) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "msg-image";
|
||||
img.src = src;
|
||||
img.alt = alt || "attached image";
|
||||
img.loading = "lazy";
|
||||
// Phase 123 (task 03, TODO L6): the load failure — the STORED file
|
||||
// was deleted out-of-band (the record keeps its path, the render
|
||||
// degrades): the img is replaced IN PLACE by the small "image
|
||||
// unavailable" line (never a broken-image icon). In practice only
|
||||
// the restore's stored path can 404 (a live data URL is inline); the
|
||||
// shared page carries its own copy of the same degradation (the
|
||||
// per-page duplication house style).
|
||||
img.onerror = () => {
|
||||
const note = document.createElement("span");
|
||||
note.className = "msg-image-unavailable";
|
||||
note.textContent = "image unavailable";
|
||||
img.replaceWith(note);
|
||||
};
|
||||
bubble.prepend(img);
|
||||
return img;
|
||||
}
|
||||
|
||||
function addTyping() {
|
||||
removeTyping(); // idempotent: at most one indicator at a time
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
@@ -1268,6 +1319,26 @@ let leavePartialIndex = -1; // index of this turn's pagehide partial (-1 = none)
|
||||
let turnAbort = null; // AbortController of the in-flight turn (null idle)
|
||||
let stoppedByUser = false; // the Stop button took this turn (not the guard)
|
||||
|
||||
/* Phase 123 (task 02, TODO L6; locked A5): the composer's ATTACHED
|
||||
* image — the { file, name, dataUrl } triple, held until send. The
|
||||
* bytes NEVER touch the chat payload: the send flow uploads the File
|
||||
* to POST /api/chat-images and the record + the request body carry the
|
||||
* returned STORED PATH (A5: never base64). The data URL feeds two
|
||||
* local things only — the preview thumbnail and the LIVE user bubble
|
||||
* (no fetch needed); the restore (task 03) re-renders from the stored
|
||||
* path instead. null = no attachment — the text-only path, byte-
|
||||
* identical to pre-phase (request body, record, bubble). */
|
||||
let attachedImage = null;
|
||||
let attachUpload = false; // phase 123: one upload at a time (double-fire guard — the upload is the first await in handleSend; a second submit mid-upload is a no-op, the first owns the send)
|
||||
|
||||
/* The six extensions the upload endpoint accepts (phase 122's image
|
||||
* set, one list — the server re-validates on the upload; this pre-check
|
||||
* only keeps a bad pick from opening a state change + a wasted
|
||||
* round-trip, and it checks the file NAME's extension: the accept
|
||||
* attribute is advisory, and a drag-pasted or renamed file can carry
|
||||
* any extension the server will 422 anyway). */
|
||||
const ATTACHABLE_IMAGE_EXTENSIONS = ["bmp", "gif", "jpeg", "jpg", "png", "webp"];
|
||||
|
||||
function stopThinkingClock() {
|
||||
if (thinkingClock) {
|
||||
clearInterval(thinkingClock);
|
||||
@@ -1788,7 +1859,22 @@ function renderStoredMessage(m) {
|
||||
// the default SCROLL (smooth; "auto" under prefers-reduced-motion)
|
||||
// instead of the old forced "auto" — noted per the phase-42 task.
|
||||
if (m.who === "user") {
|
||||
addMessage("user", renderMarkdown(m.text), true);
|
||||
const wrap = addMessage("user", renderMarkdown(m.text), true);
|
||||
// Phase 123 (task 03, TODO L6): the restored record may carry the
|
||||
// question's attached image — `m.image`, the STORED PATH (A5:
|
||||
// never base64; a pre-phase / text-only record has no key at all,
|
||||
// so it renders byte-identically — no img). It lands through the
|
||||
// SAME one bubble-image renderer the live send uses
|
||||
// (attachBubbleImage — the live bubble passed the data URL, the
|
||||
// restore passes the stored path; the helper takes any src): the
|
||||
// img at the top of the user bubble, above the text. A load
|
||||
// failure degrades inside the helper (deleted out-of-band file →
|
||||
// the small "image unavailable" line, never a broken icon). A
|
||||
// re-ask (retryLastTurn) re-sends prev.text only (locked A7) —
|
||||
// this bubble's restored attachment is untouched by the redo.
|
||||
if (typeof m.image === "string" && m.image) {
|
||||
attachBubbleImage(wrap.querySelector(".bubble"), m.image, m.text || "attached image");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wrap = addMessage("brain", renderMarkdown(m.text), true);
|
||||
@@ -2355,6 +2441,7 @@ function startNewChat() {
|
||||
input.value = "";
|
||||
autoGrow();
|
||||
updateCharCount(); // phase 104: the cleared composer hides the counter again
|
||||
clearAttachedImage(); // phase 123: the attachment is composer draft state — it resets with the conversation
|
||||
input.focus();
|
||||
sendStatus.textContent = "New chat started — previous conversation cleared.";
|
||||
}
|
||||
@@ -2460,6 +2547,70 @@ function retryLastTurn(wrap) {
|
||||
return runTurn(text, { reask: true });
|
||||
}
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): the send flow's UPLOAD STEP (locked
|
||||
* A8) — the attached File goes to POST /api/chat-images as multipart
|
||||
* (the session cookie rides the browser; the endpoint is user-gated
|
||||
* like the turn it feeds) and the returned STORED path is what the
|
||||
* record + the /api/chat body carry (A5: never base64). ANY failure —
|
||||
* 413 over the cap, 422 a bad extension (a renamed file the client
|
||||
* pre-check missed), 5xx, or a network drop — settles the
|
||||
* phase-114-style OUT-OF-TURN banner with the server's detail and
|
||||
* returns null: the send is BLOCKED (the question is never sent without
|
||||
* the image the user attached — the typed text stays, the attachment
|
||||
* stays for the retry). */
|
||||
async function uploadAttachedImage(file) {
|
||||
let res;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
res = await fetch("/api/chat-images", { method: "POST", body: form });
|
||||
} catch {
|
||||
// Network drop before the server answered — no detail to show.
|
||||
showErrorBanner("Couldn't attach the image — try again.");
|
||||
return null;
|
||||
}
|
||||
let detail = "";
|
||||
let path = null;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (typeof body?.detail === "string") detail = body.detail;
|
||||
if (typeof body?.path === "string") path = body.path;
|
||||
} catch { /* non-JSON error body — the status line stands in */ }
|
||||
if (!res.ok || !path) {
|
||||
showErrorBanner(
|
||||
detail
|
||||
? `Couldn't attach the image — ${detail}.`
|
||||
: "Couldn't attach the image — try again."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): the preview strip — revealed with the
|
||||
* attached image's data-URL thumbnail + filename (the thumbnail is
|
||||
* decorative, alt="" in the static markup — the filename beside it is
|
||||
* the readable label), and cleared with the attachment (the remove button,
|
||||
* the send, or a New chat). The strip's markup is static (hidden by
|
||||
* default); only the thumbnail's src + the name's textContent move
|
||||
* here (the createElement/textContent house rule — no HTML strings).
|
||||
* Idempotent: a fresh pick re-renders the same strip in place. */
|
||||
function showAttachPreview() {
|
||||
if (!attachedImage || !attachPreview) return;
|
||||
attachPreview.querySelector("img").src = attachedImage.dataUrl;
|
||||
attachPreview.querySelector(".attach-preview-name").textContent = attachedImage.name;
|
||||
attachPreview.hidden = false;
|
||||
}
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): clear the attachment + hide the strip
|
||||
* (idempotent — calling it with nothing attached is a no-op). The
|
||||
* strip must not linger into a turn, and a New chat resets the
|
||||
* composer's draft (the question text + its attachment) together. */
|
||||
function clearAttachedImage() {
|
||||
attachedImage = null;
|
||||
if (attachPreview) attachPreview.hidden = true;
|
||||
}
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault();
|
||||
// Phase 48: while a turn is in flight the Send button IS the Stop
|
||||
@@ -2479,6 +2630,26 @@ async function handleSend(e) {
|
||||
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
|
||||
return;
|
||||
}
|
||||
// Phase 123 (task 02, TODO L6; locked A8): an attached image uploads
|
||||
// FIRST — BEFORE the input is cleared, so a failed upload BLOCKS the
|
||||
// send and the typed question stays exactly where the user left it
|
||||
// (the question is never sent without the image the user attached;
|
||||
// the banner says what failed, the attachment stays for the retry).
|
||||
// The returned STORED path (never the bytes, A5) then rides runTurn
|
||||
// into the record + the request body.
|
||||
let image = null; // { path, src, alt } | null — null = text-only send
|
||||
if (attachedImage) {
|
||||
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
|
||||
attachUpload = true;
|
||||
const path = await uploadAttachedImage(attachedImage.file);
|
||||
attachUpload = false; // the helper never throws (every failure path is a banner + null)
|
||||
if (path === null) return; // A8: the send is blocked — the question stays
|
||||
image = {
|
||||
path, // the record + the /api/chat body (A5: the path, never base64)
|
||||
src: attachedImage.dataUrl, // the live bubble (no fetch); restore uses the path
|
||||
alt: attachedImage.name, // the filename (task 03's restore picks its own alt)
|
||||
};
|
||||
}
|
||||
// Phase 49: the user append + persistence save point 1 moved into
|
||||
// runTurn with the rest of the turn — the `reask` flag skips them on
|
||||
// the redo-in-place retry path (the question is already in the DOM +
|
||||
@@ -2487,7 +2658,7 @@ async function handleSend(e) {
|
||||
autoGrow();
|
||||
updateCharCount(); // phase 104: the sent question clears the counter with the input
|
||||
clearErrorBanner();
|
||||
await runTurn(text, { reask: false });
|
||||
await runTurn(text, { reask: false, image });
|
||||
}
|
||||
|
||||
/* Phase 120 (TODO.md L3–4, locked A1): the single funnel for every
|
||||
@@ -2561,17 +2732,38 @@ function finalizeFailedTurn(detail, { acc, thinking, tools, wrap, leavePartialIn
|
||||
* finally settle moved here verbatim, and the turn-local resets (acc,
|
||||
* thinkingAcc, sawThinking, sawDone, toolAcc, stoppedByUser, turnAbort)
|
||||
* stay turn-scoped exactly as phase 48 left them. */
|
||||
async function runTurn(text, { reask = false } = {}) {
|
||||
async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
if (!reask) {
|
||||
addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
|
||||
// Phase 123 (task 02, TODO L6): the attached image rides the user
|
||||
// bubble (the data URL live — the stored path is the fallback, so
|
||||
// any future caller passing only a path still renders) and the
|
||||
// STORED RECORD (the path, A5: never base64). A null image (every
|
||||
// text-only send, every re-ask — A7: a redo re-sends the text
|
||||
// only) leaves the bubble and the record byte-identical to
|
||||
// pre-phase.
|
||||
addMessage(
|
||||
"user",
|
||||
renderMarkdown(text),
|
||||
true, // reveal my message (owner-kept)
|
||||
image ? { src: image.src || image.path, alt: image.alt } : null
|
||||
);
|
||||
// Persistence save point 1: the question is stored the moment it is
|
||||
// sent, so a failed/interrupted turn never loses it.
|
||||
conversation.push({ who: "user", text });
|
||||
// sent, so a failed/interrupted turn never loses it. The `image`
|
||||
// key (the stored path) joins the `bor.chat.v1` record only when an
|
||||
// attachment exists (A5 — the phase-14 shape gains one optional key;
|
||||
// a text-only record is byte-identical to pre-phase).
|
||||
conversation.push(
|
||||
image ? { who: "user", text, image: image.path } : { who: "user", text }
|
||||
);
|
||||
saveConversation();
|
||||
// Phase 55 (A2): the auto-save rides the save point — an unlinked
|
||||
// conversation creates its row here (auto-title, server-side), a
|
||||
// linked one refreshes. Fire-and-forget: it never blocks the turn.
|
||||
persistConversation();
|
||||
// Phase 123 (task 02): the strip must not linger into the turn —
|
||||
// cleared AFTER the bubble is rendered (the bubble already holds
|
||||
// the image; the record holds the path; a failed turn keeps both).
|
||||
clearAttachedImage();
|
||||
}
|
||||
|
||||
let wrap = null;
|
||||
@@ -2632,10 +2824,19 @@ async function runTurn(text, { reask = false } = {}) {
|
||||
text: m.text,
|
||||
thinking: m.who === "brain" ? m.thinking || undefined : undefined,
|
||||
}));
|
||||
// Phase 123 (task 02, TODO L6): the attached image's STORED path
|
||||
// rides the body top-level (A5: the path, never base64; the server
|
||||
// builds the multimodal content from the stored bytes). Added only
|
||||
// when present — a text-only body omits the `image` key entirely
|
||||
// (byte-identical to pre-phase). HISTORY entries stay {who, text,
|
||||
// thinking}: prior turns' images are never replayed (locked A7 —
|
||||
// the image is turn-local to the original send).
|
||||
const payload = { message: text, history };
|
||||
if (image) payload.image = image.path;
|
||||
res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text, history }),
|
||||
body: JSON.stringify(payload),
|
||||
signal: turnAbort.signal, // phase 48: the Stop button aborts the fetch
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
@@ -3008,6 +3209,48 @@ input.addEventListener("keydown", (e) => {
|
||||
});
|
||||
composer.addEventListener("submit", handleSend);
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): the attach flow. The button (revealed
|
||||
* at boot ONLY when the config flag says images on) opens the hidden
|
||||
* file input; a pick is validated CLIENT-side against the six
|
||||
* extensions (the server re-validates on upload — a bad pick gets the
|
||||
* out-of-turn banner and NO state change: a previous attachment, if
|
||||
* any, survives), then the { file, name, dataUrl } triple lives in
|
||||
* attachedImage until the send flow uploads it. The remove button clears
|
||||
* the state + hides the strip; a fresh pick replaces the triple in
|
||||
* place (the strip re-renders through showAttachPreview). */
|
||||
attachBtn?.addEventListener("click", () => attachFile?.click());
|
||||
attachRemove?.addEventListener("click", () => {
|
||||
clearAttachedImage();
|
||||
attachBtn?.focus(); // back to the trigger (reachable only while the strip is visible — which means the button is too)
|
||||
});
|
||||
attachFile?.addEventListener("change", () => {
|
||||
const file = attachFile.files && attachFile.files[0];
|
||||
attachFile.value = ""; // the same file re-picked must fire change again
|
||||
if (!file) return;
|
||||
// The file NAME's extension (lowercased) is the client pre-check —
|
||||
// the accept attribute is advisory (a renamed file can carry any
|
||||
// extension); the upload endpoint is the authority (422).
|
||||
const dot = file.name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? file.name.slice(dot + 1).toLowerCase() : "";
|
||||
if (!ATTACHABLE_IMAGE_EXTENSIONS.includes(ext)) {
|
||||
showErrorBanner(
|
||||
"Only PNG, JPEG, WebP, GIF, and BMP images can be attached."
|
||||
);
|
||||
return; // no state change — a previous attachment survives
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
attachedImage = { file, name: file.name, dataUrl: String(reader.result) };
|
||||
showAttachPreview();
|
||||
};
|
||||
reader.onerror = () => {
|
||||
// The bytes never became readable (the file evicted mid-pick) —
|
||||
// the same copy as an upload failure; no state change.
|
||||
showErrorBanner("Couldn't attach the image — try again.");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save binding is
|
||||
* GONE with the pill — there is no Save control; persistConversation()
|
||||
* auto-saves headless at the save points (fire-and-forget, quiet on
|
||||
@@ -3086,8 +3329,17 @@ window.addEventListener("pagehide", () => {
|
||||
// proven), so a restored conversation of a configured admin gets the
|
||||
// "Save as doc" button exactly once: no flash, no re-render, no
|
||||
// second fetch (the brand fetch IS the config fetch).
|
||||
await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
|
||||
const bootConfig = await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
|
||||
docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;
|
||||
// Phase 123 (task 02, TODO L6): the attach control reveals ONLY when
|
||||
// the SAME settled config says images: true (the brand boot's ONE
|
||||
// /api/config request — no second round-trip). Off (the default) or
|
||||
// an unanswered fetch → the button stays hidden for good: the
|
||||
// flag-off DOM is byte-identical to pre-phase (A5's default-off
|
||||
// contract), and a degraded boot degrades quietly (the loadHealth
|
||||
// house style — the page never breaks, the affordance is simply
|
||||
// absent; the API contract still enforces the toggle server-side).
|
||||
if (attachBtn) attachBtn.hidden = bootConfig?.images !== true;
|
||||
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
|
||||
// Phase 55 (task 03): no Share-reveal step — the pill is static,
|
||||
// always-visible markup (visible to every visitor, phase 51 contract).
|
||||
|
||||
@@ -318,9 +318,42 @@ function addStoppedNote(wrap) {
|
||||
meta.appendChild(note);
|
||||
}
|
||||
|
||||
/* The question's attached image (phase 123, task 03, TODO L6) — the
|
||||
* local copy of the chat page's attachBubbleImage (the per-page
|
||||
* duplication house style: this file keeps its own small copies of
|
||||
* the chat page's message-fragment builders). The SAME .msg-image
|
||||
* treatment the chat page uses — styles.css is shared by both pages,
|
||||
* so the rule needs no second copy: the img at the TOP of the user
|
||||
* bubble (the attachment is part of the question), lazy, alt = the
|
||||
* record's text or the fallback. The load failure degrades IDENTICALLY
|
||||
* to the chat page: the img is replaced by the small "image
|
||||
* unavailable" line (the stored file was deleted out-of-band — the
|
||||
* record keeps its path, the render degrades; never a broken icon).
|
||||
* The serve route is public (the token is the shared chat's
|
||||
* credential, like saved-chat content), so the img loads for guests
|
||||
* exactly as it does for the owner. */
|
||||
function addBubbleImage(wrap, src, alt) {
|
||||
const bubble = wrap?.querySelector?.(".bubble");
|
||||
if (!bubble) return;
|
||||
const img = document.createElement("img");
|
||||
img.className = "msg-image";
|
||||
img.src = src;
|
||||
img.alt = alt || "attached image";
|
||||
img.loading = "lazy";
|
||||
img.onerror = () => {
|
||||
const note = document.createElement("span");
|
||||
note.className = "msg-image-unavailable";
|
||||
note.textContent = "image unavailable";
|
||||
img.replaceWith(note);
|
||||
};
|
||||
bubble.prepend(img);
|
||||
}
|
||||
|
||||
/* One stored record through the SAME .msg structure the chat page
|
||||
* uses (pixel-parity with the chat page's restore path): user → the
|
||||
* .msg.user bubble; brain → the .msg.brain bubble with the optional
|
||||
* .msg.user bubble (with the question's attached image when the
|
||||
* record carries the stored path — phase 123, task 03); brain → the
|
||||
* .msg.brain bubble with the optional
|
||||
* thinking block (restored COLLAPSED — phase 17), the tool lines,
|
||||
* the deflection treatment + the plain-text "Maybe try" chips, the
|
||||
* plain-text source chips, and the stopped note. NO interactive
|
||||
@@ -331,7 +364,16 @@ function addStoppedNote(wrap) {
|
||||
* unchanged. */
|
||||
function renderSharedMessage(m) {
|
||||
if (m.who === "user") {
|
||||
addSharedMessage("user", renderMarkdown(m.text));
|
||||
const wrap = addSharedMessage("user", renderMarkdown(m.text));
|
||||
// Phase 123 (task 03, TODO L6): the question's attached image —
|
||||
// the record's `m.image` carries the STORED PATH (A5: never
|
||||
// base64; a pre-phase record has no key at all, so it renders
|
||||
// byte-identically — no img). The public image route makes the
|
||||
// shared view faithful: the SAME bubble treatment, alt, and
|
||||
// load-failure degradation as the chat page's restore.
|
||||
if (typeof m.image === "string" && m.image) {
|
||||
addBubbleImage(wrap, m.image, m.text || "attached image");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
|
||||
|
||||
@@ -551,6 +551,42 @@ body::before {
|
||||
}
|
||||
.msg.user .bubble code { background: color-mix(in srgb, var(--bg) 16%, transparent); }
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): the question's IMAGE in the user
|
||||
bubble — the attachment is PART of the question, so it sits at the
|
||||
TOP of the bubble (above the text). Capped height (a tall portrait
|
||||
must not blow the chat column) + full-width safe; the theme's
|
||||
bordered-image treatment (the .source-image-img language) — the
|
||||
border gives the bytes a boundary against the brand bubble fill. */
|
||||
.msg-image {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
/* Phase 123 (task 03, TODO L6): the question's image is UNAVAILABLE —
|
||||
the stored file was deleted out-of-band (the record keeps its
|
||||
path, the render degrades): the small muted line takes the img's
|
||||
place at the top of the user bubble on BOTH pages (the chat
|
||||
restore and the shared view render the same record shape through
|
||||
the same .msg/.bubble structure, and share this stylesheet). It
|
||||
inherits the bubble's text color — the user bubble's text already
|
||||
passes 4.5:1 (PLAN §7.2), so the note does too; the smaller size +
|
||||
italic make it read as a note, never a broken-image icon. The
|
||||
margin-bottom mirrors .msg-image's, so the text below keeps its
|
||||
spacing either way. */
|
||||
.msg-image-unavailable {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.msg.brain .bubble { border-bottom-left-radius: 4px; }
|
||||
.msg.brain.is-deflected .bubble {
|
||||
background: var(--accent-bg);
|
||||
@@ -1581,6 +1617,62 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
color, never color alone (B3). */
|
||||
.char-count { margin: 0; text-align: right; font-size: 0.75rem; line-height: 1.2; color: var(--ink-soft); }
|
||||
.char-count.is-max { color: var(--err-ink); }
|
||||
/* Phase 123 (task 02, TODO L6): the ATTACH PREVIEW STRIP — the
|
||||
selected image ABOVE the input row (thumbnail ≤48px + the filename
|
||||
+ the remove button): a surface card in the .chat-bottom stack, between
|
||||
the char-count line and the composer. The name is ellipsized
|
||||
(AA-safe --ink on --surface = 13.8:1) — a long filename never widens
|
||||
the strip; the thumbnail is a fixed 48px cover box (the preview
|
||||
crops, the bubble shows the whole image); the remove button keeps the
|
||||
44px touch
|
||||
target (PLAN §7.1) with a destructive hover (--err-ink on --err-bg =
|
||||
9.3:1 — text + color, never color alone, B3). Hidden by default
|
||||
(the global [hidden] rule) — revealed only while a file is attached,
|
||||
cleared with the send so the strip never lingers into a turn. */
|
||||
.attach-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.45rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.attach-preview-img {
|
||||
flex: none;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.attach-preview-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
.attach-preview-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.attach-preview-remove svg { width: 18px; height: 18px; }
|
||||
.attach-preview-remove:hover { background: var(--err-bg); color: var(--err-ink); }
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -1605,6 +1697,31 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
background: transparent;
|
||||
}
|
||||
.composer textarea::placeholder { color: var(--ink-soft); }
|
||||
/* Phase 123 (task 02, TODO L6): the composer's ATTACH CONTROL — the
|
||||
paperclip glyph button LEFT of the input. Hidden in the markup until
|
||||
app.js reveals it from the config's images flag (the global [hidden]
|
||||
rule keeps it out of the flag-off DOM — A5's default-off contract).
|
||||
A neutral cut of the .send-btn family: the same 44px hit target +
|
||||
radius + the global :focus-visible ring (PLAN §7.2); surface fill
|
||||
with a muted hover step (the icon: --ink-soft on --surface = 5.1:1,
|
||||
hover --brand-ink on --brand-soft = 12.4:1 — both past the 3:1
|
||||
non-text floor). */
|
||||
.attach-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.attach-btn svg { width: 20px; height: 20px; }
|
||||
.attach-btn:hover { background: var(--brand-soft); border-color: var(--brand-soft); color: var(--brand-ink); }
|
||||
.send-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user