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

+347
View File
@@ -0,0 +1,347 @@
//! # AI 助手(右栗三档 + `Route::Agent` 独立页)
//!
//! 用户定案(D16):「右侧常驻可折叠,允许拆分成独立页」+「中栗宽度可调(磁铁吸附)」。
//! 本模块交付的是**壳**:三档状态、布局数学、两个宿主共用的一份会话视图。真数据来自
//! M103(`presenter::agent::AgentRail`,四个签名见任务上下文)——本 mission **一行都不调**,
//! 会话内容用的是内置样本([`demo`])。
//!
//! ## 文件与职责
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`state`] | 三档状态机 + **布局数学**(纯函数,可脱机单测)+ [`AgentUi`](挂在 `App` 上的状态) |
//! | `conversation` | **会话视图组件**:右栗与独立页唯一的对话 UI(输入契约见该文件) |
//! | [`demo`] | 占位样本会话 + `ULL_AGENT_*` 取证开关 |
//! | 本文件 | 两个**宿主**([`docked`] 右栗 / [`page`] 独立页)+ 消息枚举 + 拖拽层 |
//! | `tests.rs` / `metrics/tests.rs` | 三档迁移 / 单实现判据 / a11y 文案;数值表与磁铁在后者 |
//!
//! ## 为什么物理路径在 `pages/agent/`、逻辑路径却是 `crate::shell::agent`
//!
//! 本 mission 的 scope 给了 `pages/agent/**`,但**没给** `pages/mod.rs`(那是一个四条页面线共用的
//! 声明文件)。于是 `shell/mod.rs` 用 `#[path = "../pages/agent/mod.rs"]` 挂载它——与
//! `app/mod.rs` 挂 `../components/mod.rs` 是同一个先例(理由也一样:别动共用声明文件)。
//!
//! **没有第二份实现**:物理目录只有 `crates/app-iced/src/pages/agent/`,`crate::shell::agent`
//! 只是它的逻辑名字。搬回 `pages::agent` 只需在 `pages/mod.rs` 加一行 `mod agent;`,
//! 那一步不属于本 mission(报告里登记)。
//!
//! ## 三档状态([`RailState`])
//!
//! | 档位 | 右栗 | 会话在哪 | 切法 |
//! |---|---|---|---|
//! | `Collapsed` | 48 图标条 + 未读点 | 不画 | 点条上的 AI 图标 / `Esc` |
//! | `Expanded` | 280~360(默认 320)+ 8 拖拽缝 | 面板内 | 条上图标 / `⌄` 收起 / 拆出后收回 |
//! | `Detached` | 0 | `Route::Agent` 独立页 | `⇥` 拆出 / 导航第五项 `Assistant` |
//!
//! ## 为什么消息不走 `pages::Message`
//!
//! 助手栏是**外壳级视图**(挂在任何页面右边),不是某一页的消息:塞进 `pages::Message` 要动
//! `app/update/page_messages.rs` 的穷尽 match(那个文件正被未合的 M101 改),语义也不对。
//! 所以是一等公民 [`Message`] + `app::Message::Agent(..)` 一次映射,落点 `app/update/agent.rs`。
pub(crate) mod demo;
mod metrics;
mod state;
mod conversation;
#[cfg(test)]
mod tests;
pub use state::{AgentUi, Focus, RailState};
use iced::widget::{column, container, mouse_area, row, rule, space, stack, text, tooltip};
use iced::{mouse, Alignment, Background, Border, Element, Length, Padding, Theme as IcedTheme};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, IconVariant};
use crate::pages::{self, layout};
use crate::theme::Ui;
use conversation::{Conversation, Host};
use metrics::{COLLAPSED_WIDTH, RESIZE_GUTTER};
/// 界面语言(与产品其它页面同一口径:i18n 接线前是英文基线,D28 的「英文环境零中文负担」)。
const LANG: Lang = Lang::En;
/// 助手栏消息(**唯一**的消息类型;`app/view.rs` 用 `Message::Agent(..)` 包一层)。
#[derive(Debug, Clone)]
pub enum Message {
/// 折叠条 → 展开。
Expand,
/// `⌄` 收起 / 独立页上 `Esc`。
Collapse,
/// `⇥` 拆成独立页(同时压一层 `Route::Agent`)。
Detach,
/// `⇤` 收回右栗(离开 `Route::Agent`)。
Dock,
/// 输入框的编辑动作(由 `text_editor::on_action` 来)。
Edit(iced::widget::text_editor::Action),
/// 发送(`Send` 键 / 输入框里按 Enter)。
Submit,
/// 中断流式回复。
Stop,
/// 按下拖拽缝。
DragStart,
/// 拖拽中:窗口坐标里的指针 x。
DragTo(f32),
/// 松手。
DragEnd,
/// 键盘:`Esc`(收栗 / 独立页收回)。
Escape,
/// 键盘:`Tab`(走焦点环;`shift` = 反向)。
Tab { shift: bool },
/// 键盘:`Enter`(激活软焦点上的按钮)。
Activate,
/// 原始键盘事件(`app/subscription.rs` 的 `keyboard::listen()` 搬过来的)。
///
/// 订阅层只负责搬运,**解释权**在 `app/update/agent.rs` 的 `on_agent_key`:
/// `Esc` / `Tab` / `Enter` 各自在什么状态下做什么,集中在一个函数里一眼看完。
Key(iced::keyboard::Event),
}
/// 停靠档的右栗(`None` = 已拆成独立页,右栗不占地)。
///
/// 两档共用同一条 `Element` 通道交给外壳的 `row!`:折叠是 48 的条,展开是「8 拖拽缝 + 面板」。
pub fn docked<'a>(
ui: &'a Ui,
agent: &'a AgentUi,
window_width: f32,
) -> Option<Element<'a, Message>> {
// 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条
// (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。
match agent.effective_rail(window_width) {
RailState::Detached => None,
RailState::Collapsed => Some(strip(ui, agent)),
RailState::Expanded => Some(panel(ui, agent, window_width)),
}
}
/// `Route::Agent` 独立页(与右栗**同一个** `conversation::view`)。
///
/// 页级骨架(页边距 + 正文列宽)与其它页面同源:列宽取 [`layout::frame_metrics`],单列 ≤424。
pub fn page<'a>(ui: &'a Ui, agent: &'a AgentUi, window_width: f32) -> Element<'a, Message> {
let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width);
let body = conversation::view(ui, spec(agent, Host::Page), Catalog::of(LANG));
container(
container(body)
.width(Length::Fixed(metrics.column))
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.padding(metrics.gutter)
.align_x(Alignment::Center)
.align_y(Alignment::Start)
.into()
}
/// 拖拽层:拖拽期间铺满窗口,把指针 x 变成 [`Message::DragTo`]。
///
/// 为什么铺满整窗:`mouse_area::on_move` 只在**它自己的边界内**回报坐标,而拖拽时指针一定会离开
/// 那 8px 的缝隙。铺满整窗后 `Point.x` 就是窗口坐标 ⇒ `目标宽 = 窗口宽 − x`。
/// 松手由同一层的 `on_release` 收尾(否则会一直跟着指针走)。
pub fn drag_overlay<'a>(agent: &AgentUi) -> Option<Element<'a, Message>> {
if !agent.is_dragging() {
return None;
}
Some(
mouse_area(
container(space::horizontal())
.width(Length::Fill)
.height(Length::Fill),
)
.interaction(mouse::Interaction::ResizingHorizontally)
.on_move(|point| Message::DragTo(point.x))
.on_release(Message::DragEnd)
.into(),
)
}
// ---------------------------------------------------------------- 折叠档:48 图标条
fn strip<'a>(ui: &'a Ui, agent: &'a AgentUi) -> Element<'a, Message> {
let catalog = Catalog::of(LANG);
let side = ui.metrics().control_height;
// 图标按钮 40×40(M3 icon button),未读点叠在右上角;下面两行小字保证「不得只有图标」:
// 条上每个可点元素都有可读文字(完整名字在 tooltip 里)。
let icon: Element<'a, Message> = buttons::icon(
ui,
IconVariant::Standard,
IconKind::Ai,
Some(Message::Expand),
None,
);
let glyph: Element<'a, Message> = if agent.unread > 0 {
stack![
icon,
container(dot(ui))
.width(Length::Fixed(side))
.height(Length::Fixed(side))
.align_x(Alignment::End)
.align_y(Alignment::Start)
.padding(Padding::from([0.0, ui.space(Space::X1)])),
]
.into()
} else {
icon
};
let labelled = container(column![
glyph,
conversation::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AgentStripLabel),
ui.color(Role::OnSurfaceVariant),
// `Shrink` 而不是 `Fill`:48 宽的条上 Fill 会让文字贴左缘,"2 unread" 会被窗角裁掉
Length::Shrink,
),
unread_text(ui, agent.unread, catalog),
])
.width(Length::Fixed(COLLAPSED_WIDTH))
.align_x(Alignment::Center)
.padding(Padding::from([ui.space(Space::X6), 0.0]));
let tip_style = ui.style(TypeRole::BodySmall);
let tip = container(
text(catalog.text(Key::AgentOpen))
.size(tip_style.size_px)
.font(ui.font_of(tip_style))
.color(ui.color(Role::OnSurface)),
)
.padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)]))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: Border {
radius: ui.radius_dp(ShapeScale::Small),
..Border::default()
},
..container::Style::default()
});
container(tooltip(labelled, tip, tooltip::Position::Left))
.width(Length::Fixed(COLLAPSED_WIDTH))
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
})
.into()
}
/// 未读点(8×8,`Primary`)。
fn dot<'a>(ui: &'a Ui) -> Element<'a, Message> {
let side = ui.space(Space::X2);
container(space::horizontal())
.width(Length::Fixed(side))
.height(Length::Fixed(side))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::Primary))),
border: Border {
radius: ui.radius(ShapeScale::Full, side),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 未读的**可读形式**:条上只放计数(`2`),完整那句(`2 unread`)在面板头部的胶囊里。
///
/// 为什么不是「2 unread」:48px 的条装不下那 8 个字符(实测会贴到窗角被裁),条上本来只需
/// 「有几条」。零未读时给一个等高留白,免得条上元素上下跳。
fn unread_text<'a>(ui: &'a Ui, unread: usize, _catalog: Catalog) -> Element<'a, Message> {
let style = ui.style(TypeRole::LabelMedium);
if unread == 0 {
return space::vertical().height(style.line_height_px).into();
}
text(unread.to_string())
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::Primary))
.width(Length::Shrink)
.into()
}
// ---------------------------------------------------------------- 展开档:拖拽缝 + 面板
fn panel<'a>(ui: &'a Ui, agent: &'a AgentUi, window_width: f32) -> Element<'a, Message> {
let width = agent.width(window_width);
let body = container(conversation::view(
ui,
spec(agent, Host::Rail),
Catalog::of(LANG),
))
.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), body]
.width(Length::Shrink)
.height(Length::Fill)
.into()
}
/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖。
fn splitter<'a>(ui: &'a Ui) -> Element<'a, Message> {
let line = container(
rule::vertical::<IcedTheme>(ui.metrics().divider_thickness).style(move |_theme| {
rule::Style {
color: ui.color(Role::OutlineVariant),
radius: ui.radius_dp(ShapeScale::None),
fill_mode: rule::FillMode::Full,
snap: true,
}
}),
)
.width(Length::Fixed(RESIZE_GUTTER))
.height(Length::Fill)
.align_x(Alignment::Center);
mouse_area(line)
.interaction(mouse::Interaction::ResizingHorizontally)
.on_press(Message::DragStart)
.into()
}
/// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。
fn spec<'a>(agent: &'a AgentUi, host: Host) -> Conversation<'a> {
Conversation {
transcript: &agent.transcript,
draft: &agent.draft,
busy: agent.busy,
unread: agent.unread,
blank: agent.draft_is_blank(),
rail: agent.rail,
host,
// 只有键盘巡航过才画焦点环(鼠标操作时那是噪音,而且会被误读成 tonal 按钮)
focus: agent.keyboard_mode.then_some(agent.focus),
}
}