diff --git a/src/local-model/connect.ts b/src/local-model/connect.ts index 0af1fb12..d6d8e87b 100644 --- a/src/local-model/connect.ts +++ b/src/local-model/connect.ts @@ -263,16 +263,30 @@ export async function isManagedLocalEndpoint(id: string): Promise { } /** - * The names of the live sessions that use the endpoint `endpointId`, in the order given. - * Pure. What `DELETE /api/local-model` answers its 409 with. + * What a session is called where a person reads it. A session an agent started (a worker of + * the codeman skill, `codeman agent spawn`) has no name of its own, and its tab then shows + * the last folder of its working directory, else the first eight characters of its id + * (`getSessionName()` in app.js). The same steps here, so that a sentence which lists + * sessions never lists an empty name. Pure. + */ +export function sessionLabel(session: { id?: string; name?: string; workingDir?: string }): string { + const name = (session.name ?? '').trim(); + if (name) return name; + const folder = (session.workingDir ?? '').split('/').filter(Boolean).pop(); + return folder || (session.id ?? '').slice(0, 8); +} + +/** + * The live sessions that use the endpoint `endpointId`, in the order given, each by the name + * its tab shows (`sessionLabel()`). Pure. What the feature answers a 409 `sessions-open` with. */ export function sessionNamesUsingEndpoint( - sessions: Iterable<{ name: string; customModel?: { endpointId: string } }>, + sessions: Iterable<{ id?: string; name: string; workingDir?: string; customModel?: { endpointId: string } }>, endpointId: string = LOCAL_MODEL_ENDPOINT_ID ): string[] { const names: string[] = []; for (const session of sessions) { - if (session.customModel?.endpointId === endpointId) names.push(session.name); + if (session.customModel?.endpointId === endpointId) names.push(sessionLabel(session)); } return names; } diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 62d14ba4..5cc46b70 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). -**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ The coding agents of an installed model (`install.harnesses`) are ONE builder, `_localModelAgentsEl()`: a radio group of chips under "Start a session with" (the server's order, its default selected, roving `tabindex`, arrows, Home, End), a tag on a chip that is not there ("Not installed", "Installing", "Needs setup"), and under the chips one line about the selected one. The main button follows the selected chip by what the server lists for THAT row (`row.actions`): "Start a session" with that agent, or "Install " with the row's literal `installCommand` printed above it (the click is the consent; `POST /api/local-model/agent` carries the id only, never the command), a disabled "Installing " while it runs, and nothing to press for `setup-needed`. An install of an agent never changes the screen: the state stays `installed`, and a failed one shows the server's sentence under the chip (the installer's words folded away), never as the model's headline. On a stopped model Start stays the one main button. The block is not on the screen that ends an install and not while an error blocks every session; a payload without the list keeps the old one-agent screen. The device remembers the agent a session was last STARTED with (`codeman-local-model-agent` in localStorage, read in try/catch, compared only with ids of the payload), and an opening of the window selects it only while it is `ready`: a chip that was merely looked at, or an agent that has gone, must never reopen the window on "Install" with "Start a session" out of sight. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`, and `test/local-model-ui.browser.test.ts` (real Chromium, not in the gate: the columns, the two-line phone rows, the long list that scrolls from its top); the visual walk is a scratch Playwright script, not a test. +**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`; never after a switch, a job without a `model` step, which ends on the whole installed screen so the list of models is still there for the next Use or Delete), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ The coding agents of an installed model (`install.harnesses`) are ONE builder, `_localModelAgentsEl()`: a radio group of chips under "Start a session with" (the server's order, its default selected, roving `tabindex`, arrows, Home, End), a tag on a chip that is not there ("Not installed", "Installing", "Needs setup"), and under the chips one line about the selected one. The main button follows the selected chip by what the server lists for THAT row (`row.actions`): "Start a session" with that agent, or "Install " with the row's literal `installCommand` printed above it (the click is the consent; `POST /api/local-model/agent` carries the id only, never the command), a disabled "Installing " while it runs, and nothing to press for `setup-needed`. An install of an agent never changes the screen: the state stays `installed`, and a failed one shows the server's sentence under the chip (the installer's words folded away), never as the model's headline. On a stopped model Start stays the one main button. The block is not on the screen that ends an install and not while an error blocks every session; a payload without the list keeps the old one-agent screen. The device remembers the agent a session was last STARTED with (`codeman-local-model-agent` in localStorage, read in try/catch, compared only with ids of the payload), and an opening of the window selects it only while it is `ready`: a chip that was merely looked at, or an agent that has gone, must never reopen the window on "Install" with "Start a session" out of sight. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`, and `test/local-model-ui.browser.test.ts` (real Chromium, not in the gate: the columns, the two-line phone rows, the long list that scrolls from its top); the visual walk is a scratch Playwright script, not a test. **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index a33a6bd1..cb885f20 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -569,14 +569,20 @@ Object.assign(CodemanApp.prototype, { /** * Called with the `install` object that is about to replace the one on screen. An install * that was running and is installed now finished in front of the person: the window then - * shows the screen that ends it (see `justInstalled`). Any other state ends that moment. + * shows the screen that ends it (see `justInstalled`). A switch is the exception: it ends + * on the whole installed screen, list of models included. Any other state ends that moment. */ _noteLocalModelInstall(next) { const ui = this._localModelWindowState(); - const before = localModelInstall(this._localModelStatus).state; + const last = localModelInstall(this._localModelStatus); + const before = last.state; const after = next && typeof next.state === 'string' ? next.state : 'idle'; + // A switch downloads nothing (its job has no `model` step). The person pressed Use in the + // list of models, so it ends on that list, with "In use" on the other row: the screen + // that ends an install would take the list away from someone who came to tidy it. + const switched = Array.isArray(last.steps) && !last.steps.includes('model'); if (after !== 'installed') ui.justInstalled = false; - else if (before === 'installing' && ui.open) ui.justInstalled = true; + else if (before === 'installing' && ui.open) ui.justInstalled = !switched; }, _localModelRefused() { @@ -614,8 +620,9 @@ Object.assign(CodemanApp.prototype, { */ confirmDelete: null, /** - * The install finished while this window was open: the screen that ends it shows - * "Start a session" alone. Stop and Remove belong to the window opened later. + * An install (a job that downloaded a model) finished while this window was open: + * the screen that ends it shows "Start a session" alone. Stop and Remove belong to + * the window opened later. Never set by a switch (`_noteLocalModelInstall`). */ justInstalled: false, }; diff --git a/test/local-model-connect.test.ts b/test/local-model-connect.test.ts index 3630baf6..41d43e6f 100644 --- a/test/local-model-connect.test.ts +++ b/test/local-model-connect.test.ts @@ -375,6 +375,30 @@ describe('sessionNamesUsingEndpoint', () => { expect(sessionNamesUsingEndpoint(new Set(sessions), 'office-gpu')).toEqual(['w3-lib']); expect(sessionNamesUsingEndpoint(new Map().values())).toEqual([]); }); + + // Seen on a Mac: a worker the codeman skill started has no name, and the refusal read + // "Sessions are still using this model: . Close them first." + it('names a session without a name the way its tab does: the folder, else the short id', () => { + const ours = { endpointId: 'local-ollama' }; + expect( + sessionNamesUsingEndpoint([ + { + id: '7dbd7b0d-0000-4000-8000-000000000001', + name: '', + workingDir: '/Users/x/codeman-cases/summaries', + customModel: ours, + }, + { + id: 'a030dddb-0000-4000-8000-000000000002', + name: ' ', + workingDir: '/Users/x/codeman-cases/review/', + customModel: ours, + }, + { id: 'c1d2e3f4-0000-4000-8000-000000000003', name: '', workingDir: '', customModel: ours }, + { id: 'ffffffff-0000-4000-8000-000000000004', name: 'w1-app', workingDir: '/Users/x/app', customModel: ours }, + ]) + ).toEqual(['summaries', 'review', 'c1d2e3f4', 'w1-app']); + }); }); describe('compareModelDigest', () => { diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index dafdbb37..ce726e61 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -2681,6 +2681,49 @@ describe('the models on this Mac', () => { expect(buttons(page.body)).toEqual(['Start a session']); }); + // Seen on a Mac: Use, then the list was gone, and deleting the other model meant closing the + // window and opening it again. + it('a switch that ends in the open window comes back to the list, the other model in use', async () => { + const page = mount(); + const switching = status( + {}, + { + ...TWO.install, + state: 'installing', + step: 'verify', + steps: ['verify', 'connect'], + model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B' }, + library: [ + { ...QWEN_ROW, active: false }, + { ...GPT_ROW, actions: [] }, + ], + actions: ['cancel'], + } + ); + const switched = status( + {}, + { + ...TWO.install, + model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B' }, + library: [ + { ...GPT_ROW, active: true, actions: [] }, + { ...QWEN_ROW, active: false, actions: ['use', 'delete'] }, + ], + } + ); + const { app, serve } = await opened(page, switching); + expect(page.body.dataset.screen).toBe('progress'); + serve(switched); + await app.loadLocalModel({ force: true }); + expect(page.body.dataset.screen).toBe('installed'); + expect(texts(page.body, '.lm-lib-label')).toEqual(['GPT-OSS 20B', 'Qwen3.6 35B-A3B']); + expect(texts(page.body, '.lm-lib-tag .lm-tag')).toEqual(['In use', 'Ready']); + expect(rowButtons(page)).toEqual(['Use', 'Delete']); + // The whole installed screen, not the one-button screen that ends an install. + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + expect(texts(page.body, '.lm-lib-foot button')).toEqual(['Add a model']); + }); + it('"Add a model" opens the chooser on an installed model, and Back returns to it', async () => { const page = mount(); const { app } = await opened(page, TWO);