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)
382 lines
15 KiB
Rust
382 lines
15 KiB
Rust
//! # 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),
|
||
}
|
||
}
|