fix(app-iced): M119 r2 —— 四处「模拟」文案全由 simulated 驱动;确认条不再替用户 arm 键盘

复审 r2 的两条 p2 都是「上一轮各差一步」:字段/焦点都真的做了,但**另有三处没接上**、
**键盘闸是代码自己打开的**。

## P2-1:四处文案只认一个开关

r1 只有标题胶囊由 `simulated` 驱动;副标题 / 输入框占位符 / 空态正文是**无条件取词**,
而它们的译文里写着「模拟 / 没接真实模型」——接上真 transport 之后这三句会**反向**留着
(`simulated == false` 时界面还在声明「没接真实模型」)。而且没有一条测试钉得住那个声明。

现在:

- 新增 `pages/agent::Disclaimer { simulated, subtitle, placeholder, empty_body }`,
  `Disclaimer::of(catalog, simulated)` 一次算好;`Conversation.simulated: bool` 换成
  `Conversation.note: Disclaimer`,header / composer / transcript 三处各自从它取词——
  **视图里没有第二种取词路径**;
- i18n 配成对:模拟态用既有的 `AgentSubtitle` / `AgentPlaceholder` / `AgentEmptyBody`,
  真实态新增 `AgentSubtitleReal` / `AgentPlaceholderReal` / `AgentEmptyBodyReal`(中性话)。
  (没有把三句改成中性话:那句告知本身有价值,接真模型后它才该消失。)
- 证据:`crates/app-iced/src/pages/agent/tests/disclaimer.rs`
  —— `all_four_disclaimers_appear_when_the_backend_is_simulated`(四处都提模拟)、
  `no_disclaimer_survives_when_a_real_backend_is_wired`(四处都不提,且不许用空串糊)、
  `the_two_states_use_different_keys`(两态不能共用同一个 key)。

**golden.rs 的 400 行边界**:+3 条会破线。按「按功能拆、不压注释」处理——把这张表的
**说明文档**(35 行)原样搬到 `catalog.rs` 里 `mod golden;` 的文档上(一个字的说明都没删),
`golden.rs` 只留表数据 + 3 行指针。现在 `golden.rs` 374 行、`catalog.rs` 328 行。

## P2-2:键盘闸不许由代码替用户打开

r1 在 `sync_confirm_focus`(`on_agent` 的无条件尾巴,`Events` 又由心跳驱动)里顺手
`keyboard_mode = true`——于是**只要冒出待确认,全局键盘闸就开了**;用户发完那句话接着打下一句,
第一个空格就成了「批准写操作」。复审用临时用例复现过(`rail.confirm` 变 `None`)。

现在:

- `sync_confirm_focus` **只挪焦点**到「允许」,不再碰 `keyboard_mode`(字段文档里写清了为什么);
- 键盘用户的表态是 `Tab`:`AgentUi::step_focus` 加了一条 **arming 规则**——待确认、焦点已在
  确认条上、但键盘闸还关着时,这一次 `Tab` **只点亮焦点环、不移动焦点**(否则第一次 Tab 会把
  「允许」挪到「拒绝」,用户按 Enter 就是反着裁决);之后再 Tab 才逐格走;
- 反向回归:`a_stray_space_or_enter_never_approves_a_pending_write`(打字 → 空格 / 回车都不得裁决,
  审计也不许留);`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` 改成
  「确认到达时未 armed → Tab 点亮 → Enter 允许;第二轮 Tab·Tab 到拒绝 → Space」。

## 出图

`DevDocs/artifacts/m119/` 18 张全部重出(新增取证夹具 `ULL_AGENT_FIXTURE=armed`:
跑一轮真提交后再按一次 Tab,专供「焦点环可见」那张图):

- `08-agent-simulated-{light,dark}`:`simulated = true` 下四处「模拟」同时可见;
- `09-agent-confirm-focus-{light,dark}`:**按过 Tab 之后**的确认条(焦点环在「允许」上);
- `05-agent-confirm-{light,dark}`:确认条刚出现、**还没 arm**(无焦点环)——与 09 成对,
  正好把 r2 的行为差异拍下来。

## 门禁(真实退出码,最终树)

| 命令 | 退出码 |
|---|---|
| `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(605 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2281 passed / 0 failed / 11 ignored)|

全树受检文件无 >400(`golden.rs` 374、`catalog.rs` 328、`pages/agent/mod.rs` 371)。
This commit is contained in:
AskaEth committed 2026-11-05 10:43:22 +08:00
1 parent a22baebe70
commit 0e48ea15b8
14 files changed
+332 -81

No files matched your search

@@ -56,7 +56,7 @@ use crate::app::components::interaction::Interaction;
use crate::theme::Ui;
use super::state::{Focus, RailState};
use super::{Failure, Message};
use super::{Disclaimer, Failure, Message};
mod banners;
mod composer;
@@ -90,11 +90,12 @@ pub struct Conversation<'a> {
pub confirm: Option<&'a ConfirmRequest>,
/// 失败横幅(`Some` = 画具名错误;见 [`Failure`])。
pub failure: Option<Failure<'a>>,
/// 这套后端是不是模拟的(`true` ⇒ 标题旁常驻「模拟」标记 + 副标题说清楚)。
/// 「这是模拟助手」的**四处文案**(副标题 / 占位符 / 空态正文 / 标题胶囊),
/// 全部由 [`Disclaimer::of`] 一次算好——视图里没有第二种取词路径。
///
/// M119 r1 / 复审 P2-1:生产默认后端是脚本化 rig,而界面上曾**没有任何模拟标识**——
/// 用户敲一句话得到一段像是模型编的回答。这个字段就是那个事实的渲染入口(不是硬编码)。
pub simulated: bool,
/// M119 r1 / r2 复审 P2-1:生产默认后端是脚本化 rig,界面上曾**没有任何模拟标识**;
/// 上一轮又只有标题胶囊认这个开关,另外三处会反方向地留在「没接真实模型」上。
pub note: Disclaimer<'a>,
/// 三档状态(决定头部画哪组动作)。
pub rail: RailState,
/// 宿主。
@@ -119,7 +120,7 @@ pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element
}
body = body.push(if spec.messages.is_empty() {
transcript::empty(ui, catalog)
transcript::empty(ui, catalog, spec.note.empty_body)
} else {
transcript::list(ui, spec, catalog)
});
@@ -148,7 +149,7 @@ fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
// 常驻标记:与标题同一行,不点开、不 hover、不藏在设置里(P2-1 的判据)
if spec.simulated {
if spec.note.simulated {
title = title.push(simulated_chip(ui, catalog));
}
title = title.push(unread_badge(ui, spec.unread, catalog));
@@ -159,7 +160,7 @@ fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AgentSubtitle),
spec.note.subtitle,
ui.color(Role::OnSurfaceVariant),
Length::Fill,
),
@@ -37,7 +37,7 @@ pub(super) fn composer<'a>(
let style = ui.style(TypeRole::BodyMedium);
let field = editor(spec.draft)
.placeholder(catalog.text(Key::AgentPlaceholder))
.placeholder(spec.note.placeholder)
.on_action(Message::Edit)
.key_binding(binding_for)
.size(style.size_px)
@@ -28,14 +28,12 @@ use super::super::Message;
use super::{text_role, text_role_owned, Conversation};
/// 空会话:引导空态(零条消息时替代气泡列表)。
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
///
/// `body` 由调用方从 [`super::super::Disclaimer`] 取(模拟态/真实态两句话),这里不再自己取词。
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog, body: &'a str) -> Element<'a, Message> {
container(empty_state(
ui,
EmptyState::new(
IconKind::Ai,
catalog.text(Key::AgentEmptyTitle),
catalog.text(Key::AgentEmptyBody),
),
EmptyState::new(IconKind::Ai, catalog.text(Key::AgentEmptyTitle), body),
))
.width(Length::Fill)
.height(Length::Fill)
+6
View File
@@ -41,6 +41,11 @@ pub enum Fixture {
Unread,
/// 键盘巡航一次的样子(焦点环 = 10% 状态层)。
Focused,
/// 跑一轮真提交,然后**按一次 `Tab`**(M119 r2 出图用)。
///
/// 复现的是键盘用户走到确认条的路径:待确认出现 ⇒ 焦点已落在「允许」但**键盘闸没开**
/// (图 `05`,看不到环);按一次 `Tab` 才算表态(图 `09`,环亮出来且焦点不动)。
Armed,
}
/// 夹具里的那句用户消息(**演示数据**,不是界面文案:它跟实例名一样属于样本内容,
@@ -86,6 +91,7 @@ pub fn from_env() -> Option<(AgentUi, Fixture)> {
Some("turn") => Fixture::Turn,
Some("unread") => Fixture::Unread,
Some("focused") => Fixture::Focused,
Some("armed") => Fixture::Armed,
Some("empty") | None => Fixture::None,
Some(other) => {
eprintln!("[agent] {FIXTURE_ENV}={other:?} 不是夹具之一,按 empty 处理");
+69 -17
View File
@@ -126,6 +126,53 @@ pub struct Failure<'a> {
pub body: &'a str,
}
/// 「这是模拟助手」的**四处文案**(M119 r2 / 复审 P2-1)。
///
/// ## 为什么是一个结构体而不是四个 `if`
///
/// 上一轮只有标题胶囊由 `simulated` 驱动,另外三处(副标题 / 输入框占位符 / 空态正文)是
/// **无条件取词**,而它们的译文里写着「模拟 / 没接真实模型」——接上真 transport 之后,这三句会
/// 反方向地留着(`simulated == false` 时界面上还在说「没接真实模型」)。复审把这条列为 P2。
///
/// 现在四处**只认这一个开关**:`Disclaimer::of(catalog, simulated)` 一次算好,视图没有第二种
/// 取词路径。`pages/agent/tests/disclaimer.rs` 钉住「四处一起出现 / 一起消失」。
#[derive(Debug, Clone, Copy)]
pub struct Disclaimer<'a> {
/// 这套后端是不是模拟的(标题旁的胶囊只在 `true` 时画)。
pub simulated: bool,
/// 标题下的副标题。
pub subtitle: &'a str,
/// 输入框占位符。
pub placeholder: &'a str,
/// 空会话正文。
pub empty_body: &'a str,
}
impl<'a> Disclaimer<'a> {
/// 按 `simulated` 选模拟态或真实态那一套文案。
#[must_use]
pub fn of(catalog: Catalog, simulated: bool) -> Self {
Self {
simulated,
subtitle: if simulated {
catalog.text(Key::AgentSubtitle)
} else {
catalog.text(Key::AgentSubtitleReal)
},
placeholder: if simulated {
catalog.text(Key::AgentPlaceholder)
} else {
catalog.text(Key::AgentPlaceholderReal)
},
empty_body: if simulated {
catalog.text(Key::AgentEmptyBody)
} else {
catalog.text(Key::AgentEmptyBodyReal)
},
}
}
}
/// 两个宿主共用的**会话入参**(`Session` = 从 presenter 借来的那一份 + 两个展示事实)。
///
/// 为什么是一个结构体而不是继续加形参:`docked`/`page` 已经收到 6 个形参,这一轮还要加
@@ -139,7 +186,8 @@ pub struct Session<'a> {
pub rail: &'a AgentRail,
/// 失败横幅的展示输入。
pub failure: Option<Failure<'a>>,
/// 这套后端是不是模拟的(M119 r1 / 复审 P2-1)。`true` ⇒ 头部常驻「模拟」标记。
/// 这套后端是不是模拟的(M119 r1 / 复审 P2-1)。`true` ⇒ 头部常驻「模拟」标记,
/// 且三处文案走模拟态(见 [`Disclaimer`]:**四处一起变**,只认这一个开关)。
pub simulated: bool,
}
@@ -172,7 +220,7 @@ pub fn page<'a>(
) -> Element<'a, Message> {
let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width);
let body = conversation::view(ui, spec(session, Host::Page), catalog);
let body = conversation::view(ui, spec(session, catalog, Host::Page), catalog);
container(
container(body)
@@ -230,19 +278,23 @@ fn panel<'a>(
) -> Element<'a, Message> {
let width = session.agent.rail_width(window_width);
let body = container(conversation::view(ui, spec(session, Host::Rail), catalog))
.width(Length::Fixed(width))
.height(Length::Fill)
.padding(Padding {
top: ui.space(Space::X6),
right: ui.space(Space::X4),
bottom: ui.space(Space::X4),
left: ui.space(Space::X4),
})
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
});
let body = container(conversation::view(
ui,
spec(session, catalog, Host::Rail),
catalog,
))
.width(Length::Fixed(width))
.height(Length::Fill)
.padding(Padding {
top: ui.space(Space::X6),
right: ui.space(Space::X4),
bottom: ui.space(Space::X4),
left: ui.space(Space::X4),
})
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
});
row![splitter(ui, width, catalog), body]
.width(Length::Shrink)
@@ -300,7 +352,7 @@ fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message
}
/// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。
fn spec<'a>(session: Session<'a>, host: Host) -> Conversation<'a> {
fn spec<'a>(session: Session<'a>, catalog: Catalog, host: Host) -> Conversation<'a> {
Conversation {
// 会话内容、未读、busy、待确认:**全部**来自 presenter(M119)
messages: &session.rail.messages,
@@ -308,7 +360,7 @@ fn spec<'a>(session: Session<'a>, host: Host) -> Conversation<'a> {
unread: session.rail.unread,
confirm: session.rail.confirm.as_ref(),
failure: session.failure,
simulated: session.simulated,
note: Disclaimer::of(catalog, session.simulated),
draft: &session.agent.draft,
blank: session.agent.draft_is_blank(),
rail: session.agent.rail,
+12 -1
View File
@@ -300,9 +300,20 @@ impl AgentUi {
}
/// Tab 走一格(走一次就把键盘巡航打开)。`busy` / `confirm_pending` 都由 presenter 的 rail 给。
///
/// **待确认的那一次 Tab 只点亮焦点环、不移动焦点**:待确认出现时 app 层已经把焦点放到
/// 「允许」上(阻塞态,见 `App::sync_confirm_focus`),但**不会**替用户把键盘巡航打开
/// (M119 r2 / 复审 P2-2:自动 arm 会让用户随手敲的空格变成「批准」)。所以第一次 `Tab`
/// 是「我要用键盘」的显式表态,此时焦点原地不动——否则第一次 Tab 会把「允许」挪到「拒绝」,
/// 用户按 `Enter` 就是反着裁决。
pub fn step_focus(&mut self, shift: bool, busy: bool, confirm_pending: bool) {
self.focus = self.focus.step(shift, self.rail, busy, confirm_pending);
let arming = !self.keyboard_mode
&& confirm_pending
&& matches!(self.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
self.keyboard_mode = true;
if !arming {
self.focus = self.focus.step(shift, self.rail, busy, confirm_pending);
}
}
}
+12 -3
View File
@@ -14,6 +14,7 @@ use unlucky_presenter::dto::{ChatMessage, ChatRole, ConfirmRequest, Sensitivity}
use crate::theme::{Families, Ui};
mod disclaimer;
mod drag;
use super::conversation::{Conversation, Host};
@@ -317,13 +318,21 @@ fn the_spec_borrows_the_presenter_rail_verbatim() {
failure: None,
simulated: true,
};
let spec = super::spec(session, Host::Rail);
let spec = super::spec(session, Catalog::of(Lang::En), 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, "模拟事实要原样传给视图(标记不是硬编码)");
assert!(
spec.note.simulated,
"模拟事实要原样传给视图(标记不是硬编码)"
);
assert!(
spec.note.subtitle.to_lowercase().contains("simulated"),
"副标题也归这个开关管:{}",
spec.note.subtitle
);
}
/// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。
@@ -376,7 +385,7 @@ fn every_state_builds_in_both_hosts() {
blank: agent.draft_is_blank(),
confirm: rail.confirm.as_ref(),
failure: shown,
simulated: true,
note: Disclaimer::of(catalog, true),
rail: agent.rail,
host: Host::Rail,
focus: agent.keyboard_mode.then_some(agent.focus),
@@ -0,0 +1,96 @@
//! 「这是模拟助手」的四处文案必须**一起**变(M119 r2 / 复审 P2-1 的验收标准)。
//!
//! ## 为什么这个文件存在
//!
//! 复审 r2 的 P2-1 抓得很准:`simulated` 是真字段,但 r1 只有**标题胶囊**一个消费者,另外三处
//! (副标题 / 输入框占位符 / 空态正文)是**无条件取词**,而它们的译文里写着「模拟 / 没接真实模型」。
//! 于是「接上真 transport 之后四处一起消失」只是一句声明——没有测试钉住它。
//!
//! 现在四处都从 [`Disclaimer`] 取词(`Disclaimer::of(catalog, simulated)`),本文件就是那句声明的
//! 证据:`simulated == true` 四处都提模拟;`simulated == false` 四处**都不提**。
//!
//! 抓得住:谁把某一处改回无条件取词(那就会出现「字段说真、界面说模拟」的反向假话)。
//! 抓不住:文案本身翻得好不好读(那要人看),也不管视图有没有真的用这几个字段
//! (那是 `tests.rs::the_spec_borrows_the_presenter_rail_verbatim` 与出图的活)。
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::Disclaimer;
/// 这句话里有没有「模拟 / 没接真实模型」的意思(中英各一套写法)。
fn mentions_simulated(text: &str) -> bool {
let lower = text.to_lowercase();
lower.contains("simulated") || lower.contains("no real model") || text.contains("模拟")
}
/// **四处一起出现**(`simulated == true`):副标题 / 占位符 / 空态正文 / 标题胶囊。
#[test]
fn all_four_disclaimers_appear_when_the_backend_is_simulated() {
for lang in [Lang::En, Lang::ZhCn] {
let catalog = Catalog::of(lang);
let note = Disclaimer::of(catalog, true);
assert!(note.simulated, "{}:开关要传下去", lang.tag());
for (place, text) in [
("副标题", note.subtitle),
("输入框占位符", note.placeholder),
("空态正文", note.empty_body),
// 第四处是标题胶囊:只在 `simulated` 时渲染,文案来自这条 key
("标题胶囊", catalog.text(Key::AgentSimulated)),
] {
assert!(
mentions_simulated(text),
"{} 的{place}没有说清「这是模拟」:{text:?}",
lang.tag()
);
}
}
}
/// **四处一起消失**(`simulated == false`):三处文案都不许再提模拟,胶囊也不该被画。
///
/// 这条就是复审要的那一句验收:接上真 transport 那天,界面不会再反向地声明「没接真实模型」。
#[test]
fn no_disclaimer_survives_when_a_real_backend_is_wired() {
for lang in [Lang::En, Lang::ZhCn] {
let catalog = Catalog::of(lang);
let note = Disclaimer::of(catalog, false);
assert!(!note.simulated, "{}:开关要传下去", lang.tag());
for (place, text) in [
("副标题", note.subtitle),
("输入框占位符", note.placeholder),
("空态正文", note.empty_body),
] {
assert!(
!mentions_simulated(text),
"{} 的{place}在真实态还在说模拟:{text:?}",
lang.tag()
);
}
// 真实态的三句也得是**有内容的话**,不能靠空串糊过去
for (place, text) in [
("副标题", note.subtitle),
("输入框占位符", note.placeholder),
("空态正文", note.empty_body),
] {
assert!(!text.trim().is_empty(), "{} 的{place}是空的", lang.tag());
}
}
}
/// 两态必须是**两套不同的文案**(同一个 key 两边都用就退化成「永远说模拟」或「永远不说」)。
#[test]
fn the_two_states_use_different_keys() {
let catalog = Catalog::of(Lang::En);
assert_ne!(
catalog.text(Key::AgentSubtitle),
catalog.text(Key::AgentSubtitleReal)
);
assert_ne!(
catalog.text(Key::AgentPlaceholder),
catalog.text(Key::AgentPlaceholderReal)
);
assert_ne!(
catalog.text(Key::AgentEmptyBody),
catalog.text(Key::AgentEmptyBodyReal)
);
}