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

+67 -3
View File
@@ -24,6 +24,7 @@ use iced::Task;
use unlucky_i18n::{Catalog, Lang};
use crate::app::{MIN_SIZE, WINDOW_SIZE};
use crate::shell::agent::{self, Focus, RailState};
use super::*;
@@ -33,6 +34,22 @@ impl App {
pub(super) fn on_agent(&mut self, message: agent::Message) -> Task<Message> {
let catalog = Catalog::of(Lang::En);
// 只有**几何/路由**会变的几支才需要重建视图模型(`pages::Model` 里没有会话内容:
// transcript / draft 全在 `App.agent` 上)。复审 P3-9:以前每个 `Edit`(= 每敲一个字符)
// 和每个 `DragTo`(= 指针频率)都重建一次整张模型,白白克隆 N 个实例的名字。
let geometry_changed = matches!(
message,
agent::Message::Expand
| agent::Message::Collapse
| agent::Message::Detach
| agent::Message::Dock
| agent::Message::DragTo(_)
| agent::Message::DragEnd
| agent::Message::Escape
| agent::Message::Activate
| agent::Message::Key(_)
);
match message {
agent::Message::Expand => self.agent.expand(),
agent::Message::Collapse | agent::Message::Escape => self.collapse_agent(),
@@ -59,12 +76,59 @@ impl App {
agent::Message::Key(event) => return self.on_agent_key(event),
}
// 右栗的宽度/档位都会改「页面能用多宽」,所以视图模型必须重建(不是只重绘)。
self.view_dirty = true;
self.refresh_view();
if geometry_changed {
self.view_dirty = true;
self.refresh_view();
}
Task::none()
}
/// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。
///
/// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变,
/// 而路由还能从三条**别的**路径进 `Route::Agent`——左侧导航第五项、`pages::Message::Navigate`、
/// `ULL_START_ROUTE=agent`。脱钩的后果:从导航进独立页时头部出现「Open assistant」(那是
/// 「展开右栗」的意思)、标题旁挂着不化的未读胶囊、`Esc` 也收不走页面。
///
/// 两条对向规则:
/// - **在**独立页上 ⇒ `Detached`(顺带按「拆出即读」清未读);
/// - **不在**独立页上而档位还是 `Detached`(用户从页上点了别的导航)⇒ 会话收回右栗(`dock()`),
/// 否则会话会「两头都没有」:页走了、栏还是 0 宽。
///
/// 调用点只有一个:`App::refresh_view` 的开头。每一次路由变化都会经过它,于是**模型算宽度时**
/// 与**画头部动作时**读到的永远是同一份档位。
pub(crate) fn sync_agent_route(&mut self) {
let on_page = self.state.route() == &Route::Agent;
match (on_page, self.agent.rail) {
(true, RailState::Detached) | (false, RailState::Collapsed | RailState::Expanded) => {}
(true, _) => self.agent.detach(),
(false, RailState::Detached) => self.agent.dock(),
}
}
/// 启动时助手栏要做的两件事(从 `on_boot` 搬出来:那边只许留一行调用,复审 P3-6)。
///
/// 1. **窗口尺寸 adopt**:唯一真源是 `boot.rs` 开窗时用的那一个(`ULL_WINDOW_SIZE` + 最小尺寸
/// 钳位)。`App::with_presenter` 只认识常量 1180×760,不 adopt 的话验收矩阵开 900 / 1520 的窗
/// 时页面还按 1180 排版(窄窗里内容被裁)。
/// 2. **拆出档直接落到独立页**:`ULL_AGENT_RAIL=detached`(以及将来持久化的偏好)时,会话就在
/// 独立页里,启动后不该停在实例页。
pub(super) fn boot_agent(&mut self) {
let (window_size, _source) = crate::renderer::acceptance::window::window_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height),
);
self.width = window_size.0;
self.height = window_size.1;
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
eprintln!("[agent] rail=detached → Route::Agent");
self.state.navigate(Route::Agent);
// 直达独立页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true;
}
}
/// 键盘订阅来的按键(`app/subscription.rs` 把整条键盘事件搬过来,解释权在这一层)。
///
/// 为什么不让订阅层直接翻成三个枚举:那样 `Escape`/`Tab`/`Enter` 的语义(什么状态下该做什么)