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:
1 parent
a22baebe70
commit
0e48ea15b8
14 files changed
+332
-81
No files matched your search
@@ -187,12 +187,16 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
/// 待确认请求出现 ⇒ 焦点直接落在「允许」并打开键盘巡航;消失 ⇒ 收回输入框。
|
||||
/// 待确认请求出现 ⇒ 焦点直接落在「允许」;消失 ⇒ 收回输入框。
|
||||
///
|
||||
/// 为什么要**打开** `keyboard_mode`(而不是只挪焦点):待确认时 `busy` 锁着发送键,
|
||||
/// `Stop` 又走主后端、清不掉 presenter 的 pending——键盘用户若够不着这两个按钮,这一轮对话
|
||||
/// 就再也推不动(复审 P2-2 的原话)。所以这是一个**阻塞态**:焦点环主动亮出来,
|
||||
/// 按 `Enter` 或 `Space` 就能裁决。
|
||||
/// **只挪焦点,不替用户打开键盘巡航**(M119 r2 / 复审 P2-2):r1 在这里顺手把
|
||||
/// `keyboard_mode` 置真,而 `sync_confirm_focus` 是 `on_agent` 的无条件尾巴、`Events` 又由心跳
|
||||
/// 驱动——于是只要冒出待确认,全局键盘闸就被打开;用户发完那句话接着打下一句,第一个空格
|
||||
/// 就成了「批准写操作」。现在那道闸只由真实的键盘表态打开(`Tab`,见 `AgentUi::step_focus`;
|
||||
/// 或者 `on_agent_key` 把按键送进来时用户本来就在键盘巡航)。
|
||||
///
|
||||
/// 键盘用户怎么走到确认条:待确认时焦点已经在这两个按钮上,**按一次 `Tab` 点亮焦点环即是
|
||||
/// 表态**(`step_focus` 里那条 arming 规则:这一次不移动焦点),之后 `Enter`/`Space` 就能裁决。
|
||||
///
|
||||
/// 唯一写入口:`on_agent` 末尾(任何会改动 rail 的分支之后都要经过它)。
|
||||
fn sync_confirm_focus(&mut self) {
|
||||
@@ -200,8 +204,7 @@ impl App {
|
||||
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
|
||||
if pending && !on_confirm {
|
||||
self.agent.focus = Focus::ConfirmAllow;
|
||||
self.agent.keyboard_mode = true;
|
||||
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Enter/Space 裁决)");
|
||||
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
|
||||
} else if !pending && on_confirm {
|
||||
self.agent.focus = Focus::Composer;
|
||||
self.agent.keyboard_mode = false;
|
||||
@@ -258,6 +261,15 @@ impl App {
|
||||
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
|
||||
self.drain_agent();
|
||||
}
|
||||
Fixture::Armed => {
|
||||
// 先跑一轮(写操作脚本会挂一个待确认),再按一次 `Tab`:
|
||||
// 待确认时这一下只 arm、不移动焦点(见 `AgentUi::step_focus` 的 arming 规则)
|
||||
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
|
||||
self.drain_agent();
|
||||
let rail = self.agent_host.rail();
|
||||
let (busy, pending) = (rail.busy, rail.confirm.is_some());
|
||||
self.agent.step_focus(false, busy, pending);
|
||||
}
|
||||
Fixture::Focused => {
|
||||
let rail = self.agent_host.rail();
|
||||
let (busy, pending) = (rail.busy, rail.confirm.is_some());
|
||||
|
||||
@@ -275,15 +275,27 @@ fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() {
|
||||
app.agent_host.rail().confirm.is_some(),
|
||||
"先要有一个待确认请求"
|
||||
);
|
||||
// ① 焦点主动落在「允许」,并且键盘巡航已经打开(否则 Enter 那道闸过不去)
|
||||
// ① 焦点主动落在「允许」(阻塞态),但**键盘闸不替用户打开**(复审 r2 / P2-2)
|
||||
assert_eq!(
|
||||
app.agent.focus,
|
||||
Focus::ConfirmAllow,
|
||||
"焦点要落在可操作元素上"
|
||||
);
|
||||
assert!(app.agent.keyboard_mode, "阻塞态要主动亮出焦点环");
|
||||
assert!(
|
||||
!app.agent.keyboard_mode,
|
||||
"塔台 r2:不许自动 arm——自动 arm 会让用户随手敲的空格变成「批准」"
|
||||
);
|
||||
|
||||
// ② Enter = 允许
|
||||
// ② 第一次 Tab = 「我要用键盘」的表态:只点亮焦点环,**不移动**焦点
|
||||
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
|
||||
assert!(app.agent.keyboard_mode, "Tab 之后才算 armed");
|
||||
assert_eq!(
|
||||
app.agent.focus,
|
||||
Focus::ConfirmAllow,
|
||||
"arming 的那一次不移动焦点(否则 Enter 会反着裁决)"
|
||||
);
|
||||
|
||||
// ③ Enter = 允许
|
||||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
|
||||
assert!(
|
||||
app.agent_host.rail().confirm.is_none(),
|
||||
@@ -293,12 +305,48 @@ fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() {
|
||||
assert_eq!(app.agent.focus, Focus::Composer, "裁决完焦点收回输入框");
|
||||
assert!(!app.agent.keyboard_mode, "阻塞态结束就不再亮焦点环");
|
||||
|
||||
// ③ 第二轮走「Tab 到拒绝 + Space 触发」:键盘用户不必背 Enter 是哪一个
|
||||
// ④ 第二轮走「Tab 点环 → Tab 走到拒绝 → Space 触发」:键盘用户不必背 Enter 是哪一个
|
||||
submit(&mut app, "那把并发改回来");
|
||||
assert_eq!(app.agent.focus, Focus::ConfirmAllow);
|
||||
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
|
||||
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
|
||||
assert_eq!(app.agent.focus, Focus::ConfirmDeny, "Tab 要能走到「拒绝」");
|
||||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
|
||||
assert!(app.agent_host.rail().confirm.is_none(), "Space 也要能裁决");
|
||||
assert_eq!(app.agent_host.audit_len(), 2);
|
||||
}
|
||||
|
||||
/// **复审 r2 / P2-2 的反向回归**:没 armed 的时候,空格与回车都不许裁决写操作。
|
||||
///
|
||||
/// 复审用临时用例复现的路径:WriteOp 提交后(r1 无条件 arm),用户接着打下一句,第一个空格就成了
|
||||
/// 「允许」。真实场景里编辑器仍持 iced 焦点,所以这条不是理论问题。
|
||||
///
|
||||
/// 抓得住:谁把 `sync_confirm_focus` 里的 `keyboard_mode = true` 加回来;谁把 `Space`/`Enter` 的
|
||||
/// `Activate` 绕到 `keyboard_mode` 闸之外。
|
||||
/// 抓不住:iced 组件树里空格到底有没有进输入框(那要真窗口,出图另算)——这里只断言「没裁决」。
|
||||
#[test]
|
||||
fn a_stray_space_or_enter_never_approves_a_pending_write() {
|
||||
let mut app = app_with(Script::WriteOp);
|
||||
submit(&mut app, "把下载并发改小");
|
||||
assert!(app.agent_host.rail().confirm.is_some());
|
||||
assert!(!app.agent.keyboard_mode, "待确认不该自动 arm");
|
||||
|
||||
// 用户接着打下一句:先敲一个字符(编辑器自己消化),再敲空格
|
||||
let _ = app.update(Message::Agent(AgentMessage::Edit(
|
||||
text_editor::Action::Edit(text_editor::Edit::Insert('a')),
|
||||
)));
|
||||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
|
||||
assert!(
|
||||
app.agent_host.rail().confirm.is_some(),
|
||||
"未 armed 时空格不许批准写操作"
|
||||
);
|
||||
assert_eq!(app.agent_host.audit_len(), 0, "也不许留下审计");
|
||||
|
||||
// 回车同理:未 armed 时它归编辑器(发送键),不归确认条
|
||||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
|
||||
assert!(
|
||||
app.agent_host.rail().confirm.is_some(),
|
||||
"未 armed 时回车不许批准写操作"
|
||||
);
|
||||
assert_eq!(app.agent_host.audit_len(), 0);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 处理");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -88,8 +88,10 @@ pub(crate) struct AgentHost {
|
||||
/// 这套后端是不是**模拟**的(M119 r1 / 复审 P2-1)。
|
||||
///
|
||||
/// 今天恒 `true`:transport 只有 `RigTransport` 一种(脚本化,没有真模型)。接真 `AgentTransport`
|
||||
/// 的那一天,这里传 `false`,界面上的「模拟」标记会自动消失——**标记不是硬编码在视图里的**,
|
||||
/// 它是这个事实字段的渲染。
|
||||
/// 的那一天,这里传 `false`——**四处「模拟」措辞会一起消失**(标题胶囊 / 副标题 / 输入框占位符 /
|
||||
/// 空态正文,全部出自 `pages::agent::Disclaimer::of(catalog, simulated)`)。
|
||||
/// 标记不是硬编码在视图里的,它是这个事实字段的渲染;「四处一起变」由
|
||||
/// `pages/agent/tests/disclaimer.rs` 钉住(r1 时只有一处认它,那句声明是空的)。
|
||||
simulated: bool,
|
||||
queue: EventQueue,
|
||||
failure: FailureSlot,
|
||||
|
||||
@@ -126,6 +126,39 @@ mod logs;
|
||||
mod nav;
|
||||
mod settings;
|
||||
|
||||
/// 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。
|
||||
///
|
||||
/// ## 它是什么
|
||||
///
|
||||
/// M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」
|
||||
/// 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来,
|
||||
/// 再原封不动抄进来(生成方式见 `tests.rs` 里 `split_keeps_every_translation` 的注释)。
|
||||
/// M108 在它上面改了 3 条(`NavDownload` / `DownloadTitle` / `DownloadSubtitle`)、追加了 1 条
|
||||
/// (`NavDownloadExpanded`);M105/M106/M107 各自追加的条目也都在,整表现在是 138 条。
|
||||
///
|
||||
/// ## 为什么不是「数条数」
|
||||
///
|
||||
/// 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。
|
||||
/// 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红:
|
||||
///
|
||||
/// | 坏法 | 红在哪 |
|
||||
/// |---|---|
|
||||
/// | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 |
|
||||
/// | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 |
|
||||
/// | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) |
|
||||
/// | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 |
|
||||
///
|
||||
/// **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。
|
||||
///
|
||||
/// ## M108 更新了三条、新增了一条(版本管理改名)
|
||||
///
|
||||
/// 用户令(2026-11-04)把这页的显示名从「下载 / Downloads」改成「版本管理 / Version management」:
|
||||
/// [`Key::NavDownload`] / [`Key::DownloadTitle`] / [`Key::DownloadSubtitle`]
|
||||
/// 三条译文按令改掉,并**新增** [`Key::NavDownloadExpanded`](240 展开态的全标签,
|
||||
/// 88 轨放不下 18 字符的英文,见 `catalog/nav.rs` 的「两条 key」一节)。
|
||||
///
|
||||
/// 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的,
|
||||
/// 其余一字未动」,不再是「整表一字未动」。
|
||||
#[cfg(test)]
|
||||
mod golden;
|
||||
|
||||
|
||||
@@ -50,6 +50,15 @@ macro_rules! agent_entries {
|
||||
AgentConfirmDeny => ("Deny", "拒绝"),
|
||||
// 头部常驻小标记(P2-1):与副标题/占位符互为冗余,三个位置都能看到「这是模拟」
|
||||
AgentSimulated => ("Simulated", "模拟"),
|
||||
// ── M119 r2:三处文案的**真实态**配对(模拟态用上面那三条已有的 key)
|
||||
//
|
||||
// 复审 r2 的 P2-1:上一轮只有标题胶囊由 `simulated` 驱动,副标题/占位符/空态正文是
|
||||
// 无条件取词,而那三条译文里写着「模拟 / 没接真实模型」——接上真 transport 之后
|
||||
// 这三句会**反方向**地留着。现在配成对:`simulated == true` 走模拟态,
|
||||
// `false` 走这三条中性话(`pages/agent/tests/disclaimer.rs` 钉住四处一起变)。
|
||||
AgentSubtitleReal => ("Conversation", "会话"),
|
||||
AgentPlaceholderReal => ("Message the assistant…", "给助手发消息…"),
|
||||
AgentEmptyBodyReal => ("Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
//! 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。
|
||||
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。
|
||||
//!
|
||||
//! ## 它是什么
|
||||
//!
|
||||
//! M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」
|
||||
//! 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来,
|
||||
//! 再原封不动抄进来(生成方式见 `catalog/tests.rs` 里 `split_keeps_every_translation` 的注释)。
|
||||
//! M108 在它上面改了 3 条(`NavDownload` / `DownloadTitle` / `DownloadSubtitle`)、追加了 1 条
|
||||
//! (`NavDownloadExpanded`);M105/M106/M107 各自追加的条目也都在,整表现在是 138 条。
|
||||
//!
|
||||
//! ## 为什么不是「数条数」
|
||||
//!
|
||||
//! 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。
|
||||
//! 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红:
|
||||
//!
|
||||
//! | 坏法 | 红在哪 |
|
||||
//! |---|---|
|
||||
//! | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 |
|
||||
//! | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 |
|
||||
//! | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) |
|
||||
//! | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 |
|
||||
//!
|
||||
//! **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。
|
||||
//!
|
||||
//! ## M108 更新了三条、新增了一条(版本管理改名)
|
||||
//!
|
||||
//! 用户令(2026-11-04)把这页的显示名从「下载 / Downloads」改成「版本管理 / Version management」:
|
||||
//! [`super::Key::NavDownload`] / [`super::Key::DownloadTitle`] / [`super::Key::DownloadSubtitle`]
|
||||
//! 三条译文按令改掉,并**新增** [`super::Key::NavDownloadExpanded`](240 展开态的全标签,
|
||||
//! 88 轨放不下 18 字符的英文,见 `catalog/nav.rs` 的「两条 key」一节)。
|
||||
//!
|
||||
//! 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的,
|
||||
//! 其余一字未动」,不再是「整表一字未动」。
|
||||
//! 「为什么有这么一张表、它抓得住什么」写在 `catalog.rs` 中 `mod golden;` 的文档里——
|
||||
//! M119 r2 把它搬到那儿,是为了给这张表腾出追加新 key 的行数(400 行硬线),一个字的说明都没删。
|
||||
|
||||
/// 拆分前 `Key::ALL` 的顺序 × (en, zh-CN)。第一列是变体名(`Key::name()` 的返回值)。
|
||||
pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
@@ -150,6 +121,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("AgentConfirmAllow", "Allow", "允许"),
|
||||
("AgentConfirmDeny", "Deny", "拒绝"),
|
||||
("AgentSimulated", "Simulated", "模拟"),
|
||||
("AgentSubtitleReal", "Conversation", "会话"),
|
||||
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
|
||||
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
|
||||
// ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**,
|
||||
// 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。
|
||||
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
|
||||
|
||||
Reference in new issue
Block a user