feat(app-iced): M104 AGENTRAIL —— 右栗三档 + 布局数学 + 独立页复用同一会话视图

用户定案(D16):右侧常驻可折叠、允许拆分成独立页、中栗宽度可调(磁铁吸附)。
本 mission 只做壳:三档状态 + 布局数学 + 一份会话视图;真数据仍归 M103,一行 presenter 都不调。

- 右栗三档:Collapsed(48) / Expanded(280~360,默认 320,锚=令牌 ai_panel_width) / Detached(Route::Agent);
  会话、草稿、未读在切档时一条不丢(拆出去再收回,话说了一半还在)
- 布局数学:中栗 = 窗口 − 右栗 − 拖拽缝(8),下限 472(= 行宽上限 424 + 两侧页边距);
  右栗一展开,页面按更窄的宽度重新排版(收缩而不是溢出);拖拽磁铁吸附到 280/320/360,超上限夹紧
- 独立页与右栗共用 conversation::view 这一份实现(输入=纯数据,输出=agent::Message)
- 控件:多行输入框(Enter 发送 / Shift+Enter 换行,走 text_editor::key_binding 的 modifiers 分流)、
  发送禁用态、流式中断、未读点 + 可读计数、⌄ 收起 / ⇥ 拆出 / ⇤ 收回;Esc 收栗、Tab 焦点环、Enter 激活
- 文案全部进 crates/i18n 的新域 catalog/agent.rs(+ golden 追加 20 行),零硬编码中文
- 取证:ULL_AGENT_RAIL / ULL_AGENT_FIXTURE / ULL_AGENT_WIDTH(截图走产品 bin,标题栏与三键可见)
- 消息不走 pages::Message(外壳级视图),走 app::Message::Agent(..) → app/update/agent.rs
- 删掉 M68 骨架回落(shell::view + shell/fallback.rs):Route::Agent 现在有真页面

门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / app-iced lib 418 条全绿
This commit is contained in:
AskaEth committed 2026-11-04 00:21:32 +08:00
1 parent 875ac159ee
commit c3f20784a8
20 files changed
+2463 -153

No files matched your search

+381
View File
@@ -0,0 +1,381 @@
//! 三档状态机、两个宿主、a11y 文案的判据(数值表与磁铁在 `metrics/tests.rs`)。
//!
//! 每条断言都写清「抓得住什么 / 抓不住什么」——纯函数与源码扫描能钉住语义,**钉不住**像素。
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::theme::{Families, Ui};
use super::conversation::{Conversation, Host};
use super::metrics::{layout_for, PRESETS, RESIZE_GUTTER};
use super::state::{AgentUi, Focus, RailState, Speaker, Turn};
use super::*;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// 三档迁移:会话、草稿、未读的语义。
///
/// 抓得住:拆出去再收回,话说了一半还在(transcript + draft 不丢);未读只在收起态累积。
/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看)。
#[test]
fn the_three_states_share_one_conversation() {
let mut agent = AgentUi::new();
assert_eq!(agent.rail, RailState::Collapsed, "出厂是折叠档");
assert_eq!(agent.unread, 2, "出厂带两条未读(折叠条上要有点)");
agent.expand();
assert_eq!(agent.rail, RailState::Expanded);
assert_eq!(agent.unread, 0, "展开即读");
agent.draft = iced::widget::text_editor::Content::with_text("kick off Survival");
agent.draft.perform(iced::widget::text_editor::Action::Edit(
iced::widget::text_editor::Edit::Enter,
));
assert_eq!(
agent.draft.line_count(),
2,
"多行:Enter 的编辑动作能落进草稿"
);
let turns = agent.transcript.len();
let draft = agent.draft.text();
agent.detach();
assert_eq!(agent.rail, RailState::Detached);
assert_eq!(agent.transcript.len(), turns, "拆出去不丢会话");
assert_eq!(agent.draft.text(), draft, "拆出去不丢草稿");
agent.dock();
assert_eq!(agent.rail, RailState::Expanded);
assert_eq!(agent.draft.text(), draft);
// 收起 / Esc:只改档位,不清未读记录(下一批消息仍会点亮)
agent.collapse();
assert_eq!(agent.rail, RailState::Collapsed);
// 独立页上的 Esc = 收回(`collapse` 对 Detached 等价于 dock)
agent.rail = RailState::Detached;
agent.collapse();
assert_eq!(agent.rail, RailState::Expanded);
}
/// 发送 / 中断:**只做视图态**(没有后端),但绝不静默——两条都会在会话里留一条提示。
#[test]
fn submit_and_stop_leave_a_visible_notice() {
let catalog = Catalog::of(Lang::En);
let mut agent = AgentUi::new();
agent.expand();
assert!(agent.draft_is_blank());
assert!(
!agent.submit(catalog),
"空草稿发不出去(发送键同时是禁用态)"
);
agent.draft = iced::widget::text_editor::Content::with_text(" hello ");
let before = agent.transcript.len();
assert!(agent.submit(catalog));
assert_eq!(agent.transcript.len(), before + 2, "一条「你」+ 一条提示");
assert_eq!(agent.transcript[before].speaker, Speaker::You);
assert_eq!(agent.transcript[before].text, "hello", "两侧空白被剪掉");
assert_eq!(agent.transcript[before + 1].speaker, Speaker::Notice);
assert_eq!(
agent.transcript[before + 1].text,
catalog.text(Key::AgentNotePending)
);
assert!(agent.draft_is_blank(), "发出去后草稿清空");
// 流式中:发送键禁用、中断键出现;中断也留一条提示
agent.pretend_streaming();
assert!(agent.busy);
agent.draft = iced::widget::text_editor::Content::with_text("second");
assert!(!agent.submit(catalog), "流式中不许再发");
let before = agent.transcript.len();
agent.stop(catalog);
assert!(!agent.busy);
assert_eq!(agent.transcript.len(), before + 1);
assert_eq!(
agent.transcript[before].text,
catalog.text(Key::AgentNoteStopped)
);
// 没在流式时按中断是 no-op(不刷屏)
let before = agent.transcript.len();
agent.stop(catalog);
assert_eq!(agent.transcript.len(), before);
}
/// 空会话:零消息 + 一句引导文案(任务第 7 条)。
#[test]
fn the_empty_session_has_a_guidance_line() {
let empty = AgentUi::empty();
assert!(empty.transcript.is_empty(), "空会话 = 零消息");
assert_eq!(empty.unread, 0);
let catalog = Catalog::of(Lang::En);
assert!(!catalog.text(Key::AgentEmptyTitle).is_empty());
assert!(
catalog.text(Key::AgentEmptyBody).split_whitespace().count() >= 5,
"空态要有一句能读懂下一步的引导,而不是一个词"
);
// 中文也得有且不同(D28:两套目录一行一个 key,缺译会红)
let zh = Catalog::of(Lang::ZhCn);
assert_ne!(
zh.text(Key::AgentEmptyBody),
catalog.text(Key::AgentEmptyBody)
);
}
/// **单实现判据**:右栗与独立页都只通过 `conversation::view` 画对话。
///
/// 抓得住:有人把「独立页要更宽」当成理由另写一份气泡/输入框装配(复制粘贴是这类任务最常见的坏法)。
/// 抓不住:`conversation::view` **内部**再分叉成两套(那要人读那个文件)。
#[test]
fn both_hosts_render_the_same_conversation() {
let hosts = include_str!("mod.rs");
let conversation = include_str!("conversation.rs");
let composer = include_str!("conversation/composer.rs");
assert_eq!(
conversation.matches("pub fn view<").count(),
1,
"会话视图只能有一个 `view`"
);
assert_eq!(
hosts.matches("conversation::view(").count(),
2,
"右栗与独立页各调用一次同一个 `conversation::view`(不多不少)"
);
assert!(
!hosts.contains("text_editor("),
"输入框的装配只许在 conversation/composer.rs 里(宿主不许自己拼一份)"
);
assert!(
!hosts.contains("scrollable("),
"滚动区的装配只许在 conversation.rs 里"
);
assert!(
composer.contains("text_editor(") || composer.contains("editor(spec.draft)"),
"输入框就在 composer 里"
);
}
/// a11y:所有动作按钮的标签都来自词表,折叠条也不是「只有图标」。
///
/// 抓得住:有人写死一句英文/中文在视图里("Cancel"/"发送"),或给折叠条只留一个图标按钮。
/// 抓不住:译文本身好不好读(那要人看)。
#[test]
fn every_action_has_a_readable_label() {
let catalog = Catalog::of(Lang::En);
for key in [
Key::AgentCollapse,
Key::AgentDetach,
Key::AgentDock,
Key::AgentOpen,
Key::AgentSend,
Key::AgentStop,
Key::AgentStripLabel,
Key::AgentResize,
] {
assert!(
!catalog.text(key).trim().is_empty(),
"{} 没有可读标签",
key.name()
);
}
for source in [
include_str!("conversation.rs"),
include_str!("conversation/composer.rs"),
include_str!("mod.rs"),
] {
for literal in ["Button::new(\"", "Button::disabled(\""] {
assert!(
!source.contains(literal),
"按钮标签不许写死在视图里:{literal}"
);
}
assert!(
!source.contains("text(\"Assistant\""),
"文案不许写死在视图里(走 i18n 的 Key)"
);
}
// 折叠条的 48px 条上,图标下面必须有文字标签(否则就是「只有图标」)
assert!(
include_str!("mod.rs").contains("Key::AgentStripLabel"),
"折叠条必须有可读的条上标签"
);
}
/// Tab 环:只在**真正存在**的控件之间走,且顺序自左向右可预期。
#[test]
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);
walked.push(focus);
}
assert_eq!(
walked,
vec![
Focus::Collapse,
Focus::Detach,
Focus::Send,
Focus::Composer,
Focus::Collapse,
]
);
// 反向:Collapse 往回走是 Composer
assert_eq!(
Focus::Collapse.step(true, RailState::Expanded, false),
Focus::Composer
);
// 流式中多一档中断键
assert_eq!(
Focus::Send.step(false, RailState::Expanded, true),
Focus::Stop
);
// 独立页只有「收回 + 输入区」
assert_eq!(
Focus::Detach.step(false, RailState::Detached, false),
Focus::Send
);
// 折叠档没有会话动作:环上只有「展开」一格
assert_eq!(
Focus::Collapse.step(false, RailState::Collapsed, false),
Focus::Collapse
);
// `Tab` 真的把键盘巡航打开(`Enter` 的闸门)
let mut agent = AgentUi::new();
assert!(!agent.keyboard_mode);
agent.step_focus(false);
assert!(agent.keyboard_mode);
}
/// 两个宿主都能构图(含空会话 / 折叠 / 流式中),且能吃 880~2560 的窗口宽度。
///
/// 抓得住:某档位下 `view` 的某条分支 panic(例如空 transcript + 空未读)。
/// 抓不住:像素级观感(出图另算)。
#[test]
fn every_state_builds_in_both_hosts() {
let ui = ui();
let catalog = Catalog::of(Lang::En);
for window in [880.0, 900.0, 1180.0, 2560.0] {
for rail in RailState::ALL {
for empty in [false, true] {
let mut agent = if empty {
AgentUi::empty()
} else {
AgentUi::new()
};
agent.rail = rail;
let _: iced::Element<'_, Message> = page(&ui, &agent, window);
let spec = Conversation {
transcript: &agent.transcript,
draft: &agent.draft,
busy: agent.busy,
unread: agent.unread,
blank: agent.draft_is_blank(),
rail: agent.rail,
host: Host::Rail,
focus: agent.keyboard_mode.then_some(agent.focus),
};
let _: iced::Element<'_, Message> = conversation::view(&ui, spec, catalog);
let _ = docked(&ui, &agent, window);
}
}
}
}
/// 拖拽层只在拖拽期间存在(否则它会吃掉整窗的点击)。
#[test]
fn the_drag_overlay_only_exists_while_dragging() {
let mut agent = AgentUi::new();
agent.expand();
assert!(drag_overlay(&agent).is_none());
agent.drag_start();
assert!(agent.is_dragging());
assert!(drag_overlay(&agent).is_some());
agent.drag_end();
assert!(drag_overlay(&agent).is_none());
}
/// 拆出档右栗必须**不占地**(否则会话会在两个地方各画一份)。
#[test]
fn the_detached_state_frees_the_rail_slot() {
let ui = ui();
let mut agent = AgentUi::new();
agent.detach();
assert!(docked(&ui, &agent, 1180.0).is_none());
assert_eq!(agent.width(1180.0), 0.0);
assert_eq!(agent.content_width(1180.0), 1180.0);
}
/// 拖拽把「展开宽度」写进状态(松手后保有该宽度)。
#[test]
fn the_drag_writes_back_the_snapped_width() {
let mut agent = AgentUi::new();
agent.expand();
agent.drag_start();
agent.drag_to(1180.0, 1180.0 - 280.0);
agent.drag_end();
assert_eq!(agent.expanded(), 280.0);
assert!(!agent.is_dragging());
// 没按下就收到的 DragTo(例如上一步松手后的残留)不该改宽度
let before = agent.expanded();
agent.drag_to(1180.0, 100.0);
assert_eq!(agent.expanded(), before);
// 三档预设都能被拖到(1180 下上限 612 > 360)
for preset in PRESETS {
agent.drag_start();
agent.drag_to(1180.0, 1180.0 - preset);
agent.drag_end();
assert_eq!(agent.expanded(), preset);
}
}
/// 面板与中栗之间的缝在布局里被真的留出来了(宽度常量与 `layout_for` 同源)。
#[test]
fn the_resize_gutter_is_part_of_the_budget() {
let mut agent = AgentUi::new();
agent.expand();
assert_eq!(
agent.width(1180.0) + RESIZE_GUTTER,
1180.0 - agent.content_width(1180.0)
);
// 折叠与拆出档没有缝
let collapsed = layout_for(1180.0, RailState::Collapsed, 320.0);
assert_eq!(collapsed.gutter, 0.0);
let detached = layout_for(1180.0, RailState::Detached, 320.0);
assert_eq!(detached.gutter, 0.0);
}
/// 会话数据结构不许在手边偷偷长字段(`Turn` 只有说话人 + 正文)。
#[test]
fn a_turn_is_only_a_speaker_and_a_body() {
let turn = Turn::new(Speaker::You, "hi");
assert_eq!((turn.speaker, turn.text.as_str()), (Speaker::You, "hi"));
}