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

@@ -0,0 +1,142 @@
//! 输入区(多行输入框 + 发送/中断):会话视图的**下半截**。
//!
//! 从 `conversation.rs` 拆出来的理由只有一条:那边贴着 400 行硬上限(AGENTS.md §3-1)。
//! 拆的位置是「输入区」这一整块 + 一个排版小工具,语义边界与文件边界一致。
//!
//! ## Enter / Shift+Enter 的分流(任务第 4 条)
//!
//! `text_editor` 的默认绑定把两种 Enter 都折成 `Binding::Enter`,所以必须自己接管
//! (见 [`composer`] 里的 `key_binding`):`modifiers.shift()` 决定「换行」还是「发送」。
//! **只在输入框聚焦时生效**——`KeyPress.status` 不是 `Focused` 时原样交回默认绑定,
//! 于是别处的 Enter 不会被助手栏抢走。
use iced::widget::text_editor::{self, Binding};
use iced::widget::{column, row, text, text_editor as editor};
use iced::{keyboard, Background, Border, Element, Length, Pixels, Theme};
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};
use crate::app::components::buttons::{self, Button};
use crate::theme::{Ui, STROKE_PX};
use super::super::state::Focus;
use super::super::Message;
use super::{focused_state, Conversation};
pub(super) fn composer<'a>(
ui: &'a Ui,
spec: Conversation<'a>,
catalog: Catalog,
) -> Element<'a, Message> {
let style = ui.style(TypeRole::BodyMedium);
let field = editor(spec.draft)
.placeholder(catalog.text(Key::AgentPlaceholder))
.on_action(Message::Edit)
.key_binding(|press| {
// Enter = 发送 / Shift+Enter = 换行(任务第 4 条)。
//
// 走 `key_binding` 而不是全局键盘订阅:`KeyPress` 直接带 `modifiers`,而 iced 的
// 默认绑定把两种 Enter 都折成 `Binding::Enter`(所以**必须**在这里分流)。
// 未聚焦时一律交回默认绑定(它自己不响应),于是别处的 Enter 不会被助手栏抢走。
let enter = matches!(
press.modified_key.as_ref(),
keyboard::Key::Named(keyboard::key::Named::Enter)
);
if !enter || !matches!(press.status, text_editor::Status::Focused { .. }) {
return Binding::from_key_press(press);
}
if press.modifiers.shift() {
Some(Binding::Enter)
} else {
Some(Binding::Custom(Message::Submit))
}
})
.size(style.size_px)
.font(ui.font_of(style))
.height(Length::Fixed(
ui.space(Space::X8) + ui.space(Space::X8) + ui.space(Space::X1),
))
.padding(ui.space(Space::X2))
.style(move |_theme: &Theme, status| {
let focused = matches!(status, text_editor::Status::Focused { .. });
text_editor::Style {
background: Background::Color(ui.layer(MaterialLayer::Card)),
border: if focused {
Border {
color: ui.color(Role::Primary),
width: STROKE_PX * 2.0,
radius: ui.radius_dp(ShapeScale::Small),
}
} else {
ui.outlined(ShapeScale::Small)
},
placeholder: ui.color(Role::OnSurfaceVariant),
value: ui.color(Role::OnSurface),
selection: ui.color(Role::PrimaryContainer),
}
});
let send_label = catalog.text(Key::AgentSend);
let send = if spec.blank || spec.busy {
Button::disabled(send_label)
} else {
Button::new(send_label, Message::Submit)
};
// 键盘焦点在「发送」上时用 `Interaction::Focused`(10% 状态层),与头部动作同一套焦点语言。
let send = focused_state(
send.leading(IconKind::Send),
spec.focus == Some(Focus::Send),
);
let mut actions = row![]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
if spec.busy {
actions = actions.push(buttons::outlined(
ui,
focused_state(
Button::new(catalog.text(Key::AgentStop), Message::Stop).leading(IconKind::Close),
spec.focus == Some(Focus::Stop),
),
));
}
actions = actions.push(buttons::filled(ui, send));
column![field, actions]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into()
}
/// 一档文字(字号/行高/字重来自令牌;`shell::label` 同口径,但这里要自己持消息类型)。
pub(crate) fn text_role<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
color: iced::Color,
width: Length,
) -> Element<'a, Message> {
let style = if emphasized {
ui.emphasized(role)
} else {
ui.style(role)
};
text(content)
.size(style.size_px)
.line_height(iced::widget::text::LineHeight::Absolute(Pixels(
style.line_height_px,
)))
.font(ui.font_of(style))
.color(color)
.width(width)
.into()
}