From 40b17a68f8a90bffa8649800fe534063c0ed1462 Mon Sep 17 00:00:00 2001 From: arelchan <204152633+arelchan@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:59:14 +0800 Subject: [PATCH 1/2] fix(ui-web): say a failed connect or test under the agent, not instead of it The agent connector used to replace the line about an agent with a red sentence when a connect was refused or a test failed, and grew the card and the sheet to fit whatever it had to say. Now every card is one fixed shape: the name, two lines of room for what the agent does, and a slot that always speaks. With nothing wrong the slot says which state the row is in (connected, available, not installed, and when a passed test passed); a refused write or a failed test fills it with a single-line strip in clay, one word for what failed and a short reason read from the server's remedy kind; a write in flight fills it in amber. The press that answers a strip is the card's corner control, where connect and install already live, so the strip itself carries no button. The sheet keeps its 520x500 box and folds the server's sentence and its fix into a note that scrolls inside the body. Before the first roster arrives the grid draws one placeholder card per catalogue entry, since the catalogue is the roster on every machine, with the same shimmer the settings sections wait with. Agent marks now sit on a light plate in both themes, the way the app icon does, and the dark theme filters no mark: inverting hermesagent.svg gave its negative and the hue rotation that put qoder's green back landed on a different green. The plate is a token that does not follow the theme, and the embedded color-scheme is pinned light on the tile so raven.svg and miromind.svg draw their light selves on it under either theme. The general settings' language hint now says it picks the interface's language rather than Raven's. Co-authored-by: Claude (claude-fable-5-1) --- i18n/messages.json | 160 +++++++--- ui-tui/src/i18n/messages.generated.ts | 95 ++++-- ui-web/scripts/gates/agent-mark-css.test.mjs | 121 +++----- .../scripts/gates/wait-shimmer-css.test.mjs | 2 + ui-web/src/components/AgentMark.tsx | 35 ++- .../features/extAgents/AgentsBody.test.tsx | 63 ++-- .../features/extAgents/ExtAgentsPage.test.tsx | 272 +++++++++++------ .../src/features/extAgents/ExtAgentsPage.tsx | 153 ++++++---- ui-web/src/features/extAgents/Rows.tsx | 279 ++++++++++++++---- .../__snapshots__/ExtAgentsPage.test.tsx.snap | 18 +- ui-web/src/features/extAgents/catalogue.ts | 5 + ui-web/src/features/extAgents/health.ts | 33 ++- ui-web/src/features/extAgents/styles.css | 119 ++++++-- ui-web/src/styles/page.css | 53 ++-- 14 files changed, 960 insertions(+), 448 deletions(-) diff --git a/i18n/messages.json b/i18n/messages.json index 4a5ddbd14..c24d77abc 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -5147,10 +5147,6 @@ "en": "Not installed · {by}", "zh": "本机未安装 · {by}" }, - "gui.agent.hd_test_bad": { - "en": "Test failed: {detail}", - "zh": "测试没通过:{detail}" - }, "gui.agent.fix_sign_in": { "en": "Sign in to {agent} first. Run this command in a terminal and sign in when it opens your browser, then press {button}:", "zh": "需要先登录 {agent}。在终端运行下面这条命令,按提示在浏览器里登录,完成后点「{button}」:" @@ -5267,18 +5263,6 @@ "en": "{agent} cannot read its own config file. Fix it, then press {button}.", "zh": "{agent} 的配置文件有错,它读不出来。改好后点「{button}」。" }, - "gui.agent.fix_unknown_connect": { - "en": "{agent} could not connect. The original error below says why; once it is fixed, press {button}.", - "zh": "{agent} 没接入成功。可以展开下面的原始报错排查原因,处理好后点「{button}」。" - }, - "gui.agent.fix_unknown_save": { - "en": "The change was not saved. The original error below says why; press {button} to try again.", - "zh": "这项修改没保存成功。可以展开下面的原始报错查看原因,或点「{button}」再试一次。" - }, - "gui.agent.fix_unknown_test": { - "en": "the original error below says why; once it is fixed, press {button} again.", - "zh": "可以展开下面的原始报错排查原因,处理好后再点「{button}」。" - }, "gui.agent.needs_node": { "en": "{agent} runs through npx, which comes with Node.js, and npx was not found on this machine. Install Node.js, then press {button}.", "zh": "{agent} 要通过 Node.js 自带的 npx 运行,这台机器上没找到 npx。先安装 Node.js,装好后点「{button}」。" @@ -5351,37 +5335,129 @@ "en": "{agent} could not be disconnected", "zh": "{agent} 没能断开" }, - "gui.agent.bad_download_retry": { - "en": "{what} -- check the network, the npm registry or the proxy, then press {button}", - "zh": "{what},检查网络、npm 源或代理后点「{button}」" + "gui.agent.st_connect_bad": { + "en": "Could not connect", + "zh": "接入失败" + }, + "gui.agent.st_save_bad": { + "en": "Not saved", + "zh": "没保存" + }, + "gui.agent.st_disconnect_bad": { + "en": "Not disconnected", + "zh": "断开失败" + }, + "gui.agent.st_test_bad": { + "en": "Test failed", + "zh": "测试没通过" + }, + "gui.agent.why_sign_in": { + "en": "needs sign-in", + "zh": "需要先登录" + }, + "gui.agent.why_setup": { + "en": "no provider yet", + "zh": "还没选模型服务商" + }, + "gui.agent.why_api_key": { + "en": "API key rejected", + "zh": "API key 不可用" + }, + "gui.agent.why_download": { + "en": "download failed", + "zh": "没能下载" + }, + "gui.agent.why_model": { + "en": "Model unavailable", + "zh": "模型不可用" + }, + "gui.agent.why_billing": { + "en": "Out of credit", + "zh": "余额不足" + }, + "gui.agent.why_quota": { + "en": "Rate-limited", + "zh": "被限流或额度用完" + }, + "gui.agent.why_network": { + "en": "Can't reach its provider", + "zh": "连不上服务商" + }, + "gui.agent.why_silent": { + "en": "No reply in time", + "zh": "没有及时回复" + }, + "gui.agent.why_upgrade": { + "en": "Version too old", + "zh": "版本太旧" + }, + "gui.agent.why_exited": { + "en": "Quit on start", + "zh": "一启动就退出" + }, + "gui.agent.why_runtime": { + "en": "Node.js too old", + "zh": "Node.js 版本太旧" + }, + "gui.agent.why_plan": { + "en": "Plan doesn't include it", + "zh": "套餐不含它" + }, + "gui.agent.why_config": { + "en": "Config file broken", + "zh": "配置文件有错" + }, + "gui.agent.why_open": { + "en": "see why", + "zh": "点开看原因" + }, + "gui.agent.why_hover": { + "en": "hover to see why", + "zh": "悬停看原始报错" + }, + "gui.agent.test_again": { + "en": "Test again", + "zh": "再测一次" + }, + "gui.agent.test_again_short": { + "en": "Retest", + "zh": "再测" + }, + "gui.agent.row_download": { + "en": "download failed; check the network, the npm registry or the proxy, then press {button}", + "zh": "没能下载,检查网络、npm 源或代理后点「{button}」" + }, + "gui.agent.row_run": { + "en": "{why}; run {command} in a terminal, then press {button}", + "zh": "{why},在终端运行 {command} 后点「{button}」" }, - "gui.agent.fix_unknown_disconnect": { - "en": "{agent} could not be disconnected. The original error below says why; press {button} to try again.", - "zh": "{agent} 没能断开。可以展开下面的原始报错查看原因,或点「{button}」再试一次。" + "gui.agent.row_run_then": { + "en": "{why}; run {command} in a terminal and type {then}, then press {button}", + "zh": "{why},在终端运行 {command} 并输入 {then},再点「{button}」" }, - "gui.agent.bad_open": { - "en": "{what} -- open it to see what to do", - "zh": "{what},点开看怎么做" + "gui.agent.row_run_diagnose": { + "en": "{why}; run {command} in a terminal to see why, then press {button}", + "zh": "{why},在终端运行 {command} 查看原因,处理好后点「{button}」" }, - "gui.agent.bad_run": { - "en": "{what}: run {command} in a terminal, then press {button}", - "zh": "{what}:在终端运行 {command},再点「{button}」" + "gui.agent.row_open": { + "en": "{why}; open {command} in a browser to get a plan, then press {button}", + "zh": "{why},在浏览器打开 {command} 开通套餐,再点「{button}」" }, - "gui.agent.bad_plan_link": { - "en": "{what}: open {command} in a browser to get a plan, then press {button}", - "zh": "{what}:在浏览器打开 {command} 开通套餐,再点「{button}」" + "gui.agent.said_connect": { + "en": "It did not answer the test message; its own words are below. Once that is fixed, press {button}.", + "zh": "它没有回应测试消息,原话如下。处理好后点「{button}」。" }, - "gui.agent.bad_run_then": { - "en": "{what}: run {command} in a terminal and type {then}, then press {button}", - "zh": "{what}:在终端运行 {command} 并输入 {then},再点「{button}」" + "gui.agent.said_test": { + "en": "Its own words are below. Once that is fixed, press {button}.", + "zh": "原话如下。处理好后点「{button}」。" }, - "gui.agent.bad_run_diagnose": { - "en": "{what}: run {command} in a terminal to see why, and press {button} once it is fixed", - "zh": "{what}:在终端运行 {command} 查看原因,处理好后点「{button}」" + "gui.agent.said_save": { + "en": "The server refused the change; its words are below.", + "zh": "服务端拒绝了这项修改,原话如下。" }, - "gui.agent.bad_retry": { - "en": "{what} -- once it is fixed, press {button}", - "zh": "{what},处理好后点「{button}」" + "gui.agent.said_disconnect": { + "en": "The config could not be written; the server's words are below. Once that is fixed, press {button}.", + "zh": "配置没写成功,原话如下。处理好后点「{button}」。" }, "gui.agent.short_raven": { "en": "General assistant, takes a bit of everything", @@ -6952,8 +7028,8 @@ "zh": "语言" }, "gui.settings.general.language_sub": { - "en": "Choose the language used in Raven.", - "zh": "选择 Raven 使用的语言。" + "en": "Choose the language the interface uses.", + "zh": "选择界面使用的语言。" }, "gui.settings.general.lang_zh": { "en": "中文", diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 1064d827e..c4d98ee01 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -1483,7 +1483,6 @@ export const UI_TEXT: Record> = { 'gui.agent.hd_on_test_bad': 'Connected · test failed', 'gui.agent.probe_raw': 'What the check found', 'gui.agent.hd_missing_by': 'Not installed · {by}', - 'gui.agent.hd_test_bad': 'Test failed: {detail}', 'gui.agent.fix_sign_in': 'Sign in to {agent} first. Run this command in a terminal and sign in when it opens your browser, then press {button}:', 'gui.agent.fix_sign_in_bare': 'Sign in to {agent} first, then press {button}.', @@ -1536,11 +1535,6 @@ export const UI_TEXT: Record> = { 'gui.agent.fix_config': '{agent} cannot read its own config file. Run this command in a terminal to see where it is wrong; once it is fixed, press {button}:', 'gui.agent.fix_config_bare': '{agent} cannot read its own config file. Fix it, then press {button}.', - 'gui.agent.fix_unknown_connect': - '{agent} could not connect. The original error below says why; once it is fixed, press {button}.', - 'gui.agent.fix_unknown_save': - 'The change was not saved. The original error below says why; press {button} to try again.', - 'gui.agent.fix_unknown_test': 'the original error below says why; once it is fixed, press {button} again.', 'gui.agent.needs_node': '{agent} runs through npx, which comes with Node.js, and npx was not found on this machine. Install Node.js, then press {button}.', 'gui.agent.bad_sign_in': 'Sign in to {agent} first', @@ -1560,15 +1554,39 @@ export const UI_TEXT: Record> = { 'gui.agent.bad_connect': '{agent} could not connect', 'gui.agent.bad_save': 'The change was not saved', 'gui.agent.bad_disconnect': '{agent} could not be disconnected', - 'gui.agent.bad_download_retry': '{what} -- check the network, the npm registry or the proxy, then press {button}', - 'gui.agent.fix_unknown_disconnect': - '{agent} could not be disconnected. The original error below says why; press {button} to try again.', - 'gui.agent.bad_open': '{what} -- open it to see what to do', - 'gui.agent.bad_run': '{what}: run {command} in a terminal, then press {button}', - 'gui.agent.bad_plan_link': '{what}: open {command} in a browser to get a plan, then press {button}', - 'gui.agent.bad_run_then': '{what}: run {command} in a terminal and type {then}, then press {button}', - 'gui.agent.bad_run_diagnose': '{what}: run {command} in a terminal to see why, and press {button} once it is fixed', - 'gui.agent.bad_retry': '{what} -- once it is fixed, press {button}', + 'gui.agent.st_connect_bad': 'Could not connect', + 'gui.agent.st_save_bad': 'Not saved', + 'gui.agent.st_disconnect_bad': 'Not disconnected', + 'gui.agent.st_test_bad': 'Test failed', + 'gui.agent.why_sign_in': 'needs sign-in', + 'gui.agent.why_setup': 'no provider yet', + 'gui.agent.why_api_key': 'API key rejected', + 'gui.agent.why_download': 'download failed', + 'gui.agent.why_model': 'Model unavailable', + 'gui.agent.why_billing': 'Out of credit', + 'gui.agent.why_quota': 'Rate-limited', + 'gui.agent.why_network': "Can't reach its provider", + 'gui.agent.why_silent': 'No reply in time', + 'gui.agent.why_upgrade': 'Version too old', + 'gui.agent.why_exited': 'Quit on start', + 'gui.agent.why_runtime': 'Node.js too old', + 'gui.agent.why_plan': "Plan doesn't include it", + 'gui.agent.why_config': 'Config file broken', + 'gui.agent.why_open': 'see why', + 'gui.agent.why_hover': 'hover to see why', + 'gui.agent.test_again': 'Test again', + 'gui.agent.test_again_short': 'Retest', + 'gui.agent.row_download': 'download failed; check the network, the npm registry or the proxy, then press {button}', + 'gui.agent.row_run': '{why}; run {command} in a terminal, then press {button}', + 'gui.agent.row_run_then': '{why}; run {command} in a terminal and type {then}, then press {button}', + 'gui.agent.row_run_diagnose': '{why}; run {command} in a terminal to see why, then press {button}', + 'gui.agent.row_open': '{why}; open {command} in a browser to get a plan, then press {button}', + 'gui.agent.said_connect': + 'It did not answer the test message; its own words are below. Once that is fixed, press {button}.', + 'gui.agent.said_test': 'Its own words are below. Once that is fixed, press {button}.', + 'gui.agent.said_save': 'The server refused the change; its words are below.', + 'gui.agent.said_disconnect': + "The config could not be written; the server's words are below. Once that is fixed, press {button}.", 'gui.agent.short_raven': 'General assistant, takes a bit of everything', 'gui.agent.short_raven_code': 'Writes code, runs it, fixes bugs', 'gui.agent.short_raven_design': 'Produces UI drafts, visual revisions and artwork', @@ -1974,7 +1992,7 @@ export const UI_TEXT: Record> = { 'gui.settings.nav.archive': 'Archive', 'gui.settings.nav.about': 'About', 'gui.settings.general.language': 'Language', - 'gui.settings.general.language_sub': 'Choose the language used in Raven.', + 'gui.settings.general.language_sub': 'Choose the language the interface uses.', 'gui.settings.general.lang_zh': '中文', 'gui.settings.general.lang_en': 'English', 'gui.settings.general.theme': 'Appearance', @@ -3987,7 +4005,6 @@ export const UI_TEXT: Record> = { 'gui.agent.hd_on_test_bad': '已接入 · 测试没通过', 'gui.agent.probe_raw': '检查结果', 'gui.agent.hd_missing_by': '本机未安装 · {by}', - 'gui.agent.hd_test_bad': '测试没通过:{detail}', 'gui.agent.fix_sign_in': '需要先登录 {agent}。在终端运行下面这条命令,按提示在浏览器里登录,完成后点「{button}」:', 'gui.agent.fix_sign_in_bare': '需要先登录 {agent},登录后点「{button}」。', 'gui.agent.fix_setup': @@ -4038,9 +4055,6 @@ export const UI_TEXT: Record> = { 'gui.agent.fix_config': '{agent} 的配置文件有错,它读不出来。在终端运行下面这条命令,会指出出错的位置,改好后点「{button}」:', 'gui.agent.fix_config_bare': '{agent} 的配置文件有错,它读不出来。改好后点「{button}」。', - 'gui.agent.fix_unknown_connect': '{agent} 没接入成功。可以展开下面的原始报错排查原因,处理好后点「{button}」。', - 'gui.agent.fix_unknown_save': '这项修改没保存成功。可以展开下面的原始报错查看原因,或点「{button}」再试一次。', - 'gui.agent.fix_unknown_test': '可以展开下面的原始报错排查原因,处理好后再点「{button}」。', 'gui.agent.needs_node': '{agent} 要通过 Node.js 自带的 npx 运行,这台机器上没找到 npx。先安装 Node.js,装好后点「{button}」。', 'gui.agent.bad_sign_in': '需要先登录 {agent}', @@ -4060,14 +4074,37 @@ export const UI_TEXT: Record> = { 'gui.agent.bad_connect': '{agent} 没接入成功', 'gui.agent.bad_save': '这项修改没保存成功', 'gui.agent.bad_disconnect': '{agent} 没能断开', - 'gui.agent.bad_download_retry': '{what},检查网络、npm 源或代理后点「{button}」', - 'gui.agent.fix_unknown_disconnect': '{agent} 没能断开。可以展开下面的原始报错查看原因,或点「{button}」再试一次。', - 'gui.agent.bad_open': '{what},点开看怎么做', - 'gui.agent.bad_run': '{what}:在终端运行 {command},再点「{button}」', - 'gui.agent.bad_plan_link': '{what}:在浏览器打开 {command} 开通套餐,再点「{button}」', - 'gui.agent.bad_run_then': '{what}:在终端运行 {command} 并输入 {then},再点「{button}」', - 'gui.agent.bad_run_diagnose': '{what}:在终端运行 {command} 查看原因,处理好后点「{button}」', - 'gui.agent.bad_retry': '{what},处理好后点「{button}」', + 'gui.agent.st_connect_bad': '接入失败', + 'gui.agent.st_save_bad': '没保存', + 'gui.agent.st_disconnect_bad': '断开失败', + 'gui.agent.st_test_bad': '测试没通过', + 'gui.agent.why_sign_in': '需要先登录', + 'gui.agent.why_setup': '还没选模型服务商', + 'gui.agent.why_api_key': 'API key 不可用', + 'gui.agent.why_download': '没能下载', + 'gui.agent.why_model': '模型不可用', + 'gui.agent.why_billing': '余额不足', + 'gui.agent.why_quota': '被限流或额度用完', + 'gui.agent.why_network': '连不上服务商', + 'gui.agent.why_silent': '没有及时回复', + 'gui.agent.why_upgrade': '版本太旧', + 'gui.agent.why_exited': '一启动就退出', + 'gui.agent.why_runtime': 'Node.js 版本太旧', + 'gui.agent.why_plan': '套餐不含它', + 'gui.agent.why_config': '配置文件有错', + 'gui.agent.why_open': '点开看原因', + 'gui.agent.why_hover': '悬停看原始报错', + 'gui.agent.test_again': '再测一次', + 'gui.agent.test_again_short': '再测', + 'gui.agent.row_download': '没能下载,检查网络、npm 源或代理后点「{button}」', + 'gui.agent.row_run': '{why},在终端运行 {command} 后点「{button}」', + 'gui.agent.row_run_then': '{why},在终端运行 {command} 并输入 {then},再点「{button}」', + 'gui.agent.row_run_diagnose': '{why},在终端运行 {command} 查看原因,处理好后点「{button}」', + 'gui.agent.row_open': '{why},在浏览器打开 {command} 开通套餐,再点「{button}」', + 'gui.agent.said_connect': '它没有回应测试消息,原话如下。处理好后点「{button}」。', + 'gui.agent.said_test': '原话如下。处理好后点「{button}」。', + 'gui.agent.said_save': '服务端拒绝了这项修改,原话如下。', + 'gui.agent.said_disconnect': '配置没写成功,原话如下。处理好后点「{button}」。', 'gui.agent.short_raven': '通用助手,什么活都能接一点', 'gui.agent.short_raven_code': '写代码、跑代码、修 bug', 'gui.agent.short_raven_design': '出界面稿、改视觉、做图', @@ -4460,7 +4497,7 @@ export const UI_TEXT: Record> = { 'gui.settings.nav.archive': '归档', 'gui.settings.nav.about': '关于', 'gui.settings.general.language': '语言', - 'gui.settings.general.language_sub': '选择 Raven 使用的语言。', + 'gui.settings.general.language_sub': '选择界面使用的语言。', 'gui.settings.general.lang_zh': '中文', 'gui.settings.general.lang_en': 'English', 'gui.settings.general.theme': '外观', diff --git a/ui-web/scripts/gates/agent-mark-css.test.mjs b/ui-web/scripts/gates/agent-mark-css.test.mjs index ec7991342..19c1d9a30 100644 --- a/ui-web/scripts/gates/agent-mark-css.test.mjs +++ b/ui-web/scripts/gates/agent-mark-css.test.mjs @@ -1,90 +1,67 @@ -/* Which agent marks the dark theme filters, and with which filter. +/* What the dark theme does to an agent mark: nothing to the mark, and only the + * plate under it changes. * - * Three kinds of file sit in `assets/agents/`, and `data-tone` is what carries - * the distinction from the map in AgentMark.tsx to this stylesheet: + * Three kinds of file sit in `assets/agents/`, and `data-tone` carries the + * distinction from the map in AgentMark.tsx: no tone (own fills), `mono` + * (every shape `currentColor`, so the file draws black) and `hybrid` (qoder: + * brand green beside one currentColor tone). The dark theme used to correct + * each kind with a filter -- invert the mono files, invert and hue-rotate the + * hybrid -- and each correction was a guess about the file that went wrong + * silently: hermesagent.svg is an illustration and came out as its negative, + * and qoder's green came back as a different green (#2ADB5C -> #008203). * - * - no tone: every shape carries its own fill. Renders the same in both themes - * and comes out as a photographic negative if inverted -- Claude's terracotta - * turns cyan. Filter nothing. - * - `mono`: every shape is `currentColor`, which an resolves against the - * SVG's own document rather than the page, so the file renders black and is - * invisible on a dark surface until the theme inverts the lot. - * - `hybrid`: some shapes are and some are not, which is qoder alone. A plain - * invert lifts the currentColor half correctly AND takes the brand green with - * it (#2ADB5C -> #D524A3, magenta); the hue rotation puts the hue back. + * So every mark sits on a light plate in both themes, as the app icon does, + * and the plate is what the dark theme dims. Pinned here because no DOM test + * can see it: happy-dom applies no stylesheet, so `filter`, `background` and + * `color-scheme` are unobservable there. The markup half -- that the tone + * reaches the element -- is pinned in AgentMark.test.tsx, and the + * tone-matches-the-file half in tests/test_ui_agent_marks.py. * - * So neither filter is a theme preference, each is a per-file correction, and - * applying the wrong one is silent. Pinned here because no DOM test can see it: - * happy-dom applies no stylesheet, so `filter` is unobservable there. The - * markup half -- that the tone reaches the element -- is pinned in - * AgentMark.test.tsx, and the tone-matches-the-file half in - * tests/test_ui_agent_marks.py; this is the half that says the stylesheet still - * reads it, and reads it per tone. - * - * The provider marks are the same mechanism with the ratio reversed: those - * files are all currentColor and one is not, so that block inverts by default - * and exempts the exception. Agents are mostly colour, so this one filters - * nothing by default. Neither may become a blanket rule. + * The provider marks are a different mechanism (all currentColor, inverted by + * default with one exemption) and are not read here. */ import { describe, expect, it } from 'vitest' import { rules } from './css.mjs' -const filtered = () => rules(/\.agent-mark\b/).filter(([, body]) => /filter:\s*invert/.test(body)) - -describe('the agent mark filters', () => { - /* Both theme paths, because they are not one rule: an explicit choice stamps - `data-theme`, and the default setting stamps nothing and leaves only the - media query. A block written for one and not the other is a mark that is - correct until the user touches the theme switch. Two tones x two paths. */ - it('filters both tones down both dark paths, and nothing else', () => { - const all = filtered() - expect(all.length).toBe(4) - for (const [selector] of all) expect(selector).toMatch(/\[data-tone="(mono|hybrid)"\]/) - const explicit = all.filter(([s]) => /\[data-theme="dark"\]/.test(s)) - const system = all.filter(([s]) => /:not\(\[data-theme="light"\]\)/.test(s)) - for (const path of [explicit, system]) { - expect(path.length).toBe(2) - expect(path.some(([s]) => /"mono"/.test(s))).toBe(true) - expect(path.some(([s]) => /"hybrid"/.test(s))).toBe(true) - } - }) - - /* The hybrid's rotation is the whole reason the tone is not just a boolean. - Dropping it reads as a simplification and silently recolours a brand. */ - it('rotates the hue for the hybrid and only for the hybrid', () => { - for (const [selector, body] of filtered()) { - const rotates = /hue-rotate\(\s*180deg\s*\)/.test(body) - expect(rotates).toBe(/"hybrid"/.test(selector)) +describe('the agent mark plate', () => { + /* A filter reaching a mark is the mistake this file exists to catch, and it + reads as a one-line fix for one mark. */ + it('filters no mark, in either theme path', () => { + for (const [selector, body] of rules(/\.agent-mark\b/)) { + expect(body, selector).not.toMatch(/filter:/) } }) - /* The guard that matters. A filter reaching every mark is the mistake this - file exists to catch, and it reads as a one-word simplification. */ - it('leaves the marks that carry their own palette alone', () => { - for (const [selector, body] of rules(/\.agent-mark\b/)) { - if (/filter:\s*invert/.test(body)) expect(selector).toMatch(/\[data-tone=/) - } + /* The plate is a token of its own, not the theme's surface: `--surface` goes + dark with the theme, and a brand mark on it needs the recolouring this file + forbids. Both dark paths -- an explicit choice stamps `data-theme`, the + default setting stamps nothing and leaves only the media query -- and the + explicit light path, which has to win back the light plate on a dark OS. */ + it('draws every tile on the plate token, which each theme block declares', () => { + const tile = rules(/^\.agent-mark$/)[0]?.[1] + expect(tile).toBeTruthy() + expect(tile).toMatch(/background:\s*var\(--mark-plate\)/) + expect(tile).toMatch(/border:[^;]*var\(--mark-plate-line\)/) + const declares = (selector) => rules(selector).some(([, body]) => /--mark-plate:\s*#/.test(body)) + expect(declares(/^:root$/)).toBe(true) + expect(declares(/^:root\[data-theme="dark"\]$/)).toBe(true) + expect(declares(/^:root\[data-theme="light"\]$/)).toBe(true) }) - /* The tone filters handle a file that says nothing about the theme. Two files - say plenty: miromind.svg, a favicon with its own prefers-color-scheme rule, - and raven.svg, which carries one because a black bird on the dark surface - is a silhouette and no tone filter can recolour a mark that is not one - colour. An 's SVG evaluates that rule against the embedding element's - used color-scheme rather than against this page's data-theme. Both - directions are needed, because the mismatch runs both ways -- a dark OS - under the app's explicitly light default, and a light OS under a chosen - dark theme -- and each looks like the mark simply having that colour. - It must stay on the tile: on :root it would hand every native scrollbar - and form control to the dark palette too, which no test here would see. */ - it('pins the embedded scheme to the chosen theme, on the tile', () => { + /* raven.svg and miromind.svg answer prefers-color-scheme from inside the + file, which an 's SVG evaluates against the embedding element's used + color-scheme rather than this page's data-theme. On a light plate each has + to draw its light self under either theme and either OS, so the scheme is + pinned light on the tile, unconditionally. It must stay on the tile: on + :root it would hand every native scrollbar and form control to one palette + too, which no test here would see. */ + it('pins the embedded scheme light, on the tile, in both themes', () => { const scoped = rules(/\.agent-mark\b/).filter(([, body]) => /color-scheme:/.test(body)) - expect(scoped.length).toBe(2) - for (const [selector] of scoped) expect(selector).toMatch(/\.agent-mark img$/) - expect(scoped.some(([s, b]) => /\[data-theme="dark"\]/.test(s) && /color-scheme:\s*dark/.test(b))).toBe(true) - expect(scoped.some(([s, b]) => /\[data-theme="light"\]/.test(s) && /color-scheme:\s*light/.test(b))).toBe(true) + expect(scoped.length).toBe(1) + expect(scoped[0][0]).toBe('.agent-mark img') + expect(scoped[0][1]).toMatch(/color-scheme:\s*light/) expect(rules(/^:root$/).some(([, body]) => /color-scheme:/.test(body))).toBe(false) }) diff --git a/ui-web/scripts/gates/wait-shimmer-css.test.mjs b/ui-web/scripts/gates/wait-shimmer-css.test.mjs index 7633cdaaa..8bd846a5a 100644 --- a/ui-web/scripts/gates/wait-shimmer-css.test.mjs +++ b/ui-web/scripts/gates/wait-shimmer-css.test.mjs @@ -23,6 +23,7 @@ import { css, decls } from './css.mjs' const settings = readFileSync(new URL('../../src/features/settings/styles.css', import.meta.url), 'utf8') const cron = readFileSync(new URL('../../src/features/cron/styles.css', import.meta.url), 'utf8') +const extAgents = readFileSync(new URL('../../src/features/extAgents/styles.css', import.meta.url), 'utf8') const escape = (sel) => sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -30,6 +31,7 @@ const BARS = [ ['.settings-wbar', settings], ['.two-pane-wbar', css], ['.cronwbar', cron], + ['.extAgents-wbar', extAgents], ] describe('the bars a wait is drawn with', () => { diff --git a/ui-web/src/components/AgentMark.tsx b/ui-web/src/components/AgentMark.tsx index bc372529b..3537f3926 100644 --- a/ui-web/src/components/AgentMark.tsx +++ b/ui-web/src/components/AgentMark.tsx @@ -14,22 +14,22 @@ import type { JSX } from 'react' exception is miromind.svg, which that package does not carry and which is MiroMind's own favicon, also verbatim. - `tone` says how the file answers the theme, which is a fact about the file - rather than a preference -- tests/test_ui_agent_marks.py derives each one - from the shapes in the SVG it names, and the stylesheet reads it back off - `data-tone`. Three answers, because an resolves `currentColor` against - its own document rather than the page and so renders it black: + `tone` says what the file is made of, which is a fact about the file rather + than a preference -- tests/test_ui_agent_marks.py derives each one from the + shapes in the SVG it names. Three answers, because an resolves + `currentColor` against its own document rather than the page and so renders + it black: - - absent: every shape carries its own fill. Theme-independent, touch nothing. - - `mono`: every shape is `currentColor`. Black until the dark theme inverts - the lot, which is what the provider marks already do. - - `hybrid`: some shapes are, some are not. Only qoder, whose mark is brand - green beside one tone the vendor means to follow the page's text -- so a - plain invert would take the green to magenta with it, and the hue rotation - puts it back. Measured: #2ADB5C -> #D524A3 inverted, -> #008203 with the - rotation. Not the same green, and the closest a filter gets without - constants fitted to one hex; the alternative that reproduces it exactly is - inlining the file, which ui-web/build.py's assets comment rules out. */ + - absent: every shape carries its own fill. + - `mono`: every shape is `currentColor`, so the file draws black. + - `hybrid`: some shapes are, some are not. Only qoder: brand green beside one + tone the vendor means to follow the page's text. + + No rule reads it back any more. Every mark sits on a light plate in both + themes (page.css, `.agent-mark`), because recolouring a mark for a dark + ground is a per-file guess: inverting a mono illustration gives its negative + and rotating qoder's hue back lands on a different green. The attribute + stays as the file's fact, for the test that ties each tone to its file. */ const MARKS: Record = { claude_code: { file: 'claudecode-color' }, codebuddy: { file: 'codebuddy-color' }, @@ -57,9 +57,8 @@ const MARKS: Record = { against @lobehub/icons-static-svg that settles provenance for the thirteen does not apply to it and LICENSES/README.md counts it separately. It answers the theme from a prefers-color-scheme rule inside the file, the way - miromind.svg does: every shape carries its own fill, so no `tone` filter - reaches it, and a raven is black -- which on the dark theme's surface is a - silhouette at 1.2:1, two white eyes floating in an empty frame. */ + miromind.svg does; the tile pins that scheme light, so on its light plate the + raven is black under either theme, as the app icon draws it. */ const OWN_MARK: { file: string } = { file: 'raven' } /* Spelled once, because four call sites read it off three different row types diff --git a/ui-web/src/features/extAgents/AgentsBody.test.tsx b/ui-web/src/features/extAgents/AgentsBody.test.tsx index ef0ee42e1..f1642fa63 100644 --- a/ui-web/src/features/extAgents/AgentsBody.test.tsx +++ b/ui-web/src/features/extAgents/AgentsBody.test.tsx @@ -273,15 +273,11 @@ describe('the onboarding wizard\'s agents step', () => { /* In the reader's words, with the step the row itself has to carry: the wizard opens no sheet, so there is nowhere else for it to be. The server's English sentence is not the line any more. */ - const what = `gui.agent.bad_connect ${JSON.stringify({ agent: 'preset_a' })}` - expect(rowNamed('preset_a').querySelector('.extAgents-one-bad')!.textContent).toBe( - `gui.agent.bad_retry ${JSON.stringify({ what, button: 'gui.retry' })}`, - ) + expect(rowNamed('preset_a').querySelector('.extAgents-foot-k')!.textContent).toBe('gui.agent.st_connect_bad') + expect(rowNamed('preset_a').querySelector('.extAgents-foot-r')!.textContent).toBe('gui.agent.why_hover') /* ...and the sentence is kept on hover: with no sheet to fold it into, it is the only reason this row has. */ - expect(rowNamed('preset_a').querySelector('.extAgents-one-bad')!.getAttribute('title')).toBe( - 'did not answer a test message', - ) + expect(rowNamed('preset_a').querySelector('.extAgents-row2')!.getAttribute('title')).toBe('did not answer a test message') expect(control('preset_a').textContent).toBe('gui.retry') expect(toastWriter.items).toEqual([]) @@ -293,7 +289,7 @@ describe('the onboarding wizard\'s agents step', () => { ['connect', 'preset_a', {}], ['connect', 'preset_a', {}], ]) - expect(rowNamed('preset_a').querySelector('.extAgents-one-bad')).toBeNull() + expect(rowNamed('preset_a').querySelector('.extAgents-row2')).toBeNull() expect(control('preset_a').textContent).toBe('gui.agent.disconnect') }) @@ -315,16 +311,18 @@ describe('the onboarding wizard\'s agents step', () => { await act(async () => { fireEvent.click(control('Codex')) }) - const what = `gui.agent.bad_sign_in ${JSON.stringify({ agent: 'Codex' })}` - expect(rowNamed('Codex').querySelector('.extAgents-one-bad')!.textContent).toBe( - `gui.agent.bad_run ${JSON.stringify({ what, command: 'codex login', button: 'gui.retry' })}`, + /* The command is a piece of code inside the sentence, not part of it. */ + expect(rowNamed('Codex').querySelector('.extAgents-foot-k')!.textContent).toBe('gui.agent.st_connect_bad') + expect(rowNamed('Codex').querySelector('.extAgents-foot-r')!.textContent).toBe( + `gui.agent.row_run ${JSON.stringify({ why: 'gui.agent.why_sign_in', command: 'codex login', button: 'gui.retry' })}`, ) + expect(rowNamed('Codex').querySelector('.extAgents-foot-r code')!.textContent).toBe('codex login') }) it.each([ - [{ kind: 'setup', command: 'qwen', then: '/auth' }, 'gui.agent.bad_setup', 'gui.agent.bad_run_then'], - [{ kind: 'silent', command: 'qwen hi' }, 'gui.agent.bad_silent', 'gui.agent.bad_run_diagnose'], - ])('puts a fix typed inside the agent, or a command that only says why, on the row as that (%o)', async (remedy, bad, line) => { + [{ kind: 'setup', command: 'qwen', then: '/auth' }, 'gui.agent.why_setup', 'gui.agent.row_run_then'], + [{ kind: 'silent', command: 'qwen hi' }, 'gui.agent.why_silent', 'gui.agent.row_run_diagnose'], + ])('puts a fix typed inside the agent, or a command that only says why, on the row as that (%o)', async (remedy, why, line) => { const rows = [row({ name: 'Qwen Code', preset: 'qwen_code' })] install(rows) setSources({ @@ -342,20 +340,25 @@ describe('the onboarding wizard\'s agents step', () => { await act(async () => { fireEvent.click(control('Qwen Code')) }) - const what = `${bad} ${JSON.stringify({ agent: 'Qwen Code' })}` - const vars = 'then' in remedy ? { what, command: remedy.command, then: remedy.then, button: 'gui.retry' } : { what, command: remedy.command, button: 'gui.retry' } - expect(rowNamed('Qwen Code').querySelector('.extAgents-one-bad')!.textContent).toBe(`${line} ${JSON.stringify(vars)}`) + const vars = 'then' in remedy ? { why, command: remedy.command, then: remedy.then, button: 'gui.retry' } : { why, command: remedy.command, button: 'gui.retry' } + const r = rowNamed('Qwen Code') + expect(r.querySelector('.extAgents-foot-k')!.textContent).toBe('gui.agent.st_connect_bad') + expect(r.querySelector('.extAgents-foot-r')!.textContent).toBe(`${line} ${JSON.stringify(vars)}`) + /* Each command is a piece of code inside the sentence, the step after it too. */ + const codes = [...r.querySelectorAll('.extAgents-foot-r code')].map((c) => c.textContent) + expect(codes).toEqual('then' in remedy ? [remedy.command, remedy.then] : [remedy.command]) }) /* A plan's `command` is the page that sells one: a row that told the reader to "run" a URL in a terminal would be telling them to do the wrong thing with the right address, so the row says to open it; without one there is - only the retry. A config's check is a command, and is run. */ + only the reason. A config's check is a command that says where the file is + wrong, as the sheet puts it, so the row names it as that. */ it.each([ - [{ kind: 'plan', command: 'https://www.kimi.com/code/#pricing' }, 'gui.agent.bad_plan', 'gui.agent.bad_plan_link'], - [{ kind: 'plan', command: '' }, 'gui.agent.bad_plan', 'gui.agent.bad_retry'], - [{ kind: 'config', command: 'kimi doctor config' }, 'gui.agent.bad_config', 'gui.agent.bad_run'], - ])('puts an account or config fix on the row as what it is (%o)', async (remedy, bad, line) => { + [{ kind: 'plan', command: 'https://www.kimi.com/code/#pricing' }, 'gui.agent.why_plan', 'gui.agent.row_open'], + [{ kind: 'plan', command: '' }, 'gui.agent.why_plan', ''], + [{ kind: 'config', command: 'kimi doctor config' }, 'gui.agent.why_config', 'gui.agent.row_run_diagnose'], + ])('puts an account or config fix on the row as what it is (%o)', async (remedy, why, line) => { const rows = [row({ name: 'Kimi Code', preset: 'kimi_code' })] install(rows) setSources({ @@ -373,10 +376,11 @@ describe('the onboarding wizard\'s agents step', () => { await act(async () => { fireEvent.click(control('Kimi Code')) }) - const what = `${bad} ${JSON.stringify({ agent: 'Kimi Code' })}` - const vars = - line === 'gui.agent.bad_retry' ? { what, button: 'gui.retry' } : { what, command: remedy.command, button: 'gui.retry' } - expect(rowNamed('Kimi Code').querySelector('.extAgents-one-bad')!.textContent).toBe(`${line} ${JSON.stringify(vars)}`) + const r = rowNamed('Kimi Code') + expect(r.querySelector('.extAgents-foot-k')!.textContent).toBe('gui.agent.st_connect_bad') + const said = r.querySelector('.extAgents-foot-r')!.textContent + expect(said).toBe(line ? `${line} ${JSON.stringify({ why, command: remedy.command, button: 'gui.retry' })}` : why) + expect([...r.querySelectorAll('.extAgents-foot-r code')].map((c) => c.textContent)).toEqual(remedy.command ? [remedy.command] : []) }) it('says where to look for a download, rather than the adapter\'s long command', async () => { @@ -397,10 +401,11 @@ describe('the onboarding wizard\'s agents step', () => { await act(async () => { fireEvent.click(control('Claude Code')) }) - const what = `gui.agent.bad_download ${JSON.stringify({ agent: 'Claude Code' })}` - expect(rowNamed('Claude Code').querySelector('.extAgents-one-bad')!.textContent).toBe( - `gui.agent.bad_download_retry ${JSON.stringify({ what, button: 'gui.retry' })}`, + expect(rowNamed('Claude Code').querySelector('.extAgents-foot-k')!.textContent).toBe('gui.agent.st_connect_bad') + expect(rowNamed('Claude Code').querySelector('.extAgents-foot-r')!.textContent).toBe( + `gui.agent.row_download ${JSON.stringify({ button: 'gui.retry' })}`, ) + expect(rowNamed('Claude Code').querySelector('.extAgents-foot-r code')).toBeNull() }) it('disconnects a connected row through act(toggle, {enabled: false})', async () => { diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx index b1772589d..6ecaed23d 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx @@ -126,6 +126,16 @@ const controlOf = (name: string): string | null => { } const buttonOf = (name: string): HTMLButtonElement | null => rowNamed(name).querySelector('.extAgents-ctl button') const lineOf = (name: string): string | null => rowNamed(name).querySelector('.extAgents-one')!.textContent +/* The card's footer about the last thing that went wrong: the word and the + reason. Null when the card carries none; the press is the corner control. */ +const footOf = (name: string): { k: string | null; r: string | null } | null => { + const f = rowNamed(name).querySelector('.extAgents-foot') + if (!f) return null + return { + k: f.querySelector('.extAgents-foot-k')?.textContent ?? null, + r: f.querySelector('.extAgents-foot-r')?.textContent ?? null, + } +} const ledOf = (name: string): string | null => rowNamed(name).querySelector('.extAgents-nm .extAgents-led')?.className ?? null /* Which tab lists a card, read by pressing each one in turn -- the filter under test -- and coming back to All, where every other helper looks. */ @@ -372,7 +382,8 @@ describe('connecting', () => { await mount() await click(buttonOf('off_one')) expect(buttonOf('off_one')).toBeNull() - expect(lineOf('off_one')).toBe('gui.agent.testing') + expect(lineOf('off_one')).toBe('gui.agent.short_claude_code') + expect(footOf('off_one')).toEqual({ k: 'gui.agent.testing', r: null }) expect(ledOf('off_one')).toBe('extAgents-led extAgents-led-busy') await act(async () => { release() @@ -425,11 +436,68 @@ describe('connecting', () => { await mount() await openSheet('on_one') await click(sheet()!.querySelector('.extAgents-act button.danger')) - expect(lineOf('on_one')).toBe('gui.agent.disconnecting') + expect(footOf('on_one')).toEqual({ k: 'gui.agent.disconnecting', r: null }) + await act(async () => { + release() + await gate + }) + }) + + /* Before the first answer the grid is placeholders of a card's own shape, + one per catalogue entry since the catalogue is the roster on every + machine, not the empty message and a jump when the roster lands. Only + before the first answer: a reload keeps the rows it has. */ + it('draws one placeholder card per catalogue entry until the roster arrives', async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const rows = [row({ name: 'claude_code' }), row({ name: 'off_one', enabled: false })] + install(rows, { + load: async () => { + await gate + return rows + }, + }) + await mount() + expect(document.querySelectorAll('.extAgents-wcard').length).toBe(18) + expect(document.querySelector('.extAgents-grid')!.getAttribute('aria-busy')).toBe('true') + expect(document.querySelector('.extAgents-empty')).toBeNull() + expect(document.querySelector('.extAgents-tn')).toBeNull() + expect(document.querySelectorAll('.extAgents-tab .extAgents-wtn').length).toBe(2) await act(async () => { release() await gate }) + expect(document.querySelectorAll('.extAgents-wcard').length).toBe(0) + expect(rowsOf().length).toBe(2) + expect(tabLabel(tabNamed('gui.filter.all'))).toBe('gui.filter.all') + /* A reload with rows in hand keeps them on screen. */ + await act(async () => { + await store.load(true) + }) + expect(document.querySelectorAll('.extAgents-wcard').length).toBe(0) + }) + + /* The slot under the line is never blank: with nothing wrong it says which + state the row is in, so a grid reads the same whether or not a card is in + trouble, and a strip that later replaces it takes no more room. Not the + vendor: the mark beside the name already says that. */ + it('says how a card is doing under the line about it, when nothing is wrong', async () => { + install([ + row({ name: 'tested', configured: true, enabled: true, last_test_ok: true, last_test_at_ms: Date.now() - 3 * 86400_000 }), + row({ name: 'on_one', configured: true, enabled: true }), + row({ name: 'off_one', enabled: false }), + row({ name: 'gone', preset: 'codex', configured: false, enabled: false, probe_status: 'missing' }), + ]) + await mount() + expect(footOf('tested')!.k).toContain('gui.agent.hd_on_tested') + expect(footOf('tested')!.k).toContain('gui.time.ago_d') + expect(footOf('on_one')!.k).toBe('gui.agent.g_on') + expect(footOf('off_one')!.k).toBe('gui.agent.g_avail') + expect(footOf('gone')!.k).toBe('gui.agent.g_missing') + for (const name of ['tested', 'on_one', 'off_one']) expect(rowNamed(name).querySelector('.extAgents-foot-quiet')).not.toBeNull() + expect(buttonOf('tested')).toBeNull() }) it('keeps a refusal on the card in red with a retry, and does not toast it', async () => { @@ -439,11 +507,11 @@ describe('connecting', () => { }) await mount() await click(buttonOf('off_one')) - /* What failed, in the reader's words, and where to look: the card opens the - sheet, which has the steps and the server's sentence folded under them. */ - const what = `gui.agent.bad_connect ${JSON.stringify({ agent: 'off_one' })}` - expect(lineOf('off_one')).toBe(`gui.agent.bad_open ${JSON.stringify({ what })}`) - expect(rowNamed('off_one').querySelector('.extAgents-one')!.className).toContain('extAgents-one-bad') + /* The line about the agent stays as it was; what failed and the short + reason are a footer under it, the sheet has the steps, and the corner, + where every press on a card lives, offers the retry. */ + expect(rowNamed('off_one').querySelector('.extAgents-one')!.className).toBe('extAgents-one') + expect(footOf('off_one')).toEqual({ k: 'gui.agent.st_connect_bad', r: 'gui.agent.why_open' }) expect(controlOf('off_one')).toBe('gui.retry') expect(ledOf('off_one')).toBe('extAgents-led extAgents-led-bad') expect(toasts).toEqual([]) @@ -510,13 +578,25 @@ describe('the sheet', () => { detail.close() }) await openSheet('failed') - /* A failure the server named no fix for is still said in the reader's - words, with the server's sentence folded under it rather than shown. */ - const said = `gui.agent.fix_unknown_test ${JSON.stringify({ button: 'gui.agent.test_label' })}` - const fix = sheet()!.querySelector('.extAgents-fix')! - expect(fix.firstElementChild!.textContent).toBe(`gui.agent.hd_test_bad ${JSON.stringify({ detail: said })}`) - expect(fix.querySelector('details.extAgents-raw')!.textContent).toBe('gui.agent.fix_rawit returned nothing') + /* The head says which state it is in; the block at the top of the body + says what failed, when, and -- the server having named no fix -- shows + its sentence as it came rather than folding the only reason away. */ + expect(sheetStatus()).toBe('gui.agent.hd_on_test_bad') expect(sheet()!.querySelector('.extAgents-by')!.className).toContain('extAgents-by-bad') + const note = sheet()!.querySelector('.extAgents-note')! + expect(note.querySelector('.extAgents-note-t')!.firstChild!.textContent).toBe('gui.agent.st_test_bad') + expect(note.querySelector('.extAgents-note-when')!.textContent).toContain('gui.time.ago_d') + expect(note.querySelector('.extAgents-note-p')!.textContent).toBe( + `gui.agent.said_test ${JSON.stringify({ button: 'gui.agent.test_again' })}`, + ) + expect(note.querySelector('.extAgents-note-said')!.textContent).toBe('it returned nothing') + expect(note.querySelector('details')).toBeNull() + expect(sheetActs()).toEqual(['gui.agent.disconnect', 'gui.agent.test_again']) + /* The card says it too, under the line about the agent, with a retest. */ + expect(rowNamed('failed').querySelector('.extAgents-one')!.className).toBe('extAgents-one') + expect(footOf('failed')).toEqual({ k: 'gui.agent.st_test_bad', r: expect.stringContaining('gui.time.ago_d') }) + expect(controlOf('failed')).toBe('gui.agent.test_again_short') + expect(ledOf('failed')).toBe('extAgents-led extAgents-led-bad') }) it('runs a test from its action bar, says so while it runs, and offers to stop it', async () => { @@ -709,12 +789,11 @@ describe('the sheet', () => { await mount() await openSheet('ua') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.test_label')) - const said = `gui.agent.fix_unknown_test ${JSON.stringify({ button: 'gui.agent.test_label' })}` - const fix = sheet()!.querySelector('.extAgents-fix')! - expect(fix.firstElementChild!.textContent).toBe(`gui.agent.hd_test_bad ${JSON.stringify({ detail: said })}`) - expect(fix.querySelector('details.extAgents-raw')!.textContent).toBe( - 'gui.agent.fix_rawit connected and then answered nothing', - ) + /* Not connected, so the head names the maker rather than "connected". */ + expect(sheetStatus()).toBe('gui.agent.st_test_bad · Anthropic') + const note = sheet()!.querySelector('.extAgents-note')! + expect(note.querySelector('.extAgents-note-t')!.textContent).toBe('gui.agent.st_test_bad') + expect(note.querySelector('.extAgents-note-said')!.textContent).toBe('it connected and then answered nothing') }) }) @@ -725,7 +804,10 @@ describe('the sheet', () => { with the wrong words, the wrong agent or the wrong button fails here. */ describe('a refusal that names its fix', () => { const say = (key: string, vars: Record): string => `${key} ${JSON.stringify(vars)}` - const fix = (): Element | null => sheet()?.querySelector('.extAgents-fix') ?? null + const note = (): Element | null => sheet()?.querySelector('.extAgents-note') ?? null + const title = (): string | null => note()?.querySelector('.extAgents-note-t')?.firstChild?.textContent ?? null + const lead = (): string | null => note()?.querySelector('.extAgents-note-p')?.textContent ?? null + const raw = (): HTMLDetailsElement | null => note()?.querySelector('details.extAgents-note-raw') ?? null const hermesSaid = 'connected, but no session could be opened: Internal error: Hermes is not connected to any AI provider yet. ' + 'Run `hermes model` to pick one (auth methods: hermes-setup)' @@ -747,13 +829,13 @@ describe('a refusal that names its fix', () => { ]) await mount() await openSheet('Hermes Agent') - const lead = say('gui.agent.fix_setup', { agent: 'Hermes Agent', button: 'gui.agent.test_label' }) - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.hd_test_bad', { detail: lead })) - expect(fix()!.querySelector('.extAgents-cmd code')!.textContent).toBe('hermes model') - expect(fix()!.querySelector('.extAgents-raw summary')!.textContent).toBe('gui.agent.fix_raw') - expect(fix()!.querySelector('.extAgents-raw')!.textContent).toContain(hermesSaid) - expect((fix()!.querySelector('.extAgents-raw') as HTMLDetailsElement).open).toBe(false) - expect(sheet()!.querySelector('.extAgents-by')!.className).toContain('extAgents-by-fix') + expect(title()).toBe(say('gui.agent.bad_setup', { agent: 'Hermes Agent' })) + expect(lead()).toBe(say('gui.agent.fix_setup', { agent: 'Hermes Agent', button: 'gui.agent.test_label' })) + expect(note()!.querySelector('.extAgents-cmd code')!.textContent).toBe('hermes model') + expect(raw()!.querySelector('summary')!.textContent).toBe('gui.agent.fix_raw') + expect(raw()!.textContent).toContain(hermesSaid) + expect(raw()!.open).toBe(false) + expect(sheet()!.querySelector('.extAgents-by')!.className).toContain('extAgents-by-bad') }) it('says a refused connect needs a sign-in, names Retry, and copies the command', async () => { @@ -770,12 +852,14 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('Codex') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.connect')) - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.fix_sign_in', { agent: 'Codex', button: 'gui.retry' })) + expect(sheetStatus()).toBe('gui.agent.st_connect_bad · OpenAI') + expect(title()).toBe(say('gui.agent.bad_sign_in', { agent: 'Codex' })) + expect(lead()).toBe(say('gui.agent.fix_sign_in', { agent: 'Codex', button: 'gui.retry' })) expect(sheetActs()).toEqual(['gui.retry']) - expect(fix()!.querySelector('.extAgents-raw')!.textContent).toContain(said) - await click(fix()!.querySelector('.extAgents-cmd button')) + expect(raw()!.textContent).toContain(said) + await click(note()!.querySelector('.extAgents-cmd button')) expect(writeText).toHaveBeenCalledWith('npx -y @openai/codex login') - expect(fix()!.querySelector('.extAgents-cmd button')!.textContent).toBe('gui.agent.copied') + expect(note()!.querySelector('.extAgents-cmd button')!.textContent).toBe('gui.agent.copied') }) it('gives an endpoint row no command, since its key is fixed here and not in a terminal', async () => { @@ -792,9 +876,10 @@ describe('a refusal that names its fix', () => { ]) await mount() await openSheet('MiroThinker') - const lead = say('gui.agent.fix_api_key', { agent: 'MiroThinker', button: 'gui.agent.test_label' }) - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.hd_test_bad', { detail: lead })) - expect(fix()!.querySelector('.extAgents-cmd')).toBeNull() + expect(title()).toBe(say('gui.agent.bad_api_key', { agent: 'MiroThinker' })) + /* An endpoint with no key is not connected, so its press is plain Test. */ + expect(lead()).toBe(say('gui.agent.fix_api_key', { agent: 'MiroThinker', button: 'gui.agent.test_label' })) + expect(note()!.querySelector('.extAgents-cmd')).toBeNull() }) it('says to sign in, and offers nothing to run, when no command is known for the agent', async () => { @@ -810,9 +895,9 @@ describe('a refusal that names its fix', () => { ]) await mount() await openSheet('opencode') - const lead = say('gui.agent.fix_sign_in_bare', { agent: 'opencode', button: 'gui.agent.test_label' }) - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.hd_test_bad', { detail: lead })) - expect(fix()!.querySelector('.extAgents-cmd')).toBeNull() + expect(title()).toBe(say('gui.agent.bad_sign_in', { agent: 'opencode' })) + expect(lead()).toBe(say('gui.agent.fix_sign_in_bare', { agent: 'opencode', button: 'gui.agent.test_again' })) + expect(note()!.querySelector('.extAgents-cmd')).toBeNull() }) it('says a download npx could not make, and gives the command that makes it with no time limit', async () => { const r = row({ name: 'Claude Code', preset: 'claude_code', configured: false, enabled: false }) @@ -827,12 +912,14 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('Claude Code') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.connect')) - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.fix_download', { agent: 'Claude Code', button: 'gui.retry' })) - expect(fix()!.querySelector('.extAgents-cmd code')!.textContent).toBe(command) - expect(fix()!.querySelector('.extAgents-raw')!.textContent).toContain(said) - /* The card says the same thing in a line, and points at the sheet. */ - const what = say('gui.agent.bad_download', { agent: 'Claude Code' }) - expect(lineOf('Claude Code')).toBe(say('gui.agent.bad_open', { what })) + expect(title()).toBe(say('gui.agent.bad_download', { agent: 'Claude Code' })) + expect(lead()).toBe(say('gui.agent.fix_download', { agent: 'Claude Code', button: 'gui.retry' })) + expect(note()!.querySelector('.extAgents-cmd code')!.textContent).toBe(command) + expect(raw()!.textContent).toContain(said) + /* The card keeps its line about the agent and says the rest in a footer: + what failed, the short reason, and the retry. */ + expect(lineOf('Claude Code')).toBe('gui.agent.short_claude_code') + expect(footOf('Claude Code')).toEqual({ k: 'gui.agent.st_connect_bad', r: 'gui.agent.why_download' }) }) it('offers no command for a download when the row\'s command is its own, not the preset\'s', async () => { @@ -846,23 +933,25 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('my-agent') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.connect')) - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.fix_download_bare', { agent: 'my-agent', button: 'gui.retry' })) - expect(fix()!.querySelector('.extAgents-cmd')).toBeNull() + expect(lead()).toBe(say('gui.agent.fix_download_bare', { agent: 'my-agent', button: 'gui.retry' })) + expect(note()!.querySelector('.extAgents-cmd')).toBeNull() }) it.each([ - ['sign_in', 'gui.agent.bad_sign_in'], - ['setup', 'gui.agent.bad_setup'], - ['api_key', 'gui.agent.bad_api_key'], - ['download', 'gui.agent.bad_download'], - ['model', 'gui.agent.bad_model'], - ['billing', 'gui.agent.bad_billing'], - ['quota', 'gui.agent.bad_quota'], - ['network', 'gui.agent.bad_network'], - ['silent', 'gui.agent.bad_silent'], - ['upgrade', 'gui.agent.bad_upgrade'], - ['exited', 'gui.agent.bad_exited'], - ['runtime', 'gui.agent.bad_runtime'], + ['sign_in', 'gui.agent.why_sign_in'], + ['setup', 'gui.agent.why_setup'], + ['api_key', 'gui.agent.why_api_key'], + ['download', 'gui.agent.why_download'], + ['model', 'gui.agent.why_model'], + ['billing', 'gui.agent.why_billing'], + ['quota', 'gui.agent.why_quota'], + ['network', 'gui.agent.why_network'], + ['silent', 'gui.agent.why_silent'], + ['upgrade', 'gui.agent.why_upgrade'], + ['exited', 'gui.agent.why_exited'], + /* Sent without its two versions, a runtime fix names no Node.js, and the + card says what the sheet does: a launch that quit. */ + ['runtime', 'gui.agent.why_exited'], ])('puts a %s fix on the card as a line in the reader\'s words', async (kind, key) => { const r = row({ name: 'Codex', preset: 'codex', configured: false, enabled: false }) install([r], { @@ -873,8 +962,10 @@ describe('a refusal that names its fix', () => { }) await mount() await click(buttonOf('Codex')) - expect(lineOf('Codex')).toBe(say('gui.agent.bad_open', { what: say(key, { agent: 'Codex' }) })) + expect(lineOf('Codex')).toBe('gui.agent.short_codex') + expect(footOf('Codex')).toEqual({ k: 'gui.agent.st_connect_bad', r: key }) expect(rowNamed('Codex').textContent).not.toContain('the English sentence') + expect(controlOf('Codex')).toBe('gui.retry') }) /* Qwen Code's fixes are typed at its own prompt: `qwen` alone leaves the @@ -897,13 +988,13 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('Qwen Code') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.connect')) - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.fix_model', { agent: 'Qwen Code', button: 'gui.retry' })) - const steps = [...fix()!.querySelectorAll('.extAgents-steps li')] + expect(lead()).toBe(say('gui.agent.fix_model', { agent: 'Qwen Code', button: 'gui.retry' })) + const steps = [...note()!.querySelectorAll('.extAgents-steps li')] expect(steps.map((li) => li.firstChild!.textContent)).toEqual(['gui.agent.fix_step_run', 'gui.agent.fix_step_type']) expect(steps.map((li) => li.querySelector('.extAgents-cmd code')!.textContent)).toEqual(['qwen', '/auth']) await click(steps[1]!.querySelector('.extAgents-cmd button')) expect(writeText).toHaveBeenCalledWith('/auth') - expect(fix()!.querySelector('.extAgents-raw')!.textContent).toContain(said) + expect(raw()!.textContent).toContain(said) }) it('says an agent with no provider yet is set up at its own prompt, in the same two steps', async () => { @@ -919,9 +1010,11 @@ describe('a refusal that names its fix', () => { ]) await mount() await openSheet('Qwen Code') - const lead = say('gui.agent.fix_setup_then', { agent: 'Qwen Code', button: 'gui.agent.test_label' }) - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.hd_test_bad', { detail: lead })) - expect([...fix()!.querySelectorAll('.extAgents-steps code')].map((c) => c.textContent)).toEqual(['qwen', '/auth']) + /* The note names the press the action bar offers: a connected row's says + "again", since its last test is why the note is there. */ + expect(title()).toBe(say('gui.agent.bad_setup', { agent: 'Qwen Code' })) + expect(lead()).toBe(say('gui.agent.fix_setup_then', { agent: 'Qwen Code', button: 'gui.agent.test_again' })) + expect([...note()!.querySelectorAll('.extAgents-steps code')].map((c) => c.textContent)).toEqual(['qwen', '/auth']) }) it.each([ @@ -941,9 +1034,9 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('Qwen Code') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.connect')) - expect(fix()!.firstElementChild!.textContent).toBe(say(key, { agent: 'Qwen Code', button: 'gui.retry' })) - expect([...fix()!.querySelectorAll('.extAgents-cmd code')].map((c) => c.textContent)).toEqual([command]) - expect(fix()!.querySelector('.extAgents-steps')).toBeNull() + expect(lead()).toBe(say(key, { agent: 'Qwen Code', button: 'gui.retry' })) + expect([...note()!.querySelectorAll('.extAgents-cmd code')].map((c) => c.textContent)).toEqual([command]) + expect(note()!.querySelector('.extAgents-steps')).toBeNull() }) it.each([ @@ -967,9 +1060,9 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('my-agent') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.connect')) - expect(fix()!.firstElementChild!.textContent).toBe(say(key, { agent: 'my-agent', button: 'gui.retry' })) - expect(fix()!.querySelector('.extAgents-cmd')).toBeNull() - expect(fix()!.querySelector('.extAgents-raw')!.textContent).toContain('the English sentence') + expect(lead()).toBe(say(key, { agent: 'my-agent', button: 'gui.retry' })) + expect(note()!.querySelector('.extAgents-cmd')).toBeNull() + expect(raw()!.textContent).toContain('the English sentence') }) /* Measured on Qwen Code 0.24.4 under Node 18.20.8: it dies at import with a @@ -989,8 +1082,10 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('Qwen Code') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.connect')) - expect(fix()!.firstElementChild!.textContent).toBe(say(key, { agent: 'Qwen Code', button: 'gui.retry', needs: '22', found: '18.20.8' })) - expect([...fix()!.querySelectorAll('.extAgents-cmd code')].map((c) => c.textContent)).toEqual(command ? [command] : []) + expect(title()).toBe(say('gui.agent.bad_runtime', { agent: 'Qwen Code' })) + expect(lead()).toBe(say(key, { agent: 'Qwen Code', button: 'gui.retry', needs: '22', found: '18.20.8' })) + expect([...note()!.querySelectorAll('.extAgents-cmd code')].map((c) => c.textContent)).toEqual(command ? [command] : []) + expect(footOf('Qwen Code')).toEqual({ k: 'gui.agent.st_connect_bad', r: 'gui.agent.why_runtime' }) }) it('says only that it quit when the versions did not come with the fix', async () => { @@ -1004,7 +1099,11 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('Qwen Code') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.connect')) - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.fix_exited', { agent: 'Qwen Code', button: 'gui.retry' })) + /* The title and the card say the same as the sentence, not a Node.js + they cannot name. */ + expect(title()).toBe(say('gui.agent.bad_exited', { agent: 'Qwen Code' })) + expect(lead()).toBe(say('gui.agent.fix_exited', { agent: 'Qwen Code', button: 'gui.retry' })) + expect(footOf('Qwen Code')).toEqual({ k: 'gui.agent.st_connect_bad', r: 'gui.agent.why_exited' }) }) it('says a failure with no fix in the reader\'s words too, and folds the server\'s sentence', async () => { @@ -1014,13 +1113,14 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('Claude Code') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.connect')) - expect(fix()!.firstElementChild!.textContent).toBe( - say('gui.agent.fix_unknown_connect', { agent: 'Claude Code', button: 'gui.retry' }), - ) - expect(fix()!.querySelector('.extAgents-cmd')).toBeNull() - expect((fix()!.querySelector('.extAgents-raw') as HTMLDetailsElement).open).toBe(false) - expect(fix()!.querySelector('.extAgents-raw')!.textContent).toContain(said) - expect(lineOf('Claude Code')).toBe(say('gui.agent.bad_open', { what: say('gui.agent.bad_connect', { agent: 'Claude Code' }) })) + /* No fix named, so the server's sentence is the reason, and it is shown as + it came rather than folded. */ + expect(title()).toBe(say('gui.agent.bad_connect', { agent: 'Claude Code' })) + expect(lead()).toBe(say('gui.agent.said_connect', { button: 'gui.retry' })) + expect(note()!.querySelector('.extAgents-cmd')).toBeNull() + expect(raw()).toBeNull() + expect(note()!.querySelector('.extAgents-note-said')!.textContent).toBe(said) + expect(footOf('Claude Code')).toEqual({ k: 'gui.agent.st_connect_bad', r: 'gui.agent.why_open' }) }) it('says a refused disconnect did not disconnect, on the card and in the sheet', async () => { @@ -1029,8 +1129,10 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('Codex') await click([...sheet()!.querySelectorAll('.extAgents-act button')].find((b) => b.textContent === 'gui.agent.disconnect')) - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.fix_unknown_disconnect', { agent: 'Codex', button: 'gui.retry' })) - expect(lineOf('Codex')).toBe(say('gui.agent.bad_open', { what: say('gui.agent.bad_disconnect', { agent: 'Codex' }) })) + expect(sheetStatus()).toBe('gui.agent.st_disconnect_bad · OpenAI') + expect(title()).toBe(say('gui.agent.bad_disconnect', { agent: 'Codex' })) + expect(lead()).toBe(say('gui.agent.said_disconnect', { button: 'gui.retry' })) + expect(footOf('Codex')).toEqual({ k: 'gui.agent.st_disconnect_bad', r: 'gui.agent.why_open' }) }) it('says a refused edit was not saved, rather than that the agent did not connect', async () => { @@ -1040,9 +1142,11 @@ describe('a refusal that names its fix', () => { await act(async () => { await store.setModel(r, 'x') }) - expect(lineOf('Codex')).toBe(say('gui.agent.bad_open', { what: say('gui.agent.bad_save', { agent: 'Codex' }) })) + expect(footOf('Codex')).toEqual({ k: 'gui.agent.st_save_bad', r: 'gui.agent.why_open' }) await openSheet('Codex') - expect(fix()!.firstElementChild!.textContent).toBe(say('gui.agent.fix_unknown_save', { agent: 'Codex', button: 'gui.retry' })) + expect(title()).toBe('gui.agent.bad_save') + expect(lead()).toBe('gui.agent.said_save') + expect(note()!.querySelector('.extAgents-note-said')!.textContent).toBe('model x is not on the menu') }) }) diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.tsx index e15cb2656..b9c18b116 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.tsx @@ -9,8 +9,8 @@ import * as lang from '../../state/lang' import { defaultProviders as hostProviders, loadDefaultProviders } from '../model/source' import { offered, withCurrent } from '../model/types' import { byOf, installOf, isOwnRow } from './catalogue' -import { healthOf, ledClass, pendingLabel, refusedWrite, shownOf } from './health' -import { CardGrid, Spin, Tile, connect, ordered } from './Rows' +import { badOf, healthOf, kindOf, ledClass, pendingLabel, refusedWrite, shownOf } from './health' +import { CardGrid, Spin, Tile, WaitGrid, ago, connect, ordered, refusedLabel } from './Rows' import { sectionOf, stageOf } from './source' import * as store from './store' @@ -349,32 +349,78 @@ const FIX_SAY: Record< config: (v, command) => (command ? t('gui.agent.fix_config', v) : t('gui.agent.fix_config_bare', v)), } -/* A refusal, and the fix when the server named one. The server's sentence is - English and written for a log; a remedy is the same verdict as data, so here - it is said in the reader's language -- what is missing, the command that - supplies it on a line of its own, and the button to press after -- with the - agent's own words folded under it instead of put first. A fix made inside - the agent is two numbered steps, the command that opens it and what to type - there, each with its own copy button: told only to run `qwen`, a reader is - left at its prompt with nothing saying what next. Without a remedy the - reader is still told in their own language that it failed and what to press - (`unknown`), and the server's sentence is folded the same way: shown first it - was a red English paragraph, and all a reader could do with it was copy it. */ -function Refusal({ agent, detail, remedy, button, unknown, lead = (say) => say }: { - agent: string - detail: string - remedy: Remedy | null - button: string - unknown: string - lead?: (say: string) => string -}): JSX.Element { - const command = remedy && remedy.kind !== 'api_key' ? remedy.command : '' - const then = command ? remedy?.then || '' : '' - const say = remedy ? FIX_SAY[remedy.kind]({ agent, button }, !!command, !!then, remedy) : unknown +/* What to do about the last thing that went wrong, as the block at the top of + the sheet's body draws it. The head only says what state the agent is in; + this is the why and the how. */ +interface NoteSpec { + title: string + /* How long ago, for a remembered test verdict. */ + when?: string + lead: string + command?: string + /* What to type once `command` runs, when the fix is a step inside the agent: + the note then draws two numbered steps, each with its own copy button. */ + then?: string + /* The server's own sentence. Folded under the lead when the lead already + explains it (a classified refusal); shown as it came when it is all there + is to go on. */ + raw: string + folded: boolean +} + +/* A refusal the server classified: what is missing, in the reader's language, + the command that supplies it on a line of its own, and the button to press + after -- with the agent's own words folded under, since the sentence is + English and written for a log. Null when the server named no fix. */ +function remedied(agent: string, remedy: Remedy | null, button: string, raw: string): NoteSpec | null { + if (!remedy) return null + const command = remedy.kind === 'api_key' ? '' : remedy.command + const then = command ? remedy.then || '' : '' + const lead = FIX_SAY[remedy.kind]({ agent, button }, !!command, !!then, remedy) + return { title: badOf(kindOf(remedy), agent), lead, command, then, raw, folded: true } +} + +/* The block for this row, or none. A write this page refused comes first; then + a test the server remembers failing, which the sheet reports from the + unauthorized state too, since Test is offered there and its failure would + otherwise leave no trace. Without a fix the reader is told in their own + language what failed and what to press, and the server's sentence is shown + as it came: it is the only reason there is, and folding it away made the + block say nothing. */ +function noteOf(row: ExtAgentRow, s: ExtAgentsState, shown: Shown): NoteSpec | null { + const agent = row.name + const failed = s.failed[row.name] + if (failed) { + const button = t('gui.retry') + const spec = remedied(agent, failed.remedy || null, button, failed.detail) + if (spec) return spec + const write = refusedWrite(failed) + return { + title: write === 'save' ? t('gui.agent.bad_save') : t(write === 'disconnect' ? 'gui.agent.bad_disconnect' : 'gui.agent.bad_connect', { agent }), + lead: write === 'save' ? t('gui.agent.said_save') : t(write === 'disconnect' ? 'gui.agent.said_disconnect' : 'gui.agent.said_connect', { button }), + raw: failed.detail, + folded: false, + } + } + if (shown === 'pending' || shown === 'missing' || row.last_test_ok !== false) return null + /* The press named is the one the action bar offers: a connected row's says + "again", since its last test is the reason this block is here. */ + const button = t(shown === 'on' ? 'gui.agent.test_again' : 'gui.agent.test_label') + const when = ago(row.last_test_at_ms) + const spec = remedied(agent, row.last_test_remedy || null, button, row.last_test_detail || '') + if (spec) return { ...spec, when } + return { title: t('gui.agent.st_test_bad'), when, lead: t('gui.agent.said_test', { button }), raw: row.last_test_detail || '', folded: false } +} + +function Note({ title, when, lead, command, then, raw, folded }: NoteSpec): JSX.Element { return ( -
-
{lead(say)}
- {then ? ( +
+
+ {title} + {when ? {when} : null} +
+
{lead}
+ {command && then ? (
  1. {t('gui.agent.fix_step_run')} @@ -388,12 +434,14 @@ function Refusal({ agent, detail, remedy, button, unknown, lead = (say) => say } ) : command ? ( ) : null} - {detail ? ( -
    + {!raw ? null : folded ? ( +
    {t('gui.agent.fix_raw')} - {detail} +
    {raw}
    - ) : null} + ) : ( +
    {raw}
    + )}
) } @@ -414,42 +462,22 @@ function StatusLine({ row, shown, s }: { row: ExtAgentRow; shown: Shown; s: ExtA
) } + /* The head says only which state: the why and the how are the block at the + top of the body (`noteOf`). */ if (health.from === 'write') { const failed = s.failed[row.name] - const write = failed ? refusedWrite(failed) : 'connect' - const button = t('gui.retry') return ( -
+
{led} - + {[failed ? refusedLabel(failed) : t('gui.agent.st_connect_bad'), by].filter(Boolean).join(' · ')}
) } if (health.from === 'test') { return ( -
+
{led} - t('gui.agent.hd_test_bad', { detail: say })} - /> + {shown === 'on' ? t('gui.agent.hd_on_test_bad') : [t('gui.agent.st_test_bad'), by].filter(Boolean).join(' · ')}
) } @@ -495,6 +523,7 @@ function AgentSheet({ row, s }: { row: ExtAgentRow; s: ExtAgentsState }): JSX.El const shown = shownOf(row, s) const stage = stageOf(row) const testing = s.testing.includes(row.name) || row.test_running + const note = noteOf(row, s, shown) const saveKey = (): void => { const api_key = keyRef.current ? keyRef.current.value.trim() : '' store.saveKey(row, api_key) @@ -532,7 +561,7 @@ function AgentSheet({ row, s }: { row: ExtAgentRow; s: ExtAgentsState }): JSX.El ) : canTest(row) ? ( ) : null} @@ -587,6 +616,7 @@ function AgentSheet({ row, s }: { row: ExtAgentRow; s: ExtAgentsState }): JSX.El ) : ( <> + {note ? : null} = { all: [], on: by('on'), avail: by('avail'), missing: by('missing') } rows.all = [...rows.on, ...rows.avail, ...rows.missing] const current = TABS.find((x) => x.tab === tab)! + /* Before the first answer only: a later reload keeps showing the rows it + has, the way the wizard's step does. */ + const scanning = s.loading && s.rows.length === 0 const sheetRow = s.sheet ? s.rows.find((x) => x.name === s.sheet) : undefined return ( <> @@ -658,11 +691,11 @@ export function ExtAgentsApp(): JSX.Element { type="button" > {t(x.label)} - {String(rows[x.tab].length)} + {scanning ? : {String(rows[x.tab].length)}} ))}
- + {scanning ? : } {sheetRow ? : null} ) diff --git a/ui-web/src/features/extAgents/Rows.tsx b/ui-web/src/features/extAgents/Rows.tsx index 5cea869a9..81be2dfba 100644 --- a/ui-web/src/features/extAgents/Rows.tsx +++ b/ui-web/src/features/extAgents/Rows.tsx @@ -16,15 +16,15 @@ import { AgentMark } from '../../components/AgentMark' import { Glyph } from '../../components/Ico' import { t } from '../../i18n/t' import { ask as confirmAsk } from '../../state/confirm' -import { isOwnRow, shortOf } from './catalogue' -import { healthOf, ledClass, pendingLabel, shownOf, whatFailed } from './health' +import { catalogueSize, isOwnRow, shortOf } from './catalogue' +import { healthOf, kindOf, ledClass, pendingLabel, refusedWrite, shownOf } from './health' import { stageOf } from './source' import * as store from './store' import type { Health, Shown } from './health' import type { ExtAgentsState, Failure } from './store' -import type { ExtAgentRow } from './types' -import type { JSX } from 'react' +import type { ExtAgentRow, Remedy } from './types' +import type { JSX, ReactNode } from 'react' const kindText = (kind: string): string => t( @@ -77,30 +77,184 @@ function oneLine(row: ExtAgentRow): string { return stale ? `${base} · ${stale}` : base } -/* The red line itself. A card or row that opens a sheet points there, where - the steps and the original error are. The wizard's rows open nothing, so - theirs has to carry the step: the command, when it is one to run in a - terminal, what to type in it when the fix is inside the agent, and the - retry to press after. A command that only makes the agent say why it is - failing is named as that, not as the fix. */ -function failureLine(row: ExtAgentRow, failed: Failure, opens: boolean): string { - const what = whatFailed(row, failed) - if (opens) return t('gui.agent.bad_open', { what }) - const button = t('gui.retry') - const kind = failed.remedy?.kind - /* The adapter's own command is too long for a row, and what fixes a - download is the network anyway, so the row names where to look. */ - if (kind === 'download') return t('gui.agent.bad_download_retry', { what, button }) - /* A plan's `command` is the page that sells one: opened in a browser, not run - in a terminal, and the row is the only place a wizard reader sees it. */ - if (kind === 'plan' && failed.remedy?.command) { - return t('gui.agent.bad_plan_link', { what, command: failed.remedy.command, button }) +/* The one word for what went wrong, by the write that was refused. */ +export function refusedLabel(failed: Failure): string { + const write = refusedWrite(failed) + return t(write === 'save' ? 'gui.agent.st_save_bad' : write === 'disconnect' ? 'gui.agent.st_disconnect_bad' : 'gui.agent.st_connect_bad') +} + +/* The short reason, by the fix the server named. Spelled out rather than built + from the kind, so the i18n gate can see every key. */ +const WHY: Record = { + sign_in: 'gui.agent.why_sign_in', + setup: 'gui.agent.why_setup', + api_key: 'gui.agent.why_api_key', + download: 'gui.agent.why_download', + model: 'gui.agent.why_model', + billing: 'gui.agent.why_billing', + quota: 'gui.agent.why_quota', + network: 'gui.agent.why_network', + silent: 'gui.agent.why_silent', + upgrade: 'gui.agent.why_upgrade', + exited: 'gui.agent.why_exited', + runtime: 'gui.agent.why_runtime', + plan: 'gui.agent.why_plan', + config: 'gui.agent.why_config', +} + +export function whyOf(remedy: Remedy): string { + return t(WHY[kindOf(remedy)]) +} + +/* How long ago a verdict was measured, in the largest unit that is not zero. */ +export function ago(atMs: number | null, now = Date.now()): string { + if (!atMs) return '' + const m = Math.floor((now - atMs) / 60_000) + if (m < 1) return t('gui.time.ago_now') + if (m < 60) return t('gui.time.ago_m', { n: m }) + const h = Math.floor(m / 60) + if (h < 24) return t('gui.time.ago_h', { n: h }) + return t('gui.time.ago_d', { n: Math.floor(h / 24) }) +} + +/* What a card or row says about the last thing that went wrong, under the + line about the agent rather than instead of it: one word for what failed, a + short reason, and the press that answers it (the card's corner, a row's + button). Two sources, in this order: a + write this page refused (held on the page, gone with the next write on the + row), then a test the server remembered failing (on the row, so it survives + a reload). A test under way shows as such, so the press cannot be pressed + twice. Nothing for a row whose state already says it all: pending, absent, + or waiting on a sign-in. */ +export interface Verdict { + label: string + /* The short reason, or '' when the label is the whole of it. */ + why: string + /* The server's own sentence, for a row with no sheet to fold it into. */ + raw: string + /* The fix the server named, when it did. */ + remedy: Remedy | null + act: string + onAct: () => void + busy?: boolean + /* Nothing wrong: the line says how the row is doing, with no wash. */ + quiet?: boolean +} + +/* What a card says when there is nothing to answer: which state it is in, + in the words the tabs use, so the slot under the line is never blank and a + grid reads the same whether or not a card is in trouble. A passed test says + when it passed. Not the vendor: the mark beside the name already says that. */ +export function quietOf(row: ExtAgentRow, shown: Shown, health: Health): Verdict { + let label: string + if (health.tone === 'warn') { + label = health.label + } else if (shown === 'on') { + label = row.last_test_ok ? [t('gui.agent.hd_on_tested'), ago(row.last_test_at_ms)].filter(Boolean).join(' · ') : t('gui.agent.g_on') + } else if (shown === 'missing') { + label = t('gui.agent.g_missing') + } else { + label = t('gui.agent.g_avail') + } + return { label, why: '', raw: '', remedy: null, act: '', onAct: () => {}, quiet: true } +} + +function busyVerdict(label: string): Verdict { + return { label, why: '', raw: '', remedy: null, act: '', onAct: () => {}, busy: true } +} + +export function verdictOf(row: ExtAgentRow, s: ExtAgentsState, shown: Shown, opens: boolean): Verdict | null { + if (shown === 'pending' || shown === 'missing') return null + const failed = s.failed[row.name] + if (failed) { + return { + label: refusedLabel(failed), + why: failed.remedy ? whyOf(failed.remedy) : t(opens ? 'gui.agent.why_open' : 'gui.agent.why_hover'), + raw: failed.detail, + remedy: failed.remedy || null, + act: t('gui.retry'), + onAct: () => store.retry(row), + } + } + if (stageOf(row) === 'unauthorized') return null + if (s.testing.includes(row.name) || row.test_running) return busyVerdict(t('gui.agent.testing')) + if (row.last_test_ok === false) { + return { + label: t('gui.agent.st_test_bad'), + why: row.last_test_remedy ? whyOf(row.last_test_remedy) : ago(row.last_test_at_ms), + raw: row.last_test_detail, + remedy: row.last_test_remedy || null, + act: t('gui.agent.test_again_short'), + onAct: () => void store.runTest(row), + } + } + return null +} + +/* The verdict as a card's footer: a strip under the line about the agent. + It only says; the press that answers it is the card's corner control. */ +function Foot({ v }: { v: Verdict }): JSX.Element { + return ( +
+ + + {v.busy ? : null} + {v.label} + + {v.why ? {v.why} : null} + +
+ ) +} + +/* Private-use characters: never in a catalogue string, and left alone by the + JSON the test double renders its arguments with. */ +const MARK = '\uE000' +const MARK_THEN = '\uE001' + +/* A sentence with commands in it, as text with each command a piece of code: + split around markers the catalogue cannot contain. */ +function withCode(sentence: string, codes: Record): ReactNode { + return sentence.split(/(\uE000|\uE001)/).map((part, i) => + part in codes ? {codes[part]} : part, + ) +} + +/* The verdict as a row's second line. A row with no sheet has to carry the + step itself: the command to run as a piece of code -- and what to type once + it runs, when the fix is inside the agent; a command that only makes the + agent say why it fails is named as that -- or where to look for a download; + and the server's sentence on hover, when there is no fix. */ +function RowVerdict({ v, opens }: { v: Verdict; opens: boolean }): JSX.Element { + const kind = v.remedy?.kind + const command = !opens && kind && kind !== 'api_key' && kind !== 'download' ? v.remedy?.command || '' : '' + const then = command ? v.remedy?.then || '' : '' + let why: ReactNode = v.why + if (command) { + const vars = then ? { why: v.why, command: MARK, then: MARK_THEN, button: v.act } : { why: v.why, command: MARK, button: v.act } + /* A plan's `command` is the page that sells one, opened in a browser; a + config's and a network's only make the agent say what is wrong. */ + const key = + kind === 'plan' + ? 'gui.agent.row_open' + : then + ? 'gui.agent.row_run_then' + : kind === 'network' || kind === 'silent' || kind === 'config' + ? 'gui.agent.row_run_diagnose' + : 'gui.agent.row_run' + why = withCode(t(key, vars), { [MARK]: command, [MARK_THEN]: then }) + } else if (!opens && kind === 'download') { + why = t('gui.agent.row_download', { button: v.act }) } - const command = kind && kind !== 'api_key' ? failed.remedy?.command : '' - if (!command) return t('gui.agent.bad_retry', { what, button }) - if (kind === 'network' || kind === 'silent') return t('gui.agent.bad_run_diagnose', { what, command, button }) - const then = failed.remedy?.then - return then ? t('gui.agent.bad_run_then', { what, command, then, button }) : t('gui.agent.bad_run', { what, command, button }) + return ( +
+ + {v.busy ? : null} + {v.label} + + {v.why ? {why} : null} +
+ ) } /* Connect, by what the row's stage calls for. The one case with a question in @@ -129,7 +283,7 @@ function RowControl({ row, s, shown }: { row: ExtAgentRow; s: ExtAgentsState; sh if (shown === 'pending') return {t(pendingLabel(row, s))} if (shown === 'failed') { return ( - ) @@ -173,8 +327,8 @@ function AgentRow({ }: { row: ExtAgentRow; s: ExtAgentsState; onOpen?: (row: ExtAgentRow) => void }): JSX.Element { const shown = shownOf(row, s) const health = healthOf(row, s) - const failed = s.failed[row.name] const open = onOpen ? (): void => onOpen(row) : undefined + const verdict = verdictOf(row, s, shown, !!open) return (
{t(pendingLabel(row, s))}
- ) : shown === 'failed' && failed ? ( - /* With no sheet to fold it into, the server's sentence -- all a row - with no named fix has to say why -- is kept on hover, not shown. */ -
- {failureLine(row, failed, !!open)} -
+ ) : verdict ? ( + ) : (
{oneLine(row)}
)} @@ -222,15 +372,15 @@ function AgentRow({ ) } +const RETRY = 'M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4' const PLUS = 'M12 5v14M5 12h14' const DOWNLOAD = 'M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19h14' -const RETRY = 'M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4' -/* The card's corner control: Connect, Install, Retry, or the sign-in the - agent wants. A connected card carries none -- Disconnect is in the sheet, - one click away and out of reach of a stray click -- and a write in flight is - said by the line itself. */ -function CardControl({ row, shown }: { row: ExtAgentRow; shown: Shown }): JSX.Element | null { +/* The corner holds the one press the card calls for, so every press on a + card is in the same place: connect, install, and after a refusal the retry; + on a connected agent whose last test failed, the retest. A card still to + connect keeps its connect over a retest, since connecting tests it anyway. */ +function CardControl({ row, shown, verdict }: { row: ExtAgentRow; shown: Shown; verdict: Verdict | null }): JSX.Element | null { const iconButton = (label: string, d: string, onClick: () => void, cls = ''): JSX.Element => ( ) - if (shown === 'failed') return iconButton(t('gui.retry'), RETRY, () => store.retry(row), 'extAgents-cbtn-bad') + if (shown === 'failed' && verdict) return iconButton(verdict.act, RETRY, verdict.onAct) if (shown === 'missing') return iconButton(t('gui.agent.go_install'), DOWNLOAD, () => store.sheetOpen(row)) + if (shown === 'on' && verdict && !verdict.busy && !verdict.quiet) return iconButton(verdict.act, RETRY, verdict.onAct) if (shown !== 'off') return null if (stageOf(row) === 'unauthorized') return {t('gui.agent.unauthorized')} return iconButton(t('gui.agent.connect'), PLUS, () => connect(row)) } +/* A card's own write in flight wears the same amber strip as a test under + way, so the line about the agent stays put. The slot under the line is + always filled -- a quiet word on how the row is doing when nothing is + wrong -- so a card is the same height before a press, during it and after a + refusal. */ function AgentCard({ row, s }: { row: ExtAgentRow; s: ExtAgentsState }): JSX.Element { const shown = shownOf(row, s) const health = healthOf(row, s) - const failed = s.failed[row.name] + const verdict = shown === 'pending' ? busyVerdict(t(pendingLabel(row, s))) : verdictOf(row, s, shown, true) || quietOf(row, shown, health) const open = (): void => store.sheetOpen(row) return (
e.stopPropagation()}> - +
- {shown === 'pending' ? ( -
- - {t(pendingLabel(row, s))} +
{oneLine(row)}
+
+ +
+
+ ) +} + +/* The grid before the first answer: one card of the fixed shape a real card + has per catalogue entry, which is the roster on every machine, so it lands + in place instead of after an empty message and a jump. Bars, not rows: a + card would be claiming a state the server has not confirmed. */ +export function WaitGrid(): JSX.Element { + return ( +
+ {Array.from({ length: catalogueSize() }, (_, i) => ( +
+
+ + +
+ +
+ +
- ) : shown === 'failed' && failed ? ( -
{failureLine(row, failed, true)}
- ) : ( -
{oneLine(row)}
- )} + ))}
) } diff --git a/ui-web/src/features/extAgents/__snapshots__/ExtAgentsPage.test.tsx.snap b/ui-web/src/features/extAgents/__snapshots__/ExtAgentsPage.test.tsx.snap index 6f5fcc9bf..565bd1b0d 100644 --- a/ui-web/src/features/extAgents/__snapshots__/ExtAgentsPage.test.tsx.snap +++ b/ui-web/src/features/extAgents/__snapshots__/ExtAgentsPage.test.tsx.snap @@ -25,6 +25,10 @@ div.extAgents-grid span.extAgents-led div.extAgents-ctl div.extAgents-one + div.extAgents-foot-slot + div.extAgents-foot.extAgents-foot-quiet + span.extAgents-foot-say + span.extAgents-foot-k div.extAgents-card div.extAgents-ctop span.extAgents-tile @@ -35,6 +39,10 @@ div.extAgents-grid span.extAgents-led div.extAgents-ctl div.extAgents-one + div.extAgents-foot-slot + div.extAgents-foot.extAgents-foot-quiet + span.extAgents-foot-say + span.extAgents-foot-k div.extAgents-card div.extAgents-ctop span.extAgents-tile @@ -47,6 +55,10 @@ div.extAgents-grid svg path div.extAgents-one + div.extAgents-foot-slot + div.extAgents-foot.extAgents-foot-quiet + span.extAgents-foot-say + span.extAgents-foot-k div.extAgents-card div.extAgents-ctop span.extAgents-tile @@ -58,7 +70,11 @@ div.extAgents-grid button.extAgents-cbtn svg path - div.extAgents-one" + div.extAgents-one + div.extAgents-foot-slot + div.extAgents-foot.extAgents-foot-quiet + span.extAgents-foot-say + span.extAgents-foot-k" `; exports[`the sheet > keeps its rendered shape, sheet 1`] = ` diff --git a/ui-web/src/features/extAgents/catalogue.ts b/ui-web/src/features/extAgents/catalogue.ts index 2b6d6a84c..fe5fba1f0 100644 --- a/ui-web/src/features/extAgents/catalogue.ts +++ b/ui-web/src/features/extAgents/catalogue.ts @@ -107,6 +107,11 @@ function entryOf(row: ExtAgentRow): CatalogueEntry | undefined { server that predates `own`: an install that registered a shipped product as a config row (its `install.py` does that) carries no flag there, and the row is still Raven's. */ +/* How many rows a roster has before the server has said: the catalogue is + the roster on every machine -- Raven's own agents and every preset, whether + or not it is installed -- and only a hand-written agent adds to it. */ +export const catalogueSize = (): number => Object.keys(CATALOGUE).length + export function isOwnRow(row: ExtAgentRow): boolean { return !!row.own || !!row.builtin || !!row.vendored || entryOf(row)?.by === OWN_BY } diff --git a/ui-web/src/features/extAgents/health.ts b/ui-web/src/features/extAgents/health.ts index ac41aafe8..d95ce393b 100644 --- a/ui-web/src/features/extAgents/health.ts +++ b/ui-web/src/features/extAgents/health.ts @@ -16,7 +16,7 @@ import { sectionOf } from './source' import * as store from './store' import type { ExtAgentsState, Failure } from './store' -import type { ExtAgentRow } from './types' +import type { ExtAgentRow, Remedy } from './types' /* The states a row and its sheet are drawn in. `pending` and `failed` are this page's own, about a write in flight or refused; the other three are the @@ -52,14 +52,10 @@ export function refusedWrite(failed: Failure): 'connect' | 'disconnect' | 'save' return failed.op === 'model' || failed.op === 'update' ? 'save' : 'connect' } -/* What a refused write comes to, in the reader's language: the fix the server - named, by its kind, or what failed when it named none. The server's own - sentence is English, and cut to the two lines a card has it said neither - what went wrong nor what to do -- so it is shown only in the sheet, folded - under the fix. */ -export function whatFailed(row: ExtAgentRow, failed: Failure): string { - const agent = row.name - const kind = failed.remedy?.kind +/* What a named fix is about, as a title: the sheet heads its note with it for + a refused write and for a failed test alike. Every key is written out, so + the catalogue gate reads each one. */ +export function badOf(kind: Remedy['kind'], agent: string): string { if (kind === 'sign_in') return t('gui.agent.bad_sign_in', { agent }) if (kind === 'setup') return t('gui.agent.bad_setup', { agent }) if (kind === 'api_key') return t('gui.agent.bad_api_key', { agent }) @@ -70,10 +66,27 @@ export function whatFailed(row: ExtAgentRow, failed: Failure): string { if (kind === 'network') return t('gui.agent.bad_network', { agent }) if (kind === 'silent') return t('gui.agent.bad_silent', { agent }) if (kind === 'upgrade') return t('gui.agent.bad_upgrade', { agent }) - if (kind === 'exited') return t('gui.agent.bad_exited', { agent }) if (kind === 'runtime') return t('gui.agent.bad_runtime', { agent }) if (kind === 'plan') return t('gui.agent.bad_plan', { agent }) if (kind === 'config') return t('gui.agent.bad_config', { agent }) + return t('gui.agent.bad_exited', { agent }) +} + +/* The kind a remedy is read as. A `runtime` fix without both versions would + name no Node.js, so what is left of it is a launch that quit -- the sentence + `FIX_SAY` gives it -- and the title and a card's reason say the same. */ +export function kindOf(remedy: Remedy): Remedy['kind'] { + return remedy.kind === 'runtime' && (!remedy.needs || !remedy.found) ? 'exited' : remedy.kind +} + +/* What a refused write comes to, in the reader's language: the fix the server + named, by its kind, or what failed when it named none. The server's own + sentence is English, and cut to the two lines a card has it said neither + what went wrong nor what to do -- so it is shown only in the sheet, folded + under the fix. */ +export function whatFailed(row: ExtAgentRow, failed: Failure): string { + const agent = row.name + if (failed.remedy) return badOf(kindOf(failed.remedy), agent) const write = refusedWrite(failed) return t(write === 'save' ? 'gui.agent.bad_save' : write === 'disconnect' ? 'gui.agent.bad_disconnect' : 'gui.agent.bad_connect', { agent, diff --git a/ui-web/src/features/extAgents/styles.css b/ui-web/src/features/extAgents/styles.css index 8253c648b..02a3b13a1 100644 --- a/ui-web/src/features/extAgents/styles.css +++ b/ui-web/src/features/extAgents/styles.css @@ -48,7 +48,7 @@ /* The brand mark is the tile: the shared component, sized inside this box. */ .extAgents-tile { flex: none; display: grid; place-items: center; } -.extAgents-tile .agent-mark { width: 36px; height: 36px; border-radius: 10px; background: var(--paper); border: 1px solid var(--line); } +.extAgents-tile .agent-mark { width: 36px; height: 36px; border-radius: 10px; background: var(--mark-plate); border: 1px solid var(--mark-plate-line); } .extAgents-tile .agent-mark img, .extAgents-tile .agent-mark svg { width: 23px; height: 23px; } .extAgents-tile-own .agent-mark img { width: 21px; height: 26px; } @@ -66,11 +66,46 @@ prefers-reduced-motion. The sheet's smaller dot stays solid. */ .extAgents-nm .extAgents-led-warn { background: var(--paper); box-shadow: inset 0 0 0 1.5px var(--gold); } .extAgents-one { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 1px; } -.extAgents-one-bad { color: var(--clay); } -/* A row's refusal can carry a command, so it wraps rather than cutting it off. */ -.extAgents-row .extAgents-one-bad { white-space: normal; } .extAgents-one-work { font-family: var(--mono); font-size: 11.5px; color: var(--amber); display: flex; align-items: center; gap: 6px; } +/* The verdict: one word in clay for what went wrong, the reason after a dot + in the ordinary grey, and the press that answers it. On a card it is a + strip under the line about the agent, on a faint clay wash so a grid of + twenty can be scanned for the ones in trouble; on a row it is the second + line, where a command is a piece of code rather than part of a sentence. + The light by the name is the only other thing that turns red. */ +.extAgents-foot-slot { margin-top: auto; height: 30px; } +.extAgents-foot { + height: 100%; padding: 0 9px; border-radius: 7px; min-width: 0; + background: color-mix(in oklab, var(--clay) 6%, var(--paper)); + display: flex; align-items: center; font-size: 12px; line-height: 1.4; +} +/* One line, cut with a dot when the card is narrow for it: the sheet has + the whole of it, and the strip's height is what keeps the card's. */ +.extAgents-foot-say { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.extAgents-foot-k { color: var(--clay); font-weight: 600; display: inline-flex; align-items: center; gap: 6px; } +.extAgents-foot-r { color: var(--muted); } +.extAgents-foot .extAgents-foot-r { color: color-mix(in oklab, var(--clay) 55%, var(--muted)); } +/* The dot travels with the word before it: the zero-width space after it + gives the line a place to break after the dot, not in front of it. */ +.extAgents-foot-r::before { content: "\00b7\200b"; margin: 0 6px; color: var(--faint); } +/* Nothing wrong: the same slot says how the row is doing, in the words the + sheet's head uses, with no wash. So a card is never blank under its line, + and the strip that replaces this when something fails takes no more room. */ +.extAgents-foot-quiet { background: none; padding: 0; } +.extAgents-foot-quiet .extAgents-foot-k { color: var(--faint); font-weight: 400; } +/* A test under way is not a failure: the same strip in amber. */ +.extAgents-foot-busy { background: color-mix(in oklab, var(--amber) 8%, var(--paper)); } +.extAgents-foot-busy .extAgents-foot-k { color: var(--amber); } +.extAgents-row2 { font-size: 12px; margin-top: 2px; display: flex; align-items: center; min-width: 0; } +.extAgents-row2.extAgents-foot-busy { background: none; } +.extAgents-row2 .extAgents-foot-k { flex: none; } +.extAgents-row2 .extAgents-foot-r { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.extAgents-row2 code { + font-family: var(--mono); font-size: 11px; color: var(--text); white-space: nowrap; + background: var(--surface); border: 1px solid var(--line-soft); border-radius: 4px; padding: 1px 5px; +} + /* One control per row, and the buttons in a column are one size: Connect, Disconnect, Retry, Install and Re-check differ in length and the frames must not. The floor is the longest word in the language, so it is two values -- @@ -113,8 +148,34 @@ } .extAgents-empty { padding: 28px 0; font-size: 12.5px; color: var(--faint); } +/* The wait: bars in the places a card's parts go, at the sizes they take, so + the eight placeholders stand the same height as the cards that replace + them. A translucent wash rather than a named surface, as the settings bars + are: a bar in the ground's own grey is invisible until the shimmer's peak + sweeps past. */ +.extAgents-wbar { + display: block; flex: none; border-radius: 5px; + background: linear-gradient(90deg, + color-mix(in oklab, var(--text) 7%, transparent) 30%, + color-mix(in oklab, var(--text) 15%, transparent) 50%, + color-mix(in oklab, var(--text) 7%, transparent) 70%); + background-size: 300% 100%; animation: extAgents-wshine 1.3s linear infinite; +} +@keyframes extAgents-wshine { from { background-position: 100% 0; } to { background-position: -100% 0; } } +@media (prefers-reduced-motion: reduce) { .extAgents-wbar { animation: none; } } +.extAgents-wcard { cursor: default; pointer-events: none; } +.extAgents-wtile { width: 32px; height: 32px; border-radius: 9px; } +.extAgents-wname { width: 44%; height: 14px; } +.extAgents-wline { width: 72%; height: 12px; margin: 13px 0 32px; } +.extAgents-wfoot { width: 38%; height: 12px; margin-top: 9px; } +.extAgents-wtn { display: inline-block; width: 14px; height: 10px; border-radius: 3px; margin-left: 6px; vertical-align: -1px; } + +/* A column of fixed parts -- the name, two lines of room for the line about + the agent, the strip's place -- so every card is one height, with or + without a strip, and a row's strips sit level. */ .extAgents-card { - position: relative; min-width: 0; min-height: 104px; padding: 14px 14px 16px; + position: relative; min-width: 0; min-height: 104px; padding: 14px 14px 14px; + display: flex; flex-direction: column; background: var(--paper); border: 1px solid var(--line); border-radius: 12px; color: inherit; cursor: pointer; transition: box-shadow .15s ease, border-color .15s ease; } @@ -130,10 +191,9 @@ .extAgents-card .extAgents-tile .agent-mark img, .extAgents-card .extAgents-tile .agent-mark svg { width: 20px; height: 20px; } .extAgents-card .extAgents-tile-own .agent-mark img { width: 18px; height: 23px; } .extAgents-card .extAgents-one { - margin-top: 10px; line-height: 1.55; white-space: normal; + margin: 10px 0; line-height: 1.55; min-height: 3.1em; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } -.extAgents-card .extAgents-one-work { display: flex; } /* The corner control: a quiet square, always drawn. */ .extAgents-cbtn { @@ -144,8 +204,6 @@ .extAgents-cbtn svg { width: 14px; height: 14px; } .extAgents-cbtn:hover { color: var(--text); background: color-mix(in oklab, var(--raised) 70%, var(--line)); } .extAgents-cbtn:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; } -.extAgents-cbtn-bad { color: var(--clay); background: color-mix(in oklab, var(--clay) 10%, transparent); } -.extAgents-cbtn-bad:hover { color: var(--clay); background: color-mix(in oklab, var(--clay) 18%, transparent); } .extAgents-ctag { font-size: 11.5px; color: var(--faint); white-space: nowrap; } /* The wizard's agents step: a scanning placeholder before the first @@ -165,7 +223,8 @@ /* ── the sheet ──────────────────────────────────────────────────── */ /* The panel is the owner's fixed 520x500 box (page.css sizes `.dpanel` off - `data-owner`); the sheet fills it, and spends the padding itself. */ + `data-owner`), whatever the body holds: a note at the top scrolls with the + body. The sheet fills it, and spends the padding itself. */ .extAgents-sheet { width: 100%; flex: 1; display: flex; flex-direction: column; min-height: 0; } .extAgents-head { flex: none; display: flex; align-items: center; gap: 12px; padding: 20px 48px 0 20px; } .extAgents-head .extAgents-tile .agent-mark { width: 46px; height: 46px; border-radius: 12px; } @@ -176,22 +235,44 @@ .extAgents-by { font-size: 11.5px; color: var(--faint); margin-top: 1px; display: flex; align-items: center; gap: 6px; } .extAgents-by .extAgents-led { width: 5px; height: 5px; } .extAgents-by-bad { color: var(--clay); } -/* A refusal with a named fix: the sentence, the command on a line of its own, - and the agent's own words folded under both. The dot sits on the first line - rather than centred against the block. */ +/* A caveat on a connected row: the sheet's line, with the probe's own words + folded under it. The dot sits on the first line rather than centred against + the block. */ .extAgents-by-fix { align-items: flex-start; } .extAgents-by-fix .extAgents-led { margin-top: 5px; } .extAgents-fix { flex: 1; min-width: 0; line-height: 1.5; } -.extAgents-fix .extAgents-cmd { margin: 6px 0 4px; color: var(--text); } -/* A fix made inside the agent: the command that opens it, then what to type - there. Numbered, each label over its own command. */ -.extAgents-steps { margin: 6px 0 4px; padding-left: 18px; } -.extAgents-steps li + li { margin-top: 4px; } -.extAgents-fix .extAgents-steps .extAgents-cmd { margin: 3px 0 0; } +/* A fix made inside the agent, in the note: the command that opens it, then + what to type there. Numbered, each label over its own command. */ +.extAgents-steps { margin: 8px 0 0; padding-left: 18px; color: var(--text); font-size: 12.5px; } +.extAgents-steps li + li { margin-top: 6px; } +.extAgents-note .extAgents-steps .extAgents-cmd { margin: 4px 0 0; } .extAgents-raw { color: var(--faint); } .extAgents-raw summary { cursor: pointer; } .extAgents-body { flex: 1; overflow-y: auto; padding: 18px 20px 4px; min-height: 0; } + +/* What to do about the last thing that went wrong: a block at the top of the + body, since the head only says what state the agent is in. The title is + the one thing in clay; the sentence is in the ordinary text colour; a + command gets a line of its own; and the agent's own words are folded under + when the sentence already explains them, or shown as they came -- in a + quiet mono block, not as prose in red -- when they are all there is. */ +.extAgents-note { + margin: 0 0 18px; padding: 12px 14px; border-radius: 10px; + border: 1px solid color-mix(in oklab, var(--clay) 22%, var(--line)); + background: color-mix(in oklab, var(--clay) 4%, var(--paper)); +} +.extAgents-note-t { font-size: 13px; font-weight: 600; color: var(--clay); display: flex; align-items: center; gap: 7px; } +.extAgents-note-when { font-weight: 400; font-size: 11.5px; color: var(--faint); } +.extAgents-note-p { font-size: 12.5px; color: var(--text); line-height: 1.6; margin-top: 4px; } +.extAgents-note .extAgents-cmd { margin-top: 9px; background: var(--paper); } +.extAgents-note-raw { margin-top: 9px; font-size: 11.5px; color: var(--faint); } +.extAgents-note-raw summary { cursor: pointer; } +.extAgents-note-raw pre, .extAgents-note-said { + margin: 6px 0 0; padding: 8px 10px; border-radius: 7px; background: var(--paper); border: 1px solid var(--line-soft); + font: 11.5px/1.55 var(--mono); color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; +} +.extAgents-note-said { margin-top: 9px; color: var(--text); } .extAgents-fld { display: block; } .extAgents-fld + .extAgents-fld { margin-top: 16px; } .extAgents-k { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; } diff --git a/ui-web/src/styles/page.css b/ui-web/src/styles/page.css index fe27ffbd3..8721f0a70 100644 --- a/ui-web/src/styles/page.css +++ b/ui-web/src/styles/page.css @@ -10,6 +10,10 @@ content that needs one (tables, diffs). Colour is spent only where something is actually happening: a link, a live dot, an error. */ --ink: #ffffff; + /* The plate under an agent mark. It does not follow the theme: a brand mark + is drawn for a light ground, so the dark theme dims the plate and keeps it + light rather than recolouring what sits on it. */ + --mark-plate: #f7f7f7; --mark-plate-line: #f0f0f0; --surface: #f7f7f7; --raised: #f0f0f0; --line: #e7e7e7; @@ -229,6 +233,7 @@ @media (prefers-color-scheme: dark) { :root { --ink: #1d1d1d; + --mark-plate: #ecebe7; --mark-plate-line: #ecebe7; --surface: #262626; --raised: #303030; --line: #333333; @@ -310,6 +315,7 @@ /* the viewer's toggle must win over the media query, both directions */ :root[data-theme="dark"] { --stage-bg: #0e0e11; + --mark-plate: #ecebe7; --mark-plate-line: #ecebe7; --ink: #1d1d1d; --surface: #262626; --raised: #303030; --line: #333333; --line-soft: #282828; --text: #e8e8e6; --muted: #afafad; --faint: #8a8a88; --link: #86abe4; --amber: #e8cf49; --amber-dp: #6f6114; --moss: #7fbf6a; @@ -338,6 +344,7 @@ --scrim-detail: rgba(6, 6, 8, .55); } :root[data-theme="light"] { + --mark-plate: #f7f7f7; --mark-plate-line: #f0f0f0; --bdg-ink: #faf7ee; --ink: #ffffff; --surface: #f7f7f7; --raised: #f0f0f0; --line: #e7e7e7; --line-soft: #f0f0f0; --text: #1f1f1f; --muted: #6b6b6b; --faint: #9d9d9d; @@ -5522,44 +5529,28 @@ body:has(.app[data-page="on"]) #deskHost { display: none; } which came with a card of its own and was the one mark with a background before every mark had one. */ .agent-mark { width: 20px; height: 20px; display: grid; place-items: center; flex: none; - border: 1px solid var(--line-soft); border-radius: 5px; background: var(--surface); + border: 1px solid var(--mark-plate-line); border-radius: 5px; background: var(--mark-plate); overflow: hidden; color: var(--muted); } .agent-mark svg { display: block; width: 14px; height: 14px; stroke-linecap: round; stroke-linejoin: round; } .agent-mark img { display: block; width: 14px; height: 14px; object-fit: contain; } /* The two marks that ARE tiles fill the frame instead of sitting a card inside one. MiroMind's file is a favicon and carries its own rounded card. */ .agent-mark img[data-agent="mirothinker"] { width: 100%; height: 100%; } -/* Only the marks that draw in currentColor, which an resolves against its - own document and so renders black. `mono` is every shape, so the whole file - inverts, as the provider marks already do. `hybrid` is qoder alone: brand - green beside one currentColor tone, where a plain invert takes the green to - magenta and the hue rotation puts it back. A file with no currentColor at all - carries its own palette and gets neither. - Both theme paths need each rule: an explicit choice stamps data-theme, the - default setting stamps nothing. Guarded by +/* Every mark sits on a light plate in both themes, the way the app icon does. + A brand mark is drawn for a light ground, and recolouring it for a dark one + is a per-file guess that goes wrong silently: inverting a mono file turns an + illustration into its negative, and rotating a hue back lands on a different + green. So the dark theme changes the plate, not the mark, and no filter + reaches an agent mark. `data-tone` stays on the element as the file's fact + for the provenance test, not for a rule here. + raven.svg and miromind.svg answer prefers-color-scheme from inside the file, + which an 's SVG evaluates against the used color-scheme of the element + embedding it, not against this page's data-theme. Pinned light on the tile, + so each draws its light self on the light plate under either theme and + either OS. On the tile rather than on :root: there it would also hand every + native scrollbar and form control to one palette. Guarded by ui-web/scripts/gates/agent-mark-css.test.mjs. */ -:root[data-theme="dark"] .agent-mark img[data-tone="mono"] { filter: invert(1); } -:root[data-theme="dark"] .agent-mark img[data-tone="hybrid"] { filter: invert(1) hue-rotate(180deg); } -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .agent-mark img[data-tone="mono"] { filter: invert(1); } - :root:not([data-theme="light"]) .agent-mark img[data-tone="hybrid"] { filter: invert(1) hue-rotate(180deg); } -} -/* miromind.svg answers the theme from inside its own file, with a - prefers-color-scheme rule of its own. An - 's SVG evaluates that rule against the used color-scheme of the element - embedding it, NOT against this page's data-theme, so without these two lines - it follows the operating system while the rest of the roster follows the - switch -- and since the app's default is explicitly light, a fresh install on - a dark OS is a normal way to see it. - raven.svg once needed the same treatment for the opposite reason: a black bird - sits at 1.2:1 on the dark surface and no tone filter can lift it, because it is - not one colour to invert. It now carries an opaque plate, which is theme - independent, so these two lines no longer decide anything for it. - Declared on the tile rather than on :root: there it would also hand every - native scrollbar and form control to the dark palette, which is a change to - the whole page and not to a mark. */ -:root[data-theme="dark"] .agent-mark img { color-scheme: dark; } -:root[data-theme="light"] .agent-mark img { color-scheme: light; } +.agent-mark img { color-scheme: light; } .agent-kind { flex: none; padding: 1px 6px; border: 1px solid var(--line-soft); border-radius: 999px; background: var(--surface); color: var(--muted); font: 8.5px/1.45 var(--mono); text-transform: lowercase; } /* Beside the mode chip in a pane header, in the tabular mono the other elapsed From c52fb63cb51bc1a9696a7fb6eea428f7a8bdd4c1 Mon Sep 17 00:00:00 2001 From: arelchan <204152633+arelchan@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:56:03 +0800 Subject: [PATCH 2/2] fix(ui-web): name the press the sheet's bar offers in a failed test's note The note at the top of the sheet told the reader to press Test after any failed test on a row that was not connected. The bar offers Test only to an unauthorized row. A row whose test has since cleared its handshake, and an endpoint still waiting on its key, show Connect alone, so the note named a control that was not there. The note now reads its press from the same branches as the bar: a connected row retests, an unauthorized one tests, any other row that is not connected connects, which runs the same test. The endpoint test that pinned Test for a row waiting on its key now expects Connect, and a new case walks every state a failed test can leave a row in and checks the note names a button the bar renders. Co-authored-by: Claude (claude-opus-5-5) --- .../features/extAgents/ExtAgentsPage.test.tsx | 30 +++++++++++++++++-- .../src/features/extAgents/ExtAgentsPage.tsx | 20 ++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx index 6ecaed23d..145fe5812 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.test.tsx @@ -794,6 +794,29 @@ describe('the sheet', () => { const note = sheet()!.querySelector('.extAgents-note')! expect(note.querySelector('.extAgents-note-t')!.textContent).toBe('gui.agent.st_test_bad') expect(note.querySelector('.extAgents-note-said')!.textContent).toBe('it connected and then answered nothing') + /* The test cleared the handshake, so the bar is back to Connect, and the + note names Connect rather than a Test the bar no longer offers. */ + expect(sheetActs()).toEqual(['gui.agent.connect']) + expect(note.querySelector('.extAgents-note-p')!.textContent).toBe( + `gui.agent.said_test ${JSON.stringify({ button: 'gui.agent.connect' })}`, + ) + }) + + /* The note and the bar are drawn from one state, so whatever the note tells + the reader to press has to be on the bar, in every state a failed test can + leave a row in. */ + it.each([ + ['connected', { configured: true, enabled: true }], + ['unauthorized', { configured: false, enabled: false, needs_auth: true, probe_status: 'attention' }], + ['available', { configured: false, enabled: false }], + ['waiting on a key', { preset: 'mirothinker', kind: 'openai', configured: false, enabled: false }], + ] as const)('names a press the bar offers, after a failed test on a %s row', async (_state, over) => { + install([row({ name: 'x', last_test_ok: false, last_test_at_ms: 1, last_test_detail: 'it answered nothing', ...over })]) + await mount() + await openSheet('x') + const said = sheet()!.querySelector('.extAgents-note .extAgents-note-p')!.textContent! + const named = (JSON.parse(said.slice(said.indexOf('{'))) as { button: string }).button + expect(sheetActs()).toContain(named) }) }) @@ -831,6 +854,7 @@ describe('a refusal that names its fix', () => { await openSheet('Hermes Agent') expect(title()).toBe(say('gui.agent.bad_setup', { agent: 'Hermes Agent' })) expect(lead()).toBe(say('gui.agent.fix_setup', { agent: 'Hermes Agent', button: 'gui.agent.test_label' })) + expect(sheetActs()).toContain('gui.agent.test_label') expect(note()!.querySelector('.extAgents-cmd code')!.textContent).toBe('hermes model') expect(raw()!.querySelector('summary')!.textContent).toBe('gui.agent.fix_raw') expect(raw()!.textContent).toContain(hermesSaid) @@ -877,8 +901,10 @@ describe('a refusal that names its fix', () => { await mount() await openSheet('MiroThinker') expect(title()).toBe(say('gui.agent.bad_api_key', { agent: 'MiroThinker' })) - /* An endpoint with no key is not connected, so its press is plain Test. */ - expect(lead()).toBe(say('gui.agent.fix_api_key', { agent: 'MiroThinker', button: 'gui.agent.test_label' })) + /* An endpoint with no key is not connected, and its bar offers Connect + beside the key field, not Test: the note names the press that is there. */ + expect(lead()).toBe(say('gui.agent.fix_api_key', { agent: 'MiroThinker', button: 'gui.agent.connect' })) + expect(sheetActs()).toEqual(['gui.agent.connect']) expect(note()!.querySelector('.extAgents-cmd')).toBeNull() }) diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.tsx index b9c18b116..d8f8ad3d3 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.tsx @@ -387,6 +387,19 @@ function remedied(agent: string, remedy: Remedy | null, button: string, raw: str language what failed and what to press, and the server's sentence is shown as it came: it is the only reason there is, and folding it away made the block say nothing. */ +/* The press the sheet's action bar offers after a failed test, which is the + one its note has to name: read off the same branches as the bar in + `AgentSheet`. A connected row retests; an unauthorized one tests, since that + is how it earns its Connect back; any other row that is not connected offers + Connect alone -- a key to fill in, or a handshake a test has since cleared -- + and connecting runs the same test. Empty where the bar offers nothing to + press, which is Raven itself, whose rows are never tested. */ +function testPress(row: ExtAgentRow, shown: Shown): string { + if (shown === 'on') return canTest(row) ? t('gui.agent.test_again') : '' + if (shown !== 'off') return '' + return t(stageOf(row) === 'unauthorized' ? 'gui.agent.test_label' : 'gui.agent.connect') +} + function noteOf(row: ExtAgentRow, s: ExtAgentsState, shown: Shown): NoteSpec | null { const agent = row.name const failed = s.failed[row.name] @@ -402,10 +415,9 @@ function noteOf(row: ExtAgentRow, s: ExtAgentsState, shown: Shown): NoteSpec | n folded: false, } } - if (shown === 'pending' || shown === 'missing' || row.last_test_ok !== false) return null - /* The press named is the one the action bar offers: a connected row's says - "again", since its last test is the reason this block is here. */ - const button = t(shown === 'on' ? 'gui.agent.test_again' : 'gui.agent.test_label') + if (row.last_test_ok !== false) return null + const button = testPress(row, shown) + if (!button) return null const when = ago(row.last_test_at_ms) const spec = remedied(agent, row.last_test_remedy || null, button, row.last_test_detail || '') if (spec) return { ...spec, when }