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)
This commit is contained in:
AskaEth committed 2026-11-04 00:21:33 +08:00
1 parent 8db1c5ab16
commit 9253684387
11 files changed
+495 -208

No files matched your search

+58 -24
View File
@@ -149,7 +149,15 @@ pub fn page<'a>(ui: &'a Ui, agent: &'a AgentUi, window_width: f32) -> Element<'a
///
/// 为什么铺满整窗:`mouse_area::on_move` 只在**它自己的边界内**回报坐标,而拖拽时指针一定会离开
/// 那 8px 的缝隙。铺满整窗后 `Point.x` 就是窗口坐标 ⇒ `目标宽 = 窗口宽 − x`。
/// 松手由同一层的 `on_release` 收尾(否则会一直跟着指针走)。
///
/// **三条收尾路径**(复审 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;
@@ -164,6 +172,8 @@ pub fn drag_overlay<'a>(agent: &AgentUi) -> Option<Element<'a, Message>> {
.interaction(mouse::Interaction::ResizingHorizontally)
.on_move(|point| Message::DragTo(point.x))
.on_release(Message::DragEnd)
.on_press(Message::DragEnd)
.on_exit(Message::DragEnd)
.into(),
)
}
@@ -284,33 +294,34 @@ fn unread_text<'a>(ui: &'a Ui, unread: usize, _catalog: Catalog) -> Element<'a,
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::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()
});
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), body]
row![splitter(ui, width, catalog), body]
.width(Length::Shrink)
.height(Length::Fill)
.into()
}
/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖。
fn splitter<'a>(ui: &'a Ui) -> Element<'a, Message> {
/// 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 {
@@ -325,10 +336,33 @@ fn splitter<'a>(ui: &'a Ui) -> Element<'a, Message> {
.height(Length::Fill)
.align_x(Alignment::Center);
mouse_area(line)
let grab = mouse_area(line)
.interaction(mouse::Interaction::ResizingHorizontally)
.on_press(Message::DragStart)
.into()
.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()
}
/// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。