From 8e13686b30a35a3775da07abaa9ed15656d1c955 Mon Sep 17 00:00:00 2001 From: Nadia Rodionova <273119990+sosidudku1@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:14:19 +0300 Subject: [PATCH 1/3] feat(desktop): offer a restart when the serving model gets its vision files (ATO-1373) A vision projector is read only when a model starts. Fetching it for the model that is already running changed nothing: the Switch card is dropped for the current model, and the model stayed without vision although the file was on disk. When the finished pull is for the model the local server runs now, the download card shows "Vision files for are ready" with a Restart model button instead of Switch. Nothing restarts by itself, since that would end a reply in progress; under a running turn the button is refused with the reason and the offer stays. The offer goes away once that model stopped or started again another way. A model that is not running gets nothing: its next start picks the projector up. --- desktop/renderer/analytics-acts.js | 2 +- desktop/renderer/renderer.js | 93 ++++++++++++++++++++--- desktop/test/vision-restart.test.mjs | 109 +++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 desktop/test/vision-restart.test.mjs diff --git a/desktop/renderer/analytics-acts.js b/desktop/renderer/analytics-acts.js index aa5bdb505..62854dda9 100644 --- a/desktop/renderer/analytics-acts.js +++ b/desktop/renderer/analytics-acts.js @@ -50,7 +50,7 @@ const ANX_ACTS = (() => { tts:{auto:L}, ftoggle:L, fnew:L, frename:L, fdel:L, fpick:L, fput:L, fautosort:L, more:{chats:L, tasks:L}, task:L, toastx:L, 'turn':{continue:L}, sessmodel:{apply:L}, copy:{reply:L, session:L}, agent:{restart:L, update:L}, jump:{appr:L}, diag:{llmlogs:L, check:L, startModel:L}, - dl:{cancel:L}, dlc:{cloud:L, dismiss:L, fold:L, keep:L, retry:L, switch:L, unfold:L}, + dl:{cancel:L}, dlc:{cloud:L, dismiss:L, fold:L, keep:L, retry:L, switch:L, unfold:L, visionLater:L, visionRestart:L}, appr:{y:L, n:L, esc:L, s:L, a:L}, selector:{model:L}, cards:{expand:L, collapse:L}, room:{chat:L, tasks:L, skills:L}, insp:{steps:L, reasoning:L, world:L}, console:{agent:L, llm:L}, toggle:{sidebar:L, inspector:L, console:L, files:L}, diff --git a/desktop/renderer/renderer.js b/desktop/renderer/renderer.js index 15ea8c005..602d3eca3 100644 --- a/desktop/renderer/renderer.js +++ b/desktop/renderer/renderer.js @@ -787,6 +787,12 @@ const DL = { const SPOT = {gate: false, ids: null, asked: false, dismissed: null}; /* Backlog 18 — the download card's own state (renderDlcard). Declared up here for the same reason as DL: the first top-level render() reads it. */ +/* ATO-1373: a vision projector landed for the model that is serving now. The + projector is read only when a model starts, so the running one still has no + vision. The download card offers a Restart model instead of restarting it + unasked: a restart would end a reply in progress (llmRestartModel refuses + under a running turn). {id, pid, busy, error} or null. */ +const VISION = {restart: null}; const DLC = { /** Folded to the round badge. A fold lasts one run of downloads: the next run, after the card has been empty, starts open (DownloadPanel). */ @@ -13249,6 +13255,11 @@ function dlCardRows() { } // F8: a vision projector waiting for its turn (dlProjectorPark). Its × takes it off. DL.projectorQueue.forEach((p) => rows.push({src:'dl', kind:'projector', id: p.id, state:'queued', cancel:'dlc:drop:projector:' + p.id})); + // ATO-1373: the serving model's projector landed; a restart makes it read images. + const vr = VISION.restart; + if (vr && !rows.some((r) => r.kind === 'weights' && r.id === vr.id)) { + rows.push({src:'restart', kind:'weights', id: vr.id, state: vr.busy ? 'restarting' : 'restart', error: vr.error}); + } // Backlog 18: landed while the agent was on another model; it waits for a Switch. if (DL.ready && !rows.some((r) => r.kind === 'weights' && r.id === DL.ready.id)) { rows.push({src:'ready', kind:'weights', id: DL.ready.id, state:'ready'}); @@ -13313,6 +13324,8 @@ function dlCardPercent(r) { function dlCardLine(r) { if (r.state === 'failed') return r.error; if (r.state === 'ready') return 'Downloaded. The agent stays on its current model until you switch.'; + if (r.state === 'restart') return r.error || 'Restart the model to read images.'; + if (r.state === 'restarting') return 'Restarting the model…'; const lead = r.kind === 'projector' ? 'Vision projector · ' : ''; if (r.state === 'queued') return lead + 'Queued'; if (r.state === 'cancelling') return 'Cancelling…'; @@ -13327,6 +13340,17 @@ function dlCardLine(r) { function dlCardRowHTML(r, i) { const name = dlCardName(r); + if (r.state === 'restart' || r.state === 'restarting') { + // ATO-1373: no bar, the files are on disk. The restart is the person's to press. + const busy = r.state === 'restarting'; + return '
  • ' + + '
    ' + esc('Vision files for ' + name + ' are ready') + '' + + '' + + (busy ? '' : '') + '
    ' + + '

    ' + esc(dlCardLine(r)) + '

  • '; + } if (r.state === 'ready') { // No bar: there is nothing left to move. Switch is the start it did not get by itself. return '
  • ' @@ -13352,17 +13376,22 @@ function dlCardRowHTML(r, i) { + '
  • '; } +/** A row whose bytes are still coming: not failed, not landed, not a restart offer (ATO-1373). */ +function dlRowMoving(r) { + return r.state !== 'failed' && r.state !== 'ready' && r.src !== 'restart'; +} + /** Open: a header with the count and the fold, the rows, the cloud offer. */ function dlCardHTML(rows) { - const moving = rows.some((r) => r.state !== 'failed' && r.state !== 'ready'); + const moving = rows.some((r) => dlRowMoving(r)); const failed = rows.some((r) => r.state === 'failed'); - const ready = rows.some((r) => r.state === 'ready'); + const ready = rows.some((r) => r.state === 'ready' || r.src === 'restart'); const n = rows.length; /* The setup screen's "set up a cloud model meanwhile" offer moved here with the download it was about. Not for an embedding model: nothing waits on it. Not beside a ready row alone either: that model landed while the agent was already on another one. */ - const offer = (moving || failed) && rows.some((r) => r.kind !== 'embedding' && r.state !== 'ready'); + const offer = (moving || failed) && rows.some((r) => r.kind !== 'embedding' && r.state !== 'ready' && r.src !== 'restart'); const title = moving ? 'Downloading' : failed && ready ? 'Downloads' : failed ? 'Download failed' : 'Download complete'; const glyph = moving ? ic('download') : failed ? ic('alert') : ic('check'); return '
    ' @@ -13381,7 +13410,7 @@ function dlCardHTML(rows) { /** Folded: a round button that is still a live count. Red when one failed. */ function dlCardBadgeHTML(rows) { const failed = rows.some((r) => r.state === 'failed'); - const moving = rows.some((r) => r.state !== 'failed' && r.state !== 'ready'); + const moving = rows.some((r) => dlRowMoving(r)); return '