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:
1 parent
8db1c5ab16
commit
9253684387
11 files changed
+495
-208
No files matched your search
@@ -272,6 +272,11 @@ impl App {
|
||||
///
|
||||
/// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。
|
||||
pub(super) fn refresh_view(&mut self) {
|
||||
// **不变式先对齐再算宽度**(M104 / 复审 r1 P2-1):`Route::Agent` ⟺ `RailState::Detached`。
|
||||
// 每一次路由变化都会走到这里,所以它是「档位 ↔ 路由」的唯一点;放在算宽度之前,
|
||||
// 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。
|
||||
self.sync_agent_route();
|
||||
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
|
||||
@@ -19,6 +19,8 @@ use super::state::App;
|
||||
use super::{APP_TITLE, WINDOW_SIZE};
|
||||
use crate::cli::{Options, Screen};
|
||||
use crate::pages;
|
||||
use crate::shell::agent::RailState;
|
||||
use crate::shell::NavItem;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
@@ -249,8 +251,10 @@ fn resizing_the_window_rebuilds_the_view_model() {
|
||||
app.view.window_width
|
||||
);
|
||||
|
||||
// Detached 档:右栗不占宽,中栗 = 窗口 = 1180
|
||||
app.agent.detach();
|
||||
// Detached 档:右栗不占宽,中栗 = 窗口 = 1180。
|
||||
// **必须走路由**(`Select(Ai)`):r1 之后「档位 = Detached」与「路由 = Agent」是一条不变式,
|
||||
// 直接 `agent.detach()` 再手工 refresh 会被 `sync_agent_route` 立刻收回右栗(那正是要的语义)。
|
||||
let _ = app.update(Message::Select(NavItem::Ai));
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
@@ -262,6 +266,67 @@ fn resizing_the_window_rebuilds_the_view_model() {
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
/// **不变式(复审 r1 的 P2-1)**:`Route::Agent` ⟺ `RailState::Detached`。
|
||||
///
|
||||
/// 走的是**真实路由入口**(外壳导航第五项 `Assistant` → `Message::Select(NavItem::Ai)`),不是
|
||||
/// 直接调 `agent.detach()`:r1 复现的 bug 正是「导航进页面时档位没变」,只测 `detach()` 抓不到它。
|
||||
///
|
||||
/// 抓得住:①进独立页后档位 = Detached(页头才会是 `Dock back` 而不是 `Open assistant`/`Pop out`);
|
||||
/// ②未读在进页时清零;③`Esc` 在独立页真的离开(不是只把档位改成折叠);④从页上点别的导航,
|
||||
/// 会话收回右栗(档位不再停在 Detached)。
|
||||
/// 抓不住:按钮在像素上长什么样(那要出图,见 `13/14` 两张)。
|
||||
#[test]
|
||||
fn entering_the_agent_route_detaches_the_rail() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
assert_eq!(app.agent.rail, RailState::Collapsed, "出厂是折叠档");
|
||||
assert_eq!(app.agent.unread, 2, "出厂带两条未读");
|
||||
|
||||
// ① 外壳导航第五项(真实入口)
|
||||
let _ = app.update(Message::Select(NavItem::Ai));
|
||||
assert_eq!(app.state.route(), &Route::Agent);
|
||||
assert_eq!(
|
||||
app.agent.rail,
|
||||
RailState::Detached,
|
||||
"进了独立页,档位必须是 Detached——否则页头会画右栗才有的动作"
|
||||
);
|
||||
assert_eq!(app.agent.unread, 0, "进独立页即读");
|
||||
|
||||
// ③ Esc 真的离开这一页
|
||||
let _ = app.update(Message::Agent(crate::shell::agent::Message::Escape));
|
||||
assert_ne!(app.state.route(), &Route::Agent, "独立页上 Esc 必须离开");
|
||||
assert_eq!(app.agent.rail, RailState::Expanded, "收回到右栗展开档");
|
||||
|
||||
// ④ 从独立页点别的导航:会话收回右栗(档位不能停在 Detached)
|
||||
let _ = app.update(Message::Select(NavItem::Ai));
|
||||
assert_eq!(app.agent.rail, RailState::Detached);
|
||||
let _ = app.update(Message::Select(NavItem::Instances));
|
||||
assert_eq!(app.state.route(), &Route::Instances);
|
||||
assert_eq!(
|
||||
app.agent.rail,
|
||||
RailState::Expanded,
|
||||
"离开独立页 ⇒ 会话收回右栗(否则页走了、栏还是 0 宽)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 同一条不变式的另一个入口:页面层的 `Navigate(Route::Agent)`(右栗里的 `pages::Message` 路径)。
|
||||
#[test]
|
||||
fn the_page_navigation_entry_also_detaches() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
app.agent.expand();
|
||||
assert_eq!(app.agent.rail, RailState::Expanded);
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Agent)));
|
||||
assert_eq!(app.state.route(), &Route::Agent);
|
||||
assert_eq!(app.agent.rail, RailState::Detached);
|
||||
assert_eq!(app.agent.unread, 0);
|
||||
|
||||
// 页面层回退(不是 Dock 消息)也要把档位收回来
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||||
assert_eq!(app.agent.rail, RailState::Expanded);
|
||||
}
|
||||
|
||||
/// 截图模式不读设置文件里的主题(D30 可复现:两台机器出同一张图)。
|
||||
#[test]
|
||||
fn screenshot_mode_ignores_the_settings_file_theme() {
|
||||
|
||||
@@ -40,7 +40,7 @@ use crate::renderer::Kind;
|
||||
|
||||
use super::message::{route_of, Message};
|
||||
use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW};
|
||||
use super::{CAPTURE_AT_FRAME, MIN_SIZE, WINDOW_SIZE};
|
||||
use super::CAPTURE_AT_FRAME;
|
||||
|
||||
mod agent;
|
||||
mod download;
|
||||
@@ -157,19 +157,15 @@ impl App {
|
||||
tasks.push(iced::system::theme().map(Message::SystemTheme));
|
||||
}
|
||||
|
||||
// M104:助手栏的启动尾巴(窗口尺寸 adopt + 「拆出档直接落到独立页」)在 update/agent.rs。
|
||||
// **必须在 RefreshAll 之前**:那次 dispatch 会 `pump → auto_select`,把路由拉到实例详情,
|
||||
// 而 `sync_agent_route` 看到「不在 Agent 页却还是 Detached」就会把档位收回来——顺序反了
|
||||
// 就会出现「`ULL_AGENT_RAIL=detached` 启动后停在实例页」(r2 实测踩到)。
|
||||
self.boot_agent();
|
||||
|
||||
// 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置
|
||||
self.dispatch(AppCommand::RefreshAll);
|
||||
|
||||
// 窗口尺寸的**唯一真源**是 `boot.rs` 开窗时用的那一个(`ULL_WINDOW_SIZE` + 最小尺寸钳位)。
|
||||
// `App::with_presenter` 只认识常量 1180×760,所以这里 adopt 一次:不 adopt 的话,验收矩阵
|
||||
// 开 900 或 1520 的窗时页面还按 1180 排版(图上就是「窄窗里内容被裁掉」)。
|
||||
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;
|
||||
|
||||
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
|
||||
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
|
||||
if let Some(route) = env_start_route() {
|
||||
@@ -179,16 +175,6 @@ impl App {
|
||||
self.auto_selected = true;
|
||||
}
|
||||
|
||||
// M104:三档状态若从「拆出」开始(`ULL_AGENT_RAIL=detached` 或将来持久化的偏好),
|
||||
// 会话就在独立页里——直接落到那一页,别再被「自动选中第一个实例」拉回详情页。
|
||||
if self.agent.rail == crate::shell::agent::RailState::Detached
|
||||
&& self.state.route() != &Route::Agent
|
||||
{
|
||||
eprintln!("[agent] rail=detached → Route::Agent");
|
||||
self.state.navigate(Route::Agent);
|
||||
self.auto_selected = true;
|
||||
}
|
||||
|
||||
self.auto_select();
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
|
||||
@@ -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` 的语义(什么状态下该做什么)
|
||||
|
||||
@@ -44,67 +44,49 @@ impl App {
|
||||
}
|
||||
|
||||
// 产品页走**内容槽位**:外壳(标题栏 / 拖动与缩放热区 / 三个 caption 键 / rail)由
|
||||
// `shell` 画,中间那块才是页面。M90 r2 之前这里用的是 `pages::app`(自带一条**没有任何
|
||||
// 控件**的标题栏留白带),而 `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` /
|
||||
// `Close` 五个消息只在外壳里构造 ⇒ 产品页「拖不动、缩不了、点不到关闭」。
|
||||
// `shell` 画,中间那块才是页面(`DragWindow`/`ResizeStart`/`Minimize`/`ToggleMaximize`/`Close`
|
||||
// 五种消息的构造点只在外壳里,页面树自己画的标题栏拖不动窗口)。
|
||||
//
|
||||
// M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读
|
||||
// 的那份状态本身。M101 r2 起 ③ 走 `page_with_settings`:外观状态 + 各组自己的状态
|
||||
// (`App.settings`)一起递进去,四条填页线加字段不用动这一行。
|
||||
// 的那份状态。M101 r2 起 ③ 走 `page_with_settings`:外观状态 + 各组自己的状态一起递进去。
|
||||
let background =
|
||||
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
|
||||
.map(|element| element.map(Message::Page));
|
||||
// 右栗(M104):拆出档返回 `None`(会话已在独立页里);拖拽期间另有铺满窗口的取指针层。
|
||||
let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
|
||||
|
||||
match pages::Screen::of(self.state.route()) {
|
||||
Some(_) => {
|
||||
let background =
|
||||
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
|
||||
.map(|element| element.map(Message::Page));
|
||||
|
||||
// 右栗(M104):`Detached` 档返回 `None`(会话已经在独立页里);拖拽期间另有一层
|
||||
// 铺满窗口的取指针层,所以两件东西分开传。
|
||||
let right = agent::docked(&self.ui, &self.agent, self.width)
|
||||
.map(|element| element.map(Message::Agent));
|
||||
let overlay =
|
||||
agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
|
||||
|
||||
shell::view_with_background(
|
||||
Some(_) => shell::view_with_background(
|
||||
&self.ui,
|
||||
nav_item_of(self.state.route()),
|
||||
APP_TITLE,
|
||||
background,
|
||||
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
|
||||
pages::page_with_settings(
|
||||
&self.ui,
|
||||
nav_item_of(self.state.route()),
|
||||
APP_TITLE,
|
||||
background,
|
||||
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
|
||||
// `settings` 的所有者是 `App`;这里只借一帧,加/改某一组的字段不用动这行。
|
||||
pages::page_with_settings(
|
||||
&self.ui,
|
||||
&self.view,
|
||||
pages::settings::States {
|
||||
appearance: Some(&self.appearance),
|
||||
groups: &self.settings,
|
||||
},
|
||||
)
|
||||
.map(Message::Page),
|
||||
right,
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
&self.view,
|
||||
pages::settings::States {
|
||||
appearance: Some(&self.appearance),
|
||||
groups: &self.settings,
|
||||
},
|
||||
)
|
||||
}
|
||||
// `Route::Agent`:AI 独立页(M104)。它与右栗**共用**同一个会话视图
|
||||
// (`agent::page` 内部调的是 `conversation::view`),这里只是把会话摆进页面槽位。
|
||||
None => {
|
||||
let background =
|
||||
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
|
||||
.map(|element| element.map(Message::Page));
|
||||
let overlay =
|
||||
agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
|
||||
|
||||
shell::view_with_background(
|
||||
&self.ui,
|
||||
NavItem::Ai,
|
||||
APP_TITLE,
|
||||
background,
|
||||
agent::page(&self.ui, &self.agent, self.width).map(Message::Agent),
|
||||
None,
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
)
|
||||
}
|
||||
.map(Message::Page),
|
||||
agent::docked(&self.ui, &self.agent, self.width)
|
||||
.map(|element| element.map(Message::Agent)),
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
),
|
||||
// `Route::Agent`:独立页与右栗共用同一个 `conversation::view`(`agent::page` 内部调的它)。
|
||||
None => shell::view_with_background(
|
||||
&self.ui,
|
||||
NavItem::Ai,
|
||||
APP_TITLE,
|
||||
background,
|
||||
agent::page(&self.ui, &self.agent, self.width).map(Message::Agent),
|
||||
None,
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user