diff --git a/i18n/messages.json b/i18n/messages.json index be945cde7..bb7c5c1e3 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -2855,10 +2855,6 @@ "en": "Not wired up yet", "zh": "功能尚未接入" }, - "gui.model.add_group": { - "en": "Add the whole group", - "zh": "添加整组" - }, "gui.model.add_manual": { "en": "Add a model by hand", "zh": "手动添加模型" @@ -2935,10 +2931,6 @@ "en": "Show the key", "zh": "显示密钥" }, - "gui.model.add_all": { - "en": "Add all ({n})", - "zh": "全部添加({n})" - }, "gui.model.catalogue_n": { "en": "{n} shown", "zh": "显示 {n} 个" @@ -7023,6 +7015,18 @@ "en": "Models on offer", "zh": "可选用的模型" }, + "gui.settings.providers.models_more": { + "en": "{n} more", + "zh": "还有 {n} 个" + }, + "gui.settings.providers.models_less": { + "en": "Show fewer", + "zh": "收起" + }, + "gui.settings.providers.all_added": { + "en": "Every model this vendor lists is already added. Type an id to add one it does not list.", + "zh": "这家列出的模型都已添加。要加它没列出的,直接填 id。" + }, "gui.settings.providers.no_models_yet": { "en": "No models yet. Use \"Add model\" to pick from this vendor's list, or type an id.", "zh": "还没有模型。点「添加模型」从这家的列表里挑,或者直接填一个 id。" @@ -7043,9 +7047,17 @@ "en": "Add model", "zh": "添加模型" }, - "gui.settings.providers.showing_added": { - "en": "showing {n} · added {m}", - "zh": "显示 {n} 个 · 已添加 {m}" + "gui.settings.providers.pick_all": { + "en": "Select all", + "zh": "全选" + }, + "gui.settings.providers.add_picked": { + "en": "Add ({n})", + "zh": "添加({n})" + }, + "gui.settings.providers.pick_group": { + "en": "Select all of {name}", + "zh": "全选 {name}" }, "gui.settings.providers.fetching": { "en": "Fetching the model list from {name}", @@ -7200,8 +7212,8 @@ "zh": "主对话" }, "gui.settings.roles.chat_use": { - "en": "The model conversations run on", - "zh": "对话时使用的模型" + "en": "The default model configuration a new session starts on", + "zh": "新开会话时的默认模型配置" }, "gui.settings.roles.curator": { "en": "Context compaction", @@ -7331,9 +7343,17 @@ "en": "Fixed", "zh": "自定义" }, - "gui.settings.roles.ctx_current": { - "en": "Current", - "zh": "当前" + "gui.settings.roles.sum_effort": { + "en": "Effort", + "zh": "思考" + }, + "gui.settings.roles.sum_iters": { + "en": "Tools", + "zh": "工具" + }, + "gui.settings.roles.sum_ctx": { + "en": "Context", + "zh": "上下文" }, "gui.settings.roles.ctx_fixed": { "en": "Fixed at", @@ -7515,13 +7535,13 @@ "en": "Needs setup", "zh": "待配置" }, - "gui.settings.plugins.failed_retry": { - "en": "Connection failed · retry", - "zh": "连接失败 · 重试" + "gui.settings.plugins.failed": { + "en": "Connection failed", + "zh": "连接失败" }, - "gui.settings.plugins.auth": { - "en": "Authorization", - "zh": "授权" + "gui.settings.plugins.retry": { + "en": "Reconnect", + "zh": "重新连接" }, "gui.settings.plugins.auth_browser": { "en": "Authorize in browser", @@ -7551,6 +7571,22 @@ "en": "This server takes no credential", "zh": "这个服务不需要凭据" }, + "gui.settings.plugins.no_fields": { + "en": "The catalogue names no credential field for this server", + "zh": "目录里没有登记这个服务要填什么凭据" + }, + "gui.settings.plugins.address": { + "en": "Address", + "zh": "连接" + }, + "gui.settings.plugins.tools": { + "en": "Tools", + "zh": "工具" + }, + "gui.settings.plugins.tools_n": { + "en": "{n}", + "zh": "{n} 个" + }, "gui.model.pick_search": { "en": "Search or type a model id", "zh": "搜索或输入模型 id…" diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index a809dc2f2..f87f5ff0e 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -902,7 +902,6 @@ export const UI_TEXT: Record> = { 'gui.model.none_yet': 'No models yet. Add one by id below.', 'gui.model.select_platform': 'Select the platform', 'gui.model.soon': 'Not wired up yet', - 'gui.model.add_group': 'Add the whole group', 'gui.model.add_manual': 'Add a model by hand', 'gui.model.add_caps': 'Model capabilities', 'gui.model.add_id': 'Model ID', @@ -922,7 +921,6 @@ export const UI_TEXT: Record> = { 'gui.model.in.vision': 'Vision', 'gui.key.hide': 'Hide the key', 'gui.key.show': 'Show the key', - 'gui.model.add_all': 'Add all ({n})', 'gui.model.catalogue_n': '{n} shown', 'gui.model.catalogue_search': 'Search models...', 'gui.model.catalogue_title': '{name} models', @@ -1965,13 +1963,19 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.model_in_use': '{roles} use {model}', 'gui.settings.providers.models': 'Models', 'gui.settings.providers.models_listed': 'Models on offer', + 'gui.settings.providers.models_more': '{n} more', + 'gui.settings.providers.models_less': 'Show fewer', + 'gui.settings.providers.all_added': + 'Every model this vendor lists is already added. Type an id to add one it does not list.', 'gui.settings.providers.no_models_yet': 'No models yet. Use "Add model" to pick from this vendor\'s list, or type an id.', 'gui.settings.providers.remove_model': 'Remove {model}', 'gui.settings.providers.typed': 'typed', 'gui.settings.providers.added': 'added', 'gui.settings.providers.add_model': 'Add model', - 'gui.settings.providers.showing_added': 'showing {n} · added {m}', + 'gui.settings.providers.pick_all': 'Select all', + 'gui.settings.providers.add_picked': 'Add ({n})', + 'gui.settings.providers.pick_group': 'Select all of {name}', 'gui.settings.providers.fetching': 'Fetching the model list from {name}', 'gui.settings.providers.filter_direct': 'Direct vendors', 'gui.settings.providers.filter_gateway': 'Aggregators', @@ -2011,7 +2015,7 @@ export const UI_TEXT: Record> = { 'gui.settings.roles.locked_env': 'Set by exported EVEROS_* variables, which outrank anything saved here', 'gui.settings.roles.connect_openrouter': 'Connect OpenRouter', 'gui.settings.roles.chat': 'Chat', - 'gui.settings.roles.chat_use': 'The model conversations run on', + 'gui.settings.roles.chat_use': 'The default model configuration a new session starts on', 'gui.settings.roles.curator': 'Context compaction', 'gui.settings.roles.curator_use': 'Tidies the history when a conversation outgrows its budget; a small model does', 'gui.settings.roles.title': 'Session naming', @@ -2045,7 +2049,9 @@ export const UI_TEXT: Record> = { 'gui.settings.roles.ctx': 'Context window', 'gui.settings.roles.ctx_auto': 'Follow the model', 'gui.settings.roles.ctx_pin': 'Fixed', - 'gui.settings.roles.ctx_current': 'Current', + 'gui.settings.roles.sum_effort': 'Effort', + 'gui.settings.roles.sum_iters': 'Tools', + 'gui.settings.roles.sum_ctx': 'Context', 'gui.settings.roles.ctx_fixed': 'Fixed at', 'gui.settings.roles.ctx_unknown': 'Unknown for this model', 'gui.settings.roles.ctx_min': 'At least {n} tokens', @@ -2091,8 +2097,8 @@ export const UI_TEXT: Record> = { 'gui.settings.plugins.connected': 'Connected', 'gui.settings.plugins.connecting': 'Connecting', 'gui.settings.plugins.setup': 'Needs setup', - 'gui.settings.plugins.failed_retry': 'Connection failed · retry', - 'gui.settings.plugins.auth': 'Authorization', + 'gui.settings.plugins.failed': 'Connection failed', + 'gui.settings.plugins.retry': 'Reconnect', 'gui.settings.plugins.auth_browser': 'Authorize in browser', 'gui.settings.plugins.authorized': 'Authorized', 'gui.settings.plugins.reauth': 'Re-authorize', @@ -2100,6 +2106,10 @@ export const UI_TEXT: Record> = { 'gui.settings.plugins.api_key': 'API Key', 'gui.settings.plugins.key_first': 'Enter the key first', 'gui.settings.plugins.no_credential': 'This server takes no credential', + 'gui.settings.plugins.no_fields': 'The catalogue names no credential field for this server', + 'gui.settings.plugins.address': 'Address', + 'gui.settings.plugins.tools': 'Tools', + 'gui.settings.plugins.tools_n': '{n}', 'gui.model.pick_search': 'Search or type a model id', 'gui.model.pick_use': 'Use {id}', 'gui.model.pick_add_to': 'add to {name}', @@ -3268,7 +3278,6 @@ export const UI_TEXT: Record> = { 'gui.model.none_yet': '还没有模型,可在下方按 id 添加。', 'gui.model.select_platform': '选择平台', 'gui.model.soon': '功能尚未接入', - 'gui.model.add_group': '添加整组', 'gui.model.add_manual': '手动添加模型', 'gui.model.add_caps': '模型能力', 'gui.model.add_id': '模型 ID', @@ -3288,7 +3297,6 @@ export const UI_TEXT: Record> = { 'gui.model.in.vision': '视觉', 'gui.key.hide': '隐藏密钥', 'gui.key.show': '显示密钥', - 'gui.model.add_all': '全部添加({n})', 'gui.model.catalogue_n': '显示 {n} 个', 'gui.model.catalogue_search': '搜索模型…', 'gui.model.catalogue_title': '{name} 模型', @@ -4314,12 +4322,17 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.model_in_use': '{roles} 正在用 {model}', 'gui.settings.providers.models': '模型', 'gui.settings.providers.models_listed': '可选用的模型', + 'gui.settings.providers.models_more': '还有 {n} 个', + 'gui.settings.providers.models_less': '收起', + 'gui.settings.providers.all_added': '这家列出的模型都已添加。要加它没列出的,直接填 id。', 'gui.settings.providers.no_models_yet': '还没有模型。点「添加模型」从这家的列表里挑,或者直接填一个 id。', 'gui.settings.providers.remove_model': '移除 {model}', 'gui.settings.providers.typed': '手动填写', 'gui.settings.providers.added': '已添加', 'gui.settings.providers.add_model': '添加模型', - 'gui.settings.providers.showing_added': '显示 {n} 个 · 已添加 {m}', + 'gui.settings.providers.pick_all': '全选', + 'gui.settings.providers.add_picked': '添加({n})', + 'gui.settings.providers.pick_group': '全选 {name}', 'gui.settings.providers.fetching': '正在从 {name} 获取模型列表', 'gui.settings.providers.filter_direct': '直连厂商', 'gui.settings.providers.filter_gateway': '聚合网关', @@ -4358,7 +4371,7 @@ export const UI_TEXT: Record> = { 'gui.settings.roles.locked_env': '由导出的 EVEROS_* 环境变量设定,优先级高于这里保存的任何值', 'gui.settings.roles.connect_openrouter': '连接 OpenRouter', 'gui.settings.roles.chat': '主对话', - 'gui.settings.roles.chat_use': '对话时使用的模型', + 'gui.settings.roles.chat_use': '新开会话时的默认模型配置', 'gui.settings.roles.curator': '上下文压缩', 'gui.settings.roles.curator_use': '对话超出预算时整理历史,适合小模型', 'gui.settings.roles.title': '会话命名', @@ -4391,7 +4404,9 @@ export const UI_TEXT: Record> = { 'gui.settings.roles.ctx': '上下文窗口', 'gui.settings.roles.ctx_auto': '跟随模型', 'gui.settings.roles.ctx_pin': '自定义', - 'gui.settings.roles.ctx_current': '当前', + 'gui.settings.roles.sum_effort': '思考', + 'gui.settings.roles.sum_iters': '工具', + 'gui.settings.roles.sum_ctx': '上下文', 'gui.settings.roles.ctx_fixed': '固定为', 'gui.settings.roles.ctx_unknown': '这个模型没有记录', 'gui.settings.roles.ctx_min': '至少 {n} tok', @@ -4437,8 +4452,8 @@ export const UI_TEXT: Record> = { 'gui.settings.plugins.connected': '已连接', 'gui.settings.plugins.connecting': '连接中', 'gui.settings.plugins.setup': '待配置', - 'gui.settings.plugins.failed_retry': '连接失败 · 重试', - 'gui.settings.plugins.auth': '授权', + 'gui.settings.plugins.failed': '连接失败', + 'gui.settings.plugins.retry': '重新连接', 'gui.settings.plugins.auth_browser': '在浏览器中授权', 'gui.settings.plugins.authorized': '已授权', 'gui.settings.plugins.reauth': '重新授权', @@ -4446,6 +4461,10 @@ export const UI_TEXT: Record> = { 'gui.settings.plugins.api_key': 'API Key', 'gui.settings.plugins.key_first': '先填入 Key', 'gui.settings.plugins.no_credential': '这个服务不需要凭据', + 'gui.settings.plugins.no_fields': '目录里没有登记这个服务要填什么凭据', + 'gui.settings.plugins.address': '连接', + 'gui.settings.plugins.tools': '工具', + 'gui.settings.plugins.tools_n': '{n} 个', 'gui.model.pick_search': '搜索或输入模型 id…', 'gui.model.pick_use': '使用 {id}', 'gui.model.pick_add_to': '加入 {name}', diff --git a/ui-web/scripts/fixture-params.mjs b/ui-web/scripts/fixture-params.mjs index 8c206f2cd..f77004c92 100644 --- a/ui-web/scripts/fixture-params.mjs +++ b/ui-web/scripts/fixture-params.mjs @@ -45,7 +45,7 @@ export const PARAMS = { 'skillhub.install': { id: 'sh-meeting-notes' }, 'skillhub.remove': { name: 'SQL 规范' }, 'plughub.search': { q: '', category: '' }, - 'plughub.detail': { id: 'github-mcp' }, + 'plughub.detail': { id: 'github' }, 'plug.install': { id: 'notion', form: {} }, 'plug.remove': { name: 'notion' }, 'plug.toggle': { name: 'github', enabled: false }, diff --git a/ui-web/scripts/gates/settings-drawer-css.test.mjs b/ui-web/scripts/gates/settings-drawer-css.test.mjs new file mode 100644 index 000000000..d29ae8cdc --- /dev/null +++ b/ui-web/scripts/gates/settings-drawer-css.test.mjs @@ -0,0 +1,154 @@ +/* The row drawer and the standalone box, read off the settings sheet. + * + * `.settings-cfg` is both: on its own it is the panel a row opens under itself + * (no edges of its own, a rail tying it to that row, controls aligned to the + * row's own control edge), and paired with `.settings-padd` it is the + * add-a-provider form, which keeps a box. The two differ on padding, + * background, the rail and the control alignment, and every one of those + * declarations is a single class against a single class -- so the pair's rules + * only win by naming both classes AND sitting later in the sheet. They did + * neither at first, and the form silently took the drawer's 31px indent, its + * transparent ground and its right-aligned controls. jsdom applies no + * stylesheet, so the component tests cannot see any of it. + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { decls, rules } from './css.mjs' + +const sheet = readFileSync(new URL('../../src/features/settings/styles.css', import.meta.url), 'utf8') +const at = (selector) => { + const i = sheet.indexOf(`\n${selector} {`) + if (i < 0) throw new Error(`no rule for ${selector}`) + return i +} + +describe('the settings row drawer', () => { + it('has no edges of its own and hangs its content off a rail', () => { + const cfg = decls('.settings-cfg', sheet) + expect(cfg.get('border')).toBeUndefined() + expect(cfg.get('border-radius')).toBeUndefined() + expect(cfg.get('background')).toBe('transparent') + /* The rail is absolute, so the drawer must be its containing block. */ + expect(cfg.get('position')).toBe('relative') + const rail = decls('.settings-cfg::before', sheet) + expect(rail.get('position')).toBe('absolute') + expect(rail.get('width')).toBe('2px') + /* Content clears the rail: the rail sits at the row's own 15px gutter. */ + expect(cfg.get('padding')).toContain('31px') + expect(rail.get('left')).toBe('15px') + }) + + it('lines its controls up with the control edge of the row above, not a second column', () => { + expect(decls('.settings-cfg .settings-row', sheet).get('grid-template-columns')).toBe('minmax(0,1fr) auto') + expect(decls('.settings-cfg .settings-row .settings-ctl', sheet).get('justify-self')).toBe('end') + }) + + it('shows the chevron as a mark that turns, not a control in a box', () => { + const chev = decls('.settings-xrow .settings-chev', sheet) + expect(chev.get('border')).toBeUndefined() + expect(chev.get('background')).toBeUndefined() + expect(chev.get('border-radius')).toBeUndefined() + expect(decls('.settings-xrow.settings-open .settings-chev', sheet).get('transform')).toBe('rotate(90deg)') + }) + + it('takes the separator off the row it opens under, of either kind', () => { + /* The role row carries no separator until a drawer opens under it, which is + the moment it stops being its wrapper's :last-child; the tool and plugin + rows carry one all along and hand it to the drawer below. */ + expect(decls('.settings-row.settings-open,.settings-xrow.settings-open', sheet).get('border-bottom')).toBe('0') + expect(decls('.settings-xrow.settings-open + .settings-cfg', sheet).get('border-bottom')).toContain('1px') + expect(decls('.settings-xrow.settings-open + .settings-cfg:last-child', sheet).get('border-bottom')).toBe('0') + }) +}) + +describe('what a drawer sizes', () => { + it('gives every control in it one size, a notch under the dialog', () => { + /* The dialog's own scale is 32px and 13px (page.css's `.smodal .mini`), and + these are the page's controls, reached into where this domain embeds + them -- so they are named unprefixed on purpose. */ + const one = decls('.settings-cfg .mini,.settings-cfg .sel,.settings-cfg .settings-tbox,.settings-cfg .settings-seg', sheet) + expect(one.get('height')).toBe('28px') + expect(one.get('font-size')).toBe('12px') + expect(decls('.settings-cfg .settings-stepper', sheet).get('height')).toBe('28px') + }) + + it('sizes classes the markup actually carries', () => { + /* `.settings-cfg .settings-mini` sized the drawer's buttons for as long as + the drawer existed and never matched anything: every button on this page + is the page's own `.mini`. Nothing said so -- the button just stayed the + page size inside a panel built to be one notch smaller. */ + const dir = new URL('../../src/features/settings/', import.meta.url) + const words = new Set() + const walk = (at) => { + for (const entry of readdirSync(at, { withFileTypes: true })) { + const path = join(at, entry.name) + if (entry.isDirectory()) { walk(path); continue } + if (!entry.name.endsWith('.tsx') || entry.name.endsWith('.test.tsx')) continue + for (const quoted of readFileSync(path, 'utf8').matchAll(/'([^'\n]*)'|"([^"\n]*)"|`([^`\n]*)`/g)) { + for (const word of (quoted[1] ?? quoted[2] ?? quoted[3] ?? '').split(/[\s{}]+/)) if (word) words.add(word) + } + } + } + walk(new URL('.', dir).pathname) + const unused = [] + for (const [selector] of rules(/^\.settings-cfg\s+\./, sheet)) { + for (const part of selector.split(',')) { + const found = part.trim().match(/^\.settings-cfg\s+\.([A-Za-z0-9_-]+)/) + if (found && !words.has(found[1])) unused.push(`${selector}: no markup carries .${found[1]}`) + } + } + expect(unused).toEqual([]) + }) +}) + +describe('a section label', () => { + it('does not lend its bold to the controls that sit in it', () => { + /* Every button on the page inherits its weight, and `Sec` seats its action + inside the bold label -- so "Add model" came out as bold as "Models", + on every build since the two were put on one line. */ + expect(decls('.settings-lab', sheet).get('font-weight')).toBe('600') + expect(decls('.settings-lab .mini,.settings-lab .settings-sub2,.settings-tp-name .exlink', sheet).get('font-weight')).toBe('400') + }) +}) + +describe('a provider page', () => { + it('sizes every field as the row less one button slot, and stands the button at the right edge', () => { + /* Sized by content the boxes came out 186 and 208; filling their row made + the one box with no button beside it wider than the rest; either way the + buttons formed a second column left of "Add model". */ + const field = decls('.settings-tp-main .settings-sec .settings-tbox,.settings-tp-main .settings-sec .keyfield,\n.settings-tp-main .settings-sec .selw', sheet) + expect(field.get('width')).toBe('calc(100% - var(--tp-act) - 6px)') + expect(field.get('flex')).toBe('none') + expect(decls('.settings-tp-main .settings-taglist:has(> .settings-tbox) > .mini,.settings-tp-main .settings-taglist:has(> .keyfield) > .mini,\n.settings-tp-main .settings-taglist:has(> .selw) > .mini', sheet).get('margin-left')).toBe('auto') + }) +}) + +describe('the add-a-provider box', () => { + it('names both classes on every rule that corrects the drawer', () => { + for (const [selector, prop, value] of [ + ['.settings-cfg.settings-padd', 'padding', '6px 14px 8px'], + ['.settings-cfg.settings-padd', 'background', 'var(--paper)'], + ['.settings-cfg.settings-padd .settings-row', 'grid-template-columns', '132px minmax(0,1fr)'], + ['.settings-cfg.settings-padd .settings-row .settings-ctl', 'justify-self', 'start'], + ]) { + expect(decls(selector, sheet).get(prop)).toBe(value) + } + expect(decls('.settings-cfg.settings-padd', sheet).get('border')).toContain('1px') + expect(decls('.settings-cfg.settings-padd::before', sheet).get('display')).toBe('none') + }) + + it('sits after the drawer it corrects, which is the half specificity cannot supply', () => { + /* Equal-specificity pairs -- the two ::before rules -- are decided by order + alone, so this is not redundant with naming both classes above. */ + expect(at('.settings-cfg.settings-padd')).toBeGreaterThan(at('.settings-cfg')) + expect(at('.settings-cfg.settings-padd::before')).toBeGreaterThan(at('.settings-cfg::before')) + }) + + it('shapes every row, not only the last one', () => { + /* `:last-child` was how the box used to hold its label column, which left + a multi-row form taking the drawer's shape on every row but the last. */ + expect(decls('.settings-cfg.settings-padd .settings-row:last-child', sheet)).toBeNull() + }) +}) diff --git a/ui-web/src/features/composer/store.dockLift.test.ts b/ui-web/src/features/composer/store.dockLift.test.ts new file mode 100644 index 000000000..c4f03569a --- /dev/null +++ b/ui-web/src/features/composer/store.dockLift.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from 'vitest' + +import { dockLift } from './store' + +/* What --lift has to be worth: the distance from the chat column's bottom edge + * up to the top of the highest thing docked over it. The chat reserves it as + * bottom padding and fades its tail from it (styles/page.css), so anything + * docked that this misses is something that covers the transcript instead of + * moving it up. The running-task chip did exactly that -- it sits above the + * composer card, and the measurement named the card's classes rather than + * counting what is docked. + * + * happy-dom does no layout and reports every rect as zero, so the rects are + * stubbed per element; the numbers are the only thing being asserted, and a + * presence-only check would pass with the chip still unmeasured. + */ +const CHAT_BOTTOM = 600 + +function box(el: Element, top: number, height: number): void { + el.getBoundingClientRect = () => ({ + top, height, bottom: top + height, left: 0, right: 0, width: 300, x: 0, y: top, toJSON: () => ({}), + }) as DOMRect +} + +const lift = (): string => (document.querySelector('.chat') as HTMLElement).style.getPropertyValue('--lift') + +beforeEach(() => { + document.body.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
' + box(document.querySelector('.chat')!, 0, CHAT_BOTTOM) + box(document.querySelector('.dock')!, 460, 140) + box(document.querySelector('.dock-in')!, 500, 100) +}) + +describe('the dock lift', () => { + it('reaches the top of the composer card when nothing else is docked', () => { + box(document.querySelector('.tkruns')!, 0, 0) + box(document.querySelector('.sheets')!, 0, 0) + dockLift() + expect(lift()).toBe('100px') + }) + + it('reaches over the running-task chip, so the transcript moves up by its height', () => { + box(document.querySelector('.tkruns')!, 470, 26) + box(document.querySelector('.sheets')!, 0, 0) + dockLift() + expect(lift()).toBe('130px') + }) + + it('reaches over a sheet stacked above both', () => { + box(document.querySelector('.tkruns')!, 470, 26) + box(document.querySelector('.sheets')!, 400, 60) + dockLift() + expect(lift()).toBe('200px') + }) + + it('ignores a docked thing with no height, which is how an empty sheet rack reads', () => { + box(document.querySelector('.tkruns')!, 0, 0) + box(document.querySelector('.sheets')!, 0, 0) + dockLift() + expect(lift()).toBe('100px') + }) +}) diff --git a/ui-web/src/features/composer/store.ts b/ui-web/src/features/composer/store.ts index 44f1ce9e8..fb2ec2514 100644 --- a/ui-web/src/features/composer/store.ts +++ b/ui-web/src/features/composer/store.ts @@ -248,8 +248,15 @@ export function dockLift(): void { /* Scoped to the dock on purpose: a clarify sheet lives in the transcript, so a global match would anchor the pill to wherever that sheet has scrolled. The card itself is included: its border and padding are visual height too, - and measuring only its children parked the pill 6px into the glass. */ - dock.querySelectorAll('.dock-in, .dock-in > *, .sheets > *').forEach((n) => { + and measuring only its children parked the pill 6px into the glass. + + Every direct child counts, rather than a list of classes to keep in step. + The running-task chip is one of them (`.tkruns`, which chrome/Dock mounts + above the card) and it was not on the list, so the height it added went + unmeasured: the chat reserved and faded for the card alone and the chip + covered the tail of the transcript instead of moving it up. */ + const boxes = [...dock.children, ...dock.querySelectorAll('.dock-in > *, .sheets > *')] + boxes.forEach((n) => { const r = n.getBoundingClientRect() if (r.height > 0) top = Math.min(top, r.top) }) diff --git a/ui-web/src/features/settings/Fields.tsx b/ui-web/src/features/settings/Fields.tsx index 454cdac07..a1191adf4 100644 --- a/ui-web/src/features/settings/Fields.tsx +++ b/ui-web/src/features/settings/Fields.tsx @@ -74,17 +74,21 @@ export function Fold({ open, label, onToggle, children }: { ) } -export function Row({ label, sub, stack, k, children }: { +export function Row({ label, sub, stack, k, open, children }: { label?: ReactNode sub?: string /* Label above the control rather than beside it: key fields, lists. */ stack?: boolean /* A whole label cell of the caller's own (the role rows). */ k?: ReactNode + /* This row has a drawer open under it, so it gives up its own separator -- + a line between a row and its own panel reads as a third row. */ + open?: boolean children?: ReactNode }): JSX.Element { + const cls = ['settings-row', stack ? 'settings-stack' : '', open ? 'settings-open' : ''].filter(Boolean).join(' ') return ( -
+
{k ?? (
{label} diff --git a/ui-web/src/features/settings/Skeletons.test.tsx b/ui-web/src/features/settings/Skeletons.test.tsx index 93f4c8841..66cdc834d 100644 --- a/ui-web/src/features/settings/Skeletons.test.tsx +++ b/ui-web/src/features/settings/Skeletons.test.tsx @@ -10,7 +10,7 @@ import { SettingsApp } from './SettingsApp' import * as store from './store' import type { SectionId } from './store' -import type { SettingsSnapshot } from './types' +import type { McpDetail, SettingsSnapshot } from './types' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -139,13 +139,13 @@ describe('the waits a settings page owns', () => { }) it('draws a plugin\'s credential fields as a key row until the catalogue answers', async () => { - let land: ((f: never[]) => void) | null = null - install(undefined, { serverAuthFields: () => new Promise((resolve) => { land = resolve }) }) + let land: ((d: McpDetail) => void) | null = null + install(undefined, { serverDetail: () => new Promise((resolve) => { land = resolve }) }) await mount('plugins') await act(async () => { fireEvent.click(screen.getByText('github')) }) expect(count('.settings-wait .settings-row.settings-stack')).toBe(1) - await act(async () => { land!([]); await Promise.resolve() }) + await act(async () => { land!({ known: true, fields: [], tools: [] }); await Promise.resolve() }) expect(count('.settings-wbar')).toBe(0) expect(screen.getByText('gui.settings.plugins.no_credential')).toBeTruthy() }) diff --git a/ui-web/src/features/settings/pages/Plugins.test.tsx b/ui-web/src/features/settings/pages/Plugins.test.tsx index 9f5dfd8e5..e97d56ca1 100644 --- a/ui-web/src/features/settings/pages/Plugins.test.tsx +++ b/ui-web/src/features/settings/pages/Plugins.test.tsx @@ -46,15 +46,41 @@ describe('plugins page', () => { expect(screen.getByText('gui.settings.plugins.counter {"on":1,"total":4}')).toBeTruthy() expect(screen.getByText('gui.settings.plugins.connected')).toBeTruthy() expect(screen.getByText('gui.settings.plugins.setup')).toBeTruthy() - expect(screen.getByText('gui.settings.plugins.failed_retry')).toBeTruthy() + expect(screen.getByText('gui.settings.plugins.failed')).toBeTruthy() expect(document.querySelectorAll('.settings-xrow.settings-dim')).toHaveLength(1) }) - it('retry reconnects without toggling; the switch goes through plug.toggle', async () => { + it('the failed chip is a status, and the reconnect lives in the drawer beside the reason', async () => { + /* "Failed · retry" was a button on the row: the one place that says what + state a server is in was also where a stray click reconnected it. */ const { calls } = install() await mount('plugins') - await act(async () => { fireEvent.click(screen.getByText('gui.settings.plugins.failed_retry')) }) + const chip = screen.getByText('gui.settings.plugins.failed') + expect(chip.closest('button')).toBeNull() + await act(async () => { fireEvent.click(chip) }) + expect(calls).toEqual([]) + await act(async () => { fireEvent.click(screen.getByText('github')) }) + await act(async () => { await Promise.resolve() }) + const fail = document.querySelector('.settings-pfail') as HTMLElement + /* First in the drawer, before the credential: it is what the row was + opened for. */ + expect(document.querySelector('.settings-cfg')!.firstElementChild).toBe(fail) + expect(fail.textContent).toContain('boom') + await act(async () => { fireEvent.click(screen.getByText('gui.settings.plugins.retry')) }) expect(calls).toEqual([['retryServer', 'github']]) + }) + + it('a connected server\'s drawer offers no reconnect', async () => { + install() + await mount('plugins') + await act(async () => { fireEvent.click(screen.getByText('context7')) }) + await act(async () => { await Promise.resolve() }) + expect(document.querySelector('.settings-pfail')).toBeNull() + }) + + it('the switch goes through plug.toggle', async () => { + const { calls } = install() + await mount('plugins') calls.length = 0 await act(async () => { fireEvent.click(screen.getByRole('switch', { name: 'context7' })) }) expect(calls).toEqual([['toggleServer', { name: 'context7', on: false }]]) @@ -100,4 +126,40 @@ describe('plugins page', () => { ['configureServer', { name: 'github', form: { token: '' } }], ]) }) + + it('shows what the server is and what went wrong, not just how to sign in', async () => { + install() + await mount('plugins') + await act(async () => { fireEvent.click(screen.getByText('github')) }) + await act(async () => { await Promise.resolve() }) + /* The manager's own words. They reached the reader as the word "failed" on + a chip and nowhere else, which is not something anyone can act on. */ + expect(screen.getByText('boom')).toBeTruthy() + expect(screen.getByText('https://api.githubcopilot.com/mcp')).toBeTruthy() + expect(screen.getByText('gui.settings.plugins.tools_n {"n":3}')).toBeTruthy() + expect(screen.getByText('list_issues get_pr')).toBeTruthy() + }) + + it('does not call a server credential-free just because the catalogue has no entry for it', async () => { + /* Two different facts, one of them a lie: the row says this server takes a + key, and the catalogue -- which is where the FIELD names come from -- + simply has nothing filed under its name. github reached this state on + the live page, with an expired token and a panel telling the reader it + needed nothing. */ + install(undefined, { serverDetail: async () => ({ known: false, fields: [], tools: [] }) }) + await mount('plugins') + await act(async () => { fireEvent.click(screen.getByText('github')) }) + await act(async () => { await Promise.resolve() }) + expect(screen.queryByText('gui.settings.plugins.no_credential')).toBeNull() + expect(screen.getByText('gui.settings.plugins.no_fields')).toBeTruthy() + }) + + it('says a credential-free server takes none, on the server\'s own word', async () => { + install() + await mount('plugins') + await act(async () => { fireEvent.click(screen.getByText('context7')) }) + await act(async () => { await Promise.resolve() }) + expect(screen.getByText('gui.settings.plugins.no_credential')).toBeTruthy() + expect(screen.queryByLabelText('Token')).toBeNull() + }) }) diff --git a/ui-web/src/features/settings/pages/Plugins.tsx b/ui-web/src/features/settings/pages/Plugins.tsx index 7266dc149..5fd34d970 100644 --- a/ui-web/src/features/settings/pages/Plugins.tsx +++ b/ui-web/src/features/settings/pages/Plugins.tsx @@ -9,8 +9,8 @@ import { KeyFieldsWait } from '../Skeletons' import * as store from '../store' import type { McpSnapshot } from '../../../rpc/generated' -import type { AuthField } from '../types' -import type { JSX } from 'react' +import type { AuthField, McpDetail } from '../types' +import type { JSX, ReactNode } from 'react' type PlugChip = 'none' | 'connected' | 'connecting' | 'setup' | 'failed' @@ -34,18 +34,45 @@ function ChipFor({ m }: { m: McpSnapshot }): JSX.Element | null { if (kind === 'connected') return {t('gui.settings.plugins.connected')} if (kind === 'connecting') return {t('gui.settings.plugins.connecting')} if (kind === 'setup') return {t('gui.settings.plugins.setup')} + /* A status, not a control. "Failed · retry" made the chip a button, so the + one place that says what state the server is in was also a place a stray + click reconnected it, beside a switch that already means on and off. The + retry is in the drawer, beside the reason it failed. */ + return {t('gui.settings.plugins.failed')} +} + +/* What went wrong and the one thing to try, together and first: for a server + that will not connect, that is what the reader opened the row for. The + reason is the manager's own words; a failure that came back without any + still gets the button. */ +function Failure({ m }: { m: McpSnapshot }): JSX.Element | null { + const busy = store.isBusy(busyKey(m.name)) + if (plugChip(m, busy) !== 'failed' && !(busy && m.error)) return null return ( - void store.run(busyKey(m.name), () => store.source().retryServer(m.name))}> - {t('gui.settings.plugins.failed_retry')} - +
+ {m.error ? {m.error} : } + +
) } -function OauthPanel({ m }: { m: McpSnapshot }): JSX.Element { +/* A sentence rather than a labelled value: an empty label cell is what tells + the sheet to let the line start at the left, where prose is read. */ +function Note({ children, bad }: { children: ReactNode; bad?: boolean }): JSX.Element { + return {children} +} + +function OauthRow({ m }: { m: McpSnapshot }): JSX.Element { const auth = (): void => { void store.run(busyKey(m.name), () => store.source().authServer(m.name)) } const revoke = (): void => { void store.run(busyKey(m.name), () => store.source().revokeServer(m.name)) } + /* No label: every control here names the thing it does, and an "Authorization" + label opposite them only bought a panel's width of empty space between the + word and the button that acts on it. */ return ( - + {m.credentialed ? ( <> @@ -61,24 +88,59 @@ function OauthPanel({ m }: { m: McpSnapshot }): JSX.Element { ) } -/* The catalog names the credential field; a server nobody installed from the - catalog has none, and its row has no panel (the list decides that). */ -function KeyPanel({ m }: { m: McpSnapshot }): JSX.Element { - const [fields, setFields] = useState(null) +/* What the credential part of a drawer says, which depends on two sources that + can disagree. The runtime row knows WHETHER this server authenticates; the + catalogue knows WHICH fields it takes. An empty field list used to print + "takes no credential" whatever the row said, so a server the catalogue has + no entry for -- anything added by hand, and github, whose entry was filed + under another id -- was told it needed nothing while its connection failed + on an expired token. */ +function Credential({ m, detail }: { m: McpSnapshot; detail: McpDetail }): JSX.Element { + if (m.auth === 'oauth') return + if (m.auth === 'none') return {t('gui.settings.plugins.no_credential')} + if (detail.fields.length) { + return <>{detail.fields.map((f) => )} + } + return {t(detail.known ? 'gui.settings.plugins.no_credential' : 'gui.settings.plugins.no_fields')} +} + +/* The drawer, in the order a reader needs it: what to do about it, then what + it is, then what went wrong. Everything here is a fact one of the two + sources already carried and the panel used to drop -- the address it + connects to, the tools it brings, and the manager's own error text, which + only ever reached the reader as the word "failed" on a chip. */ +function Panel({ m }: { m: McpSnapshot }): JSX.Element { + const [detail, setDetail] = useState(null) useEffect(() => { let live = true - store.source().serverAuthFields(m.name) - .then((f) => { if (live) setFields(f) }) - .catch(() => { if (live) setFields([]) }) + store.source().serverDetail(m.name) + .then((d) => { if (live) setDetail(d) }) + .catch(() => { if (live) setDetail({ known: false, fields: [], tools: [] }) }) return () => { live = false } }, [m.name]) - if (fields === null) return - if (!fields.length) return {t('gui.settings.plugins.no_credential')} + if (!detail) return return ( <> - {fields.map((f) => ( - - ))} + + + {/* Facts, not controls, so they read as a definition list rather than + through the row grid: a label column the width of its longest word + and the value right beside it. Sent through the grid they sat at + opposite edges of the panel, a two-character label a thousand pixels + from the URL it names, with nothing in between to pair them. */} +
+ {detail.address && ( + <> +
{t('gui.settings.plugins.address')}
+
{detail.address}
+ + )} +
{t('gui.settings.plugins.tools')}
+
+ {t('gui.settings.plugins.tools_n', { n: m.tool_count })} + {detail.tools.length > 0 && {detail.tools.join(' ')}} +
+
) } @@ -122,7 +184,7 @@ export function Plugins(): JSX.Element { {!rows.length && {t('gui.settings.plugins.none')}} {rows.map((m) => { - const panel = m.auth === 'oauth' ? : m.auth === 'apikey' ? : undefined + const panel = return ( m.added || configured.some((c) => bare(host, c) === bare(host, m.id)) -/* Every row the popover may draw: what the vendor named, plus what is already - configured and the vendor did not name -- a model added by hand is only in - the second, and leaving it out would make it unremovable from here. */ +/* Every model this provider could serve: what the vendor named, plus what is + already configured and the vendor did not name. The second half is what + makes a hand-typed id count as present -- `exact` reads this list to decide + whether the search term is already on the provider, and without it typing an + id that is already there would offer to add it again. + What the popover DRAWS is the unadded part of this; see `shownRows`. */ export function allRows(sheet: Sheet, configured: string[], labels?: ProviderRow['labels']): ModelCandidate[] { const seen = new Set(sheet.items.map((m) => bare({ id: sheet.slug }, m.id))) /* A model somebody added by hand is not in the vendor's list, and its kind is @@ -59,9 +61,17 @@ export function allRows(sheet: Sheet, configured: string[], labels?: ProviderRow const matches = (m: ModelCandidate, q: string): boolean => !q || m.id.toLowerCase().includes(q) || (m.label || '').toLowerCase().includes(q) +/* What the popover draws: what is left to add. A model already on the provider + is not a choice here -- it was drawn ticked, taking a line each to say + nothing the reader can act on, and a vendor whose whole list is on already + opened as a full page of ticks with nothing to press. Taking one off is the + chip's job on the page behind (`ProviderDetail`, where the `x` also refuses + while a role is using it), which is where the models the provider HAS are + listed. */ export function shownRows(sheet: Sheet, configured: string[], labels?: ProviderRow['labels']): ModelCandidate[] { const q = sheet.q.trim().toLowerCase() return allRows(sheet, configured, labels) + .filter((m) => !isAdded({ id: sheet.slug }, m, configured)) .filter((m) => matches(m, q)) .filter((m) => sheet.kind === 'all' || m.kind === sheet.kind) } @@ -71,7 +81,9 @@ export function shownRows(sheet: Sheet, configured: string[], labels?: ProviderR promise of nothing. */ export function kindCounts(sheet: Sheet, configured: string[], labels?: ProviderRow['labels']): Array<['all' | Kind, number]> { const q = sheet.q.trim().toLowerCase() - const matched = allRows(sheet, configured, labels).filter((m) => matches(m, q)) + const matched = allRows(sheet, configured, labels) + .filter((m) => !isAdded({ id: sheet.slug }, m, configured)) + .filter((m) => matches(m, q)) const out: Array<['all' | Kind, number]> = [['all', matched.length]] for (const kind of KIND_ORDER) { const n = matched.filter((m) => m.kind === kind).length @@ -83,10 +95,25 @@ export function kindCounts(sheet: Sheet, configured: string[], labels?: Provider /* A gateway's ids are `vendor/model`, so they group; a direct vendor's are not, so they do not. Rows with no prefix trail the labelled groups rather than sitting between two of them, where they read as the previous group's tail. */ -export function groups(rows: ModelCandidate[]): Array<[string, ModelCandidate[]]> { +/* The id as this provider's own list would write it: without a leading prefix + naming the provider itself, case kept. A gateway's live list qualifies every + id with its own name -- `openrouter/anthropic/claude-opus-5.5` -- so grouping + on the first segment filed all of them under one head called "openrouter", + which is the provider the popover already belongs to. `bareModel` answers + the identity question and lowercases to do it; this one is for display. */ +export function ownId(host: ModelHost, id: string): string { + const lower = id.toLowerCase() + for (const head of host.routes?.length ? host.routes : [host.id]) { + if (lower.startsWith(`${head.toLowerCase()}/`)) return id.slice(head.length + 1) + } + return id +} + +export function groups(rows: ModelCandidate[], host: ModelHost): Array<[string, ModelCandidate[]]> { const by = new Map() for (const m of rows) { - const g = m.id.includes('/') ? m.id.split('/')[0]! : '' + const own = ownId(host, m.id) + const g = own.includes('/') ? own.split('/')[0]! : '' if (!by.has(g)) by.set(g, []) by.get(g)!.push(m) } @@ -97,6 +124,18 @@ export function groups(rows: ModelCandidate[]): Array<[string, ModelCandidate[]] return [...by.entries()].sort((a, b) => (a[0] === '' ? 1 : b[0] === '' ? -1 : a[0].localeCompare(b[0]))) } +/* What a row is called. The vendor's display name when it gave one -- "DeepSeek + V4 Flash" -- and otherwise the id without the group's own prefix: the head + over the row already says `deepseek`, and the raw id beside a display name + read as the same vendor spelled twice, once capitalised and once not. */ +export function rowName(m: ModelCandidate, group: string, host: ModelHost): string { + /* A gateway's "display name" is often just its id less the gateway -- + `anthropic/claude-opus-5.5` -- so the group's prefix comes off whichever of + the two is shown, not only off the id. */ + const text = m.label && m.label !== m.id ? m.label : ownId(host, m.id) + return group && text.startsWith(`${group}/`) ? text.slice(group.length + 1) : text +} + function KindChip({ value, onCycle }: { value: Kind; onCycle(next: Kind): void }): JSX.Element { return ( bare(p, m.id) === bare(p, q)) const typedKind = sheet.typed ?? guessKind(q) - const pending = rows.filter((m) => !isAdded(p, m, configured)) /* Below the button it hangs off, flipping above only when the room below is too short to be useful and there is more of it above. The height follows @@ -181,26 +219,35 @@ export function AddModelPop({ p }: { p: ProviderRow }): JSX.Element { } }, [p.id]) - const toggle = (m: ModelCandidate): void => { - const listed = isAdded(p, m, configured) - if (listed) { - /* The same refusal the tag list beside this popover makes: a model a role - runs on does not come off by a click here either. */ - const used = rolesUsing(store.get().snap, p.id, m.id) - if (used.length) { - store.refuse(t('gui.settings.providers.model_in_use', { roles: used.map(roleName).join(', '), model: m.id })) - return - } + /* Tick, then add. A click on a row only marks it; the write is the footer's, + for the ticked set or for everything shown. A click that wrote at once + made each pick its own round trip and its own redraw, and gave no way to + look over what was about to land before it did. The picks are counted + against what is still addable, so one that was added some other way + meanwhile stops being counted rather than being written twice. */ + const addable = allRows(sheet, configured, p.labels).filter((m) => !isAdded(p, m, configured)) + const picked = new Set(sheet.picked.filter((id) => addable.some((m) => m.id === id))) + const flip = (ids: string[], on: boolean): void => { + const next = new Set(sheet.picked) + for (const id of ids) { + if (on) next.add(id) + else next.delete(id) } - void store.sheetToggleModel(p.id, m.id, listed) + store.sheetPatch({ picked: [...next] }) + } + const shownOn = rows.filter((m) => picked.has(m.id)).length + const shownState = shownOn === 0 ? 'false' : shownOn === rows.length ? 'true' : 'mixed' + const commit = (ids: string[]): void => { + if (!ids.length) return + void store.run(`prov:${p.id}`, () => store.source().addModels(p.id, ids)) + .then((ok) => { if (ok) store.set({ sheet: null }) }) } + /* The typed row stays one press: it is an id the list does not carry, so + there is nothing in the list to tick. */ const addTyped = (): void => { void store.sheetToggleModel(p.id, q, false, typedKind) store.sheetPatch({ q: '', typed: null }) } - const addMany = (ids: string[]): void => { - if (ids.length) void store.run(`prov:${p.id}`, () => store.source().addModels(p.id, ids)) - } return createPortal((
@@ -219,9 +266,8 @@ export function AddModelPop({ p }: { p: ProviderRow }): JSX.Element { onChange={(e) => store.sheetPatch({ q: e.currentTarget.value, typed: null })} onKeyDown={(e) => { if (e.key !== 'Enter') return - const first = pending[0] - if (first) toggle(first) - else if (q && !exact) addTyped() + if (q && !exact && !rows.length) addTyped() + else commit([...picked]) }} />
@@ -254,36 +300,32 @@ export function AddModelPop({ p }: { p: ProviderRow }): JSX.Element { message, which is what hid a provider's own list behind its failure to enumerate one. */} {sheet.state === 'failed' &&
{t('gui.settings.providers.no_list')}
} - {sheet.state !== 'loading' && groups(rows).map(([name, ids]) => { - const folded = !!sheet.folded[name] - const unadded = ids.filter((m) => !isAdded(p, m, configured)) + {sheet.state !== 'loading' && groups(rows, p).map(([name, ids]) => { + /* A vendor's head is that vendor's select-all, drawn as the same box + its rows carry: all ticked, some (a dash), or none. It used to be a + fold on the name and a bare "+" at the far edge that wrote the whole + vendor at once -- a second way to add, with a different + commitment, that looked like neither a tick nor a button. */ + const on = ids.filter((m) => picked.has(m.id)).length + const state = on === 0 ? 'false' : on === ids.length ? 'true' : 'mixed' return ( -
+
{name && ( -
- - -
+ )} - {!folded && ids.map((m) => { - const has = isAdded(p, m, configured) + {ids.map((m) => { + const has = picked.has(m.id) return ( ) @@ -314,16 +356,38 @@ export function AddModelPop({ p }: { p: ProviderRow }): JSX.Element { )} + {/* Nothing left to add is not the same as nothing to add: a provider + carrying every model its vendor lists would otherwise be told it + has no models at all. */} {sheet.state !== 'loading' && !rows.length && !q && ( -
{t('gui.settings.providers.no_models_yet')}
+
+ {t(configured.length ? 'gui.settings.providers.all_added' : 'gui.settings.providers.no_models_yet')} +
)}
+ {/* One way to commit. "Select all" is a tick like every other on the list + -- over what is shown, so a search narrows what it takes -- and the + only button is the add. An "add all" button beside it was a second, + unreviewed way to write, and it did not look like the rest of a list + that is otherwise built by ticking. */}
- {t('gui.settings.providers.showing_added', { n: String(rows.length), m: String(configured.length) })} - {rows.length > 0 && ( - + )} + + {addable.length > 0 && ( + )}
diff --git a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx index f4b8d68ad..d1429c0b6 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx @@ -43,6 +43,59 @@ async function open(slug: string): Promise { } describe('provider detail', () => { + const many = (): ReturnType => { + const data = snap() + const row = data.providers.find((r) => r.id === 'openrouter')! + row.configured = Array.from({ length: 11 }, (_, i) => `openrouter/vendor/model-${i}`) + return data + } + const chips = (): string[] => [...document.querySelectorAll('.settings-tag2')].map((c) => c.firstChild!.textContent!) + + it('folds a long model list past eight, and a removal does not snap it shut', async () => { + /* A gateway takes models by the dozen; the list only ever grew, pushing the + rest of the pane out of reach. */ + install(many()) + await open('openrouter') + expect(chips()).toHaveLength(8) + const more = document.querySelector('.settings-tagmore') as HTMLButtonElement + expect(more.textContent).toBe('gui.settings.providers.models_more {"n":"3"}') + await act(async () => { fireEvent.click(more) }) + expect(chips()).toHaveLength(11) + /* A write redraws the page from a new snapshot; the fold lives in the store + so that redraw keeps it open. */ + await act(async () => { store.set({ epoch: store.get().epoch + 1 }) }) + expect(chips()).toHaveLength(11) + await act(async () => { fireEvent.click(document.querySelector('.settings-tagmore')!) }) + expect(chips()).toHaveLength(8) + }) + + it('names each chip without the provider in front, keeping the full id as its title', async () => { + install(many()) + await open('openrouter') + expect(chips()[0]).toBe('vendor/model-0') + expect(document.querySelector('.settings-tag2')!.getAttribute('title')).toBe('openrouter/vendor/model-0') + }) + + it('asks a keyless local server for its address once', async () => { + const data = snap() + data.providers.push({ id: 'vllm', name: 'vLLM', models: [], configured: [], on: false, kind: 'local', + acceptsKey: false, needsBase: true }) + install(data) + await open('vllm') + expect(screen.getAllByText('gui.settings.providers.base')).toHaveLength(1) + expect(document.querySelectorAll('.settings-tp-main input[aria-label="gui.settings.providers.base"]')).toHaveLength(1) + }) + + it('leaves the page one way out: the key link when there is one, the vendor site when not', async () => { + /* The name's arrow used to be `keyUrl || homepage`, the same page as "Get a + key" two lines below it. */ + install() + await open('anthropic') + expect(document.querySelectorAll('.settings-tp-main a.exlink')).toHaveLength(1) + expect(document.querySelector('.settings-tp-name a')).toBeNull() + expect(screen.getByText('gui.settings.get_key').closest('a')!.getAttribute('href')).toBe('https://console.anthropic.com/settings/keys') + }) + it('refuses to disconnect a provider a role runs on, naming the roles, and writes nothing', async () => { const { calls } = install() await open('anthropic') @@ -171,14 +224,41 @@ describe('provider detail', () => { return calls } - it('fetches the vendor list on open and writes one model per click', async () => { + const addPicked = (): HTMLButtonElement => document.querySelector('.settings-apfoot .mini.go') as HTMLButtonElement + const pickAll = (): HTMLElement => document.querySelector('.settings-apall') as HTMLElement + + it('a click ticks a row and writes nothing; the footer adds the ticked set in one call and closes', async () => { const calls = await catalogue([ { id: 'gpt-5', label: 'gpt-5', kind: 'text', added: false }, - { id: 'claude-opus-4-5', label: 'x', kind: 'text', added: true }, + { id: 'gpt-5-mini', label: 'gpt-5-mini', kind: 'text', added: false }, + { id: 'o4', label: 'o4', kind: 'text', added: false }, ]) expect(calls).toEqual([['fetchModels', 'anthropic']]) + expect(addPicked().disabled).toBe(true) await act(async () => { fireEvent.click(popRows()[0]!) }) - expect(calls[1]).toEqual(['provider', { op: 'add_model', slug: 'anthropic', model: 'gpt-5' }]) + await act(async () => { fireEvent.click(popRows()[2]!) }) + expect(popRows().map((r) => r.getAttribute('aria-checked'))).toEqual(['true', 'false', 'true']) + expect(calls).toHaveLength(1) + expect(addPicked().textContent).toBe('gui.settings.providers.add_picked {"n":"2"}') + /* A second click takes the tick off again. */ + await act(async () => { fireEvent.click(popRows()[2]!) }) + await act(async () => { fireEvent.click(popRows()[1]!) }) + await act(async () => { fireEvent.click(addPicked()) }) + expect(calls[1]).toEqual(['addModels', { slug: 'anthropic', models: ['gpt-5', 'gpt-5-mini'] }]) + expect(document.querySelector('.settings-apop')).toBeNull() + }) + + it('keeps a tick through a search that hides its row', async () => { + const calls = await catalogue([ + { id: 'gpt-5', label: 'gpt-5', kind: 'text', added: false }, + { id: 'o4', label: 'o4', kind: 'text', added: false }, + ]) + await act(async () => { fireEvent.click(popRows()[0]!) }) + await act(async () => { fireEvent.change(popSearch(), { target: { value: 'o4' } }) }) + await act(async () => { fireEvent.click(popRows()[0]!) }) + expect(addPicked().textContent).toBe('gui.settings.providers.add_picked {"n":"2"}') + await act(async () => { fireEvent.click(addPicked()) }) + expect(calls[calls.length - 1]).toEqual(['addModels', { slug: 'anthropic', models: ['gpt-5', 'o4'] }]) }) it('counts the kinds over the search result and narrows the rows to the tab', async () => { @@ -188,9 +268,9 @@ describe('provider detail', () => { { id: 'other-embed', label: 'other-embed', kind: 'embedding', added: false }, ]) const tabs = (): string[] => [...document.querySelectorAll('.settings-mkind')].map((b) => b.textContent!) - /* Five, not three: the two models already on this provider are rows too, - or a model added by hand could never be taken off from here. */ - expect(tabs()).toEqual(['gui.model.kind_all5', 'gui.model.type.text3', 'gui.model.type.embedding2']) + /* Three, not five: the harness's anthropic already carries two models, and + what is already on the provider is not a choice this list offers. */ + expect(tabs()).toEqual(['gui.model.kind_all3', 'gui.model.type.text1', 'gui.model.type.embedding2']) await act(async () => { fireEvent.change(popSearch(), { target: { value: 'gpt' } }) }) expect(tabs()).toEqual(['gui.model.kind_all2', 'gui.model.type.text1', 'gui.model.type.embedding1']) await act(async () => { fireEvent.click([...document.querySelectorAll('.settings-mkind')][2]!) }) @@ -219,52 +299,131 @@ describe('provider detail', () => { expect(calls[calls.length - 1]).toEqual(['provider', { op: 'add_model', slug: 'anthropic', model: 'typed-one' }]) }) - it('treats one model written two ways as one row, already added', async () => { + it('one model written two ways is one model, and the added spelling stays out', async () => { /* `model.fetch_models` returns provider-qualified ids; a model added by hand is stored as it was typed. The backend calls them the same model - (`wire.py` merge_key); drawing them as two rows offered to add one that - was already there and left "add all" at a number it could never reach. */ + (`wire.py` merge_key), so the qualified row for a bare configured id is + a model this provider already has, and offering to add it again is the + bug the merge key exists to stop. */ const calls = await catalogue([ { id: 'anthropic/claude-opus-4-5', label: 'Opus', kind: 'text', added: true }, { id: 'anthropic/claude-sonnet-4-5', label: 'Sonnet', kind: 'text', added: true }, { id: 'anthropic/claude-haiku', label: 'Haiku', kind: 'text', added: false }, ]) /* The harness's anthropic carries the two bare spellings as configured. */ - expect(popRows().map((r) => r.getAttribute('aria-checked'))).toEqual(['true', 'true', 'false']) - expect(popRows()).toHaveLength(3) - expect(screen.getByText('gui.model.add_all {"n":"1"}')).toBeTruthy() - await act(async () => { fireEvent.click(screen.getByText('gui.model.add_all {"n":"1"}')) }) + expect(popRows().map((r) => r.querySelector('.settings-apnm')!.textContent)).toEqual(['Haiku']) + await act(async () => { fireEvent.click(pickAll()) }) + await act(async () => { fireEvent.click(addPicked()) }) expect(calls[calls.length - 1]).toEqual(['addModels', { slug: 'anthropic', models: ['anthropic/claude-haiku'] }]) }) - it('adds everything shown in one call', async () => { + it('drops a model from the list the moment it is added, and says so when none is left', async () => { + /* The whole point of the list is what is not on the provider yet. A vendor + every one of whose models is already added used to open as a page of + ticks with nothing to press, and the footer offered "add all (0)". */ + const calls = await catalogue([ + { id: 'claude-opus-4-5', label: 'Opus', kind: 'text', added: true }, + { id: 'claude-sonnet-4-5', label: 'Sonnet', kind: 'text', added: true }, + ]) + expect(popRows()).toHaveLength(0) + expect(screen.getByText('gui.settings.providers.all_added')).toBeTruthy() + expect(screen.queryByText('gui.settings.providers.no_models_yet')).toBeNull() + expect(document.querySelector('.settings-apfoot .mini')).toBeNull() + expect(document.querySelector('.settings-apall')).toBeNull() + expect(calls.some((c) => c[0] === 'provider')).toBe(false) + }) + + it('"select all" ticks what is shown and writes nothing; the add is still the one write', async () => { + /* It replaced an "add all" button: a second way to write, beside a list + that is otherwise built by ticking and committed by one button. */ const calls = await catalogue([ { id: 'a', label: 'a', kind: 'text', added: false }, { id: 'b', label: 'b', kind: 'text', added: false }, + { id: 'zz', label: 'zz', kind: 'text', added: false }, ]) - await act(async () => { fireEvent.click(screen.getByText('gui.model.add_all {"n":"2"}')) }) - expect(calls[calls.length - 1]).toEqual(['addModels', { slug: 'anthropic', models: ['a', 'b'] }]) + expect(document.querySelectorAll('.settings-apfoot .mini')).toHaveLength(1) + expect(pickAll().getAttribute('aria-checked')).toBe('false') + await act(async () => { fireEvent.click(popRows()[0]!) }) + expect(pickAll().getAttribute('aria-checked')).toBe('mixed') + await act(async () => { fireEvent.click(pickAll()) }) + expect(pickAll().getAttribute('aria-checked')).toBe('true') + expect(calls).toHaveLength(1) + /* Over what is shown: a search narrows what it takes. */ + await act(async () => { fireEvent.click(pickAll()) }) + await act(async () => { fireEvent.change(popSearch(), { target: { value: 'z' } }) }) + await act(async () => { fireEvent.click(pickAll()) }) + await act(async () => { fireEvent.click(addPicked()) }) + expect(calls[calls.length - 1]).toEqual(['addModels', { slug: 'anthropic', models: ['zz'] }]) }) it('heads each vendor group and trails the bare rows with none', async () => { - const calls = await catalogue([ + await catalogue([ { id: 'zeta/one', label: 'Zeta One', kind: 'text', added: false }, { id: 'alpha/two', label: 'Alpha Two', kind: 'text', added: false }, { id: 'alpha/three', label: 'Alpha Three', kind: 'text', added: true }, ]) const heads = [...document.querySelectorAll('.settings-mgroup')] expect(heads.map((g) => g.querySelector('.settings-gn')!.textContent)).toEqual(['alpha', 'zeta']) - expect(heads.map((g) => g.querySelector('.settings-gc')!.textContent)).toEqual(['2', '1']) - /* The harness's anthropic carries two bare configured ids. They trail the - labelled groups and are drawn without a head of their own: a bare row - between two vendor heads reads as the previous vendor's tail, and an - "add this whole vendor" control over rows with no vendor means nothing. */ + /* One each: `alpha/three` is on the provider already, and the harness's two + bare configured ids are too, so neither the group counts nor the rows + carry them. */ + expect(heads.map((g) => g.querySelector('.settings-gc')!.textContent)).toEqual(['1', '1']) expect(popRows().map((r) => r.querySelector('.settings-apnm')!.textContent)) - .toEqual(['Alpha Two', 'Alpha Three', 'Zeta One', 'claude-opus-4-5', 'claude-sonnet-4-5']) - /* The group's control offers the group, not the page: `alpha/three` is - already on the provider and stays out of the frame. */ - await act(async () => { fireEvent.click(heads[0]!.querySelector('.settings-ga')!) }) - expect(calls[calls.length - 1]).toEqual(['addModels', { slug: 'anthropic', models: ['alpha/two'] }]) + .toEqual(['Alpha Two', 'Zeta One']) + expect(document.querySelector('.settings-ga')).toBeNull() + }) + + it('names a row by its display name, or by its id without the group prefix', async () => { + /* A hand-typed model has no display name, so it fell back to the raw id -- + `deepseek/1111` under a head that already says deepseek, beside rows + named "DeepSeek V4 Flash": one vendor, spelled twice in two cases. */ + await catalogue([ + { id: 'deepseek/deepseek-v4-pro', label: 'DeepSeek V4 Pro', kind: 'text', added: false }, + { id: 'deepseek/1111', label: 'deepseek/1111', kind: 'text', added: false }, + { id: 'bare-one', label: 'bare-one', kind: 'text', added: false }, + ]) + const names = popRows().map((r) => r.querySelector('.settings-apnm')!.textContent) + expect(names).toEqual(['DeepSeek V4 Pro', '1111', 'bare-one']) + /* The full id stays on the row for anyone who needs it. */ + expect(popRows()[1]!.getAttribute('title')).toBe('deepseek/1111') + }) + + it('groups a gateway\'s qualified ids by vendor, not under the gateway\'s own name', async () => { + /* A live gateway list writes every id with its own name in front -- + `openrouter/anthropic/claude-opus-5.5` -- and grouping on the first + segment filed all of them under one head named after the provider the + popover already belongs to. The harness provider here is anthropic. */ + await catalogue([ + { id: 'anthropic/meta/llama-4', label: 'anthropic/meta/llama-4', kind: 'text', added: false }, + { id: 'anthropic/qwen/qwen3-max', label: 'qwen/qwen3-max', kind: 'text', added: false }, + ]) + const heads = [...document.querySelectorAll('.settings-mgroup .settings-gn')].map((g) => g.textContent) + expect(heads).toEqual(['meta', 'qwen']) + expect(popRows().map((r) => r.querySelector('.settings-apnm')!.textContent)).toEqual(['llama-4', 'qwen3-max']) + }) + + it('a vendor head is that vendor\'s select-all: none, some, all', async () => { + /* It replaced a bare "+" at the head's far edge that wrote the whole vendor + at once -- a second, differently committing way to add, beside rows + that tick. */ + const calls = await catalogue([ + { id: 'alpha/one', label: 'A1', kind: 'text', added: false }, + { id: 'alpha/two', label: 'A2', kind: 'text', added: false }, + { id: 'zeta/one', label: 'Z1', kind: 'text', added: false }, + ]) + const head = (): HTMLElement => document.querySelectorAll('.settings-mgroup')[0]! + expect(head().getAttribute('aria-checked')).toBe('false') + await act(async () => { fireEvent.click(popRows()[0]!) }) + expect(head().getAttribute('aria-checked')).toBe('mixed') + await act(async () => { fireEvent.click(head()) }) + expect(head().getAttribute('aria-checked')).toBe('true') + expect(popRows().map((r) => r.getAttribute('aria-checked'))).toEqual(['true', 'true', 'false']) + expect(calls).toHaveLength(1) + await act(async () => { fireEvent.click(head()) }) + expect(popRows().map((r) => r.getAttribute('aria-checked'))).toEqual(['false', 'false', 'false']) + await act(async () => { fireEvent.click(head()) }) + await act(async () => { fireEvent.click(addPicked()) }) + expect(calls[calls.length - 1]).toEqual(['addModels', { slug: 'anthropic', models: ['alpha/one', 'alpha/two'] }]) }) it('an OAuth provider authorizes in the browser and shows the code until it lands or expires', async () => { diff --git a/ui-web/src/features/settings/providers/ProviderDetail.tsx b/ui-web/src/features/settings/providers/ProviderDetail.tsx index 3d7f7cf27..591c9762a 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.tsx @@ -8,6 +8,7 @@ import { ProviderIcon } from '../../../components/ProviderMark' import { t } from '../../../i18n/t' import { Fold, IconBtn, Rov, Sec } from '../Fields' import * as store from '../store' +import { ownId } from './AddModelPop' import { AZURE, OauthNote, kindOf, needsKey, takesBase, takesKey } from './Providers' import { roleName, rolesUsing } from './Roles' @@ -123,7 +124,11 @@ function Connection({ p }: { p: ProviderRow }): JSX.Element { )} - {kind !== 'oauth' && !takesKey(p) && ( + {/* The address for a provider that takes no key -- unless the block above + has already drawn it: a local server that takes an address and no + key met both conditions and showed "Server address" twice, each with + its own Connect. */} + {kind !== 'oauth' && !takesKey(p) && !(!needsKey(p) && takesBase(p)) && ( { const used = rolesUsing(s.snap, p.id, m) if (used.length) { store.refuse(t('gui.settings.providers.model_in_use', { roles: used.map(roleName).join(', '), model: m })); return } @@ -190,11 +198,23 @@ function Models({ p }: { p: ProviderRow }): JSX.Element { }> {/* The chips are the list -- a second heading over them ("models - available") said what the section's own label already says. */} + available") said what the section's own label already says. + Folded past a few: a gateway takes models by the dozen, and a list + that only ever grew pushed everything under it off the pane. A fold, + not a scrolling box -- the pane is itself the scroller, and a second + one inside it is a place the wheel gets stuck. Each chip names the + model as the provider's own list does, without the provider's name in + front of every one; the full id is its title. */} - {listed.map((m) => ( - {m} remove(m)}>{'\u00d7'} + {(all ? listed : listed.slice(0, MODELS_FOLDED)).map((m) => ( + {ownId(p, m)} remove(m)}>{'\u00d7'} ))} + {listed.length > MODELS_FOLDED && ( + + )} {!listed.length && {t('gui.settings.providers.no_models_yet')}} @@ -333,7 +353,13 @@ function Head({ p }: { p: ProviderRow }): JSX.Element { const state = p.on ? t('gui.settings.providers.connected') : t(kindOf(p) === 'oauth' ? 'gui.settings.providers.needs_auth' : kindOf(p) === 'local' ? 'gui.settings.providers.needs_base' : 'gui.settings.providers.needs_key') - const link = p.keyUrl || p.homepage + /* One way out of the page, not two. The name's arrow was `keyUrl || + homepage`, so on any provider with a key page it opened the same page as + "Get a key" two lines below it. It stays only where the key section has no + link of its own to offer -- a browser-authorized or local provider, or one + the registry knows no key page for -- and then it is the vendor's site. */ + const keyLinked = kindOf(p) !== 'oauth' && takesKey(p) && !!p.keyUrl + const link = keyLinked ? null : (p.homepage || p.keyUrl) return (
diff --git a/ui-web/src/features/settings/providers/Roles.test.tsx b/ui-web/src/features/settings/providers/Roles.test.tsx index b0c9a252a..651c5dc62 100644 --- a/ui-web/src/features/settings/providers/Roles.test.tsx +++ b/ui-web/src/features/settings/providers/Roles.test.tsx @@ -315,4 +315,42 @@ describe('model roles', () => { expect(second.calls).toEqual([]) expect(screen.getByRole('alert').textContent).toBe('gui.settings.roles.ctx_min {"n":1024}') }) + + it('the closed disclosure reads out all three values, and opening it hands them to the drawer', async () => { + install() + await mount('model') + const disc = screen.getByText('gui.settings.roles.params').closest('button') as HTMLButtonElement + const chunks = (): string[] => [...disc.querySelectorAll('.settings-sv')].map((n) => n.textContent || '') + expect(disc.getAttribute('aria-expanded')).toBe('false') + expect(chunks()).toEqual([ + 'gui.settings.roles.sum_effortgui.settings.roles.effort_low', + 'gui.settings.roles.sum_iters40 gui.settings.roles.times', + 'gui.settings.roles.sum_ctxgui.settings.roles.ctx_auto', + ]) + expect(document.querySelector('.settings-cfg')).toBeNull() + const row = disc.closest('.settings-row') as HTMLElement + expect(row.className).not.toContain('settings-open') + await act(async () => { fireEvent.click(disc) }) + expect(disc.getAttribute('aria-expanded')).toBe('true') + expect(chunks()).toEqual([]) + expect(document.querySelector('.settings-cfg')).toBeTruthy() + /* The row hands over its separator, which it only grows at all because the + drawer stopped it being the last child of its wrapper. */ + expect(row.className).toContain('settings-open') + }) + + it('a pinned window is the value the closed disclosure shows, and the mode sits beside it when open', async () => { + const pinned = snap() + ;(pinned.raw.agents as { defaults: Record }).defaults.contextWindowTokens = 300000 + install(pinned) + await mount('model') + const disc = screen.getByText('gui.settings.roles.params').closest('button') as HTMLButtonElement + expect([...disc.querySelectorAll('.settings-sv')].map((n) => n.textContent)).toContain('gui.settings.roles.sum_ctx300,000 tok') + await act(async () => { fireEvent.click(disc) }) + /* One row owns the window: the box that holds it and the two modes. */ + const row = screen.getByLabelText('gui.settings.roles.ctx_fixed').closest('.settings-row') as HTMLElement + expect(row.querySelector('.settings-k')?.textContent).toBe('gui.settings.roles.ctx') + expect([...row.querySelectorAll('.settings-seg button')].map((b) => b.textContent)) + .toEqual(['gui.settings.roles.ctx_auto', 'gui.settings.roles.ctx_pin']) + }) }) diff --git a/ui-web/src/features/settings/providers/Roles.tsx b/ui-web/src/features/settings/providers/Roles.tsx index e5a2b2ef0..f450f0173 100644 --- a/ui-web/src/features/settings/providers/Roles.tsx +++ b/ui-web/src/features/settings/providers/Roles.tsx @@ -315,15 +315,55 @@ const modelWindow = (snap: SettingsSnapshot): number | null => { return typeof win === 'number' && win > 0 ? win : null } +const EFFORTS: Array<[string, string]> = [ + ['minimal', 'gui.settings.roles.effort_minimal'], ['low', 'gui.settings.roles.effort_low'], + ['medium', 'gui.settings.roles.effort_medium'], ['high', 'gui.settings.roles.effort_high'], +] + +const effortWord = (v: string): string => + t(EFFORTS.find(([id]) => id === v)?.[1] ?? 'gui.settings.roles.effort_low') + +/* The three values the parameters drawer edits. Read out here rather than + inside it, because the closed trigger says what they are. */ +function chatParams(raw: Record): { effort: string; iters: number; pin: number | null } { + const pinned = dig(raw, 'agents.defaults.contextWindowTokens') + return { + effort: (dig(raw, 'agents.defaults.reasoningEffort') as string) || 'low', + iters: Number(dig(raw, 'agents.defaults.maxToolIterations')) || 40, + pin: typeof pinned === 'number' && pinned > 0 ? pinned : null, + } +} + +/* The disclosure the chat row carries: a chevron, the word, and -- while it is + closed -- the values behind it, so none of the three has to be opened to be + read. The labels are shorter than the drawer's own rows use: the summary has + to sit on one line beside the role's name, where the full ones would not. */ +function ParamsDisc(): JSX.Element { + const s = store.get() + const open = s.chatCfg + const { effort, iters, pin } = chatParams(s.snap.raw) + const sum: Array<[string, string]> = [ + [t('gui.settings.roles.sum_effort'), effortWord(effort)], + [t('gui.settings.roles.sum_iters'), `${iters} ${t('gui.settings.roles.times')}`], + [t('gui.settings.roles.sum_ctx'), pin ? `${pin.toLocaleString()} tok` : t('gui.settings.roles.ctx_auto')], + ] + return ( + + ) +} + /* The chat model's parameters: effort, the tool-iteration cap, the context window. Refusals are the page's: outside 1-200, below 1024 tokens. */ function ChatParams(): JSX.Element { const s = store.get() const raw = s.snap.raw - const effort = (dig(raw, 'agents.defaults.reasoningEffort') as string) || 'low' - const iters = Number(dig(raw, 'agents.defaults.maxToolIterations')) || 40 - const pinned = dig(raw, 'agents.defaults.contextWindowTokens') - const pin = typeof pinned === 'number' && pinned > 0 ? pinned : null + const { effort, iters, pin } = chatParams(raw) const win = modelWindow(s.snap) const bump = (d: number): void => { const step = iters >= 100 ? 10 : 1 @@ -341,8 +381,7 @@ function ChatParams(): JSX.Element {
[v, t(key)])} value={effort} onPick={(v) => void store.write('agents.defaults.reasoningEffort', v)} /> @@ -351,26 +390,27 @@ function ChatParams(): JSX.Element { - void store.write('agents.defaults.contextWindowTokens', v === 'auto' ? null : (win || 128000))} - /> + + {pin ? ( + <> + { if (Number(e.currentTarget.value) !== pin) pinChange(e.currentTarget.value) }} + onKeyDown={(e) => { if (e.key === 'Enter') pinChange(e.currentTarget.value) }} /> + tok + + ) : ( + {win ? `${win.toLocaleString()} tok` : t('gui.settings.roles.ctx_unknown')} + )} + void store.write('agents.defaults.contextWindowTokens', v === 'auto' ? null : (win || 128000))} + /> + - {pin ? ( - - - { if (Number(e.currentTarget.value) !== pin) pinChange(e.currentTarget.value) }} - onKeyDown={(e) => { if (e.key === 'Enter') pinChange(e.currentTarget.value) }} /> - tok - {win && pin > win && {t('gui.settings.roles.ctx_over', { n: win.toLocaleString() })}} - - - ) : ( - - {win ? `${win.toLocaleString()} tok` : t('gui.settings.roles.ctx_unknown')} - + {pin && win && pin > win && ( +
{t('gui.settings.roles.ctx_over', { n: win.toLocaleString() })}
)}
) @@ -382,12 +422,8 @@ export function Roles(): JSX.Element { {ROLES.map((r) => (
- store.set({ chatCfg: !s.chatCfg })}> - {t('gui.settings.roles.params')} {'⌄'} - - ) : undefined} /> + : undefined} /> )}> diff --git a/ui-web/src/features/settings/source.ts b/ui-web/src/features/settings/source.ts index e21dda8fc..8b095839e 100644 --- a/ui-web/src/features/settings/source.ts +++ b/ui-web/src/features/settings/source.ts @@ -175,6 +175,9 @@ const hubText = (v: unknown): string => interface AuthContribution { kind: string auth?: { fields?: AuthField[] } + /* An http or sse server carries a url; a stdio one carries the command. */ + connection?: { url?: string; command?: string; args?: string[] } + tools_preview?: string[] } const settingsErr = (e: unknown): string => { @@ -299,16 +302,24 @@ export const settingsSource: SettingsSource = { openSkillFile: (name, file) => run(gateway().call('skills.manage', { action: 'open', query: name, file }) .then(() => undefined)), uninstallSkill: (name) => run(gateway().call('skillhub.remove', { name }).then(afterExt)), - serverAuthFields: (name) => gateway().call('plughub.detail', { id: name }) + serverDetail: (name) => gateway().call('plughub.detail', { id: name }) .then((r) => { const entry = r.item as unknown as { contributes?: AuthContribution[] } const mcp = (entry.contributes || []).find((c) => c.kind === 'mcp') - return ((mcp && mcp.auth && mcp.auth.fields) || []).map((f) => ({ ...f, label: hubText(f.label) })) + const conn = (mcp && mcp.connection) || {} + const command = conn.command ? [conn.command, ...(conn.args || [])].join(' ') : undefined + return { + known: true, + fields: ((mcp && mcp.auth && mcp.auth.fields) || []).map((f) => ({ ...f, label: hubText(f.label) })), + address: conn.url || command, + tools: (mcp && mcp.tools_preview) || [], + } }) - /* A server the catalogue does not carry is the ordinary case, not a - failure to report: the panel says so itself when the list comes back - empty. */ - .catch(() => []), + /* A server the catalogue does not carry is ordinary -- anything added by + hand is one -- so this is a miss, not a failure. It is reported as a + miss rather than as an empty entry because the panel must not read it + as "takes no credential". */ + .catch(() => ({ known: false, fields: [], tools: [] })), toggleServer: (name, on) => run(gateway().call('plug.toggle', { name, enabled: on }).then(afterExt)), retryServer: (name) => run(gateway().call('plug.retry', { name }).then(afterExt)), revokeServer: (name) => run(gateway().call('plug.revoke', { name }).then(afterExt)), diff --git a/ui-web/src/features/settings/store.test.ts b/ui-web/src/features/settings/store.test.ts index 1d68c8266..3f1afc144 100644 --- a/ui-web/src/features/settings/store.test.ts +++ b/ui-web/src/features/settings/store.test.ts @@ -33,7 +33,7 @@ describe('settings store', () => { }) it('setTab closes every drawer of the section it leaves', () => { - store.set({ provider: 'openai', sheet: { slug: 'openai', q: '', state: 'ready', items: [], kind: 'all', folded: {}, typed: null }, skill: 's', toolOpen: 'exec', plugOpen: 'p', err: 'oops' }) + store.set({ provider: 'openai', sheet: { slug: 'openai', q: '', state: 'ready', items: [], kind: 'all', picked: [], typed: null }, skill: 's', toolOpen: 'exec', plugOpen: 'p', err: 'oops' }) store.setTab('tools') const s = store.get() expect(s.tab).toBe('tools') diff --git a/ui-web/src/features/settings/store.ts b/ui-web/src/features/settings/store.ts index c223ae25f..4ad06e14e 100644 --- a/ui-web/src/features/settings/store.ts +++ b/ui-web/src/features/settings/store.ts @@ -72,8 +72,9 @@ export interface Sheet { items: ModelCandidate[] /* The kind tab in force, or every kind. */ kind: 'all' | Kind - /* Vendor groups the reader has collapsed, by prefix. */ - folded: Record + /* Ticked and not yet added, by model id. Kept across a search or a kind + tab, so narrowing the list to find the next one does not drop the last. */ + picked: string[] /* What a typed id would be added as, once the reader has said; null means the guess from its name still stands. */ typed: Kind | null @@ -130,6 +131,10 @@ export interface SettingsState { skq: string toolOpen: string | null plugOpen: string | null + /* The provider whose model list is shown in full rather than folded. Here, + not in the list's own state: a removal redraws the page from a new + snapshot, and a fold kept in the component snapped shut on every one. */ + modelsAll: string | null archived: ArchivedSession[] | null } @@ -163,6 +168,7 @@ const initial = (): SettingsState => ({ skq: '', toolOpen: null, plugOpen: null, + modelsAll: null, archived: null, }) @@ -289,7 +295,7 @@ export function setTab(id: string): void { settingsTab.id = id set({ tab: curTab(), err: '', provider: null, provQ: '', provFilt: 'all', provAdd: null, sheet: null, hdrAdd: null, ovlAdd: null, adv: null, - skill: null, detail: null, toolOpen: null, plugOpen: null, + skill: null, detail: null, toolOpen: null, plugOpen: null, modelsAll: null, }) if (moved) settingsDialog.enterSection(id) } @@ -357,7 +363,7 @@ export async function skillOpen(name: string): Promise { with no list endpoint answers a status other than ok, and the sheet then takes a typed id alone. */ export async function sheetOpen(slug: string): Promise { - set({ sheet: { slug, q: '', state: 'loading', items: [], kind: 'all', folded: {}, typed: null } }) + set({ sheet: { slug, q: '', state: 'loading', items: [], kind: 'all', picked: [], typed: null } }) let items: ModelCandidate[] = [] let ok = false try { diff --git a/ui-web/src/features/settings/styles.css b/ui-web/src/features/settings/styles.css index 8b706bb72..998b47666 100644 --- a/ui-web/src/features/settings/styles.css +++ b/ui-web/src/features/settings/styles.css @@ -115,15 +115,6 @@ textarea.settings-tbox {height:auto} .settings-setup .mini.ghost { background: none; color: var(--muted); } .settings-setup .mini.danger { color: var(--clay); border-color: var(--clay); } -.settings-mini {font:inherit; font-size:13px; border:1px solid var(--line); border-radius:var(--ctl-r); - background:var(--paper); color:var(--text); padding:0 13px; cursor:pointer; height:var(--ctl-h); box-sizing:border-box; - display:inline-flex; align-items:center; justify-content:center; min-width:64px; white-space:nowrap} -/* The floor is per language: two Chinese characters do not fill the English - frame, and one number leaves Delete and Restore half empty. */ -:root[data-lang="en"] .settings-mini {min-width:88px} -.settings-mini:hover {border-color:var(--amber)} -.settings-mini.settings-ghost {background:none; color:var(--muted)} -.settings-mini.settings-danger {color:var(--clay); border-color:var(--clay)} .settings-rov {font-family:var(--mono); font-size:13px; color:var(--muted)} .settings-chip {display:inline-flex; align-items:center; gap:5px; font-size:11px; font-weight:600; padding:1px 7px; border-radius:99px; border:1px solid transparent} .settings-chip .settings-led {width:6px; height:6px; border-radius:99px; background:currentColor} @@ -261,9 +252,6 @@ footer {border-top:1px solid var(--line); margin:48px 0 0; padding:18px 0 70px; height:var(--ctl-h); box-sizing:border-box} .settings-kvform input:focus,.settings-kvform select:focus {outline:none; border-color:var(--amber); box-shadow:var(--ctl-focus)} .settings-kvform select {font-family:var(--mono)} -.settings-padd {margin:10px 15px 6px; border-color:var(--line)} -.settings-padd .settings-row .settings-k:empty {display:none} -.settings-padd .settings-row:last-child {grid-template-columns:132px minmax(0,1fr)} .settings-mlist {border-top:1px solid var(--line-soft); background:var(--paper)} .settings-mls {display:flex; align-items:center; gap:9px; padding:9px 15px; border-bottom:1px solid var(--line-soft)} .settings-mls svg {width:14px; height:14px; fill:none; stroke:var(--faint); stroke-width:1.7; stroke-linecap:round} @@ -279,7 +267,6 @@ footer {border-top:1px solid var(--line); margin:48px 0 0; padding:18px 0 70px; .settings-mli.settings-add .settings-sd {color:var(--gold)} .settings-mlempty {padding:26px 14px; color:var(--faint); font-size:13px; text-align:center} .settings-mlf {display:flex; align-items:center; gap:8px; padding:9px 15px; border-top:1px solid var(--line-soft)} -.settings-mini[disabled] {opacity:.45; cursor:not-allowed} .settings-ctl {position:relative} /* Placed by lib/popover.ts, which sets position, top, left and z-index: the row it hangs off lives inside .spanels, whose overflow clips an absolutely @@ -309,7 +296,6 @@ footer {border-top:1px solid var(--line); margin:48px 0 0; padding:18px 0 70px; .settings-arow .settings-ameta {display:flex; gap:10px; align-items:center; color:var(--faint); font-size:12px; margin-top:3px} .settings-arow .settings-ameta .settings-kd {font-family:var(--mono); border:1px solid var(--line); border-radius:4px; padding:0 5px} .settings-arow .settings-aacts {display:flex; gap:7px; flex:0 0 auto} -.settings-mini.settings-ghost.settings-danger {color:var(--clay); border-color:transparent} .settings-pathv {display:inline-flex; align-items:center; gap:7px; min-width:0} .settings-pathv .settings-pp {font-family:var(--mono); font-size:13px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap} .settings-iconbtn {display:inline-grid; place-items:center; width:24px; height:24px; flex:0 0 24px; padding:0; cursor:pointer; @@ -317,36 +303,133 @@ footer {border-top:1px solid var(--line); margin:48px 0 0; padding:18px 0 70px; .settings-iconbtn:hover {color:var(--text); border-color:var(--line); background:var(--raised)} .settings-iconbtn:focus-visible {outline:2px solid var(--amber); outline-offset:1px} .settings-iconbtn svg {width:14px; height:14px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round} -.settings-mini.settings-ghost.settings-danger:hover {border-color:var(--clay)} .settings-warnmark {color:var(--clay); font-weight:600} .settings-req {font-size:11px; font-family:var(--disp); font-weight:600; letter-spacing:.04em; color:var(--gold); background:var(--amber-dp); border-radius:4px; padding:0 5px} .settings-card > .settings-ch.settings-hasact {display:flex; align-items:center; justify-content:space-between; gap:10px; padding-top:9px; padding-bottom:8px} -.settings-cfg {margin:2px 15px 12px 15px; padding:6px 14px 8px; border:1px solid var(--line-soft); border-radius:8px; background:var(--paper)} -.settings-cfg .settings-row {display:grid; grid-template-columns:132px minmax(0,1fr); gap:6px 14px; padding:5px 0; border:0; align-items:center} +/* Settings that belong to the row above: not a card inside a card, but the same + table indented one step and tied back to that row by a rail. A boxed panel + reads as a second object with its own edges, which is what makes a list of + them look like stacked cards; the rail says "these belong to that line" and + spends no border doing it. The controls sit a notch under the page's own + (28px against 32px) and line up with the parent row's control edge rather + than starting a second column, so an open row keeps one alignment, not two. + .settings-padd opts back out to a box: it is a standalone form, not a row's + dependents. */ +.settings-cfg {position:relative; margin:0; padding:0 15px 12px 31px; background:transparent} +.settings-cfg::before {content:""; position:absolute; left:15px; top:2px; bottom:14px; width:2px; border-radius:2px; + background:var(--faint); opacity:.45} +.settings-cfg .settings-row {display:grid; grid-template-columns:minmax(0,1fr) auto; gap:0 14px; min-height:34px; padding:1px 0; + border:0; align-items:center} .settings-cfg .settings-row .settings-k {font-size:13px; color:var(--muted)} -.settings-cfg .settings-row .settings-ctl {justify-self:start; margin:0} -.settings-cfg .settings-row.settings-stack {grid-template-columns:132px minmax(0,1fr)} -.settings-cfg .settings-row.settings-stack .settings-ctl {margin:0} -.settings-cfg .settings-tbox {min-width:220px; padding:0 9px; font-size:12px} -.settings-cfg .settings-mini {font-size:12px; padding:4px 10px} +.settings-cfg .settings-row .settings-ctl {justify-self:end; margin:0} +/* Three shapes, because a drawer holds three kinds of line. + + A labelled control keeps the two columns above: it joins the column of + controls the page already has down its right edge. + + A stacked field puts its label over a control that takes the whole width -- + which is what `stack` means everywhere else on this page, and what the key + fields ask for. The drawer was overriding it back into two columns, so a + label two characters long sat a full panel-width away from its own input. + + A sentence has no label at all. Left in the two-column grid it was pushed + against the right edge with nothing to pair with; prose reads from the left, + so the empty cell collapses and the line spans. */ +.settings-cfg .settings-row.settings-stack {grid-template-columns:minmax(0,1fr)} +.settings-cfg .settings-row.settings-stack .settings-ctl {justify-self:stretch; margin-top:5px} +/* The field takes the width the stack just gave the row, which puts its + buttons at the right end where the row's own controls are. */ +.settings-cfg .settings-row.settings-stack .settings-taglist {width:100%} +.settings-cfg .settings-row.settings-stack .settings-tbox {flex:1; min-width:180px} +.settings-cfg .settings-row .settings-k:empty {display:none} +.settings-cfg .settings-row:has(> .settings-k:empty) {grid-template-columns:minmax(0,1fr)} +.settings-cfg .settings-row:has(> .settings-k:empty) .settings-ctl {justify-self:stretch} +/* A server's own facts. + + Two type styles, and no third: 13px sans for anything written for a reader, + 12px mono for anything written for a machine -- an address, a command, a + tool id, a handshake error. That pair is the drawer's own already (its row + labels are 13 sans, its boxes and readouts 12 mono), and mono set one step + under the sans it sits beside is what makes the two look the same size; at + equal px the mono reads larger. This block had grown four sizes in six + words -- 13.5, 12, 12, 11.5 -- which is what a jumble is made of. + + Hierarchy is carried by colour instead: --faint for the label and for names + nobody has to read, --muted for the value, --clay for the failure. */ +.settings-pnote {font-size:13px; color:var(--muted)} +.settings-pbad {font-family:var(--mono); font-size:12px; color:var(--clay); overflow-wrap:anywhere} +/* The reason on the left, wrapping; the retry on the right at the reason's + first line, where the row's own controls stand. */ +.settings-pfail {display:flex; align-items:flex-start; gap:14px; padding:4px 0 8px} +.settings-pfail .settings-pbad {flex:1; min-width:0; line-height:1.55; padding-top:5px} +.settings-pfail .mini {flex:none} +.settings-pfacts {display:grid; grid-template-columns:max-content minmax(0,1fr); gap:3px 14px; margin:3px 0 0; + align-items:baseline} +.settings-pfacts dt {font-size:13px; color:var(--faint)} +.settings-pfacts dd {margin:0; min-width:0; font-size:13px; color:var(--muted); overflow:hidden; + text-overflow:ellipsis; white-space:nowrap} +.settings-pmono {font-family:var(--mono); font-size:12px} +.settings-ptnames {font-family:var(--mono); font-size:12px; color:var(--faint); margin-left:9px} +.settings-cfg .settings-taglist {gap:10px} +.settings-cfg .settings-cfnote {font-size:12px; color:var(--clay); text-align:right; padding:0 0 4px} +/* One size for everything a drawer holds: 28px and 12px, against the dialog's + own 32px and 13px (page.css's `.smodal .mini` sets that scale). The sheet + already claimed the step down and did not keep it -- only the stepper was + 28, while the segment, the select, the box and the button all stayed 32, and + the button's rule named `.settings-mini`, a class no markup carries. Reached + into from here because these are the page's controls embedded in this + domain's container; this sheet loads after page.css, which is what lets an + equal-specificity rule tune them. */ +.settings-cfg .mini,.settings-cfg .sel,.settings-cfg .settings-tbox,.settings-cfg .settings-seg {height:28px; font-size:12px} +.settings-cfg .mini {padding:0 11px} +.settings-cfg .sel {padding:0 24px 0 9px} +.settings-cfg .settings-tbox {min-width:220px; padding:0 9px} +.settings-cfg .settings-ctxbox {min-width:0; width:104px; text-align:right} +.settings-cfg .settings-stepper {height:28px} .settings-cfg .settings-stepper button {height:26px; width:24px} .settings-cfg .settings-stepper .settings-v {font-size:12px; min-width:52px} -.settings-cfg .settings-seg button {font-size:12px; padding:4px 10px} +.settings-cfg .settings-seg button {font-size:12px; padding:0 10px} .settings-cfg .settings-rov {font-size:12px} +.settings-cfg .settings-fl2 {font-size:12px} .settings-cfg .settings-swi {width:34px; height:20px} .settings-cfg .settings-swi::after {width:14px; height:14px} .settings-cfg .settings-swi[aria-checked="true"]::after {transform:translateX(14px)} -.settings-xrow[aria-expanded="true"] {border-bottom-color:transparent} +/* An open row gives up its own separator: a line between a row and the panel it + just opened reads as a third row, and on the role rows -- which carry no + separator at all -- one appeared out of nowhere on expand, because opening + the drawer is what stopped the row being its wrapper's :last-child. The tool + and plugin rows do carry one, and hand it to the drawer below. */ +.settings-row.settings-open,.settings-xrow.settings-open {border-bottom:0} +.settings-xrow.settings-open + .settings-cfg {border-bottom:1px solid var(--line-soft)} +.settings-xrow.settings-open + .settings-cfg:last-child {border-bottom:0} +/* The add-a-provider block opts back out to a box: it is a standalone form with + its own edges, not a row's dependents, so it keeps the border, the ground and + the label column, and drops the rail. These outrank the rules above by + naming both classes -- they sit later in the sheet as well, because the + single-class ones would otherwise win every declaration the two share. */ +.settings-cfg.settings-padd {margin:10px 15px 6px; padding:6px 14px 8px; border:1px solid var(--line); border-radius:8px; + background:var(--paper)} +.settings-cfg.settings-padd::before {display:none} +.settings-cfg.settings-padd .settings-row {grid-template-columns:132px minmax(0,1fr); gap:6px 14px; padding:5px 0; + min-height:0} +.settings-cfg.settings-padd .settings-row .settings-k:empty {display:none} +.settings-cfg.settings-padd .settings-row .settings-ctl {justify-self:start} .settings-xrow {cursor:pointer} .settings-xrow:hover {background:var(--surface)} .settings-xrow .settings-k {gap:9px} -.settings-xrow .settings-chev {display:inline-grid; place-items:center; width:18px; height:18px; border:1px solid var(--line); border-radius:5px; - background:var(--raised); color:var(--muted); transition:transform .16s, border-color .16s, color .16s} -.settings-xrow:hover .settings-chev {border-color:var(--amber); color:var(--gold); background:var(--amber-dp)} -.settings-xrow[aria-expanded="true"] .settings-chev {transform:rotate(90deg); border-color:var(--amber); color:var(--gold); background:var(--amber-dp)} -.settings-xrow .settings-chev svg {width:11px; height:11px; fill:none; stroke:currentColor; stroke-width:2.2; stroke-linecap:round; stroke-linejoin:round} +/* The same chevron the role rows and the page's own folds show: a mark that + turns. In a bordered box it read as a second control beside the name, and + gave one list three looks at once -- a grey box, an amber-filled box, and + nothing at all on a row with no panel. The element IS the svg, so it carries + its own stroke; the `.settings-chev svg` rule matched nothing. The two + [aria-expanded] rules matched nothing either: the attribute is on the button + inside the row, not on the row. */ +.settings-xrow .settings-chev {flex:none; width:11px; height:11px; color:var(--faint); fill:none; stroke:currentColor; + stroke-width:2.2; stroke-linecap:round; stroke-linejoin:round; transition:transform .16s, color .16s} +.settings-xrow:hover .settings-chev {color:var(--muted)} +@media (prefers-reduced-motion:reduce) {.settings-xrow .settings-chev {transition:none}} .settings-row.settings-dim .settings-k {color:var(--faint)} -.settings-row.settings-dim .settings-chev {opacity:.5} +.settings-xrow.settings-dim .settings-chev {opacity:.5} .settings-srch {display:flex; align-items:center; gap:9px; border:1px solid var(--line); border-radius:var(--ctl-r); background:var(--paper); padding:0 11px; height:var(--ctl-h); box-sizing:border-box; margin-bottom:14px} .settings-srch:focus-within {border-color:var(--amber); box-shadow:var(--ctl-focus)} @@ -419,20 +502,35 @@ footer {border-top:1px solid var(--line); margin:48px 0 0; padding:18px 0 70px; .settings-row .settings-rk {display:block} .settings-row .settings-rk .settings-rt {font-size:14px; display:flex; align-items:center; gap:7px} .settings-row .settings-rk .settings-rd {font-size:13px; color:var(--faint); margin-top:2px; line-height:1.45} -.settings-lnk {font:inherit; font-size:13px; color:var(--muted); background:none; border:0; padding:0; margin-top:5px; cursor:pointer; display:inline-flex; align-items:center; gap:4px} -.settings-lnk:hover {color:var(--text)} -.settings-lnk .settings-ch {display:inline-block; transition:transform .16s; font-size:11px} -.settings-lnk[aria-expanded="true"] .settings-ch {transform:rotate(180deg)} +/* A row's disclosure: the page's own fold (.foldcap -- chevron, word, chevron + turns when open), tuned to sit under a role's description and to carry the + values it hides while it is closed, so the row answers "what is this set to" + without being opened. The word alone was an 11px amber link: it read as + navigation, it was the size of one, and it said nothing about the three + values behind it. This sheet loads after page.css, which is what lets these + tune .foldcap's own declarations. */ +.settings-disc {font-size:12px; line-height:1.3; margin:5px 0 0 -4px; padding:3px 8px 3px 4px; border-radius:6px; + flex-wrap:wrap; gap:3px 6px; max-width:100%; text-align:left} +.settings-disc:hover {background:var(--line-soft)} +.settings-disc .settings-sl {white-space:nowrap} +.settings-disc .settings-sv {color:var(--text); font-variant-numeric:tabular-nums; white-space:nowrap} +/* The separator trails its own value rather than leading the next, so a + summary that wraps starts the new line on a label, not on a dot. */ +.settings-disc .settings-sv:not(:last-child)::after {content:"\00b7"; color:var(--faint); margin-left:6px} +.settings-disc .settings-sn {color:var(--faint); margin-right:3px} .settings-opttag {font-size:11px; font-family:var(--disp); font-weight:600; letter-spacing:.04em; color:var(--muted); background:var(--raised); border-radius:4px; padding:0 5px} /* The expandable rows (tools, plugins): the name is the button, the switch and the chip stay outside it. */ -.settings-xrow {display:flex; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--line)} +/* --line-soft, the weight every other row on this page separates with; these + were a shade darker, so a list of tools read as ruled and a list of roles + did not. */ +.settings-xrow {display:flex; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--line-soft)} .settings-xrow:last-child {border-bottom:0} .settings-xrow.settings-dim .settings-xn {color:var(--muted)} .settings-xname {display:inline-flex; align-items:center; gap:9px; flex:1; min-width:0; font:inherit; color:inherit; background:none; border:0; padding:0; text-align:left; cursor:pointer} .settings-xname .settings-xn {font-size:13.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap} -.settings-xrow.settings-open .settings-chev {transform:rotate(90deg); border-color:var(--amber); color:var(--gold); background:var(--amber-dp)} +.settings-xrow.settings-open .settings-chev {transform:rotate(90deg); color:var(--amber)} .settings-lnkico {margin-left:6px; vertical-align:middle} /* ---- Model providers: the catalogue in two columns ------------------------ @@ -469,6 +567,26 @@ footer {border-top:1px solid var(--line); margin:48px 0 0; padding:18px 0 70px; .settings-tp-dot {width:7px; height:7px; border-radius:50%; background:var(--moss); flex:none} .settings-tp-empty {padding:12px 9px; color:var(--faint); font-size:13px} .settings-tp-main {padding:17px 20px 20px; min-width:0; min-height:0; overflow-y:auto} +/* Every one-line field on a provider's page is one width, and every button + beside one stands at the pane's right edge -- the edge "Add model" already + stands at. The field is the row less one button slot, whether or not that + row has its button: sized by their contents the boxes came out 186 and 208 + wide, and filling their row instead made the one address box with no button + beside it wider than every other. The slot is the dialog's own button floor + (`.smodal .mini`, per language), so a two-character label fills it exactly. */ +.settings-tp-main {--tp-act:64px} +:root[data-lang="en"] .settings-tp-main {--tp-act:88px} +.settings-tp-main .settings-sec .settings-tbox,.settings-tp-main .settings-sec .keyfield, +.settings-tp-main .settings-sec .selw {width:calc(100% - var(--tp-act) - 6px); max-width:none; min-width:0; flex:none; + box-sizing:border-box} +.settings-tp-main .keyfield > .settings-tbox,.settings-tp-main .selw > .sel {width:100%} +.settings-tp-main .settings-taglist:has(> .settings-tbox),.settings-tp-main .settings-taglist:has(> .keyfield), +.settings-tp-main .settings-taglist:has(> .selw) {flex-wrap:nowrap; width:100%} +.settings-tp-main .settings-taglist:has(> .settings-tbox) > .mini,.settings-tp-main .settings-taglist:has(> .keyfield) > .mini, +.settings-tp-main .settings-taglist:has(> .selw) > .mini {min-width:var(--tp-act); margin-left:auto} +/* The empty model list is a sentence under the heading, at the heading's edge; + the list page's empty state carries padding this one does not want. */ +.settings-tp-main .settings-taglist > .settings-tp-empty {padding:2px 0} .settings-tp-none {display:grid; place-items:center; padding:60px 20px; color:var(--faint); font-size:13px} .settings-tp-head {display:flex; align-items:flex-start; gap:10px} .settings-tp-head .provider-icon {width:30px; height:30px; border-radius:8px} @@ -490,6 +608,10 @@ footer {border-top:1px solid var(--line); margin:48px 0 0; padding:18px 0 70px; /* The get-a-key link is a named link, not a bare arrow: an icon alone beside "API key" does not say that what it opens is where the key comes from. */ .settings-lab .exlink {margin:0; font-size:13px; font-weight:400} +/* The label is bold and every button on the page inherits its weight, so a + control sitting in the label -- "Add model" beside "Models" -- came out as + bold as the heading it is not. Same for the hint after it. */ +.settings-lab .mini,.settings-lab .settings-sub2,.settings-tp-name .exlink {font-weight:400} /* ---- Add model: a catalogue that hangs off its button --------------------- The prototype's `apop` family, prefixed. Positioned in JS against the button @@ -507,22 +629,45 @@ footer {border-top:1px solid var(--line); margin:48px 0 0; padding:18px 0 70px; .settings-mkind i {font-style:normal; font-family:var(--mono); font-size:11px; color:var(--faint)} .settings-apbody {flex:1; min-height:0; overflow-y:auto; padding:6px} .settings-apwait {display:flex; padding:18px 14px; color:var(--faint); font-size:13px; text-align:center; align-items: center; gap: 9px} -.settings-mgroup {display:flex; align-items:center; gap:4px; padding:8px 6px 3px} -.settings-gt {flex:1; min-width:0; display:flex; align-items:center; gap:7px; font:inherit; background:none; border:0; color:var(--muted); padding:2px 4px; border-radius:6px; cursor:pointer; text-align:left} -.settings-gt:hover {background:var(--surface)} +/* A vendor's head is its select-all, the same box as its rows. The rows sit + one step in, their box under the head's name rather than under the head's + box: at one edge a head read as one more row, and the list lost the shape + that says which models the head's tick covers. */ +.settings-mgroup {display:flex; align-items:center; gap:9px; width:100%; margin-top:6px; padding:5px 8px; font:inherit; + background:none; border:0; border-radius:7px; color:var(--muted); text-align:left; cursor:pointer} .settings-gn {font-size:11px; font-weight:600; letter-spacing:.04em} .settings-gc {font-family:var(--mono); font-size:10px; color:var(--faint)} -.settings-ga {flex:none; width:22px; height:22px; border:0; border-radius:6px; background:none; color:var(--faint); cursor:pointer; font-size:14px; line-height:1} -.settings-ga:hover:not([disabled]) {background:var(--raised); color:var(--text)} -.settings-ga[disabled] {opacity:.3; cursor:not-allowed} +.settings-apgrp .settings-apm {padding-left:32px} +/* The fold at the end of a provider's model chips: a word in the chips' own + line, not a button in a box, so it reads as the list's tail. */ +.settings-tagmore {font:inherit; font-size:12px; color:var(--muted); background:none; border:0; padding:3px 6px; + border-radius:6px; cursor:pointer} +.settings-tagmore:hover {color:var(--text); background:var(--surface)} +/* The footer's select-all: the rows' tick, at the rows' left edge. */ +.settings-apall {display:inline-flex; align-items:center; gap:9px; font:inherit; font-size:12px; color:var(--muted); + background:none; border:0; padding:4px 6px 4px 0; border-radius:6px; cursor:pointer} +.settings-apall:hover {color:var(--text)} +.settings-apall:hover .settings-aptick {border-color:var(--muted)} +.settings-apall[aria-checked="true"] .settings-aptick, +.settings-apall[aria-checked="mixed"] .settings-aptick {background:var(--amber); border-color:var(--amber); color:var(--ink-on-accent)} .settings-apm {display:flex; align-items:center; gap:9px; width:100%; min-width:0; text-align:left; font:inherit; font-size:13px; background:none; border:0; color:var(--text); padding:6px 8px; border-radius:7px; cursor:pointer} .settings-apm:hover {background:var(--surface)} -.settings-aptick {width:15px; height:15px; flex:none; border-radius:4px; border:1.5px solid var(--line); display:grid; place-items:center; font-size:10px; color:var(--ink)} -.settings-apm[aria-checked="true"] .settings-aptick {background:var(--amber); border-color:var(--amber); color:var(--ink-on-accent)} +/* A tick box: the list is a selection, and the footer commits it. The glyph + inside is inked in the ground colour, so it only shows once the box fills. */ +.settings-aptick {width:15px; height:15px; flex:none; border-radius:4px; border:1.5px solid var(--line); display:grid; + place-items:center; font-size:10px; line-height:1; color:var(--ink)} +/* A vendor head does not light up on hover: a filled band there read as a + selection of its own, a second state beside the box's. Only the box answers + the pointer, the way it does on a row. */ +.settings-apm:hover .settings-aptick,.settings-mgroup:hover .settings-aptick {border-color:var(--muted)} +.settings-apm[aria-checked="true"] .settings-aptick, +.settings-mgroup[aria-checked="true"] .settings-aptick, +.settings-mgroup[aria-checked="mixed"] .settings-aptick {background:var(--amber); border-color:var(--amber); color:var(--ink-on-accent)} .settings-apnm {flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:var(--mono); font-size:12px} -.settings-apm[aria-checked="true"] .settings-apnm {color:var(--muted)} .settings-apsd {font-family:var(--sans); font-size:11.5px; color:var(--faint); flex:none} .settings-apadd {color:var(--gold)} +/* The typed-id row's box is dashed and holds a plus: it is the one row that is + not a tick -- one press adds an id the list does not carry. */ .settings-apadd .settings-aptick {border-style:dashed; border-color:var(--gold); color:var(--gold)} .settings-apadd .settings-apsd {color:var(--gold)} .settings-apkind {display:inline-flex; align-items:center; gap:4px; flex:none; font:inherit; font-size:11px; color:var(--muted); border:1px solid var(--line); border-radius:6px; padding:1px 7px; background:var(--paper); cursor:pointer} diff --git a/ui-web/src/features/settings/types.ts b/ui-web/src/features/settings/types.ts index fdb48446d..977428e10 100644 --- a/ui-web/src/features/settings/types.ts +++ b/ui-web/src/features/settings/types.ts @@ -182,6 +182,18 @@ export interface AuthField { help_url?: string } +/* What the catalogue knows about one installed server, beside what the runtime + row already carries. `known` is the difference between "this server takes no + credential" and "the catalogue has no entry for it": both leave `fields` + empty, and only one of them is a thing to tell the reader. */ +export interface McpDetail { + known: boolean + fields: AuthField[] + /* A URL for an http or sse server, a command line for a stdio one. */ + address?: string + tools: string[] +} + export interface SettingsSource { load(): Promise set(key: string, value: unknown): Promise @@ -217,7 +229,7 @@ export interface SettingsSource { uninstallSkill(name: string): Promise /* The credential fields the catalogue declares for this server, empty for one nobody installed from the catalogue. A read, so no snapshot back. */ - serverAuthFields(name: string): Promise + serverDetail(name: string): Promise toggleServer(name: string, on: boolean): Promise retryServer(name: string): Promise revokeServer(name: string): Promise diff --git a/ui-web/src/rpc/fixtures/ext.ts b/ui-web/src/rpc/fixtures/ext.ts index 533897b58..78d81fcd5 100644 --- a/ui-web/src/rpc/fixtures/ext.ts +++ b/ui-web/src/rpc/fixtures/ext.ts @@ -66,7 +66,14 @@ const PLUGINS: ExtPlugin[] = [ /* One server per state the rows have to be able to draw: connected, waiting for an authorization, failed, and switched off. The rail's attention badge - counts the middle two, which is why both are here. */ + counts the middle two, which is why both are here. + + `name` is also the catalogue id the drawer looks the server up by, and each + row's `auth` has to match the mode its catalogue entry names (see + fixtures/plughub.ts): github was filed there as `github-mcp` with `oauth` + while the row here says `apikey`, so its drawer found no entry, read the + miss as an empty field list, and told the reader a server whose token had + expired needed no credential. */ const MCP: ExtMcp[] = [ { name: 'websearch', transport: 'http', state: 'auth_required', connected: false, tool_count: 2, enabled: true, auth: 'oauth', credentialed: false }, diff --git a/ui-web/src/rpc/fixtures/model.ts b/ui-web/src/rpc/fixtures/model.ts index 7838c609f..dc4b43ef2 100644 --- a/ui-web/src/rpc/fixtures/model.ts +++ b/ui-web/src/rpc/fixtures/model.ts @@ -36,6 +36,17 @@ interface Entry { kinds?: Record } +/* What each vendor lists and this install has NOT added, per slug. Kept apart + from `models` because that field is what the provider carries; this is what + the add-a-model list has to offer. */ +const UNADDED: Record = { + anthropic: ['claude-haiku-4-5', 'claude-opus-4-1'], + openai: ['gpt-5', 'o4-mini'], + deepseek: ['deepseek-r2'], + minimax: ['minimax/MiniMax-Text-01'], + openrouter: ['meta-llama/llama-4-maverick', 'qwen/qwen3-max', 'mistralai/mistral-large-3'], +} + const PROVIDERS: Entry[] = [ { slug: 'minimax', name: 'MiniMax (Global)', homepage: 'https://platform.minimax.io/', models: ['minimax/MiniMax-M3', 'minimax/MiniMax-M2'], authenticated: true, auth_type: 'key' }, @@ -150,12 +161,23 @@ export function createModel(_env: FixtureEnv): ModelFixture { }, /* Nothing to ask: the drawer's list is whatever the row already declares, which is the honest answer with no provider behind it. */ - 'model.fetch_models': (p) => ({ - models: (find((p as { slug: string }).slug) || { models: [] }).models.map((id) => ({ - id, label: id, kind: 'chat', added: true, - })), - status: 'ok', - }), + /* A vendor lists more than any one install has added -- that gap is the + whole content of the add-a-model list, and returning the configured + rows back verbatim left the offline page unable to show it at all: + every row came back `added` and the list had nothing to offer. The + catalogue is the row's own models plus a few of this vendor's that + nobody added. */ + 'model.fetch_models': (p) => { + const row = find((p as { slug: string }).slug) + const extra = row ? (UNADDED[row.slug] || []) : [] + return { + models: [...(row ? row.models : []), ...extra].map((id) => ({ + id, label: id, kind: (row && row.kinds && row.kinds[id]) || 'chat', + added: !!row && row.models.includes(id), + })), + status: 'ok', + } + }, 'model.add_models': (p) => { const row = find(p.slug) for (const m of p.models) if (row && !row.models.includes(m)) row.models.push(m) diff --git a/ui-web/src/rpc/fixtures/plughub.ts b/ui-web/src/rpc/fixtures/plughub.ts index 1c6b59272..65bfedc53 100644 --- a/ui-web/src/rpc/fixtures/plughub.ts +++ b/ui-web/src/rpc/fixtures/plughub.ts @@ -32,14 +32,14 @@ interface Entry { const CATS = ['developer', 'productivity', 'data'] const MARKET: Entry[] = [ - { id: 'github-mcp', name: 'GitHub', publisher: 'github.com', verified: true, + { id: 'github', name: 'GitHub', publisher: 'github.com', verified: true, summary: '读 issue 与 PR,提交评论', category: 'developer', tool_preview_count: 3, risk_tier: 1, installed: false, version: '1.4.0', homepage: 'https://github.com/mcp', description: '连上 GitHub 的官方 MCP 服务,读写 issue、PR 与评论。', contributes: [{ kind: 'mcp', connection: { type: 'http', url: 'https://api.githubcopilot.com/mcp' }, - auth: { mode: 'oauth' }, + auth: { mode: 'apikey', fields: [{ key: 'token', label: 'Token', secret: true, help_url: 'https://github.com/settings/tokens' }] }, tools_preview: ['list_issues', 'get_pr', 'create_comment'] }] }, { id: 'websearch', name: '网页搜索', publisher: 'serper.dev', verified: true, summary: '让 Raven 查得到网上的实时信息', category: 'data', @@ -48,7 +48,7 @@ const MARKET: Entry[] = [ description: '接入 serper.dev 的搜索接口,Raven 可以自己找资料。', contributes: [{ kind: 'mcp', connection: { type: 'http', url: 'https://mcp.serper.dev' }, - auth: { mode: 'apikey', fields: [{ key: 'api_key', label: 'API Key', secret: true, help_url: 'https://serper.dev' }] }, + auth: { mode: 'oauth' }, tools_preview: ['web_search', 'web_news'] }] }, { id: 'sqlite', name: 'SQLite', publisher: 'raven-tools', verified: false, summary: '在本机查询与修改 SQLite 数据库', category: 'data', diff --git a/ui-web/src/test/settingsHarness.ts b/ui-web/src/test/settingsHarness.ts index a2dd9f1f8..2b88f018a 100644 --- a/ui-web/src/test/settingsHarness.ts +++ b/ui-web/src/test/settingsHarness.ts @@ -182,7 +182,12 @@ export function install(data: SettingsSnapshot = snap(), over: Partial rec('uninstallSkill', name), /* A read the credential panel makes on its own, so it is not recorded: a case asserting what a row's buttons wrote would have to skip past it. */ - serverAuthFields: async () => [{ key: 'token', label: 'Token', help_url: 'https://github.com/settings/tokens' }], + serverDetail: async () => ({ + known: true, + fields: [{ key: 'token', label: 'Token', help_url: 'https://github.com/settings/tokens' }], + address: 'https://api.githubcopilot.com/mcp', + tools: ['list_issues', 'get_pr'], + }), toggleServer: async (name, on) => rec('toggleServer', { name, on }), retryServer: async (name) => rec('retryServer', name), revokeServer: async (name) => rec('revokeServer', name),