发布

  • Studio: add a loaded models indicator with per-model eject (#8082)

    frostbyte_neo 发布于 2026-08-08 15:27:45 +00:00

    • Studio: add a loaded models indicator with per-model eject

    A loaded model is only visible on the page that loaded it. Leave Chat for the
    Hub or Train and nothing on screen says a model is still holding memory, so the
    only way to check or release it is to navigate back.

    Adds a compact card in the bottom-right listing everything resident, with an
    eject button per row. Chat, images, video and dictation each own a runtime and
    their own /status, so all four are read and merged. A TTS load is split out from
    a chat load by audio_type: same picker, but it does not answer prompts.

    It joins the existing bottom-right stack in provider.tsx alongside the update
    banners and the download panel, so nothing overlaps. Collapses to a pill, and
    that choice persists. On by default, with a toggle in Settings, General,
    Notifications.

    Reads fail soft per runtime, so a chat-only host missing /video keeps its other
    rows. Ejecting the active chat model reuses the API monitor's read, unload,
    re-read sequence, since an API auto-switch can replace the model mid-eject.

    • Studio: address review on the loaded models indicator

    Eject the row's own model, not whatever is active. An API auto-switch can land
    between the poll and the click, and re-reading unconditionally would free a
    model nobody asked to free while reporting the clicked one as ejected. The
    sequence moves to eject-chat-model.ts, taking its I/O as deps like
    api-monitor/unload-resident.ts, so the scoping is under test.

    Fall back to the top-level STT fields. Servers predating the per-engine split
    report the resident Transformers model only there, so those installs showed no
    dictation row and had no eject for it.

    Bound each status read. authFetch has no timeout, so a runtime that accepts the
    connection and never answers left the Promise.all pending forever, and with it
    the in-flight guard that gates every later refresh.

    Re-sync Images and Video after an eject. Both hold their own status and re-read
    it on tab activation, not on a timer, so ejecting from the indicator left their
    controls offering to generate on a freed runtime.

    Add both preference keys to "Reset all local preferences", which otherwise left
    the indicator hidden or collapsed after a reset.

    • Studio: verify the row before the identity-less unloads

    /images/unload, /video/unload and the STT unload carry no model id and release
    whatever their runtime holds, so acting on a row up to one poll old could free a
    model nobody clicked while reporting the clicked one as ejected. The image and
    video races need a load landing in that window; dictation loads on demand and
    releases when idle, so its engine can change with no user action at all.

    Each now re-reads its runtime and only unloads when the resident model still
    matches the row. This narrows the window to the round trip rather than closing
    it, which would take a backend that accepts the model id.

    Ejecting reports an outcome instead of a bare string, so a stale row says the
    model is already gone and names what took its place, rather than claiming a
    success it did not perform. An unreadable dictation status is now an error
    rather than a false success.

    • Studio: stop dictation rows printing their engine twice

    The llama.cpp and whisper.cpp STT sidecars report their engine name as the
    device (stt_mtmd_sidecar.device is "llama.cpp", stt_ggml_sidecar.device is
    "whisper.cpp"), so a row read "Dictation - llama.cpp - llama.cpp". Only the
    Transformers sidecar reports a real device.

    joinDetail now drops repeated parts, so the engine is named once and a genuine
    device still shows beside it.

    • Studio: make the loaded models card movable and keep it off the Live monitor

    The card and the Live monitor both anchor to the bottom-right, so opening the
    monitor buried the card underneath it. The monitor is draggable and resizable,
    so a fixed offset would only hold until it moved.

    The monitor now publishes its box. The corner stack lifts above it, but only
    while it is in the stack's column and low enough to be in the way, so a monitor
    dragged elsewhere leaves the corner alone. This clears the update banners and
    the download panel too, which had the same overlap.

    The card also gets a drag handle: anchored to its corner until moved, then kept
    where it was left, clamped back into view if the window shrinks. Its position
    persists and joins the reset list.

    • Studio: use the AI brain icon and lead the Notifications section

    The loaded models card and the Train wizard's Model step both take
    AiBrain01Icon, and the indicator toggle moves above the llama.cpp one so the
    Notifications section leads with it.

    • Studio: let the collapsed indicator be dragged too

    The drag handle only existed in the expanded header, so the pill could not be
    moved. The pill is now its own handle: a press becomes a drag past a few
    pixels, below that it stays the click that expands the card, and reading the
    flag clears it so a keyboard activation is not swallowed.

    Re-clamping now also runs on a ResizeObserver, so expanding a pill dragged to
    the bottom edge pulls the taller card back into view instead of growing off
    screen.

    • Studio: address review on the loaded models indicator

    Keep the Chat picker selection when an eject leaves the model resident, so a
    reload during the run no longer empties the picker while the model still runs.

    Republish the Live monitor's frame from a layout effect. ResizeObserver does
    not fire for a position-only change, so dragging the monitor left the overlay
    stack dodging its old corner.

    Cap the overlay stack's height to the space above its own bottom inset. Lifting
    it over the monitor without shortening it pushed the top of a long download
    list off screen.

    Measure the stack column from the update banners (448px), not the download
    panel (400px), so a monitor beside a banner is still dodged.

    Name the precision an image or video pipeline loaded at, so a quantised build
    is distinguishable from bf16 in the row.

    • Studio: make the loaded models card resizable

    The card is anchored bottom-right, where a native CSS resize grip has nowhere
    to grow, so it resizes from a grip at the leading corner instead: the anchored
    corner is held still and the box opens up and to the left. The grip shares the
    title icon's slot rather than adding another control to a small header, and
    double-clicking it returns the card to its default size and corner.

    The size persists next to the position, is clamped to a floor and to the room
    available, and is cleared by Reset all local preferences.

    • Studio: open a loaded model's page from its row

    Clicking a row now goes to where that model is used: chat models to Chat,
    image and video pipelines to their tabs, dictation to the Voice settings tab
    that drives the sidecars. The target follows the runtime holding the weights,
    not the kind, so a Whisper checkpoint in the chat slot goes to Chat rather
    than to dictation.

    Navigation carries no search params, so it only changes page: it does not
    start a new thread or reload anything. Dictation moves to the Audio page once
    that lands.

    • Studio: revert the resizable loaded models card

    Back to the fixed-size card. Reverts 96749d1f.

    Also read /api/inference/status directly instead of through the chat barrel,
    matching the dictation read alongside it. The server logs show the indicator's
    chat read never reaching the backend: across every session the status count
    sits at roughly half the images/video/dictation count, matching the chat
    page's own 10s poll rather than the indicator's 5s one, which is why a loaded
    text model showed no row while an image model did.

    • Studio: keep the loaded models card reachable, and stop the ejects that report work they did not do

    Two of these break the feature outright, the rest report success for an eject
    that never happened.

    The card can become permanently unreachable. use-drag-position captured
    panelRef.current once, in an effect keyed [position, reclamp]. The indicator
    returns null until the first poll produces a row, so that effect always ran
    while the ref was still null: no ResizeObserver was ever constructed, and the
    resize listener closed over null. Neither dependency changes when the node
    finally mounts, so it never re-ran, and nothing in production ever called
    clampToViewport except an active drag. Dragging the card on a 2560x1440 screen
    and reopening at 1440x900 left it at x=2502, off screen, taking its own drag
    handle and collapse button with it; resizing did not help. The only way back
    was devtools or Reset all local preferences. The node now arrives through
    state so the effect re-subscribes, and the stored position is clamped as it is
    read so it cannot paint off screen even for one frame.

    Locale parity fails. CI's first gate is i18n:check:strict and the two new keys
    were added to en.ts only, so all 11 overlays were short. Added everywhere.

    The chat eject reported ejects it never performed. When nothing matching was
    resident it POSTed /unload anyway and returned stillResident: null without
    re-reading. The backend answers 200 "unloaded" for a model it never held, so
    the user got a success toast for a no-op and their picker selection was
    cleared off the back of it. Reachable by an ordinary sequence: loading B
    evicts A, and the card shows A for up to one poll. It now reports the same
    replaced / already-gone outcomes the runtime rows use, and only names a model
    directly for a cached row, which is the one case a status read cannot see.

    The dictation eject could release another engine's model. /audio/stt/unload
    silently serves gguf from the transformers engine when whisper-server is
    absent, and the unload response body is a fixed {loaded_model: null}, so a 200
    was not evidence this engine let go. It now re-reads and reports what the
    engine actually holds.

    No timeout on the eject path. READ_TIMEOUT_MS guarded only readLoadedModels; a
    runtime that accepted the connection and never answered left the row disabled
    with a spinner until reload. Same bound, raised rather than swallowed.

    An audio-input VLM was filed under Speech. The split was audio_type !==
    "whisper", but the backend's own is_audio_input_type names two input types,
    whisper and audio_vlm. A model that listens was labelled as one that speaks,
    which is the confusion this card exists to remove.

    A stale poll could restore an ejected row, and the trailing refresh could not
    repair it because the in-flight guard was still held by that same read. A
    generation counter retires reads issued before an eject, and a pending flag
    keeps the trailing refresh.

    The drag now captures the pointer, as the Live monitor's does. Without it a
    release over another window is never delivered and the card follows the cursor.

    Backend: the card polls four runtimes every 5s for as long as the app is open,
    and only /api/inference/status was in _QUIET_POLL_PATHS. Its three siblings
    are now too, or every desktop tauri.log carries three lines every five seconds.

    Tests

    tests/loaded-models-platform-matrix.test.ts walks the payloads
    [Windows, Linux, WSL, macOS] x [NVIDIA, AMD ROCm, Intel XPU, CPU-only, Apple]
    actually produce. None of it is visible from a CUDA box: ROCm reports itself
    as cuda, Apple reports mps, XPU reports xpu for images but cpu for dictation
    because the STT device probe never checks torch.xpu, and the sd.cpp engine
    omits model_kind and puts "gguf" in dtype.

    tests/loaded-models-backcompat.test.ts covers the desktop app running against
    an older wheel, which is a supported configuration: it ships its own frontend
    bundle and adopts a separately installed backend. /video/status and the STT
    mtmd block are both days old, so a 404 route, a missing engine block, the
    pre-split top-level fields and absent optional fields all have to degrade
    rather than blank the rows a working runtime returned. Also pins that fields
    a future backend adds are ignored.

    tests/loaded-models-drag-restore.test.ts guards the restore geometry and, by
    reading the source, the wiring it depends on. Four of its cases fail against
    the code before this commit.

    tests/studio/playwright_loaded_models_indicator.py runs 28 checks in Chromium,
    Firefox and WebKit, covering Chrome, Edge and the WebView2, WKWebView and
    WebKitGTK the desktop app embeds. It stubs the four status endpoints with
    page.route, so it needs no model, no GPU and no llama.cpp build, and can
    therefore replay the AMD, Apple and CPU-only payloads on any runner. The
    position restore is here rather than in the node suite because it needs a real
    layout and a real ResizeObserver. Wired into the Linux, macOS and Windows UI
    workflows alongside the existing permission runs. Against the code before this
    commit it reports 25/28, failing exactly on the off-screen card and the
    mislabelled audio-input model.

    • [pre-commit.ci] auto fixes from pre-commit.com hooks

    for more information, see https://pre-commit.ci

    • Studio: read the indicator's rows in one call, so the eject checks stop racing them

    Staging CI on macos-14 caught this: every check up to the eject passed, then
    the run died on a 60s locator timeout.

    rows() read count() and then indexed nth(i). The eject checks poll it while
    the row is disappearing, which is the whole point of them, so the row could go
    between the two calls and nth(1) would block for the full locator timeout
    rather than reporting a shorter list. evaluate_all takes the snapshot in a
    single evaluation, so there is no window.

    card_text had the same count()-then-read shape; it is now bounded and returns
    empty rather than blocking when the card is mid-change.

    Test-only. Confirmed 28/28 in Chromium, Firefox and WebKit after the change.

    • [pre-commit.ci] auto fixes from pre-commit.com hooks

    for more information, see https://pre-commit.ci

    • Studio: finish the second review round on the indicator

    Run the media pages' full unload cleanup on an indicator eject, not just a
    status refresh. Images and Video kept lastLoad and canReapply, so Reapply
    still offered to reload the model that was just ejected, and the stale
    quantisation stayed on screen. Both now share that cleanup with their own
    Unload rather than duplicating half of it.

    Cap the overlay stack under a monitor parked high in its column. Avoidance was
    skipped entirely whenever the monitor's bottom sat above the midpoint, on the
    grounds that the corner was free, but the stack grows upwards from the bottom
    and a full download list plus the card reaches well past halfway. It is capped
    to the gap below such a monitor instead, since lifting over it would be wrong.

    stackBottomInset and stackMaxHeight now come from one stackGeometry call, so
    the two cannot disagree about where the monitor is.

    • Studio: stop the chat header claiming an evicted model is loaded

    Loading an image or video model evicts the chat model: the GPU arbiter allows
    one owner and logs 'evicting chat for diffusion'. Nothing on the chat page
    said so. modelLoaded was '!!checkpoint && !modelLoading', which reads the
    picker selection rather than what the backend holds, and the status poll's
    null-active_model branch reset three capability flags but left the checkpoint
    alone. So the header kept its green tick for a model that was gone and the
    next prompt came back as a bare 'Request failed (400)'.

    The store now tracks residentCheckpoint from /api/inference/status, published
    in applyActiveModelStatusToStore so the poll and the readopt path share it,
    and the header derives loaded from that. undefined until the first read, so
    startup does not flash 'not loaded'; an external model is unaffected, having
    nothing resident.

    An eviction also raises a toast naming the model, since it is otherwise silent
    and the card in the corner was the only thing reporting it.

    • Studio: fix the header tick for a model that was evicted

    The previous commit added residency tracking but changed the wrong
    modelLoaded: that one gates image attachments. The tick in the header comes
    from the model selector's own isLoaded, which was 'selected !== ""', so it
    still lit up for a chat model an image load had already evicted.

    The selector now takes an optional loaded prop, defaulting to the old rule so
    the Images and Video pickers are untouched, and the chat header passes
    residency through it. A test reads both the selector rule and the header call
    site out of the source, since the failure mode here was wiring, not logic.

    • Studio: show a loading model in the indicator, with the toast

    The load toast appeared at once and the card up to five seconds later, so a
    slow load looked like nothing was happening anywhere but the toast.

    The four load calls now announce themselves. model-eject-events becomes
    model-lifecycle-events, and withModelLoadNotice wraps loadModel,
    loadDiffusionModel, loadVideoModel and loadSttModel, so every caller is
    covered without each page remembering to and the announcement cannot drift
    from the request. The indicator adds a row on the start event and re-reads on
    the settle one, rather than waiting for its next tick.

    Loads are also read from the polls that already carry them: /status reports
    loading[] and the dictation status reports loading per engine. That covers a
    load started in another tab or before the page opened, which no event can.

    A loading row shows a spinner where the eject button goes, since there is
    nothing resident to release yet, and a status row for the same runtime always
    replaces the announced one.

    • Studio: let an audio VLM stay a chat row, and keep an exiting monitor from clearing the live frame

    Two review items from the latest round.

    An audio VLM is a chat model. is_audio means TTS in this codebase -- both
    ModelConfig constructors build it as audio_type is not None and audio_type != "audio_vlm", llama.cpp keeps _is_audio False for csm/whisper/audio_vlm, and
    mlx_inference says so outright: "audio_vlm (omni audio input; is_audio stays
    False -- it means TTS and redirects in the chat route)". The frontend already
    agrees elsewhere: isMultimodalResponse counts audio_vlm as multimodal chat
    capability, which is what lets a user attach audio to a chat message.

    So the audio types split three ways, not two: whisper is the ASR sidecar,
    audio_vlm answers prompts and happens to listen, and the remaining four speak.
    The previous rule sorted every audio type as speaks-or-listens and filed
    Gemma 3n under Dictation. Nothing was user-visibly wrong, because is_audio is
    never true for audio_vlm and the row already fell through to Chat -- but the
    rule encoded a false invariant and the test pinned it as the contract, which is
    the part worth fixing.

    Reopening the Live monitor during its exit animation blanked the frame.
    AnimatePresence defaults to sync mode, so the replacement panel mounts while
    the old one is still animating out, and the old one unmounts last. Its cleanup
    called setFrame(null) unconditionally, wiping the frame the replacement had
    just published. Nothing republishes afterwards: the reconcile effect is
    ResizeObserver-driven and the republish effect keys on layout, so a monitor
    left sitting still never writes again. The overlay stack then dropped back to
    the bottom-right corner and covered the open monitor, including its close
    button.

    The store now records which panel published the frame, and clearFrame is a
    no-op unless that panel still owns it.

    tests/monitor-frame-ownership.test.ts covers the reopen-during-exit case, a
    lone close still clearing, the replacement clearing its own frame afterwards,
    and that no-op writes still do not notify (the overlay stack re-renders on
    every notification). Six of its seven cases fail without the change.

    • Studio: keep the images and video loading rows up for the whole background load

    Both loads only start the work: begin_load hands off to a daemon thread and the
    POST returns at once, while /images/status and /video/status keep reporting
    loaded: false until it finishes. withModelLoadNotice settled on the POST, so the
    indicator row appeared and vanished within one round trip while the page toast
    went on saying "loading" for minutes, which is the exact gap the notice exists
    to close.

    withBackgroundLoadNotice settles from the same load-progress endpoint the toast
    watches instead. The poll belongs to the load call rather than to a page, so the
    row still settles when the user navigates away mid-load; a load that never
    started settles from the POST, so exactly one of the two paths ends the notice.
    An unreadable progress read is not treated as terminal, since a restarting
    backend would otherwise drop a live row, and an hour-long deadline covers a
    backend that never answers again.

    • Studio: say what the eject read bound does and does not cover

    • Studio: bound each background progress read, and drop pending loads on disable

    Two follow-ups on the loading rows.

    The hour-long deadline only bounded the settle loop if every turn of it ended.
    A load-progress request that accepted the connection and never answered parked
    the await forever, so the deadline was never tested again and the row stayed
    loading for the life of the tab. Each read now runs under its own AbortController
    timer, and both progress endpoints take a signal so the request is really
    dropped, not just ignored. A plain controller rather than AbortSignal.timeout,
    which the older WebKitGTK builds Tauri embeds do not have.

    Nothing listens for the terminal event while the indicator is disabled, so a
    load in flight when the pref went off left a pending entry that came back on
    re-enable as a row no poll could retire: withPendingLoads only yields to a
    status row for the same runtime, and a failed or since-unloaded load has none.
    The pending map is now emptied on the disable transition, adjusted during render
    rather than from an effect so the stale rows never reach the DOM.

    • Studio: re-read the chat status when another runtime loads

    The header kept its tick on an evicted model because nothing ever told the
    chat page the model had gone. syncInferenceStatusToStore, the only thing that
    updates residency, runs on mount and from refreshModelLists when the model
    lists change. It is not on a timer. So an image load evicting the chat model
    was never observed here: residentCheckpoint stayed undefined, which reads as
    loaded, and the previous two commits could not take effect.

    Confirmed against the running server rather than by reading: /api/inference/status
    reports active_model null while the header still showed the model ticked, and
    /api/models/list, which only that sync calls, was absent from the log for the
    whole period after the eviction.

    The chat runtime now re-reads whenever another runtime finishes a load, using
    the lifecycle event added for the indicator. Loras are skipped: only residency
    is in question.

    • Studio: settle a cancelled load, and show a replacement next to what it replaces

    Two faults in the loading rows, both in the window where a load is in flight.

    A cancelled or evicted load leaves load-progress reporting null: all three
    engines return that only for "nothing loading and nothing loaded", and unload
    bumps the load token and drops the loading marker, so the phase never changes
    again. Treating null as non-terminal was wrong -- begin_load records its loading
    state before the POST answers, so a read before the first byte moves already
    says downloading, and null after that means the load ended with nothing to show.
    Ejecting a model whose replacement was still downloading therefore replaced the
    row with a spinner that had no eject on it and stood for the full hour deadline.
    The Images page's own poll has always treated null as terminal, "else this loop
    spins forever". An unreadable read stays distinct from it, as undefined.

    The other half is why that eject was offered at all. Images and video keep the
    old pipeline resident while the replacement downloads, freeing it only at the
    commit, so status reports the old model for the whole pull. Suppressing the
    announcement on the runtime alone hid the incoming model for minutes, showing
    only the one being replaced. A status row now wins only when it describes the
    same load; a row that is itself loading still wins, which is what keeps chat and
    dictation from announcing a load twice when the backend spells the name
    differently.

    • Studio: retire a settled load row only once a read replaces it

    The row vanished at random. On the settle event the optimistic row was dropped
    immediately and a read was asked for, but that read is a round trip away and
    can be queued behind one already in flight. For that gap the card had one row
    fewer, and nothing at all when it was the only one, so it blinked out and came
    back a moment later.

    The row is now retired by the read that supersedes it rather than ahead of it,
    so the card hands over from the announced row to the status row with no gap
    either way. A read that fails still retires it, from the same finally, so a
    row cannot be left spinning.

    • Studio: initialise the loaded models preference before the indicator

    There is a real cycle here: the barrel evaluates the indicator, the indicator
    imports the settings barrel, that eagerly evaluates SettingsDialog and so
    general-tab, and general-tab's reset list dereferences
    LOADED_MODELS_PREFERENCE_KEYS back through the barrel it is still inside.

    It happens to work today only because app.tsx reaches the settings barrel first,
    which finishes the preference module before general-tab's body runs. Entering
    the loaded models barrel first is enough to break it: under Vite dev, which
    serves native ESM and so has no bundler to reorder the graph, that throws
    "Cannot access 'LOADED_MODELS_PREFERENCE_KEYS' before initialization" and the
    app never starts.

    Exporting the preference module first evaluates the constant before the
    indicator is touched, so either entry order is safe. Pinned by a test, since a
    bundler can hide this.

    • Studio: gate the picker and hub "Loaded" marks on residency too

    The header tick was one of three places claiming a model was loaded, and
    all three read the picker selection. An image or video load evicts the
    chat model, which leaves the selection alone, so the dropdown's green
    "Loaded" badge and the Model hub cards went on marking a model the
    backend had already released.

    Both now use the same rule as the header: resident, not merely picked.
    An unread status stays as it was, so nothing flashes on startup, and an
    external selection is untouched since it holds no local weights.

    • Studio: drag both floating panels without a render per frame

    Dragging the loaded models card or the Live monitor was heavy. Three
    things, all per pointermove: each move set React state, so a trackpad
    reporting faster than the display refreshed rendered frames nobody saw;
    the move was written to left/top, which is the layout path, and both
    panels are expensive to lay out again (a wide blurred shadow on one, a
    backdrop filter on the other); and the card's reclamp effect was keyed
    on the position it was updating, so every frame disconnected its
    ResizeObserver and built a new one, each rebuild forcing a synchronous
    layout.

    A drag now records the offset, asks for one animation frame, and moves
    the node with a translate3d, so it costs no render at all. Release hands
    the offset back to left/top, written to the node as well as to state so
    it cannot flash at the spot it started from, and folds in a frame still
    owed rather than landing behind a flick.

    The monitor also published its box to the shared frame store on every
    frame, re-rendering each overlay subscribed to it. That now settles on
    release, so the stack moves aside once instead of chasing the drag.

    • Studio: drop the chat pick when the server evicts the model

    Loading an image model evicts the chat one, and the picker went on
    naming it. The last few rounds only dimmed the marks that said "loaded",
    which was the wrong end of it: the name on its own reads as "this is my
    model", and sending to it comes back a bare 400.

    An eviction now clears the pick, so the picker drops to "Select model",
    which is already what a server-side unload does. Guarded on the model
    having been known resident and on no load being in flight, since a load
    also reports no active model while it runs and must not wipe the pick
    the user just made.

    • Studio: time the load row's giving-up from the last healthy read, not the start

    An absolute hour deadline retired the row on a download that was still visibly
    progressing: a large video checkpoint on a slow link legitimately runs longer
    than that, and every progress read still said downloading. The window now
    measures a stall, restarting on each healthy read, so only a sustained run of
    unreadable polls gives up and a load that keeps reporting progress is never
    abandoned.

    Also drop the drag sentinel when the handle that started the drag has no click
    to consume it. The expanded card's grip is not a button, so after dragging by it
    the flag stayed set, and the collapsed pill's next click read a drag that was
    not its own and refused to expand. Cleared on both end paths: pointerup and the
    buttons === 0 bail for a release the window never saw.

    • Studio: let the loaded models card be closed, and reopen it on the next load

    Adds an X beside the collapse chevron, and swaps the card's brain badge
    for the sparkle.

    Closing is its own flag, not the Settings toggle. They read as the same
    thing but are not: closing means "not now", so the next model load
    brings the card back, while the toggle means "stop showing me this" and
    no load may override it. Reopening happens on the start of a load, so
    the card is up for the same stretch as the toast, and setting the flag
    to what it already holds is inert, since every load start writes it.

    Kept in storage rather than in memory, like the collapsed state, so a
    reload does not undo a close.

    • Studio: eject with the eject glyph, and badge the card with the single sparkle

    A row's X released the model's weights while the header's X only closed
    the card, so one glyph stood for two unrelated acts. The row now uses
    Remove circle, which is already the model picker's own eject shortcut,
    and the header keeps the X the Live monitor closes with.

    The badge was Sparkles, which is the two-star glyph. Sparkle is a
    separate icon and the free set shipped in 4.1.1 predates it, so it is
    defined in lib/ beside the chevrons rather than moving every other glyph
    in the app to a newer icon set for one shape. The path is the published
    stroke-rounded outline with its fixed stroke swapped for currentColor.

    • Studio: drop the drag sentinel option, which guarded a state that cannot happen

    I added this on a review note and the note was wrong. Every drag handle wires
    onPointerDown to startDrag, and startDrag zeroes movedRef; pointerdown fires
    before click, so the pill always clears the sentinel before its own click reads
    it. A drag by the expanded grip cannot leave a stale flag for the pill to trip
    over, and the clickFollows option was guarding a state the code cannot reach.

    Checked rather than reasoned: with the option removed, dragging by the grip,
    collapsing, and clicking the pill once reopens the card, on all three engines.
    That sequence is now a browser check, and it asserts the collapse really
    happened first so it cannot pass vacuously. The unit tests assert the invariant
    that actually holds the line, which is that every handle goes through startDrag.

    • Studio: keep recording loads while the card is closed, so one can reopen it

    Closing the card is meant to mean "not now", with the next load bringing it
    back. The dismissal was cleared correctly, from the indicator's own
    subscription, but the rows come from useLoadedModels and that subscription
    early-returned while the card was hidden. So the loading: true event was never
    recorded, and the card reopened onto nothing.

    Chat and dictation limped: the 5s poll synthesises their loading rows from
    status.loading and the per-engine flag. Images and video have no such fallback,
    so with nothing else resident the card did not come back at all until the load
    finished, which on a large download is minutes. Reproduced in a real browser
    before fixing, and the reopened card now reads "Image . Loading" for the model
    that reopened it.

    Recording is now separate from showing: only the Settings toggle stops it, since
    that is the one that means "stop telling me", while a closed card or a route
    that hides the card keeps listening. The pending map is still emptied when
    recording stops, so a load missed while the preference was off cannot come back
    as a row no poll can retire.

    • Studio: do not read an unreadable dictation status as an empty one

    The dictation unload answers {loaded_model: null} whatever it did, and the
    backend serves gguf from the transformers engine when whisper-server is absent,
    so the eject re-reads the status to find out what actually happened. But
    readSttStatus returns null on any non-2xx as well as for an empty runtime, and
    the re-read treated the two the same: a transient 404 or 500 on the confirming
    read toasted "Ejected" and dropped a row whose model was still holding memory.
    That is the exact case the re-read exists to catch.

    An unconfirmed unload is now its own outcome rather than being collapsed into
    success or failure, since neither is true, and it warns instead of claiming the
    memory was freed. Images and video already raise here, since their unload
    responses go through parseJson; only dictation swallowed it.

    • Studio: re-read chat residency when a media load takes the GPU, not when it ends

    The arbiter evicts chat inside the image or video load POST, before the
    background download starts, but the chat runtime only re-read its status when
    that load settled. Since the settle waits for the whole download, the picker and
    the header went on naming the evicted model for its entire duration.

    Measured against a live backend rather than inferred: with Qwen3-0.6B-GGUF
    resident, /api/inference/status reported active_model null 1.8s after the image
    load POST returned, and a send to the model the picker still offered answered
    400 "No model loaded. Call POST /inference/load first."

    The load is now announced a second time once the POST returns, which is the
    instant the eviction has committed; announcing only before it would have had
    listeners re-read a status that was still correct. Dictation is excluded, since
    it is a sidecar and takes no GPU ownership.

    Also settle the media page's own busy state when an eject cancels its load. The
    indicator shows the resident row next to the incoming one during a replacement,
    so that eject is reachable; the page's listener then stops the load-progress
    poll, which is the only thing that clears busy. Left set it locked the page --
    the picker ignores every choice, and Unload is not rendered once the status read
    comes back empty -- and both pages stay mounted for the session, so only a
    reload recovered.

    • Studio: stop the indicator reporting ejects it did not perform

    Three of the four eject paths could report a success with no unload behind
    it, and the card could overwrite a saved position it was only adapting to
    the current window. Found by driving the branch against a real backend in
    Chromium, Firefox and WebKit with the four /status endpoints stubbed.

    A stale row over an idle runtime said "Ejected X". ejectRuntimeRow folded
    verifyResident's "gone" verdict into "ejected", so a row up to one poll old
    whose runtime had already released everything reported an unload that never
    ran. Images, video and dictation all reach this without anyone doing
    anything: the dictation sidecars release themselves when idle. It is now a
    distinct alreadyFree outcome, and the toast says the model was no longer
    loaded. The row still goes, since its memory is free either way. That also
    retires the && resident guard, which could not be false: verifyResident
    only answers "replaced" when resident is set.

    A cached chat row was unloaded without confirmation. It is the one path with
    no scoped re-read, because a cached row is never the active model and so can
    never match one, and /unload answers 200 for a name the backend no longer
    holds. So the success was the call itself rather than evidence of a release,
    and the picker was cleared on it. ejectChatModel now takes an optional
    readCached and checks the row against what the runtime still holds, the way
    every other path checks its own.

    The card overwrote a position saved on a larger screen. The persist ran from
    an effect on position, and a reclamp changes position too, so opening the
    app on a laptop wrote the laptop's clamp back to localStorage and going back
    to the large monitor left the card where the laptop had put it. Only a landed
    drag is stored now; the read path clamps, so nothing needs the clamp kept.

    Arabic said the card is in the bottom-left. Nothing sets document.dir and the
    stack is anchored with a physical right-4, so it is bottom-right there too.

    Covered by three cases in the node suite for the cached row, one for the
    persist rule, and one in the Playwright suite for the stale row, which runs
    on all three engines. Full suite 896 pass, typecheck, i18n parity and build
    clean, and the same on a tree merged with current main (1126 pass).

    • Studio: keep a row whose runtime could not answer, rather than reading it as gone

    A read that fails or times out comes back as null and the describe helpers map
    null to no rows, so one bad poll took a loaded model off the card. Against a
    remote Studio all four reads can fail on the same blip, and the whole card went
    while every runtime stayed resident.

    An unreadable source now keeps what it last showed. A readable one is still
    replaced outright, including by an empty answer, so an unload clears the row as
    before -- that is the distinction that matters, and both halves are checked in
    the browser suite: a sustained 503 on the chat status keeps the row, and a
    readable empty status still retires it. Reverting the change fails the first and
    leaves the second passing.

    • Studio: sequence the chat refreshes, and reopen a closed card for a silent load

    Announcing a media load twice put two chat refreshes in flight inside the load
    POST's own duration, measured at 1.8s against a live backend. They read the
    status either side of the eviction and answer in whatever order the network
    gives, so the older one could land last and re-pin the model the newer had just
    seen released, leaving chat claiming a model that 400s on send until the load
    settled. Every refresh now takes a generation and the newest wins; a superseded
    one writes nothing back and reports no failure either, since its answer
    describes a moment that has passed.

    Separately, the close tooltip promises "Back on the next model load", and that
    did not hold for a load started outside the frontend: the OpenAI-compatible API
    and auto-switch raise no lifecycle event, and dismissing the card had also
    stopped the poll, so nothing was left to notice. The poll now runs while the
    card is closed, and a row appearing that was not there on the previous poll
    reopens it. The first poll after closing is the baseline rather than a reopen,
    or -- since a dismissal survives a reload -- whatever was already resident would
    make the card impossible to close at all.

    • Studio: keep a settled load's row when the read that should replace it failed

    An optimistic row is retired once a status read has replaced it with a real one.
    But readLoadedModels is handed the polled rows only, and an optimistic row lives
    in the pending map, so a read that could not see that source has nothing to
    preserve: retiring anyway took the row for a model that had just finished
    loading off the card, on the strength of a request that failed.

    The read now reports which sources it could say nothing about. Those stay
    settled, so the next readable poll retires them instead, and a read that failed
    outright is treated as evidence about none of them. A source that did answer is
    retired exactly as before, including when it answers empty, or the row would
    never go at all.

    • Studio: stop writing off an eject target that was only demoted to the cache

    Eject an active row that was replaced inside the card's five-second poll window
    and nothing was released. The standard backend does not evict on a switch:
    load_model only moves active_model_name and leaves the previous model in
    backend.models (core/inference/inference.py:620), which /status still reports
    under loaded (routes/inference.py:8112). So a switch from another tab, the
    OpenAI-compatible API or the CLI demotes A to a cached entry that is still
    holding its weights, the scoped read cannot match it, and the click reported
    "A is no longer loaded. B took its place and was left alone" having unloaded
    nothing. With the replacement since gone it read as alreadyFree and dropped the
    row as well.

    The branch now asks what the runtime is really holding before writing the row
    off, reusing the readCached the confirming read already had, so the demoted
    model is unloaded by name and confirmed like any other. A target that is
    genuinely gone still reports replaced and unloads nothing, and with readCached
    omitted the behaviour is unchanged. GGUF is unaffected: that branch reports
    loaded as the single active id.

    Three cases in the node suite: a row replaced while still cached, a row
    replaced and really gone, and an idle runtime still holding the row. The first
    and third fail with the source reverted.

    • Studio: keep the indicator off the login page, and surface what a GGUF load left behind

    Two from the latest round, both real.

    Recording polled four protected endpoints on /login. Widening the recording
    flag past enabled was right for dismissal, so a closed card still hears the
    load that reopens it, but it went one step too far: canShowIndicator also
    carries the auth gate, and gating recording on the Settings toggle alone left
    the poll running before there was a session. Each tick then sent four requests
    that 401 and drove authFetch's refresh-then-redirect ladder, every five
    seconds, for as long as the login page was open. Reachability is now hoisted
    and shared, so tracking survives a dismissal but not a route with no session.

    A model cached behind a GGUF was invisible and could not be released. The GGUF
    load unloads only the ACTIVE Unsloth model (routes/inference.py:6603-6610), so
    a Transformers model cached behind it keeps its weights, but the GGUF status
    branch reported loaded as the single active id. So after loading A, then B,
    then a GGUF, A held VRAM that nothing surfaced and no client could free. That
    branch now also reports what the registry still holds, deduped against the
    GGUF's own id and peeking rather than constructing the orchestrator. The unload
    route already reaches a cached model by name through
    _resident_standard_model_name, so the rows this adds are ejectable as they
    stand.

    Three backend cases and one frontend case, each failing with its source
    reverted. 919 node tests and 31 backend tests pass; typecheck, i18n parity and
    build clean.

    • [pre-commit.ci] auto fixes from pre-commit.com hooks

    for more information, see https://pre-commit.ci

    • Studio: lift the corner stack above the chat composer, which the card was covering

    The loaded models card is the first overlay in the bottom-right stack that is
    persistent rather than transient, and the chat composer docks to the bottom of
    that same column once a thread has turns. The card landed on top of the Send
    button and swallowed the click: Playwright resolved the button, found it
    visible, enabled and stable, then retried for 60s while
    "unsloth/gemma-3-270m-it-GGUF ... subtree intercepts pointer events". Caught by
    the chat UI suite on both ubuntu-latest and macos-14.

    The stack already knows how to keep clear of something: the Live monitor
    publishes its box and stackGeometry lifts the stack above it. That store held
    one box, from one publisher, so the composer had nowhere to say where it is.
    It now keeps a box per publisher and dodges their union, which is also the
    right answer while the monitor is mid-exit and two panels are briefly
    published at once.

    The composer publishes through a small usePublishedFrame hook, re-measuring on
    resize and through a ResizeObserver because it grows with its input and moves
    from centred to docked without a resize event. A hidden composer measures 0x0
    and is withdrawn rather than published, which would otherwise pull the union
    to the top-left and pin the stack there.

    Verified against a live backend at 1280x900, 1280x720 and 1440x900 with the
    composer docked: the element under the Send button's centre is the composer,
    and reverting the publish alone puts the overlay stack back there at all
    three. 1289 node tests pass, typecheck, i18n parity and build clean.

    • Studio: fold the stack's obstacles one at a time, not as their bounding box

    Follow-up to the composer fix. Publishing a second box made the store hold two,
    and reading their union as one obstacle was wrong: a tall monitor and the wide
    docked composer share almost no area, so the rectangle around the pair covers
    most of the viewport. stackBottomInset then lifted the stack to its clamp near
    the top of the screen and put it back over the monitor it was dodging. The chat
    UI suite caught it on Windows and macOS as the card swallowing the monitor's
    Close button, at the step that drags the monitor up the column first, which is
    exactly when the union is most unlike its parts.

    stackGeometry now folds per box: each asks for the lift it needs, the stack
    takes the largest, and the height is the shortest any of them allows. The
    single-box maths is untouched, so its existing cases still pin it. The store
    keeps the union as a summary only, and says so.

    Three cases added: the fold against a monitor parked high, which is the case
    that diverges, plus an empty list and a single box matching the old calls.
    1295 node tests pass, typecheck and build clean, and the docked-composer probe
    still finds the composer under the Send button at three viewports.

    • Studio: let only the newest status read write on the media pages

    The Images and Video pages hold their own status and re-read it on tab
    activation and on their own actions, never on a timer. Two reads are therefore
    in flight across an eject: the activation read that saw the pipeline loaded,
    and the post-eject read that saw it gone. Responses have no order, so the older
    one could land last and put the loaded status back, leaving the page offering
    to generate against a runtime that was already free, with no poll coming to
    correct it. Both pages had it, identically.

    Every read now takes a ticket before it awaits, and only the newest may write.
    All three writers on each page go through the same gate, not just the refresh:
    the load-progress read and the unload response could overwrite a newer answer
    just as easily.

    Also drops the merged rectangle the frame store still exposed. Nothing read it
    after the fold landed, but it was the shape the review picked up on, and it
    merged obstacles that do not touch: the rectangle around a left-hand monitor
    and a bottom-right composer spans the empty space between them. The store keeps
    the boxes apart and says why; stackGeometry already folds them one at a time.

    Four cases pin the sequencing, all four failing with the pages reverted. 1299
    node tests pass, typecheck, i18n parity and build clean.


    Co-authored-by: shimmyshimmer danielhanchen@gmail.com
    Co-authored-by: danielhanchen moonshotaisubstack@gmail.com
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

    下载附件