From 593a5dc13c317e4d56c976e1332d27434bf0fd3c Mon Sep 17 00:00:00 2001 From: arelchan <204152633+arelchan@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:42:41 +0800 Subject: [PATCH 1/3] feat(ui-web): move channels out of settings and beside agents under one rail row Channels were a section of the settings dialog, so a reader who never opened settings never learned Raven can be reached from a chat app. They are a page of their own now, and share one rail row ("Connections") with the agent hub: two module pages, each still its own island, under one set of underlined tabs (components/HubHead.tsx). state/hub.ts remembers the module the reader was last on, so the rail row goes back to it. The channel page is drawn the way the agent hub is: filter tabs over a grid of fixed-height cards, and the picked one in the shared detail drawer, sized and spaced like the agent sheet. The list's switches and search are gone: a press on a card only opens it, and connecting happens in the sheet. Cards say three things for the five states underneath: connected, not connected, and a connection problem (switched on, host up, adapter not running), which is red and sorted first with a line saying what to do. An adapter waiting on a scan counts as not connected. No host at all is one notice over the grid instead of a reason on every card. The gateway's refusal from a write is kept on the row and shown in the sheet instead of a toast. The sheet drops the mono captions, says where credentials come from in one guide box with the console link, says "leave blank to keep" once instead of in every box, counts what is missing only in its head, moves disconnect to the far end of the foot, and lays a scan channel out as the code's frame beside its three steps. Co-authored-by: Claude (claude-opus-5-5) --- i18n/messages.json | 218 +++---- ui-tui/src/i18n/messages.generated.ts | 136 +++-- .../scripts/__golden__/boot-live-noserver.txt | 153 +++-- ui-web/scripts/__golden__/boot-stub.txt | 153 +++-- ui-web/scripts/check-class-namespace.mjs | 9 +- .../scripts/gates/rail-nav-registry.test.mjs | 3 +- ui-web/src/App.test.tsx | 6 +- ui-web/src/App.tsx | 3 +- ui-web/src/chrome/Rail.test.tsx | 6 +- ui-web/src/chrome/Rail.tsx | 15 +- ui-web/src/components/HubHead.test.tsx | 37 ++ ui-web/src/components/HubHead.tsx | 40 ++ .../connections/ConnectionsPage.test.tsx | 421 +++++++------- .../features/connections/ConnectionsPage.tsx | 530 ++++++++++-------- .../ConnectionsPage.test.tsx.snap | 125 +++-- ui-web/src/features/connections/manifest.ts | 6 +- .../src/features/connections/source.test.ts | 30 +- ui-web/src/features/connections/source.ts | 19 +- ui-web/src/features/connections/store.ts | 73 ++- ui-web/src/features/connections/styles.css | 191 +++++++ ui-web/src/features/connections/types.ts | 6 +- .../src/features/extAgents/ExtAgentsPage.tsx | 8 +- .../__snapshots__/ExtAgentsPage.test.tsx.snap | 7 +- ui-web/src/features/extAgents/store.ts | 2 + .../features/settings/SettingsApp.test.tsx | 2 +- ui-web/src/features/settings/SettingsApp.tsx | 4 +- ui-web/src/features/settings/store.ts | 18 +- ui-web/src/state/detail.ts | 8 +- ui-web/src/state/escapeOrder.test.ts | 18 +- ui-web/src/state/escapeOrder.ts | 6 +- ui-web/src/state/hub.test.ts | 64 +++ ui-web/src/state/hub.ts | 58 ++ ui-web/src/state/page.test.ts | 2 +- ui-web/src/state/pages.ts | 13 +- ui-web/src/state/portals.test.ts | 2 +- ui-web/src/state/settings.ts | 15 +- ui-web/src/styles/page.css | 77 ++- .../__golden__/region-connectionsPage.txt | 5 + ui-web/src/test/__golden__/region-setVeil.txt | 1 - ui-web/src/test/portals.test.ts | 4 +- 40 files changed, 1551 insertions(+), 943 deletions(-) create mode 100644 ui-web/src/components/HubHead.test.tsx create mode 100644 ui-web/src/components/HubHead.tsx create mode 100644 ui-web/src/features/connections/styles.css create mode 100644 ui-web/src/state/hub.test.ts create mode 100644 ui-web/src/state/hub.ts create mode 100644 ui-web/src/test/__golden__/region-connectionsPage.txt diff --git a/i18n/messages.json b/i18n/messages.json index 9c749b7ff..ea00e8583 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -1388,8 +1388,8 @@ "zh": "接入" }, "gui.conn.field_set": { - "en": "set - leave blank to keep", - "zh": "已设置 · 留空则保持不变" + "en": "Saved", + "zh": "已设置" }, "gui.conn.saved_x": { "en": "{name} saved", @@ -1400,32 +1400,20 @@ "zh": "登录二维码" }, "gui.conn.qr_done": { - "en": "Signed in.", - "zh": "已登录。" + "en": "Signed in", + "zh": "已登录" }, "gui.conn.qr_noenc": { - "en": "A code is pending, but this install cannot draw it. Reinstall Raven to pick up the encoder.", - "zh": "有待扫的登录码,但这个安装画不出来。重装 Raven 即可带上编码器。" + "en": "Couldn't draw the code. Reinstalling Raven fixes this.", + "zh": "二维码生成失败,重新安装 Raven 可以修复" }, "gui.conn.qr_scan": { - "en": "Scan it with the app on your phone.", - "zh": "用手机上的 App 扫这个码。" + "en": "Scan with the app on your phone", + "zh": "用手机上的 App 扫一扫" }, "gui.conn.qr_wait": { - "en": "Waiting for a sign-in code...", - "zh": "正在等待登录码…" - }, - "gui.conn.st_down": { - "en": "Turned on, but not running", - "zh": "已开启,但没有在运行" - }, - "gui.conn.st_unknown": { - "en": "Cannot tell - the gateway is not answering", - "zh": "状态未知 —— gateway 没有应答" - }, - "gui.conn.st_unpaired": { - "en": "Running, waiting to be signed in", - "zh": "已运行,等待登录" + "en": "Getting a code...", + "zh": "正在生成二维码…" }, "gui.conn.disconnect": { "en": "Disconnect", @@ -1443,34 +1431,6 @@ "en": "No channels available yet", "zh": "还没有可用的渠道" }, - "gui.conn.none_match": { - "en": "No channel matches.", - "zh": "没有匹配的渠道。" - }, - "gui.conn.pick": { - "en": "Pick a channel on the left", - "zh": "在左边选一个渠道" - }, - "gui.conn.search": { - "en": "Search channels…", - "zh": "搜索渠道…" - }, - "gui.conn.g_on": { - "en": "In service", - "zh": "已接入" - }, - "gui.conn.g_off": { - "en": "Available", - "zh": "可接入" - }, - "gui.conn.cost_scan": { - "en": "scan to sign in", - "zh": "扫码登录" - }, - "gui.conn.cost_n": { - "en": "{n} credentials", - "zh": "{n} 项凭据" - }, "gui.conn.st_off": { "en": "switched off", "zh": "已停用" @@ -1487,13 +1447,21 @@ "en": "Receiving on {n} entries", "zh": "{n} 个入口在收消息" }, - "gui.conn.creds": { - "en": "Credentials", - "zh": "凭据" - }, "gui.conn.apply": { - "en": "Where to get these", - "zh": "去申请" + "en": "Open console", + "zh": "前往" + }, + "gui.conn.guide": { + "en": "Create these in {name}'s developer console, then paste them below.", + "zh": "凭据要在{name}的开发者后台创建,拿到后填在下面" + }, + "gui.conn.keep_hint": { + "en": "Leave a saved field blank to keep it.", + "zh": "已设置的项留空即保持不变" + }, + "gui.conn.qr_idle": { + "en": "The code appears here", + "zh": "二维码会显示在这里" }, "gui.conn.toggled": { "en": "{name} {state} - reopen the Raven app to apply", @@ -1503,10 +1471,6 @@ "en": "{name} {state}", "zh": "{name} 已{state}" }, - "gui.conn.toggle_failed": { - "en": "{name} did not start - {detail}", - "zh": "{name} 没有启动 —— {detail}" - }, "gui.conn.out_missing_dep": { "en": "its package is not installed.", "zh": "缺少这个渠道的依赖包。" @@ -3211,9 +3175,17 @@ "en": "{n} failed", "zh": "{n} 失败" }, - "gui.nav.agents": { - "en": "Agent Connector", - "zh": "智能体连接" + "gui.nav.hub": { + "en": "Connectors", + "zh": "连接" + }, + "gui.hub.agents": { + "en": "Agents", + "zh": "智能体" + }, + "gui.hub.channels": { + "en": "Channels", + "zh": "渠道" }, "gui.nav.conn": { "en": "Channels", @@ -4991,14 +4963,6 @@ "en": "Signs in by scanning a code on your phone.", "zh": "手机扫码登录" }, - "gui.conn.foot_need": { - "en": "{n} required boxes left", - "zh": "还需 {n} 项必填" - }, - "gui.conn.foot_clean": { - "en": "unchanged", - "zh": "未修改" - }, "gui.conn.foot_dirty": { "en": "unsaved", "zh": "未保存" @@ -5012,12 +4976,12 @@ "zh": "发件(SMTP)" }, "gui.conn.w1_done": { - "en": "Entry is on", - "zh": "入口已启用" + "en": "Started", + "zh": "已启动" }, "gui.conn.w1_idle": { - "en": "Turn the entry on", - "zh": "启用入口" + "en": "Press Connect", + "zh": "点「接入」启动" }, "gui.conn.w2": { "en": "Scan with your phone", @@ -5032,8 +4996,8 @@ "zh": "开始收消息" }, "gui.conn.w_wait": { - "en": "Waiting for the scan. Closing this card does not cancel it.", - "zh": "等待扫描 · 关掉卡片不会取消" + "en": "Waiting for the scan. Closing this won't stop it.", + "zh": "等待扫码,关掉这张卡片不会中断" }, "gui.conn.w_done": { "en": "Done", @@ -5044,12 +5008,12 @@ "zh": "关闭" }, "gui.conn.w2_blocked": { - "en": "Raven App is not running - open it and the code appears here", - "zh": "Raven 后台没在运行 · 打开 Raven App 就会启动并出现二维码" + "en": "Raven isn't running in the background. The code appears once it is.", + "zh": "Raven 后台没在运行,运行后这里会出现二维码" }, "gui.conn.w2_down": { - "en": "It started and then stopped - a code nobody scanned expires. Try again.", - "zh": "入口启动过又停了 —— 没人扫的码会过期。可以重试。" + "en": "The code expired. Try again for a new one.", + "zh": "二维码过期了,点「重试」重新生成" }, "gui.conn.w_retry": { "en": "Try again", @@ -5059,18 +5023,6 @@ "en": "the preset now uses {to}", "zh": "预设现在走 {to}" }, - "gui.conn.tag_down": { - "en": "not started", - "zh": "未启动" - }, - "gui.conn.tag_unknown": { - "en": "state unknown", - "zh": "状态未知" - }, - "gui.conn.tag_nohost": { - "en": "nothing is running it", - "zh": "后台未运行" - }, "gui.conn.retry": { "en": "Try again", "zh": "重试" @@ -6187,10 +6139,6 @@ "en": "Saved. It applies once the engine restarts.", "zh": "已保存 · 重启引擎后生效" }, - "gui.conn.not_receiving": { - "en": "The enabled entry is not receiving yet - open the Raven app and it starts.", - "zh": "已启用的入口还没在收消息——打开 Raven App 就会启动" - }, "gui.sess.branch_empty": { "en": "This session has no content yet, so it cannot be branched", "zh": "这个会话还没有内容,无法分叉" @@ -10222,6 +10170,82 @@ "gui.pb.stint_status_finished": { "en": "finished", "zh": "已完成" + }, + "gui.conn.page": { + "en": "Channels", + "zh": "渠道" + }, + "gui.conn.tab_all": { + "en": "All", + "zh": "全部" + }, + "gui.conn.tab_on": { + "en": "Connected", + "zh": "已接入" + }, + "gui.conn.tab_off": { + "en": "Not connected", + "zh": "未接入" + }, + "gui.conn.foot_off": { + "en": "Not connected", + "zh": "未接入" + }, + "gui.conn.foot_on": { + "en": "Connected", + "zh": "已接入" + }, + "gui.conn.foot_bad": { + "en": "Connection problem", + "zh": "连接异常" + }, + "gui.conn.line_bad_creds": { + "en": "Credentials may be wrong. Open to update them.", + "zh": "凭据可能有误,点开重新填写" + }, + "gui.conn.line_bad_scan": { + "en": "Sign-in didn't finish. Open to scan again.", + "zh": "登录没成功,点开重新扫码" + }, + "gui.conn.st_bad": { + "en": "Couldn't connect", + "zh": "没能连上" + }, + "gui.conn.st_bad_why": { + "en": "Couldn't connect: {why}", + "zh": "没能连上:{why}" + }, + "gui.conn.host_down": { + "en": "Raven isn't running in the background, so channels can't receive messages right now.", + "zh": "Raven 后台没在运行,渠道暂时收不到消息" + }, + "gui.conn.line_live": { + "en": "Messages you send there reach Raven", + "zh": "在这里发的消息 Raven 都能收到" + }, + "gui.conn.line_scan": { + "en": "Sign in by scanning a code with your phone", + "zh": "用手机扫码登录即可接入" + }, + "gui.conn.line_creds": { + "en": "Needs keys from its developer console", + "zh": "需要从开发者后台拿 {n} 项凭据" + }, + "gui.conn.line_ready": { + "en": "Credentials saved, not switched on", + "zh": "凭据已填好,还没开启" + }, + "gui.conn.line_saved": { + "en": "Credentials saved", + "zh": "凭据已填好" + }, + "gui.conn.none_on": { + "en": "No channel connected yet", + "zh": "还没有接入任何渠道" + }, + "gui.conn.none_off": { + "en": "Every channel is connected", + "zh": "所有渠道都已接入" } } } diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index fedf1deb8..559f2f3a2 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -532,36 +532,27 @@ export const UI_TEXT: Record> = { 'gui.confirm.asker': 'asked by {name}', 'gui.conn.as_you': '{who} - sends and receives as you', 'gui.conn.connect': 'Connect', - 'gui.conn.field_set': 'set - leave blank to keep', + 'gui.conn.field_set': 'Saved', 'gui.conn.saved_x': '{name} saved', 'gui.conn.qr_alt': 'Sign-in code', - 'gui.conn.qr_done': 'Signed in.', - 'gui.conn.qr_noenc': 'A code is pending, but this install cannot draw it. Reinstall Raven to pick up the encoder.', - 'gui.conn.qr_scan': 'Scan it with the app on your phone.', - 'gui.conn.qr_wait': 'Waiting for a sign-in code...', - 'gui.conn.st_down': 'Turned on, but not running', - 'gui.conn.st_unknown': 'Cannot tell - the gateway is not answering', - 'gui.conn.st_unpaired': 'Running, waiting to be signed in', + 'gui.conn.qr_done': 'Signed in', + 'gui.conn.qr_noenc': "Couldn't draw the code. Reinstalling Raven fixes this.", + 'gui.conn.qr_scan': 'Scan with the app on your phone', + 'gui.conn.qr_wait': 'Getting a code...', 'gui.conn.disconnect': 'Disconnect', 'gui.conn.disabled': 'disabled', 'gui.conn.enabled': 'enabled', 'gui.conn.none': 'No channels available yet', - 'gui.conn.none_match': 'No channel matches.', - 'gui.conn.pick': 'Pick a channel on the left', - 'gui.conn.search': 'Search channels…', - 'gui.conn.g_on': 'In service', - 'gui.conn.g_off': 'Available', - 'gui.conn.cost_scan': 'scan to sign in', - 'gui.conn.cost_n': '{n} credentials', 'gui.conn.st_off': 'switched off', 'gui.conn.st_live': 'receiving', 'gui.conn.st_missing': '{n} credentials still needed', 'gui.conn.gw_live': 'Receiving on {n} entries', - 'gui.conn.creds': 'Credentials', - 'gui.conn.apply': 'Where to get these', + 'gui.conn.apply': 'Open console', + 'gui.conn.guide': "Create these in {name}'s developer console, then paste them below.", + 'gui.conn.keep_hint': 'Leave a saved field blank to keep it.', + 'gui.conn.qr_idle': 'The code appears here', 'gui.conn.toggled': '{name} {state} - reopen the Raven app to apply', 'gui.conn.toggled_now': '{name} {state}', - 'gui.conn.toggle_failed': '{name} did not start - {detail}', 'gui.conn.out_missing_dep': 'its package is not installed.', 'gui.conn.out_bad_config': 'the gateway refused its settings; its log names the field.', 'gui.conn.out_deny_all': 'its allow-list is empty, so it would let nobody in.', @@ -990,7 +981,9 @@ export const UI_TEXT: Record> = { 'gui.model.unset': 'not set', 'gui.model.update': 'Update', 'gui.n_failed': '{n} failed', - 'gui.nav.agents': 'Agent Connector', + 'gui.nav.hub': 'Connectors', + 'gui.hub.agents': 'Agents', + 'gui.hub.channels': 'Channels', 'gui.nav.conn': 'Channels', 'gui.nav.cron': 'Schedules', 'gui.nav.more': 'More', @@ -1444,26 +1437,21 @@ export const UI_TEXT: Record> = { 'gui.connf.user_id': 'User ID', 'gui.agent.kind_builtin': 'built-in', 'gui.conn.cost_scan_line': 'Signs in by scanning a code on your phone.', - 'gui.conn.foot_need': '{n} required boxes left', - 'gui.conn.foot_clean': 'unchanged', 'gui.conn.foot_dirty': 'unsaved', 'gui.conn.g_imap': 'Receiving (IMAP)', 'gui.conn.g_smtp': 'Sending (SMTP)', - 'gui.conn.w1_done': 'Entry is on', - 'gui.conn.w1_idle': 'Turn the entry on', + 'gui.conn.w1_done': 'Started', + 'gui.conn.w1_idle': 'Press Connect', 'gui.conn.w2': 'Scan with your phone', 'gui.conn.w2_done': 'Scanned', 'gui.conn.w3': 'Start receiving', - 'gui.conn.w_wait': 'Waiting for the scan. Closing this card does not cancel it.', + 'gui.conn.w_wait': "Waiting for the scan. Closing this won't stop it.", 'gui.conn.w_done': 'Done', 'gui.conn.close': 'Close', - 'gui.conn.w2_blocked': 'Raven App is not running - open it and the code appears here', - 'gui.conn.w2_down': 'It started and then stopped - a code nobody scanned expires. Try again.', + 'gui.conn.w2_blocked': "Raven isn't running in the background. The code appears once it is.", + 'gui.conn.w2_down': 'The code expired. Try again for a new one.', 'gui.conn.w_retry': 'Try again', 'gui.agent.stale_to': 'the preset now uses {to}', - 'gui.conn.tag_down': 'not started', - 'gui.conn.tag_unknown': 'state unknown', - 'gui.conn.tag_nohost': 'nothing is running it', 'gui.conn.retry': 'Try again', 'gui.agent.migrate_do': 'Switch transport', 'gui.agent.migrate_body': @@ -1776,7 +1764,6 @@ export const UI_TEXT: Record> = { 'gui.op.branch_failed': 'Could not branch - {detail}', 'gui.op.saved_next_turn': 'Saved. It applies from the next turn.', 'gui.op.saved_restart': 'Saved. It applies once the engine restarts.', - 'gui.conn.not_receiving': 'The enabled entry is not receiving yet - open the Raven app and it starts.', 'gui.sess.branch_empty': 'This session has no content yet, so it cannot be branched', 'gui.sess.branched_n': 'Branched, carrying {n} messages', 'gui.model.protocol_unsupported': 'This WebUI backend cannot set the API type', @@ -2811,7 +2798,26 @@ export const UI_TEXT: Record> = { 'gui.pb.when_hour': '{n} h ago', 'gui.pb.when_day': '{n} d ago', 'gui.pb.stints_unsupported': 'This engine has no runs surface; update it to see multi-round runs here.', - 'gui.pb.stint_status_finished': 'finished' + 'gui.pb.stint_status_finished': 'finished', + 'gui.conn.page': 'Channels', + 'gui.conn.tab_all': 'All', + 'gui.conn.tab_on': 'Connected', + 'gui.conn.tab_off': 'Not connected', + 'gui.conn.foot_off': 'Not connected', + 'gui.conn.foot_on': 'Connected', + 'gui.conn.foot_bad': 'Connection problem', + 'gui.conn.line_bad_creds': 'Credentials may be wrong. Open to update them.', + 'gui.conn.line_bad_scan': "Sign-in didn't finish. Open to scan again.", + 'gui.conn.st_bad': "Couldn't connect", + 'gui.conn.st_bad_why': "Couldn't connect: {why}", + 'gui.conn.host_down': "Raven isn't running in the background, so channels can't receive messages right now.", + 'gui.conn.line_live': 'Messages you send there reach Raven', + 'gui.conn.line_scan': 'Sign in by scanning a code with your phone', + 'gui.conn.line_creds': 'Needs keys from its developer console', + 'gui.conn.line_ready': 'Credentials saved, not switched on', + 'gui.conn.line_saved': 'Credentials saved', + 'gui.conn.none_on': 'No channel connected yet', + 'gui.conn.none_off': 'Every channel is connected' }, zh: { 'gui.act.ing.ask_user': '等待你回答', @@ -3065,36 +3071,27 @@ export const UI_TEXT: Record> = { 'gui.confirm.asker': '{name} 请求', 'gui.conn.as_you': '{who} · 以你的身份收发消息', 'gui.conn.connect': '接入', - 'gui.conn.field_set': '已设置 · 留空则保持不变', + 'gui.conn.field_set': '已设置', 'gui.conn.saved_x': '{name} 配置已保存', 'gui.conn.qr_alt': '登录二维码', - 'gui.conn.qr_done': '已登录。', - 'gui.conn.qr_noenc': '有待扫的登录码,但这个安装画不出来。重装 Raven 即可带上编码器。', - 'gui.conn.qr_scan': '用手机上的 App 扫这个码。', - 'gui.conn.qr_wait': '正在等待登录码…', - 'gui.conn.st_down': '已开启,但没有在运行', - 'gui.conn.st_unknown': '状态未知 —— gateway 没有应答', - 'gui.conn.st_unpaired': '已运行,等待登录', + 'gui.conn.qr_done': '已登录', + 'gui.conn.qr_noenc': '二维码生成失败,重新安装 Raven 可以修复', + 'gui.conn.qr_scan': '用手机上的 App 扫一扫', + 'gui.conn.qr_wait': '正在生成二维码…', 'gui.conn.disconnect': '断开', 'gui.conn.disabled': '停用', 'gui.conn.enabled': '启用', 'gui.conn.none': '还没有可用的渠道', - 'gui.conn.none_match': '没有匹配的渠道。', - 'gui.conn.pick': '在左边选一个渠道', - 'gui.conn.search': '搜索渠道…', - 'gui.conn.g_on': '已接入', - 'gui.conn.g_off': '可接入', - 'gui.conn.cost_scan': '扫码登录', - 'gui.conn.cost_n': '{n} 项凭据', 'gui.conn.st_off': '已停用', 'gui.conn.st_live': '正在收消息', 'gui.conn.st_missing': '还差 {n} 项凭据', 'gui.conn.gw_live': '{n} 个入口在收消息', - 'gui.conn.creds': '凭据', - 'gui.conn.apply': '去申请', + 'gui.conn.apply': '前往', + 'gui.conn.guide': '凭据要在{name}的开发者后台创建,拿到后填在下面', + 'gui.conn.keep_hint': '已设置的项留空即保持不变', + 'gui.conn.qr_idle': '二维码会显示在这里', 'gui.conn.toggled': '{name} 已{state} · 重新打开 Raven App 生效', 'gui.conn.toggled_now': '{name} 已{state}', - 'gui.conn.toggle_failed': '{name} 没有启动 —— {detail}', 'gui.conn.out_missing_dep': '缺少这个渠道的依赖包。', 'gui.conn.out_bad_config': '后台拒绝了它的配置,后台日志里写着是哪一项。', 'gui.conn.out_deny_all': '它的允许名单是空的,谁都进不来。', @@ -3521,7 +3518,9 @@ export const UI_TEXT: Record> = { 'gui.model.unset': '未选择', 'gui.model.update': '更新', 'gui.n_failed': '{n} 失败', - 'gui.nav.agents': '智能体连接', + 'gui.nav.hub': '连接', + 'gui.hub.agents': '智能体', + 'gui.hub.channels': '渠道', 'gui.nav.conn': '入口', 'gui.nav.cron': '定时', 'gui.nav.more': '更多', @@ -3971,26 +3970,21 @@ export const UI_TEXT: Record> = { 'gui.connf.user_id': '账号 User ID', 'gui.agent.kind_builtin': '内置', 'gui.conn.cost_scan_line': '手机扫码登录', - 'gui.conn.foot_need': '还需 {n} 项必填', - 'gui.conn.foot_clean': '未修改', 'gui.conn.foot_dirty': '未保存', 'gui.conn.g_imap': '收件(IMAP)', 'gui.conn.g_smtp': '发件(SMTP)', - 'gui.conn.w1_done': '入口已启用', - 'gui.conn.w1_idle': '启用入口', + 'gui.conn.w1_done': '已启动', + 'gui.conn.w1_idle': '点「接入」启动', 'gui.conn.w2': '用手机扫码', 'gui.conn.w2_done': '扫描成功', 'gui.conn.w3': '开始收消息', - 'gui.conn.w_wait': '等待扫描 · 关掉卡片不会取消', + 'gui.conn.w_wait': '等待扫码,关掉这张卡片不会中断', 'gui.conn.w_done': '完成', 'gui.conn.close': '关闭', - 'gui.conn.w2_blocked': 'Raven 后台没在运行 · 打开 Raven App 就会启动并出现二维码', - 'gui.conn.w2_down': '入口启动过又停了 —— 没人扫的码会过期。可以重试。', + 'gui.conn.w2_blocked': 'Raven 后台没在运行,运行后这里会出现二维码', + 'gui.conn.w2_down': '二维码过期了,点「重试」重新生成', 'gui.conn.w_retry': '重试', 'gui.agent.stale_to': '预设现在走 {to}', - 'gui.conn.tag_down': '未启动', - 'gui.conn.tag_unknown': '状态未知', - 'gui.conn.tag_nohost': '后台未运行', 'gui.conn.retry': '重试', 'gui.agent.migrate_do': '换到新方式', 'gui.agent.migrate_body': '{name} 的预设改成走 {to} 了 · 会先移除再按新预设装回来,已经在跑的会话句柄会失效', @@ -4292,7 +4286,6 @@ export const UI_TEXT: Record> = { 'gui.op.branch_failed': '分叉失败:{detail}', 'gui.op.saved_next_turn': '已保存 · 下一轮对话生效', 'gui.op.saved_restart': '已保存 · 重启引擎后生效', - 'gui.conn.not_receiving': '已启用的入口还没在收消息——打开 Raven App 就会启动', 'gui.sess.branch_empty': '这个会话还没有内容,无法分叉', 'gui.sess.branched_n': '已分叉,带上了 {n} 条消息', 'gui.model.protocol_unsupported': '当前 WebUI 后端不支持 API 类型设置', @@ -5300,6 +5293,25 @@ export const UI_TEXT: Record> = { 'gui.pb.when_hour': '{n} 小时前', 'gui.pb.when_day': '{n} 天前', 'gui.pb.stints_unsupported': '这个引擎没有运行列表接口;升级后才能在这里看到多轮运行。', - 'gui.pb.stint_status_finished': '已完成' + 'gui.pb.stint_status_finished': '已完成', + 'gui.conn.page': '渠道', + 'gui.conn.tab_all': '全部', + 'gui.conn.tab_on': '已接入', + 'gui.conn.tab_off': '未接入', + 'gui.conn.foot_off': '未接入', + 'gui.conn.foot_on': '已接入', + 'gui.conn.foot_bad': '连接异常', + 'gui.conn.line_bad_creds': '凭据可能有误,点开重新填写', + 'gui.conn.line_bad_scan': '登录没成功,点开重新扫码', + 'gui.conn.st_bad': '没能连上', + 'gui.conn.st_bad_why': '没能连上:{why}', + 'gui.conn.host_down': 'Raven 后台没在运行,渠道暂时收不到消息', + 'gui.conn.line_live': '在这里发的消息 Raven 都能收到', + 'gui.conn.line_scan': '用手机扫码登录即可接入', + 'gui.conn.line_creds': '需要从开发者后台拿 {n} 项凭据', + 'gui.conn.line_ready': '凭据已填好,还没开启', + 'gui.conn.line_saved': '凭据已填好', + 'gui.conn.none_on': '还没有接入任何渠道', + 'gui.conn.none_off': '所有渠道都已接入' } } as Record> diff --git a/ui-web/scripts/__golden__/boot-live-noserver.txt b/ui-web/scripts/__golden__/boot-live-noserver.txt index 362ff7264..0da7cf0a7 100644 --- a/ui-web/scripts/__golden__/boot-live-noserver.txt +++ b/ui-web/scripts/__golden__/boot-live-noserver.txt @@ -200,6 +200,106 @@ section#extAgentsPage.page[data-open=false] h2 div.work div#extAgentsBody.wrap +section#connectionsPage.page[data-open=false] + header + h2 + div.work + div#connectionsBody.wrap + div.hub-head + button.hub-head-tab + button.hub-head-tab + div.su-tabs + button.su-tab + span.su-wbar.su-wtn + button.su-tab + span.su-wbar.su-wtn + button.su-tab + span.su-wbar.su-wtn + div.su-grid + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot aside#detail.detail[data-open=false] div.dpanel header @@ -223,59 +323,6 @@ div#setVeil.veil.setveil[data-open=false][data-section=usage] svg path div#spanels.spanels - div#connectionsBody.spanels[data-for=channels] - div.two-pane - div.two-pane-side - div.two-pane-find - div.two-pane-search - svg - circle - path - input - div.two-pane-list - div.two-pane-wait - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-main - div.two-pane-none.two-pane-quiet - span.two-pane-glyph - svg - rect - path - div.two-pane-ns div#cronBody.spanels[data-for=cron] div#memoryBody.spanels[data-for=memory] div#veil.veil[data-open=false] diff --git a/ui-web/scripts/__golden__/boot-stub.txt b/ui-web/scripts/__golden__/boot-stub.txt index ee749969c..92d57bde5 100644 --- a/ui-web/scripts/__golden__/boot-stub.txt +++ b/ui-web/scripts/__golden__/boot-stub.txt @@ -202,6 +202,106 @@ section#extAgentsPage.page[data-open=false] h2 div.work div#extAgentsBody.wrap +section#connectionsPage.page[data-open=false] + header + h2 + div.work + div#connectionsBody.wrap + div.hub-head + button.hub-head-tab + button.hub-head-tab + div.su-tabs + button.su-tab + span.su-wbar.su-wtn + button.su-tab + span.su-wbar.su-wtn + button.su-tab + span.su-wbar.su-wtn + div.su-grid + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot + div.su-card.su-wcard + div.su-ctop + span.su-wbar.su-wtile + span.su-wbar.su-wname + span.su-wbar.su-wline + div.su-foot-slot + span.su-wbar.su-wfoot aside#detail.detail[data-open=false] div.dpanel header @@ -225,59 +325,6 @@ div#setVeil.veil.setveil[data-open=false][data-section=usage] svg path div#spanels.spanels - div#connectionsBody.spanels[data-for=channels] - div.two-pane - div.two-pane-side - div.two-pane-find - div.two-pane-search - svg - circle - path - input - div.two-pane-list - div.two-pane-wait - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-row - span.two-pane-hit - span.two-pane-txt - span.two-pane-wbar - span.two-pane-wbar - div.two-pane-main - div.two-pane-none.two-pane-quiet - span.two-pane-glyph - svg - rect - path - div.two-pane-ns div#cronBody.spanels[data-for=cron] div#memoryBody.spanels[data-for=memory] div#veil.veil[data-open=false] diff --git a/ui-web/scripts/check-class-namespace.mjs b/ui-web/scripts/check-class-namespace.mjs index 6edcfab9b..18b68ee84 100644 --- a/ui-web/scripts/check-class-namespace.mjs +++ b/ui-web/scripts/check-class-namespace.mjs @@ -182,7 +182,7 @@ const SHARED = new Set([ // shared is gone with them. The page's total debt is unchanged; where it is // counted is not. const LEGACY_SHARED = { - a: 3, btn: 2, cap: 2, chgs: 2, cmd: 2, ct: 2, d: 4, foot: 2, k: 3, + a: 3, btn: 2, cap: 2, chgs: 2, cmd: 2, ct: 2, d: 4, foot: 2, k: 2, gap: 2, 'ghost-ic': 3, h: 2, hd: 2, key: 2, lb: 3, n: 2, rm: 2, row: 2, shot: 3, sk: 2, skel: 2, step: 2, sz: 2, tipdn: 2, v: 3, w: 3, wkg: 3, wsnote: 2, @@ -254,9 +254,10 @@ const LEGACY_LOCAL = { with it, so they are this domain's alone now and are counted here rather than on the shared tally. Nothing was added. Down to one with the onboarding wizard's agents step drawing the hub's rows: `.kd` and - `.sulist` were the step's alone, and nothing names them now; `.pmhero` - on the page's hero is what remains. */ - extAgents: 1, + `.sulist` were the step's alone, and nothing names them now. Down to none + with `.pmhero` gone: the page's title is the connections hub's shared + header now (components/HubHead.tsx). */ + extAgents: 0, importSync: 0, installed: 0, /* Down from 14 with the memory section: the page's hero, its own list and diff --git a/ui-web/scripts/gates/rail-nav-registry.test.mjs b/ui-web/scripts/gates/rail-nav-registry.test.mjs index 684b9eb6d..bd1e0788e 100644 --- a/ui-web/scripts/gates/rail-nav-registry.test.mjs +++ b/ui-web/scripts/gates/rail-nav-registry.test.mjs @@ -40,7 +40,8 @@ function declared() { describe('the rail nav registry', () => { it('declares every button once, in the page table', () => { const buttons = declared() - /* The two the page has: the draft row, and the one module row. */ + /* The two the page has: the draft row, and the one row both connection + pages light (state/hub.ts). */ expect([...buttons].sort()).toEqual(['agentsBtn', 'newBtn']) }) diff --git a/ui-web/src/App.test.tsx b/ui-web/src/App.test.tsx index 305bf6141..0d858cd82 100644 --- a/ui-web/src/App.test.tsx +++ b/ui-web/src/App.test.tsx @@ -93,8 +93,8 @@ describe('the page root', () => { 'dTitle', 'dClose', 'dBody', 'setModal', 'snav', 'snavList', 'setTitle', 'setSub', 'setClose', 'spanels', 'railShow', 'split', 'menu', 'toasts', - 'extAgentsPage', 'extAgentsBody', - 'connectionsBody', 'memoryBody', 'cronBody', + 'extAgentsPage', 'extAgentsBody', 'connectionsPage', 'connectionsBody', + 'memoryBody', 'cronBody', ]) { expect(document.querySelectorAll(`#${id}`), id).toHaveLength(1) } @@ -103,7 +103,7 @@ describe('the page root', () => { /* Shared ground: the detail-drawer openers append their own host under #dBody, the settings island portals its nav into #snavList and roots its panels in #spanels, each module page's body is the root of its own island, - and the three sections another domain fills are rooted in a box of their + and the two sections another domain fills are rooted in a box of their own beside #spanels. React owning any of those child lists would tear down what the other side put there. */ it('hands the shared grounds over empty', () => { diff --git a/ui-web/src/App.tsx b/ui-web/src/App.tsx index 6188a09af..7120e7117 100644 --- a/ui-web/src/App.tsx +++ b/ui-web/src/App.tsx @@ -202,12 +202,11 @@ function SettingsModal(): JSX.Element {
- {/* The three sections another domain's island fills. Beside #spanels + {/* The two sections another domain's island fills. Beside #spanels rather than inside it, because a React root in the settings island's own tree would be unmounted the moment the reader picked another section; the stylesheet shows whichever one `data-section` above names (features/settings/store.ts's HOSTED). */} -
diff --git a/ui-web/src/chrome/Rail.test.tsx b/ui-web/src/chrome/Rail.test.tsx index d38d2ec43..a9fe1966c 100644 --- a/ui-web/src/chrome/Rail.test.tsx +++ b/ui-web/src/chrome/Rail.test.tsx @@ -13,8 +13,8 @@ import { readFileSync } from 'node:fs' import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as extAgents from '../features/extAgents/store' import * as settings from '../features/settings/store' +import * as hub from '../state/hub' import * as lang from '../state/lang' import { mountPageRoot } from '../test/pageRoot' @@ -28,7 +28,7 @@ const opened = { list: [] as string[] } const note = (name: string) => async () => { opened.list.push(name) } -vi.spyOn(extAgents, 'open').mockImplementation(note('agents')) +vi.spyOn(hub, 'open').mockImplementation(note('hub')) vi.spyOn(settings, 'open').mockImplementation(note('settings')) /* Nothing: the page root renders the grid, the column and the collapse's twin, @@ -143,7 +143,7 @@ describe('the rail', () => { el(id).click() }) } - expect(opened.list).toEqual(['agents', 'settings']) + expect(opened.list).toEqual(['hub', 'settings']) }) /* A React onClick leaves no trace on the element -- the root delegates every diff --git a/ui-web/src/chrome/Rail.tsx b/ui-web/src/chrome/Rail.tsx index c7e6a0a59..bbdb9a4ba 100644 --- a/ui-web/src/chrome/Rail.tsx +++ b/ui-web/src/chrome/Rail.tsx @@ -42,10 +42,10 @@ import { useSyncExternalStore } from 'react' import { AgentsGlyph, Icon } from '../components/Icon' import { RavenMark } from '../components/RavenMark' -import { open as openExtAgents } from '../features/extAgents/store' import { open as openSettings } from '../features/settings/store' import { t } from '../i18n/t' import * as find from '../state/find' +import * as hub from '../state/hub' import * as lang from '../state/lang' import * as rail from '../state/rail' @@ -95,10 +95,11 @@ function RailTop(): JSX.Element { that same table (features/rail/store.ts's markNew), which is the pair a page used to be able to miss in silence. - One destination, not five. Schedules, channels and memory are set up once - and then left alone, so they are sections of the settings dialog now, which - the foot opens; a playbook is not a place a reader goes at all. What is left - on the strip is the one module you go TO. */ + One destination, not five. Schedules and memory are set up once and then + left alone, so they are sections of the settings dialog, which the foot + opens; a playbook is not a place a reader goes at all. What is left is the + one place you go TO: what Raven connects to, agents and channels, one row + that goes back to whichever of the two the reader was last on. */ const NAV_ROWS: ReadonlyArray<{ readonly button: NavButton readonly key: string @@ -107,8 +108,8 @@ const NAV_ROWS: ReadonlyArray<{ }> = [ { button: 'agentsBtn', - key: 'gui.nav.agents', - open: () => openExtAgents(), + key: 'gui.nav.hub', + open: () => hub.open(), icon: , }, ] diff --git a/ui-web/src/components/HubHead.test.tsx b/ui-web/src/components/HubHead.test.tsx new file mode 100644 index 000000000..0e03cdf1a --- /dev/null +++ b/ui-web/src/components/HubHead.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { setTranslator } from '../i18n/t' +import * as hub from '../state/hub' +import { HubHead } from './HubHead' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +setTranslator((key) => key) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +const tabs = (): HTMLButtonElement[] => [...document.querySelectorAll('.hub-head-tab')] + +describe('the hub header', () => { + it('names both modules and marks the one on screen', () => { + render() + expect(tabs().map((b) => b.textContent)).toEqual(['gui.hub.agents', 'gui.hub.channels']) + expect(tabs().map((b) => b.getAttribute('aria-selected'))).toEqual(['false', 'true']) + }) + + /* The current tab is a title, not a control: pressing it would reopen the + page it is on and re-fetch its rows for nothing. */ + it('opens the other module, and does nothing for the current one', () => { + const open = vi.spyOn(hub, 'open').mockImplementation(() => {}) + render() + act(() => tabs()[0]!.click()) + expect(open).not.toHaveBeenCalled() + act(() => tabs()[1]!.click()) + expect(open).toHaveBeenCalledWith('channels') + }) +}) diff --git a/ui-web/src/components/HubHead.tsx b/ui-web/src/components/HubHead.tsx new file mode 100644 index 000000000..ae85432d2 --- /dev/null +++ b/ui-web/src/components/HubHead.tsx @@ -0,0 +1,40 @@ +/* The header the two connection pages share: the module tabs, and nothing + above them. + + Agents and channels are one place on the rail (state/hub.ts), and the rail + row already names it, so the page carries no title of its own: the tabs are + the top of the page. Underlined rather than filled, so they read as the + page's two halves and the filter pills each module draws under them stay + the smaller, second row. */ + +import { t } from '../i18n/t' +import * as hub from '../state/hub' + +import type { HubModule } from '../state/hub' +import type { JSX } from 'react' + +const MODULES: ReadonlyArray<{ which: HubModule; key: string }> = [ + { which: 'agents', key: 'gui.hub.agents' }, + { which: 'channels', key: 'gui.hub.channels' }, +] + +export function HubHead({ current }: { current: HubModule }): JSX.Element { + return ( +
+ {MODULES.map((m) => ( + + ))} +
+ ) +} diff --git a/ui-web/src/features/connections/ConnectionsPage.test.tsx b/ui-web/src/features/connections/ConnectionsPage.test.tsx index b0ad9596b..b126539f0 100644 --- a/ui-web/src/features/connections/ConnectionsPage.test.tsx +++ b/ui-web/src/features/connections/ConnectionsPage.test.tsx @@ -1,11 +1,14 @@ // @vitest-environment happy-dom -import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { setTranslator } from '../../i18n/t' import * as confirmStore from '../../state/confirm' +import * as detail from '../../state/detail' +import * as lang from '../../state/lang' import { resetSources, setSources } from '../../state/sources' import { domSnapshot } from '../../test/domSnapshot' +import { CHANNELS } from './catalogue' import { ConnectionsApp } from './ConnectionsPage' import * as store from './store' @@ -61,7 +64,9 @@ function install(rows: ConnChannel[], over: Partial = {}) { fn() }) setSources({ connections: source }) - document.body.innerHTML = '
' + document.body.innerHTML = + '
' + + '' return { source, calls, shellCalls } } @@ -75,71 +80,79 @@ function typeInto(box: HTMLInputElement, value: string): void { box.dispatchEvent(new Event('input', { bubbles: true })) } -/* The section as the dialog hosts it: the island in its own box, and the fetch - arriving at the section costs (features/connections/store.ts's `enter`, which - state/settings.ts spends -- here it is called by hand, because the dialog is - not what this file is about). */ +/* The page as the rail opens it: the island in its own box, and the fetch + opening it costs (features/connections/store.ts's `open`, minus the page + switch, which is not what this file is about). */ async function mount() { const view = render(, { container: document.getElementById('connectionsBody')! }) await act(async () => { - store.closeChannel() await store.refresh(true) }) return view } -/* The two columns. Every list query is scoped to the left one, because the - picked channel carries the same name in its own header. */ -const side = (): HTMLElement => document.querySelector('.two-pane-side') as HTMLElement -const main = (): HTMLElement => document.querySelector('.two-pane-main') as HTMLElement -const rowsOf = (): HTMLElement[] => [...side().querySelectorAll('.two-pane-row')] +/* The page, and the sheet the shared drawer holds. Every card query is scoped + to the page, because the picked channel carries the same name in its head. */ +const side = (): HTMLElement => document.getElementById('connectionsBody')! +const main = (): HTMLElement => store.detailHost() +const sheetUp = (): boolean => detail.get().open +const rowsOf = (): HTMLElement[] => [...side().querySelectorAll('.su-card:not(.su-wcard)')] const rowNamed = (name: string): HTMLElement => - rowsOf().find((r) => r.querySelector('.nm')!.textContent === name)! -const groupOf = (name: string): string | null => { - const row = rowNamed(name) - let at: Element | null = row.previousElementSibling - while (at && !at.classList.contains('two-pane-grp')) at = at.previousElementSibling - return at ? at.textContent : null + rowsOf().find((r) => r.querySelector('.su-t')!.textContent === name)! +const tab = (key: string): HTMLElement => + [...side().querySelectorAll('.su-tab')].find((b) => b.firstChild?.textContent === key)! +/* Which of the two groups a card is in: whether the Connected tab shows it. */ +const groupOf = (name: string): string => { + act(() => tab('gui.conn.tab_on').click()) + const on = !!rowNamed(name) + act(() => tab('gui.conn.tab_all').click()) + return on ? 'gui.conn.g_on' : 'gui.conn.g_off' } -const groups = (): Array => - [...side().querySelectorAll('.two-pane-grp')].map((g) => g.textContent) -const subOf = (name: string): string | null => rowNamed(name).querySelector('.ds')?.textContent ?? null -const toneOf = (name: string): string => rowNamed(name).querySelector('.ds')!.className -/* The row's own switch, which is the one control it carries. */ -const rowSwitch = (name: string): HTMLButtonElement => rowNamed(name).querySelector('.two-pane-swi')! -/* Opening a channel is clicking its name. */ -const openRow = (name: string): void => { within(side()).getByText(name).click() } +const countOf = (key: string): string => tab(key).querySelector('.su-tn')!.textContent! +/* What a card says about itself: a reason where one is owed, else the line + under its name. */ +const subOf = (name: string): string | null => { + const foot = rowNamed(name).querySelector('.su-foot')! + return foot.classList.contains('su-foot-quiet') ? rowNamed(name).querySelector('.su-one')!.textContent : foot.textContent +} +const footOf = (name: string): string => rowNamed(name).querySelector('.su-foot')!.textContent! +const lineOf = (name: string): string => rowNamed(name).querySelector('.su-one')!.textContent! +const ledOf = (name: string): string => rowNamed(name).querySelector('.su-nm > span:last-child:not(.su-t)')?.className ?? '' +/* Opening a channel is pressing its card. */ +const openRow = (name: string): void => { rowNamed(name).click() } /* The steps' own state, which is what draws the tick and the current mark -- the titles alone read the same whether or not the sequence advances. */ const wizStates = (): Array => [...main().querySelectorAll('.suwiz .step')].map((s) => s.getAttribute('data-state')) -/* The pane's header and the row its verb sits on. */ -const paneHead = (): HTMLElement => main().querySelector('.two-pane-head')! +/* The sheet's head and the row its verb sits on. */ +const paneHead = (): HTMLElement => main().querySelector('.su-head')! const paneFoot = (): HTMLElement => main().querySelector('.sufoot')! +/* The drawer is closed, not reset: a close schedules the card's drop for after + the fade, and that drop asks whether the drawer was opened since by its open + count. Resetting the count to zero let the next case's first open read as + the same open, and the last case's drop emptied its sheet mid-test. */ afterEach(() => { - act(() => { - store.closeChannel() - }) + act(() => detail.close()) cleanup() + store._resetForTests() vi.restoreAllMocks() resetSources() }) describe('connections island', () => { - it('waits as the rows it becomes rather than as an empty column', async () => { + it('waits as the cards it becomes rather than as an empty grid', async () => { /* `!loaded && !rows.length` used to draw nothing, so the seconds before the adapters answered looked exactly like "there are no channels". */ let land: ((r: ConnChannel[]) => void) | null = null install([], { rows: () => new Promise((resolve) => { land = resolve }) }) render(, { container: document.getElementById('connectionsBody')! }) await act(async () => { void store.refresh(true); await Promise.resolve() }) - const wait = side().querySelector('.two-pane-wait')! - expect(wait.getAttribute('aria-busy')).toBe('true') - expect(wait.querySelectorAll('.two-pane-row').length).toBe(7) + const wait = side().querySelector('.su-grid[aria-busy="true"]')! + expect(wait.querySelectorAll('.su-wcard').length).toBe(CHANNELS.length) await act(async () => { land!([chan()]); await Promise.resolve() }) - expect(document.querySelector('.two-pane-wait')).toBeNull() + expect(side().querySelector('[aria-busy="true"]')).toBeNull() expect(rowNamed('Slack')).toBeTruthy() }) @@ -165,16 +178,16 @@ describe('connections island', () => { chan({ id: 'email', key: 'gui.chan.email', fields: [ { key: 'imap_host', required: true }, { key: 'imap_user', required: true }, { key: 'smtp_host', required: true }, { key: 'smtp_user', required: true }, - ] }), - chan({ id: 'telegram', key: 'Telegram', fields: [{ key: 'token', required: true }] }), + ], missing: ['imap_host', 'imap_user', 'smtp_host', 'smtp_user'] }), + chan({ id: 'telegram', key: 'Telegram', fields: [{ key: 'token', required: true }], missing: ['token'] }), chan({ id: 'weixin', key: 'gui.chan.weixin', qrLogin: true }), ]) await mount() - expect(rowsOf().map((r) => r.querySelector('.nm')!.textContent)) + expect(rowsOf().map((r) => r.querySelector('.su-t')!.textContent)) .toEqual(['gui.chan.weixin', 'Telegram', 'gui.chan.email']) - expect(subOf('gui.chan.weixin')).toBe('gui.conn.cost_scan') - expect(subOf('Telegram')).toBe('gui.conn.cost_n {"n":"1"}') - expect(subOf('gui.chan.email')).toBe('gui.conn.cost_n {"n":"4"}') + expect(subOf('gui.chan.weixin')).toBe('gui.conn.line_scan') + expect(subOf('Telegram')).toBe('gui.conn.line_creds {"n":"1"}') + expect(subOf('gui.chan.email')).toBe('gui.conn.line_creds {"n":"4"}') }) /* Whether a channel signs in by scanning is a static fact about it, but the @@ -189,9 +202,9 @@ describe('connections island', () => { ]) await mount() expect(await screen.findByText('gui.chan.weixin')).toBeTruthy() - expect(subOf('gui.chan.weixin')).toBe('gui.conn.cost_scan') + expect(subOf('gui.chan.weixin')).toBe('gui.conn.line_scan') /* And it sorts ahead of the cheapest form. */ - expect(rowsOf().map((r) => r.querySelector('.nm')!.textContent)).toEqual(['gui.chan.weixin', 'Telegram']) + expect(rowsOf().map((r) => r.querySelector('.su-t')!.textContent)).toEqual(['gui.chan.weixin', 'Telegram']) }) /* The row's second line, which is the whole of what a row says about itself: @@ -200,31 +213,58 @@ describe('connections island', () => { two -- "the gateway could not be asked" is not "off", and "running but not paired" is not "receiving". */ describe('what the row says about its own state', () => { - it('tells the five states apart, in words and in colour', async () => { + /* Five states underneath, three said: connected, not yet, and set up but + failed. Waiting on a code is not yet; nobody to ask is the page's + notice, not a card's. */ + it('says three things for the five states underneath', async () => { install([ chan({ id: 'a', key: 'A', on: true, running: true, connected: true, who: 'me' }), chan({ id: 'b', key: 'B', on: true, running: false }), chan({ id: 'c', key: 'C', on: true, running: true, connected: false, qrLogin: true }), chan({ id: 'd', key: 'D', on: true }), chan({ id: 'e', key: 'E', on: false }), - ]) + ], { hostRunning: () => true }) + await mount() + expect([footOf('A'), ledOf('A'), lineOf('A')]).toEqual(['gui.conn.foot_on', 'su-led', 'gui.conn.as_you {"who":"me"}']) + expect([footOf('B'), ledOf('B'), lineOf('B')]).toEqual(['gui.conn.foot_bad', 'su-led su-led-bad', 'gui.conn.line_bad_creds']) + for (const quiet of ['C', 'D', 'E']) { + expect([footOf(quiet), ledOf(quiet)], quiet).toEqual(['gui.conn.foot_off', '']) + } + /* Configured: switched off says so, switched on does not claim it is off. */ + expect(lineOf('E')).toBe('gui.conn.line_ready') + expect(lineOf('D')).toBe('gui.conn.line_saved') + }) + + /* The sheet carries the gateway's reason, where there is one. */ + it('names the reason in the sheet of an entrance that would not start', async () => { + install([chan({ on: true, running: false, refusal: 'gui.conn.out_bad_config' })], { hostRunning: () => true }) + await mount() + await act(async () => { openRow('Slack') }) + expect(paneHead().querySelector('.su-by')!.textContent).toBe('gui.conn.st_bad_why {"why":"gui.conn.out_bad_config"}') + }) + + it('asks a scan entrance that would not start to sign in again', async () => { + install([chan({ id: 'weixin', key: 'gui.chan.weixin', qrLogin: true, fields: [], on: true, running: false })], { + hostRunning: () => true, + }) await mount() - expect(subOf('A')).toBe('gui.conn.as_you {"who":"me"}') - expect(toneOf('A')).toContain('live') - expect(subOf('B')).toBe('gui.conn.tag_down') - expect(toneOf('B')).toContain('bad') - expect(subOf('C')).toBe('gui.conn.st_unpaired') - expect(toneOf('C')).toContain('warn') - expect(subOf('D')).toBe('gui.conn.tag_unknown') - expect(toneOf('D')).toContain('warn') - expect(subOf('E')).toBe('gui.conn.cost_n {"n":"1"}') - expect(toneOf('E')).toBe('ds') + expect(lineOf('gui.chan.weixin')).toBe('gui.conn.line_bad_scan') + }) + + /* The one card asking for something leads the rest. */ + it('puts a failed entrance ahead of the ones still to connect', async () => { + install([ + chan({ id: 'weixin', key: 'gui.chan.weixin', qrLogin: true, fields: [] }), + chan({ on: true, running: false }), + ], { hostRunning: () => true }) + await mount() + expect(rowsOf().map((r) => r.querySelector('.su-t')!.textContent)).toEqual(['Slack', 'gui.chan.weixin']) }) it('says how many credentials are still missing, where some are', async () => { install([chan({ fields: [{ key: 'bot_token', required: true }], missing: ['bot_token'] })]) await mount() - expect(subOf('Slack')).toBe('gui.conn.st_missing {"n":1}') + expect(subOf('Slack')).toBe('gui.conn.line_creds {"n":"1"}') }) }) @@ -244,7 +284,7 @@ describe('connections island', () => { ;(paneFoot().querySelector('button') as HTMLElement).click() }) expect(groupOf('gui.chan.weixin')).toBe('gui.conn.g_off') - expect(subOf('gui.chan.weixin')).toBe('gui.conn.tag_unknown') + expect(footOf('gui.chan.weixin')).toBe('gui.conn.foot_off') }) /* And no group in between. An adapter up and waiting on a code is not in @@ -256,7 +296,8 @@ describe('connections island', () => { chan({ id: 'weixin', key: 'gui.chan.weixin', qrLogin: true, fields: [], on: true, running: true, connected: false }), ]) await mount() - expect(groups()).toEqual(['gui.conn.g_off']) + expect(groupOf('gui.chan.weixin')).toBe('gui.conn.g_off') + expect(countOf('gui.conn.tab_on')).toBe('0') }) it('keeps an entrance with made-up credentials out of in service', async () => { @@ -265,7 +306,7 @@ describe('connections island', () => { install([chan({ on: true, running: false, fields: [{ key: 'bot_token', required: true, set: true }] })]) await mount() expect(groupOf('Slack')).toBe('gui.conn.g_off') - expect(subOf('Slack')).toBe('gui.conn.tag_down') + expect(footOf('Slack')).toBe('gui.conn.foot_bad') }) it('keeps a scan entrance out of in service until it is paired', async () => { @@ -315,7 +356,7 @@ describe('connections island', () => { await act(async () => { ;(paneFoot().querySelector('button.key') as HTMLElement).click() }) - expect(screen.getByText('gui.conn.pick')).toBeTruthy() + expect(sheetUp()).toBe(false) expect(groupOf('Slack')).toBe('gui.conn.g_on') }) @@ -342,7 +383,7 @@ describe('connections island', () => { written() await new Promise((resolve) => setTimeout(resolve, 0)) }) - expect(screen.getByText('gui.conn.pick')).toBeTruthy() + expect(sheetUp()).toBe(false) }) /* A rebuild that finishes before the status is re-read shows no down state @@ -360,7 +401,7 @@ describe('connections island', () => { ;(paneFoot().querySelector('button.key') as HTMLElement).click() await new Promise((resolve) => setTimeout(resolve, 0)) }) - expect(screen.getByText('gui.conn.pick')).toBeTruthy() + expect(sheetUp()).toBe(false) }) /* And the reason the card exists: a correction the adapter could not start @@ -410,17 +451,23 @@ describe('connections island', () => { saying that up front is what keeps the press from being the way to find out. */ describe('when nothing is running that could host an adapter', () => { - it('names the reason on the row instead of calling the state unknown', async () => { - install([chan({ on: true })], { hostRunning: () => false }) + /* Said once for the page, and no card claims it: every entrance is deaf + for the same reason, including one whose adapter reads as down. */ + it('says it once over the grid, and leaves every card quiet', async () => { + install([chan({ on: true }), chan({ id: 'telegram', key: 'Telegram', on: true, running: false })], { + hostRunning: () => false, + }) await mount() - expect(subOf('Slack')).toBe('gui.conn.tag_nohost') + expect(side().querySelector('.su-notice')!.textContent).toBe('gui.conn.host_down') + expect([footOf('Slack'), footOf('Telegram')]).toEqual(['gui.conn.foot_off', 'gui.conn.foot_off']) }) - /* And where it genuinely cannot say, it says the honest thing. */ - it('still says state unknown when the source cannot tell', async () => { + /* Where the source cannot say, it claims nothing either way. */ + it('draws no notice when the source cannot tell', async () => { install([chan({ on: true })]) await mount() - expect(subOf('Slack')).toBe('gui.conn.tag_unknown') + expect(side().querySelector('.su-notice')).toBeNull() + expect(footOf('Slack')).toBe('gui.conn.foot_off') }) it('tells a scan pane there is no code coming before the press, not after', async () => { @@ -431,8 +478,9 @@ describe('connections island', () => { await act(async () => { openRow('gui.chan.weixin') }) const steps = [...main().querySelectorAll('.suwiz .step')] expect(steps[1]!.querySelector('.sd')!.textContent).toBe('gui.conn.w2_blocked') - /* Unpressed: the write has not happened, and the pane said so anyway. */ - expect(steps[0]!.getAttribute('data-state')).toBe('idle') + /* Unpressed: the write has not happened, and the pane said so anyway. + Pressing connect is still the step the reader is on. */ + expect(steps[0]!.getAttribute('data-state')).toBe('now') }) /* One sentence for two states was wrong in the commoner one: "Raven is not @@ -474,74 +522,6 @@ describe('connections island', () => { }) }) - /* The switch takes the entrance in and out of service, and that is all it - does. What it must not do is offer to switch on an entrance that has - nothing to switch on WITH: the way in for those is the pane beside the - list, where the credential is handed over. */ - it('takes an entrance out of service straight from the row, with no dialog', async () => { - const { calls, shellCalls } = install([chan({ on: true, running: true })]) - await mount() - expect(rowSwitch('Slack').getAttribute('aria-checked')).toBe('true') - await act(async () => { - rowSwitch('Slack').click() - }) - expect(calls).toContainEqual(['toggle', false]) - expect(shellCalls.filter((c) => c[0] === 'confirmAsk')).toEqual([]) - expect(groupOf('Slack')).toBe('gui.conn.g_off') - expect(rowSwitch('Slack').getAttribute('aria-checked')).toBe('false') - }) - - /* The press is not the end of the errand: the source reads the status back, - and what it reads is what the row has to show. The row used to keep - whatever the section entry had loaded, so the same backend state drew - "not started" or "receiving" depending on when the reader arrived. */ - it('redraws the row from what the write read back, not from the press', async () => { - install([chan({ on: false, running: false })], { - toggle: async (c, on) => { - c.on = on - /* After the await, the way a status read is: a paint that only happens - on the press cannot have this. */ - await Promise.resolve() - c.running = on - }, - }) - await mount() - expect(groupOf('Slack')).toBe('gui.conn.g_off') - await act(async () => { - rowSwitch('Slack').click() - }) - expect(groupOf('Slack')).toBe('gui.conn.g_on') - }) - - /* Where the code appears is the pane, and the list says nothing about that: - the owner had to be told to click the row. */ - it('opens the card when a scan entrance is switched on from the list', async () => { - install([ - chan({ id: 'weixin', key: 'gui.chan.weixin', qrLogin: true, fields: [] }), - chan({ on: true, running: true }), - ]) - await mount() - await act(async () => { - rowSwitch('gui.chan.weixin').click() - }) - expect(paneHead().querySelector('.nm')!.textContent).toBe('gui.chan.weixin') - }) - - it('opens nothing for an entrance whose way in is the form, or for a switch off', async () => { - install([ - chan({ id: 'weixin', key: 'gui.chan.weixin', qrLogin: true, fields: [], on: true, running: true }), - chan({ on: false }), - ]) - await mount() - await act(async () => { - rowSwitch('Slack').click() - }) - expect(screen.getByText('gui.conn.pick')).toBeTruthy() - await act(async () => { - rowSwitch('gui.chan.weixin').click() - }) - expect(screen.getByText('gui.conn.pick')).toBeTruthy() - }) /* The page-level fact the write can change too: `host` was written only on section entry, so a gateway that came up since then left the pane telling @@ -550,8 +530,9 @@ describe('connections island', () => { let up = false install([chan({ id: 'weixin', key: 'gui.chan.weixin', qrLogin: true, fields: [], on: true, running: false })], { hostRunning: () => up, - toggle: async () => { + apply: async () => { up = true + return true }, }) await mount() @@ -559,42 +540,12 @@ describe('connections island', () => { expect(main().querySelectorAll('.suwiz .step')[1]!.querySelector('.sd')!.textContent) .toBe('gui.conn.w2_blocked') await act(async () => { - rowSwitch('gui.chan.weixin').click() + ;[...paneFoot().querySelectorAll('button')].find((b) => b.textContent === 'gui.conn.w_retry')!.click() }) expect(main().querySelectorAll('.suwiz .step')[1]!.querySelector('.sd')!.textContent) .toBe('gui.conn.w2_down') }) - it('leaves the switch unavailable while a credential is still missing', async () => { - install([ - chan({ fields: [{ key: 'bot_token', required: true }], missing: ['bot_token'] }), - chan({ id: 'telegram', key: 'Telegram', fields: [{ key: 'token', required: true, set: true }], missing: [] }), - ]) - await mount() - expect(rowSwitch('Slack').hasAttribute('disabled')).toBe(true) - expect(rowSwitch('Telegram').hasAttribute('disabled')).toBe(false) - }) - - it('takes the switch back when the source reverts and rejects handled', async () => { - install([chan({ on: true, running: true, connected: false })], { - /* What the live source does: optimistic flip now, revert on the rpc - failure, reject handled so the island only redraws. */ - toggle: (c, on) => { - c.on = on - return Promise.resolve().then(() => { - c.on = !on - throw { handled: true } - }) - }, - }) - await mount() - await act(async () => { - rowSwitch('Slack').click() - }) - expect(rowSwitch('Slack').getAttribute('aria-checked')).toBe('true') - expect(groupOf('Slack')).toBe('gui.conn.g_off') - }) - /* The row's press opens the pane, and does nothing else. It briefly did the write itself, so a press was an attempt rather than a form -- but joining is scanning a code or handing over a credential, both of which live in the @@ -605,28 +556,17 @@ describe('connections island', () => { chan({ fields: [{ key: 'bot_token', required: true, set: true }], missing: [] }), ]) await mount() - expect(screen.getByText('gui.conn.pick')).toBeTruthy() + expect(sheetUp()).toBe(false) await act(async () => { openRow('gui.chan.weixin') }) - expect(paneHead().querySelector('.nm')!.textContent).toBe('gui.chan.weixin') + expect(paneHead().querySelector('.su-meta h3')!.textContent).toBe('gui.chan.weixin') expect(calls.filter((x) => x[0] === 'apply' || x[0] === 'toggle')).toEqual([]) /* Including the entrance that has everything it needs: no write, no start, nothing claimed. */ await act(async () => { openRow('Slack') }) - expect(paneHead().querySelector('.nm')!.textContent).toBe('Slack') + expect(paneHead().querySelector('.su-meta h3')!.textContent).toBe('Slack') expect(calls.filter((x) => x[0] === 'apply' || x[0] === 'toggle')).toEqual([]) }) - it('narrows the list by what is typed in the search', async () => { - install([chan(), chan({ id: 'telegram', key: 'Telegram' })]) - await mount() - await screen.findByText('Slack') - const box = side().querySelector('input') as HTMLInputElement - await act(async () => { - fireEvent.change(box, { target: { value: 'tele' } }) - }) - expect(rowsOf().map((r) => r.querySelector('.nm')!.textContent)).toEqual(['Telegram']) - }) - /* "Connect" is unavailable until there is something to connect WITH. Pressing it with an empty box wrote nothing, started nothing and left the pane exactly as it was -- the press was the only feedback and it meant nothing. */ @@ -759,10 +699,31 @@ describe('connections island', () => { install([chan({ id: 'telegram', key: 'Telegram', fields: [{ key: 'token', required: true }], missing: ['token'] })]) await mount() await act(async () => { openRow('Telegram') }) - const jump = main().querySelector('#connDlgBody a.jump')! + const jump = main().querySelector('#connDlgBody a.su-guide')! expect(jump.href).toBe('https://t.me/BotFather') expect(jump.target).toBe('_blank') - expect(main().querySelector('#connDlgBody .sucreds .n')!.textContent).toBe('0 / 1') + /* The name sits in the sentence with its own spaces only in a Chinese one; + the stand-in language here is not, so it is passed as it is. */ + expect(jump.querySelector('.su-guide-t')!.textContent).toBe('gui.conn.guide {"name":"Telegram"}') + }) + + /* A Latin name inside a Chinese sentence takes a space either side; a + Chinese name takes none. */ + it('spaces a Latin name inside a Chinese sentence, and only there', async () => { + install([ + chan({ id: 'telegram', key: 'Telegram', fields: [{ key: 'token', required: true }], missing: ['token'] }), + chan({ id: 'feishu', key: '飞书', fields: [{ key: 'app_id', required: true }], missing: ['app_id'] }), + ]) + act(() => lang.set('zh')) + try { + await mount() + await act(async () => { openRow('Telegram') }) + expect(main().querySelector('.su-guide-t')!.textContent).toBe('gui.conn.guide {"name":" Telegram "}') + await act(async () => { openRow('飞书') }) + expect(main().querySelector('.su-guide-t')!.textContent).toBe('gui.conn.guide {"name":"飞书"}') + } finally { + act(() => lang._resetForTests()) + } }) it('has no link for a channel whose credentials are not issued anywhere', async () => { @@ -879,35 +840,49 @@ describe('connections island', () => { expect(document.querySelector('.qrbox')).toBeNull() }) - /* Nothing in service yet is the ordinary first run, and a heading over an - empty box saying so was the page explaining itself. */ - it('leaves out the in-service group until something is in it', async () => { + /* Nothing connected yet is the ordinary first run: the tab still stands, + and says so in its own words rather than as an empty grid. */ + it('says so on the Connected tab while nothing is connected', async () => { install([chan({ missing: ['bot_token'] })]) await mount() - expect(await screen.findByText('Slack')).toBeTruthy() - expect(groups()).toEqual(['gui.conn.g_off']) - expect(side().querySelector('.empty-note')).toBeNull() + expect(countOf('gui.conn.tab_on')).toBe('0') + act(() => tab('gui.conn.tab_on').click()) + expect(rowsOf()).toEqual([]) + expect(side().querySelector('.su-empty')!.textContent).toBe('gui.conn.none_on') }) - it('says so when nothing in the catalogue matches the search', async () => { - install([chan()]) + /* One fact, once. The head counts what is missing, so the body carries no + second "credentials 0 / 1" caption over the same boxes. */ + it('counts what is missing in the head alone', async () => { + install([chan({ fields: [{ key: 'bot_token', required: true }], missing: ['bot_token'] })]) await mount() - await screen.findByText('Slack') - await act(async () => { - fireEvent.change(side().querySelector('input') as HTMLInputElement, { target: { value: 'zzz' } }) - }) - expect(screen.getByText('gui.conn.none_match')).toBeTruthy() + await act(async () => { openRow('Slack') }) + expect(paneHead().querySelector('.su-by')!.textContent).toBe('gui.conn.st_missing {"n":1}') + expect(main().querySelector('#connDlgBody .sucreds')).toBeNull() }) - /* One fact, once. The credential count is the form's own caption, so the - header does not print it a second line above -- it said "needs 1 - credential" over a block already headed "credentials 0 / 1". */ - it('leaves the credential count to the form that counts it', async () => { - install([chan({ fields: [{ key: 'bot_token', required: true }], missing: ['bot_token'] })]) + /* "Set, leave blank to keep" is said once for the form, not in every box. */ + it('says once that a saved box left blank is kept', async () => { + install([chan({ on: true, running: true, fields: [ + { key: 'bot_token', required: true, set: true }, { key: 'app_token', required: true, set: true }, + ] })]) await mount() await act(async () => { openRow('Slack') }) - expect(paneHead().querySelector('.two-pane-meta')!.textContent).toBe('gui.conn.st_missing {"n":1}') - expect(main().querySelector('#connDlgBody .sucreds .n')!.textContent).toBe('0 / 1') + expect(main().querySelectorAll('.su-keep')).toHaveLength(1) + /* A connected entrance is here to rotate a secret, not to learn where + secrets come from. */ + expect(main().querySelector('.su-guide')).toBeNull() + expect([...main().querySelectorAll('input')].map((i) => i.placeholder)).toEqual(['gui.conn.field_set', 'gui.conn.field_set']) + }) + + /* Backing out and connecting sit at the two ends of the foot. */ + it('keeps disconnect apart from the verb that connects', async () => { + install([chan({ on: true, running: true })]) + await mount() + await act(async () => { openRow('Slack') }) + const kids = [...paneFoot().children] + expect(kids[0]!.textContent).toBe('gui.conn.disconnect') + expect(kids.at(-1)!.className).toContain('key') }) /* Scanning is the one way in the body does not spell out: the wizard has no @@ -916,7 +891,7 @@ describe('connections island', () => { install([chan({ id: 'weixin', key: 'gui.chan.weixin', qrLogin: true, fields: [] })]) await mount() await act(async () => { openRow('gui.chan.weixin') }) - expect(paneHead().querySelector('.two-pane-meta')!.textContent).toBe('gui.conn.cost_scan_line') + expect(paneHead().querySelector('.su-by')!.textContent).toBe('gui.conn.cost_scan_line') }) /* One fact, once, the other way round: what the row's own second line says is @@ -925,7 +900,7 @@ describe('connections island', () => { install([chan({ on: true, running: true })]) await mount() await act(async () => { openRow('Slack') }) - expect(paneHead().querySelector('.two-pane-meta')!.textContent).toBe('gui.conn.st_live') + expect(paneHead().querySelector('.su-by')!.textContent).toBe('gui.conn.st_live') expect(main().querySelector('#connDlgBody .sustate')).toBeNull() }) @@ -937,13 +912,15 @@ describe('connections island', () => { expect(paneHead().querySelector('.channel-mark img')!.getAttribute('src')).toBe('assets/channels/slack.png') }) - /* The note beside the save button used to be an empty span. */ - it('says where the form stands, beside the button that acts on it', async () => { + /* The foot says only what the head cannot: that something typed is not + saved yet. The missing count is the head's, and is not said twice. */ + it('says an edit is unsaved, and leaves the missing count to the head', async () => { install([chan({ fields: [{ key: 'bot_token', required: true, secret: true }], missing: ['bot_token'] })]) await mount() await act(async () => { openRow('Slack') }) const body = main().querySelector('#connDlgBody')! - expect(paneFoot().querySelector('.n')!.textContent).toBe('gui.conn.foot_need {"n":"1"}') + expect(paneHead().querySelector('.su-by')!.textContent).toBe('gui.conn.st_missing {"n":1}') + expect(paneFoot().querySelector('.n')!.textContent).toBe('') const box = body.querySelector('input[type="password"]')! await act(async () => { typeInto(box, 'tok') @@ -1040,7 +1017,10 @@ describe('connections island', () => { 'gui.conn.w2', 'gui.conn.w3', ]) - expect(wizStates()).toEqual(['idle', 'idle', 'idle']) + expect(wizStates()).toEqual(['now', 'idle', 'idle']) + /* The code's place is drawn before there is a code, and polls nothing. */ + expect(body.querySelector('.su-qrph')!.textContent).toBe('gui.conn.qr_idle') + expect(body.querySelector('.qrbox')).toBeNull() expect(body.querySelector('.sufield')).toBeNull() /* The list's verb, not a third one: it read "turn the entry on", which is also what step 1 above it says. */ @@ -1084,7 +1064,20 @@ describe('connections island', () => { await mount() await act(async () => { openRow('gui.chan.weixin') }) expect(main().querySelector('.suwiz')).toBeNull() - expect(paneHead().querySelector('.two-pane-meta')!.textContent).toBe('gui.conn.st_live') + expect(paneHead().querySelector('.su-by')!.textContent).toBe('gui.conn.st_live') + }) + + /* The corner is the card's one press, and it is the card's own press: it + opens the sheet and writes nothing, since getting in is a code or a + credential and both are in the sheet. A connected card has nothing to add. */ + it('opens the sheet from the corner and writes nothing on the way', async () => { + const { calls } = install([chan(), chan({ id: 'telegram', key: 'Telegram', on: true, running: true })]) + await mount() + expect(rowNamed('Telegram').querySelector('.su-cbtn')).toBeNull() + await act(async () => { (rowNamed('Slack').querySelector('.su-cbtn') as HTMLElement).click() }) + expect(sheetUp()).toBe(true) + expect(paneHead().querySelector('.su-meta h3')!.textContent).toBe('Slack') + expect(calls).toEqual([]) }) it('keeps its rendered shape, list', async () => { diff --git a/ui-web/src/features/connections/ConnectionsPage.tsx b/ui-web/src/features/connections/ConnectionsPage.tsx index 981291aff..9ec0a0eb7 100644 --- a/ui-web/src/features/connections/ConnectionsPage.tsx +++ b/ui-web/src/features/connections/ConnectionsPage.tsx @@ -1,23 +1,23 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { createPortal } from 'react-dom' import { ChannelMark } from '../../components/ChannelMark' +import { HubHead } from '../../components/HubHead' import { Field } from '../../components/SetupSheet' -import { - TwoPane, TwoPaneFind, TwoPaneGroup, TwoPaneHead, TwoPaneList, TwoPaneNoHit, TwoPaneNone, TwoPaneRow, TwoPaneSwitch, - TwoPaneWait, -} from '../../components/TwoPane' import { t } from '../../i18n/t' import * as lang from '../../state/lang' -import { chanName } from './catalogue' +import { CHANNELS, chanName } from './catalogue' import * as store from './store' import type { ConnChannel, ConnField } from './types' import type { JSX } from 'react' +import './styles.css' -/* Where Raven receives messages: a section of the settings dialog, drawn as a - list of the channel catalogue with the picked one beside it. The fields that - pane draws come from the channel's own Pydantic schema, shipped on - channels.status, so the form cannot drift from the model. +/* Where Raven receives messages: the channel half of the connections hub + (state/hub.ts), drawn the way its agent half is -- tabs over a grid of + cards, and the picked one in the shared drawer. + The fields the sheet draws come from the channel's own Pydantic schema, + shipped on channels.status, so the form cannot drift from the model. */ /* Configured means the schema's required fields are all set. Entries whose @@ -38,21 +38,35 @@ function connState(c: ConnChannel): 'off' | 'unknown' | 'down' | 'unpaired' | 'l return 'live' } -/* An entrance's state: the dot's class plus one line of fact. Five states, five - things worth saying -- "nobody could be asked" is not the same as "off". +/* What the reader is told, which is three things rather than the five above: + * it works, it does not work yet, or it was set up and failed. * - * The sentence is for the card, which has room for it; the row shows only the - * dot. Under a row it was a third grey line saying what the group heading, the - * cost badge and the button had each already said. */ -function stateOf(c: ConnChannel): { cls: string; text: string } { + * The other two collapse on purpose. An adapter up and waiting on a code is + * signing in, and signing in happens in the sheet; to the reader it is simply + * not connected yet. And "nobody could be asked" is a fact about the page, not + * about one entrance: with no host running every entrance is equally deaf, so + * the page says that once (the notice over the grid) and no card claims it. + * Only an adapter the host tried and could not start is this entrance's own + * trouble, and the one worth a red light. + */ +type Shown = 'live' | 'broken' | 'idle' + +function shownOf(c: ConnChannel): Shown { const live = connState(c) - if (live === 'live') return { cls: 'ok', text: c.who ? t('gui.conn.as_you', { who: c.who }) : t('gui.conn.st_live') } - if (live === 'down') return { cls: 'bad', text: t('gui.conn.st_down') } - if (live === 'unpaired') return { cls: 'warn', text: t('gui.conn.st_unpaired') } - if (live === 'unknown') return { cls: 'warn', text: t('gui.conn.st_unknown') } - /* Not in service. What is worth saying is how far off it is -- and a fully - configured entry that is simply switched off says that, rather than - nothing. */ + if (live === 'live') return 'live' + if (live === 'down' && store.get().host !== false) return 'broken' + return 'idle' +} + +/* The sheet's own line: the same three, with room for the reason. The reason + is the gateway's word from this page's last write (the source keeps it on + the row); a status read has none to give. */ +function stateOf(c: ConnChannel): { cls: string; text: string } { + const shown = shownOf(c) + if (shown === 'live') return { cls: 'ok', text: c.who ? t('gui.conn.as_you', { who: c.who }) : t('gui.conn.st_live') } + if (shown === 'broken') { + return { cls: 'bad', text: c.refusal ? t('gui.conn.st_bad_why', { why: c.refusal }) : t('gui.conn.st_bad') } + } const missing = (c.missing || []).length if (missing) return { cls: 'off', text: t('gui.conn.st_missing', { n: missing }) } return { cls: 'off', text: isConfigured(c) ? t('gui.conn.st_off') : '' } @@ -89,161 +103,211 @@ const APPLY: Record = { matrix: 'https://app.element.io', } -const CHANNEL_GLYPH = ( - -) - -export function ConnectionsApp(): JSX.Element { - const s = useSyncExternalStore(store.subscribe, store.get) - /* The language the page resolved, so a pick repaints this island: every word - below is a t(key) read at render time (state/lang/store.ts). */ - useSyncExternalStore(lang.subscribe, lang.get) - const [q, setQ] = useState('') - const picked = s.viewId ? s.rows.find((c) => c.id === s.viewId) : undefined - if (s.loaded && !s.rows.length) { - return - } - return ( - }> - {picked ? ( - - ) : ( - {t('gui.conn.pick')} - )} - - ) +/* What a card says under its name: what to do about a failure, how far this + entrance is from receiving, or, once it receives, who it receives as. */ +function lineOf(c: ConnChannel): string { + const shown = shownOf(c) + if (shown === 'live') return c.who ? t('gui.conn.as_you', { who: c.who }) : t('gui.conn.line_live') + if (shown === 'broken') return t(scanLogin(c) ? 'gui.conn.line_bad_scan' : 'gui.conn.line_bad_creds') + if (scanLogin(c)) return t('gui.conn.line_scan') + const missing = (c.missing || []).length + if (missing) return t('gui.conn.line_creds', { n: String(missing) }) + /* Switched on and still not in: whatever is holding it (no host, a code not + scanned yet) the page or the sheet says -- "not switched on" would be + false here. */ + return t(c.on ? 'gui.conn.line_saved' : 'gui.conn.line_ready') } -/* Why an entrance that is in service is not receiving, in the row's own second - line -- the same line an addable row uses for what it costs to get in. A live - one needs no reason: the group and the green dot have said it. */ -/* Why an entrance is not in service, when something actually went wrong. - * - * Only a thing gone wrong earns a reason. An adapter that is up and waiting on a - * code is not wrong and not a state to advertise -- it is a step of signing in, - * which happens in the pane the reader has open. An entrance that started and - * stopped, or has nothing running it, is a different matter: that is why it is - * not in service, and the row is where the reader looks for it. - * - * "State unknown" is the honest answer when nobody could be asked, and the wrong - * one when we know why nobody answered: with no host running there is no - * adapter, and naming that is the difference between a reader who thinks their - * entrance is broken and one who knows nothing is running it. */ -function reasonOf(c: ConnChannel): string | null { - const live = connState(c) - if (live === 'live' || live === 'unpaired' || live === 'off') return null - if (live === 'down') return 'tag_down' - return store.get().host === false ? 'tag_nohost' : 'tag_unknown' +/* The card's foot: one word for the three, in the same quiet grey for two of + them, so a grid of twelve can be scanned for the one in trouble. */ +function footOf(c: ConnChannel): { tone: 'quiet' | 'bad'; text: string } { + const shown = shownOf(c) + if (shown === 'live') return { tone: 'quiet', text: t('gui.conn.foot_on') } + if (shown === 'broken') return { tone: 'bad', text: t('gui.conn.foot_bad') } + return { tone: 'quiet', text: t('gui.conn.foot_off') } } -/* The row's second line: a reason where there is one, then the state, and for - an entrance nobody has started what it costs to get in. */ -function rowSub(c: ConnChannel): { text: string; tone?: 'warn' | 'live' | 'bad' } { - const live = connState(c) - if (live === 'live') return { text: stateOf(c).text, tone: 'live' } - const reason = reasonOf(c) - if (reason) return { text: t('gui.conn.' + reason), tone: reason === 'tag_down' ? 'bad' : 'warn' } - if (live === 'unpaired') return { text: t('gui.conn.st_unpaired'), tone: 'warn' } - if (scanLogin(c)) return { text: t('gui.conn.cost_scan') } - const n = (c.fields || []).filter((f) => f.required).length - const missing = (c.missing || []).length - return { text: missing ? t('gui.conn.st_missing', { n: missing }) : t('gui.conn.cost_n', { n: String(n) }) } -} +const FOOT_CLASS = { quiet: 'su-foot su-foot-quiet', bad: 'su-foot su-foot-bad' } as const -/* The left column: search, then two groups, because there are two answers to - "is this entrance mine yet". - * - * Grouping by the config switch made pressing the button the whole of joining: - * an entrance moved to "in service" before a code had been scanned, before a - * credential had been tried, and would have sat there just the same with a - * made-up token in it. The switch is a decision; being in service is a fact, - * and only the live adapter can report it. The addable ones are ordered by what - * it costs to get in -- a scan-login channel is one phone away, a channel - * wanting six credentials is an afternoon. */ -function ConnSide({ rows, loaded, q, onQ, pickedId }: { - rows: ConnChannel[] - loaded: boolean - q: string - onQ(v: string): void - pickedId: string | null -}): JSX.Element { - const term = q.trim().toLowerCase() - const shown = rows.filter((c) => !term || chanName(c).toLowerCase().includes(term) || c.id.includes(term)) - const on = shown.filter((c) => connState(c) === 'live') - const off = shown.filter((c) => connState(c) !== 'live').sort((a, b) => costOf(a) - costOf(b)) - const row = (c: ConnChannel): JSX.Element => { - const cn = chanName(c) - const sub = rowSub(c) - return ( - } - name={cn} - sub={sub.text} - {...(sub.tone ? { tone: sub.tone } : {})} - onOpen={() => store.openChannel(c)} - trailing={ - /* The switch only takes an entrance in or out of service. An entrance - whose credentials are not in yet has nothing to switch on, so the - way in is the pane beside this list rather than a control that can - only fail. */ - { - /* A scan entrance switched on from the list has its code in the - pane, and the list says nothing about that: opening the card - with the switch is the cue, and the reader is where the code - appears instead of watching a row that will never turn green - on its own. */ - if (!c.on && scanLogin(c)) store.openChannel(c) - store.toggle(c) - }} - /> - } - /> - ) - } +const PLUS = 'M12 5v14M5 12h14' + +/* One entrance. The whole card opens its sheet, and so does the corner: that + is the only press a card has, because getting in is scanning a code or + handing over credentials, and both happen in the sheet. */ +function ChanCard({ c, current }: { c: ConnChannel; current: boolean }): JSX.Element { + const shown = shownOf(c) + const live = shown === 'live' + const foot = footOf(c) + const name = chanName(c) + const open = (): void => store.openChannel(c) + const led = live ? 'su-led' : shown === 'broken' ? 'su-led su-led-bad' : null return ( - <> - - - {!loaded && !rows.length ? : shown.length === 0 ? ( - rows.length ? {t('gui.conn.none_match')} : null - ) : ( - <> - {on.length ? {t('gui.conn.g_on')} : null} - {on.map(row)} - {off.length ? {t('gui.conn.g_off')} : null} - {off.map(row)} - +
{ + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + open() + } + }} + > +
+ +
+ {name} + {led ? : null} +
+ {live ? null : ( + )} - - +
+
{lineOf(c)}
+
+
{foot.text}
+
+
) } -/* The picked entrance, in the right column. It used to be a card over the list: - inside the settings dialog that is a layer over a layer, and the list it - covered was the one thing a reader comparing entrances needed to keep. - * - * Signing in by phone is a sequence, not a form: nothing can be scanned until - * the entry is running. So a scan channel gets the wizard until it is paired, - * and the credential form is for the channels that have one. */ -function ConnDetail({ c }: { c: ConnChannel }): JSX.Element { +/* The grid before the first answer: one placeholder per catalogue entry, the + shape a real card has, so the cards land in place rather than after a jump. */ +function WaitGrid(): JSX.Element { + return ( +
+ {CHANNELS.map((c) => ( +
+
+ + +
+ +
+ +
+
+ ))} +
+ ) +} + +/* The picked entrance, in the shared drawer. Signing in by phone is a + sequence, not a form: nothing can be scanned until the entry is running. So + a scan channel gets the wizard until it is paired, and the credential form + is for the channels that have one. */ +const SHEET_LED: Record = { live: 'su-led', broken: 'su-led su-led-bad', idle: null } + +function ChanSheet({ c }: { c: ConnChannel }): JSX.Element { const st = stateOf(c) + const shown = shownOf(c) const signing = scanLogin(c) && connState(c) !== 'live' + /* A scan entrance still to sign in says how it signs in; one that failed + says it failed, like any other. */ + const led = SHEET_LED[shown] + const by = signing && shown !== 'broken' + ? {t('gui.conn.cost_scan_line')} + : {st.text} + return createPortal( +
+
+ +
+

{chanName(c)}

+
+ {led ? : null} + {by} +
+
+
+ {signing ? : } +
, + store.detailHost(), + ) +} + +type Tab = 'all' | 'on' | 'off' + +const TABS: ReadonlyArray<{ tab: Tab; label: string; empty: string }> = [ + { tab: 'all', label: 'gui.conn.tab_all', empty: 'gui.conn.none' }, + { tab: 'on', label: 'gui.conn.tab_on', empty: 'gui.conn.none_on' }, + { tab: 'off', label: 'gui.conn.tab_off', empty: 'gui.conn.none_off' }, +] + +/* Two groups, because there are two answers to "is this entrance mine yet". + Connected is a fact only the live adapter can report, so a switch flipped on + for an entrance that never came up stays with the ones still to connect. + Those are ordered by what it costs to get in: a scan-login channel is one + phone away, a channel wanting six credentials is an afternoon. */ +export function ConnectionsApp(): JSX.Element { + const s = useSyncExternalStore(store.subscribe, store.get) + /* The language the page resolved, so a pick repaints this island: every word + below is a t(key) read at render time (state/lang/store.ts). */ + useSyncExternalStore(lang.subscribe, lang.get) + const [tab, setTab] = useState('all') + const on = s.rows.filter((c) => connState(c) === 'live') + /* The ones in trouble lead the rest: they are the only cards asking for + something. */ + const broken = (c: ConnChannel): number => (shownOf(c) === 'broken' ? 0 : 1) + const off = s.rows + .filter((c) => connState(c) !== 'live') + .sort((a, b) => broken(a) - broken(b) || costOf(a) - costOf(b)) + const rows: Record = { all: [...on, ...off], on, off } + const current = TABS.find((x) => x.tab === tab)! + const waiting = !s.loaded && !s.rows.length + const picked = s.viewId ? s.rows.find((c) => c.id === s.viewId) : undefined return ( <> - } - name={chanName(c)} - meta={signing ? t('gui.conn.cost_scan_line') : {st.text}} - /> - {signing ? : } + +
+ {TABS.map((x) => ( + + ))} +
+ {/* Nothing running that could host an entrance: every one of them is + deaf for the same reason, so it is said once, here, and no card + repeats it. */} + {s.host === false ? ( +
+ {t('gui.conn.host_down')} +
+ ) : null} + {waiting ? ( + + ) : rows[tab].length ? ( +
+ {rows[tab].map((c) => )} +
+ ) : ( +
{t(current.empty)}
+ )} + {picked ? : null} ) } @@ -345,25 +409,24 @@ function ConnForm({ c }: { c: ConnChannel }): JSX.Element { return ( <>
- {/* Both sections wear the same caption: a small mono line that says - what the block below it is, and nothing else. The credential count - rides on it, which is why the head no longer repeats it. */} - {required.length ? ( -
- - {t('gui.conn.creds')} - {`${required.filter((f) => f.set).length} / ${required.length}`} + {/* Where the credentials come from, for an entrance still to get in: + the first question a reader has, answered above the boxes it is + about, with the console one press away. The count of what is + missing is the head's, so it is not said here again. */} + {apply && !live ? ( + + {t('gui.conn.guide', { name: spaced(chanName(c)) })} + + {t('gui.conn.apply')} + - {apply ? ( - - {t('gui.conn.apply')} - - - ) : null} -
+ ) : null} + {/* Said once for the form, not in every box: the boxes of a mail + entrance read "set, leave blank to keep" six times over. */} + {(c.fields || []).some((f) => f.set) ?

{t('gui.conn.keep_hint')}

: null} {groups.map(([label, fs]) => (
{label ?
{t(label)}
: null} @@ -386,25 +449,17 @@ function ConnForm({ c }: { c: ConnChannel }): JSX.Element { )}
- {/* Where this form stands, in the one place a form's state belongs: - beside the button that acts on it. It was an empty span. */} - - {dirty - ? t('gui.conn.foot_dirty') - : (c.missing || []).length - ? t('gui.conn.foot_need', { n: String((c.missing || []).length) }) - : t('gui.conn.foot_clean')} - - {/* Clearing the switch, for the entrance whose row no longer offers it: - a row shows "disconnect" only where an adapter is up, so an entrance - switched on that never started would otherwise have no way back to - off. Here, beside the form, "disconnect" has the card around it to - say what is being switched. */} + {/* The way back to off, at the far end from the verb that connects: + side by side the two were one slip apart. */} {c.on ? ( - ) : null} + {/* Only what the head cannot say: that the boxes hold something not + yet saved. What is still missing is the head's line, and a second + count down here said it twice. */} + {dirty ? t('gui.conn.foot_dirty') : ''} + ) : null} {up && !paired ? t('gui.conn.w_wait') : ''} {/* The list's two verbs, not two more of their own: the wizard's first button does what the row's does, and backing out is the same - disconnect. It read "turn the entry on" -- the words step 1 above it - already carries -- and "cancel connecting". */} + disconnect. */} {!c.on ? ( - ) : ( - <> - - {/* On and not up: the entrance gave up, or nothing started it. The - row's connect is the retry, and this card is covering it -- so - the card carries one, or the only way to try again is to close - this and find the row underneath. */} - {!up ? ( - - ) : null} - - )} + ) : !up ? ( + /* On and not up: the entrance gave up, or nothing started it. The + card carries the retry, or the only way to try again is to close + this and find the card underneath. */ + + ) : null}
) diff --git a/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap b/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap index b91777ac5..8334ac155 100644 --- a/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap +++ b/ui-web/src/features/connections/__snapshots__/ConnectionsPage.test.tsx.snap @@ -1,73 +1,76 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`connections island > keeps its rendered shape, list 1`] = ` -"div.two-pane - div.two-pane-side - div.two-pane-find - div.two-pane-search +"div.hub-head + button.hub-head-tab + button.hub-head-tab +div.su-tabs + button.su-tab + span.su-tn + button.su-tab + span.su-tn + button.su-tab + span.su-tn +div.su-grid + div.su-card + div.su-ctop + span.channel-mark + img + div.su-nm + span.su-t + span.su-led + div.su-one + div.su-foot-slot + div.su-foot.su-foot-quiet + div.su-card + div.su-ctop + span.channel-mark + img + div.su-nm + span.su-t + button.su-cbtn svg - circle path - input - div.two-pane-list - div.two-pane-grp - div.two-pane-row - button.two-pane-hit - span.channel-mark - img - span.two-pane-txt - span.nm - span.ds.live - button.two-pane-swi - div.two-pane-grp - div.two-pane-row.two-pane-off - button.two-pane-hit - span.channel-mark - img - span.two-pane-txt - span.nm - span.ds - button.two-pane-swi - div.two-pane-row.two-pane-off - button.two-pane-hit - span.channel-mark.channel-mark-inset - svg - rect - path - span.two-pane-txt - span.nm - span.ds - button.two-pane-swi - div.two-pane-main - div.two-pane-none.two-pane-quiet - span.two-pane-glyph + div.su-one + div.su-foot-slot + div.su-foot.su-foot-quiet + div.su-card + div.su-ctop + span.channel-mark.channel-mark-inset svg rect path - div.two-pane-ns" + div.su-nm + span.su-t + button.su-cbtn + svg + path + div.su-one + div.su-foot-slot + div.su-foot.su-foot-quiet" `; exports[`connections island > keeps its rendered shape, the picked channel 1`] = ` -"div.two-pane-head - span.channel-mark - img - div.ttl - div.nm - div.two-pane-meta - span.st.off -div#connDlgBody.subody - div.sucreds - span.k - span.n - a.jump - svg - path - div.sugroup - div.sufields - div.sufield - label - input -div.sufoot - span.n - button.mini.key" +"div.su-sheet + div.su-head + span.channel-mark + img + div.su-meta + h3 + div.su-by + span.st.off + div#connDlgBody.subody + a.su-guide + span.su-guide-t + span.su-guide-go + svg + path + div.sugroup + div.sufields + div.sufield + label + input + div.sufoot + span.n + button.mini.key" `; diff --git a/ui-web/src/features/connections/manifest.ts b/ui-web/src/features/connections/manifest.ts index c36710a06..b789d3390 100644 --- a/ui-web/src/features/connections/manifest.ts +++ b/ui-web/src/features/connections/manifest.ts @@ -12,11 +12,7 @@ export const manifest: DomainManifest = { domain: 'connections', /* `su*` -- the setup wizard, 9 of its 14 own classes. */ cssPrefix: 'su', - /* A section of the settings dialog rather than a page: #connectionsBody is the - box src/App.tsx renders inside it, and this island roots itself in that - box rather than in this island's tree (features/settings/store.ts's - HOSTED says why the root stays its own). */ - host: 'connectionsBody', + page: 'connectionsPage', sources: ['connections'], root: ConnectionsApp, } diff --git a/ui-web/src/features/connections/source.test.ts b/ui-web/src/features/connections/source.test.ts index eb99a0ccf..c811ef73c 100644 --- a/ui-web/src/features/connections/source.test.ts +++ b/ui-web/src/features/connections/source.test.ts @@ -59,6 +59,7 @@ beforeEach(() => { delete c.missing delete c.running delete c.connected + delete c.refusal }) }) @@ -169,22 +170,31 @@ describe('the row switch', () => { /* A refusal has a reason and something to do about it; both were dropped, and the row went red with neither. */ - it('names why an adapter would not start, with the server sentence', async () => { + /* Kept on the row rather than toasted: the sheet shows it for as long as + the entrance stays down, where a toast was gone before the reader looked. */ + it('keeps why an adapter would not start, with the server sentence', async () => { switching({ applied: true, outcome: 'missing_dep', detail: 'Run: uv sync --inexact --extra channels' }, [ { name: 'slack', enabled: true, running: false }, ]) await connSource.toggle(row('slack'), true) - expect(said).toEqual([ - 'gui.conn.toggle_failed(name=gui.chan.slack,detail=gui.conn.out_missing_dep Run: uv sync --inexact --extra channels)', - ]) + expect(said).toEqual([]) + expect(row('slack').refusal).toBe('gui.conn.out_missing_dep Run: uv sync --inexact --extra channels') }) it('keeps an outcome it has no sentence for readable', async () => { switching({ applied: true, outcome: 'no_manager' }, [{ name: 'slack', enabled: true }]) await connSource.toggle(row('slack'), true) - expect(said).toEqual([ - 'gui.conn.toggle_failed(name=gui.chan.slack,detail=gui.conn.out_refused(outcome=no_manager))', - ]) + expect(row('slack').refusal).toBe('gui.conn.out_refused(outcome=no_manager)') + }) + + /* Once the entrance comes up the reason is stale, and a status read says so. */ + it('drops the reason once a status read finds the entrance running', async () => { + switching({ applied: true, outcome: 'missing_dep' }, [{ name: 'slack', enabled: true, running: false }]) + await connSource.toggle(row('slack'), true) + expect(row('slack').refusal).toBeTruthy() + switching({ applied: true, outcome: 'started' }, [{ name: 'slack', enabled: true, running: true }]) + await connSource.rows() + expect(row('slack').refusal).toBeUndefined() }) /* The reload rides on the write's promise, so its failure would otherwise @@ -209,10 +219,8 @@ describe('the row switch', () => { { name: 'slack', enabled: true, running: false }, ]) await connSource.apply(row('slack'), { bot_token: 'x' }, true) - expect(said).toEqual([ - 'gui.conn.saved_x(name=gui.chan.slack)', - 'gui.conn.toggle_failed(name=gui.chan.slack,detail=gui.conn.out_missing_dep Run: the installer)', - ]) + expect(said).toEqual(['gui.conn.saved_x(name=gui.chan.slack)']) + expect(row('slack').refusal).toBe('gui.conn.out_missing_dep Run: the installer') }) /* Switching off is done the moment the config says so, whatever the gateway diff --git a/ui-web/src/features/connections/source.ts b/ui-web/src/features/connections/source.ts index a4668480e..e163c8126 100644 --- a/ui-web/src/features/connections/source.ts +++ b/ui-web/src/features/connections/source.ts @@ -40,6 +40,9 @@ export async function loadChannels(): Promise { c.running = s.running c.connected = s.connected c.qrLogin = !!s.qr_login + /* An entrance that came up, or was switched off, has nothing left to be + refused for. */ + if (s.running || !s.enabled) delete c.refusal }) gatewayRunningLive = r.gateway_running } @@ -79,7 +82,7 @@ function sayOutcome(c: ConnChannel, on: boolean, r: ChannelsConfigureResult): vo const name = chanName(c) const refused = refusalOf(on, r) if (refused) { - toast(t('gui.conn.toggle_failed', { name, detail: refused })) + c.refusal = refused return } const state = t(on ? 'gui.conn.enabled' : 'gui.conn.disabled') @@ -88,18 +91,15 @@ function sayOutcome(c: ConnChannel, on: boolean, r: ChannelsConfigureResult): vo } export const connSource: ConnectionsSource = { - /* `initial` is the page-open fetch: only that one toasts a failed load or - warns about a gateway that is not receiving -- a background reload (the - scan poll's refresh) stays silent, as the old page did. */ + /* `initial` is the page-open fetch: only that one toasts a failed load -- a + background reload (the scan poll's refresh) stays silent. A gateway that + is not receiving is the page's notice now, drawn off `hostRunning`. */ rows: async (initial) => { try { await loadChannels() } catch (e) { if (initial) toast(t('gui.op.load_failed', { detail: String((e as Error).message || e) })) } - if (initial && !gatewayRunningLive && CHANNELS.some((c) => c.on)) { - toast(t('gui.conn.not_receiving')) - } return CHANNELS }, /* Read off the same status call, which carries the gateway lock's answer. @@ -150,8 +150,11 @@ export const connSource: ConnectionsSource = { no words for. The write itself was applied either way, which is what the caller's boolean says; whether the adapter then came up is the row's to show. */ + /* Kept, not toasted: the sheet the write came from is open, and its state + line carries the reason for as long as the entrance stays down. */ const refused = refusalOf(!!enable, r) - if (refused) toast(t('gui.conn.toggle_failed', { name: chanName(c), detail: refused })) + if (refused) c.refusal = refused + else delete c.refusal return true } catch (e) { const err = e as { data?: { detail?: string }; message?: string } diff --git a/ui-web/src/features/connections/store.ts b/ui-web/src/features/connections/store.ts index af85de7a1..89fddcd59 100644 --- a/ui-web/src/features/connections/store.ts +++ b/ui-web/src/features/connections/store.ts @@ -1,17 +1,17 @@ import { t } from '../../i18n/t' -import * as settingsDialog from '../../state/settings' +import * as detail from '../../state/detail' +import * as hub from '../../state/hub' +import * as page from '../../state/page' import { ds } from '../../state/sources' import { makeStore } from '../../state/store' import { show as toast } from '../../state/toast' import type { ConnChannel, ConnectionsSource } from './types' -/* Section state, outside React on purpose: two of the callers that drive this - * section are not React. The Escape order closes its credentials dialog - * (state/escapeOrder.ts) and the module page's own leave slot shuts that - * dialog behind the reader (app/install.ts fills state/page.ts's slot) -- so - * the state lives in a plain store those two can call, and the component - * subscribes. +/* Page state, outside React on purpose: the callers that close the channel's + * sheet are not React -- the shared drawer's own close button, its scrim, + * Escape and a page switch all go through state/detail.ts -- so the state + * lives in a plain store those can reach, and the component subscribes. */ export interface ConnState { @@ -19,10 +19,9 @@ export interface ConnState { /* False until the first rows fetch answers: the list is not drawn at all until then, so a page still loading never reads as "no channels". */ loaded: boolean - /* Which entry the column beside the list is showing -- the old `connEdit`, - and before that the id of a modal card. */ + /* Which entry the sheet is showing. */ viewId: string | null - /* Remounts the pane's subtree when another entry is picked, so its + /* Remounts the sheet's subtree when another entry is picked, so its uncontrolled inputs start from that row's current values. */ epoch: number /* Whether anything is running that could host an adapter (see @@ -52,41 +51,41 @@ export async function refresh(initial = false): Promise { } } +/* The page. Its rows are fetched again on every open, because the greeting's + entry may have read them long before and a channel may have come up since. */ +export function open(): void { + page.show('connectionsPage') + void refresh(true) +} +hub.onOpen('channels', open) + +/** Where the sheet renders: the host the shared drawer keeps for this island. */ +export function detailHost(): HTMLDivElement { + return detail.host('connections') +} + export function openChannel(c: ConnChannel): void { + detail.open('connections') set({ viewId: c.id, epoch: get().epoch + 1 }) } export function closeChannel(): void { - set({ viewId: null }) + detail.close() } -/* What arriving at this section of the settings dialog costs: the rows, and the - credentials pane a previous visit was left on. Registered at this module's - own evaluation rather than by the page's wiring, the same shape - features/desk/store.ts fills state/escapeOrder.ts's slot with: the alternative - is src/app/install.ts importing three island stores for three lines, which is - three island graphs in the page's own wiring. */ -function enter(): void { - closeChannel() - void refresh(true) -} -settingsDialog.onEnter('channels', enter) -/* And what leaving it costs: a pane left open would come back over whatever - section the reader opens next, still showing the channel they had left. */ -settingsDialog.onLeave('clearConnChannel', closeChannel) - -/* Optimistic, like the accessor it replaces: both sources flip `c.on` before - their first await, so the redraw right after already shows the new get(); - the rpc source reverts the flag and rejects handled on failure, and the - second redraw takes the switch back. */ -export function toggle(c: ConnChannel): void { - const p = source().toggle(c, !c.on) - redraw() - /* Both ways: the write's own answer is what the row is drawn from once the - source has read the status back, so the paint the press earns is not the - last one. */ - void p.then(() => redraw(), () => redraw()) +/* Whoever closed the drawer, the card goes -- a fade later, or what fades is + an empty panel. `gen` says whether the reader opened another card inside + that window, which the id alone cannot: reopening the same channel writes + the same id back. */ +function dismissed(): void { + if (!get().viewId) return + const gen = detail.get().gen + detail.dropAfterFade( + () => set({ viewId: null }), + () => detail.get().gen !== gen, + ) } +detail.onClose('connections', dismissed) /* Credentials and the switch travel together; the source speaks its own failures, so this only has to repaint whatever get() the write left. */ diff --git a/ui-web/src/features/connections/styles.css b/ui-web/src/features/connections/styles.css new file mode 100644 index 000000000..4868fc9b5 --- /dev/null +++ b/ui-web/src/features/connections/styles.css @@ -0,0 +1,191 @@ +/* The connections domain's own rules. + * + * Collected by Vite into the one CSS asset build.py inlines after page.css + * (features/extAgents/styles.css says how). Every class below carries this + * domain's `su` prefix. The page is drawn the way the agent hub is -- tabs over + * a grid of fixed-height cards, the picked one in the shared drawer -- so the + * geometry below is that page's, restated under this prefix rather than + * borrowed: a class belongs to one domain (scripts/check-class-namespace.mjs). + */ + +/* ── the page ───────────────────────────────────────────────────── */ + +.su-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 16px; } +.su-tab { + height: 30px; padding: 0 12px; border: 0; border-radius: 8px; background: none; + font: inherit; font-size: 13px; color: var(--muted); cursor: pointer; + display: inline-flex; align-items: center; gap: 5px; +} +.su-tab:hover { color: var(--text); } +.su-tab[aria-selected="true"] { background: var(--raised); color: var(--text); font-weight: 500; } +.su-tab:focus-visible { outline: 2px solid var(--amber); outline-offset: -2px; } +.su-tn { font-size: 11.5px; color: var(--faint); font-weight: 400; font-variant-numeric: tabular-nums; } +.su-tn::before { content: "("; } +.su-tn::after { content: ")"; } + +/* Four a row at most and never narrower than 200px, the hub's tracks. */ +.su-grid { + display: grid; gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(max(200px, calc((100% - 36px) / 4)), 1fr)); +} +/* The page's one notice: nothing is running that could host an entrance. A + wash in the warning hue and one line, above the grid it explains. */ +.su-notice { + margin: -4px 0 14px; padding: 9px 13px; border-radius: 9px; + background: color-mix(in oklab, var(--gold) 9%, var(--paper)); + color: var(--text); font-size: 12.5px; line-height: 1.5; + display: flex; align-items: center; gap: 9px; +} +.su-notice::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--gold); flex: none; } +.su-empty { + padding: 18px 16px; border: 1px dashed var(--line); border-radius: 10px; + color: var(--faint); font-size: 13px; text-align: center; +} + +/* A column of fixed parts -- the name, two lines of room for the line under + it, the foot's place -- so every card is one height in every state. */ +.su-card { + position: relative; min-width: 0; min-height: 104px; padding: 14px; + display: flex; flex-direction: column; + background: var(--paper); border: 1px solid var(--line); border-radius: 12px; + color: inherit; cursor: pointer; transition: box-shadow .15s ease, border-color .15s ease; +} +.su-card:hover, .su-card[aria-current="true"] { + border-color: color-mix(in oklab, var(--line) 60%, var(--muted)); + box-shadow: 0 4px 14px rgba(0, 0, 0, .06); +} +.su-card:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; } +.su-card .channel-mark { width: 32px; height: 32px; border-radius: 9px; } +.su-card .channel-mark-inset img, .su-card .channel-mark-inset svg { width: 19px; height: 19px; } +.su-ctop { display: flex; align-items: center; gap: 10px; min-height: 32px; } +.su-nm { flex: 1; display: flex; align-items: center; gap: 7px; min-width: 0; } +.su-t { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.su-led { width: 6px; height: 6px; border-radius: 50%; flex: none; background: var(--moss); } +.su-led-bad { background: var(--clay); } +.su-one { + margin: 10px 0; font-size: 12px; line-height: 1.55; min-height: 3.1em; color: var(--muted); + overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; +} +.su-foot-slot { margin-top: auto; height: 30px; } +.su-foot { + height: 100%; padding: 0 9px; border-radius: 7px; min-width: 0; + display: flex; align-items: center; font-size: 12px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.su-foot-quiet { padding: 0; color: var(--faint); } +.su-foot-bad { color: var(--clay); font-weight: 600; background: color-mix(in oklab, var(--clay) 6%, var(--paper)); } + +/* The corner: a quiet square, the card's one press. */ +.su-cbtn { + flex: none; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; + background: var(--raised); color: var(--muted); cursor: pointer; + display: grid; place-items: center; +} +.su-cbtn svg { width: 14px; height: 14px; } +.su-cbtn:hover { color: var(--text); background: color-mix(in oklab, var(--raised) 70%, var(--line)); } +.su-cbtn:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; } + +/* The wait: bars where a card's parts go, at the sizes they take. */ +.su-wbar { + display: block; flex: none; border-radius: 5px; + background: linear-gradient(90deg, + color-mix(in oklab, var(--text) 7%, transparent) 30%, + color-mix(in oklab, var(--text) 15%, transparent) 50%, + color-mix(in oklab, var(--text) 7%, transparent) 70%); + background-size: 300% 100%; animation: su-wshine 1.3s linear infinite; +} +@keyframes su-wshine { from { background-position: 100% 0; } to { background-position: -100% 0; } } +@media (prefers-reduced-motion: reduce) { .su-wbar { animation: none; } } +.su-wcard { cursor: default; pointer-events: none; } +.su-wtile { width: 32px; height: 32px; border-radius: 9px; } +.su-wname { width: 44%; height: 14px; } +.su-wline { width: 72%; height: 12px; margin: 13px 0 32px; } +.su-wfoot { width: 38%; height: 12px; margin-top: 9px; } +.su-wtn { display: inline-block; width: 14px; height: 10px; border-radius: 3px; margin-left: 6px; vertical-align: -1px; } + +/* ── the sheet ──────────────────────────────────────────────────── */ + +/* The owner's fixed 520x500 box (page.css sizes `.dpanel` off `data-owner`): + head, one scrolling body, foot. The form and the wizard bring the body and + the foot. Its geometry is the agent sheet's, measure for measure + (features/extAgents/styles.css): the two halves of the hub open the same + drawer, and a second set of paddings, weights and a grey foot read as a + different product. */ +.su-sheet { width: 100%; flex: 1; display: flex; flex-direction: column; min-height: 0; } +.su-head { flex: none; display: flex; align-items: center; gap: 12px; padding: 20px 48px 0 20px; } +.su-head .channel-mark { width: 46px; height: 46px; border-radius: 12px; } +.su-meta { flex: 1; min-width: 0; } +.su-meta h3 { margin: 0; font-family: var(--mono); font-size: 16px; font-weight: 600; letter-spacing: -.02em; } +.su-by { display: flex; align-items: center; gap: 6px; margin-top: 1px; font-size: 11.5px; color: var(--faint); } +.su-by .su-led { width: 5px; height: 5px; } +.su-by .st.ok { color: var(--moss); } +.su-by .st.bad { color: var(--clay); } +.su-by .st.off { color: var(--faint); } + +.su-sheet > .subody { + flex: 1; min-height: 0; padding: 18px 20px 4px; overflow-y: auto; + display: grid; gap: 16px; align-content: start; + color: var(--muted); font-size: 12.5px; +} +.su-sheet > .subody > * { margin-top: 0; } +.su-sheet .sufields { gap: 16px; } +.su-sheet .sufield > label { font-size: 12px; font-weight: 400; color: var(--muted); margin-bottom: 6px; } +.su-sheet .sufield > input { + width: 100%; box-sizing: border-box; height: var(--ctl-h); padding: 0 10px; + background: var(--paper); border: 1px solid var(--line); border-radius: var(--ctl-r); + font-size: 13px; color: var(--text); outline: 0; + transition: border-color .12s ease, box-shadow .12s ease; +} +.su-sheet .sufield > input:hover { border-color: color-mix(in oklab, var(--line) 50%, var(--muted)); } +.su-sheet .sufield > input:focus { border-color: var(--amber); box-shadow: var(--ctl-focus); background: var(--paper); } +.su-sheet .sugsub { font-size: 12px; font-weight: 600; color: var(--muted); } +.su-sheet .sucap { font-family: inherit; font-size: 12px; letter-spacing: 0; text-transform: none; color: var(--muted); } + +/* Where the credentials come from: one quiet box above the boxes it is about, + the whole of it the link to the console. */ +.su-guide { + display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-radius: 10px; + background: var(--surface); border: 1px solid var(--line-soft); + color: var(--muted); font-size: 12.5px; line-height: 1.5; text-decoration: none; + transition: border-color .15s ease; +} +.su-guide:hover { border-color: var(--line); } +.su-guide-t { flex: 1; min-width: 0; } +.su-guide-go { flex: none; display: inline-flex; align-items: center; gap: 3px; color: var(--text); font-weight: 500; } +.su-guide-go svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.su-keep { margin: 0; font-size: 12px; color: var(--faint); } + +.su-sheet > .sufoot { + flex: none; margin-top: 0; padding: 14px 20px 18px; gap: 9px; + border-top: 1px solid var(--line-soft); background: none; +} +.su-sheet > .sufoot .n { font-family: inherit; font-size: 12px; color: var(--faint); } +/* Backing out: a word at the far end, not a second button beside the verb. */ +.su-sheet > .sufoot .mini.su-off { min-width: 0; padding: 0 10px; margin-left: -10px; border-color: transparent; background: none; color: var(--muted); } +:root[data-lang="en"] .su-sheet > .sufoot .mini.su-off { min-width: 0; } +.su-sheet > .sufoot .mini.su-off:hover:not(:disabled) { color: var(--clay); background: color-mix(in oklab, var(--clay) 7%, transparent); } + +/* Scanning: the code's place on the left, drawn before there is a code, and + the three steps beside it. */ +.su-sheet > .subody.su-scan { grid-template-columns: 172px minmax(0, 1fr); gap: 0 24px; align-items: start; } +.su-qr { display: grid; justify-items: center; } +.su-qr .qrbox { padding: 0; margin: 0; border: 0; gap: 10px; } +.su-qr .qrshot { width: 172px; height: 172px; border-radius: 12px; } +.su-qr .qrsay { font-size: 12px; } +.su-qrph { + width: 172px; height: 172px; box-sizing: border-box; padding: 18px; border-radius: 12px; + border: 1px dashed var(--line); background: var(--surface); + display: grid; place-items: center; align-content: center; gap: 10px; + color: var(--faint); font-size: 12px; line-height: 1.5; text-align: center; +} +.su-qrph svg { width: 34px; height: 34px; } +.su-qrph-ok { border-style: solid; border-color: color-mix(in oklab, var(--moss) 40%, transparent); color: var(--moss); } +.su-steps { display: grid; gap: 2px; padding-top: 4px; } +.su-steps .step .st { font-size: 13.5px; } +.su-steps .step .sd { font-size: 12px; line-height: 1.5; } + +/* The sheet's one verb in the ink the agent hub's sheet uses for its own. A + disabled one keeps that ink and takes the page's disabled fade, as the + agent sheet's does, rather than turning a grey of its own. */ +.su-sheet > .sufoot .mini.key { background: var(--text); border-color: var(--text); color: var(--ink); } +.su-sheet > .sufoot .mini.key:hover:not(:disabled) { filter: brightness(1.18); } diff --git a/ui-web/src/features/connections/types.ts b/ui-web/src/features/connections/types.ts index 9b1884025..5d2e71b0b 100644 --- a/ui-web/src/features/connections/types.ts +++ b/ui-web/src/features/connections/types.ts @@ -24,6 +24,10 @@ export interface ConnChannel { running?: boolean | null connected?: boolean | null qrLogin?: boolean + /* The gateway's reason, in the reader's words, for the last start this page + asked for and did not get. Kept on the row so the sheet can show it for as + long as the entrance stays down; a status read carries no reason. */ + refusal?: string } /* One channels.qr answer. `connected: true` ends the island's polling, and @@ -39,7 +43,7 @@ export interface ConnQr { /* The DS.connections contract both the offline fixture library and the rpc source (live layer) implement. The island only ever talks to this. `rows(true)` is the page-open fetch: the rpc source reserves its - gateway-not-running warning for that one call. `qr` resolving null means + failed-load toast for that one call. `qr` resolving null means "nothing to show yet"; the island keeps polling while the dialog is up. */ export interface ConnectionsSource { rows(initial?: boolean): Promise diff --git a/ui-web/src/features/extAgents/ExtAgentsPage.tsx b/ui-web/src/features/extAgents/ExtAgentsPage.tsx index 07a9458c1..bfc0aa439 100644 --- a/ui-web/src/features/extAgents/ExtAgentsPage.tsx +++ b/ui-web/src/features/extAgents/ExtAgentsPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useSyncExternalStore } from 'react' import { createPortal } from 'react-dom' +import { HubHead } from '../../components/HubHead' import { KeyInput } from '../../components/KeyInput' import { ModelPicker } from '../../components/ModelPicker' import { t } from '../../i18n/t' @@ -704,12 +705,7 @@ export function ExtAgentsApp(): JSX.Element { const sheetRow = s.sheet ? s.rows.find((x) => x.name === s.sheet) : undefined return ( <> -
-
-

{t('gui.page.agents')}

-

{t('gui.page.agents_sub')}

-
-
+ {/* All and Connected are always offered; the other two only with agents in them, or while they are the tab being read -- so connecting the last one leaves the reader on an emptied tab rather than moving them. */} diff --git a/ui-web/src/features/extAgents/__snapshots__/ExtAgentsPage.test.tsx.snap b/ui-web/src/features/extAgents/__snapshots__/ExtAgentsPage.test.tsx.snap index 565bd1b0d..65aa69897 100644 --- a/ui-web/src/features/extAgents/__snapshots__/ExtAgentsPage.test.tsx.snap +++ b/ui-web/src/features/extAgents/__snapshots__/ExtAgentsPage.test.tsx.snap @@ -1,10 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`the sheet > keeps its rendered shape, grid 1`] = ` -"div.pmhero - div - h3 - p +"div.hub-head + button.hub-head-tab + button.hub-head-tab div.extAgents-tabs button.extAgents-tab span.extAgents-tn diff --git a/ui-web/src/features/extAgents/store.ts b/ui-web/src/features/extAgents/store.ts index 34f7c51b6..4961852ab 100644 --- a/ui-web/src/features/extAgents/store.ts +++ b/ui-web/src/features/extAgents/store.ts @@ -1,5 +1,6 @@ import { t } from '../../i18n/t' import * as detail from '../../state/detail' +import * as hub from '../../state/hub' import * as page from '../../state/page' import { ds } from '../../state/sources' import { makeStore } from '../../state/store' @@ -143,6 +144,7 @@ export function open(): void { page.show('extAgentsPage') void load(true) } +hub.onOpen('agents', open) export function close(): void { page.show(null) diff --git a/ui-web/src/features/settings/SettingsApp.test.tsx b/ui-web/src/features/settings/SettingsApp.test.tsx index 3ab1abebc..00a5df313 100644 --- a/ui-web/src/features/settings/SettingsApp.test.tsx +++ b/ui-web/src/features/settings/SettingsApp.test.tsx @@ -87,7 +87,7 @@ describe('settings root', () => { expect([...nav].map((b) => b.textContent)).toEqual([ 'gui.settings.nav.general', 'gui.settings.nav.usage', 'gui.settings.nav.provider', 'gui.settings.nav.model', 'gui.settings.nav.skills', 'gui.settings.nav.tools', - 'gui.settings.nav.plugins', 'gui.settings.nav.channels', 'gui.settings.nav.cron', + 'gui.settings.nav.plugins', 'gui.settings.nav.cron', 'gui.settings.nav.memory', 'gui.settings.nav.archive', 'gui.settings.nav.about', ]) expect(nav[0]!.getAttribute('aria-current')).toBe('true') diff --git a/ui-web/src/features/settings/SettingsApp.tsx b/ui-web/src/features/settings/SettingsApp.tsx index 2da8b8db7..791e00e3d 100644 --- a/ui-web/src/features/settings/SettingsApp.tsx +++ b/ui-web/src/features/settings/SettingsApp.tsx @@ -3,7 +3,7 @@ owns the veil, the modal and the two columns; this island renders into them and writes the section's name to #setTitle on every draw. */ import { - AiContentGenerator01Icon, BrainIcon, InformationCircleIcon, Message02Icon, Plug02Icon, Settings03Icon, + AiContentGenerator01Icon, BrainIcon, InformationCircleIcon, Plug02Icon, Settings03Icon, Settings05Icon, TimeQuarter02Icon, Wrench01Icon, } from '@hugeicons/core-free-icons' import { useEffect, useSyncExternalStore } from 'react' @@ -42,7 +42,6 @@ const ICON: Record = { skills: , tools: , plugins: , - channels: , cron: , memory: , archive: , @@ -58,7 +57,6 @@ const NAV: Record = { skills: 'gui.settings.nav.skills', tools: 'gui.settings.nav.tools', plugins: 'gui.settings.nav.plugins', - channels: 'gui.settings.nav.channels', cron: 'gui.settings.nav.cron', memory: 'gui.settings.nav.memory', archive: 'gui.settings.nav.archive', diff --git a/ui-web/src/features/settings/store.ts b/ui-web/src/features/settings/store.ts index c7795693f..3d1cd4f8c 100644 --- a/ui-web/src/features/settings/store.ts +++ b/ui-web/src/features/settings/store.ts @@ -33,28 +33,28 @@ import type { export type SectionId = | 'general' | 'usage' | 'provider' | 'model' | 'skills' | 'tools' | 'plugins' - | 'channels' | 'cron' | 'memory' | 'archive' | 'about' + | 'cron' | 'memory' | 'archive' | 'about' /* The nav's order, which is also the reading: what the dialog is about first (the page itself, what it cost), then what it is made of (the accounts, the - models each role takes, skills, tools, plugins), then the three surfaces a - reader sets up once and leaves alone, then the record and the version. */ + models each role takes, skills, tools, plugins), then the two surfaces a + reader sets up once and leaves alone, then the record and the version. + Channels were a third and are a page on the rail now (state/pages.ts). */ export const SECTIONS: SectionId[] = [ 'general', 'usage', 'provider', 'model', 'skills', 'tools', 'plugins', - 'channels', 'cron', 'memory', 'archive', 'about', + 'cron', 'memory', 'archive', 'about', ] -/* The three sections another domain's island fills, as the box it fills. +/* The two sections another domain's island fills, as the box it fills. * - * Schedules, channels and memory were module pages of their own and are - * sections here now. Their islands did not move with them: each still mounts + * Schedules and memory were module pages of their own and are sections here + * now. Their islands did not move with them: each still mounts * into a box of its own (features//manifest.ts's host, rendered by * src/App.tsx inside the dialog), because a React root inside this island's * tree would be unmounted the moment the reader picked another section. So - * this island draws nothing for these three -- the pane beside it is theirs -- + * this island draws nothing for these two -- the pane beside it is theirs -- * and the stylesheet shows whichever box the open section names. */ export const HOSTED: Partial> = { - channels: 'connectionsBody', cron: 'cronBody', memory: 'memoryBody', } diff --git a/ui-web/src/state/detail.ts b/ui-web/src/state/detail.ts index 9fbb49958..d14798061 100644 --- a/ui-web/src/state/detail.ts +++ b/ui-web/src/state/detail.ts @@ -23,8 +23,8 @@ import { makeStore } from './store' -/** The four islands that share the drawer. */ -export type DetailOwner = 'memory' | 'plugins' | 'skills' | 'extAgents' +/** The five islands that share the drawer. */ +export type DetailOwner = 'memory' | 'plugins' | 'skills' | 'extAgents' | 'connections' export type DetailState = { readonly owner: DetailOwner | null @@ -37,7 +37,7 @@ export type DetailState = { readonly gen: number } -const CLOSE_ORDER: readonly DetailOwner[] = ['plugins', 'skills', 'memory', 'extAgents'] +const CLOSE_ORDER: readonly DetailOwner[] = ['plugins', 'skills', 'memory', 'extAgents', 'connections'] /* Kept in step with `.detail`'s opacity transition in page.css. A little longer than the transition, so the drop lands after the last painted frame @@ -81,7 +81,7 @@ export function host(owner: DetailOwner): HTMLDivElement { let el = hosts.get(owner) if (!el) { el = document.createElement('div') - if (owner === 'memory' || owner === 'extAgents') el.style.display = 'contents' + if (owner === 'memory' || owner === 'extAgents' || owner === 'connections') el.style.display = 'contents' hosts.set(owner, el) } return el diff --git a/ui-web/src/state/escapeOrder.test.ts b/ui-web/src/state/escapeOrder.test.ts index c4dfef42c..b795df7a2 100644 --- a/ui-web/src/state/escapeOrder.test.ts +++ b/ui-web/src/state/escapeOrder.test.ts @@ -13,7 +13,7 @@ * C11 (features/desk/store.ts's registered `desk.escapeOpen()`, for its own * fullscreen -> node -> pane -> collapse retreat). What is asserted against * it is now the table, every entry's own predicate and action against a - * fixture page, and all forty-five pairs of layers. The three + * fixture page, and all fifty-five pairs of layers. The three * capture-phase handlers * each open sheet registers run *before* the table and two of them act on * Escape without stopping propagation, so one Escape can both deny an approval @@ -32,6 +32,7 @@ import { _resetForTests as sessionReset, setCurrent } from '../lib/session' import * as escapeOrder from './escapeOrder' import * as find from './find' import { installEscapeOrder } from './globalListeners' +import * as page from './page' import * as perm from './perm' import * as plus from './plus' import * as settingsDialog from './settings' @@ -41,7 +42,7 @@ import * as workdir from './workdir' import type { ComposerSource } from '../features/composer/types' -/* The ten, in the order Escape reaches them. Each item is the text the chain +/* The eleven, in the order Escape reaches them. Each item is the text the chain tests to decide whether that layer is on screen -- a selector for the seven elements, the predicate's own name for the three that have no element of their own to look at. @@ -53,6 +54,7 @@ const LAYER_IDS = [ '#veil', '#detail', '#extAgentsPage', + '#connectionsPage', 'setIsOpen()', 'desk.escapeOpen()', '#permPop', @@ -77,6 +79,7 @@ const PAGE = [ '
', '
', '
', + '
', '', '
', '
', @@ -90,6 +93,7 @@ const PAGE = [ is visible. */ const spies = { extAgentsClose: vi.fn(), + pageShow: vi.fn(), permClose: vi.fn(), plusClose: vi.fn(), wdClose: vi.fn(), @@ -119,6 +123,7 @@ const LAYERS: Record void; taken: () => boolean }> = { '#veil': { up: flag('veil'), taken: () => cancelled.includes('cfNo') }, '#detail': { up: flag('detail'), taken: lowered('detail') }, '#extAgentsPage': { up: flag('extAgentsPage'), taken: called(spies.extAgentsClose) }, + '#connectionsPage': { up: flag('connectionsPage'), taken: called(spies.pageShow) }, 'setIsOpen()': { up: () => settingsDialog.open(), taken: () => !settingsDialog.isOpen() }, /* Its four-rung retreat (fullscreen -> node -> pane -> collapse) is store.test.ts's to prove; this fixture only needs one rung on screen and @@ -153,6 +158,7 @@ beforeEach(() => { /* Each layer's own close, stood in for one export at a time: what is under test is which one the key reaches, not what any of them does. */ vi.spyOn(extAgents, 'close').mockImplementation(spies.extAgentsClose) + vi.spyOn(page, 'show').mockImplementation(spies.pageShow) vi.spyOn(perm, 'close').mockImplementation(spies.permClose) vi.spyOn(plus, 'close').mockImplementation(spies.plusClose) vi.spyOn(workdir, 'close').mockImplementation(spies.wdClose) @@ -188,11 +194,11 @@ const key = (k: string, over: Partial = {}): KeyboardEvent => } describe('the Escape priority order', () => { - it('is the order the table reaches the ten layers in', () => { + it('is the order the table reaches the eleven layers in', () => { expect(escapeOrder.ESCAPE_ORDER.map((layer) => layer.id)).toEqual([...LAYER_IDS]) }) - it('has no eleventh entry, and every entry is in the fixture', () => { + it('has no twelfth entry, and every entry is in the fixture', () => { expect(escapeOrder.ESCAPE_ORDER).toHaveLength(LAYER_IDS.length) expect(Object.keys(LAYERS)).toEqual([...LAYER_IDS]) }) @@ -215,8 +221,8 @@ describe('the Escape priority order', () => { const pairs = LAYER_IDS.flatMap((first, i) => LAYER_IDS.slice(i + 1).map((second) => ({ first, second }))) - it('has forty-five pairs to answer for', () => { - expect(pairs).toHaveLength(45) + it('has fifty-five pairs to answer for', () => { + expect(pairs).toHaveLength(55) }) it.each(pairs)('takes back $first and leaves $second alone', ({ first, second }) => { diff --git a/ui-web/src/state/escapeOrder.ts b/ui-web/src/state/escapeOrder.ts index f1cf5ab30..0a5a2218f 100644 --- a/ui-web/src/state/escapeOrder.ts +++ b/ui-web/src/state/escapeOrder.ts @@ -1,6 +1,6 @@ /* What Escape takes back, and in which order. * - * Ten layers can be on screen at once, and one key closes one of them. + * Eleven layers can be on screen at once, and one key closes one of them. * Which one was a fourteen-branch if chain in the page's chrome: a list of * selectors read top to bottom, each branch returning so the ones below it * never ran. @@ -30,6 +30,7 @@ import { busy as turnBusy } from '../features/composer/turn' import * as extAgents from '../features/extAgents/store' import * as detail from './detail' import { close as closeImage, isOpen as imageOpen } from './lightbox' +import * as page from './page' import { byEscape } from './pages' import * as perm from './perm' import * as plus from './plus' @@ -65,6 +66,7 @@ const cancels = (id: string) => (): void => { document.getElementById(id)?.click silence. The verb is the domain's; the order is the table's. */ const CLOSERS: Record void> = { extAgentsPage: () => extAgents.close(), + connectionsPage: () => page.show(null), } /* The four layers Escape reaches before any page. Two of them are raised from @@ -126,7 +128,7 @@ const BELOW: readonly EscapeLayer[] = [ { id: 'turn.busy()', isOpen: turnBusy, close: () => ds('composer').stop() }, ] -/** The ten, in the order Escape reaches them. */ +/** The eleven, in the order Escape reaches them. */ export const ESCAPE_ORDER: readonly EscapeLayer[] = [ ...ABOVE, ...byEscape().map((page) => ({ diff --git a/ui-web/src/state/hub.test.ts b/ui-web/src/state/hub.test.ts new file mode 100644 index 000000000..9ea937257 --- /dev/null +++ b/ui-web/src/state/hub.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import * as hub from './hub' +import * as page from './page' + +/* The two pages the hub switches between, and the drawer a page switch closes, + so page.show runs against the markup it writes on. */ +beforeEach(() => { + document.body.innerHTML = + '
' + + '
' + + '
' + + '' + hub._resetForTests() +}) + +afterEach(() => { + page.show(null) + vi.restoreAllMocks() +}) + +describe('the connections hub', () => { + it('opens agents first, before the reader has been anywhere', () => { + const agents = vi.fn() + hub.onOpen('agents', agents) + hub.onOpen('channels', vi.fn()) + hub.open() + expect(agents).toHaveBeenCalledTimes(1) + }) + + /* The rail row goes back to where the reader was, whichever door they used: + the greeting's entry opens the channel page through its own store, not + through the hub. */ + it('goes back to the module the reader was last on, by the page switch', () => { + const agents = vi.fn() + const channels = vi.fn() + hub.onOpen('agents', agents) + hub.onOpen('channels', channels) + page.show('connectionsPage') + page.show(null) + hub.open() + expect(channels).toHaveBeenCalledTimes(1) + expect(agents).not.toHaveBeenCalled() + page.show('extAgentsPage') + expect(hub.lastOpened()).toBe('agents') + }) + + /* Leaving the hub is not a module: a switch to no page, or to a page that is + not one of the two, keeps the last one. */ + it('keeps the last module across a switch to nothing', () => { + page.show('connectionsPage') + page.show(null) + expect(hub.lastOpened()).toBe('channels') + }) + + it('opens the module it is asked for', () => { + const channels = vi.fn() + hub.onOpen('agents', vi.fn()) + hub.onOpen('channels', channels) + hub.open('channels') + expect(channels).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui-web/src/state/hub.ts b/ui-web/src/state/hub.ts new file mode 100644 index 000000000..e09744f2e --- /dev/null +++ b/ui-web/src/state/hub.ts @@ -0,0 +1,58 @@ +/* The connections hub: the agent page and the channel page, reached through + * one rail row and switched between by the tabs over both (components/HubHead.tsx). + * + * They stay two module pages, each its own domain's island, because each + * already owns its store, its sheet and its fetch; what makes them one place is + * that they share a rail button (state/pages.ts), a header, and this: the + * module the reader was last on, so the rail row goes back to it. + * + * Each domain registers what opening its module costs at its own module + * evaluation, the way features/cron/store.ts registers on state/settings.ts: + * state/ may not import an island, and the rail and the header would otherwise + * each need both. + */ + +import * as page from './page' + +import type { PageId } from './pages' + +export type HubModule = 'agents' | 'channels' + +const PAGE_OF: Record = { + agents: 'extAgentsPage', + channels: 'connectionsPage', +} + +const openers = new Map void>() + +let last: HubModule = 'agents' + +/** Registers what a domain does to open its module. */ +export function onOpen(which: HubModule, fn: () => void): void { + openers.set(which, fn) +} + +/** Opens one module, or the one the reader was last on. */ +export function open(which: HubModule = last): void { + openers.get(which)?.() +} + +/* Followed off the page switch rather than set by `open`: the greeting's entry + opens the channel page through its own store, and the rail should come back + to where the reader actually was, whichever door they used. */ +page.subscribe(() => { + const at = page.get() + for (const which of Object.keys(PAGE_OF) as HubModule[]) { + if (PAGE_OF[which] === at) last = which + } +}) + +/** The module the rail row opens next. */ +export function lastOpened(): HubModule { + return last +} + +export function _resetForTests(): void { + last = 'agents' + openers.clear() +} diff --git a/ui-web/src/state/page.test.ts b/ui-web/src/state/page.test.ts index d3bc00698..6f582d964 100644 --- a/ui-web/src/state/page.test.ts +++ b/ui-web/src/state/page.test.ts @@ -14,7 +14,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' /** The module pages, as NAV_OF keys them. */ -const PAGES = ['extAgentsPage'] as const +const PAGES = ['extAgentsPage', 'connectionsPage'] as const interface Fresh { page: typeof import('./page') diff --git a/ui-web/src/state/pages.ts b/ui-web/src/state/pages.ts index ebdacb6c0..5f8032358 100644 --- a/ui-web/src/state/pages.ts +++ b/ui-web/src/state/pages.ts @@ -44,14 +44,15 @@ export interface ModulePage { from it. Read through `PAGES`, which is the same rows with the shape above rather than a shape per row. - One page, because a place and a setting are different things. Schedules, - channels and memory are set up once and then left alone, so they are - sections of the settings dialog now (features/settings/store.ts's SECTIONS) - rather than pages of their own; the agent hub is the one module a reader - goes TO. The table stays a table: what a page costs to declare is what kept - six registrations from going stale, and it is the same cost for one row. */ + Two pages and one place. Schedules and memory are set up once and then left + alone, so they are sections of the settings dialog (features/settings/ + store.ts's SECTIONS). Agents and channels are the two things Raven connects + to -- what it hands work to, and where it can be reached from -- so they + share one rail button and one header (state/hub.ts), and stay two pages so + each domain keeps its own island, sheet and fetch. */ const DECLARED = [ { id: 'extAgentsPage', bodyId: 'extAgentsBody', navButtons: ['agentsBtn'], escapeRank: 1, head: 'gui.page.agents', aria: 'gui.page.agents' }, + { id: 'connectionsPage', bodyId: 'connectionsBody', navButtons: ['agentsBtn'], escapeRank: 2, head: 'gui.conn.page', aria: 'gui.conn.page' }, ] as const satisfies readonly ModulePage[] /** The module pages, keyed as their `
` ids. */ diff --git a/ui-web/src/state/portals.test.ts b/ui-web/src/state/portals.test.ts index 7f0118d08..26708f746 100644 --- a/ui-web/src/state/portals.test.ts +++ b/ui-web/src/state/portals.test.ts @@ -82,7 +82,7 @@ describe('the standing layers', () => { whose shape drifted from its row would pass every assertion above. */ it('makes the four layers the boot order names, in that order', () => { const boot = PORTALS.filter((p) => p.kind === 'append' && p.at !== 'last') - expect(boot.map((p) => p.at)).toEqual([10, 11, 12, 13]) + expect(boot.map((p) => p.at)).toEqual([11, 12, 13, 14]) expect(LAYERS).toHaveLength(boot.length) for (const name of LAYERS) host(name) const keys = [...document.body.children].map(keyOf) diff --git a/ui-web/src/state/settings.ts b/ui-web/src/state/settings.ts index 8ac8c035f..ba49fdf5c 100644 --- a/ui-web/src/state/settings.ts +++ b/ui-web/src/state/settings.ts @@ -37,8 +37,8 @@ export const settingsTab: { id: string | null } = { id: 'general' } /* What opens the dialog once a section is picked, filled by the page's wiring (src/app/install.ts) with the island's own open -- which draws, raises the - veil and reloads. A slot rather than an import: three domains are sections - of this dialog now (schedules, channels, memory) and each opens itself + veil and reloads. A slot rather than an import: two domains are sections + of this dialog now (schedules, memory) and each opens itself through `openSection` below, so an import from here would put the settings island in every one of their closures. Unfilled it falls through to `open`, which is a dialog on whatever the island last drew: enough for a test that @@ -50,16 +50,15 @@ export function onOpen(fn: () => void): void { opener = fn } -/* What a section leaves behind: the channel the channels pane was showing, and - the new-job sheet the schedules section raises over the dialog. Left alone - both survive a section pick and a close, and come back over whatever the - reader opens next, still on the entry they were left on -- which is what - these slots take back. What a slot may NOT do is throw work away: the +/* What a section leaves behind: the new-job sheet the schedules section raises + over the dialog. Left alone it survives a section pick and a close, and + comes back over whatever the reader opens next, still on the entry it was + left on -- which is what these slots take back. What a slot may NOT do is throw work away: the schedules one parks its sheet rather than closing it, because the link a reader follows out of that form is inside the form (features/cron/store.ts). Each is registered by the domain that owns it, at its own module evaluation, because state/ does not import an island; unfilled, a leave asks nothing. */ -const SLOTS = ['clearConnChannel', 'parkCronSheet'] as const +const SLOTS = ['parkCronSheet'] as const /** One of the sheets a section leaves behind. */ export type LeaveSlot = (typeof SLOTS)[number] diff --git a/ui-web/src/styles/page.css b/ui-web/src/styles/page.css index 9d5929e3d..10043688a 100644 --- a/ui-web/src/styles/page.css +++ b/ui-web/src/styles/page.css @@ -3370,17 +3370,25 @@ details[open] summary { margin-bottom: 8px; } /* ── plugin market (pm*) — rides the hub grid, adds status language ── */ /* page hero: the market's one big line -- everything else stays quiet */ -/* Every module page opens with the same hero: h3 title, muted sub, one - rhythm (10px above, 18px down to the page's controls). */ -.pmhero { margin: 10px 0 22px; display: flex; align-items: center; - justify-content: space-between; gap: 16px; flex-wrap: wrap; } #pageHero { margin: 10px 0 18px; } #pageHero[hidden] { display: none; } -.pmhero h3 { - margin: 0; font-family: var(--mono); font-size: var(--step-2); font-weight: 700; - letter-spacing: -.01em; line-height: 1.3; -} -.pmhero p { margin: 5px 0 0; font-size: 12.5px; color: var(--muted); } +/* The connections hub's header (components/HubHead.tsx): the two modules as + underlined tabs over a hairline, the top of the page with no title above + and no counts beside them. + Both tabs keep one weight so the row does not shift when the pick moves; + the colour and the rule under the current one are the whole difference. */ +.hub-head { display: flex; gap: 28px; margin: 10px 0 16px; border-bottom: 1px solid var(--line-soft); } +.hub-head-tab { + position: relative; padding: 0 0 11px; border: 0; background: none; cursor: pointer; + font: inherit; font-size: 16px; font-weight: 600; color: var(--muted); + transition: color .15s ease; +} +.hub-head-tab:hover { color: var(--text); } +.hub-head-tab[aria-selected="true"] { color: var(--text); cursor: default; } +.hub-head-tab[aria-selected="true"]::after { + content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; border-radius: 2px; background: var(--text); +} +.hub-head-tab:focus-visible { outline: 2px solid var(--amber); outline-offset: 3px; border-radius: 4px; } .pmchips { display: flex; gap: 7px; flex-wrap: wrap; margin: 14px 0 18px; } .pmcard { cursor: pointer; } .pmcard.dim { opacity: .68; } @@ -3684,19 +3692,6 @@ details[open] summary { margin-bottom: 8px; } padding-top: 14px; border-top: 1px solid var(--line); } .sufoot .n { flex: 1; font-family: var(--mono); font-size: 11px; color: var(--faint); } -/* The credentials heading, and the one link that answers "where do I get - these" -- a channel's secrets are minted in its own console, and the reader - should not have to guess which of a vendor's portals issues this token. */ -.sucreds { display: flex; align-items: baseline; gap: 10px; margin-top: 16px; } -.sucreds .k { font-family: var(--mono); font-size: var(--xs, 10.5px); letter-spacing: .1em; - text-transform: uppercase; color: var(--faint); } -.sucreds .k .n { margin-left: 8px; letter-spacing: 0; text-transform: none; color: var(--muted); - font-variant-numeric: tabular-nums; } -.jump { display: inline-flex; align-items: center; gap: 4px; margin-left: auto; - font-family: var(--mono); font-size: 11.5px; color: var(--gold); text-decoration: none; flex: none; } -.jump:hover { text-decoration: underline; } -.jump svg { width: 10px; height: 10px; stroke: currentColor; stroke-width: 2; fill: none; - stroke-linecap: round; stroke-linejoin: round; } /* The dialog's one-line subject, between title and fields. */ .sheet .dsub { padding: 3px 20px 0; font-size: 12px; color: var(--faint); } @@ -3851,10 +3846,13 @@ details[open] summary { margin-bottom: 8px; } transition: transform .3s cubic-bezier(.22, .61, .36, 1); } .detail[data-open="true"] .dpanel { transform: none; } -/* The agents sheet is a fixed box the domain fills to its edges: the panel takes - its size from the owner, and the body's padding is the sheet's to spend. */ -.detail[data-owner="extAgents"] .dpanel { width: 520px; height: min(500px, 78vh); } -.detail[data-owner="extAgents"] .body { padding: 0; display: flex; flex-direction: column; flex: 1; min-height: 0; } +/* The agent and channel sheets are fixed boxes the domain fills to its edges: + the panel takes its size from the owner, and the body's padding is the + sheet's to spend. */ +.detail[data-owner="extAgents"] .dpanel, +.detail[data-owner="connections"] .dpanel { width: 520px; height: min(500px, 78vh); } +.detail[data-owner="extAgents"] .body, +.detail[data-owner="connections"] .body { padding: 0; display: flex; flex-direction: column; flex: 1; min-height: 0; } /* A card whose body is fetched when it opens gets a settled box instead of one that sizes to its content: it went 63px (a line of loading text) -> 640px (the card), and a skeleton of any fixed height only moves that jump @@ -3982,32 +3980,28 @@ details[open] summary { margin-bottom: 8px; } .shd .sub { font-size: 12.5px; color: var(--muted); margin: 3px 0 0; line-height: 1.5; } .spanels { overflow-y: auto; min-height: 0; padding: 18px 20px 26px; } -/* Three sections are another domain's island, rooted in a box of its own - beside #spanels rather than inside it (src/App.tsx renders all four, and +/* Two sections are another domain's island, rooted in a box of its own + beside #spanels rather than inside it (src/App.tsx renders all three, and features/settings/store.ts's HOSTED says why the roots stay separate). One - of the four is on screen, and the section the dialog stands on is what picks + of the three is on screen, and the section the dialog stands on is what picks it -- `data-section` on the veil, written by the settings island on every draw. Rows: .sbody is a two-row grid, and `display: none` takes the other - three out of it rather than leaving three empty tracks. */ + two out of it rather than leaving two empty tracks. */ .sbody > .spanels[data-for] { display: none; } -.setveil[data-section="channels"] .spanels[data-for="channels"], .setveil[data-section="cron"] .spanels[data-for="cron"], .setveil[data-section="memory"] .spanels[data-for="memory"] { display: block; } -.setveil[data-section="channels"] #spanels, .setveil[data-section="cron"] #spanels, .setveil[data-section="memory"] #spanels { display: none; } /* ── the two-pane section frame ───────────────────────────────────────── - Three sections are a list and what the picked row is: channels, schedules - and memory. The frame is src/components/TwoPane.tsx, which owns these names - and is why all three read as one dialog rather than three pages that - happened to move. Beside the dialog's own rules, not in a domain's sheet, - because no one domain owns it. + Two sections are a list and what the picked row is: schedules and memory. + The frame is src/components/TwoPane.tsx, which owns these names and is why + both read as one dialog rather than two pages that happened to move. Beside + the dialog's own rules, not in a domain's sheet, because no one domain owns + it. The hosted panel does not scroll: the two columns inside it do, each on its - own, so a long channel list never carries the form on the right down with - it. */ -.setveil[data-section="channels"] .spanels[data-for="channels"], + own, so a long list never carries the form on the right down with it. */ .setveil[data-section="cron"] .spanels[data-for="cron"], .setveil[data-section="memory"] .spanels[data-for="memory"] { display: grid; grid-template-rows: minmax(0, 1fr); overflow: hidden; @@ -4439,7 +4433,6 @@ input[type="range"] { accent-color: var(--amber); width: 190px; } /* ── scheduled work: job cards ride the market card language ───────── */ .herorow { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 10px 0 18px; } -.herorow .pmhero { margin: 0; } .cronlist { display: grid; gap: 12px; } .cronjob { display: grid; grid-template-columns: 1fr auto; gap: 5px 14px; align-items: start; @@ -5851,7 +5844,7 @@ body:has(.app[data-page="on"]) #deskHost { display: none; } /* The agent connector's column is the composer's: the same `--col` cap and the same 36px gutters `.dock` gives it, so the grid's edges line up with the input box the reader just left rather than sitting inside a narrower one. */ -#extAgentsPage .wrap { max-width: calc(var(--col) + 72px); padding-left: 36px; padding-right: 36px; } +#extAgentsPage .wrap, #connectionsPage .wrap { max-width: calc(var(--col) + 72px); padding-left: 36px; padding-right: 36px; } #kbPage .wrap { max-width: none; height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 18px 22px 22px; diff --git a/ui-web/src/test/__golden__/region-connectionsPage.txt b/ui-web/src/test/__golden__/region-connectionsPage.txt new file mode 100644 index 000000000..efe6c2b9e --- /dev/null +++ b/ui-web/src/test/__golden__/region-connectionsPage.txt @@ -0,0 +1,5 @@ +section#connectionsPage.page[data-open=false] + header + h2 + div.work + div#connectionsBody.wrap diff --git a/ui-web/src/test/__golden__/region-setVeil.txt b/ui-web/src/test/__golden__/region-setVeil.txt index 13ee6a8cc..cc36b90cc 100644 --- a/ui-web/src/test/__golden__/region-setVeil.txt +++ b/ui-web/src/test/__golden__/region-setVeil.txt @@ -13,6 +13,5 @@ div#setVeil.veil.setveil[data-open=false][data-section=usage] svg path div#spanels.spanels - div#connectionsBody.spanels[data-for=channels] div#cronBody.spanels[data-for=cron] div#memoryBody.spanels[data-for=memory] diff --git a/ui-web/src/test/portals.test.ts b/ui-web/src/test/portals.test.ts index 3403f4f8c..4ad9b3c04 100644 --- a/ui-web/src/test/portals.test.ts +++ b/ui-web/src/test/portals.test.ts @@ -118,7 +118,7 @@ describe('the portal table', () => { page installs and .upshade only when an upgrade starts. The table copies what is measured, not what the comment intends. */ expect(BOOT_BODY_ORDER.indexOf('div.tipp')).toBeGreaterThan(-1) - expect(PORTALS.find((p) => p.id === '.tipp')!.at).toBe(13) + expect(PORTALS.find((p) => p.id === '.tipp')!.at).toBe(14) expect(PORTALS.find((p) => p.id === '.upshade')!.at).toBe('last') }) @@ -164,7 +164,7 @@ describe('the portal table', () => { it('gives the model picker its place among the standing layers, and reparents nothing', () => { const picker = PORTALS.find((p) => p.id === 'pickHost')! - expect(picker.at).toBe(11) + expect(picker.at).toBe(12) /* The composer's popovers hang off their chips with the stylesheet now, so no portal leaves the card it was born in. */ expect(PORTALS.filter((p) => p.kind === 'reparent')).toEqual([]) From dd73164ede858eb056990528e8cc081fdef73eab Mon Sep 17 00:00:00 2001 From: arelchan <204152633+arelchan@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:50:40 +0800 Subject: [PATCH 2/3] test(ui-web): spell the Chinese channel name as an escape in the spacing test Co-authored-by: Claude (claude-opus-5-5) --- .../src/features/connections/ConnectionsPage.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui-web/src/features/connections/ConnectionsPage.test.tsx b/ui-web/src/features/connections/ConnectionsPage.test.tsx index b126539f0..45d011f7b 100644 --- a/ui-web/src/features/connections/ConnectionsPage.test.tsx +++ b/ui-web/src/features/connections/ConnectionsPage.test.tsx @@ -708,19 +708,21 @@ describe('connections island', () => { }) /* A Latin name inside a Chinese sentence takes a space either side; a - Chinese name takes none. */ + Chinese name takes none. The Chinese name is escaped: source added in a PR stays + ASCII (scripts/check_source_language.py). */ it('spaces a Latin name inside a Chinese sentence, and only there', async () => { + const FEISHU = '\u98de\u4e66' install([ chan({ id: 'telegram', key: 'Telegram', fields: [{ key: 'token', required: true }], missing: ['token'] }), - chan({ id: 'feishu', key: '飞书', fields: [{ key: 'app_id', required: true }], missing: ['app_id'] }), + chan({ id: 'feishu', key: FEISHU, fields: [{ key: 'app_id', required: true }], missing: ['app_id'] }), ]) act(() => lang.set('zh')) try { await mount() await act(async () => { openRow('Telegram') }) expect(main().querySelector('.su-guide-t')!.textContent).toBe('gui.conn.guide {"name":" Telegram "}') - await act(async () => { openRow('飞书') }) - expect(main().querySelector('.su-guide-t')!.textContent).toBe('gui.conn.guide {"name":"飞书"}') + await act(async () => { openRow(FEISHU) }) + expect(main().querySelector('.su-guide-t')!.textContent).toBe(`gui.conn.guide {"name":"${FEISHU}"}`) } finally { act(() => lang._resetForTests()) } From 5f058a0b6945474bbf1e8d0b1640b920f3aa49cd Mon Sep 17 00:00:00 2001 From: arelchan <204152633+arelchan@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:56:57 +0800 Subject: [PATCH 3/3] docs(ui-web): describe the two-page connections hub in the architecture docs CONTEXT.md now counts two module pages, defines the connections hub (state/hub.ts and components/HubHead.tsx), and lists schedules and memory as the only hosted settings sections. CONTRIBUTING.md sections 2.3 and 3.2 say the same, and name hub.onOpen as another slot a domain fills. Comments in TwoPane.tsx, install.ts, settings.ts, catalogue.ts and page.css that still counted channels among the settings sections are brought up to date. Co-authored-by: Claude (claude-opus-5-5) --- ui-web/CONTEXT.md | 24 ++++++++++++++----- ui-web/CONTRIBUTING.md | 18 ++++++++++---- ui-web/src/app/install.ts | 12 +++++----- ui-web/src/components/TwoPane.tsx | 25 ++++++++++---------- ui-web/src/features/connections/catalogue.ts | 2 +- ui-web/src/state/settings.ts | 2 +- ui-web/src/styles/page.css | 2 +- 7 files changed, 53 insertions(+), 32 deletions(-) diff --git a/ui-web/CONTEXT.md b/ui-web/CONTEXT.md index 5df755975..e8cae6a1f 100644 --- a/ui-web/CONTEXT.md +++ b/ui-web/CONTEXT.md @@ -165,16 +165,28 @@ children -- and every table that names a page derives from them: `chrome/Rail.tsx`'s nav strip, `features/rail/store.ts`'s marks and `src/test/regions.test.ts`'s goldens. Adding a page is adding a row. -One row today, because a place and a setting are different things: what a -reader goes TO is the agent hub, while schedules, channels and memory are set -up once and then left alone, so those three are **settings sections** instead. +Two rows today, because a place and a setting are different things: what a +reader goes TO is the **connections hub** -- the agents Raven hands work to and +the channels it can be reached from -- while schedules and memory are set up +once and then left alone, so those two are **settings sections** instead. _Avoid_: "page" for the whole document, or for a dialog -- the settings dialog and the model picker are overlays, not module pages. +**Connections hub**: +The two module pages the rail's one `agentsBtn` row opens -- `extAgentsPage` +(agents) and `connectionsPage` (channels) -- and `src/state/hub.ts`, which +makes them one place: the module the reader was last on, followed off the page +switch, so the rail row goes back to it. Both pages open on the same header, +`src/components/HubHead.tsx`, whose two tabs are the top of the page. They stay +two pages because each domain already owns its store, its drawer card and its +fetch; each registers what opening its module costs on `hub.onOpen` at its own +module evaluation, because `state/` may not import an island. +_Avoid_: "agent hub" for the whole -- that is the agents module, one of its two. + **Settings section**: One row of `features/settings/store.ts`'s `SECTIONS`: a pane of the settings -dialog. Nine of the twelve are that domain's own components. The other three -- -channels, schedules, memory -- are another domain's island, rooted in a box +dialog. Nine of the eleven are that domain's own components. The other two -- +schedules and memory -- are another domain's island, rooted in a box `src/App.tsx` renders beside `#spanels` and named by that store's `HOSTED`, because a React root inside the settings island's own tree would be unmounted the moment the reader picked another section. Which one is on screen is @@ -351,7 +363,7 @@ vocabulary a card is the drawer's content. **Escape order**: The ordered table in `src/state/escapeOrder.ts` (`ESCAPE_ORDER`, of -`EscapeLayer`): ten layers that can be on screen at once, and which one an +`EscapeLayer`): eleven layers that can be on screen at once, and which one an Escape takes back. A table rather than a stack, because each entry answers "am I open" when the key arrives -- the channel dialog opens over the entries page and closes first, while the shared diff --git a/ui-web/CONTRIBUTING.md b/ui-web/CONTRIBUTING.md index 896744426..8385ac2f8 100644 --- a/ui-web/CONTRIBUTING.md +++ b/ui-web/CONTRIBUTING.md @@ -128,8 +128,15 @@ body order, `chrome/Rail.tsx`'s nav strip, `features/rail/store.ts`'s marks and readers is in `state/` and a single aggregate would pull eighteen islands in there with it. +Two pages may light one rail button: `extAgentsPage` and `connectionsPage` both +name `agentsBtn`, because agents and channels are one place on the rail, the +connections hub. What makes them one place is `state/hub.ts` (the module the +reader was last on) and `components/HubHead.tsx` (the tabs both pages open +on), not a shared island: each stays its own domain's page, store and drawer +card. A third module belongs there only if it is something Raven connects to. + A domain that is a **section of the settings dialog** rather than a page -(channels, schedules, memory) claims no page and names a `host` instead: the +(schedules, memory) claims no page and names a `host` instead: the box `src/App.tsx` renders beside `#spanels`, which `features/settings/store.ts`'s `HOSTED` maps its section id to. Its island is its own root, because one inside the settings island's tree would be unmounted the moment the reader picked @@ -189,10 +196,11 @@ effects and the session pipeline all read and write from outside any component. `state/` does not import `features/` at runtime. Where the page's machinery has to ask a domain to do something, the state module declares a slot and the domain fills it: `src/app/install.ts` fills `state/page.ts`'s `onShow` slot and -`state/settings.ts`'s `onOpen`, and the three domains that are settings sections -fill that module's `onEnter` and `onLeave` at their own module evaluation -- -the page's wiring would otherwise import three island stores for three lines, -which is three island graphs it does not otherwise carry. Enforced by +`state/settings.ts`'s `onOpen`, the two domains that are settings sections +fill that module's `onEnter` and `onLeave`, and the two connections-hub +domains fill `state/hub.ts`'s `onOpen`, each at its own module evaluation -- +the page's wiring would otherwise import an island store for every one of those +lines, which is an island graph it does not otherwise carry. Enforced by `import-direction`. ## 4. Rendering diff --git a/ui-web/src/app/install.ts b/ui-web/src/app/install.ts index b20963d1e..e3f983a82 100644 --- a/ui-web/src/app/install.ts +++ b/ui-web/src/app/install.ts @@ -350,13 +350,13 @@ export function installActions(): void { Registered here because state/page.ts does not import features/ -- it declares the slot and when it runs (state/page.ts's `show`). */ page.onShow('markNav', markNew) - /* What raises the settings dialog for the three domains that are sections of - it (schedules, channels, memory). Registered here for the same reason the - slot above is: state/settings.ts declares it and does not import the island - that fills it. What arriving at one of those three sections costs, and what + /* What raises the settings dialog for the two domains that are sections of + it (schedules, memory). Registered here for the same reason the slot above + is: state/settings.ts declares it and does not import the island that + fills it. What arriving at one of those two sections costs, and what leaving it costs, each domain registers at its own module evaluation -- - three island stores in the page's wiring for three lines is three island - graphs it does not otherwise carry. */ + two island stores in the page's wiring for two lines is two island graphs + it does not otherwise carry. */ settingsDialog.onOpen(() => { void settingsStore.open() }) /* A different conversation is a different set of tasks: carrying them across would attribute one conversation's background work to another, and the diff --git a/ui-web/src/components/TwoPane.tsx b/ui-web/src/components/TwoPane.tsx index b814bdca3..4abfde6a9 100644 --- a/ui-web/src/components/TwoPane.tsx +++ b/ui-web/src/components/TwoPane.tsx @@ -1,21 +1,22 @@ /* The two-pane frame a settings section is drawn in: a list on the left, and * what the picked row is on the right. * - * Three domains draw one -- channels, schedules and memory -- and each used to - * be a module page of its own, where the list ran the width of the window and + * Two domains draw one -- schedules and memory -- and each used to be a + * module page of its own, where the list ran the width of the window and * the detail was a place you navigated TO: a modal over the list, a second * screen behind a back button, a drawer sliding in from the right. Inside the * settings dialog none of those fit: the dialog is already a layer, and a * second one over it is a layer over a layer. So the detail stands beside the * list instead, which is also what makes the two readable at once -- picking - * another channel is a click rather than a close and a reopen. + * another schedule is a click rather than a close and a reopen. (Channels were + * a third, and left the dialog for a page of their own, drawn as cards.) * - * Here rather than in any of the three because all three draw the same frame, + * Here rather than in either domain because both draw the same frame, * and a class name is shared state: these are `two-pane*`, this file owns them, * and the rules are in src/styles/page.css beside the dialog they sit in * (scripts/check-class-namespace.mjs reads the ownership off this file's name). - * What a row HOLDS is still the domain's -- a channel has a logo and a switch, - * a schedule has a next-run time, a memory has a date -- so the row below takes + * What a row HOLDS is still the domain's -- a schedule has a next-run time and + * a switch, a memory has a date -- so the row below takes * those as children and decides only the shape. */ import { t } from '../i18n/t' @@ -79,15 +80,15 @@ export function TwoPaneList({ children }: { children: ReactNode }): JSX.Element /* The list before its rows are in. * - * All three sections drawn in this frame answered the wait with nothing at - * all -- `!loaded && !rows.length ? null` in channels and schedules, a line of - * grey text in memory -- so opening one of them showed an empty column beside - * an empty pane, which is what "there are no channels" looks like. The rows + * Every section drawn in this frame used to answer the wait with nothing at + * all -- `!loaded && !rows.length ? null` in schedules, a line of grey text in + * memory -- so opening one of them showed an empty column beside an empty + * pane, which is what "there are no schedules" looks like. The rows * this becomes are two lines and a trailing control, so that is what waits * here, at the row's own height. * - * Here rather than in each of the three: the frame owns `two-pane*` (see this - * file's header), and a domain drawing its own bars would be a fourth name for + * Here rather than in each domain: the frame owns `two-pane*` (see this + * file's header), and a domain drawing its own bars would be a third name for * one shape. */ export function TwoPaneWait({ rows = 7 }: { rows?: number }): JSX.Element { diff --git a/ui-web/src/features/connections/catalogue.ts b/ui-web/src/features/connections/catalogue.ts index 5a79c7cf3..1de42a1bc 100644 --- a/ui-web/src/features/connections/catalogue.ts +++ b/ui-web/src/features/connections/catalogue.ts @@ -2,7 +2,7 @@ * * Production data, not a fixture: the live source merges `channels.status` * onto these same rows, so the ids, the message keys and the scan-login - * marker are what the channels section draws from in both modes. It lived in + * marker are what the channel page draws from in both modes. It lived in * the demo shell's fixture table only because that is where the offline page * first needed it. * diff --git a/ui-web/src/state/settings.ts b/ui-web/src/state/settings.ts index ba49fdf5c..2f41b519d 100644 --- a/ui-web/src/state/settings.ts +++ b/ui-web/src/state/settings.ts @@ -75,7 +75,7 @@ export function leaveSection(): void { for (const name of SLOTS) slots.get(name)?.() } -/* What arriving at a section asks of the domain that fills it. Three sections +/* What arriving at a section asks of the domain that fills it. Two sections are another domain's island (features/settings/store.ts's HOSTED), and each holds rows it has to fetch: nothing else on this page would ask for them, because the nav row that used to is a section pick now. Keyed by section id diff --git a/ui-web/src/styles/page.css b/ui-web/src/styles/page.css index 10043688a..8997293c0 100644 --- a/ui-web/src/styles/page.css +++ b/ui-web/src/styles/page.css @@ -3928,7 +3928,7 @@ details[open] summary { margin-bottom: 8px; } cards is not a form. Keyed on the dialog rather than on the settings island's own root, because - channels, schedules and memory are three other domains' islands, rooted in + schedules and memory are two other domains' islands, rooted in boxes of their own beside #spanels: a rule hung on .settings-panel never reached them, and their buttons stayed 28px next to 32px fields. The setup step bodies, which render in the onboarding wizard instead, take the same