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

-75
View File
@@ -1,75 +0,0 @@
//! M68 骨架卡:**只给 `Route::Agent` 的回落路径用**(AI 独立页 P1 才落地)。
//!
//! 内容区 = 页头(`TitleLarge`)+ 一张 `OutlinedCard`("Shell ready" + 一段说明文字)。
//! 它原本住在 `shell/mod.rs`,M90 r2 为了让 `mod.rs` 回到 400 行以内搬到这里;
//! 行为一个字没改(`shell::view` 的输出与拆前逐字节相同)。
//!
//! 为什么不能直接删:删了 `shell::view` 就没有调用点,而 `mod shell` 是私有的 ——
//! 私有模块里的 `pub fn` 无人使用会被 `dead_code` 判死,`-D warnings` 直接红。
use iced::widget::{column, container};
use iced::{Alignment, Background, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::app::Message;
use crate::theme::Ui;
use super::label;
// ---------------------------------------------------------------- 内容区
pub(super) fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> {
let metrics = ui.metrics();
// 卡片档位由组件表决定(圆角/字级),组件层不自己挑
let card_spec = Component::OutlinedCard.spec();
let body_role = card_spec.support.unwrap_or(card_spec.text);
let header = container(label(
ui,
TypeRole::TitleLarge,
true,
title,
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.height(metrics.page_header_height)
.align_y(Alignment::Center)
.padding(Padding::from([0.0, ui.space(Space::X6)]));
let card = container(
column![
label(
ui,
card_spec.text,
true,
"Shell ready",
ui.color(Role::OnSurface),
),
label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)),
]
.spacing(ui.space(Space::X2)),
)
.width(Length::Fill)
.padding(metrics.card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: ui.outlined(card_spec.shape),
..container::Style::default()
});
let body_area = container(card)
.width(Length::Fill)
.height(Length::Fill)
.padding(ui.space(Space::X6))
.align_y(Alignment::Start);
column![header, body_area]
.width(Length::Fill)
.height(Length::Fill)
.into()
}
+34 -62
View File
@@ -1,4 +1,4 @@
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区 + **右侧 AI 助手栏**(M104)。
//!
//! ## 与参考判据的对应(`research/12b-component-fidelity.md`)
//!
@@ -15,9 +15,18 @@
//! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心
//! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。
mod fallback;
mod nav;
/// AI 助手栏与 AI 独立页(M104):**物理目录是 `crates/app-iced/src/pages/agent/`**,
/// 用 `#[path]` 挂到外壳的命名空间。
///
/// 为什么是 `#[path]` 而不是在 `pages/mod.rs` 加一行 `mod agent;`:`pages/mod.rs` 是四条页面线
/// 共用的声明文件(本 mission 的 scope 没给它),而右栗是**外壳的一部分**(它挂在任何页面右边)。
/// 先例是 `app/mod.rs` 挂 `../components/mod.rs`。物理路径只有一份,逻辑名字有两个用途:
/// 宿主(右栗)归外壳,视图(会话组件)归页面目录。
#[path = "../pages/agent/mod.rs"]
pub(crate) mod agent;
pub use nav::NavItem;
use iced::widget::text::LineHeight;
@@ -36,34 +45,7 @@ use crate::cli::ForcedState;
use crate::icons;
use crate::theme::Ui;
/// 整棵树(**内容槽位版**:外壳画标题栏 / 缩放热区 / 导航轨,中间那块交给调用方)。
///
/// 这是产品页面走的入口(M90 r2 / P1-1)。为什么要它:`decorations: false` 的无边框窗口里,
/// 拖动 / 缩放 / 最小化 / 关闭这四条消息的构造点**只有本模块**,页面树自己画的「标题栏留白带」
/// 没有任何控件,于是产品页会变成「拖不动、缩不了、点不到关闭」。
///
/// 分工写死:
///
/// - 本模块负责:标题栏(拖动面 = **整条 48px 栏里从窗口左缘到第一个 caption** 的 `mouse_area`,
/// 没有单独的 8px 热区;三个 caption 键各 46×48)、东/南两条缩放热区(各 `Space::X1` = 4px)、
/// 导航轨、根容器底色;
/// - 调用方负责:`content`(页面内容区)。它拿到的可用宽度是 `窗口宽 − rail 88`,
/// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距),
/// 两边的账不会算串。
///
/// 面板 α(D36 / M93)不从这里传:它是**表面色**的属性,唯一施加点是 `Ui::layer`
/// (`crate::theme`)。外壳的根容器 / 标题栏 / 导航轨都走 `ui.layer(..)`,因此自动同源。
pub fn view_with_content<'a>(
ui: &'a Ui,
selected: NavItem,
app_title: &'a str,
content: Element<'a, Message>,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
view_with_background(ui, selected, app_title, None, content, forced)
}
/// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的真图背景层)。
/// 整棵树(**内容槽位版**):外壳画标题栏 / 缩放热区 / 导航轨 / 右栗,中间那块交给调用方。
///
/// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!`
/// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。
@@ -73,39 +55,24 @@ pub fn view_with_background<'a>(
app_title: &'a str,
background: Option<Element<'a, Message>>,
content: Element<'a, Message>,
right: Option<Element<'a, Message>>,
overlay: Option<Element<'a, Message>>,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let work_area = column![
titlebar(ui, app_title, forced),
row![nav::rail(ui, selected, forced), content]
.width(Length::Fill)
.height(Length::Fill),
]
.width(Length::Fill)
.height(Length::Fill);
// 右栗(M104)是 `row` 的**第三个兄弟**而不是叠层:它是布局宽度的一部分,压在上面会让
// 中栗以为自己还有那么宽(那就是任务里禁止的「溢出」)。它的宽度由 `agent::docked` 自己声明。
let mut work = row![nav::rail(ui, selected, forced), content]
.width(Length::Fill)
.height(Length::Fill);
if let Some(right) = right {
work = work.push(right);
}
window_frame(ui, background, work_area.into())
}
let work_area = column![titlebar(ui, app_title, forced), work]
.width(Length::Fill)
.height(Length::Fill);
/// 整棵树(M68 骨架版:内容区是「页头 + 一张占位卡」)。
///
/// **保留给 `Route::Agent` 回落**(AI 独立页 P1 才落地)。删掉它会连带
/// `content` / `label` / `nav::rail` 变成死代码,而 `mod shell` 是私有的 ⇒ `-D warnings` 直接红。
pub fn view<'a>(
ui: &'a Ui,
selected: NavItem,
app_title: &'a str,
page_title: &'a str,
body: &'a str,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
view_with_content(
ui,
selected,
app_title,
fallback::content(ui, page_title, body),
forced,
)
window_frame(ui, background, work_area.into(), overlay)
}
/// 无边框窗口的「外壳壳子」:背景层 + 工作区 + 两条缩放热区 + 根容器底色。
@@ -114,6 +81,7 @@ fn window_frame<'a>(
ui: &'a Ui,
background: Option<Element<'a, Message>>,
work_area: Element<'a, Message>,
overlay: Option<Element<'a, Message>>,
) -> Element<'a, Message> {
// 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。
// **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px,
@@ -148,10 +116,14 @@ fn window_frame<'a>(
// 背景在最底层(M93 / D34)。有背景层时**根容器不再盖实色画布**:否则那张不透明的
// `Canvas` 会把背景图整块挡住(r2 实测:panel α=1 时背景完全看不见)。
// 各面板(标题栏 / 导航轨 / 卡片 / 页头带)自己画底色,空隙里露出的就是背景图。
// 拖拽层(M104)压在最上面:它只在「按下右栗分隔条」期间存在,作用是把指针 x 变成拖拽消息
// (`mouse_area::on_move` 只在自己的边界内回报坐标,所以这一层必须铺满整窗)。
let has_background = background.is_some();
let shell = match background {
Some(layer) => stack![layer, work_area, east, south],
None => stack![work_area, east, south],
let shell = match (background, overlay) {
(Some(layer), Some(overlay)) => stack![layer, work_area, east, south, overlay],
(Some(layer), None) => stack![layer, work_area, east, south],
(None, Some(overlay)) => stack![work_area, east, south, overlay],
(None, None) => stack![work_area, east, south],
};
container(shell)