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:
1 parent
6505493d4e
commit
d4fd675935
25 files changed
+2390
-698
No files matched your search
@@ -1,42 +1,60 @@
|
||||
//! 助手栏消息的落点(M104):三档状态迁移 / 输入 / 拖拽 / 键盘。
|
||||
//! 助手栏消息的落点(M104 建,M119 接线):三档状态迁移 / 输入 / 键盘 / **presenter 四契约**。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! `app/update.rs` 已经贴着 400 行硬上限(M94 也因此把下载页四支拆到 `update/download.rs`)。
|
||||
//! 助手栏有 11 个变体,塞回主文件必破线。任务也只允许主文件加一行 `mod agent;`。
|
||||
//! 助手栏有十几个变体,塞回主文件必破线。
|
||||
//!
|
||||
//! ## 分工:视图态 vs 业务命令
|
||||
//! ## 分工:视图态 vs presenter 契约(M119 之后的分界线)
|
||||
//!
|
||||
//! | 消息 | 落到哪 |
|
||||
//! |---|---|
|
||||
//! | `Expand` / `Collapse` / `Dock` / `Escape` | **只改视图态**(`AgentUi`)+ 路由栈(离开/进入 `Route::Agent`) |
|
||||
//! | `Detach` | 视图态 + `Route::Agent`(压栈,`back()` 能回去) |
|
||||
//! | `Edit` / `Submit` / `Stop` | 改视图态:草稿、追加一条占位提示 |
|
||||
//! | `Expand` / `Collapse` / `Dock` / `Escape` | **只改视图态**(`AgentUi`)+ 路由栈(离开/进入 `Route::Agent`)+ `agent_mark_read` |
|
||||
//! | `Detach` | 视图态 + `Route::Agent`(压栈,`back()` 能回去)+ `agent_mark_read` |
|
||||
//! | `Edit` | 视图态(草稿),并同步进 presenter 的草稿(`agent_set_draft`) |
|
||||
//! | `Submit` | [`AgentHost::submit`] → `agent_submit`;失败**不留白**(具名错误进横幅) |
|
||||
//! | `Stop` | `AppCommand::StopAgent`(真后端才收敛得出结果;同步 rig 下是 no-op) |
|
||||
//! | `Confirm(accept)` | [`AgentHost::confirm`] → `agent_confirm`(YOLO 下 presenter 自己放行并记审计) |
|
||||
//! | `Events(..)` | [`AgentHost::apply`] → `AppState::apply`(**事件回流的唯一落点**) |
|
||||
//! | `DragStart` / `DragTo` / `DragEnd` | 视图态:吸附宽度 |
|
||||
//! | `Key` / `Tab` / `Activate` | 键盘路径 |
|
||||
//! | `Key` / `Tab` / `Activate` | 键盘路径(`Tab` 的环要在流式中多一格,`busy` 从 rail 取) |
|
||||
//!
|
||||
//! **没有任何 `AppCommand` 派发**:本 mission 不接 AI 后端(M103 的四个签名一行都不调)。
|
||||
//! `Submit` 不假装有回复——它只把草稿记进会话 + 追加一条「占位外壳、没发给模型」的提示,
|
||||
//! `Stop` 同理。真接线时这两个分支各加一次 `dispatch`,视图不用动。
|
||||
//! **会话内容一行都不在这里攒**:`transcript` / `busy` / `unread` / `confirm` 全在 presenter 的
|
||||
//! `AgentRail` 上(M119 删掉了 M104 的自持副本)。
|
||||
//!
|
||||
//! ## 事件为什么绕一圈消息再回来
|
||||
//!
|
||||
//! `agent_submit` / `agent_confirm` 的端口是同步的,但**事件走队列**(见
|
||||
//! `app/state/presenter/transport.rs`):抽队列的那一步必须和用户点击走同一条漏斗,否则两处各改
|
||||
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
|
||||
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
|
||||
|
||||
use iced::keyboard::{key, Key};
|
||||
use iced::Task;
|
||||
|
||||
use unlucky_i18n::{Catalog, Lang};
|
||||
use unlucky_i18n::{Catalog, Key as TextKey};
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
|
||||
use crate::app::{MIN_SIZE, WINDOW_SIZE};
|
||||
use crate::app::MIN_SIZE;
|
||||
use crate::app::WINDOW_SIZE;
|
||||
use crate::shell::agent::demo::{self, Fixture};
|
||||
use crate::shell::agent::{self, Focus, RailState};
|
||||
|
||||
use super::super::state::presenter::agent::SubmitOutcome;
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
/// 助手栏消息(`app::Message::Agent(..)` 的唯一落点)。
|
||||
pub(super) fn on_agent(&mut self, message: agent::Message) -> Task<Message> {
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
// M106 口径:文案走**生效语言**的目录,不再恒英文
|
||||
let catalog = Catalog::of(self.lang());
|
||||
|
||||
// 只有**几何/路由**会变的几支才需要重建视图模型(`pages::Model` 里没有会话内容:
|
||||
// transcript / draft 全在 `App.agent` 上)。复审 P3-9:以前每个 `Edit`(= 每敲一个字符)
|
||||
// 和每个 `DragTo`(= 指针频率)都重建一次整张模型,白白克隆 N 个实例的名字。
|
||||
// 只有**几何**会变的几支才需要重建页面模型(`pages::Model` 里没有会话内容:会话在
|
||||
// presenter 的 rail 上,右栗自己那棵树每次 `view()` 都从 rail 现读)。复审 P3-9 的教训:
|
||||
// 每敲一个字符都重建整张模型是白克隆。
|
||||
let geometry_changed = matches!(
|
||||
message,
|
||||
agent::Message::Expand
|
||||
@@ -51,31 +69,52 @@ impl App {
|
||||
);
|
||||
|
||||
match message {
|
||||
agent::Message::Expand => self.agent.expand(),
|
||||
agent::Message::Expand => {
|
||||
self.agent.expand();
|
||||
// 「展开即读」是 presenter 的语义(`agent_mark_read`),壳只负责在正确的时刻调它
|
||||
self.agent_host.mark_read();
|
||||
}
|
||||
agent::Message::Collapse | agent::Message::Escape => self.collapse_agent(),
|
||||
agent::Message::Detach => {
|
||||
self.agent.detach();
|
||||
self.agent_host.mark_read();
|
||||
self.state.navigate(Route::Agent);
|
||||
}
|
||||
agent::Message::Dock => {
|
||||
self.agent.dock();
|
||||
self.agent_host.mark_read();
|
||||
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::Edit(action) => {
|
||||
self.agent.edit(action);
|
||||
// 草稿同步给 presenter(切形态不丢;它也是 rail 上唯一允许躺原文的字段)
|
||||
self.agent_host.set_draft(self.agent.draft.text());
|
||||
}
|
||||
agent::Message::Submit => self.submit_agent(),
|
||||
agent::Message::Stop => self.dispatch(AppCommand::StopAgent),
|
||||
agent::Message::Confirm(accept) => {
|
||||
self.agent_host.confirm(accept);
|
||||
self.drain_agent();
|
||||
}
|
||||
agent::Message::Events(events) => {
|
||||
for event in events {
|
||||
self.agent_host.apply(event);
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
agent::Message::Tab { shift } => {
|
||||
let busy = self.agent_host.rail().busy;
|
||||
self.agent.step_focus(shift, busy);
|
||||
}
|
||||
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),
|
||||
}
|
||||
|
||||
// 档位 / 路由变了就要把「栏可见吗」写回 presenter(未读口径的输入)
|
||||
self.sync_agent_presenter();
|
||||
if geometry_changed {
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
@@ -83,6 +122,68 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 发送:草稿 → `agent_submit` → 抽事件。
|
||||
///
|
||||
/// 三种结局各自有落点:发出去(清草稿)/ 后端失败(具名横幅,见 [`App::agent_failure`])/
|
||||
/// presenter 拒收(日志一行——这三种情形正常情况下被禁用态挡住,够不着)。
|
||||
fn submit_agent(&mut self) {
|
||||
let text = self.agent.draft.text();
|
||||
if text.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
match self.agent_host.submit(text) {
|
||||
SubmitOutcome::Sent => {
|
||||
self.agent.clear_draft();
|
||||
self.agent_host.set_draft(String::new());
|
||||
self.drain_agent();
|
||||
}
|
||||
SubmitOutcome::Failed(failure) => {
|
||||
eprintln!("[agent] 提交失败({})", failure.code());
|
||||
}
|
||||
SubmitOutcome::Rejected => {
|
||||
eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 抽干 presenter 的事件队列,**经既有 Message 通道**回流(见模块文档末节)。
|
||||
pub(crate) fn drain_agent(&mut self) {
|
||||
let events = self.agent_host.drain();
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = self.on_agent(agent::Message::Events(events));
|
||||
}
|
||||
|
||||
/// 把「栏可见吗」写回 presenter(`navigate` + `prefs_mut` 两个窄入口,见 `AgentHost`)。
|
||||
pub(crate) fn sync_agent_presenter(&mut self) {
|
||||
let on_page = self.state.route() == &Route::Agent;
|
||||
let collapsed = self.agent.rail == RailState::Collapsed;
|
||||
self.agent_host.sync_visibility(on_page, collapsed);
|
||||
}
|
||||
|
||||
/// 失败横幅要显示的东西(`None` = 没有错误)。
|
||||
///
|
||||
/// 两个来源都取**展示输入**:具名原因码 → catalog 译文;或 presenter 已脱敏的这一轮原因。
|
||||
/// 壳在这一层不做任何脱敏(那是 presenter 的`scrub_text`),也不拼中英(那是 catalog 的事)。
|
||||
pub(crate) fn agent_failure(&self) -> Option<agent::Failure<'_>> {
|
||||
let catalog = self.lang().catalog();
|
||||
if let Some(failure) = self.agent_host.last_failure() {
|
||||
return Some(agent::Failure {
|
||||
title: TextKey::AgentErrorTitle,
|
||||
body: catalog.text(failure.body_key()),
|
||||
});
|
||||
}
|
||||
self.agent_host
|
||||
.rail()
|
||||
.error
|
||||
.as_deref()
|
||||
.map(|body| agent::Failure {
|
||||
title: TextKey::AgentErrorTitle,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。
|
||||
///
|
||||
/// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变,
|
||||
@@ -106,12 +207,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动时助手栏要做的两件事(从 `on_boot` 搬出来:那边只许留一行调用,复审 P3-6)。
|
||||
/// 启动时助手栏要做的三件事(从 `on_boot` 搬出来:那边只许留一行调用,复审 P3-6)。
|
||||
///
|
||||
/// 1. **窗口尺寸 adopt**:唯一真源是 `boot.rs` 开窗时用的那一个(`ULL_WINDOW_SIZE` + 最小尺寸
|
||||
/// 钳位)。`App::with_presenter` 只认识常量 1180×760,不 adopt 的话验收矩阵开 900 / 1520 的窗
|
||||
/// 时页面还按 1180 排版(窄窗里内容被裁)。
|
||||
/// 2. **拆出档直接落到独立页**:`ULL_AGENT_RAIL=detached`(以及将来持久化的偏好)时,会话就在
|
||||
/// 2. **取证夹具**(`ULL_AGENT_*`):先落档位,再**跑一轮真提交**——夹具不造消息,消息只能
|
||||
/// 由 presenter 产生(M119 的口径,见 `pages/agent/demo.rs`)。
|
||||
/// 3. **拆出档直接落到独立页**:`ULL_AGENT_RAIL=detached`(以及将来持久化的偏好)时,会话就在
|
||||
/// 独立页里,启动后不该停在实例页。
|
||||
pub(super) fn boot_agent(&mut self) {
|
||||
let (window_size, _source) = crate::renderer::acceptance::window::window_size(
|
||||
@@ -121,6 +224,23 @@ impl App {
|
||||
self.width = window_size.0;
|
||||
self.height = window_size.1;
|
||||
|
||||
if let Some((ui, fixture)) = demo::from_env() {
|
||||
self.agent = ui;
|
||||
// 先让 presenter 知道「栏现在看得见吗」,再跑提交,未读口径才对
|
||||
self.sync_agent_presenter();
|
||||
match fixture {
|
||||
Fixture::None => {}
|
||||
Fixture::Turn | Fixture::Unread => {
|
||||
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
|
||||
self.drain_agent();
|
||||
}
|
||||
Fixture::Focused => {
|
||||
let busy = self.agent_host.rail().busy;
|
||||
self.agent.step_focus(false, busy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
|
||||
eprintln!("[agent] rail=detached → Route::Agent");
|
||||
self.state.navigate(Route::Agent);
|
||||
|
||||
Reference in new issue
Block a user