feat(app-iced): M119 把 presenter 的 agent 四契约与账户窄入口接进 iced 壳

M103 把 AI 会话的投影(`AgentRail` + `submit/mark_read/confirm`)做完了,M114 把账户三轨做完,
但 iced 壳一直没接:M103 复审确认 `Presenter::` 零非测试消费者,M104 的右栏会话数据来自内置样本。
这一个提交就是那一步——**右栏与账户页吃 presenter 的真实状态**。

## 接线层(新增 `crates/app-iced/src/presenter/`)

| 文件 | 是什么 |
|---|---|
| `agent.rs` | `AgentHost`:presenter 句柄(四契约)+ 事件队列 + **具名**失败(`AgentFailure`:`unreachable` / `not_implemented`)|
| `transport.rs` | `AgentTransport` 的 iced 侧实现(脚本化 rig + 五种剧情 + 两种失败注入)|
| `rig.rs`(仅测试) | 账户轨的 `Backend`:`AppCommand` → host 形状的 `CoreEvent` |
| `mod.rs` | 逻辑路径说明 + **线程/通道边界** |

物理目录 `src/presenter/`,逻辑名字 `crate::app::state::presenter`——由 `app/state.rs` 用
`#[path]` 挂载(与 `shell::agent` 挂 `pages/agent/` 同一个先例:scope 不含 `lib.rs`)。

## 事件回流

`transport` 只**入队**、UI 只**出队**:`advance`→`pump` 里同拍抽干,抽出来的这批经
**既有 `Message::Agent(..)` 通道**(新变体 `Events(Vec<AppEvent>)`)回到 `on_agent`,再由
`Presenter::apply` 落地。同一条漏斗,所以用户点击与后端事件不会各改一次状态。

## 会话内容只留一份

`AgentUi` 里的 `transcript/busy/unread` **删掉**,右栏与独立页直接读 `agent_rail()`
(`pages/agent::spec` 零拷贝借)。M104 的样本会话(`demo::transcript()`)一并删除:出厂就是
空会话,取证夹具要内容就**跑一轮真提交**(`ULL_AGENT_FIXTURE=turn`),消息只能由 presenter 产生。

## 视图

- 气泡按 `ChatMessage.role` 画(User/Assistant/Tool/System),没有任何壳自己的会话类型;
- 新增两条横幅(`conversation/banners.rs`):**失败态**(catalog 标题 + 已脱敏正文)与
  **写操作确认**(摘要 + Allow/Deny → `agent_confirm`);
- 拖动缝与折叠条按 400 行硬线拆到 `mod.rs` 之外(`strip.rs`、`conversation/{transcript,banners}.rs`)。

## 未读口径

presenter 判「栏可见吗」用的是它自己的 router + `prefs.ai_collapsed`,而这两件的真源在 `App`。
`refresh_view` 用 presenter 的两个**窄入口**(`navigate` / `prefs_mut().ai_collapsed`)单点写回,
带幂等闸(相同就不重复写,避免 `navigate(Route::Agent)` 顺手 mark_read 吃掉用户攒的未读)。

## 账户页

账户列表与登录事件仍走 `AppCommand`/`CoreEvent`(M114 的形状没动),本轮把**登录指令与
列表消费**纳入同一套树测试:添加离线档案 → 列表出现 → 切换 → 删除;设备码「进行中可见 + 可取消」;
失败态(名字不合法 / 凭据失效)都给具名错误。

## 树测试(9 条,`cargo test -p unlucky-app-iced --lib` 全绿:599 passed / 0 failed)

- `app/update/agent/tests.rs`:提交 → 事件回流 → 右栏出现该轮 + `busy` 翻转 + 标记已读归零;
  确认条可裁决且记审计;三种失败各给具名横幅;草稿逐字同步。
- `app/update/account/tests.rs`:增/切/删端到端;设备码进行中与取消;两种失败态;
  M114 的三条回归闸(原内联测试搬出来)。
- `pages/agent/tests.rs`:视图契约(`spec` 原样借 rail)、a11y 标签、焦点环、两种宿主全态构图。

顺带:`app/tests.rs` 里三条 `agent.unread` 断言跟着 rail 走(出厂 0 未读),`view/account_page.rs`
的 `docked` 调用点补上 rail 与失败横幅。
This commit is contained in:
AskaEth committed 2026-11-05 08:02:58 +08:00
1 parent 6505493d4e
commit d4fd675935
25 files changed
+2390 -698

No files matched your search

+27 -109
View File
@@ -1,6 +1,13 @@
//! 右栗的三档状态机 + 会话数据 + 键盘焦点环 + [`AgentUi`](挂在 `App` 上的 UI 状态)。
//! 右栗的**纯 UI 状态**:三档状态机 + 展开宽度 + 草稿 + 键盘焦点环 + [`AgentUi`]。
//!
//! 布局数学(宽度 / 磁铁吸附 / 中栗下限)在 [`super::metrics`];这里只管「状态怎么变」。
//! 布局数学(宽度 / 磁铁吸附 / 中栗下限)在 [`super::metrics`];这里只管「UI 状态怎么变」。
//!
//! ## 会话内容为什么不在这个文件里(M119)
//!
//! M104 的 `AgentUi` 自带 `transcript` / `busy` / `unread` 三个字段,数据来自内置样本
//! ([`super::demo`])——那是「壳先搭好、等 M103 接」的过渡态。M119 把这三个字段**删掉**:
//! 会话的唯一来源是 presenter 的 `AgentRail`(`app/state.rs` 的 `agent_host`),右栗与独立页
//! 直接读它。于是在这个文件里再放一份就是第二份真相,而两份真相必然漂。
//!
//! ## 三档状态(用户定案 D16)
//!
@@ -10,21 +17,15 @@
//! | [`RailState::Expanded`] | 280~360(默认 320) | 右栗面板内 | 点图标条 / 收回 |
//! | [`RailState::Detached`] | 0 | `Route::Agent` 独立页 | `⇥` 拆出 |
//!
//! 三档是**同一个会话**的三种呈现:切档只改 [`RailState`] 与路由栈,`transcript` / `draft` /
//! `busy` / `unread` 一条都不丢(「拆出去再接回来,话说了一半还在」)。
//! 三档是**同一个会话**的三种呈现:切档只改 [`RailState`] 与路由栈,草稿与焦点一条都不丢
//! (会话本身在 presenter 上,三档共享得比原来更彻底)。
//!
//! ## 为什么状态是 `App` 的字段
//! ## 未读 / busy 从哪来
//!
//! 这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,状态挂在 `App` 上最自然。
//! 早先想过 `OnceLock<Mutex<..>>`(当时 `App` 字段不在 scope),tower 明确否掉:那要测试串行化、
//! 而且不是 per-App。
//!
//! **持久化欠账**:`presenter::prefs::UiPrefs` 里的键不在本 mission 的 scope,所以三档与吸附宽度
//! **只在本次进程内有效**(重启回到折叠 / 320)。接手点:`UiPrefs` 加两个键 + `App::new` 读一次,
//! 本文件的 [`AgentUi::rail`] / [`AgentUi::set_expanded`] 就是那两个落点。
//! 都在 `AgentRail` 上:未读由 presenter 按「栏可见吗」累加、`agent_mark_read()` 清零;
//! `busy` 由「有流式消息 || 有写操作待确认」算出来。壳里**不再自己数**未读。
use iced::widget::text_editor;
use unlucky_i18n::{Catalog, Key};
use super::metrics::{
content_width, effective_state, magnet_width, rail_width, DEFAULT_EXPANDED_WIDTH,
@@ -64,36 +65,6 @@ impl RailState {
}
}
/// 会话里的一个说话人。
#[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<String>) -> Self {
Self {
speaker,
text: text.into(),
}
}
}
/// 键盘软焦点顺序(Tab 环)。顺序 = 屏幕上的阅读顺序:头部动作 → 输入区动作 → 输入框。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
@@ -143,6 +114,8 @@ impl Focus {
}
/// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。
///
/// `busy` 由 presenter 的 rail 给(壳里不自己数):流式中环上多一格 `Stop`。
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 {
@@ -165,14 +138,8 @@ pub struct AgentUi {
pub rail: RailState,
/// 展开档的吸附宽度(三档共享一个值:收起再展开回到同一宽度)。
expanded: f32,
/// 会话内容(占位样本数据,真模型由 M103 接)。
pub transcript: Vec<Turn>,
/// 输入框(多行;iced 的 `text_editor` 自己持有编辑状态,这里只放内容)。
pub draft: text_editor::Content,
/// 是否在流式回复(`false` = 没有中断键、发送键可用)。
pub busy: bool,
/// 未读数(折叠条画点 + 一行可读文字;展开/拆出即清零)。
pub unread: usize,
/// 键盘软焦点。
pub focus: Focus,
/// 焦点环是否「被键盘走过」(`Tab` 置 true,鼠标点输入框 / 换档置 false)。
@@ -186,7 +153,7 @@ pub struct AgentUi {
}
impl AgentUi {
/// 出厂态:**折叠** + 一段占位样本会话 + 2 条未读。
/// 出厂态:**折叠** + 空草稿 + 无未读(未读由 presenter 按事件累加,M119)。
///
/// 为什么默认折叠:1180 下展开 320 会把实例页挤成单列(中栗 852 < 816 的两列门槛之外还够,
/// 但 1024 就掉档了),用户第一次打开启动器不该先被一个助手栏改掉主页面版面。
@@ -194,24 +161,13 @@ impl AgentUi {
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
@@ -249,7 +205,8 @@ impl AgentUi {
content_width(window_width, self.rail, self.expanded)
}
/// 收起(`⌄` / `Esc`)。未读不清零:收起后新消息仍要能显示成点。
/// 收起(`⌄` / `Esc`)。未读**不**清零:收起后新消息仍要能显示成点(清零由 app 层在
/// 「打开栏」时调 `agent_mark_read` 做——那是 presenter 的语义,不是 UI 的)。
pub fn collapse(&mut self) {
self.keyboard_mode = false;
if self.rail == RailState::Expanded {
@@ -260,19 +217,17 @@ impl AgentUi {
}
}
/// 展开(点图标条)。展开即视为读过:未读清零(与主流聊天客户端的「打开即读」同口径)。
/// 展开(点图标条)。展开即视为读过——**清零动作在 app 层**(`mark_read`),这里只改档位。
pub fn expand(&mut self) {
self.keyboard_mode = false;
self.rail = RailState::Expanded;
self.unread = 0;
self.focus = Focus::Collapse;
}
/// 拆成独立页(`⇥`)。会话状态一条不动。
/// 拆成独立页(`⇥`)。UI 状态一条不动(会话在 presenter 上,本来就不归这里管)。
pub fn detach(&mut self) {
self.keyboard_mode = false;
self.rail = RailState::Detached;
self.unread = 0;
self.focus = Focus::Detach;
}
@@ -280,7 +235,6 @@ impl AgentUi {
pub fn dock(&mut self) {
self.keyboard_mode = false;
self.rail = RailState::Expanded;
self.unread = 0;
self.focus = Focus::Collapse;
}
@@ -288,8 +242,7 @@ impl AgentUi {
///
/// 这里**不**分 Enter / Shift+Enter:分流在 `conversation::composer::binding_for`(非 shift 的
/// Enter 根本不会变成 `Action`,它直接发 `Message::Submit`)。所以走到这里的 `Edit(Enter)`
/// 一定是「Shift+Enter 换行」那一支,`perform` 就够——早先这里有一段与之等价的 `if` 分支,
/// 读起来像关键拦截,实际是冗余(复审 P3-2 指出后删掉)。
/// 一定是「Shift+Enter 换行」那一支,`perform` 就够。
pub fn edit(&mut self, action: text_editor::Action) {
use iced::widget::text_editor::Action;
@@ -301,44 +254,9 @@ impl AgentUi {
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),
));
/// 清空输入框(发送成功之后调:草稿已经交给 presenter 了)。
pub fn clear_draft(&mut self) {
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;
}
/// 输入框是否为空(发送键的禁用判据)。
@@ -365,9 +283,9 @@ impl AgentUi {
self.pointer = None;
}
/// Tab 走一格(走一次就把键盘巡航打开)。
pub fn step_focus(&mut self, shift: bool) {
self.focus = self.focus.step(shift, self.rail, self.busy);
/// Tab 走一格(走一次就把键盘巡航打开)。`busy` 由 presenter 的 rail 给。
pub fn step_focus(&mut self, shift: bool, busy: bool) {
self.focus = self.focus.step(shift, self.rail, busy);
self.keyboard_mode = true;
}
}