417 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cda74e1fe2 |
fix: trim the account-boundary message to what a reader needs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
ac1adddd50 |
fix: the account boundary explains itself instead of reading as an SSO failure
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 13s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Tapping SSO on a frozen realm did nothing at all — no navigation, no message. The guard is correct: a page bound to one account must not start a login for another. But silence is indistinguishable from a broken button, and it was reported as "single sign on error" when no SSO request had been made; the server logged no OIDC activity because the click never left the page. The button now says what is happening and names the control that clears it. The guard itself is unchanged — same checks, same order, same outcomes. The recovery screen said "your saved credentials no longer match this session", which describes the mechanism rather than the situation. The ordinary cause is signing in as a different account on a device that already held one. It now says that, and says plainly that nothing was lost and no data was mixed — which is the reassurance the safeguard has earned and was not giving. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
74aa0c1b89 |
feat: Enter sends in the assistant, and the person chooses
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 54s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m23s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Enter made a newline and Ctrl+Enter sent, which is backwards from every chat people use. Enter now sends by default, with a toggle in the composer's + menu to put it back. Two rules hold whatever is chosen, because they are the habits people arrive with and a setting that broke either would be worse than no setting: Shift+Enter is always a newline, Ctrl/Cmd+Enter always sends. Both are checked before the preference, so neither can be switched off. A keystroke during IME composition never sends. Enter accepts a candidate word in Chinese, Japanese and Korean, and on predictive Android keyboards; sending there would cut a sentence off mid-word. Stored per device rather than per account, because a keyboard preference belongs to the keyboard: Enter-to-send suits a desk and usually does not suit a phone, where Enter is how you get a second line. Unset, it defaults by device class — send where there is a real keyboard, newline on a touch screen — and a blocked localStorage falls through to that default rather than throwing. The composer's tooltip says which key sends, where someone already looks when they wonder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
b1e039d834 |
fix: a newly added model reaches every picker, including the user-facing ones
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 1m53s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The first pass at this covered the two admin cards. It missed the pickers that matter most: the per-tab model selectors in app.js, which every clinical tab uses, and the My Resources model dropdown. Both were filled once at page load, so a model added in Admin was still invisible where people actually choose one. app.js's boot fetch is now a named loadModelList() that also runs on models-changed; My Resources re-runs loadOptions(), which is the same call that decides whether the model row is shown at all. Both rebuilds keep a choice already made. These selects can be rebuilt while someone is halfway through a form, and silently moving them off the model they picked would be worse than not refreshing. Verified against a mutation: removing the app.js listener fails the test that says every picker listens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
7305443243 |
fix: the last run's illustration no longer sits under an empty form
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
app.js loads a tab's component once and marks it data-loaded, so the DOM survives leaving and returning. Nothing cleared the result area, and an illustration from a previous generation stayed on screen under a blank form as though it were output for a topic nobody had typed. A full page refresh rebuilt the component and cleared it, which is why it looked like a leak that fixed itself. Cleared at the start of a generation, at the start of a modification, and on re-entering the tab — not on the first visit, where there is nothing to clear. Covers the illustration area, the searches line, the image-failure line and the status text. Also documented what a modification can actually change. The deck vocabulary is structural — bullets, compare, table, callout, figure, image, section, title — and none of those carries a colour; the palette is fixed in render_pptx.py and the model never sees it. So "make it yellow" lands on the only field that takes a colour, image_prompt, and yellow figures appear on an otherwise blue deck. That is not modify reaching only the images; it is the model using the one lever the schema gives it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
31e634e0ce |
fix: a model added in Admin now appears everywhere models are chosen
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Adding a discovered model refreshed exactly one dropdown — the default-model one, in the same card. Every other picker had been filled when the admin tab loaded, behind a guard that makes its loader run once per visit, so the Clinical Assistant chat model, the allowed-models list, the slide reviewer and the image-model selects all kept the roster they were given. The model was genuinely added; it simply could not be selected until the page was reloaded, which reads as the add having failed. Every mutation of the roster — add, remove, clear-all, enable/disable — now dispatches `models-changed`, and the cards that list models listen and refetch. The event carries no payload: a listener re-reads the list itself, so there is one source of truth rather than a copy to keep in step. Same pattern as the existing `assistant-image-roster` event. Each listener clears its own guard before re-running, and returns early if a load is already in flight. The assistant loader keeps unsaved drafts, so re-running it costs nothing but a refreshed set of options. The add toast said "now select it as default and click Set Default" — advice that only made sense when the default dropdown was the one thing that updated. Verified against a mutation: removing the guard reset fails the propagation test, because the listener then fires into a loader that returns early. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
fce05a2749 |
fix: the active library view is unmistakable, and a leading question cannot mislead the vision model
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Documents and Images were two buttons of the same weight, which reads as two buttons rather than as a current view and an alternative. The active one now carries the surface, the weight and a hairline — keyed off aria-selected, so the visible highlight and what a screen reader announces cannot disagree. The look_at_image question is written by a model that has not seen the image, from what the user said, so it can presume something that is not there — "describe this rash" about a photograph of a drug chart. Answering the presumption would send it back as fact. The seeing model is now told the question may assume something absent, and to say so first and describe what is actually there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
03621752e8 |
feat: image fallback chains for every workflow, and a library worth looking at
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**Fallbacks.** One image model meant a refusal, a rate limit or a model the gateway had since dropped ended as a missing picture. Every workflow now tries its model, then each fallback in order, stopping at the first that produces an image. Primary plus two, capped: each hop is a paid request, and a chain long enough to need a cap is long enough to surprise someone. My Resources previously had no fallback at all — only the Clinical Assistant did, and only one. That is backwards: a missing figure is most visible in a deck, where it leaves a hole in a slide. The retry rule is now a classifier that says *why*, rather than a boolean. Transient faults, a 404 for a model the gateway does not have, and a content refusal all move to the next model — a refusal because policy is a vendor decision, not a fact about the request. 401/403 stop immediately (one gateway, one set of credentials, the next model fails identically), as do 413 and any other 4xx, which are malformed everywhere. Refusals are recognised from the message: no provider sends a machine-readable reason and the status varies. Each hop re-leases the job, so a chain cannot outlive its claim and let a second worker repeat the same paid work, and the row records the model actually being paid for so a picture made by the third model is not attributed to the first. The old singular `fallback_image_model` is still read, so an existing configuration keeps working without anyone re-entering it. **Library.** Documents/Images tabs in My Resources, with a real grid: fixed aspect tiles so the rows line up whatever shape the pictures are, a source badge on the picture, two-line prompt, hover lift, shimmer skeletons while thumbnails land, and a lightbox that closes on Escape or the backdrop and restores focus. Actions are hidden on hover only behind `@media (hover:hover)` — hiding delete behind :hover would put it out of reach on touch and keyboard. Downloads go through privateImageBlob rather than a bare `<a download href>`: a mobile client's session is a bearer token an anchor cannot send, and these assets are served no-store on purpose. The gallery lives in My Resources only. Assistant images appear in it, which was the point; the assistant page does not grow a gallery of its own, and a test asserts no assistant module lists the endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
259b4858be |
feat: an image library — every picture this account has made
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A figure outlives the deck it was drawn for: the deck gets replaced, the diagram is still good. Until now a generated image could only be seen in the resource it was made for, and there was no way to find one again or to delete it. Library → Images is a grid of every finished image the account owns, across all three workflows, newest first. GET /api/generated-images is scoped by owner_id in the statement rather than filtered after, returns only finished jobs — an unfinished one is a broken frame in a gallery — and pages by keyset, because a gallery that grows while you scroll repeats or skips rows under OFFSET. Most of this already existed. Thumbnails were already rendered at 256 and 640 by sharp and already served by ?w=, with their own checksum so the client's tamper check passes on a derived copy; hydrateImage already handles auth, the account boundary and caching. The tiles ask for the 256px preview, so thirty of them cost a few kB each rather than thirty full-size downloads, and the prompt is decrypted for the caption because it is the only human-readable label an image has. Deleting needed new work. The storage interface had no remove at all, so a delete that dropped the row would have left the object and both previews in the bucket — paid for, and still readable by anything with credentials. Storage now removes all three keys, and the bytes go before the row: a row pointing at a missing object is a broken image in a gallery, while an object without its row is only wasted space, and unreachable storage refuses the delete outright rather than reporting a success that left the picture behind. THUMB_WIDTHS now has one definition, in generatedImageStorage. Two copies drift, and the drift that matters is a width that gets written and never deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e244ee5240 |
feat: modifying a deck can see it
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 24s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model editing a deck could not see it, which made most of what people actually ask for unanswerable: "that slide is too crowded", "the diagram is in the wrong place", "this one looks empty" are facts about the rendered page, not about the JSON. When a vision model is configured, modifying now renders the current deck — with its figures, unlike the review pass, which runs while they are still being drawn — and hands the model one image per slide alongside the JSON. Same pipeline as review, reused rather than reimplemented: pptx, Gotenberg, PDF, pdftoppm, capped at MAX_SLIDES. The vision model then does the editing, which is a second and separately measured benefit. On a real 20-slide deck, ds-deepseek-v4-flash returned the deck unchanged for "make it better" — the echo reported yesterday — while openrouter-gemini-3.8-flash applied it. So the stronger model fixes the echo even without sight. A model the author picks explicitly still wins over both. The result is rendered and reviewed again. Generation-only was the old rule, on the reasoning that refining is a text edit; it is not. The edit is made against how the deck looked before it, so a slide that gains two bullets only overflows once it is rendered again. The reviewer may reposition but is held to the same words, so a verification pass cannot quietly undo what was just asked for. Whether an instruction achieved anything is judged on the model's edit, before the reviewer runs, or a reviewer nudging a slide into two columns would mask an instruction that did nothing. Sight is an upgrade, never a dependency: no vision model, Gotenberg down, a render that fails — each falls through to editing blind, and a test covers each of those paths. Verified against two mutations: keeping the text model when images are attached, and dropping the verification pass, each fail exactly one test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
07d1c541a0 |
fix: a deck the model fumbles once is asked for again, not abandoned
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 23s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A presentation is generated as a typed deck — the model chooses each slide's layout — and that deck is what scripts/render_pptx.py renders. When the reply did not parse as a deck, the route fell straight back to asking for markdown, and slideSpec.build() then inferred slides from it. Both paths go through python-pptx, but the fallback's layout is guessed from heading and list structure rather than chosen, so everything lands as title-and-bullets. Measured on the stored library: since decks landed, 7 of 8 generations produced one and 1 did not. Models are stochastic, so one unlucky reply was costing the whole layout. It now asks a second time with the same prompt before giving up. The fallback was also invisible. It warned to the console, where the person who would simply have generated again could not see it, so they kept the plainer deck without knowing a better one was one click away. The response now carries deckFallback and the UI says it came out as plain slides, and why. Fixed the reason heuristic while adding tests for it: truncation was claimed for any reply not ending in "}", which is every prose refusal. It is now only claimed for a reply that began as JSON and stopped. The four generate tests run the handler. Verified against a mutation: removing the retry fails tests 6 and 7 and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e69eb9a9f7 |
fix: modifying a presentation failed whenever illustration was ticked
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
existingDeck was declared below the branch that reads it, so `var` hoisting made it undefined there. With illustration on, a deck modification therefore appended the *markdown* instruction — "Returning the markdown is still required; a tool call is not a substitute for it" — to a prompt whose body asked for deck JSON. The model was told to produce two different artifacts in one reply, the reply parsed as neither, and deckBuild.parse returned null, so the handler answered 502 "That change could not be applied." Moved the declaration above its first reader. Same class of fault as the savedFigureIds one, in the same file. Two things made it hard to see, both fixed: - The library row read created_at, so a modification that did apply left the visible timestamp on the generation time. That timestamp is what led to "modification doesn't work" — it was the only signal available, and it was reading the wrong column. Rows now show the modified time when there is one. - A model can also return the document back unchanged. That was logged server-side and answered "Applied. Download it to see the result", which sent people to download an identical file. The response now carries `unchanged` and the UI says so, keeping the instruction in the box so it can be reworded. Also surfaced has_deck on the library list: 28 of 38 stored presentations have no deck and go through the weaker flat-markdown path, and nothing in the UI distinguished them. They now read "plain text, no slide layout". test/my-resources-refine.test.js runs the handler rather than reading it, since all three faults were invisible to source reading. Verified against a mutation: putting the declaration back where it was fails test 1 and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
67e416c6d9 |
docs: merge the duplicate pairs and correct them against the running app
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m38s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Three pairs of docs described the same thing twice, and the copies had drifted
apart. Merged each into one file, keeping the unique content from both:
- ARCHITECTURE.md -> architecture.md (its operational map: ownership, request
flow, runtime boundaries, source of truth, deployment shape)
- DEVELOPMENT.md -> developer-guide.md (change workflow, Clinical Assistant
high-risk areas, frontend rendering rules, deployment checks)
- transcription-options.md -> speech.md (the clinic setup table, and the list
of browser-Whisper paths that must stay removed)
Then audited what remained against the code and the live database rather than
against the previous docs. Corrected:
- Google Vertex was still documented as a provider across nine files. The SDK
is gone; AI_PROVIDER=vertex now logs an advisory and falls back to
OpenRouter, and Gemini is reached through LiteLLM. Fixed the provider
selection order to match src/utils/ai.js, which starts from LITELLM_API_BASE.
- promptSafe was documented on 8 routes; it is on 13.
- Node 20 -> 24, "24 vanilla JS modules" -> no fixed count, and
transcribe.js/tts.js -> sttProvider.js/ttsProvider.js, which is what exists.
- STT/TTS are LiteLLM-only; README listed direct Google, AWS Transcribe and
ElevenLabs paths that are not in the runtime.
- Learning Hub PPTX export was documented as pptxgenjs, which is not a
dependency. It is pandoc against a reference deck.
- POST /api/admin/milestones/seed does not exist; it is /bulk-import.
- NEXTCLOUD_URL and NTFY_TOPIC are not read anywhere. Nextcloud is per-user in
the users table, and the ntfy topic is derived as pedscribe-{userId}.
- A prose paragraph sat inside the Clinical Assistant settings table, so half
the rows rendered as text.
Filled the gaps the audit exposed:
- database.md was missing 12 of 29 tables, including user_resources,
personal_notes, login_codes, registration_invites and generated_image_jobs.
- developer-guide.md was missing 11 routers and 10 frontend modules.
- api-reference.md detailed 121 of 244 endpoints and said so, but whole
features were absent. Added an endpoint index covering Clinical Assistant,
My Resources, Notes, Diagrams, ED Encounters, invites and sign-in codes.
- configuration.md was missing METRICS_TOKEN, REDIS_URL, API_RATE_LIMIT_MAX,
the LITELLM_* model variables, the DB_* ones maintenance.js reads, and the
per-purpose S3 resolution scheme.
- clinical-assistant.md documented 2 of its 17 environment variables.
- features-explained.md had no entry for My Resources or Clinical Assistant.
Renamed the three remaining SHOUTING filenames to kebab-case, which is what the
docs viewer's prettyName() was working around, and rewrote README's index,
which listed architecture.md twice and omitted nine files.
Noted but not changed: the Turnstile site key is hardcoded in index.html rather
than read from TURNSTILE_SITE_KEY, and /api/health/detailed can report
tts: 'elevenlabs' though no ElevenLabs path exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
2c3fbbcf37 |
fix: decks were falling back to markdown, so no figure could ever be requested
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
A deck's JSON is several times the size of the prose it holds, and generation used the default 4000-token budget — raised for refine and for slide review, but never here. A sixteen-slide deck ran past it, came back truncated, failed to parse, and fell back to markdown. Markdown has no way to ask for a figure, so the model described one instead and the slide rendered a literal "![Placeholder: Flow diagram — "Neonate with rash" → ...]" as its first bullet, above the steps it was meant to illustrate. That is why no generated deck was arriving with an image. Deck generation now gets room for a deck. The fallback also says how the reply failed — empty, cut short at N characters, or simply not a deck — because those want different fixes and "not usable" covered all three. Image markup is stripped wherever text enters a slide, on both the deck and markdown paths, since a described figure is not a figure and a bullet of raw markdown is worse than no bullet. The model is also told plainly: if a figure is wanted say so with image_prompt, and if that is not on offer, write the slide without one rather than describing the picture you would have drawn. Separately, the Documentation list showed ARCHITECTURE, CLINICAL_ASSISTANT, DEVELOPMENT, MODULE_CONVENTIONS and SCALING shouting in caps with underscores intact: the label builder replaced hyphens but not underscores, and uppercased the first letter of each word rather than normalising the case, so a SHOUTING_FILENAME stayed shouting. It now reads "Clinical Assistant", keeps acronyms as acronyms (AI, API, OpenID, LiteLLM) and leaves joining words lower. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
9d307fd442 |
feat: a vision model looks at the rendered deck and fixes the layout
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 1m2s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 37s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model that writes a deck never sees it. It cannot tell that slide four overflowed, that a nine-item list would read better in two columns, or that two labelled groups want to be a comparison — those are facts about the rendered page, not about the text. So each generated deck is now rendered to PDF through Gotenberg, rasterised to one image per slide with pdftoppm, and shown to a vision model. Off unless an administrator names a reviewer, in its own admin card because it is the one setting that spends money on every generation without a user having asked for anything. One pass, on generation only: a second pass costs as much as the first and fixes far less, and refining is a text edit. It returns a patch, not a deck. Asking for the corrected deck back put the reply in proportion to the deck rather than to the number of problems, and a fourteen-slide deck came back cut off mid-object at every output budget the provider would honour — measured twice before changing shape. The patch is better for a second reason. The reviewer names a slide and an action — two columns, one column, split after bullet N, compare with these two labels — and the server moves the text it already has. The words never pass through the model, so a review cannot reword, drop or invent a single bullet. That is a stronger guarantee than instructing it not to and checking afterwards. The check runs anyway, because a bug in applyChanges would be as bad as a model rewriting the words and worse for being trusted: body text must come out the same multiset, figures the same set, and a heading may only be reused or extended. A continuation heading is the reviewer's one piece of text and is replaced when it does not continue anything. Nothing here can fail a generation — no reviewer, an unreachable one, an unparseable reply, a deck too long to look at, or a patch that applies to nothing each return the deck that was written. Verified end to end against a deck with a deliberately overloaded slide: three slides rendered and sent, one change returned, ten bullets split into five and five under "Stepwise Management … (continued)", text intact. Left switched off; enable it under Admin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
f3c3f47d99 |
fix: voice mode reads the answer that just arrived, and reads what the page shows
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
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 |
||
|
|
0e17f553fc |
fix: hanging up the voice call stops the voice, and the answer is read not its markup
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 20s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
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 |
||
|
|
b98ffc61c7 |
fix: expired invitations can be cleared too, revoked ones still cannot
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
An expired code is as dead as a used one and just as accounted for, so it is now deletable. The rule the code enforces is the one that matters: a code that could still be redeemed is never deleted, because that takes it off the list without taking it out of anybody's inbox — the holder keeps something that looks valid, it quietly stops working, and nothing is left to say who had it. One condition, shared by the single delete and the bulk clear: (used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW())) Written that way rather than as "used OR past its date" because the second form also catches a revoked code whose date has since passed — a row the list still labels revoked and offers no delete on, so the button and the query would have disagreed about the same row. Revoked codes keep their rows. Revoking records a decision somebody took, and a handful of them is not the clutter a pile of expired codes is. Verified against the live database across every state: active refused, used deleted, expired deleted, revoked refused, and revoked-with-a-past-date refused rather than slipping through as expired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
7b084c7edf |
fix: an invitation can only be deleted once it has been used
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The delete button was offered on every invitation regardless of state, and the query behind it deleted any row it was given. Deleting an unused code takes it off the list without taking it out of anybody's inbox: the person still holds something that looks like a valid invitation, it silently stops working, and there is no longer a record of who it went to or why. Revoke is what stops a live code — it leaves the row behind, marked. So the delete is now for spent codes only, in three places rather than one: the query carries AND used_at IS NOT NULL, the route answers 409 with the reason instead of pretending the row is missing, and the button is rendered only on a used row. A "Clear N used" control alongside, since the complaint was clutter and clearing them one at a time is not much of an answer. Same rule — nothing unused or revoked is touched — and it confirms first, because it is still a delete. The bulk route is declared before /invites/:id, or Express reads "used" as an id. Verified against the live database: deleting an unused invitation is refused and the row survives, deleting a used one works, the bulk clear removes only used ones, and the unused probe row was still there afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
22683f3584 |
feat: sign in with a code emailed to you, offered beside the password
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The sign-in screen asks for an email first, then offers both ways in together: a six-digit code sent to that address, or the password. Beside rather than instead — a code depends on mail being delivered and a password does not, so neither may be the only route. "Use a different email" goes back a step, and creating an account stays where it was. What keeps it from being a second, weaker front door: - Only a bcrypt hash is stored, so a code read out of the database is not a working credential. - Ten minutes, single use, marked used before the session is issued so a replay cannot race it, and requesting a new one deletes the old. - Five wrong guesses burn it. Six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries. - Requesting a code answers identically whether or not the address exists, and every verify failure returns one message. A sign-in screen that says "no such account" is a way of finding out who has one. - Two-factor still applies: a code proves you can read the mailbox, which is one factor, and an account that asked for a second still wants it. - Its own rate limits, tighter for requesting than for attempting, because requesting sends mail to someone else's address. These had to be separate limiters: Express matches app.use paths on segment boundaries, so /api/auth/login does not cover /api/auth/login-code — checked against a real router rather than assumed. Two bugs found while building it, both mine: authFetch keeps an allowlist of endpoints callable with no verified owner and rejects everything else before it is sent. The new endpoints were not on it, so the request never left the browser and surfaced as "Connection error". reveal() hid elements by appending 'hidden' to className and showed them with a non-global replace, so hiding twice left two copies and showing stripped one. The "use a different email" link never reappeared. It uses classList now, which is idempotent. Verified against the running server: correct code signs in, the same code again is refused, a superseded code is refused, five wrong guesses burn it, an expired one is refused, and the stored value is a hash. In the browser: requesting a code advances the screen, a wrong code is refused without losing the screen, and the password route still signs in. Not yet demonstrated: a correct code typed into the browser. The harness keeps racing the one-live-code rule — the page's own request supersedes whatever code the test holds, and with SMTP off the delivered one cannot be read. The same request reaches the server on the wrong-code path, and the endpoint itself is verified, but that last step is untested end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
1ad72b134b |
feat: a resource can have several illustrations, on its own path
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 54s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m22s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
My Resources used imageTool.dispatch, which permits exactly one image per request — "Only one image tool invocation is permitted per request". That is the right rule for a chat reply and the wrong one for a twelve-slide deck where the author asked for three figures. Rather than relax a limit the clinical assistant and the Learning Hub also depend on, this adds a separate dispatcher for this feature. Same queue, same storage, same my_resources workflow, same asset endpoint — only the number of figures differs, bounded at six because each one is a paid request. "Use 3 diagrams" in the instructions is read as the number it is, and the illustration option now says several are possible rather than promising one. Three things had to be got right, each found by measuring rather than assuming: The illustration guidance has to be the last thing in the prompt. Placed before the output rules it lost — with the tool offered and the paragraph present, the model returned 3297 characters of markdown and zero tool calls, while the same tool and wording in a shorter prompt produced three calls. Even last, it loses to a prompt carrying thirty library excerpts: deterministically, with the library off "use 3 diagrams" made three calls and with the library on it made none and wrote a longer deck instead. So when the author names a number the call is required rather than merely offered. With no number named the choice stays the model's. And a model that has just made three tool calls tends to sign off instead of writing: "I'll create the presentation and the three teaching diagrams." was returned as the resource — 61 characters — because only a completely empty body counted as missing. A body with no title block and no heading is not a Pandoc document whatever its length, and if the continuation is no better than the first attempt, whichever actually reads like a resource is kept. Verified end to end with the library on: generate produced three figures and an eight-slide deck; modify added two more figures and a ninth slide. The figures were fetched and looked at — labelled airway anatomy, and a croup/epiglottitis/ bacterial tracheitis comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
055a86bbb1 |
feat: My Resources says what it is, offers its sources in one place, and Modify gets them too
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The screen had been trimmed to controls with no explanation of what it was for; "Private to you" said who could see it, not what it did. It now opens with a sentence that says what you get and how you get it out, and the header reads "Only you can see these". The four choices — clinical library, PubMed, web, illustration — are one "Draw on" group instead of four separate rows, with the library ticked by default. Each option hides itself when an administrator has not enabled it, so nothing appears that a person could tick and then be refused. Modify offers exactly the same choices. It had none, so "add what the 2024 trial showed" was answered from the model's memory rather than by looking anything up. Generate and Modify now go through one gatherSources(), so they cannot drift into offering different things or searching them differently. Writing "include a diagram of the airway" in the instructions now switches the illustration option on and says why, rather than the request being dropped in silence. Switching it off by hand sticks — the hint then reminds instead of fighting — and when no image model is configured it says so rather than pretending. Both the generate and modify boxes behave this way. Two things found by testing this rather than assuming it. PubMed ANDs every mapped term, so one unrecognised word takes the query to zero. "febrile seizures" returns six results and "febrile seizures in under-fives" returns none; "the anatomy of croup: subglottic narrowing and the steeple sign" returned none until it was narrowed to "anatomy croup", which returns six. A query that finds nothing is now retried against progressively shorter versions of itself, longest first, and the response says which query actually worked so the screen cannot report one that found nothing. Those extra calls tripped NCBI's three-a-second limit and produced a 429, so retries are spaced and the first attempt waits for nothing. Separately, the searches run on the topic while the library retrieval also gets the instruction: retrieval is semantic and benefits from the context, but a keyword engine handed a whole sentence returns nothing. And when a search was asked for and came back empty, the prompt now says not to invent a citation, a PMID or a URL to fill the gap. Without that the model supplies them from memory, and a fabricated PMID looks exactly like a real one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
8cca5a4796 |
feat: instructions can ask for the illustration; library scrolls and searches; Modify
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m4s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Three things to My Resources. Illustration was entirely the model's call: someone who wanted a figure of something particular had no way to say so, because the instructions steered the prose and nothing else. The illustration guidance now tells the model to follow the author's instructions when they ask for a figure or name what it should show, and to treat that as the decision already made. Verified live: "include a diagram showing the age distribution and the simple-versus-complex distinction" produced exactly that, both halves in one figure. Exactly one image per generation is a real limit, not a wording choice — the shared imageTool dispatcher rejects more than one tool call per request, and it is used by the assistant and Learning Hub too. So the prompt says to draw the single most useful one if several are asked for, and the screen says the same. The library was an unbounded list that pushed everything below it off the page. It is now a 360px scrolling box with a search over title and topic, filtered locally because the rows are already in hand. "Nothing yet" and "nothing matches" are different messages, because telling someone whose search missed that they have never generated anything is wrong. Measured in a real render: 360px visible of 642px of content, and searching narrows 10 rows to 3. Modify is new UI over the refine endpoint, which existed with no way to reach it. Pick a resource, say what to change, and it is rewritten in place keeping its id, its downloads and its References section. The picker is built from the same library array, so it cannot drift, and a selection survives the refresh that follows a generation. Verified live: "add a Key Takeaways slide before References" inserted exactly that and left the other four slides alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
1270899dcb |
feat: PubMed search for My Resources, and an image tool that actually fires
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
PubMed joins web search as an optional source for a generated resource: a
literature search on the topic, with abstracts, cited by PMID in References.
Off by default, admin-enabled, with its own optional API key (NCBI raises the
rate limit from 3/sec to 10/sec; it works without one).
Neither search is a tool any more, and that is the point. Offering them as
function calls meant the model decided whether to search, and with a prompt
ending "Output ONLY Pandoc markdown" it decided not to — every time, with and
without corpus grounding, no matter how the tool description was worded.
Calling callAI with the tool directly produced a correct pubmed_search call, so
the plumbing was never the problem. The search only ever needed the topic, and
the route knows the topic before it calls the model, so both searches now run up
front and their results go into the prompt as findings, exactly the way corpus
excerpts do. Ticking the box now means the search happened.
Verified live against deepseek-v4-flash: 30 corpus excerpts and 6 PubMed
results, and a References slide carrying both the library sources and four real
PMIDs (29562151, 38506440, 35721052, 28814254).
Three fixes to illustration, which had never once fired:
- The dispatch call had been lost in a refactor. The tool was still offered, the
model still called it, and the call was dropped, so no job was ever enqueued.
- imageContext was passed as a bare topic string where dispatch expects
{ request, history }, which made the bound request undefined.
- The prompt never mentioned the tool existed while explicitly demanding only
markdown — the same suppression that killed the searches. It now says an
illustration is available and that calling it is not a violation of that rule.
my_resources is its own image workflow rather than a reuse of learning_hub,
because generated_image_links only accepts learning_hub assets, and that is
exactly the barrier that keeps a private illustration out of published content.
The illustration renders in the panel, rather than a toast pointing at an image
history this feature does not have.
Verified end to end: job queued, rendered, and the asset served to its owner as
a correctly labelled subglottic-anatomy teaching diagram.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
571a013d29 |
feat: optional web search, admin-enabled and off by default
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m9s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The one feature here that sends text outside the building, so the defaults are the careful ones: disabled unless an administrator turns it on, opt-in per generation even then, and the option is hidden entirely rather than shown as something a user can tick and be refused. Only the search query leaves. Library excerpts, the generated resource and anything about the user never do. Both screens say so plainly, because a topic typed while drafting clinical material can carry clinical detail and the provider keeps its own logs. Four providers behind one shape, so swapping changes nothing downstream: Tavily, Serper over Google, Brave, and SearXNG — the only one where the query does not reach a commercial third party at all, which is why it is worth supporting even though it needs somewhere to run. The tool description says when NOT to search, because a model handed a search tool will reach for it constantly: not for settled clinical knowledge, which is what the indexed library is for, and one search per resource. That last one is enforced in the route with toolChoice: 'none' on the continuation rather than trusted to the model. A failed search never fails a generation — same contract as corpus retrieval. The resource is written without it and the response says what was searched for and what came back, so a query that left the network is visible rather than silent. The API key is masked on read and preserved when the field is left blank, the handling the OIDC client secret already gets, so changing provider cannot silently wipe a working key. Verified on the running instance: with nothing configured, webSearchAvailable is false, and a request asking for it anyway is ignored rather than honoured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
7eca509b02 |
fix: slides shrink to fit, and an article is never offered as slides
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A generated deck was losing content. pandoc writes a bare <a:bodyPr/> on every shape, which leaves the body with no autofit even though the slide master has one, so a slide with too much on it is cut off mid-sentence and the rest is not rendered at all. Reproduced and counted: eight bullets went in, three came out, the third ending mid-word. Every generated deck now carries <a:normAutofit/> on its body placeholders. No fontScale, deliberately — the renderer works out the reduction, so a slide that already fits is untouched, where a fixed scale would shrink all of them. The same eight bullets now fit with nothing in the bottom 6% of the slide. This is a floor, not a licence to overcrowd. The prompt still asks for one idea per slide; this stops a long one becoming unreadable. Also: an article is no longer offered as PowerPoint. A deck of paragraphs is not a presentation. Word and PDF suit either kind, and the route refuses the combination rather than relying on the button being absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
fac8757ce8 |
feat: My Resources has a menu, a library and three downloads
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The pathway existed but was reachable only by API. It now has a tab of its own next to the Learning Hub — related, not the same thing, and sitting together is how someone discovers the difference — visible to every signed-in user with no role gate in the markup. Generate a deck or an article, see everything you have made, download each as PowerPoint, Word or PDF, delete what you no longer want. The screen says "Private to you" and "Nobody else sees these", because the distinction from published Learning content is the thing a person needs to understand before typing a patient's condition into it. Downloads are fetched rather than linked: an <a href> cannot carry the Authorization header. The blob is saved under the filename the server chose and the object URL is revoked afterwards. Resource titles come from a model, so rows are built as elements and a title is only ever assigned to textContent. The e2e stack now joins danvics_convert too. It could previously reach only Postgres and Redis, so a PDF download failed there in a way production would not — which did at least prove the degradation path works: with Gotenberg unreachable the response is "PDF conversion is unavailable right now. PowerPoint and Word still work", and the other two formats download unaffected. Verified in a browser as an ordinary user: the tab appears and opens, the form swaps slide count for word count when the format changes, the library lists their own work, and pptx, docx and pdf all download with sensible filenames (36360, 13285 and 68310 bytes). Also documents retrieval sizing in docs/retrieval-tuning.md — the per-feature budgets, and RERANKER_TOP_K, which caps all of them and had until now appeared in no configuration file at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
99a72734fb |
feat: the Learning screen can ask for grounding, and says what it got
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m20s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The route accepted useCorpus and reported grounding, but nothing in the admin screen sent the flag or showed the result — so the feature existed and was unreachable. Opt-out in the UI rather than opt-in. For clinical teaching the library is nearly always the right source, so someone who never notices the checkbox should get the grounded version. The help text explains when to turn it OFF, which is the non-obvious case: a topic the library does not cover is better written without grounding than padded with the nearest unrelated excerpts. Afterwards it says what happened — "Written from 12 library excerpts", or "Not grounded — nothing indexed matched. Written from the model alone." Ungrounded material presented as grounded is the failure worth preventing here, so the wording never implies the library was used when it was not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
b3d66caaca |
feat: a retried transcript goes back to the tab the audio came from
Retrying a kept recording transcribed it and put the text on the clipboard, leaving you to find the right tab and paste. The app already had the answer: the module was recorded with the audio. Retry now opens that tab and puts the text in its transcript box. Two things had to be true first. The module was not actually being recorded. transcribeAudio never sent one, so the server stored its default for every upload — all 28 rows in audio_backups said "recording", and a retry had nowhere to send anything back to. Each module's call now tags its own upload. And the names disagreed. The recorders tagged 'encounter', 'soap', 'dictation' while the recording-started events said 'enc', 'sick', 'dict'. One table now holds the mapping and resolves the aliases, so the recorder that tags the upload, the backup row that labels it and the retry that delivers it cannot drift apart again. Existing text is appended to, never replaced: a retry usually recovers something on top of a live transcript, and overwriting would lose the words the browser did hear. The box only exists once its tab's markup has been fetched, so delivery polls briefly rather than guessing a delay, and falls back to the clipboard if the tab never opens. An empty result says so rather than claiming success. Backup rows now name their source and the button reads "Retry into SOAP Note" instead of "Retry". Verified in a browser: enc resolves to encounter, delivery switched tabs and produced 'existing live transcript\n\nRECOVERED TEXT'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
fadf09bf4a |
revert: remove the signed-out assistant preview
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Removed at the owner's request, entirely rather than switched off: the route's allow-list, anonymous identity and flag lookup; the client's entry path, the authFetch exception that let four endpoints out without an account, and the workspace guard; the CSS, the in-page note, the admin flag and its save/load; the test file and the assertions elsewhere that pinned it. Both settings rows are deleted from app_settings. Two things were checked rather than assumed on the way out. Removing the anonymous identity collapsed every `if (!req.user.preview)` branch to its authenticated side, so image tools, audit logging and citation storage now run unconditionally — which is what they did before preview existed. And the route's gate went back to a bare router.use(authMiddleware), which on a /api mount gates every path below it in server.js; it is scoped to /clinical-assistant again, the guard test catches it either way. Verified after deploy: signed out, status, examples and chat all refuse with 401; signed in, chat still answers with 8 sources; extensions, encounters, documents and admin remain shut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
cd37a9d728 |
fix: the preview page rendered nothing
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
/assistant in the address bar, the preview class on the body, and a blank screen. The app container starts hidden and enterApp is what reveals it; enterPreview declined the login overlay without doing the same, which left a hidden app under a hidden overlay. One line. Verified in a browser: composer, chat view, example prompts and the preview note are all visible and sized, with no page or console errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
004fb80a60 |
fix: the signed-out preview is reachable from the page, from any path
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 1m51s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The server side has worked since this morning, but no browser could reach it. authFetch rejects every /api request that has no account before it is sent, and the four preview endpoints were not on its short list — so the status call that decides whether to show the login screen never left the browser, and the screen was always shown. The list now mirrors the server's own allow-list exactly: status, examples, chat, chat/stream, and nothing else. Preview now begins from any path. A visitor landing on the root met the login wall while /assistant did not, which read as "preview doesn't work"; both now enter the assistant, and the URL follows. Reaching for anything that needs an account raises the sign-in screen through one hook in authFetch rather than a check on every control — but only for something the visitor did. The page also fetches saved chats and config in the background on load, and the first version raised the screen for those too, burying the assistant before a word was typed. The hook is gated on navigator.userActivation. The HIPAA notice is hidden on that screen in preview: it is an invitation to sign in, not the compliance notice a clinician sees on first login. Verified in a browser: landing on / and on /assistant both show the assistant with no login wall and no HIPAA text; clicking Workspace raises sign-in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
96a6a353fc |
fix: the assistant settings page says what saves what
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 1m2s
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The card holds more than one Save button and nothing said so. "Save image settings" is injected directly above "Save model & retrieval settings", with no indication of where one stops and the other starts, and the page saves nothing automatically. It now states that up front, and the bottom button says which settings it applies. "Retry loading settings" sat beside Save looking like an ordinary control, because it did: a bare button with the hidden attribute, which the browser's own [hidden] rule could not hide once .btn-sm set a display. It is now inside an error message that exists only on failure, says what failed, and says that nothing typed has been lost. The status line used to read "Settings ready." forever, which answers a question nobody asks. It now reports the thing an admin actually wants to know when they come back: whether the last save went through, and at what time. A toast is gone in three seconds; this stays on the page. The signed-out preview moves to Feature Flags, where it belongs. It was a second checkbox under a row labelled "Sources", followed by two paragraphs, the first about preview and the second about citations — so neither paragraph clearly belonged to either checkbox. It is stored as feature.assistant_preview now, with the old clinical_assistant.preview_enabled still honoured when the new key has never been written. That also means an ordinary admin can toggle it under ADMIN_LOCKDOWN: clinical_assistant.* is locked, and putting a day-to-day switch behind host access was never the intent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
fed4bd154f |
fix: recordings that produced nothing, and the boxes that zoomed on iOS
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
|
||
|
|
050a7d5241 |
feat: citation quality tracking, and the SSO settings fit a phone
Citation quality - A citation naming a source that never came back is never rendered as a link, so it appears as plain text and nobody learns it happened. It is now measured on the server, where the answer and the sources both exist, so it is seen whether or not a browser rendered it. - Four Prometheus counters feed a Grafana dashboard (Ped-AI Citation Quality): answers, citations written, answers affected, and individual unresolved markers. Only answers with at least one unresolved citation are stored, with the question and the titles retrieval returned, so an operator can judge whether retrieval came back thin or the model over-cited. Rows expire after 30 days: this is a quality signal, not a transcript log. - Both answer paths are covered. /chat/stream is normal; /chat is the fallback the client uses when streaming fails, so auditing only the first would have hidden exactly the answers produced under failure. - The tracker is resolved on demand and allowed to be absent. Seven test files load this route with a hand-built list of permitted imports, and adding a hard dependency would mean editing all seven — and the eighth written later would break. Observation must never be able to fail an answer, so a missing module simply means no tracking. - Metric registration reuses an already-registered counter, because this module can legitimately load twice in one process. SSO settings on mobile - Six rows were laid out inline: flex with a 160px label and an input that would not shrink, so on a phone the row was wider than the screen with nothing to scroll and no way to reach the rest. They use .admin-row now, which already stacks below 640px. Verified at 390px and 360px: nothing off-screen, no sideways overflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
272ea94768 |
feat: admin lockdown, so several admins do not all get to change everything
With more than one admin, every setting in the panel was editable by all of them — prompts, model policy, retrieval budgets, SMTP, email templates. ADMIN_LOCKDOWN separates running the service from changing how it behaves. It is an environment variable on purpose: a setting could be switched off by the very admin it restrains, so lifting this needs host access and a restart. The server is the control. One gate refuses configuration writes rather than a check in each of the fifteen write routes, because that list grows and a route added later would quietly miss it. Reads always pass — lockdown hides nothing. Day-to-day operation stays available: invitations, announcements, registration, feature flags, and the test endpoints, which persist nothing. A setting invented later is locked until someone deliberately makes it editable, rather than defaulting to open. The panel disables what it cannot save and says why, but that is courtesy; the refusal is what enforces it. Two things this taught me, both fixed: my first version painted the panel from an IIFE, which the module conventions forbid, and fetched the whole config a second time just to read one flag — breaking the test that pins admin loaders firing exactly once. The state now rides on the invites response the panel already requests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
39c1663334 |
feat: invite-only registration
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 |
||
|
|
6dcdf36c81 |
fix: resolve a citation by its number, not its position in the array
A marker names a source by the `number` dedupeSources assigns server-side. Rendering looked it up as sources[n - 1], which works only while the array order and the numbers agree. Nothing breaks that today, but it is an implicit contract across a network boundary: any later filtering or reordering of the list — hiding low-score sources, say — would point citations at the wrong source silently, which is worse than not linking at all. Matching on the number cannot drift. Positional lookup remains as the fallback for a list whose entries carry no number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e2444066a0 |
feat: the announcement banner renders Markdown, safely
It was textContent, so formatting appeared literally. It now renders inline Markdown only — bold, italic, code, links, breaks — because this text is injected into every page and must not be able to introduce headings, images or block layout that shifts the app around. parseInline rather than parse, a tag allow-list, event-handler and style attributes forbidden, data attributes off, and one innerHTML assignment that can only be reached through DOMPurify. Missing either library, or any failure while rendering, falls back to the literal text rather than to unsanitised markup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
80d468a85d |
feat: a recording survives switching to the Assistant, and signing out keeps it
Switching to the Assistant set window.location, which reloads the document
and silently ended any running recording. The assistant is a tab in the same
page and activateTab already rewrites the URL to /assistant, so the switch
now happens in place; the reload stays as a fallback. Verified in Chromium:
recorder still running, no reload, URL /assistant, assistant visible.
Signing out mid-recording used to end it with nothing kept. It now says so
first — "the audio will be saved for 24 hours so you can transcribe it
later" — and stores the audio either way, tagged with the module that
produced it ('encounter', 'soap', 'dictation'), which is what makes it
findable in Settings afterwards. Sign-out completes whether or not the save
worked, and the rescue never rejects, because the caller is on its way out.
Verified: the warning appears and the upload carries module=encounter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
523926ab17 |
feat: keep the screen awake while recording, and keep every recording 24h
Recording - A screen wake lock is held for as long as a recording runs. Browsers drop the lock whenever the page is hidden, so it is taken again on return — without that, one glance away ended it for the session. The lock is reference counted (two recorders cannot release each other's), never requested while hidden (the request would just be rejected), and a denial or an unsupported browser leaves the recording running. - Signing out releases it and stops the recording; nothing is sent, because the session that owned the audio is gone. - start() on an already-running recorder is now a no-op instead of replacing the MediaRecorder and silently dropping everything captured so far. - A recording that ends by itself — recorder error, or the microphone taken by another app, unplugged or revoked — takes the same path as pressing Stop, so it is transcribed and stored rather than left in a tab that still says "recording". Moving around the workspace already kept recording. Retention - Every recording is kept for 24 hours now, not only the ones whose transcription failed. /api/transcribe already has the audio, so this costs no second upload, and a storage failure is logged rather than thrown: it must never lose the transcription someone is waiting for. - One store (src/utils/audioBackupStore.js) is shared by /api/transcribe and /api/audio-backups so the two cannot drift. Payload goes to object storage when AUDIO_BACKUPS_S3_* is set and to the encrypted Postgres column otherwise; metadata always stays in Postgres, so listing, ownership and expiry behave the same either way. Object keys are scoped by owner, and the expiry sweep deletes the object with the row. Verified against the live database: round trip byte-identical, another user reads null, 950 -> 48 bytes compressed, expired rows take their objects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
48a3b06ebb |
feat: export a recording; report a recorder that has silently died
Export - Both stores already held the audio (server /audio-backups/:id/audio and the local IndexedDB record) but nothing exposed it, so a recording could not be taken out of the app. Each backup row now has a download, naming the file by its timestamp and using the extension actually recorded (webm, or m4a on iOS). A local record is only handed over to the account that owns it. Robustness - MediaRecorder had no onerror and nothing watched the audio track, so a recorder that failed, or a microphone claimed by another app, unplugged, or revoked, left the tab saying "recording" while capturing nothing. Both are now reported once, with the chunks captured so far kept, so stopping still returns the audio up to the failure. Deliberately not added: a wake lock. Stopping when the screen sleeps or the session ends is the intended behaviour — recording is meant to be deliberate, and nothing is left behind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
cf7a3161a8 |
fix: stop iOS zoom, compact model control, quieter composer, better search
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 |
||
|
|
609305538a |
fix: drop the dead admin jump links; stop a test flake at its source
Admin links - The [data-admin-jump] click handler had been pasted inside discoverModels(), so it only registered once someone pressed Search in AI Model Management, and re-registered on every later search. Normally nothing intercepted the click, so href="#" did what it says: jump to the top and leave "#" in the URL. - This is an app, so the pointers are plain text naming the sections rather than links, and the handler is gone. Test flake - admin-clinical-assistant-wiring failed about 1 run in 4 with "Unable to deserialize cloned data due to invalid or unsupported version": node:test reads a test file's results back over the child's stdout, and app.js's own console.log landed inside a serialized frame. - The page's console is now forwarded to stderr (jsdom 29: forwardTo). Verified: child stdout clean, 0 failures in 32 stress runs (was 6 in 24), and three full-suite runs at 668/668. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
adcea2a0ca |
fix: image models can be added and offered; solid phone top bar on iOS
Image models - The Clinical Assistant "Image models" list waited on an old #assistant-image-model dropdown that no longer exists, so discovery never reached it and only four hard-coded fallbacks appeared — with no way to add any of the gateway's 50 image models. - Image Generation search rows now have + Add / Added. Added models are saved as clinical_assistant.image_model_roster (validated server-side: up to 100 ids) and appear in the Clinical Assistant list at once; ticking one there offers it to users. Anything already allowed or configured stays listed. Unsaved ticks survive an add. - The roster notification is guarded, so it can never fail the settings load. Phone top bar - The page is drawn under the status bar (viewport-fit=cover) and its theme colour was the removed header's blue, so on an iPhone content showed scrolling at the top of the screen. The row is now a real fixed element that extends behind the status bar (env(safe-area-inset-top), 0 in a normal tab), the menu button, sources pill and drawers clear it, and the theme colour is white. Verified in Chromium: + Add -> saved roster -> listed unticked; tick kept; remove works. Top bar is the only thing in the top 48px on all 22 pages; phone menu positions unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS |
||
|
|
6e0609f285 |
refactor: drop the "Saved Chats" header and collapse arrow from the rail
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 |
||
|
|
f93994ad69 |
fix: collapsed strip, workspace-mode search, image dialog chat list
- 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 |
||
|
|
f3a2309536 |
fix: one menu shape in both views, measured; phone menu follows the view
Measured in real Chromium (1440x900 and 390x844) rather than inferred. Desktop - The assistant rail sat inside the content area's 14px padding, on the tiled ground. So its background differed from the workspace sidebar's, its name card sat 6px lower and further in, and the page was 28px taller than the window. It is now the same flush white sidebar with the same insets; the chat and sources panels carry the 14px. Measured: name card 848-892 at x=8, brand, switch and 210px width identical in both views, expanded and collapsed, with no page scroll. Phone - The menu button opens the chat history in the assistant (its drawer) and the workspace list elsewhere. In workspace mode the drawer lists the workspace links. Choosing a page, switching mode or pressing « closes it. - The drawer no longer scrolls as a whole: only the chat list does, so the name card is pinned at the foot, the same place as in the workspace menu (790-836 at x=12 in both). - Both menus are one plain shape: same width, text rows, grey current row, no "Menu" header and one close control. - The assistant page no longer overflows the phone by 16px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS |
||
|
|
b8d2153cca |
fix: phone menu links, Workspace pill and sources column; image model discovery
Phone - The desktop collapse rules (52px icon rail) were unscoped, and menu-hidden is remembered across viewports, so after collapsing on a desktop the phone sheet opened with its links, the Assistant/Workspace switch and the account name all display:none. Scoped them to min-width:769px. - Workspace pill: the sheet now closes on tap, and the launcher is no longer display:none below 640px, so the pill opens the workspace instead of toggling a view that could never appear. - The menu button has its own 48px row instead of floating over the page; layouts subtract --mobile-bar (0px on desktop, so desktop is unchanged). - Sources open as their own scrollable band under that row, and the toggle sits in the row, instead of a sheet/FAB covering the composer. - The sheet uses dvh, so the account card is not pushed below the visible screen by the browser toolbar. Admin - Image Generation card beside TTS and STT: search the gateway's image models and test one. No Set button: an image model is chosen per workflow. It searches only on demand, so opening Admin still makes one discovery call. - Account card sits at the same height in every rail state; the model availability list points to where new models are added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS |
||
|
|
2c9c2d12c0 |
fix: one mobile menu, and sources reachable on a phone
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 |