feat(app-iced): M104 AGENTRAIL —— 右栗三档 + 布局数学 + 独立页复用同一会话视图
用户定案(D16):右侧常驻可折叠、允许拆分成独立页、中栗宽度可调(磁铁吸附)。 本 mission 只做壳:三档状态 + 布局数学 + 一份会话视图;真数据仍归 M103,一行 presenter 都不调。 - 右栗三档:Collapsed(48) / Expanded(280~360,默认 320,锚=令牌 ai_panel_width) / Detached(Route::Agent); 会话、草稿、未读在切档时一条不丢(拆出去再收回,话说了一半还在) - 布局数学:中栗 = 窗口 − 右栗 − 拖拽缝(8),下限 472(= 行宽上限 424 + 两侧页边距); 右栗一展开,页面按更窄的宽度重新排版(收缩而不是溢出);拖拽磁铁吸附到 280/320/360,超上限夹紧 - 独立页与右栗共用 conversation::view 这一份实现(输入=纯数据,输出=agent::Message) - 控件:多行输入框(Enter 发送 / Shift+Enter 换行,走 text_editor::key_binding 的 modifiers 分流)、 发送禁用态、流式中断、未读点 + 可读计数、⌄ 收起 / ⇥ 拆出 / ⇤ 收回;Esc 收栗、Tab 焦点环、Enter 激活 - 文案全部进 crates/i18n 的新域 catalog/agent.rs(+ golden 追加 20 行),零硬编码中文 - 取证:ULL_AGENT_RAIL / ULL_AGENT_FIXTURE / ULL_AGENT_WIDTH(截图走产品 bin,标题栏与三键可见) - 消息不走 pages::Message(外壳级视图),走 app::Message::Agent(..) → app/update/agent.rs - 删掉 M68 骨架回落(shell::view + shell/fallback.rs):Route::Agent 现在有真页面 门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / app-iced lib 418 条全绿
This commit is contained in:
1 parent
875ac159ee
commit
c3f20784a8
20 files changed
+2463
-153
No files matched your search
@@ -42,6 +42,12 @@ pub enum Message {
|
||||
WindowHandle(Option<isize>),
|
||||
/// 页面消息(导航 / 启动 / 停止 / 占位动作)。
|
||||
Page(pages::Message),
|
||||
/// **AI 助手栏 / 独立页**的消息(M104:三档状态、输入框、拖拽、键盘)。
|
||||
///
|
||||
/// 为什么不走 `pages::Message`:助手栏是**外壳级视图**(它挂在任何页面右边),不是某一页的
|
||||
/// 消息;塞进页面枚举还要动 `app/update/page_messages.rs` 的穷尽 match(那个文件正被未合的
|
||||
/// M101 改),绕开它就少一处 rebase 面。落点是 `app/update/agent.rs`。
|
||||
Agent(crate::shell::agent::Message),
|
||||
/// 外壳导航轨点选(`src/shell/nav.rs` 的回调仍然发这一条;收到就翻成同一个路由跳转)。
|
||||
///
|
||||
/// M90 起产品界面用 `pages` 里的真 `NavigationRail`,这条变体是给**外壳回退路径**
|
||||
|
||||
@@ -125,6 +125,11 @@ pub struct App {
|
||||
/// 本字段与 `app/view.rs` 都不用动(`settings::States` 只看 `&Groups`,不看任何一组的字段)。
|
||||
/// 放在字段列表**末尾**:后续 mission 若也要往这里加字段,冲突面最小。
|
||||
pub(super) settings: pages::settings::Groups,
|
||||
/// AI 助手栏的 UI 状态(M104):右栗三档 + 展开宽度 + 会话/草稿/未读。
|
||||
///
|
||||
/// 挂在 `App` 上而不是全局:这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,
|
||||
/// 状态跟着 App 走才能 per-app、可测(tower 明确否掉了 `OnceLock<Mutex<..>>`)。
|
||||
pub(super) agent: crate::shell::agent::AgentUi,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -209,6 +214,8 @@ impl App {
|
||||
appearance_prefs,
|
||||
hwnd: None,
|
||||
settings: pages::settings::Groups::default(),
|
||||
// 取证开关 `ULL_AGENT_*`(三态矩阵 / 空会话)在这里落地;不设变量 = 出厂态
|
||||
agent: crate::shell::agent::demo::from_env().unwrap_or_default(),
|
||||
};
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
||||
app.rebuild_ui();
|
||||
@@ -268,7 +275,9 @@ impl App {
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
self.view = super::view::model(&self.state, self.width, self.options.force_state, category);
|
||||
// 右栗占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
|
||||
let width = self.agent.content_width(self.width);
|
||||
self.view = super::view::model(&self.state, width, self.options.force_state, category);
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,19 @@ impl App {
|
||||
window::events().map(|(_, event)| Message::Window(event))
|
||||
};
|
||||
|
||||
Subscription::batch([frames, events])
|
||||
// 键盘(M104):`Esc` 收栗 / `Tab` 走焦点环 / `Enter` 激活焦点键。
|
||||
//
|
||||
// 为什么订阅整条键盘流而不是自己挑键:`iced::keyboard::listen()` 是唯一的全窗键盘入口,
|
||||
// 挑键与「什么状态下该做什么」的解释权放在 `app/update/agent.rs`(一眼看完),
|
||||
// 订阅表只负责搬运。截图通道不开它——D30 要可复现,键盘事件一个都不该进图。
|
||||
let keys = if self.has_screenshot() {
|
||||
Subscription::none()
|
||||
} else {
|
||||
iced::keyboard::listen()
|
||||
.map(|event| Message::Agent(crate::shell::agent::Message::Key(event)))
|
||||
};
|
||||
|
||||
Subscription::batch([frames, events, keys])
|
||||
}
|
||||
|
||||
/// 是否订阅帧事件(= 截图模式要帧数闸门)。
|
||||
|
||||
@@ -203,20 +203,61 @@ fn idle_ticks_do_not_rebuild_the_view_model() {
|
||||
assert!(app.refreshes > before, "导航之后必须重建一次");
|
||||
}
|
||||
/// 视图模型跟着窗口宽度走(交互模式下 600 / 840 两条断点靠 resize 事件生效)。
|
||||
///
|
||||
/// M104 起 `view.window_width` 装的是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝),所以这里按三档
|
||||
/// 各钉一个**字面量**:48 的折叠条与 320 的面板各自的减法都得对。
|
||||
/// (断言里不调 `agent.content_width(..)`——那是同义反复,实现错一处它照样绿。)
|
||||
#[test]
|
||||
fn resizing_the_window_rebuilds_the_view_model() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
|
||||
// 出厂是 Collapsed(48) 档:中栗 = 窗口 − 48
|
||||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||||
iced::Size::new(1180.0, 760.0),
|
||||
)));
|
||||
assert!((app.view.window_width - 1180.0).abs() < f32::EPSILON);
|
||||
assert!(
|
||||
(app.view.window_width - 1132.0).abs() < f32::EPSILON,
|
||||
"1180 − 48(折叠条)= 1132,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
|
||||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||||
iced::Size::new(700.0, 600.0),
|
||||
)));
|
||||
assert!((app.view.window_width - 700.0).abs() < f32::EPSILON);
|
||||
assert!(
|
||||
(app.view.window_width - 652.0).abs() < f32::EPSILON,
|
||||
"700 − 48(折叠条)= 652,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
|
||||
// Expanded(320) 档:中栗 = 窗口 − 320(面板)− 8(拖拽缝)= 852
|
||||
app.agent.expand();
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
(app.view.window_width - 652.0).abs() < f32::EPSILON,
|
||||
"700 上展开档不成立(上限 700 − 88 − 472 − 8 = 132 < 280)⇒ 实际按折叠条 48 算: 700 − 48 = 652,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||||
iced::Size::new(1180.0, 760.0),
|
||||
)));
|
||||
assert!(
|
||||
(app.view.window_width - 852.0).abs() < f32::EPSILON,
|
||||
"1180 − 320 − 8 = 852,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
|
||||
// Detached 档:右栗不占宽,中栗 = 窗口 = 1180
|
||||
app.agent.detach();
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
(app.view.window_width - 1180.0).abs() < f32::EPSILON,
|
||||
"拆出后右栗不占宽:1180 − 0 = 1180,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
// 页面照旧能构图(窄窗退化到单列,不 panic)
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
@@ -40,8 +40,9 @@ 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;
|
||||
use super::{CAPTURE_AT_FRAME, MIN_SIZE, WINDOW_SIZE};
|
||||
|
||||
mod agent;
|
||||
mod download;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
@@ -100,6 +101,7 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
Message::Page(message) => self.on_page(message),
|
||||
Message::Agent(message) => self.on_agent(message),
|
||||
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
|
||||
Message::Select(nav) => {
|
||||
self.state.navigate(route_of(nav));
|
||||
@@ -158,6 +160,16 @@ impl App {
|
||||
// 「重新扫描实例目录 + 全部页面快照」:真 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() {
|
||||
@@ -167,6 +179,16 @@ 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();
|
||||
@@ -209,6 +231,7 @@ impl App {
|
||||
if !self.wants_frames() || self.frames < CAPTURE_AT_FRAME {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match self.window {
|
||||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||
None => Task::none(),
|
||||
@@ -365,6 +388,8 @@ fn env_start_route() -> Option<Route> {
|
||||
"download" => Some(Route::Download),
|
||||
"logs" => Some(Route::Logs),
|
||||
"settings" => Some(Route::Settings),
|
||||
// M104:AI 独立页也要能直达(三态矩阵的 Detached 那张)
|
||||
"agent" => Some(Route::Agent),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! 助手栏消息的落点(M104):三档状态迁移 / 输入 / 拖拽 / 键盘。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! `app/update.rs` 已经贴着 400 行硬上限(M94 也因此把下载页四支拆到 `update/download.rs`)。
|
||||
//! 助手栏有 11 个变体,塞回主文件必破线。任务也只允许主文件加一行 `mod agent;`。
|
||||
//!
|
||||
//! ## 分工:视图态 vs 业务命令
|
||||
//!
|
||||
//! | 消息 | 落到哪 |
|
||||
//! |---|---|
|
||||
//! | `Expand` / `Collapse` / `Dock` / `Escape` | **只改视图态**(`AgentUi`)+ 路由栈(离开/进入 `Route::Agent`) |
|
||||
//! | `Detach` | 视图态 + `Route::Agent`(压栈,`back()` 能回去) |
|
||||
//! | `Edit` / `Submit` / `Stop` | 改视图态:草稿、追加一条占位提示 |
|
||||
//! | `DragStart` / `DragTo` / `DragEnd` | 视图态:吸附宽度 |
|
||||
//! | `Key` / `Tab` / `Activate` | 键盘路径 |
|
||||
//!
|
||||
//! **没有任何 `AppCommand` 派发**:本 mission 不接 AI 后端(M103 的四个签名一行都不调)。
|
||||
//! `Submit` 不假装有回复——它只把草稿记进会话 + 追加一条「占位外壳、没发给模型」的提示,
|
||||
//! `Stop` 同理。真接线时这两个分支各加一次 `dispatch`,视图不用动。
|
||||
|
||||
use iced::keyboard::{key, Key};
|
||||
use iced::Task;
|
||||
|
||||
use unlucky_i18n::{Catalog, Lang};
|
||||
|
||||
use crate::shell::agent::{self, Focus, RailState};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 助手栏消息(`app::Message::Agent(..)` 的唯一落点)。
|
||||
pub(super) fn on_agent(&mut self, message: agent::Message) -> Task<Message> {
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
|
||||
match message {
|
||||
agent::Message::Expand => self.agent.expand(),
|
||||
agent::Message::Collapse | agent::Message::Escape => self.collapse_agent(),
|
||||
agent::Message::Detach => {
|
||||
self.agent.detach();
|
||||
self.state.navigate(Route::Agent);
|
||||
}
|
||||
agent::Message::Dock => {
|
||||
self.agent.dock();
|
||||
self.leave_agent_page();
|
||||
}
|
||||
agent::Message::DragStart => self.agent.drag_start(),
|
||||
agent::Message::DragTo(x) => self.agent.drag_to(self.width, x),
|
||||
agent::Message::DragEnd => self.agent.drag_end(),
|
||||
agent::Message::Edit(action) => self.agent.edit(action),
|
||||
agent::Message::Submit => {
|
||||
if self.agent.submit(catalog) {
|
||||
eprintln!("[agent] submit → 占位(没有接后端)");
|
||||
}
|
||||
}
|
||||
agent::Message::Stop => self.agent.stop(catalog),
|
||||
agent::Message::Tab { shift } => self.agent.step_focus(shift),
|
||||
agent::Message::Activate => self.activate_focused(catalog),
|
||||
agent::Message::Key(event) => return self.on_agent_key(event),
|
||||
}
|
||||
|
||||
// 右栗的宽度/档位都会改「页面能用多宽」,所以视图模型必须重建(不是只重绘)。
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 键盘订阅来的按键(`app/subscription.rs` 把整条键盘事件搬过来,解释权在这一层)。
|
||||
///
|
||||
/// 为什么不让订阅层直接翻成三个枚举:那样 `Escape`/`Tab`/`Enter` 的语义(什么状态下该做什么)
|
||||
/// 会散在订阅表里;这里一眼能看完。
|
||||
fn on_agent_key(&mut self, event: iced::keyboard::Event) -> Task<Message> {
|
||||
let iced::keyboard::Event::KeyPressed { key, modifiers, .. } = event else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let message = match key {
|
||||
Key::Named(key::Named::Escape) => agent::Message::Escape,
|
||||
// 折叠档没有会话动作可巡航,`Tab` 不该被助手栏吃掉
|
||||
Key::Named(key::Named::Tab) if self.agent.rail == RailState::Collapsed => {
|
||||
return Task::none();
|
||||
}
|
||||
Key::Named(key::Named::Tab) => agent::Message::Tab {
|
||||
shift: modifiers.shift(),
|
||||
},
|
||||
// `Activate` 只在软焦点落在**按钮**上时才动手(输入框的 Enter 由
|
||||
// `text_editor::key_binding` 分流成「发送」,不能在这里重复发一次)。
|
||||
Key::Named(key::Named::Enter) => agent::Message::Activate,
|
||||
_ => return Task::none(),
|
||||
};
|
||||
|
||||
self.on_agent(message)
|
||||
}
|
||||
|
||||
/// `Esc` / `⌄`:展开 → 收起;独立页 → 收回右栗并离开 `Route::Agent`。
|
||||
fn collapse_agent(&mut self) {
|
||||
let was_detached = self.agent.rail == RailState::Detached;
|
||||
self.agent.collapse();
|
||||
if was_detached {
|
||||
self.leave_agent_page();
|
||||
}
|
||||
}
|
||||
|
||||
/// 离开 AI 独立页:能回退就回退(`Detach` 是压栈的),否则回实例页。
|
||||
fn leave_agent_page(&mut self) {
|
||||
if self.state.route() != &Route::Agent {
|
||||
return;
|
||||
}
|
||||
if !self.state.back() {
|
||||
self.state.navigate(Route::Instances);
|
||||
}
|
||||
}
|
||||
|
||||
/// 键盘 `Enter`:激活软焦点上的按钮(`Tab` 环的顺序见 [`Focus`])。
|
||||
///
|
||||
/// 焦点在 [`Focus::Composer`] 上时**什么都不做**:那一条路径由输入框自己的 `key_binding`
|
||||
/// 负责(按 `Tab` 走到输入框并不等于真的把 iced 焦点交过去,两次发送会打架)。
|
||||
fn activate_focused(&mut self, catalog: Catalog) {
|
||||
// 鼠标刚点过输入框(或从没按过 `Tab`)⇒ 这个 Enter 不归助手栏管
|
||||
if !self.agent.keyboard_mode {
|
||||
return;
|
||||
}
|
||||
|
||||
let intent = match (self.agent.rail, self.agent.focus) {
|
||||
(RailState::Collapsed, Focus::Collapse) => Some(agent::Message::Expand),
|
||||
(RailState::Expanded, Focus::Collapse) => Some(agent::Message::Collapse),
|
||||
(RailState::Expanded, Focus::Detach) | (RailState::Detached, Focus::Detach) => {
|
||||
Some(if self.agent.rail == RailState::Detached {
|
||||
agent::Message::Dock
|
||||
} else {
|
||||
agent::Message::Detach
|
||||
})
|
||||
}
|
||||
(_, Focus::Send) => Some(agent::Message::Submit),
|
||||
(_, Focus::Stop) => Some(agent::Message::Stop),
|
||||
// 折叠档没有「拆出」、独立页没有「收起」:焦点环按 `Focus::order` 不会落到这两格,
|
||||
// 显式列成 None 只是为了让编译器确认这份 intent 表是穷尽的。
|
||||
(RailState::Collapsed, Focus::Detach)
|
||||
| (RailState::Detached, Focus::Collapse)
|
||||
| (_, Focus::Composer) => None,
|
||||
};
|
||||
|
||||
if let Some(message) = intent {
|
||||
let _ = self.on_agent(message);
|
||||
} else {
|
||||
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
|
||||
eprintln!(
|
||||
"[agent] Enter on focus={} handled by the composer",
|
||||
self.agent.focus.name()
|
||||
);
|
||||
let _ = catalog;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use unlucky_presenter::state::AppState;
|
||||
use crate::app::components::interaction::Interaction;
|
||||
use crate::cli::Screen;
|
||||
use crate::pages::{self, Detail, Meta, Row};
|
||||
use crate::shell::{self, NavItem};
|
||||
use crate::shell::{self, agent, NavItem};
|
||||
|
||||
use super::message::{nav_item_of, Message};
|
||||
use super::state::{log_first_frame, App};
|
||||
@@ -57,6 +57,13 @@ impl App {
|
||||
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(
|
||||
&self.ui,
|
||||
nav_item_of(self.state.route()),
|
||||
@@ -73,19 +80,31 @@ impl App {
|
||||
},
|
||||
)
|
||||
.map(Message::Page),
|
||||
right,
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
)
|
||||
}
|
||||
// `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,
|
||||
)
|
||||
}
|
||||
// `Route::Agent`:AI 独立页还没有页面实现(P1),回退到 M68 的外壳卡(同一套装饰)。
|
||||
None => shell::view(
|
||||
&self.ui,
|
||||
NavItem::Ai,
|
||||
APP_TITLE,
|
||||
NavItem::Ai.label(),
|
||||
"The assistant page is not built yet (P1). Chat lives in the docked panel once \
|
||||
the AI mission lands; this screen is the M68 skeleton fallback.",
|
||||
self.options.force_state,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +114,10 @@ impl App {
|
||||
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
|
||||
///
|
||||
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
|
||||
///
|
||||
/// `width` 是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝,由 `AgentUi::content_width` 算),不是窗口宽:
|
||||
/// 页面布局系统仍按「一个窗口宽」扣左轨与页边距,所以右栗占掉的那一段必须在这里先减掉——
|
||||
/// 否则助手栏一展开,页面还按 1180 排版,内容就顶到面板底下了(任务里的「收缩而不是溢出」)。
|
||||
pub(super) fn model(
|
||||
state: &AppState,
|
||||
width: f32,
|
||||
|
||||
Reference in new issue
Block a user