From c3f20784a8dd2c7d6674edba4f631ec627334f8f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 21:16:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-iced):=20M104=20AGENTRAIL=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=8F=B3=E6=A0=97=E4=B8=89=E6=A1=A3=20+=20?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E6=95=B0=E5=AD=A6=20+=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E9=A1=B5=E5=A4=8D=E7=94=A8=E5=90=8C=E4=B8=80=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户定案(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 条全绿 --- crates/app-iced/src/app/message.rs | 6 + crates/app-iced/src/app/state.rs | 11 +- crates/app-iced/src/app/subscription.rs | 14 +- crates/app-iced/src/app/tests.rs | 45 ++- crates/app-iced/src/app/update.rs | 27 +- crates/app-iced/src/app/update/agent.rs | 154 +++++++ crates/app-iced/src/app/view.rs | 45 ++- .../app-iced/src/pages/agent/conversation.rs | 367 +++++++++++++++++ .../src/pages/agent/conversation/composer.rs | 142 +++++++ crates/app-iced/src/pages/agent/demo.rs | 113 ++++++ crates/app-iced/src/pages/agent/metrics.rs | 176 ++++++++ .../app-iced/src/pages/agent/metrics/tests.rs | 178 ++++++++ crates/app-iced/src/pages/agent/mod.rs | 347 ++++++++++++++++ crates/app-iced/src/pages/agent/state.rs | 376 +++++++++++++++++ crates/app-iced/src/pages/agent/tests.rs | 381 ++++++++++++++++++ crates/app-iced/src/shell/fallback.rs | 75 ---- crates/app-iced/src/shell/mod.rs | 96 ++--- crates/i18n/src/catalog.rs | 3 + crates/i18n/src/catalog/agent.rs | 40 ++ crates/i18n/src/catalog/golden.rs | 20 + 20 files changed, 2463 insertions(+), 153 deletions(-) create mode 100644 crates/app-iced/src/app/update/agent.rs create mode 100644 crates/app-iced/src/pages/agent/conversation.rs create mode 100644 crates/app-iced/src/pages/agent/conversation/composer.rs create mode 100644 crates/app-iced/src/pages/agent/demo.rs create mode 100644 crates/app-iced/src/pages/agent/metrics.rs create mode 100644 crates/app-iced/src/pages/agent/metrics/tests.rs create mode 100644 crates/app-iced/src/pages/agent/mod.rs create mode 100644 crates/app-iced/src/pages/agent/state.rs create mode 100644 crates/app-iced/src/pages/agent/tests.rs delete mode 100644 crates/app-iced/src/shell/fallback.rs create mode 100644 crates/i18n/src/catalog/agent.rs diff --git a/crates/app-iced/src/app/message.rs b/crates/app-iced/src/app/message.rs index 4975ec4..ebe4259 100644 --- a/crates/app-iced/src/app/message.rs +++ b/crates/app-iced/src/app/message.rs @@ -42,6 +42,12 @@ pub enum Message { WindowHandle(Option), /// 页面消息(导航 / 启动 / 停止 / 占位动作)。 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`,这条变体是给**外壳回退路径** diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 6fadc95..56a1eed 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -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>`)。 + 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; } diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index bb6e8f0..15393d6 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -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]) } /// 是否订阅帧事件(= 截图模式要帧数闸门)。 diff --git a/crates/app-iced/src/app/tests.rs b/crates/app-iced/src/app/tests.rs index 7630022..b3dfbeb 100644 --- a/crates/app-iced/src/app/tests.rs +++ b/crates/app-iced/src/app/tests.rs @@ -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(); } diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index a422b1a..970f05f 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -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 { "download" => Some(Route::Download), "logs" => Some(Route::Logs), "settings" => Some(Route::Settings), + // M104:AI 独立页也要能直达(三态矩阵的 Detached 那张) + "agent" => Some(Route::Agent), _ => None, } } diff --git a/crates/app-iced/src/app/update/agent.rs b/crates/app-iced/src/app/update/agent.rs new file mode 100644 index 0000000..5c66640 --- /dev/null +++ b/crates/app-iced/src/app/update/agent.rs @@ -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 { + 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 { + 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; + } + } +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 54584cb..9f6743b 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -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, diff --git a/crates/app-iced/src/pages/agent/conversation.rs b/crates/app-iced/src/pages/agent/conversation.rs new file mode 100644 index 0000000..fcffd88 --- /dev/null +++ b/crates/app-iced/src/pages/agent/conversation.rs @@ -0,0 +1,367 @@ +//! **会话视图组件**:右栗面板与 `Route::Agent` 独立页的**同一份**对话 UI。 +//! +//! ## 输入契约(纯数据入、消息出) +//! +//! ```text +//! Conversation { transcript, draft, busy, unread, blank, rail, host, focus } ──view()──▶ Element +//! ``` +//! +//! - **入**:全部是**借用**的纯数据(`&[Turn]` / `&text_editor::Content` / `bool` / 枚举), +//! 组件自己不持有会话状态、不读环境、不碰 presenter(任务里「不许提前调用 M103 的 API」); +//! - **出**:控件树只发 [`super::Message`](`Collapse` / `Detach` / `Dock` / `Submit` / `Stop` / +//! `Edit` / `Expand`),app 层再包一层 `Message::Agent(..)`。于是同一份视图可以被两个宿主 +//! 摆在不同宽度里,而「点了发生什么」只有一处实现(`app/update/agent.rs`)。 +//! +//! ## 两个宿主只差「外壳」 +//! +//! | 宿主 | 谁调用 | 给的宽度 | 差别 | +//! |---|---|---|---| +//! | [`Host::Rail`] | [`super::rail`](右栗面板) | 280~360(本组件吃 Fill) | 头部动作是 `⌄` 收起 + `⇥` 拆出 | +//! | [`Host::Page`] | [`super::page`](独立页) | 行宽上限 424 | 头部动作是 `⇤` 收回;标题是页级标题 | +//! +//! **判据不是「长得像」而是「同一个函数」**:`tests::both_hosts_render_the_same_conversation` +//! 用源码扫描钉住「两个宿主都只通过 [`view`] 画对话」,`mod.rs` 里不许出现第二份气泡/输入框装配。 +//! +//! ## 控件与可达性(任务第 4 条逐项) +//! +//! | 要求 | 落点 | +//! |---|---| +//! | 输入框多行 | `text_editor`(真多行,不是 `text_input` 的单行) | +//! | Enter 发送 / Shift+Enter 换行 | [`composer`] 的 `key_binding`:`Binding::Custom(Submit)` vs `Binding::Enter`,用 `modifiers.shift()` 分流(**只有输入框聚焦时生效**,不会抢别处的 Enter) | +//! | 发送禁用态 | 草稿空白或流式中 → `Button::disabled`(M3 的 10% 容器 / 38% 内容,不是自创灰色) | +//! | 流式中断 | 只在 `busy` 时出现 `Stop`(`IconKind::Close` + 文字标签) | +//! | 未读点 | 折叠条上的圆点([`super::rail`])+ 面板头部的「N unread」胶囊 | +//! | `⌄` / `⇥` / `⇤` | 头部两个文字按钮,图标只是**前导**,标签一律可读 | +//! | 键盘 | `Esc` 收栗 / `Tab` 走焦点环 / `Enter` 激活焦点键(订阅在 `app/subscription.rs`) | + +use iced::widget::text_editor; +use iced::widget::{column, container, row, rule, scrollable, space, text}; +use iced::{Background, Border, Element, Length, Padding, Theme}; + +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}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::feedback::empty_state::{empty_state, EmptyState}; +use crate::app::components::interaction::Interaction; +use crate::pages::frame; +use crate::theme::Ui; + +use super::state::{Focus, RailState, Speaker, Turn}; +use super::Message; + +mod composer; + +pub(super) use composer::text_role; + +/// 会话视图落在哪种外壳里(只影响头部动作与标题密度,**不影响**对话本体)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Host { + /// 右侧栏(280~360)。 + Rail, + /// 独立页(列宽 ≤424)。 + Page, +} + +/// 会话视图的**输入契约**(见模块文档;全部是借用的纯数据)。 +#[derive(Clone, Copy)] +pub struct Conversation<'a> { + /// 会话内容(零条 = 空会话,画引导空态)。 + pub transcript: &'a [Turn], + /// 输入框内容(iced 的 `text_editor` 自己持有编辑状态)。 + pub draft: &'a text_editor::Content, + /// 是否在流式回复(决定 `Stop` 是否出现、`Send` 是否禁用)。 + pub busy: bool, + /// 未读数(>0 时头部画胶囊)。 + pub unread: usize, + /// 草稿是否空白(发送键的禁用判据,由 `AgentUi::draft_is_blank` 给)。 + pub blank: bool, + /// 三档状态(决定头部画哪组动作)。 + pub rail: RailState, + /// 宿主。 + pub host: Host, + /// 键盘软焦点(`None` = 用户在用鼠标,**不画焦点环**)。 + /// + /// 为什么不直接给 `Focus`:焦点环是键盘巡航的可视证据,鼠标操作时还挂着 10% 状态层会让人 + /// 以为那是一个 tonal 按钮(r1 出图实测)。`AgentUi::keyboard_mode` 就是这道闸。 + pub focus: Option, +} + +/// 画会话视图(**右栗与独立页的唯一实现**)。 +pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> { + let body = if spec.transcript.is_empty() { + empty(ui, catalog) + } else { + transcript(ui, spec, catalog) + }; + + column![ + header(ui, spec, catalog), + divider(ui), + body, + divider(ui), + composer::composer(ui, spec, catalog), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +// ---------------------------------------------------------------- 头部 + +fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> { + let title = row![ + text_role( + ui, + TypeRole::TitleLarge, + true, + catalog.text(Key::AgentTitle), + ui.color(Role::OnSurface), + Length::Shrink, + ), + unread_badge(ui, spec.unread, catalog), + ] + .spacing(ui.space(Space::X2)) + .align_y(iced::Alignment::Center); + + let titles = column![ + title, + text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::AgentSubtitle), + ui.color(Role::OnSurfaceVariant), + Length::Fill, + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + // 独立页的头部与页面同一套(标题在左、动作在右);右栗太窄,动作单独一行,免得挤成两截。 + match spec.host { + Host::Rail => column![ + titles, + row![actions(ui, spec, catalog)] + .width(Length::Fill) + .spacing(ui.space(Space::X2)), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .into(), + Host::Page => row![ + titles, + row![actions(ui, spec, catalog)] + .spacing(ui.space(Space::X2)) + .align_y(iced::Alignment::Center), + ] + .width(Length::Fill) + .align_y(iced::Alignment::Center) + .into(), + } +} + +/// 头部动作:`⌄` 收起 / `⇥` 拆出(停靠时)或 `⇤` 收回(独立页)。 +/// +/// 每个按钮都带**文字标签**(图标只做前导):任务要求「不得只有图标」。焦点环用 +/// `Interaction::Focused`(组件的 10% 状态层),键序见 [`Focus`]。 +fn actions<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> { + let mut items: Vec> = Vec::new(); + + match spec.rail { + RailState::Collapsed => items.push(label_button( + ui, + catalog.text(Key::AgentOpen), + IconKind::ChevronRight, + Some(Message::Expand), + spec.focus == Some(Focus::Collapse), + )), + RailState::Expanded => { + items.push(label_button( + ui, + catalog.text(Key::AgentCollapse), + IconKind::ChevronDown, + Some(Message::Collapse), + spec.focus == Some(Focus::Collapse), + )); + items.push(label_button( + ui, + catalog.text(Key::AgentDetach), + IconKind::Expand, + Some(Message::Detach), + spec.focus == Some(Focus::Detach), + )); + } + RailState::Detached => items.push(label_button( + ui, + catalog.text(Key::AgentDock), + IconKind::ChevronLeft, + Some(Message::Dock), + spec.focus == Some(Focus::Detach), + )), + } + + row(items) + .spacing(ui.space(Space::X2)) + .align_y(iced::Alignment::Center) + .into() +} + +fn label_button<'a>( + ui: &'a Ui, + label: &'a str, + icon: IconKind, + on_press: Option, + focused: bool, +) -> Element<'a, Message> { + let button = match on_press { + Some(message) => Button::new(label, message).leading(icon), + None => Button::disabled(label).leading(icon), + }; + buttons::text(ui, focused_state(button, focused)) +} + +/// 焦点环:键盘走到这一格时叠 10% 的 `Focused` 状态层(组件库的取证状态也是它)。 +fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> Button<'a, Message> { + if focused { + button.state(Interaction::Focused) + } else { + button + } +} + +fn unread_badge<'a>(ui: &'a Ui, unread: usize, catalog: Catalog) -> Element<'a, Message> { + if unread == 0 { + return space::horizontal().into(); + } + + let count = unread.to_string(); + let label = catalog.render(Key::AgentUnread, &[("count", count.as_str())]); + let style = ui.style(TypeRole::LabelMedium); + + container( + text(label) + .size(style.size_px) + .font(ui.font_of(style)) + .color(ui.color(Role::OnSecondaryContainer)), + ) + .padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)])) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::SecondaryContainer))), + border: Border { + radius: ui.radius(ShapeScale::Full, ui.metrics().control_height / 2.0), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +// ---------------------------------------------------------------- 对话本体 + +fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { + container(empty_state( + ui, + EmptyState::new( + IconKind::Ai, + catalog.text(Key::AgentEmptyTitle), + catalog.text(Key::AgentEmptyBody), + ), + )) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn transcript<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> { + let mut list = column![].spacing(ui.space(Space::X3)).width(Length::Fill); + + for turn in spec.transcript { + list = list.push(bubble(ui, turn, catalog)); + } + + scrollable(container(list).padding(Padding::from([ui.space(Space::X1), 0.0]))) + .width(Length::Fill) + .height(Length::Fill) + .style(frame::scroll_style(ui)) + .into() +} + +/// 一条消息:角色小标 + 正文。三种说话人三种容器(用户 = `SecondaryContainer`、 +/// 助手 = 卡片层、提示 = 无容器只留文字),气泡整体右/左对齐。 +fn bubble<'a>(ui: &'a Ui, turn: &'a Turn, catalog: Catalog) -> Element<'a, Message> { + let (role, fill, ink, align) = match turn.speaker { + Speaker::You => ( + Key::AgentRoleYou, + Some(ui.color(Role::SecondaryContainer)), + ui.color(Role::OnSecondaryContainer), + iced::Alignment::End, + ), + Speaker::Assistant => ( + Key::AgentRoleAssistant, + Some(ui.layer(MaterialLayer::Card)), + ui.color(Role::OnSurface), + iced::Alignment::Start, + ), + Speaker::Notice => ( + Key::AgentRoleNotice, + None, + ui.color(Role::OnSurfaceVariant), + iced::Alignment::Start, + ), + }; + + let inner = column![ + text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(role), + ui.color(Role::OnSurfaceVariant), + Length::Fill, + ), + text_role( + ui, + TypeRole::BodyMedium, + false, + turn.text.as_str(), + ink, + Length::Fill, + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + let card = container(inner) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X2), ui.space(Space::X3)])) + .style(move |_theme| container::Style { + background: fill.map(Background::Color), + border: if fill.is_some() { + ui.outlined(ShapeScale::Medium) + } else { + Border::default() + }, + ..container::Style::default() + }); + + container(card).width(Length::Fill).align_x(align).into() +} + +/// 一像素分隔线(`pages::frame::divider` 绑在 `pages::Message` 上,这里要自己的消息类型)。 +fn divider<'a>(ui: &'a Ui) -> Element<'a, Message> { + rule::horizontal::(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, + }) + .into() +} diff --git a/crates/app-iced/src/pages/agent/conversation/composer.rs b/crates/app-iced/src/pages/agent/conversation/composer.rs new file mode 100644 index 0000000..7ccb69f --- /dev/null +++ b/crates/app-iced/src/pages/agent/conversation/composer.rs @@ -0,0 +1,142 @@ +//! 输入区(多行输入框 + 发送/中断):会话视图的**下半截**。 +//! +//! 从 `conversation.rs` 拆出来的理由只有一条:那边贴着 400 行硬上限(AGENTS.md §3-1)。 +//! 拆的位置是「输入区」这一整块 + 一个排版小工具,语义边界与文件边界一致。 +//! +//! ## Enter / Shift+Enter 的分流(任务第 4 条) +//! +//! `text_editor` 的默认绑定把两种 Enter 都折成 `Binding::Enter`,所以必须自己接管 +//! (见 [`composer`] 里的 `key_binding`):`modifiers.shift()` 决定「换行」还是「发送」。 +//! **只在输入框聚焦时生效**——`KeyPress.status` 不是 `Focused` 时原样交回默认绑定, +//! 于是别处的 Enter 不会被助手栏抢走。 + +use iced::widget::text_editor::{self, Binding}; +use iced::widget::{column, row, text, text_editor as editor}; +use iced::{keyboard, Background, Border, Element, Length, Pixels, Theme}; + +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}; + +use crate::app::components::buttons::{self, Button}; +use crate::theme::{Ui, STROKE_PX}; + +use super::super::state::Focus; +use super::super::Message; +use super::{focused_state, Conversation}; + +pub(super) fn composer<'a>( + ui: &'a Ui, + spec: Conversation<'a>, + catalog: Catalog, +) -> Element<'a, Message> { + let style = ui.style(TypeRole::BodyMedium); + + let field = editor(spec.draft) + .placeholder(catalog.text(Key::AgentPlaceholder)) + .on_action(Message::Edit) + .key_binding(|press| { + // Enter = 发送 / Shift+Enter = 换行(任务第 4 条)。 + // + // 走 `key_binding` 而不是全局键盘订阅:`KeyPress` 直接带 `modifiers`,而 iced 的 + // 默认绑定把两种 Enter 都折成 `Binding::Enter`(所以**必须**在这里分流)。 + // 未聚焦时一律交回默认绑定(它自己不响应),于是别处的 Enter 不会被助手栏抢走。 + let enter = matches!( + press.modified_key.as_ref(), + keyboard::Key::Named(keyboard::key::Named::Enter) + ); + if !enter || !matches!(press.status, text_editor::Status::Focused { .. }) { + return Binding::from_key_press(press); + } + if press.modifiers.shift() { + Some(Binding::Enter) + } else { + Some(Binding::Custom(Message::Submit)) + } + }) + .size(style.size_px) + .font(ui.font_of(style)) + .height(Length::Fixed( + ui.space(Space::X8) + ui.space(Space::X8) + ui.space(Space::X1), + )) + .padding(ui.space(Space::X2)) + .style(move |_theme: &Theme, status| { + let focused = matches!(status, text_editor::Status::Focused { .. }); + text_editor::Style { + background: Background::Color(ui.layer(MaterialLayer::Card)), + border: if focused { + Border { + color: ui.color(Role::Primary), + width: STROKE_PX * 2.0, + radius: ui.radius_dp(ShapeScale::Small), + } + } else { + ui.outlined(ShapeScale::Small) + }, + placeholder: ui.color(Role::OnSurfaceVariant), + value: ui.color(Role::OnSurface), + selection: ui.color(Role::PrimaryContainer), + } + }); + + let send_label = catalog.text(Key::AgentSend); + let send = if spec.blank || spec.busy { + Button::disabled(send_label) + } else { + Button::new(send_label, Message::Submit) + }; + // 键盘焦点在「发送」上时用 `Interaction::Focused`(10% 状态层),与头部动作同一套焦点语言。 + let send = focused_state( + send.leading(IconKind::Send), + spec.focus == Some(Focus::Send), + ); + + let mut actions = row![] + .spacing(ui.space(Space::X2)) + .align_y(iced::Alignment::Center); + if spec.busy { + actions = actions.push(buttons::outlined( + ui, + focused_state( + Button::new(catalog.text(Key::AgentStop), Message::Stop).leading(IconKind::Close), + spec.focus == Some(Focus::Stop), + ), + )); + } + actions = actions.push(buttons::filled(ui, send)); + + column![field, actions] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .into() +} + +/// 一档文字(字号/行高/字重来自令牌;`shell::label` 同口径,但这里要自己持消息类型)。 +pub(crate) fn text_role<'a>( + ui: &'a Ui, + role: TypeRole, + emphasized: bool, + content: &'a str, + color: iced::Color, + width: Length, +) -> Element<'a, Message> { + let style = if emphasized { + ui.emphasized(role) + } else { + ui.style(role) + }; + + text(content) + .size(style.size_px) + .line_height(iced::widget::text::LineHeight::Absolute(Pixels( + style.line_height_px, + ))) + .font(ui.font_of(style)) + .color(color) + .width(width) + .into() +} diff --git a/crates/app-iced/src/pages/agent/demo.rs b/crates/app-iced/src/pages/agent/demo.rs new file mode 100644 index 0000000..07c56a8 --- /dev/null +++ b/crates/app-iced/src/pages/agent/demo.rs @@ -0,0 +1,113 @@ +//! 占位样本数据:一段内置会话 + 出图矩阵要用的 `ULL_AGENT_*` 取证开关。 +//! +//! ## 为什么是内置样本而不是 presenter +//! +//! 本 mission 只做**壳**:真数据由 M103(`presenter::agent::AgentRail` + 四个签名)供, +//! 这里一行 presenter 调用都没有(任务里点名「不许提前调用」)。样本的职责只有两个: +//! ①让三档状态在图上都有内容;②让「空会话」有对照([`FIXTURE_ENV`])。 +//! +//! 样本正文是**演示数据**,与 `pages/instances/data.rs` 的 demo 同一性质:它不是界面文案, +//! 不进 i18n 词表(界面上会随真模型一起消失)。界面自己的文案(标题/按钮/空态/提示)全部走 `Key`。 +//! +//! ## 取证开关(只影响这一次进程,语义与 `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 一致) +//! +//! | 变量 | 取值 | 作用 | +//! |---|---|---| +//! | [`RAIL_ENV`] | `collapsed` / `expanded` / `detached` | 启动时直接落到某一档(三态矩阵) | +//! | [`FIXTURE_ENV`] | `empty` / `streaming` / `unread` / `focused` | 空会话(零消息)/ 流式中(中断键可见)/ 展开但带未读(单独看徽标)/ 键盘焦点环(`Tab` 走过一次的样子) | +//! | [`WIDTH_ENV`] | `280` / `320` / `360` | 展开档宽度(磁铁档位的对照图) | + +use super::metrics::PRESETS; +use super::state::{AgentUi, RailState, Speaker, Turn}; + +/// 启动档位。 +pub const RAIL_ENV: &str = "ULL_AGENT_RAIL"; +/// 会话样本(`empty` / `streaming`)。 +pub const FIXTURE_ENV: &str = "ULL_AGENT_FIXTURE"; +/// 展开档宽度(只接受三档预设值)。 +pub const WIDTH_ENV: &str = "ULL_AGENT_WIDTH"; + +/// 内置会话样本(4 条,两类说话人 + 一条工具提示)。 +pub fn transcript() -> Vec { + vec![ + Turn::new(Speaker::You, "Which instance should I start for a 1.20.4 test?"), + Turn::new( + Speaker::Assistant, + "Survival is on 1.20.4 with Fabric 0.16.5 and its files are present. Redstone Lab is the same \ + version but its folder is missing a jar.", + ), + Turn::new(Speaker::You, "Use Survival then. Anything I should know first?"), + Turn::new( + Speaker::Assistant, + "Java 21 is already adopted for that instance, and the last launch exited normally. \ + Say the word and I will queue it.", + ), + ] +} + +/// 按环境变量构造一份状态(没设变量 = `None`,调用方用出厂态)。 +/// +/// 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。 +pub fn from_env() -> Option { + let rail = std::env::var(RAIL_ENV).ok(); + let fixture = std::env::var(FIXTURE_ENV).ok(); + let width = std::env::var(WIDTH_ENV).ok(); + + if rail.is_none() && fixture.is_none() && width.is_none() { + return None; + } + + let fixture = fixture.as_deref().map(str::trim); + let mut ui = match fixture { + Some("empty") => AgentUi::empty(), + _ => AgentUi::new(), + }; + + if let Some(raw) = width.as_deref().map(str::trim) { + match raw.parse::() { + Ok(value) if PRESETS.contains(&value) => ui.set_expanded(value), + _ => eprintln!("[agent] {WIDTH_ENV}={raw:?} 不是预设档位 {PRESETS:?} 之一,忽略"), + } + } + + if fixture == Some("streaming") { + ui.pretend_streaming(); + } + + // 档位走**真迁移**(`expand` / `detach` / `collapse`)而不是直接写字段:这样未读的语义 + // (展开即读)在取证图里也成立,不会出现「展开着还挂着 2 条未读」这种自相矛盾的画面。 + // 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(见模块文档的表)。 + match rail.as_deref().map(str::trim) { + None => ui.expand(), + Some(name) => match RailState::from_name(name) { + Some(RailState::Collapsed) => ui.collapse(), + Some(RailState::Detached) => ui.detach(), + Some(RailState::Expanded) => ui.expand(), + None => eprintln!( + "[agent] {RAIL_ENV}={name:?} 不是三档之一({}),忽略", + RailState::ALL.map(RailState::name).join("/") + ), + }, + } + + if fixture == Some("unread") { + ui.unread = 3; + } + + if fixture == Some("focused") { + // 键盘巡航一次的样子(焦点环 = 10% 状态层)。真窗口里按一次 `Tab` 就会到这一态, + // 但截图通道收不到键盘输入,所以只能这样取证。 + ui.step_focus(false); + } + + eprintln!( + "[agent] rail={} width={} turns={} busy={} unread={}", + ui.rail.name(), + ui.expanded(), + ui.transcript.len(), + ui.busy, + ui.unread + ); + + Some(ui) +} diff --git a/crates/app-iced/src/pages/agent/metrics.rs b/crates/app-iced/src/pages/agent/metrics.rs new file mode 100644 index 0000000..8c09f34 --- /dev/null +++ b/crates/app-iced/src/pages/agent/metrics.rs @@ -0,0 +1,176 @@ +//! 右侧助手栏的**布局数学**:三档宽度、磁铁吸附、中栗下限。纯函数 + 常量,不碰 iced 控件树。 +//! +//! ## 一屏的横向预算 +//! +//! ```text +//! 窗口宽 = 左轨 88 + [中栗] + [拖拽缝 8(仅展开档)] + [右栗] +//! ``` +//! +//! - 右栗宽度 = [`rail_width`]:折叠 48 / 展开 280~360(吸附值)/ 拆出 0; +//! - 中栗宽度 = 窗口 − 右栗 − 拖拽缝([`content_width`],页面层再扣左轨与页边距); +//! - **中栗下限** [`min_content_width`] = 行宽上限 424 + 两侧页边距 48 = **472**: +//! 再窄正文列就装不下一行满宽文,所以拖拽被夹在这里(不是「拖到 0 再说」)。 +//! +//! 「展开时中栗**收缩而不是溢出**」这句话的落点就是 [`content_width`]:页面布局系统仍以 +//! 「一个窗口宽」为输入(它内部扣 rail 与页边距),所以右栗占掉的那一段必须在这里先减掉。 +//! 四个对照宽度的实际数值由 `metrics/tests.rs` 的表钉住(报告里贴的是同一张表)。 +//! +//! ## 三档预设的取值理由(tower 要求写明,不许默默定) +//! +//! | 档位 | 值 | 依据 | +//! |---|---|---| +//! | 折叠 | **48** | `LayoutMetrics::touch_target`:M3 无障碍下限,图标按钮 40 + 未读点要放得下 | +//! | 展开(锚) | **320** | 令牌 `ai_panel_width`:M69 起这个令牌就叫「AI 面板宽度」,改锚等于改令牌语义 | +//! | 展开 ±1 档 | **280 / 360** | 在 4px 栅格上、离锚正好 40(`Space::X8 + Space::X2`);280 = 官方 supporting-pane 侧列下限 240 + 两份 20 内边距,仍能排 40 字符/行 | +//! +//! **不取 420**:900 宽窗上右栗上限只有 340([`max_rail_width`]),420 这个预设永远点不到 = 假档位。 +//! **不取 240**:减去两侧 16 内边距只剩 208,一行不到 35 字符,会话气泡会碎成柱状。 + +use unlucky_design::spacing::{snap_to_grid, LayoutMetrics, Space}; + +use crate::pages::layout; + +/// 折叠态图标条宽 = M3 触控目标 **48**(`LayoutMetrics::touch_target`,不是新字面量)。 +pub const COLLAPSED_WIDTH: f32 = LayoutMetrics::DESKTOP.touch_target; + +/// 展开态面板宽 = 令牌 `ai_panel_width` = **320**。 +pub const DEFAULT_EXPANDED_WIDTH: f32 = LayoutMetrics::DESKTOP.ai_panel_width; + +/// 磁铁档位间距 = 一个页级留白(32)+ 一个小留白(8)= **40**。 +pub const PRESET_STEP: f32 = Space::X8.dp() + Space::X2.dp(); + +/// 三个预设档位:**280 / 320 / 360**(理由见模块文档)。 +pub const PRESETS: [f32; 3] = [ + DEFAULT_EXPANDED_WIDTH - PRESET_STEP, + DEFAULT_EXPANDED_WIDTH, + DEFAULT_EXPANDED_WIDTH + PRESET_STEP, +]; + +/// 磁铁半径(`Space::X6` = 24):离预设档位 24px 以内就吸过去,否则按 4px 栅格落地。 +/// +/// 24 > 档位间距的一半(20),所以 280~360 之间**任何**位置都会被吸到某一档;360 以上到上限 +/// 之间不再有档位,那段才走 4px 栅格(用户想要 500 宽的面板也给)。 +pub const MAGNET_RADIUS: f32 = Space::X6.dp(); + +/// 面板与中栗之间的可拖拽缝隙宽(`Space::X2` = 8)。 +/// +/// 它既是分隔线(1px `OutlineVariant` 居中)也是命中区:不是「看起来细就抓不到」。 +pub const RESIZE_GUTTER: f32 = Space::X2.dp(); + +/// 中栗的最小可用宽度 = 行宽上限 424 + 两侧页边距 24×2 = **472**。 +/// +/// 依据是布局系统自己的判据([`layout::measure_cap_px`]):1180 展开 320 后中栗 **852**, +/// 880(进程最小窗宽)展开 320 后中栗 **552** —— 都还在 472 之上。 +pub fn min_content_width() -> f32 { + layout::measure_cap_px() + 2.0 * Space::X6.dp() +} + +/// 右栗能占的最大宽度:中栗不许低于 [`min_content_width`](拖拽缝也算右栗的开销)。 +/// +/// 下限取 [`COLLAPSED_WIDTH`](窗口再窄也不把右栗夹成负数)。 +pub fn max_rail_width(window_width: f32) -> f32 { + (window_width - layout::rail_width() - min_content_width() - RESIZE_GUTTER).max(COLLAPSED_WIDTH) +} + +/// 展开宽度的合法化:夹到可用范围。低于 280 就吸到 280(不是变细)。 +/// +/// 窗口窄到连 280 都不成立时返回 [`COLLAPSED_WIDTH`]——那时「展开」这件事本身不成立, +/// 画一条 280 的栏会把中栗压到 472 以下。 +pub fn clamp_width(window_width: f32, requested: f32) -> f32 { + let ceiling = max_rail_width(window_width); + if ceiling < PRESETS[0] { + return COLLAPSED_WIDTH; + } + requested.clamp(PRESETS[0], ceiling) +} + +/// 拖拽落点 → 吸附后的右栗宽度(磁铁 → 4px 栅格 → 夹紧)。 +pub fn magnet_width(window_width: f32, raw: f32) -> f32 { + let nearest = PRESETS + .iter() + .copied() + .min_by(|left, right| (left - raw).abs().total_cmp(&(right - raw).abs())) + .unwrap_or(DEFAULT_EXPANDED_WIDTH); + + let settled = if (nearest - raw).abs() <= MAGNET_RADIUS { + nearest + } else { + snap_to_grid(raw) + }; + + clamp_width(window_width, settled) +} + +/// 窗口窄到连最小展开档都放不下时,`Expanded` **事实上**就是折叠档。 +/// +/// 不是「自动帮用户收起」的一时兴起:`clamp_width` 在这种情况下返回的就是 [`COLLAPSED_WIDTH`], +/// 若仍报 `Expanded`,宿主会画一条 48 宽的面板、还白扣 8px 拖拽缝。 +pub fn effective_state( + window_width: f32, + state: crate::shell::agent::RailState, + expanded: f32, +) -> crate::shell::agent::RailState { + use crate::shell::agent::RailState; + match state { + RailState::Expanded if clamp_width(window_width, expanded) <= COLLAPSED_WIDTH => { + RailState::Collapsed + } + other => other, + } +} + +/// 一屏的横向预算(窗口 → 左轨 / 中栗 / 右栗)。纯数据,供单测与日志用。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RailLayout { + /// 窗口宽度(输入)。 + pub window: f32, + /// 左侧导航轨宽度(令牌 `nav_rail_width` = 88)。 + pub left: f32, + /// 右栗面板宽度(0 / 48 / 280~360)。 + pub rail: f32, + /// 右栗前面的拖拽缝宽度(只有展开档有,其余 0)。 + pub gutter: f32, + /// 中栗宽度 = 窗口 − 右栗 − 拖拽缝(页面层再扣左轨与页边距)。 + pub content: f32, +} + +/// 算一屏的横向预算(档位先经 [`effective_state`] 归一)。 +pub fn layout_for( + window_width: f32, + state: crate::shell::agent::RailState, + expanded: f32, +) -> RailLayout { + use crate::shell::agent::RailState; + + let (rail, gutter) = match effective_state(window_width, state, expanded) { + RailState::Collapsed => (COLLAPSED_WIDTH, 0.0), + RailState::Expanded => (clamp_width(window_width, expanded), RESIZE_GUTTER), + RailState::Detached => (0.0, 0.0), + }; + + RailLayout { + window: window_width, + left: layout::rail_width(), + rail, + gutter, + content: (window_width - rail - gutter).max(0.0), + } +} + +/// 右栗面板宽度(`App::view` 与 shell 都用这一个入口,避免两处各算一套)。 +pub fn rail_width(window_width: f32, state: crate::shell::agent::RailState, expanded: f32) -> f32 { + layout_for(window_width, state, expanded).rail +} + +/// **交给页面布局系统的宽度** = 窗口 − 右栗 − 拖拽缝。 +pub fn content_width( + window_width: f32, + state: crate::shell::agent::RailState, + expanded: f32, +) -> f32 { + layout_for(window_width, state, expanded).content +} + +#[cfg(test)] +#[path = "metrics/tests.rs"] +mod tests; diff --git a/crates/app-iced/src/pages/agent/metrics/tests.rs b/crates/app-iced/src/pages/agent/metrics/tests.rs new file mode 100644 index 0000000..3cd7722 --- /dev/null +++ b/crates/app-iced/src/pages/agent/metrics/tests.rs @@ -0,0 +1,178 @@ +//! 布局数学的数值判据(M104 任务第 2 条那张表就长在这里)。 +//! +//! 每条断言都写清「抓得住什么 / 抓不住什么」——纯函数能钉住算式,**钉不住**像素与手感。 + +use crate::pages::layout::{self, PaneKit}; + +use crate::shell::agent::RailState; + +use super::{ + clamp_width, content_width, effective_state, layout_for, magnet_width, max_rail_width, + min_content_width, COLLAPSED_WIDTH, DEFAULT_EXPANDED_WIDTH, MAGNET_RADIUS, PRESETS, + RESIZE_GUTTER, +}; + +/// **布局数学的核心表**(1180 / 1024 / 900 / 700,任务第 2 条点名要的四个宽度)。 +/// +/// 抓得住:右栗占宽之后中栗是**收缩**的(`content = window − rail − gutter`),以及页面布局系统 +/// 拿到的列宽确实跟着变(1180 展开时实例页详情列 424 → 356)。 +/// 抓不住:真实的像素宽度(那要量图),也抓不住「右栗和外框之间有没有缝」这种观感问题。 +#[test] +fn the_column_table_matches_the_mission_widths() { + // (窗口, 档位, 右栗面板, 拖拽缝, 中栗, 实例详情列, 单列页列宽) + let expect = [ + (1180.0, RailState::Detached, 0.0, 0.0, 1180.0, 424.0, 424.0), + ( + 1180.0, + RailState::Collapsed, + 48.0, + 0.0, + 1132.0, + 424.0, + 424.0, + ), + (1180.0, RailState::Expanded, 320.0, 8.0, 852.0, 356.0, 424.0), + (1024.0, RailState::Detached, 0.0, 0.0, 1024.0, 424.0, 424.0), + (1024.0, RailState::Collapsed, 48.0, 0.0, 976.0, 424.0, 424.0), + // 1024 展开:中栗 696 掉到 medium 档 ⇒ 实例页退化成单列(不再两列硬挤) + (1024.0, RailState::Expanded, 320.0, 8.0, 696.0, 424.0, 424.0), + // 900 收起:中栗 852 仍够 list-detail(几何门槛 816),详情列 356 + (900.0, RailState::Collapsed, 48.0, 0.0, 852.0, 356.0, 424.0), + (900.0, RailState::Expanded, 320.0, 8.0, 572.0, 424.0, 424.0), + // 700 < 进程最小窗宽 880:展开档不成立(中栗会被压到 472 以下),退回收起档 + (700.0, RailState::Expanded, 48.0, 0.0, 652.0, 424.0, 424.0), + ]; + + for (window, rail, width, gutter, content, detail, single) in expect { + let laid_out = layout_for(window, rail, DEFAULT_EXPANDED_WIDTH); + assert_eq!( + (laid_out.rail, laid_out.gutter, laid_out.content), + (width, gutter, content), + "{window} / {} 的横向预算不对", + rail.name() + ); + assert_eq!(laid_out.left, layout::rail_width(), "左轨宽度只有一个真源"); + + // 页面布局系统吃的就是这个 content 宽:实例页(list-detail)与单列页各算一次 + let instances = layout::frame_metrics(PaneKit::ListDetail, content); + assert_eq!( + instances.column, + detail, + "{window} / {} 的实例详情列宽不对", + rail.name() + ); + let page = layout::frame_metrics(PaneKit::Single, content); + assert_eq!( + page.column, + single, + "{window} / {} 的单列页列宽不对", + rail.name() + ); + } +} + +/// **不溢出**的硬判据:窗口 ≥ 最小窗宽时,任何档位、任何预设,中栗都不低于 472。 +/// +/// 抓得住:有人把 [`min_content_width`] 调小、或让 [`content_width`] 忘了扣右栗/拖拽缝。 +/// 抓不住:`row!` 的兄弟顺序写错(那要出图看)。 +#[test] +fn the_middle_column_never_drops_below_the_usable_width() { + assert_eq!( + min_content_width(), + 472.0, + "472 = 行宽上限 424 + 两侧页边距 48" + ); + + for window in [880.0, 900.0, 1024.0, 1180.0, 1440.0, 2560.0] { + for rail in RailState::ALL { + for preset in PRESETS { + let width = content_width(window, rail, preset); + assert!( + width >= 472.0, + "{window} / {} / 预设 {preset} 把中栗压到 {width}", + rail.name() + ); + } + } + } + + // 边界:最小窗宽 880 上,展开档被夹到上限 312(880 − 88 − 472 − 8),中栗留 560 + assert_eq!(max_rail_width(880.0), 312.0); + assert_eq!(content_width(880.0, RailState::Expanded, 320.0), 560.0); +} + +/// 拖拽:磁铁吸附 + 夹紧。 +/// +/// 抓得住:①拖动结果落在预设档或 4px 栅格上(不会一像素一像素抖);②离预设 24px 内被吸住; +/// ③往左拖过头会被夹在 [`max_rail_width`],而不是把中栗压扁。 +/// 抓不住:真实指针手感(`mouse_area::on_move` 在真窗口里的采样率只能人试)。 +#[test] +fn the_drag_magnetises_and_refuses_to_squeeze_the_middle() { + // 1180 窗口:上限 = 1180 − 88(左轨)− 472(中栗下限)− 8(拖拽缝)= 612 + assert_eq!(max_rail_width(1180.0), 612.0); + + // 离 320 只有 6px ⇒ 吸到 320 + assert_eq!(magnet_width(1180.0, 326.0), 320.0); + // 两档之间(310):离 320 只有 10px ⇒ 还是吸住 + assert_eq!(magnet_width(1180.0, 310.0), 320.0); + // 360 之上再没有档位 ⇒ 走 4px 栅格 + assert_eq!(magnet_width(1180.0, 500.0), 500.0); + assert_eq!(magnet_width(1180.0, 497.0), 496.0); + // 低于最小档 ⇒ 吸到 280(不是变细) + assert_eq!(magnet_width(1180.0, 120.0), 280.0); + // 超过上限 ⇒ 夹在 612(中栗正好 472) + assert_eq!(magnet_width(1180.0, 900.0), 612.0); + assert_eq!( + content_width(1180.0, RailState::Expanded, 612.0), + 1180.0 - 612.0 - 8.0 + ); + + // 900 窗口:上限 = 900 − 88 − 472 − 8 = 332 ⇒ 360 档被夹到 332 + assert_eq!(max_rail_width(900.0), 332.0); + assert_eq!(magnet_width(900.0, 360.0), 332.0); + // 窄到连 280 都放不下(700):展开档不成立,返回折叠宽 + assert_eq!(clamp_width(700.0, 320.0), COLLAPSED_WIDTH); + assert_eq!( + effective_state(700.0, RailState::Expanded, 320.0), + RailState::Collapsed + ); + assert_eq!( + effective_state(1180.0, RailState::Expanded, 320.0), + RailState::Expanded + ); +} + +/// 三段常量必须互相自洽(改一个忘一个,图上就是「面板 320 但内容按 280 排」)。 +#[test] +fn the_rail_constants_stay_consistent() { + assert_eq!(COLLAPSED_WIDTH, 48.0, "折叠条 = M3 触控目标"); + assert_eq!( + DEFAULT_EXPANDED_WIDTH, 320.0, + "展开档 = 令牌 ai_panel_width" + ); + assert_eq!(PRESETS, [280.0, 320.0, 360.0]); + assert_eq!(RESIZE_GUTTER, 8.0); + assert_eq!(MAGNET_RADIUS, 24.0); + + assert!( + PRESETS.contains(&DEFAULT_EXPANDED_WIDTH), + "锚档必须在预设里" + ); + + // 下面两条用**变量**而不是常量表达式:clippy 的 `assertions_on_constants` 会把 + // 「拿常量断言常量」判成无用断言,而这里断的正是两个常量之间的关系。 + let shortest_rail = COLLAPSED_WIDTH; + let narrowest_panel = PRESETS[0]; + assert!(shortest_rail < narrowest_panel, "折叠比最小展开还窄"); + + // 磁铁半径 > 档位间距的一半 ⇒ 280~360 之间没有「吸不住」的空档 + let magnet = MAGNET_RADIUS; + let half_step = (PRESETS[1] - PRESETS[0]) / 2.0; + assert!(magnet > half_step); + + // 三档名字是取证开关的取值,改名等于改命令行契约 + for rail in RailState::ALL { + assert_eq!(RailState::from_name(rail.name()), Some(rail)); + } + assert_eq!(RailState::from_name("open"), None); +} diff --git a/crates/app-iced/src/pages/agent/mod.rs b/crates/app-iced/src/pages/agent/mod.rs new file mode 100644 index 0000000..e7f53d4 --- /dev/null +++ b/crates/app-iced/src/pages/agent/mod.rs @@ -0,0 +1,347 @@ +//! # 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> { + // 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 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`。 +/// 松手由同一层的 `on_release` 收尾(否则会一直跟着指针走)。 +pub fn drag_overlay<'a>(agent: &AgentUi) -> Option> { + 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) + .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.width(window_width); + + 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() + }); + + row![splitter(ui), body] + .width(Length::Shrink) + .height(Length::Fill) + .into() +} + +/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖。 +fn splitter<'a>(ui: &'a Ui) -> Element<'a, Message> { + let line = container( + rule::vertical::(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); + + mouse_area(line) + .interaction(mouse::Interaction::ResizingHorizontally) + .on_press(Message::DragStart) + .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), + } +} diff --git a/crates/app-iced/src/pages/agent/state.rs b/crates/app-iced/src/pages/agent/state.rs new file mode 100644 index 0000000..bd1fe85 --- /dev/null +++ b/crates/app-iced/src/pages/agent/state.rs @@ -0,0 +1,376 @@ +//! 右栗的三档状态机 + 会话数据 + 键盘焦点环 + [`AgentUi`](挂在 `App` 上的 UI 状态)。 +//! +//! 布局数学(宽度 / 磁铁吸附 / 中栗下限)在 [`super::metrics`];这里只管「状态怎么变」。 +//! +//! ## 三档状态(用户定案 D16) +//! +//! | 档位 | 右栗宽度 | 会话视图在哪 | 切法 | +//! |---|---|---|---| +//! | [`RailState::Collapsed`] | 48(图标条 + 未读点) | 不画 | `⌄` 收起 / `Esc` | +//! | [`RailState::Expanded`] | 280~360(默认 320) | 右栗面板内 | 点图标条 / 收回 | +//! | [`RailState::Detached`] | 0 | `Route::Agent` 独立页 | `⇥` 拆出 | +//! +//! 三档是**同一个会话**的三种呈现:切档只改 [`RailState`] 与路由栈,`transcript` / `draft` / +//! `busy` / `unread` 一条都不丢(「拆出去再接回来,话说了一半还在」)。 +//! +//! ## 为什么状态是 `App` 的字段 +//! +//! 这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,状态挂在 `App` 上最自然。 +//! 早先想过 `OnceLock>`(当时 `App` 字段不在 scope),tower 明确否掉:那要测试串行化、 +//! 而且不是 per-App。 +//! +//! **持久化欠账**:`presenter::prefs::UiPrefs` 里的键不在本 mission 的 scope,所以三档与吸附宽度 +//! **只在本次进程内有效**(重启回到折叠 / 320)。接手点:`UiPrefs` 加两个键 + `App::new` 读一次, +//! 本文件的 [`AgentUi::rail`] / [`AgentUi::set_expanded`] 就是那两个落点。 + +use iced::widget::text_editor; +use unlucky_i18n::{Catalog, Key}; + +use super::metrics::{ + content_width, effective_state, magnet_width, rail_width, DEFAULT_EXPANDED_WIDTH, +}; + +/// 三档状态(顺序 = 展开程度,测试与文件名按它遍历)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RailState { + /// 48px 图标条。 + Collapsed, + /// 280~360 的会话面板。 + Expanded, + /// 收进 `Route::Agent` 独立页(右栗消失)。 + Detached, +} + +impl RailState { + /// 三档全序(截图矩阵按它铺)。 + pub const ALL: [RailState; 3] = [ + RailState::Collapsed, + RailState::Expanded, + RailState::Detached, + ]; + + /// 稳定名字(文件名 / `ULL_AGENT_RAIL` 的取值 / 失败信息)。 + pub const fn name(self) -> &'static str { + match self { + RailState::Collapsed => "collapsed", + RailState::Expanded => "expanded", + RailState::Detached => "detached", + } + } + + /// 反向解析(取证开关用)。 + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|state| state.name() == name) + } +} + +/// 会话里的一个说话人。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Speaker { + /// 用户。 + You, + /// 助手(占位样本数据;真模型由 M103 的 presenter 供)。 + Assistant, + /// 系统提示(占位说明 / 中断记录)。 + Notice, +} + +/// 一条会话消息(**纯数据**:视图只读它,不改它)。 +#[derive(Debug, Clone)] +pub struct Turn { + /// 说话人。 + pub speaker: Speaker, + /// 正文(多行;空会话时不会有 `Turn`)。 + pub text: String, +} + +impl Turn { + /// 造一条。 + pub fn new(speaker: Speaker, text: impl Into) -> Self { + Self { + speaker, + text: text.into(), + } + } +} + +/// 键盘软焦点顺序(Tab 环)。顺序 = 屏幕上的阅读顺序:头部动作 → 输入区动作 → 输入框。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + /// `⌄` 收起。 + Collapse, + /// `⇥` 拆出(独立页上则是 `⇤` 收回,同一个焦点位)。 + Detach, + /// 发送。 + Send, + /// 中断(只在流式中出现)。 + Stop, + /// 输入框(真实焦点由 iced 的 `text_editor` 持有,这里只记「轮到它了」)。 + Composer, +} + +impl Focus { + /// 稳定名字(日志 / 测试)。 + pub const fn name(self) -> &'static str { + match self { + Focus::Collapse => "collapse", + Focus::Detach => "detach", + Focus::Send => "send", + Focus::Stop => "stop", + Focus::Composer => "composer", + } + } + + /// Tab 环上「这一档真正存在的控件」(流式中才多一个中断键;折叠档没有会话动作)。 + fn order(state: RailState, busy: bool) -> Vec { + let mut order = Vec::new(); + match state { + RailState::Collapsed => order.push(Focus::Collapse), + RailState::Expanded => { + order.push(Focus::Collapse); + order.push(Focus::Detach); + } + RailState::Detached => order.push(Focus::Detach), + } + if state != RailState::Collapsed { + order.push(Focus::Send); + if busy { + order.push(Focus::Stop); + } + order.push(Focus::Composer); + } + order + } + + /// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。 + pub fn step(self, shift: bool, state: RailState, busy: bool) -> Focus { + let order = Self::order(state, busy); + let Some(at) = order.iter().position(|focus| *focus == self) else { + return if shift { order.last() } else { order.first() } + .copied() + .unwrap_or(Focus::Collapse); + }; + let next = if shift { + (at + order.len() - 1) % order.len() + } else { + (at + 1) % order.len() + }; + order[next] + } +} + +/// 助手栏的 UI 状态(挂在 `App` 上;**唯一**的写入口是 `app/update/agent.rs`)。 +pub struct AgentUi { + /// 三档状态。 + pub rail: RailState, + /// 展开档的吸附宽度(三档共享一个值:收起再展开回到同一宽度)。 + expanded: f32, + /// 会话内容(占位样本数据,真模型由 M103 接)。 + pub transcript: Vec, + /// 输入框(多行;iced 的 `text_editor` 自己持有编辑状态,这里只放内容)。 + pub draft: text_editor::Content, + /// 是否在流式回复(`false` = 没有中断键、发送键可用)。 + pub busy: bool, + /// 未读数(折叠条画点 + 一行可读文字;展开/拆出即清零)。 + pub unread: usize, + /// 键盘软焦点。 + pub focus: Focus, + /// 焦点环是否「被键盘走过」(`Tab` 置 true,鼠标点输入框 / 换档置 false)。 + /// + /// 为什么需要它:`Enter` 全局订阅与输入框自己的 `key_binding` 共用同一个键。若不加这道闸, + /// 用户在**别的页面**用鼠标点过发送键之后,随手按 Enter 也会把草稿发出去。只有真的按过 + /// `Tab`(= 用户在用键盘巡航助手栏)才让 `Activate` 生效。 + pub keyboard_mode: bool, + /// 拖拽中的指针 x(`Some` = 正在拖)。 + pointer: Option, +} + +impl AgentUi { + /// 出厂态:**折叠** + 一段占位样本会话 + 2 条未读。 + /// + /// 为什么默认折叠:1180 下展开 320 会把实例页挤成单列(中栗 852 < 816 的两列门槛之外还够, + /// 但 1024 就掉档了),用户第一次打开启动器不该先被一个助手栏改掉主页面版面。 + pub fn new() -> Self { + Self { + rail: RailState::Collapsed, + expanded: DEFAULT_EXPANDED_WIDTH, + transcript: super::demo::transcript(), + draft: text_editor::Content::new(), + busy: false, + unread: 2, + focus: Focus::Composer, + keyboard_mode: false, + pointer: None, + } + } + + /// 空会话(出图矩阵的「零消息」那一张:清空 transcript 与未读)。 + pub fn empty() -> Self { + let mut ui = Self::new(); + ui.transcript.clear(); + ui.unread = 0; + ui + } + + /// 当前展开宽度(吸附后的值)。 + pub fn expanded(&self) -> f32 { + self.expanded + } + + /// 直接指定展开宽度(只给取证开关用:三档预设值之一)。 + pub fn set_expanded(&mut self, width: f32) { + self.expanded = width; + } + + /// 是否正在拖拽(shell 据此铺一层取指针的透明层)。 + pub fn is_dragging(&self) -> bool { + self.pointer.is_some() + } + + /// 这一窗宽下**实际**生效的档位(窄窗里 `Expanded` 会退化成 `Collapsed`)。 + pub fn effective_rail(&self, window_width: f32) -> RailState { + effective_state(window_width, self.rail, self.expanded) + } + + /// 右栗当前宽度(窗口宽度是判据之一:窗口变窄时展开档会被夹住)。 + pub fn width(&self, window_width: f32) -> f32 { + rail_width(window_width, self.rail, self.expanded) + } + + /// **交给页面布局系统的宽度**(窗口 − 右栗 − 拖拽缝)。 + /// + /// `refresh_view` 用它算视图模型:面板展开后页面按更窄的宽度排版,内容才会「收缩」而不是 + /// 顶到面板底下。 + pub fn content_width(&self, window_width: f32) -> f32 { + content_width(window_width, self.rail, self.expanded) + } + + /// 收起(`⌄` / `Esc`)。未读不清零:收起后新消息仍要能显示成点。 + pub fn collapse(&mut self) { + self.keyboard_mode = false; + if self.rail == RailState::Expanded { + self.rail = RailState::Collapsed; + self.focus = Focus::Collapse; + } else if self.rail == RailState::Detached { + self.dock(); + } + } + + /// 展开(点图标条)。展开即视为读过:未读清零(与主流聊天客户端的「打开即读」同口径)。 + pub fn expand(&mut self) { + self.keyboard_mode = false; + self.rail = RailState::Expanded; + self.unread = 0; + self.focus = Focus::Collapse; + } + + /// 拆成独立页(`⇥`)。会话状态一条不动。 + pub fn detach(&mut self) { + self.keyboard_mode = false; + self.rail = RailState::Detached; + self.unread = 0; + self.focus = Focus::Detach; + } + + /// 收回右栗(`⇤` / 独立页上的 `Esc`)。 + pub fn dock(&mut self) { + self.keyboard_mode = false; + self.rail = RailState::Expanded; + self.unread = 0; + self.focus = Focus::Collapse; + } + + /// 输入框内容变化(`text_editor::Action` 由 app 层转过来)。 + pub fn edit(&mut self, action: text_editor::Action) { + use iced::widget::text_editor::{Action, Edit}; + + // Enter:**发送**(`Shift+Enter` 才换行)。这条语义在 `conversation::composer` 的 + // `key_binding` 里已经分好流,走到这里的 `Enter` 只可能是「换行」那一支。 + if let Action::Edit(Edit::Enter) = action { + self.draft.perform(Action::Edit(Edit::Enter)); + return; + } + if matches!(action, Action::Click(_)) { + // 鼠标点了输入框 = 用户切回鼠标,键盘巡航结束(见 `keyboard_mode` 的理由) + self.focus = Focus::Composer; + self.keyboard_mode = false; + } + self.draft.perform(action); + } + + /// 发送:把草稿记成一条「你」的消息 + 一条占位说明。 + /// + /// **不接后端**(M103 的 presenter API 不在本 mission 里调用):所以既不改 `busy`, + /// 也不假装有回复——只追加一条**说明自己没接模型**的提示。返回是否真的发了。 + pub fn submit(&mut self, catalog: Catalog) -> bool { + let text = self.draft.text(); + let trimmed = text.trim(); + if trimmed.is_empty() || self.busy { + return false; + } + + self.transcript + .push(Turn::new(Speaker::You, trimmed.to_owned())); + self.transcript.push(Turn::new( + Speaker::Notice, + catalog.text(Key::AgentNotePending), + )); + self.draft = text_editor::Content::new(); + true + } + + /// 中断流式回复(`Stop`)。占位期只在**视图态**上停:记一条提示,不派任何业务命令。 + pub fn stop(&mut self, catalog: Catalog) { + if !self.busy { + return; + } + self.busy = false; + self.transcript.push(Turn::new( + Speaker::Notice, + catalog.text(Key::AgentNoteStopped), + )); + } + + /// 一键置为「流式中」(出图矩阵的 `streaming` 态与 `Stop` 键要靠它;真流式由 M103 驱动)。 + pub fn pretend_streaming(&mut self) { + self.busy = true; + self.rail = RailState::Expanded; + self.unread = 0; + } + + /// 输入框是否为空(发送键的禁用判据)。 + pub fn draft_is_blank(&self) -> bool { + self.draft.text().trim().is_empty() + } + + /// 拖拽开始(按下分隔条)。`0.0` 只是占位:第一次 `drag_to` 就会换成真实指针位置。 + pub fn drag_start(&mut self) { + self.pointer = Some(0.0); + } + + /// 拖拽中:`x` = 指针在**窗口**里的横坐标(0 = 窗口左缘)。 + pub fn drag_to(&mut self, window_width: f32, x: f32) { + if self.pointer.is_none() { + return; + } + self.pointer = Some(x); + self.expanded = magnet_width(window_width, window_width - x); + } + + /// 拖拽结束(松手)。 + pub fn drag_end(&mut self) { + self.pointer = None; + } + + /// Tab 走一格(走一次就把键盘巡航打开)。 + pub fn step_focus(&mut self, shift: bool) { + self.focus = self.focus.step(shift, self.rail, self.busy); + self.keyboard_mode = true; + } +} + +impl Default for AgentUi { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/app-iced/src/pages/agent/tests.rs b/crates/app-iced/src/pages/agent/tests.rs new file mode 100644 index 0000000..91329f1 --- /dev/null +++ b/crates/app-iced/src/pages/agent/tests.rs @@ -0,0 +1,381 @@ +//! 三档状态机、两个宿主、a11y 文案的判据(数值表与磁铁在 `metrics/tests.rs`)。 +//! +//! 每条断言都写清「抓得住什么 / 抓不住什么」——纯函数与源码扫描能钉住语义,**钉不住**像素。 + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; +use unlucky_i18n::{Catalog, Key, Lang}; + +use crate::theme::{Families, Ui}; + +use super::conversation::{Conversation, Host}; +use super::metrics::{layout_for, PRESETS, RESIZE_GUTTER}; +use super::state::{AgentUi, Focus, RailState, Speaker, Turn}; +use super::*; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui() -> Ui { + Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) +} + +/// 三档迁移:会话、草稿、未读的语义。 +/// +/// 抓得住:拆出去再收回,话说了一半还在(transcript + draft 不丢);未读只在收起态累积。 +/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看)。 +#[test] +fn the_three_states_share_one_conversation() { + let mut agent = AgentUi::new(); + assert_eq!(agent.rail, RailState::Collapsed, "出厂是折叠档"); + assert_eq!(agent.unread, 2, "出厂带两条未读(折叠条上要有点)"); + + agent.expand(); + assert_eq!(agent.rail, RailState::Expanded); + assert_eq!(agent.unread, 0, "展开即读"); + + agent.draft = iced::widget::text_editor::Content::with_text("kick off Survival"); + agent.draft.perform(iced::widget::text_editor::Action::Edit( + iced::widget::text_editor::Edit::Enter, + )); + assert_eq!( + agent.draft.line_count(), + 2, + "多行:Enter 的编辑动作能落进草稿" + ); + + let turns = agent.transcript.len(); + let draft = agent.draft.text(); + agent.detach(); + assert_eq!(agent.rail, RailState::Detached); + assert_eq!(agent.transcript.len(), turns, "拆出去不丢会话"); + assert_eq!(agent.draft.text(), draft, "拆出去不丢草稿"); + + agent.dock(); + assert_eq!(agent.rail, RailState::Expanded); + assert_eq!(agent.draft.text(), draft); + + // 收起 / Esc:只改档位,不清未读记录(下一批消息仍会点亮) + agent.collapse(); + assert_eq!(agent.rail, RailState::Collapsed); + // 独立页上的 Esc = 收回(`collapse` 对 Detached 等价于 dock) + agent.rail = RailState::Detached; + agent.collapse(); + assert_eq!(agent.rail, RailState::Expanded); +} + +/// 发送 / 中断:**只做视图态**(没有后端),但绝不静默——两条都会在会话里留一条提示。 +#[test] +fn submit_and_stop_leave_a_visible_notice() { + let catalog = Catalog::of(Lang::En); + let mut agent = AgentUi::new(); + agent.expand(); + + assert!(agent.draft_is_blank()); + assert!( + !agent.submit(catalog), + "空草稿发不出去(发送键同时是禁用态)" + ); + + agent.draft = iced::widget::text_editor::Content::with_text(" hello "); + let before = agent.transcript.len(); + assert!(agent.submit(catalog)); + assert_eq!(agent.transcript.len(), before + 2, "一条「你」+ 一条提示"); + assert_eq!(agent.transcript[before].speaker, Speaker::You); + assert_eq!(agent.transcript[before].text, "hello", "两侧空白被剪掉"); + assert_eq!(agent.transcript[before + 1].speaker, Speaker::Notice); + assert_eq!( + agent.transcript[before + 1].text, + catalog.text(Key::AgentNotePending) + ); + assert!(agent.draft_is_blank(), "发出去后草稿清空"); + + // 流式中:发送键禁用、中断键出现;中断也留一条提示 + agent.pretend_streaming(); + assert!(agent.busy); + agent.draft = iced::widget::text_editor::Content::with_text("second"); + assert!(!agent.submit(catalog), "流式中不许再发"); + let before = agent.transcript.len(); + agent.stop(catalog); + assert!(!agent.busy); + assert_eq!(agent.transcript.len(), before + 1); + assert_eq!( + agent.transcript[before].text, + catalog.text(Key::AgentNoteStopped) + ); + + // 没在流式时按中断是 no-op(不刷屏) + let before = agent.transcript.len(); + agent.stop(catalog); + assert_eq!(agent.transcript.len(), before); +} + +/// 空会话:零消息 + 一句引导文案(任务第 7 条)。 +#[test] +fn the_empty_session_has_a_guidance_line() { + let empty = AgentUi::empty(); + assert!(empty.transcript.is_empty(), "空会话 = 零消息"); + assert_eq!(empty.unread, 0); + + let catalog = Catalog::of(Lang::En); + assert!(!catalog.text(Key::AgentEmptyTitle).is_empty()); + assert!( + catalog.text(Key::AgentEmptyBody).split_whitespace().count() >= 5, + "空态要有一句能读懂下一步的引导,而不是一个词" + ); + // 中文也得有且不同(D28:两套目录一行一个 key,缺译会红) + let zh = Catalog::of(Lang::ZhCn); + assert_ne!( + zh.text(Key::AgentEmptyBody), + catalog.text(Key::AgentEmptyBody) + ); +} + +/// **单实现判据**:右栗与独立页都只通过 `conversation::view` 画对话。 +/// +/// 抓得住:有人把「独立页要更宽」当成理由另写一份气泡/输入框装配(复制粘贴是这类任务最常见的坏法)。 +/// 抓不住:`conversation::view` **内部**再分叉成两套(那要人读那个文件)。 +#[test] +fn both_hosts_render_the_same_conversation() { + let hosts = include_str!("mod.rs"); + let conversation = include_str!("conversation.rs"); + let composer = include_str!("conversation/composer.rs"); + + assert_eq!( + conversation.matches("pub fn view<").count(), + 1, + "会话视图只能有一个 `view`" + ); + assert_eq!( + hosts.matches("conversation::view(").count(), + 2, + "右栗与独立页各调用一次同一个 `conversation::view`(不多不少)" + ); + assert!( + !hosts.contains("text_editor("), + "输入框的装配只许在 conversation/composer.rs 里(宿主不许自己拼一份)" + ); + assert!( + !hosts.contains("scrollable("), + "滚动区的装配只许在 conversation.rs 里" + ); + assert!( + composer.contains("text_editor(") || composer.contains("editor(spec.draft)"), + "输入框就在 composer 里" + ); +} + +/// a11y:所有动作按钮的标签都来自词表,折叠条也不是「只有图标」。 +/// +/// 抓得住:有人写死一句英文/中文在视图里("Cancel"/"发送"),或给折叠条只留一个图标按钮。 +/// 抓不住:译文本身好不好读(那要人看)。 +#[test] +fn every_action_has_a_readable_label() { + let catalog = Catalog::of(Lang::En); + for key in [ + Key::AgentCollapse, + Key::AgentDetach, + Key::AgentDock, + Key::AgentOpen, + Key::AgentSend, + Key::AgentStop, + Key::AgentStripLabel, + Key::AgentResize, + ] { + assert!( + !catalog.text(key).trim().is_empty(), + "{} 没有可读标签", + key.name() + ); + } + + for source in [ + include_str!("conversation.rs"), + include_str!("conversation/composer.rs"), + include_str!("mod.rs"), + ] { + for literal in ["Button::new(\"", "Button::disabled(\""] { + assert!( + !source.contains(literal), + "按钮标签不许写死在视图里:{literal}" + ); + } + assert!( + !source.contains("text(\"Assistant\""), + "文案不许写死在视图里(走 i18n 的 Key)" + ); + } + + // 折叠条的 48px 条上,图标下面必须有文字标签(否则就是「只有图标」) + assert!( + include_str!("mod.rs").contains("Key::AgentStripLabel"), + "折叠条必须有可读的条上标签" + ); +} + +/// Tab 环:只在**真正存在**的控件之间走,且顺序自左向右可预期。 +#[test] +fn the_focus_ring_walks_the_controls_that_exist() { + // 展开且空闲:收起 → 拆出 → 发送 → 输入框(没有中断键) + let mut focus = Focus::Collapse; + let mut walked = vec![focus]; + for _ in 0..4 { + focus = focus.step(false, RailState::Expanded, false); + walked.push(focus); + } + assert_eq!( + walked, + vec![ + Focus::Collapse, + Focus::Detach, + Focus::Send, + Focus::Composer, + Focus::Collapse, + ] + ); + + // 反向:Collapse 往回走是 Composer + assert_eq!( + Focus::Collapse.step(true, RailState::Expanded, false), + Focus::Composer + ); + + // 流式中多一档中断键 + assert_eq!( + Focus::Send.step(false, RailState::Expanded, true), + Focus::Stop + ); + + // 独立页只有「收回 + 输入区」 + assert_eq!( + Focus::Detach.step(false, RailState::Detached, false), + Focus::Send + ); + + // 折叠档没有会话动作:环上只有「展开」一格 + assert_eq!( + Focus::Collapse.step(false, RailState::Collapsed, false), + Focus::Collapse + ); + + // `Tab` 真的把键盘巡航打开(`Enter` 的闸门) + let mut agent = AgentUi::new(); + assert!(!agent.keyboard_mode); + agent.step_focus(false); + assert!(agent.keyboard_mode); +} + +/// 两个宿主都能构图(含空会话 / 折叠 / 流式中),且能吃 880~2560 的窗口宽度。 +/// +/// 抓得住:某档位下 `view` 的某条分支 panic(例如空 transcript + 空未读)。 +/// 抓不住:像素级观感(出图另算)。 +#[test] +fn every_state_builds_in_both_hosts() { + let ui = ui(); + let catalog = Catalog::of(Lang::En); + + for window in [880.0, 900.0, 1180.0, 2560.0] { + for rail in RailState::ALL { + for empty in [false, true] { + let mut agent = if empty { + AgentUi::empty() + } else { + AgentUi::new() + }; + agent.rail = rail; + let _: iced::Element<'_, Message> = page(&ui, &agent, window); + + let spec = Conversation { + transcript: &agent.transcript, + draft: &agent.draft, + busy: agent.busy, + unread: agent.unread, + blank: agent.draft_is_blank(), + rail: agent.rail, + host: Host::Rail, + focus: agent.keyboard_mode.then_some(agent.focus), + }; + let _: iced::Element<'_, Message> = conversation::view(&ui, spec, catalog); + let _ = docked(&ui, &agent, window); + } + } + } +} + +/// 拖拽层只在拖拽期间存在(否则它会吃掉整窗的点击)。 +#[test] +fn the_drag_overlay_only_exists_while_dragging() { + let mut agent = AgentUi::new(); + agent.expand(); + assert!(drag_overlay(&agent).is_none()); + + agent.drag_start(); + assert!(agent.is_dragging()); + assert!(drag_overlay(&agent).is_some()); + + agent.drag_end(); + assert!(drag_overlay(&agent).is_none()); +} + +/// 拆出档右栗必须**不占地**(否则会话会在两个地方各画一份)。 +#[test] +fn the_detached_state_frees_the_rail_slot() { + let ui = ui(); + let mut agent = AgentUi::new(); + agent.detach(); + assert!(docked(&ui, &agent, 1180.0).is_none()); + assert_eq!(agent.width(1180.0), 0.0); + assert_eq!(agent.content_width(1180.0), 1180.0); +} + +/// 拖拽把「展开宽度」写进状态(松手后保有该宽度)。 +#[test] +fn the_drag_writes_back_the_snapped_width() { + let mut agent = AgentUi::new(); + agent.expand(); + agent.drag_start(); + agent.drag_to(1180.0, 1180.0 - 280.0); + agent.drag_end(); + assert_eq!(agent.expanded(), 280.0); + assert!(!agent.is_dragging()); + + // 没按下就收到的 DragTo(例如上一步松手后的残留)不该改宽度 + let before = agent.expanded(); + agent.drag_to(1180.0, 100.0); + assert_eq!(agent.expanded(), before); + + // 三档预设都能被拖到(1180 下上限 612 > 360) + for preset in PRESETS { + agent.drag_start(); + agent.drag_to(1180.0, 1180.0 - preset); + agent.drag_end(); + assert_eq!(agent.expanded(), preset); + } +} + +/// 面板与中栗之间的缝在布局里被真的留出来了(宽度常量与 `layout_for` 同源)。 +#[test] +fn the_resize_gutter_is_part_of_the_budget() { + let mut agent = AgentUi::new(); + agent.expand(); + assert_eq!( + agent.width(1180.0) + RESIZE_GUTTER, + 1180.0 - agent.content_width(1180.0) + ); + + // 折叠与拆出档没有缝 + let collapsed = layout_for(1180.0, RailState::Collapsed, 320.0); + assert_eq!(collapsed.gutter, 0.0); + let detached = layout_for(1180.0, RailState::Detached, 320.0); + assert_eq!(detached.gutter, 0.0); +} + +/// 会话数据结构不许在手边偷偷长字段(`Turn` 只有说话人 + 正文)。 +#[test] +fn a_turn_is_only_a_speaker_and_a_body() { + let turn = Turn::new(Speaker::You, "hi"); + assert_eq!((turn.speaker, turn.text.as_str()), (Speaker::You, "hi")); +} diff --git a/crates/app-iced/src/shell/fallback.rs b/crates/app-iced/src/shell/fallback.rs deleted file mode 100644 index 1291988..0000000 --- a/crates/app-iced/src/shell/fallback.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! M68 骨架卡:**只给 `Route::Agent` 的回落路径用**(AI 独立页 P1 才落地)。 -//! -//! 内容区 = 页头(`TitleLarge`)+ 一张 `OutlinedCard`("Shell ready" + 一段说明文字)。 -//! 它原本住在 `shell/mod.rs`,M90 r2 为了让 `mod.rs` 回到 400 行以内搬到这里; -//! 行为一个字没改(`shell::view` 的输出与拆前逐字节相同)。 -//! -//! 为什么不能直接删:删了 `shell::view` 就没有调用点,而 `mod shell` 是私有的 —— -//! 私有模块里的 `pub fn` 无人使用会被 `dead_code` 判死,`-D warnings` 直接红。 - -use iced::widget::{column, container}; -use iced::{Alignment, Background, Element, Length, Padding}; - -use unlucky_design::color::Role; -use unlucky_design::component_map::Component; -use unlucky_design::elevation::MaterialLayer; -use unlucky_design::spacing::Space; -use unlucky_design::typography::TypeRole; - -use crate::app::Message; -use crate::theme::Ui; - -use super::label; - -// ---------------------------------------------------------------- 内容区 - -pub(super) fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> { - let metrics = ui.metrics(); - // 卡片档位由组件表决定(圆角/字级),组件层不自己挑 - let card_spec = Component::OutlinedCard.spec(); - let body_role = card_spec.support.unwrap_or(card_spec.text); - - let header = container(label( - ui, - TypeRole::TitleLarge, - true, - title, - ui.color(Role::OnSurface), - )) - .width(Length::Fill) - .height(metrics.page_header_height) - .align_y(Alignment::Center) - .padding(Padding::from([0.0, ui.space(Space::X6)])); - - let card = container( - column![ - label( - ui, - card_spec.text, - true, - "Shell ready", - ui.color(Role::OnSurface), - ), - label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)), - ] - .spacing(ui.space(Space::X2)), - ) - .width(Length::Fill) - .padding(metrics.card_padding) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Card))), - border: ui.outlined(card_spec.shape), - ..container::Style::default() - }); - - let body_area = container(card) - .width(Length::Fill) - .height(Length::Fill) - .padding(ui.space(Space::X6)) - .align_y(Alignment::Start); - - column![header, body_area] - .width(Length::Fill) - .height(Length::Fill) - .into() -} diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index b684080..800f280 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -1,4 +1,4 @@ -//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。 +//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区 + **右侧 AI 助手栏**(M104)。 //! //! ## 与参考判据的对应(`research/12b-component-fidelity.md`) //! @@ -15,9 +15,18 @@ //! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心 //! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。 -mod fallback; mod nav; +/// AI 助手栏与 AI 独立页(M104):**物理目录是 `crates/app-iced/src/pages/agent/`**, +/// 用 `#[path]` 挂到外壳的命名空间。 +/// +/// 为什么是 `#[path]` 而不是在 `pages/mod.rs` 加一行 `mod agent;`:`pages/mod.rs` 是四条页面线 +/// 共用的声明文件(本 mission 的 scope 没给它),而右栗是**外壳的一部分**(它挂在任何页面右边)。 +/// 先例是 `app/mod.rs` 挂 `../components/mod.rs`。物理路径只有一份,逻辑名字有两个用途: +/// 宿主(右栗)归外壳,视图(会话组件)归页面目录。 +#[path = "../pages/agent/mod.rs"] +pub(crate) mod agent; + pub use nav::NavItem; use iced::widget::text::LineHeight; @@ -36,34 +45,7 @@ use crate::cli::ForcedState; use crate::icons; use crate::theme::Ui; -/// 整棵树(**内容槽位版**:外壳画标题栏 / 缩放热区 / 导航轨,中间那块交给调用方)。 -/// -/// 这是产品页面走的入口(M90 r2 / P1-1)。为什么要它:`decorations: false` 的无边框窗口里, -/// 拖动 / 缩放 / 最小化 / 关闭这四条消息的构造点**只有本模块**,页面树自己画的「标题栏留白带」 -/// 没有任何控件,于是产品页会变成「拖不动、缩不了、点不到关闭」。 -/// -/// 分工写死: -/// -/// - 本模块负责:标题栏(拖动面 = **整条 48px 栏里从窗口左缘到第一个 caption** 的 `mouse_area`, -/// 没有单独的 8px 热区;三个 caption 键各 46×48)、东/南两条缩放热区(各 `Space::X1` = 4px)、 -/// 导航轨、根容器底色; -/// - 调用方负责:`content`(页面内容区)。它拿到的可用宽度是 `窗口宽 − rail 88`, -/// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距), -/// 两边的账不会算串。 -/// -/// 面板 α(D36 / M93)不从这里传:它是**表面色**的属性,唯一施加点是 `Ui::layer` -/// (`crate::theme`)。外壳的根容器 / 标题栏 / 导航轨都走 `ui.layer(..)`,因此自动同源。 -pub fn view_with_content<'a>( - ui: &'a Ui, - selected: NavItem, - app_title: &'a str, - content: Element<'a, Message>, - forced: Option, -) -> Element<'a, Message> { - view_with_background(ui, selected, app_title, None, content, forced) -} - -/// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的真图背景层)。 +/// 整棵树(**内容槽位版**):外壳画标题栏 / 缩放热区 / 导航轨 / 右栗,中间那块交给调用方。 /// /// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!` /// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。 @@ -73,39 +55,24 @@ pub fn view_with_background<'a>( app_title: &'a str, background: Option>, content: Element<'a, Message>, + right: Option>, + overlay: Option>, forced: Option, ) -> Element<'a, Message> { - let work_area = column![ - titlebar(ui, app_title, forced), - row![nav::rail(ui, selected, forced), content] - .width(Length::Fill) - .height(Length::Fill), - ] - .width(Length::Fill) - .height(Length::Fill); + // 右栗(M104)是 `row` 的**第三个兄弟**而不是叠层:它是布局宽度的一部分,压在上面会让 + // 中栗以为自己还有那么宽(那就是任务里禁止的「溢出」)。它的宽度由 `agent::docked` 自己声明。 + let mut work = row![nav::rail(ui, selected, forced), content] + .width(Length::Fill) + .height(Length::Fill); + if let Some(right) = right { + work = work.push(right); + } - window_frame(ui, background, work_area.into()) -} + let work_area = column![titlebar(ui, app_title, forced), work] + .width(Length::Fill) + .height(Length::Fill); -/// 整棵树(M68 骨架版:内容区是「页头 + 一张占位卡」)。 -/// -/// **保留给 `Route::Agent` 回落**(AI 独立页 P1 才落地)。删掉它会连带 -/// `content` / `label` / `nav::rail` 变成死代码,而 `mod shell` 是私有的 ⇒ `-D warnings` 直接红。 -pub fn view<'a>( - ui: &'a Ui, - selected: NavItem, - app_title: &'a str, - page_title: &'a str, - body: &'a str, - forced: Option, -) -> Element<'a, Message> { - view_with_content( - ui, - selected, - app_title, - fallback::content(ui, page_title, body), - forced, - ) + window_frame(ui, background, work_area.into(), overlay) } /// 无边框窗口的「外壳壳子」:背景层 + 工作区 + 两条缩放热区 + 根容器底色。 @@ -114,6 +81,7 @@ fn window_frame<'a>( ui: &'a Ui, background: Option>, work_area: Element<'a, Message>, + overlay: Option>, ) -> Element<'a, Message> { // 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。 // **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px, @@ -148,10 +116,14 @@ fn window_frame<'a>( // 背景在最底层(M93 / D34)。有背景层时**根容器不再盖实色画布**:否则那张不透明的 // `Canvas` 会把背景图整块挡住(r2 实测:panel α=1 时背景完全看不见)。 // 各面板(标题栏 / 导航轨 / 卡片 / 页头带)自己画底色,空隙里露出的就是背景图。 + // 拖拽层(M104)压在最上面:它只在「按下右栗分隔条」期间存在,作用是把指针 x 变成拖拽消息 + // (`mouse_area::on_move` 只在自己的边界内回报坐标,所以这一层必须铺满整窗)。 let has_background = background.is_some(); - let shell = match background { - Some(layer) => stack![layer, work_area, east, south], - None => stack![work_area, east, south], + let shell = match (background, overlay) { + (Some(layer), Some(overlay)) => stack![layer, work_area, east, south, overlay], + (Some(layer), None) => stack![layer, work_area, east, south], + (None, Some(overlay)) => stack![work_area, east, south, overlay], + (None, None) => stack![work_area, east, south], }; container(shell) diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index dbf16a7..f6c70de 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -111,6 +111,7 @@ macro_rules! finish { ({ $($acc:tt)* }) => { catalog! { $($acc)* } }; } +mod agent; mod core_text; mod download; mod font; @@ -127,6 +128,7 @@ mod golden; #[cfg(test)] mod tests; +use agent::agent_entries; use core_text::core_text_entries; use download::download_entries; use font::font_entries; @@ -155,6 +157,7 @@ nav_entries! { core_text_entries, download_entries, logs_entries, + agent_entries, finish, {} } diff --git a/crates/i18n/src/catalog/agent.rs b/crates/i18n/src/catalog/agent.rs new file mode 100644 index 0000000..ba9d7fb --- /dev/null +++ b/crates/i18n/src/catalog/agent.rs @@ -0,0 +1,40 @@ +//! AI 助手栏 / AI 独立页的文案(M104 `AGENTRAIL`)。 +//! +//! 这两处**共用同一套会话视图**(`crates/app-iced/src/pages/agent/`),所以文案也只在这里出现一次: +//! 栏上的折叠/拆出、页上的收回、两处都要的发送/中断/空会话引导。 +//! +//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节:本宏把条目**追加**到 `$acc` 之后, +//! 再把「还没装配的域」原样交给 `$next`。条目顺序 = `Key` 的声明顺序(金表 `catalog::golden` 守门)。 +//! +//! **新增 key 时按顺序追加在最后**:金表是「拆分前那份表的逐字快照」,重排既有行会让它红。 + +macro_rules! agent_entries { + ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { + $next! { $($rest,)* { + $($acc)* + // ── AI 助手(右栗 + 独立页共用) + AgentTitle => ("Assistant", "助手"), + AgentSubtitle => ("Conversation shell · no backend yet", "会话外壳 · 后端未接"), + AgentCollapse => ("Collapse", "收起"), + AgentDetach => ("Pop out", "拆出"), + AgentDock => ("Dock back", "收回"), + AgentOpen => ("Open assistant", "打开助手"), + AgentStripLabel => ("AI", "AI"), + AgentSend => ("Send", "发送"), + AgentStop => ("Stop", "中断"), + AgentPlaceholder => ("Message the assistant…", "给助手发消息…"), + AgentEmptyTitle => ("Nothing in this session yet", "这个会话还没有内容"), + AgentEmptyBody => ("Ask about instances, downloads or logs. No model is wired up yet.", "可以问实例、下载或日志。模型还没接上。"), + AgentNotePending => ("Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"), + AgentNoteStopped => ("Stopped by you.", "你中断了这次回复。"), + AgentUnread => ("{count} unread", "{count} 条未读"), + AgentResize => ("Drag to resize the assistant rail", "拖动调整助手栏宽度"), + AgentWidthLabel => ("Width {width}", "宽度 {width}"), + AgentRoleYou => ("You", "你"), + AgentRoleAssistant => ("Assistant", "助手"), + AgentRoleNotice => ("Notice", "提示"), + } } + }; +} + +pub(crate) use agent_entries; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index d643333..3dc4b81 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -94,4 +94,24 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("LogsEmptyTitle", "No output yet", "还没有输出"), ("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"), ("LogsEmptyAction", "Choose an instance", "选择实例"), + ("AgentTitle", "Assistant", "助手"), + ("AgentSubtitle", "Conversation shell · no backend yet", "会话外壳 · 后端未接"), + ("AgentCollapse", "Collapse", "收起"), + ("AgentDetach", "Pop out", "拆出"), + ("AgentDock", "Dock back", "收回"), + ("AgentOpen", "Open assistant", "打开助手"), + ("AgentStripLabel", "AI", "AI"), + ("AgentSend", "Send", "发送"), + ("AgentStop", "Stop", "中断"), + ("AgentPlaceholder", "Message the assistant…", "给助手发消息…"), + ("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"), + ("AgentEmptyBody", "Ask about instances, downloads or logs. No model is wired up yet.", "可以问实例、下载或日志。模型还没接上。"), + ("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"), + ("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"), + ("AgentUnread", "{count} unread", "{count} 条未读"), + ("AgentResize", "Drag to resize the assistant rail", "拖动调整助手栏宽度"), + ("AgentWidthLabel", "Width {width}", "宽度 {width}"), + ("AgentRoleYou", "You", "你"), + ("AgentRoleAssistant", "Assistant", "助手"), + ("AgentRoleNotice", "Notice", "提示"), ];