Two bugs, one cause each.
It read the previous answer. setBusy(false) is what announces
assistant-answer-done, and it ran before lastAnswer was assigned — so every
listener was handed the answer before last. It now fires after the answer exists
both in that variable and on the page. A test asserts the order, because the
order is the whole bug.
And it read the markdown. The better answer than unpicking the markup is not to
have any: the rendered bubble is already the answer with its headings, emphasis
and tables resolved, so voice mode reads that. It cannot drift from what the
reader is looking at, and it needs no rules about what "##" sounds like. Read
from a clone, with the parts that are not the answer removed — the action
buttons, the sources list, the follow-up suggestions, code blocks and tables —
so the page itself is untouched. A bubble still thinking is never read.
speakableText() stays as the fallback for when the bubble cannot be found, since
raw markdown read aloud is worse than silence.
Separately: e2e/seed.js hashed with bcrypt directly, so seeded accounts did not
exercise the argon2id path production writes. It uses the app's own hasher now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Ending the call stopped listening and aborted the request but never stopped
playback, and the <audio> element was a local variable with no handle kept — so
nothing could stop it and the answer talked on until the page was reloaded.
conversationMode now holds whatever is speaking, whichever of the two players it
is, and hanging up silences it before anything else. Starting a new turn does
the same, because speaking over the previous answer is how an assistant talks
past you, and a reply that arrives after the overlay has closed no longer starts
talking into a closed call.
The other half: the raw answer went straight to the speaker. It is markdown, so
a browser voice reads "#" and "**" aloud or stumbles over them. speakableText()
now reduces it to what a person would read out — headings and bullets become
sentences, links keep their words, emphasis and code fences are dropped, tables
are dropped entirely because a table read aloud is noise, and a removed citation
marker leaves no gap before the punctuation it preceded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Measured in a real browser against the app rather than reasoned about.
The transcript boxes are contenteditable divs, and an editable div zooms on
focus exactly like an <input>. The earlier 16px sweep covered input, textarea
and select, so every workspace tab still zoomed while the calculators did not —
which is exactly what was reported. Every focusable text control in every tab
now measures 16px at phone width; the count of ones below it is zero.
Three ways a recording could end with nothing to show for it:
- Safari supports none of the audio/webm types and throws NotSupportedError
when handed one. Six modules built their own recorder on resume with
"opus, else audio/webm", so resuming threw there and the recording stopped.
There is now one codec chain in the app, and no module constructs a
MediaRecorder of its own.
- audio-recorder-failed is dispatched on document, and the encounter tab
stopped its recording on any of them. The assistant's microphone failing
ended a consultation being recorded in another tab. The recorder now
travels with the event and the listener checks it is its own.
- The server answers {success:true, text:''} for silence, and five modules
assigned that straight into the transcript — emptying the box the browser
had been filling live. It reads as a recording that vanished. Text is now
required before overwriting, and a recording that captured nothing says so
instead of resetting the button over an empty box.
Also: the citation counters were registered on prom-client's default registry
while the app serves its own, so they were never scraped. They read zero at
/metrics now instead of being absent, which is what the Grafana panels need.
And the reference linter passes for the first time, so scripts/e2e.sh gets past
its preflight: KaTeX is vendored (it was referenced by the assistant's LaTeX
rendering but never shipped — three 404s a page load and no math), and the
JavaScript left behind by the removed image picker, saved-chats toggle, image
gallery and visual-output panel is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
registration_enabled was a single switch: open to anyone, or closed to
everyone. This adds the setting an operator actually wants in between — open
to people you invited.
A code is single-use, expires (7 days by default, 90 maximum), and can be
revoked or deleted. It is stored hashed with only its last four characters
kept, because an invite grants account creation and a database dump should
not hand someone a working one. The code is readable exactly once, in the
response that creates it.
The claim is a single conditional UPDATE carrying every condition, so two
registrations racing the same code cannot both succeed. It happens after the
account exists, so a code is never spent on a failed registration — and if
the race is lost, the just-created account is removed rather than left behind
as a free registration. The rejection never says which of the four reasons
applied; distinguishing them would tell someone probing codes which guesses
were closer.
Codes avoid I, L, O and U so they survive being read aloud or copied off a
screen, and matching ignores case and separators.
The sign-up field appears only when the server says a code is required. The
admin card creates, lists, revokes and deletes, and carries the toggle.
Verified against the live database: create, claim, second claim refused,
unknown code refused, revoking a used code refused, delete. 684 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
iOS zoom (and the menu button scrolling away with it)
- Safari zooms the whole page when a text field under 16px takes focus and
never zooms back; while zoomed, fixed chrome sits outside the visual
viewport, which is why the menu button had to be scrolled to. All 144
visible text controls were 13-15px. They are 16px on phones now.
!important is deliberate: per-component rules like `.demo-field select`
outrank any element selector, and a new component would slip through.
- The composer set 15px AFTER its own mobile 16px rule, so at equal
specificity the base rule won; the size is per breakpoint now.
- Pinch-zoom is untouched: no user-scalable=no. Desktop unchanged (13/15px).
Composer
- "Ready" / "Looking up sources..." / "saved" repeated what the send button
already shows by turning into Stop, and on a phone they wrapped under the
model control. Kept in the DOM as live regions; nothing is drawn.
- The model control shows the word "Model" (76px) instead of ids as long as
"openrouter-gemini-3.1-flash-image-preview", and opens the full list on
click. The select stays as the state holder, so choosing still saves
through the same change event.
Search
- Nothing typed lists the 19 destinations only; sub-items appear once there
is something to match.
- Listing, matching and opening now read one attribute list instead of three
that could drift, and bedside's emergency sections are searchable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The list is simply there now. Its collapse preference is removed too, so a
list someone had collapsed cannot come back hidden with no control to open
it. The header's CSS (desktop, phone and preview) goes with it.
Sizes, measured in Chromium before and after: switch, New chat and the
name card are unchanged (desktop card 848, phone 790). The list takes the
header's 16px, and the first date heading sits 10px below New chat on both
desktop (167 -> 151) and phone (173 -> 165; phone list top padding 2px ->
10px), matching the menu's 10px rhythm. The rail itself still never scrolls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
- Collapsed menu: its own plain white 52px column against the tiled ground,
no border line, in both views. Flat, it had no edge.
- Collapsed + workspace launcher: assistant.css set the rail track to 0 and
beat the 52px rule, so the collapsed menu (name card included) vanished.
- Search follows the view, not the URL: the workspace launcher keeps
/assistant, so Workspace searched chats.
- Create image, "Base it on": a native <select> list is as wide as its
longest option, so full titles pushed it past the dialog. Labels now keep
the whole words that fit the select's width; the full title is on hover.
Verified in Chromium: account menu items on top in all 8 states; collapsed
strip 52px white with the name card at the same place in all 3 views;
search placeholder and results per mode; dropdown widest label 500px in a
506px box (279 in 280 on a phone).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
Three faults, all visible on a phone at once.
Two hamburgers, neither useful. The assistant added a drawer button of its own
AND the app's button was still there, stacked on top of it. Worse, the assistant
hid the app sidebar unconditionally, so the app's button opened something
invisible. A phone now has exactly one menu — the app sidebar — and the assistant
only replaces it with its own rail on desktop.
The collapse control had nothing to collapse on a phone, where the menu is a
sheet rather than a rail. It closes the sheet instead.
Sources were `display:none` below 640px with no alternative, so every SRC chip in
an answer pointed at nothing. They now slide up as a dismissible sheet, opened by
a button that appears only when an answer actually has citations, or by tapping a
citation itself. Tapping away or pressing Escape closes it — a sheet with no way
out is a trap.
The menu itself is sized for a thumb, borrowing the reference layout: a wide
sheet, close and search leading, tappable rows, and padding clear of the home
indicator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161xNW1z4vPusrXKGWcQdQu
The Create image popup showed "Generating image…" forever even after the job
finished. The status poll called fetchAssistantImageJob, which was never
imported, so every tick threw ReferenceError — and the catch treated that like a
transient network failure and rescheduled, permanently. The import is added, a
test now asserts that every api.js function the assistant calls is actually
imported, and the poll distinguishes a programming error (surface it) from a
transient one (retry, but not forever).
Chat titles were hard-cut at 60 characters mid-word, so "Rickets Radiographic
Fea" was all the Create image picker could ever show. The server already allows
160, so titles now keep whole words up to that, and each view decides its own
visible length from the width it actually has rather than inheriting one cut made
at save time. A single very long token still falls back to a hard cut.
Extension cards led with the number at 20px with word-break:break-all, so
"5616/3764/5619" wrapped as "5616/3764/56 19" — unreadable, and unsafe to dial
from. The name leads now, since that is what the eye hunts for in a list of
fifty; the number follows in tabular figures and may only break between groups,
never inside a run of digits. Cards share a minimum height so a grid reads as
rows rather than a ragged mosaic.
The collapsed rail's brand kept its expanded margin-right:auto, which pushed the
stethoscope off the axis the two buttons sat on. Every child of the collapsed
head is now the same centred fixed-size box.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd
Gallery tiles are 56px but were downloading the full ~280kB original. Previews
are now rendered with sharp and stored beside the originals in the same MinIO
bucket under a thumbs/ prefix, so nothing about credentials, lifecycle or backup
changes. Measured on live assets: 216-294kB originals become 13-19kB at 256px,
about 16x smaller; 640px is about 4x.
Both paths, as asked:
- Rendered when a job completes, so the first viewer never waits for a resize.
A preview failure never unmakes a finished job.
- Rendered on demand for anything that has none — the existing 26 images work
immediately with no backfill required, and the result is stored for next time.
Boundaries that matter more than the speed:
- Only 256 and 640 are honoured. An open width parameter would let a caller
drive arbitrary resizes.
- Permission is checked against the ORIGINAL before a preview is served, so a
preview can never widen who can see an image.
- Previews carry their own SHA-256 and owner headers, because the client
verifies both on every asset; sending the original's checksum would be
rejected as tampering, which is that check working correctly.
- Still private, no-store. The client asset pattern was widened to exactly
?w=256 and ?w=640 and nothing else.
Client-side downscaling stays as the fallback when a preview cannot be produced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd
Generated assets are served `private, no-store`, which is right for a clinical
app on a shared workstation — but it meant every gallery render re-downloaded
every image. Measured: 26 images averaging 279kB, so 7.2MB fetched to draw 56px
tiles, on every open of the Create image popup.
Two changes, both reusable anywhere in the app:
- A session cache holding decoded blobs in MEMORY ONLY, so nothing is written to
disk and the no-store posture is unchanged. Entries are keyed by owner ticket
as well as asset, and cleared when the account boundary moves, so one account
can never read another's bytes out of memory. Concurrent tiles asking for the
same asset share one request rather than racing.
- Any img carrying data-image-thumb gets a downscaled copy instead of the
original, so a 56px tile no longer decodes a 300kB image. The gallery asks for
256px and the in-chat preview for 640px; opening the full view still gets the
original. Browsers without OffscreenCanvas, and any decode failure, fall back
to the full image rather than showing nothing.
This does not reduce the first fetch. Serving genuinely smaller bytes needs
server-side resizing, which needs an image library this project does not carry —
worth a deliberate decision rather than adding a native dependency in passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
The header bar is gone. Settings and Log out moved into the account card, so a
whole band of chrome had nothing left to hold. The brand now heads the menu
alongside the search and collapse controls, on one row rather than three stacked
full-width ones. btn-settings and btn-logout are retained hidden as the canonical
handlers other code already calls, rather than duplicating that logic.
One shell for every view. Each tab was a plain white page while the assistant and
the workspace launcher sat on a tiled card, so opening any menu item changed the
whole face of the app. The tiled ground and the card edge now belong to
.main-content, and only the contents differ. The assistant replaces the shell
card rather than nesting a second one inside it.
Search, in both views, from the same palette: the workspace searches the app
menu, the assistant searches saved chats. Both sources are already in memory, so
this needs no new endpoint. Cmd/Ctrl+K opens it, arrows and Enter work, and the
assistant publishes its chats rather than the palette reaching into its
internals.
The collapse icon is now directional angles that rotate when collapsed, so it
always states what the click will do; fa-table-columns said nothing.
Mobile: the sidebar slides in whole, so the in-sidebar collapse control is hidden
there, the account card sticks to the foot of a scrolling menu, and the palette
is full-bleed instead of a centred dialog on a small screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
Sources (correcting what I built earlier)
The previous toggle branched the SYSTEM PROMPT, so the same question could get a
different answer depending on a display setting — the bias this was meant to
avoid. The prompt is now unconditional: buildSystemPrompt takes no display
argument and is byte-identical either way. Hiding sources happens on the way out
— the server omits them and strips the now-orphaned [n] markers from the copy it
sends. The answer is generated, stored and exported with citations intact, so
turning the setting back on restores them without re-asking anything. Renamed to
clinical_assistant.show_sources; the old key is still honoured.
Signed-out preview (admin opt-in, default off)
A visitor may try the assistant; reaching for the workspace asks them to sign in.
Deliberately narrow:
- Reachable paths are an exact allow-list, not a pattern, so a new endpoint is
private unless someone adds it on purpose.
- A preview visitor gets no identity at all (id: null), so nothing can be owned,
saved, billed or addressed to them.
- The image tool is withheld rather than left to fail on a null owner, and no
audit rows are written.
- A caller presenting a token is authenticated normally, so preview can never
downgrade a real session; if the setting cannot be read, authentication is
required.
- Actions needing an account are hidden rather than offered and refused.
Composer
The bar above the transcript is gone. Patient take home, Export PDF, Download
transcript and Attach images moved into a + menu in the composer, and the model
selector moved beside send — shown only when there is more than one model, as
before. Both views now start at the same top edge, so switching modes cannot
nudge the page up or down. On an empty transcript the tiled ground runs behind
and below the composer, which floats on it above centre.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
The app IS workspace mode, so the switch belongs in both places and the only
difference between the two screens is which pill is highlighted:
- The app sidebar's "AI Assistant" nav entry is replaced by the switch, with
Workspace highlighted. The assistant rail carries the same control with
Assistant highlighted. Clicking Assistant from the app navigates to
/assistant; inside the assistant, Workspace opens the launcher in place.
- Accent corrected: I had it backwards last round and turned the assistant
purple. The app's accent is blue (.tab-btn.active in styles.css); the
assistant now uses it throughout, so neither screen looks like a different
product. Citation chips keep purple so evidence stays distinct from
interactive blue.
- One menu toggle labelled "Hide menu", shared by the app sidebar and the
assistant rail. It replaces three separate controls: a pin button, a floating
expand button, and the assistant's own rail toggle. The toggle stays reachable
when the rail it collapses is hidden, or there would be no way back.
- Workspace cards list clinical work only. Settings, FAQ, Admin, Docs and
Content Manager stay in the app sidebar under Account, where they belong.
- Model availability is discovery-only again: a hand-typed id cannot be verified
at that point, so the manual entry field is gone.
The in-page mode state machine is removed along with the whole class of
hidden-versus-display bugs it carried.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
The mode switch was toggling the `hidden` attribute on elements that each carry
their own `display:` rule. A class selector beats the UA stylesheet's
[hidden]{display:none}, so every one of those toggles silently did nothing —
which is why workspace mode still showed the chat, and why coming back never
restored the saved-chat list. Every affected element was in that state.
CSS now owns both modes from a single body class, in one block, and the JS does
nothing but set that class. No element.hidden juggling remains.
One accent. The assistant used --blue for user bubbles, focus rings, blockquotes
and example pills while citations, cards and the mode switch used --purple, so
the two halves read as two different apps. Purple was already dominant (23 uses
to 6) and is the assistant's identity, so blue is gone entirely. Source cards
pick up the same shape, shadow and hover accent as the workspace cards, and a
targeted citation highlights in the accent instead of a bare border.
Go back is removed from both the topbar and the rail: the Assistant/Workspace
switch is how you leave now, and the dead handler went with it.
Create image no longer takes a full-width gradient row competing with New chat;
it is a square icon button beside it with a tooltip and an aria-label.
Also guards the delegated document listeners against double registration.
initIfNeeded already prevented a second bind in production, but nothing stopped
a stray bindEvents() from double-firing every click — which is exactly what the
test harness hit, activating a tab twice from one click.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
Both surfaces render from the same pass over the app's real .tab-btn list, so
neither can drift from the menu it mirrors.
- The card view now shares the empty state's graph-paper ground, card language
and spacing, and is centred with a max width, so switching modes reads as the
same app instead of a page that looks nothing like it.
- Cards carry the clinical work tabs only: Settings, Admin, Docs and FAQ stay in
the rail list, where someone looking for them will go, rather than sitting
alongside Encounter HPI and Calculators.
- On phones the card grid is hidden entirely and the chat stays on screen — the
drawer already is the menu there, so a second copy only pushed it away.
- The Sources column and its 330px grid track both go in workspace mode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
Workspace mode rendered the app menu twice — once in the rail and again as a
card grid that replaced the chat column — so clicking it landed you somewhere
that looked nothing like the assistant. There is one menu now: the rail swaps,
the chat column stays where it is.
The Sources panel was already hidden in workspace mode, but its 330px grid track
was not, leaving an empty gutter. Both collapse states now drop the track.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
Borrowed from the Tinfoil chat layout, keeping our own palette:
- The empty state leads with a title and a single status pill instead of an
oversized icon plus two paragraphs, so the composer is what the eye lands on.
- Example prompts are raised pill buttons rather than flat outlines.
- A faint graph-paper ground behind the transcript makes the composer read as a
lifted surface; it switches off as soon as there are messages, where it would
fight the text.
- New chat carries its Ctrl+Shift+O shortcut on the right, and the shortcut now
actually works — scoped to the assistant workspace so it does not hijack the
key elsewhere in the app.
- Saved Chats is a collapsible group with a chevron, remembered per browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
The rail's Workspace section was a collapsible extra sitting under the chats.
It is now the second of two modes, selected by a segmented switch at the top of
the rail:
- Assistant keeps the chat column, the saved-chat list, New chat and Create image.
- Workspace REPLACES the chat column and the Sources panel with the app's own
menu — a card grid in the main column and the same list in the rail — so
nothing from the assistant is left half-visible beside it.
Both the rail list and the cards are built from the app's real .tab-btn
elements, so a tab added, renamed or hidden in index.html follows automatically.
Learning Hub moved down from second place to sit immediately before Content
Manager, with the other content tools rather than above the clinical ones.
Also removed dead CSS for .assistant-view-switch, .assistant-learning-view and
#assistant-learning-root: that markup no longer exists anywhere in the app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
New setting clinical_assistant.citations_enabled (default true, admin checkbox).
With it off, retrieval, grounding and every other rule are unchanged — answers
are still built only from retrieved sources — but:
- buildSystemPrompt swaps only the citation block: the "cite factual claims with
[1]" rules are replaced with "do not include citations, source numbers or
bracketed markers", and the note that the sourcing requirement itself is
unchanged. Grounding, scope, table formatting and tone rules are byte-identical
between the two modes.
- The server strips any stray [n] the model emits anyway, from the stored answer
rather than only the view, so saved chats and exports match what was shown.
- No sources are sent to the client at all, and the status endpoint reports the
mode so the UI hides the Sources panel and gives its 330px column back to the
chat instead of showing an empty rail.
Validated as a boolean in adminConfig, like the feature.* keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
Mobile
body.assistant-workspace .assistant-layout is both later in the file and more
specific than the .assistant-layout rule inside @media (max-width:640px), so
phones were inheriting height: calc(100vh - 64px) — an offset for the app header
that workspace mode already hides — plus grid rules on a flex container. The
topbar then overlapped the content. Added a matching-specificity mobile override
restoring 100dvh and the flex layout.
Workspace menu in the rail
The saved-chats rail gains a collapsible Workspace section listing the app's own
tabs, so the assistant sidebar carries both its chats and the workspace
navigation. It is built from the real .tab-btn elements rather than a second copy
of the menu, so tabs added, renamed or hidden in index.html follow automatically
and admin-only tabs stay hidden.
Prompt
Rewritten as principles instead of an enumerated rulebook: "respond to the user's
latest message, not to an earlier one", "if it carries no question, ask what they
would like you to look up", "never repeat a previous answer", and for the tool
"use it when the latest message asks for a picture, or for a change to one you
just made, and not otherwise". No example words in any language remain — the
model reads the message as written. 1032 -> 788 characters.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
Saved chats
Grouped the way Open WebUI groups them: Pinned, Today, Yesterday, Previous 3
days, Previous 7 days, Previous 30 days, then calendar months (with the year
once it is not the current one). Chats with no usable timestamp land in Undated
rather than disappearing.
Sidebar toggle
The collapse button used <i class="fas fa-sidebar">, which is a Font Awesome PRO
icon; on the Free 6.5.0 build this app loads it rendered nothing, so the toggle
has been an invisible button since it shipped. It now uses fa-table-columns,
keeps aria-expanded/aria-controls in sync, flips its label between "Hide saved
chats" and "Show saved chats" so the collapsed state is escapable, and animates.
Acknowledgements
"Окей", "Nice", "Perfect", "gracias" and friends now get the existing
"What clinical question would you like me to look up?" reply instead of a
retrieval and a paid generation. "yes", "sure", "no" and "more" are deliberately
excluded: answers end by offering more detail, so those must still be answered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
Translation formatting
LibreTranslate's text mode destroys markdown syntax. Verified against the live
container: table pipes come back as "←", the |---| delimiter row is translated
as prose ("Silencio."), and "**bold**" returns as "** bold**" which no longer
renders. Its html mode leaves tags — and bare [n] markers — completely intact.
Messages and the patient take home are now rendered to HTML, simplified (maths
and UI chrome flattened to text), and translated as HTML. Citation chips are
re-linked from the returned markers afterwards, which is the step the original
html path was missing. A text-mode fallback remains for builds that reject html.
Repeat image generation
Typing "Окей" or "Nice" after an image turn produced another image every time:
the model saw its own "I'll generate an educational image…" in the history and
repeated it. Recognising acknowledgements in every language is not possible, so
the rule is inverted — a short follow-up (<=3 words) that mentions nothing about
a picture does not get the image tool offered at all when the previous assistant
turn produced an image. Terse repeat requests ("again", "ещё", "another one")
still work. The worst case is that a terse question is answered in text.
In-chat images
Generated images render as a 320x240 thumbnail instead of filling the bubble,
and the image itself opens the full-resolution preview.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
Translation
- Stop scrubbing markdown before sending it to LibreTranslate. The scrub
deleted ordered-list numbering ("1. Give amoxicillin" -> "Give amoxicillin"),
flattened tables into ambiguous whitespace and ate underscores inside
identifiers. Raw markdown now goes to the translator unchanged.
- Render the translation through the same markdown pipeline as the original
bubble, with the message's own sources, so [n] markers come back as the usual
clickable .assistant-cite chips instead of escaped literal text. Headings,
lists and tables survive with them.
- When the translator drops citation markers, surface the affected sources in a
recovery block rather than letting the evidence disappear.
- Image cards are live nodes: they are now re-attached on every path out of a
translation (success, failure and Show original), so a failed translation no
longer silently removes a generating image from the message.
Patient take home
- Add a language selector to the take-home modal, reusing the existing
/translate endpoint and offering only what the local LibreTranslate reports.
- Copy, Export and Email carry what the caregiver is actually reading; the
original stays canonical behind "Original".
Conversation budget
- The admin field no longer prefills with the environment value, which turned
the next Save into an accidental override and made the documented "leave
empty to use the environment" path unreachable. The effective limit is shown
as a placeholder instead.
- Report source 'default' honestly instead of naming an unset env var.
- The load-failure notice now lands on the <p> instead of an <input>'s
textContent, where it rendered nothing.
- One validator for the budget everywhere: conversationLimit() replaces a
parseInt that accepted "120000abc".
Other
- /assistant is addressed by its URL, not by ped_last_tab, so "/" no longer
reopens the assistant; the URL follows tab changes and Back leaves it.
- Remove the dead DeepL path (it referenced an undefined DEEPL_BASES) and stop
offering admins a provider the server silently ignores.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq