fix(app-iced): M119 r1 —— 常驻「模拟」标识 + 确认条的键盘通路

复审 r1 的两条 p2,都是「说成真的、其实是假的」与「能走进死路」这个级别的问题。

## P2-1:界面必须自己说清「这是模拟」

原判:生产默认后端是**脚本化 rig**(`ULL_AGENT_SCRIPT` 的 6 种剧情),而「后端未接 / 模型还没
接上」两句诚实文案被我改成了中性话,理由写着「由端点标签说」——**那条端点标签在壳里根本不存在**。
于是默认配置下用户敲一句话得到一段像是模型编的回答。判据:没读过代码的用户,打开右栏、敲一句话,
必须能自己看出来这不是真模型(不接受 tooltip、不接受藏在设置里)。

修法(①恢复诚实文案 + ②常驻可见标记,并用):

| 位置 | 文案 / 形态 | 谁给的 |
|---|---|---|
| 标题右侧**常驻胶囊**(`SurfaceVariant` tonal)| `Key::AgentSimulated`(Simulated / 模拟)| `AgentHost::is_simulated()` → `Session.simulated` → 视图 |
| 标题下副标题 | 「模拟助手 · 还没接真实模型」 | `Key::AgentSubtitle`(原本是「未接」被改成中性的「会话」,现在写回来且更明确)|
| 空会话正文 | 「…当前是模拟助手,没有接真实模型。」 | `Key::AgentEmptyBody` |
| 输入框占位符 | 「给模拟助手发消息…」 | `Key::AgentPlaceholder` |

**标记不是硬编码在视图里**:`AgentHost.simulated` 是事实字段(今天 transport 只有 rig ⇒ 恒 true)。
接真 `AgentTransport` 那天把它传 `false`,四个位置的「模拟」措辞会一起消失——这是为什么用字段而不是
写死一句话。

## P2-2:确认条必须有键盘通路

原判:Allow/Deny 只有鼠标路径 ⇒ 键盘用户在写操作确认处**永久卡死**(`busy` 锁发送、
`Stop` 走主后端、清不掉 presenter 的 pending confirm)。

- 焦点环多两格:`Focus::ConfirmAllow` / `Focus::ConfirmDeny`,**只在有待确认时**存在
  (鼠标用户不会 Tab 到不存在的按钮);
- 待确认出现 ⇒ `sync_confirm_focus()` 把焦点直接落到「允许」并**打开键盘巡航**(阻塞态主动亮环),
  消失 ⇒ 焦点收回输入框、巡航关闭;
- `Enter` 与 `Space` 都映射到 `Activate`(`Space` 不抢输入框:`keyboard_mode` 那道闸在
  鼠标点进输入框时是关的);
- 焦点环不是 10% 状态层——在**实心主色**按钮上那层几乎看不见(出图实测过一版就是没有)。
  改成 2dp `Primary` 描边 + 3px 留白,两个按钮恒定加这层 padding,所以焦点变化不移动布局。

测试(`crates/app-iced/src/app/update/agent/tests.rs`):
`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` —— 全程只有键盘消息:
确认到达后断言 `focus == ConfirmAllow && keyboard_mode`,发一条 `KeyPressed(Enter)` 完成允许;
第二轮 `Tab`(走到 Deny)+ `KeyPressed(Space)` 完成拒绝;两轮各记一条审计、焦点都收回 Composer。
另有 `the_default_backend_reports_itself_as_simulated` 钉住 P2-1 的事实来源。

## 视图入参顺带收成结构体

`docked`/`page` 本来是 6 个形参(`ui, agent, rail, failure, catalog, width`),这一轮还要加
`simulated` ⇒ 7 个。按塔台登记的可维护性口径改成 `Session { agent, rail, failure, simulated }`:
两个宿主各 3 个形参,以后再加展示事实只改 `Session`。

## 出图(18 张,浅深各一)

`DevDocs/artifacts/m119/`:新增 `08-agent-simulated-*`(空会话 + 四处「模拟」措辞)与
`09-agent-confirm-focus-*`(确认条 + 焦点环落在「允许」);其余 7 组全部重出(文案与焦点环都变了)。

## 门禁(真实退出码)

| 命令 | 退出码 |
|---|---|
| `cargo fmt --all --check` | 0 |
| `cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-agent -p unlucky-i18n --all-targets -- -D warnings` | 0(0 warning)|
| `cargo test -p unlucky-app-iced --lib` | 0(601 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2277 passed / 0 failed / 11 ignored)|

`golden.rs` 400 行(+1 条 `AgentSimulated` 正好顶线);全树受检文件无 >400。
This commit is contained in:
AskaEth committed 2026-11-05 09:17:50 +08:00
1 parent b5e96c61ac
commit a22baebe70
13 files changed
+383 -122

No files matched your search

+46 -10
View File
@@ -192,6 +192,9 @@ fn every_action_has_a_readable_label() {
Key::AgentErrorTitle,
Key::AgentErrorUnavailable,
Key::AgentErrorNotImplemented,
// M119 r1 / P2-1:「这是模拟」的常驻标记也必须有词(用户不点就要能看到)
Key::AgentSimulated,
Key::AgentSubtitle,
] {
assert!(
!catalog.text(key).trim().is_empty(),
@@ -234,7 +237,7 @@ fn the_focus_ring_walks_the_controls_that_exist() {
let mut focus = Focus::Collapse;
let mut walked = vec![focus];
for _ in 0..4 {
focus = focus.step(false, RailState::Expanded, false);
focus = focus.step(false, RailState::Expanded, false, false);
walked.push(focus);
}
assert_eq!(
@@ -250,33 +253,53 @@ fn the_focus_ring_walks_the_controls_that_exist() {
// 反向:Collapse 往回走是 Composer
assert_eq!(
Focus::Collapse.step(true, RailState::Expanded, false),
Focus::Collapse.step(true, RailState::Expanded, false, false),
Focus::Composer
);
// 流式中多一档中断键(`busy` 由 presenter 的 rail 给)
assert_eq!(
Focus::Send.step(false, RailState::Expanded, true),
Focus::Send.step(false, RailState::Expanded, true, false),
Focus::Stop
);
// 独立页只有「收回 + 输入区」
assert_eq!(
Focus::Detach.step(false, RailState::Detached, false),
Focus::Detach.step(false, RailState::Detached, false, false),
Focus::Send
);
// 折叠档没有会话动作:环上只有「展开」一格
assert_eq!(
Focus::Collapse.step(false, RailState::Collapsed, false),
Focus::Collapse.step(false, RailState::Collapsed, false, false),
Focus::Collapse
);
// `Tab` 真的把键盘巡航打开(`Enter` 的闸门)
let mut agent = AgentUi::new();
assert!(!agent.keyboard_mode);
agent.step_focus(false, false);
agent.step_focus(false, false, false);
assert!(agent.keyboard_mode);
// M119 r1 / P2-2:有待确认写操作时,环上多两格(允许 / 拒绝),且**排在发送之前**——
// 阻塞态该先被看见。这也是键盘用户唯一能推进这一轮的路径。
assert_eq!(
Focus::Detach.step(false, RailState::Expanded, true, true),
Focus::ConfirmAllow
);
assert_eq!(
Focus::ConfirmAllow.step(false, RailState::Expanded, true, true),
Focus::ConfirmDeny
);
assert_eq!(
Focus::ConfirmDeny.step(false, RailState::Expanded, true, true),
Focus::Send
);
// 没有待确认时这两格不在环上(鼠标用户不该 Tab 到不存在的按钮)
assert_eq!(
Focus::Detach.step(false, RailState::Expanded, false, false),
Focus::Send
);
}
/// 视图契约:`spec()` 把 presenter 的三件事**原样借**给视图(零拷贝、不复制)。
@@ -288,12 +311,19 @@ fn the_spec_borrows_the_presenter_rail_verbatim() {
let mut agent = AgentUi::new();
agent.expand();
let rail = conversation_rail();
let spec = super::spec(&agent, &rail, None, Host::Rail);
let session = Session {
agent: &agent,
rail: &rail,
failure: None,
simulated: true,
};
let spec = super::spec(session, Host::Rail);
assert_eq!(spec.messages.len(), rail.messages.len());
assert_eq!(spec.messages[0].text, rail.messages[0].text, "同一条消息");
assert_eq!(spec.unread, rail.unread);
assert_eq!(spec.busy, rail.busy);
assert!(spec.failure.is_none());
assert!(spec.simulated, "模拟事实要原样传给视图(标记不是硬编码)");
}
/// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。
@@ -330,9 +360,14 @@ fn every_state_builds_in_both_hosts() {
let mut agent = AgentUi::new();
agent.rail = state;
for shown in [None, Some(failure)] {
let _: iced::Element<'_, Message> =
page(&ui, &agent, rail, shown, catalog, window);
let _ = docked(&ui, &agent, rail, shown, catalog, window);
let session = Session {
agent: &agent,
rail,
failure: shown,
simulated: true,
};
let _: iced::Element<'_, Message> = page(&ui, session, catalog, window);
let _ = docked(&ui, session, catalog, window);
let spec = Conversation {
messages: &rail.messages,
draft: &agent.draft,
@@ -341,6 +376,7 @@ fn every_state_builds_in_both_hosts() {
blank: agent.draft_is_blank(),
confirm: rail.confirm.as_ref(),
failure: shown,
simulated: true,
rail: agent.rail,
host: Host::Rail,
focus: agent.keyboard_mode.then_some(agent.focus),