Files
Unlucky-Launcher/crates/app-iced/src/pages/agent/mod.rs
T
AskaEth 9253684387 fix(app-iced): M104 r2 —— Route::Agent ⟺ Detached 的不变式 + 复审 P3 六条
p2-1(唯一 p2):`Route::Agent` 与 `RailState::Detached` 之前没有绑定——从左侧导航第五项
(本 mission 点名的入口)进独立页时档位还是 Collapsed/Expanded,于是页头出现「Open assistant」
「Collapse/Pop out」、挂着不化的未读胶囊、Esc 也收不走页面(复审用产品 bin 复现)。

- **单一入口**:`App::sync_agent_route()`(update/agent.rs)在 `refresh_view` 开头调用,
  两条对向规则:在独立页 ⇒ Detached(顺带清未读);离开独立页而档位还是 Detached ⇒ 收回右栗。
  每一次路由变化都经过 refresh_view,所以导航 / ULL_START_ROUTE / detach / back 共用这一处。
- **结构性兜底**:`conversation::actions` 先按宿主分流,`Host::Page` 的动作恒为「Dock back」。
- **启动顺序**:`boot_agent()`(窗口尺寸 adopt + 拆出档直达独立页)必须在 `dispatch(RefreshAll)`
  之前——那次 dispatch 会 pump→auto_select 把路由拉到实例详情,同步器随即把档位收回(实测踩到)。
- 测试:`entering_the_agent_route_detaches_the_rail`(走 `Message::Select(NavItem::Ai)` 真路径,
  含未读清零 / Esc 离开 / 从页上点别的导航收回)、`the_page_navigation_entry_also_detaches`。
  变异自证:把 `sync_agent_route` 改成 no-op ⇒ 3 红(含这两条)。
- 图 13/14:导航入口(折叠档带 2 条未读 / 展开档)进页后都是 Dock back、无未读胶囊、副标题单行。

P3-2 Enter/Shift+Enter 有测试了:分流抽成纯函数 `composer::binding_for`,
`enter_sends_and_shift_enter_breaks_the_line` 覆盖「发送 / 换行 / 未聚焦不抢 / 其它键交回默认」;
`AgentUi::edit` 里与 fallthrough 等价的冗余 Enter 分支删掉;三档测试改走 `agent.edit(...)`。
P3-4 自证式断言换字面量(320+8+852=1180 等);变异 RESIZE_GUTTER 8→0 ⇒ 6 红(含这条)。
P3-5 两条死文案真用上:8px 拖拽缝有了 tooltip(AgentResize + AgentWidthLabel「当前多宽」)。
P3-6 `on_boot` 的两块逻辑搬进 `update/agent.rs::boot_agent`:update.rs 372→383(+11,见报告逐行)。
P3-7 拖拽层补 on_press/on_exit:窗外松手不再僵住(窗内按下→清态;离窗→收在当前值)。
P3-9 顺手:只有几何/路由会变的消息才重建视图模型(每个字符、每次指针移动不再重建)。

门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / 全文件 ≤400(最大 view.rs 373 之外
conversation.rs 383、update.rs 383)
2026-11-04 00:21:33 +08:00

382 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 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`。
///
/// **三条收尾路径**(复审 P3-7:`mouse_area` 在 `!cursor.is_over` 时直接 return,窗**外**松手
/// 的 `on_release` 永远不来,于是叠层会留在最上层、右栗继续跟着指针缩放):
///
/// | 路径 | 触发 | 语义 |
/// |---|---|---|
/// | `on_release` | 窗内松手 | 正常结束 |
/// | `on_exit` | 指针离开窗口 | 「拖到窗外」= 收在当前值(不是把指针丢掉继续跟) |
/// | `on_press` | 拖拽中再按一次 | 兜底清态(万一上两条都没来,一次点击就能恢复) |
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)
.on_press(Message::DragEnd)
.on_exit(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.rail_width(window_width);
let catalog = Catalog::of(LANG);
let body = container(conversation::view(ui, spec(agent, 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)
.height(Length::Fill)
.into()
}
/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖 + **可读标签**(tooltip)。
///
/// 复审 P3-5:这条 8px 的缝此前没有任何可读文字,而 `AgentResize` / `AgentWidthLabel` 两条 key
/// 在视图里一次没用(只在测试里被断言「不空」)——那是空头支票。现在 tooltip 同时给出
/// 「拖它做什么」(`AgentResize`)与「当前多宽」(`AgentWidthLabel`),两个 key 都真用上了。
fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> 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);
let grab = mouse_area(line)
.interaction(mouse::Interaction::ResizingHorizontally)
.on_press(Message::DragStart);
let tip_style = ui.style(TypeRole::BodySmall);
let pixels = format!("{width:.0}");
let tip = container(column![
text(catalog.text(Key::AgentResize))
.size(tip_style.size_px)
.font(ui.font_of(tip_style))
.color(ui.color(Role::OnSurface)),
text(catalog.render(Key::AgentWidthLabel, &[("width", pixels.as_str())]))
.size(tip_style.size_px)
.font(ui.font_of(tip_style))
.color(ui.color(Role::OnSurfaceVariant)),
])
.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()
});
tooltip(grab, tip, tooltip::Position::Left).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),
}
}