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

@@ -0,0 +1,155 @@
//! 会话里的两条**横幅**(M119 从 `conversation.rs` 拆出来守 400 行硬线):
//! 失败态(具名错误)与写操作确认。
//!
//! ## 为什么必须存在
//!
//! 任务书第 4 条:「presenter 返回错误时 UI 必须显示**具名**错误而不是静默无语」。M104 的壳没有
//! 任何错误位——`Submit` 只在本地数组追加一句「没发给模型」的提示。现在两条来源都有了落点:
//!
//! | 来源 | 触发 | 画什么 |
//! |---|---|---|
//! | `AgentHost::last_failure` | `agent_submit` 返回 `Err`(端点不可达 / 路由未实现) | 标题 = [`Key::AgentErrorTitle`],正文 = 原因码对应的 catalog 译文 |
//! | `AgentRail::error` | 后端发 `AgentEvent::Failed`(这一轮回复中止) | 标题同上,正文 = presenter 已脱敏的那句原因 |
//!
//! 两者都不把后端诊断原文直接上屏:诊断串只进日志(M114 复审 r1 立的口径)。
//!
//! ## 确认条
//!
//! `rail.confirm` 非空 ⇒ 一轮写操作在等用户裁决,而 `busy` 会一直锁着输入(presenter 的
//! `sync_busy` 语义:`busy = streaming || confirm.is_some()`)。所以这段 UI 不是装饰:
//! **不按就不动**。两个按钮都带文字标签(M104 的「不得只有图标」同一条规矩)。
use iced::widget::{column, container, row, Row};
use iced::{Alignment, Background, Element, Length};
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 unlucky_presenter::dto::ConfirmRequest;
use crate::app::components::buttons::{self, Button};
use crate::theme::Ui;
use super::super::{Failure, Message};
use super::text_role_owned;
/// 失败横幅:`ErrorContainer` 图标块 + 标题/正文(M3 内联 alert 的形状,与账户页横幅同源)。
pub(super) fn failure<'a>(
ui: &'a Ui,
failure: Failure<'a>,
catalog: Catalog,
) -> Element<'a, Message> {
banner(
ui,
catalog.text(failure.title).to_string(),
failure.body.to_string(),
Vec::new(),
)
}
/// 写操作确认条:一行请求摘要 + Allow / Deny。
pub(super) fn confirm<'a>(
ui: &'a Ui,
request: &'a ConfirmRequest,
catalog: Catalog,
) -> Element<'a, Message> {
// 摘要来自 presenter(已过 `scrub_text`):它是「要改什么」的唯一描述,必须显示,
// 否则用户是在盲签。句框走 catalog,摘要本身是正文。
let title = catalog.render(
Key::AgentConfirmRequest,
&[("summary", request.summary.as_str())],
);
let actions: Vec<Element<'a, Message>> = vec![
buttons::outlined(
ui,
Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false))
.leading(IconKind::Close),
),
buttons::filled(
ui,
Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true))
.leading(IconKind::Check),
),
];
banner(ui, title, String::new(), actions)
}
/// 横幅本体:图标块 + 标题/正文 + 右侧动作。
///
/// 形状与 `pages::account::widgets::alert` 同源(面板底走卡片层 + 1dp 描边,
/// `ErrorContainer` / `OnErrorContainer` 只作左侧图标块的强调,不是通栏红条)。
fn banner<'a>(
ui: &'a Ui,
title: String,
body: String,
actions: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
let side = ui.space(Space::X8);
let glyph_px = ui.space(Space::X6);
let chip = container(crate::icons::view_at(
IconKind::Error,
ui.color(Role::Error),
glyph_px,
))
.width(Length::Fixed(side))
.height(Length::Fixed(side))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
border: iced::Border {
radius: ui.radius(ShapeScale::Full, side),
..iced::Border::default()
},
..container::Style::default()
});
let mut lines = column![text_role_owned(
ui,
TypeRole::TitleSmall,
true,
title,
ui.color(Role::OnSurface),
Length::Fill,
)]
.spacing(ui.space(Space::X1));
if !body.is_empty() {
lines = lines.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
body,
ui.color(Role::OnSurfaceVariant),
Length::Fill,
));
}
let mut content = Row::new()
.spacing(ui.space(Space::X3))
.align_y(Alignment::Center)
.width(Length::Fill)
.push(chip)
.push(Element::from(container(lines).width(Length::Fill)));
if !actions.is_empty() {
content = content.push(Element::from(
row(actions)
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
));
}
container(content)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: ui.outlined(ShapeScale::Medium),
..container::Style::default()
})
.into()
}
@@ -120,7 +120,38 @@ pub(super) fn binding_for(press: KeyPress) -> Option<Binding<Message>> {
}
}
/// 一档文字(字号/行高/字重来自令牌;`shell::label` 同口径,但这里要自己持消息类型)。
/// 一档文字(**借来的**正文)。
///
/// 与 `pages::frame::text_role` 同源,但那边返回的是 `Element<pages::Message>`,助手栏要自己的
/// 消息类型——这是 M104 起就有的分工,不是重复实现。
/// 一档文字(**拼出来的**正文:工具条目 `name · summary`、横幅文案都走它)。
///
/// 为什么不能直接用借用版:正文是 `String`,而 `Element` 不允许借用局部变量。
pub(crate) fn text_role_owned<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: String,
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()
}
pub(crate) fn text_role<'a>(
ui: &'a Ui,
role: TypeRole,
@@ -0,0 +1,124 @@
//! 对话本体:空态 + 消息气泡(M119 从 `conversation.rs` 拆出来守 400 行硬线)。
//!
//! ## 数据从哪来
//!
//! 直接画 presenter 的 `AgentRail::messages`(`ChatMessage`):`role` 决定角色小标与气泡容器,
//! `text` 是正文(**已经过 presenter 的第二道脱敏闸门** `scrub_text`),`tool` 是工具调用条目
//! (D4:工具调用会话可见)。壳里不再有 `Speaker`/`Turn` 那种自己的会话类型——那是一份要跟
//! presenter 对齐的镜像,M119 已经删掉。
use iced::widget::{column, container, scrollable};
use iced::{Alignment, Background, Border, Element, Length, Padding};
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 unlucky_presenter::dto::{ChatMessage, ChatRole};
use crate::app::components::feedback::empty_state::{empty_state, EmptyState};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::Message;
use super::{text_role, text_role_owned, Conversation};
/// 空会话:引导空态(零条消息时替代气泡列表)。
pub(super) 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()
}
/// 气泡列表(可滚动)。
pub(super) fn list<'a>(
ui: &'a Ui,
spec: Conversation<'a>,
catalog: Catalog,
) -> Element<'a, Message> {
let mut items = column![].spacing(ui.space(Space::X3)).width(Length::Fill);
for message in spec.messages {
items = items.push(bubble(ui, message, catalog));
}
scrollable(container(items).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, message: &'a ChatMessage, catalog: Catalog) -> Element<'a, Message> {
let (role, fill, ink, align) = match message.role {
ChatRole::User => (
Key::AgentRoleYou,
Some(ui.color(Role::SecondaryContainer)),
ui.color(Role::OnSecondaryContainer),
Alignment::End,
),
// 工具条目也挂在助手名下:它是助手这一轮做的事,不是第三方说话人
ChatRole::Assistant | ChatRole::Tool => (
Key::AgentRoleAssistant,
Some(ui.layer(MaterialLayer::Card)),
ui.color(Role::OnSurface),
Alignment::Start,
),
ChatRole::System => (
Key::AgentRoleNotice,
None,
ui.color(Role::OnSurfaceVariant),
Alignment::Start,
),
};
// 工具条目:正文是结果摘要,名字前缀一行(`tool.name`,与 ToolSpec 注册表同名)。
// 两者都来自 presenter 的事件,已过脱敏;这里只拼展示串。
let body = match &message.tool {
Some(tool) => format!("{} · {}", tool.name, tool.summary),
None => message.text.clone(),
};
let inner = column![
text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(role),
ui.color(Role::OnSurfaceVariant),
Length::Fill,
),
text_role_owned(ui, TypeRole::BodyMedium, false, body, 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()
}