feat(local-model): one button and a chooser window replace the welcome card

The read-only card is gone. The welcome screen has one button, "Run a local
model" ("Local model" once one is installed), and App Settings > Models has
one row; both open the same window.

The window follows `install.state`: choose a model (a radio group, with a
memory bar filled to what the selected model needs), confirm the sizes and
the folder, watch the download and the steps, and end on "Start a session".
Paused, failed, installed (running or stopped), Remove and "no model fits"
are screens of the same window. It polls the GET once a second while open.

Every dynamic string goes in through textContent, labels and sentences come
from the payload, no CLI id is compared, and every new string has a zh-CN
entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 22:16:38 +01:00
1 parent 3af94df4f6
commit 84d5c691ff
13 files changed
+4543 -1264

No files matched your search

+1 -1
View File
@@ -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).
**Model on this Mac** (`local-model-ui.js`; welcome card `#localModelCard` + App Settings > Models group `#localModelGroup`; server rules in the root CLAUDE.md, Local model recommendation): a READ-ONLY view of `GET /api/local-model`. ONE builder (`localModelBody`) paints both containers, so the card and the Settings row can never name a different pick; the card folds the alternatives, Settings lists them. ⚠️ Every dynamic string goes in through `textContent` (model, harness and chip names are someone else's text), both containers carry `data-i18n-skip`, and the module translates its own sentences at render time, so a language or display-name change must repaint from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or harnesses, never compares a CLI id, and never contains the word "Recommended" (that headline is the server's to send). ⚠️ `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, and the only non-literal calls are the two payload ones. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never divided here; the installed memory alone is `memClassGb`, a number or nothing (an unknown size is left out, never `0 GB`). ⚠️ The card ships `hidden` and shows only once the server answered for a Mac; the card AND the Settings group stay hidden for `not-macos` (the server is usually Linux, and "this Mac" always means the machine the server runs on), for a refusal and while the first request is in flight; a failed first request shows one sentence in Settings only. ⚠️ Refused means `403` with `errorCode: FORBIDDEN` (a non-admin in multi-user mode: stop asking); the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request that is asked again on `codeman:me`. ⚠️ Each fit and speed pill carries what it measures as `title` + `aria-label` ("Good" is the memory fit, not a rating), and the line saying Codeman cannot download or install yet sits right under the pick and its notes, in `--text-dim` like every other line. The dismissal is a per-device localStorage key named for this stage (`codeman:local-model-dismissed:readonly`), never a server setting: the stage that can set a model up takes a new key so its offer shows once more. At most one request a minute (the server caches its probe for 60 s), shared while in flight, and a failed refresh keeps the answer on screen. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`.
**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. 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 memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, a flat `--accent` fill on a `--control-border-hover` track (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 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`) plus a quiet Remove. ⚠️ 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 `<html lang>` 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`. ⚠️ "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.cli, 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`; 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)
+6
View File
@@ -1468,6 +1468,12 @@ class CodemanApp {
this.handleTabRailSearchKeydown(e);
return;
}
// The local model window can sit on top of App Settings (it opens from a
// row there): Escape closes the window alone and leaves Settings open.
if (this.isLocalModelWindowOpen?.()) {
this.closeLocalModelWindow();
return;
}
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
+126 -26
View File
@@ -1407,39 +1407,96 @@
'该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。',
'Git status is not available for a tmux session opened outside Codeman.':
'在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。',
// Model on this Mac (local-model-ui.js: the welcome card and its App Settings group). The first
// block is the module's own text; sizes and names fill the {placeholder} slots.
// Run a model on this Mac (local-model-ui.js: the welcome button, the chooser window and its
// App Settings group). The first block is the page's and the module's own text; sizes and
// names fill the {placeholder} slots.
'Model on this Mac': '此 Mac 上的模型',
'What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.':
'运行 {name} 的这台 Mac 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。',
'Run a coding model on the Mac that Codeman runs on. No account and no API key.':
'在运行 {name} 的这台 Mac 上运行编程模型。无需账号,也无需 API 密钥。',
'Run a model on this Mac': '在此 Mac 上运行模型',
'Run a local model': '运行本地模型',
'Local model': '本地模型',
'The Mac that Codeman runs on, which is not always the device you are reading on.':
'指运行 {name} 的那台 Mac,不一定是你正在使用的这台设备。',
Hide: '隐藏',
'Hide this card on this device. It stays in App Settings, under Models.':
'在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。',
'{chip}, {memory} GB memory, {free} GB free': '{chip},内存 {memory} GB,可用磁盘空间 {free} GB',
'{chip}, {memory} GB memory': '{chip},内存 {memory} GB',
'{memory} GB memory, {free} GB free': '内存 {memory} GB,可用磁盘空间 {free} GB',
'{memory} GB memory': '内存 {memory} GB',
'{model} ({id}) with {harness}': '{model}({id}),搭配 {harness}',
'Context {context} tokens': '上下文 {context} 个 Token',
'{need} GB of memory': '占用内存 {need} GB',
'{download} GB download': '下载 {download} GB',
'Needs about {need} GB of the {budget} GB this Mac can give a model.':
'大约需要 {need} GB,此 Mac 可分配给模型的内存为 {budget} GB。',
'Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).':
'大约需要 {need} GB,此 Mac 可分配给模型的内存约为 {budget} GB(估算)。',
'About {speed} tokens per second (estimated).': '每秒约 {speed} 个 Token(估算)。',
'{harness} is not installed on this Mac yet.': '此 Mac 上尚未安装 {harness}。',
'With {harness}, the first answer can take minutes.': '使用 {harness} 时,第一次回答可能需要几分钟。',
'about {speed} tokens per second': '每秒约 {speed} 个 Token',
// What the fit and speed labels measure (their tooltip and accessible name).
'Memory fit: needs {share}% of what this Mac can give a model':
'内存占用:需要此 Mac 可分配给模型的内存的 {share}%',
'No speed estimate for this model on this chip': '此芯片上没有该模型的速度估算',
'Other models that fit': '其他装得下的模型',
'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.':
'{name} 目前还不能为你下载或安装它。这是下一步的功能,尚未实现。',
// The App Settings row: where the install stands, in a few words.
'No model installed': '尚未安装模型',
'Installing {model}': '正在安装 {model}',
'The install of {model} is paused': '{model} 的安装已暂停',
'The install of {model} stopped': '{model} 的安装已中止',
'{model} is running': '{model} 正在运行',
'{model} is installed and stopped': '{model} 已安装,当前已停止',
'Checking this Mac': '正在检查此 Mac',
// The chooser: the memory bar, the list, the coding agent.
'About {need} of {budget} GB of memory': '约需 {need} GB 内存,共 {budget} GB 可用',
estimated: '估算',
'The selected model needs {share}% of the memory this Mac can give a model':
'所选模型需要此 Mac 可分配给模型的内存的 {share}%',
'The model at {context} tokens of context': '模型在 {context} 个 Token 上下文下的占用',
'What stays free': '剩余可用',
'Models that fit this Mac': '此 Mac 装得下的模型',
Speed: '速度',
Download: '下载',
'speed not known': '速度未知',
'{speed} tokens/s': '{speed} Token/秒',
'about {speed} tokens/s': '约 {speed} Token/秒',
'Runs with': '搭配',
'Not on this Mac yet. It is installed for you.': '此 Mac 上尚未安装,将为你一并安装。',
'The first answer can take minutes.': '第一次回答可能需要几分钟。',
'No account and no API key. The model runs only on this Mac.': '无需账号,也无需 API 密钥。模型只在此 Mac 上运行。',
'Install on this Mac': '安装到此 Mac',
// The confirm screen: what is downloaded, where it goes.
'Runs with {cli}, at {context} tokens of context.': '搭配 {cli},上下文为 {context} 个 Token。',
'What is downloaded': '将下载的内容',
'Ollama {version}, the program that runs the model': 'Ollama {version},用于运行模型的程序',
Total: '合计',
'{cli} is not on this Mac yet. Its own installer is run with this command:':
'此 Mac 上尚未安装 {cli}。将用以下命令运行它自带的安装程序:',
'{cli} is not on this Mac yet. It is installed with its own installer.':
'此 Mac 上尚未安装 {cli}。将用它自带的安装程序进行安装。',
'The model and the program that runs it go into:': '模型和运行它的程序将存放在:',
'No administrator password.': '无需管理员密码。',
'Download and install': '下载并安装',
// Progress, and the same picture paused.
'{done} of {total} GB': '已下载 {done} GB,共 {total} GB',
'{speed} MB per second, {left}': '每秒 {speed} MB,{left}',
'{speed} MB per second': '每秒 {speed} MB',
'less than a minute left': '剩余不到 1 分钟',
'about 1 minute left': '剩余约 1 分钟',
'about {minutes} minutes left': '剩余约 {minutes} 分钟',
'about 1 hour left': '剩余约 1 小时',
'about {hours} hours left': '剩余约 {hours} 小时',
'Getting the runtime': '获取运行时',
'Downloading the model': '下载模型',
'Loading and checking': '加载并检查',
'Installing {cli}': '安装 {cli}',
Connecting: '正在连接',
'You can close this window. The download continues while Codeman is running.':
'你可以关闭此窗口。只要 {name} 在运行,下载就会继续。',
'Nothing is downloaded until you continue.': '在你继续之前不会下载任何内容。',
Continue: '继续',
'Choose another model': '选择其他模型',
// After an error: the one action that helps.
'Use another port': '改用其他端口',
'Try {model}': '试试 {model}',
Start: '启动',
'The server did not accept that.': '服务器未接受该请求。',
// Installed, and the Remove question.
'{model} is ready on this Mac.': '{model} 已在此 Mac 上准备就绪。',
'{model} is installed on this Mac.': '{model} 已安装在此 Mac 上。',
'Runs with {cli}.': '搭配 {cli} 运行。',
'Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.':
'上下文 {context} 个 Token。加载后占用 {memory} GB 内存,全部位于 GPU 上。',
'Context {context} tokens. Takes {memory} GB of memory when loaded.':
'上下文 {context} 个 Token。加载后占用 {memory} GB 内存。',
'Context {context} tokens.': '上下文 {context} 个 Token。',
'Start a session': '开始会话',
'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?',
'Also delete the downloaded model': '同时删除已下载的模型',
'The coding agent stays installed.': '编程智能体会保留,不会被卸载。',
// The sentences and labels the SERVER picks (LOCAL_MODEL_LABELS in src/types/local-model.ts), keyed
// by their exact English text: changing a text there means changing its key here.
'Best fit for this Mac': '最适合此 Mac 的选择',
@@ -1471,6 +1528,49 @@
'此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。',
'No coding agent that Codeman can connect to a local model is available on this Mac.':
'此 Mac 上没有 {name} 可以连接到本地模型的编程智能体。',
// The tag of a model row (LOCAL_MODEL_TAG_LABELS in src/types/local-model.ts). Tight and Slow are
// the entries above.
'Tested on a Mac like this': '已在同类 Mac 上测试',
'Best fit': '最适合',
'Small model': '小型模型',
// The sentences of the install (LOCAL_MODEL_INSTALL_LABELS in src/types/local-model-install.ts):
// why a job stopped, why a request was refused, and the one sentence about the developer tools.
'The download of the runtime stopped and could not be continued. Check the network connection, then try again.':
'运行时的下载已中断,且无法继续。请检查网络连接,然后重试。',
'The runtime download was damaged and has been deleted. Try again.': '下载的运行时已损坏,已被删除。请重试。',
'The runtime could not be unpacked on this Mac.': '无法在此 Mac 上解压运行时。',
'The runtime did not start on this Mac.': '运行时未能在此 Mac 上启动。',
'The runtime keeps stopping, so Codeman no longer restarts it.': '运行时反复停止,因此 {name} 不再重新启动它。',
'The model on this Mac is not running. Start it first.': '此 Mac 上的模型没有在运行。请先启动它。',
'Port {port} is in use by another program.': '端口 {port} 已被其他程序占用。',
'The disk is full. Free up space, then try again.': '磁盘已满。请腾出空间,然后重试。',
'The download keeps stopping. The network may be damaging data. Try again or use another network.':
'下载反复中断。网络可能正在损坏数据。请重试,或换一个网络。',
'{model} is no longer offered under this name. Update Codeman or pick another model.':
'{model} 已不再以此名称提供。请更新 {name},或选择其他模型。',
'{model} does not fit in the memory of this Mac after all.': '{model} 终究装不进此 Mac 的内存。',
'{model} loaded with a context of {actual} tokens instead of {wanted}.':
'{model} 加载时的上下文为 {actual} 个 Token,而不是 {wanted} 个。',
'{model} did not load on this Mac.': '{model} 未能在此 Mac 上加载。',
'{model} loaded but did not call a tool, so a coding agent cannot work with it.':
'{model} 已加载,但没有调用工具,因此编程智能体无法使用它。',
'The check of {model} did not finish.': '对 {model} 的检查没有完成。',
'{cli} could not be installed.': '无法安装 {cli}。',
'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.':
'已存在名为 {id} 的已保存模型连接,而它不是在这里创建的。请在“应用设置”中重命名或删除它,然后重试。',
'Codeman could not save the connection to the model.': '{name} 无法保存与模型的连接。',
'The model on this Mac changed since it was installed. Remove it and install it again.':
'此 Mac 上的模型在安装后发生了变化。请移除后重新安装。',
'The install stopped on an unexpected error.': '安装因意外错误而中止。',
'An install is already running.': '已有一个安装正在进行。',
'A model is already installed on this Mac. Remove it first.': '此 Mac 上已安装了一个模型。请先移除它。',
'The download is {size} GB now. Check the sizes and confirm again.':
'现在的下载大小为 {size} GB。请核对大小后再次确认。',
'Sessions are still using this model: {sessions}. Close them first.':
'仍有会话在使用此模型:{sessions}。请先关闭它们。',
'That is not possible in the current state.': '当前状态下无法执行此操作。',
'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.':
'编程智能体会用到 git 和 Python。此 Mac 尚未安装开发者工具,因此在智能体首次需要时,macOS 会提示安装。',
});
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
+41 -16
View File
@@ -511,6 +511,17 @@
contents on every state change and owns its inline display. -->
<div class="welcome-actions">
<div class="welcome-cli-actions" id="welcomeCliActions"></div>
<!-- A model that runs on the Mac the server runs on (local-model-ui.js).
One button in the chip family, a sibling of the launcher slot and
never inside it (renderWelcomeCliActions empties that slot on every
state change). Ships `hidden`: only that module reveals it, once the
server has answered for a Mac. It opens the chooser window
(#localModelModal) and the module writes its label through its own
translator call, hence data-i18n-skip. -->
<button type="button" class="welcome-chip welcome-local-model" id="welcomeLocalModelBtn" data-i18n-skip hidden onclick="app.openLocalModelWindow()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9.5 2.5v3.5M14.5 2.5v3.5M9.5 18v3.5M14.5 18v3.5M2.5 9.5H6M2.5 14.5H6M18 9.5h3.5M18 14.5h3.5"/></svg>
<span class="welcome-label" id="welcomeLocalModelLabel">Run a local model</span>
</button>
<button type="button" class="welcome-tunnel-link" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel
@@ -583,14 +594,6 @@
</div>
<div class="history-list" id="historyList"></div>
</div>
<!-- What the Mac the server runs on can run as a local coding model
(local-model-ui.js). Read-only. Ships `hidden` and only that module
reveals it, once the server has answered for a Mac: a server on
another system never shows it, and the user can hide it per device.
The module writes every string through its own translator call
(model and chip names are not ours to translate), hence
data-i18n-skip. -->
<section class="local-model-card" id="localModelCard" aria-labelledby="localModelCardTitle" data-i18n-skip hidden></section>
<!-- Sessions a human started outside Codeman (foreign-sessions.js).
Hidden until a scan finds one; never rendered empty on a fresh
install, where an empty block reads as a broken feature. -->
@@ -2876,20 +2879,24 @@
</div>
</div>
<!-- What the Mac the server runs on can run as a local model (local-model-ui.js).
Read-only: the module fills the row from GET /api/local-model, the same body
the welcome card shows, through its own translator call (hence data-i18n-skip
on the row's text). The module shows the group only once the server has
<!-- The model that runs on the Mac the server runs on (local-model-ui.js). One row:
the state in a few words, written by the module from GET /api/local-model
through its own translator call (hence data-i18n-skip on the text and on the
button), and a button that opens the chooser window (#localModelModal), where
everything happens. The module shows the group only once the server has
answered for a Mac: it hides it for a server on another system (the title
and hint are about "this Mac"), while the first request is in flight, and
for a non-admin in multi-user mode (the route is admin-only). Not gated on
the endpoints toggle above: the answer does not depend on it. -->
for a non-admin in multi-user mode (the routes are admin-only). Not gated on
the endpoints toggle above: an install switches that on itself. -->
<div class="set-group" id="localModelGroup">
<div class="set-group-head"><h4>Model on this Mac</h4></div>
<p class="set-group-hint">What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.</p>
<p class="set-group-hint">Run a coding model on the Mac that Codeman runs on. No account and no API key.</p>
<div class="set-group-body">
<div class="set-row" data-search="local model mac apple silicon memory llm recommendation offline">
<div class="set-row" data-search="local model mac apple silicon memory llm offline install download ollama">
<div class="set-row-text" id="localModelSettings" data-i18n-skip aria-live="polite"></div>
<div class="set-row-actions">
<button type="button" class="btn-toolbar btn-sm" id="localModelSettingsOpen" data-i18n-skip onclick="app.openLocalModelWindow()">Open</button>
</div>
</div>
</div>
</div>
@@ -3671,6 +3678,24 @@
</div>
</div>
<!-- The local model window (local-model-ui.js): choose a model, confirm the sizes,
watch the download, start a session. Opened by the welcome button and by the
row in App Settings > Models; placed AFTER #appSettingsModal so it stacks above
it when opened from there. The module fills the body from GET /api/local-model
through its own translator call (model, agent and chip names are someone
else's text), hence data-i18n-skip on the body; the title and the close button
are static and go through the page translator. -->
<div class="modal" id="localModelModal" role="dialog" aria-modal="true" aria-labelledby="localModelModalTitle">
<div class="modal-backdrop" onclick="app.closeLocalModelWindow()"></div>
<div class="modal-content local-model-window">
<div class="modal-header">
<h3 id="localModelModalTitle">Run a model on this Mac</h3>
<button class="modal-close" onclick="app.closeLocalModelWindow()" aria-label="Close">&times;</button>
</div>
<div class="modal-body lm-body" id="localModelWindowBody" data-i18n-skip></div>
</div>
</div>
<!-- Shortcut Overlay Modal -->
<div class="modal shortcut-overlay-modal" id="shortcutOverlayModal" tabindex="-1">
<div class="modal-backdrop" onclick="app.closeShortcutOverlay()"></div>
File diff suppressed because it is too large. Load diff
+92 -17
View File
@@ -1857,15 +1857,27 @@ html.mobile-init .file-browser-panel {
margin-top: 0.75rem;
}
/* Model on this Mac (local-model-ui.js). Only reached with the phone overview
switched off. The card has no fixed width and every line in it wraps, so
the narrow column only takes some of the spacing back. */
.local-model-card {
margin-top: 0.6rem;
/* Run a model on this Mac (local-model-ui.js). The welcome button is only
reached with the phone overview switched off; it takes the chips' finger
size from `.welcome-chip` above. The window is full-screen like every
modal here: its own width rule in styles.css is more specific than the
full-screen `.modal-content` rule above, so the size is restated. */
.modal-content.local-model-window {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
}
.local-model-box {
padding: 0.5rem 0.55rem;
.local-model-window > .lm-body {
flex: 1;
padding: 1rem 1rem calc(1rem + var(--safe-area-bottom));
}
/* The window fills the screen, so its actions sit at the bottom, under the thumb. */
.lm-body > .lm-foot,
.lm-body > .lm-remove {
margin-top: auto;
}
/* Ralph wizard responsive */
@@ -3980,16 +3992,6 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
max-width: none;
}
/* Model on this Mac (local-model-ui.js): its row follows the label size above. */
#localModelSettings .local-model-headline,
#localModelSettings .local-model-name {
font-size: 0.84rem;
}
#localModelSettings .local-model-alt .local-model-name {
font-size: 0.78rem;
}
/* Fields go full width under their label instead of fighting for the row */
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row.has-field {
flex-direction: column;
@@ -4282,3 +4284,76 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
touch-action: pan-x pan-y;
}
}
/* Run a model on this Mac (local-model-ui.js), under 560px: each model row
becomes two lines (name and tag, then speed and download size), the bar
stays full width and the buttons go full width under the foot's sentence. */
@media (max-width: 559px) {
.lm-list {
grid-template-columns: auto minmax(0, 1fr) auto;
column-gap: 0.7rem;
}
.lm-caption-speed,
.lm-caption-size {
display: none;
}
.lm-model {
row-gap: 0.1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.lm-radio {
grid-row: 1;
grid-column: 1;
}
.lm-model-name {
grid-row: 1;
grid-column: 2;
}
.lm-model-tag {
grid-row: 1;
grid-column: 3;
justify-content: flex-end;
}
.lm-model-speed {
grid-row: 2;
grid-column: 2;
text-align: start;
}
.lm-model-size {
grid-row: 2;
grid-column: 3;
}
.lm-model-static .lm-model-name {
grid-column: 1 / span 2;
}
.lm-model-static .lm-model-speed {
grid-column: 1 / span 2;
}
.lm-foot {
flex-direction: column;
align-items: stretch;
gap: 0.8rem;
}
/* The main action on top, the way back under it. */
.lm-buttons {
flex-direction: column-reverse;
margin-inline-start: 0;
}
.local-model-window .wt-btn.lm-btn {
min-height: 44px;
font-size: 0.88rem;
}
}
+3 -2
View File
@@ -550,8 +550,9 @@ Object.assign(CodemanApp.prototype, {
// Assigning .checked above does not fire onchange, so the body's visibility
// (and its lazy load) needs an explicit sync on every open, not just a save.
this.applyCustomModelEndpointsVisibility();
// Model on this Mac (local-model-ui.js): a read-only row, not a setting. Server state,
// asked for at most once a minute; nothing here is loaded or saved by id.
// Model on this Mac (local-model-ui.js): a row that says where the local model stands and
// opens its window, not a setting. Server state, asked for at most once a minute; nothing
// here is loaded or saved by id.
this.loadLocalModel?.();
// CLI management (docs/cli-enable-disable-plan.md): synced, default OFF.
document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true;
+632 -137
View File
@@ -22304,192 +22304,687 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
}
/* ═══════════════════════════════════════════════════════════════
Model on this Mac (local-model-ui.js): what the server's Mac can run as
a local coding model. One body (`.local-model-body`), drawn in two places:
the welcome card and a row of App Settings > Models.
Run a model on this Mac (local-model-ui.js): the welcome button and the
chooser window (#localModelModal). The App Settings row is ordinary
`.set-row` markup and needs nothing here.
Same vocabulary as the two blocks above it on the welcome screen: a dim
heading row, then one bordered surface with a name over dim meta lines.
Theme tokens only (`--control-bg`, never a white alpha: that fill is
invisible on the light skins), and the fit and speed labels are neutral
pills: the words carry the meaning, so no skin needs a colour of its own.
Nothing here is keyed on a CLI or a model id, and nothing animates.
The subject is this Mac's memory, so the window has ONE strong element:
the bar (`.lm-bar`), the memory this Mac can give a model, filled to what
the selected model needs. Everything around it is quiet: hierarchy by size
and weight, no box inside the window, no border around a row. The selected
row carries the accent on its reading edge and a faint fill.
Theme tokens only, never a black or white alpha (those vanish on the light
skins). The bar's track is `--control-border-hover`: the fill of the app's
inputs (`--bg-input`) is the window's own surface on the dark skins, so the
part of the bar that stays free could not be seen. Nothing here is keyed on
a CLI or a model id. The only motion is the bar's width, and it stops under
prefers-reduced-motion.
═══════════════════════════════════════════════════════════════ */
.local-model-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
width: 100%;
margin-top: 0.75rem;
text-align: left;
/* The welcome button: one more chip, on its own line under the launchers. */
.welcome-local-model {
margin-top: 1.1rem;
}
/* A flex container, so `[hidden]` needs re-asserting or the module's only
visibility lever does nothing (same trap as .foreign-sessions). */
.local-model-card[hidden] {
/* `.welcome-chip` is inline-flex, so `[hidden]` needs re-asserting or the
module's only visibility lever does nothing. */
.welcome-local-model[hidden] {
display: none;
}
.local-model-head {
.welcome-local-model svg {
flex-shrink: 0;
}
/* The window: App Settings' chrome (`.modal`, `.modal-header`, `.modal-close`)
at a width of its own, the body scrolling under a fixed header. */
.modal-content.local-model-window {
display: flex;
align-items: center;
flex-direction: column;
width: min(640px, 94vw);
max-width: min(640px, 94vw);
max-height: 88vh;
}
.local-model-window > .modal-header {
flex-shrink: 0;
}
.local-model-window > .lm-body {
display: flex;
flex-direction: column;
gap: 1.05rem;
min-height: 0;
padding: 1.15rem 1.25rem 1.2rem;
overflow-y: auto;
text-align: start;
}
.lm-body p,
.lm-body h4 {
margin: 0;
}
/* ── type: four sizes, nothing in capitals ─────────────────────────── */
.lm-hw {
font-size: 0.78rem;
line-height: 1.45;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.lm-title {
font-size: 1rem;
font-weight: 650;
line-height: 1.4;
letter-spacing: -0.01em;
color: var(--text);
overflow-wrap: anywhere;
}
.lm-lead {
font-size: 0.84rem;
line-height: 1.5;
color: var(--text);
overflow-wrap: anywhere;
}
/* Why no model fits: one sentence, read as a sentence and not as a headline. */
.lm-reason {
font-size: 0.92rem;
font-weight: 500;
line-height: 1.55;
color: var(--text);
overflow-wrap: anywhere;
}
.lm-notes {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.lm-aside {
font-size: 0.74rem;
line-height: 1.5;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.lm-heading {
font-size: 0.74rem;
font-weight: 600;
color: var(--text-dim);
}
/* ── the bar ───────────────────────────────────────────────────────── */
.lm-meter {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.local-model-title {
margin: 0;
font-size: 0.85rem;
color: var(--text-dim);
font-weight: 500;
}
.local-model-hide {
margin-left: auto;
font: inherit;
font-size: 0.68rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.12rem 0.5rem;
cursor: pointer;
}
.local-model-hide:hover {
color: var(--text);
background: var(--control-bg-hover);
}
.local-model-box {
padding: 0.55rem 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--control-bg);
min-width: 0;
}
.local-model-body {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
/* The line that says nothing can be set up from here yet (.local-model-next) is
the one a reader must not miss, so it reads like every other line: same size,
same --text-dim. --text-muted on this surface was 3.9:1. */
.local-model-hw,
.local-model-line,
.local-model-note,
.local-model-next {
font-size: 0.68rem;
line-height: 1.45;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.local-model-headline {
font-size: 0.82rem;
line-height: 1.45;
color: var(--text);
overflow-wrap: anywhere;
}
/* The pick is the one thing in the card set apart: a rule in the accent, on the
reading side (logical, so it follows a right-to-left page). */
.local-model-pick {
display: flex;
flex-direction: column;
gap: 0.12rem;
padding-inline-start: 0.55rem;
border-inline-start: 2px solid var(--accent);
}
.local-model-name-row {
.lm-meter-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2rem 0.4rem;
min-width: 0;
align-items: baseline;
gap: 0.15rem 0.6rem;
}
.local-model-name {
font-size: 0.82rem;
.lm-figure {
font-size: 1.06rem;
font-weight: 680;
letter-spacing: -0.012em;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.lm-figure-aside {
font-size: 0.74rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.lm-bar {
height: 10px;
border-radius: 999px;
background: var(--control-border-hover);
overflow: hidden;
}
.lm-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--accent);
transition: width 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.lm-legend {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.68rem;
line-height: 1.4;
color: var(--text-muted);
}
.lm-legend > :last-child {
text-align: end;
}
/* ── the models: one grid, so the numbers line up down the list ──── */
/* Radio, name, tag, speed, download. The tag sits next to the name and takes
the room that is left, which pushes the two numbers to the right edge. */
.lm-list {
display: grid;
grid-template-columns: auto minmax(0, max-content) minmax(0, 1fr) auto auto;
column-gap: 0.9rem;
row-gap: 0.1rem;
/* A row's fill reaches into the window's padding, so what is IN a row lines
up with every other line of the window on both edges. */
margin-inline: calc(-0.7rem - 2px) -0.7rem;
}
.lm-caption,
.lm-models,
.lm-model {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.lm-models {
row-gap: 0.1rem;
}
.lm-caption,
.lm-model {
align-items: center;
padding: 0 0.7rem;
border-inline-start: 2px solid transparent;
}
.lm-caption {
padding-bottom: 0.3rem;
font-size: 0.68rem;
color: var(--text-muted);
}
.lm-caption-label {
grid-column: 1 / span 3;
}
/* The server's word for a pick it has run on a Mac like this one stands where
the list's label does, in the text colour. */
.lm-caption-strong {
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
}
.lm-caption-speed,
.lm-caption-size {
text-align: end;
}
.lm-model {
min-height: 2.35rem;
border-radius: 7px;
cursor: pointer;
transition: background var(--transition-smooth);
}
.lm-model:hover {
background: var(--control-bg);
}
.lm-model:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* Selected: the accent on the reading edge and a faint fill. Nothing else. */
.lm-model-on,
.lm-model-on:hover {
border-inline-start-color: var(--accent);
background: rgba(var(--accent-rgb), 0.075);
}
/* The chosen row of the confirm screen: the same line, nothing to press. */
.lm-model-static {
cursor: default;
}
.lm-model-static .lm-model-name {
grid-column: 1 / span 2;
}
.lm-radio {
box-sizing: border-box;
width: 14px;
height: 14px;
border: 1.5px solid var(--text-muted);
border-radius: 50%;
}
.lm-model-on .lm-radio {
border-color: var(--accent);
background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px);
}
.lm-model-name {
min-width: 0;
font-size: 0.86rem;
font-weight: 600;
color: var(--text);
overflow-wrap: anywhere;
min-width: 0;
}
/* Each pill carries what it measures as its tooltip (a title), hence the cursor. */
.local-model-pill {
flex: 0 0 auto;
font-size: 0.62rem;
.lm-model-tag {
display: flex;
justify-content: flex-start;
}
/* A tag is text with a hairline, not a coloured pill. The accent is for the
pick (and for a row that was run on a Mac like this one). */
.lm-tag {
padding: 0.05rem 0.42rem;
border: 1px solid var(--border-light);
border-radius: 5px;
font-size: 0.66rem;
line-height: 1.5;
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0 0.42rem;
white-space: nowrap;
cursor: help;
}
.local-model-alts-title {
font-size: 0.72rem;
.lm-tag-accent {
border-color: rgba(var(--accent-rgb), 0.5);
color: var(--accent);
}
.lm-model-speed,
.lm-model-size {
text-align: end;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.lm-model-speed {
font-size: 0.76rem;
color: var(--text-dim);
}
/* The welcome card folds the list behind a native disclosure (keyboard and
screen-reader behaviour for free); the summary keeps `display: list-item`
so its marker stays. */
.local-model-alts > summary {
cursor: pointer;
}
.local-model-alts > summary:hover {
.lm-model-size {
font-size: 0.8rem;
font-weight: 560;
color: var(--text);
}
.local-model-alt {
/* A browser without subgrid: fixed tracks keep the columns in line. */
@supports not (grid-template-columns: subgrid) {
.lm-list,
.lm-models {
display: block;
}
.lm-caption,
.lm-model {
grid-template-columns: 14px 10.5rem minmax(0, 1fr) 8.6rem 4.4rem;
column-gap: 0.9rem;
}
.lm-model {
margin-top: 0.1rem;
}
}
/* ── the coding agent ─────────────────────────────────────────────── */
.lm-runs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.6rem;
font-size: 0.78rem;
color: var(--text-dim);
}
.lm-runs-label {
color: var(--text);
}
.lm-select {
max-width: 100%;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-input);
color: var(--text);
font: inherit;
font-size: 0.78rem;
outline: none;
}
.lm-select:focus-visible {
border-color: var(--accent);
}
.lm-runs-hint {
min-width: 0;
font-size: 0.74rem;
}
/* ── the confirm screen ───────────────────────────────────────────── */
.lm-sizes {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-top: 0.3rem;
padding-top: 0.3rem;
}
.lm-sizes .lm-heading {
margin-bottom: 0.3rem;
}
.lm-size-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.28rem 0;
font-size: 0.8rem;
color: var(--text-dim);
}
.lm-size-what {
min-width: 0;
overflow-wrap: anywhere;
}
.lm-size-gb {
flex-shrink: 0;
color: var(--text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.lm-size-total {
margin-top: 0.25rem;
padding-top: 0.55rem;
border-top: 1px solid var(--border);
font-weight: 650;
color: var(--text);
}
.lm-agent,
.lm-where {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
/* A literal command and a folder: text to read and to copy, so it is set in
the mono face and selects whole. */
.lm-command {
display: block;
padding: 0.42rem 0.6rem;
border-radius: 6px;
background: var(--bg-input);
color: var(--text);
font-family: var(--font-mono);
font-size: 0.72rem;
line-height: 1.5;
overflow-wrap: anywhere;
user-select: all;
}
/* ── progress: the steps ARE a sequence, so they are a list ───────── */
.lm-steps {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.lm-step {
display: flex;
align-items: center;
gap: 0.65rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.lm-step-mark {
position: relative;
box-sizing: border-box;
flex-shrink: 0;
width: 14px;
height: 14px;
border: 1.5px solid var(--border-light);
border-radius: 50%;
}
.lm-step-done {
color: var(--text-dim);
}
.lm-step-done .lm-step-mark {
border-color: var(--accent);
background: var(--accent);
}
/* The check of a finished step, drawn from two borders in the page colour. */
.lm-step-done .lm-step-mark::after {
content: '';
position: absolute;
left: 3.5px;
top: 1px;
width: 3px;
height: 6px;
border: solid var(--accent-ink);
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg);
}
.lm-step-now,
.lm-step-stopped {
font-weight: 600;
color: var(--text);
}
.lm-step-now .lm-step-mark {
border-color: var(--accent);
background: radial-gradient(circle, var(--accent) 0 3px, transparent 3.5px);
}
.lm-step-stopped .lm-step-mark {
border-color: var(--text-dim);
background: radial-gradient(circle, var(--text-dim) 0 3px, transparent 3.5px);
}
/* ── installed ────────────────────────────────────────────────────── */
.lm-state {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.5rem;
font-size: 0.8rem;
color: var(--text-dim);
}
.lm-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.lm-dot-on {
background: var(--green);
}
.lm-state-text {
font-weight: 600;
color: var(--text);
}
/* ── a refused verb, an error's raw detail, the Remove question ──── */
.lm-notice {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding-inline-start: 0.7rem;
border-inline-start: 2px solid var(--yellow);
}
.lm-notice-text {
font-size: 0.8rem;
line-height: 1.5;
color: var(--text);
overflow-wrap: anywhere;
}
.lm-notice-detail {
font-size: 0.72rem;
line-height: 1.45;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.lm-details {
margin-top: 0.55rem;
font-size: 0.74rem;
color: var(--text-dim);
}
.lm-details > summary {
width: fit-content;
cursor: pointer;
}
.lm-details > summary:hover {
color: var(--text);
}
.lm-detail-text {
max-height: 9rem;
margin: 0.45rem 0 0;
padding: 0.5rem 0.6rem;
border-radius: 6px;
background: var(--bg-input);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.68rem;
line-height: 1.5;
white-space: pre-wrap;
overflow: auto;
overflow-wrap: anywhere;
}
.lm-remove {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding-top: 0.95rem;
border-top: 1px solid var(--border);
}
.local-model-alt .local-model-name {
font-size: 0.76rem;
font-weight: 500;
.lm-check {
display: flex;
align-items: center;
gap: 0.5rem;
width: fit-content;
font-size: 0.8rem;
color: var(--text);
cursor: pointer;
}
/* In App Settings the body sits inside an ordinary .set-row, so it takes that
surface's type scale (label 0.76rem over a muted description) and the row
supplies the border. */
#localModelSettings {
padding: 3px 0;
.lm-check input {
margin: 0;
accent-color: var(--accent);
}
#localModelSettings .local-model-headline,
#localModelSettings .local-model-name {
font-size: 0.76rem;
/* ── the foot: one promise on the left, the action on the right ──── */
.lm-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 0.95rem;
border-top: 1px solid var(--border);
}
#localModelSettings .local-model-alt .local-model-name {
.lm-remove .lm-foot {
padding-top: 0.2rem;
border-top: 0;
}
.lm-foot-note {
min-width: 0;
font-size: 0.72rem;
line-height: 1.45;
color: var(--text-muted);
}
/* Touch: the two things in the card that take a press get a finger's height. */
.lm-buttons {
display: flex;
flex-shrink: 0;
gap: 0.5rem;
margin-inline-start: auto;
}
/* The buttons are the Git worktrees dialog's set (`.wt-btn`): a main action
that reads as one on every skin (the skin block greys
`.btn-toolbar.btn-primary`) and that the phone toolbar's !important sizing
does not reach. Only the size is the window's own. */
.local-model-window .wt-btn.lm-btn {
padding: 0.5rem 0.95rem;
font-weight: 600;
white-space: nowrap;
}
.local-model-window .wt-btn-primary.lm-btn {
font-weight: 700;
}
/* OG has no designed --accent-ink: the main action takes the deeper blue and
light text of that skin's own Run button, as `.welcome-primary` does. Keep
the two in step. */
html[data-skin='og'] .local-model-window .wt-btn-primary.lm-btn {
border-color: rgba(96, 165, 250, 0.5);
background: linear-gradient(135deg, #1e4fb8 0%, #2563eb 100%);
color: #fff;
}
html[data-skin='og'] .local-model-window .wt-btn-primary.lm-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #2454a8 0%, #2d6cf0 100%);
}
.local-model-window .wt-btn.lm-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.lm-bar-fill,
.lm-model {
transition: none;
}
}
/* Touch: rows and the select get a finger's height. */
@media (pointer: coarse) {
.local-model-hide {
min-height: 32px;
padding: 0.3rem 0.8rem;
.lm-model {
min-height: 2.9rem;
}
.local-model-alts > summary {
padding: 0.4rem 0;
.lm-select {
min-height: 36px;
}
.local-model-window .wt-btn.lm-btn {
min-height: 40px;
}
}
+3 -2
View File
@@ -2964,8 +2964,9 @@ Object.assign(CodemanApp.prototype, {
// up (stopped in hideWelcome), see foreign-sessions.js.
this.wireForeignSessions?.();
this.startForeignPolling?.();
// What this Mac can run as a local model (local-model-ui.js). One request,
// at most once a minute, no timer: nothing to stop in hideWelcome.
// The "Run a local model" button (local-model-ui.js). One request, at most
// once a minute, no timer here: only its open window polls, and closing
// that window stops it, so there is nothing to stop in hideWelcome.
this.loadLocalModel?.();
}
// Home screen has no input target — hide the CJK textarea (activeSessionId