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 与失败横幅。
149 lines
6.9 KiB
Rust
149 lines
6.9 KiB
Rust
//! `App` 的两个构造器(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
|
|
//!
|
|
//! | 构造器 | 谁用 | 碰不碰磁盘 |
|
|
//! |---|---|---|
|
|
//! | [`App::new`](super::App::new) | 生产(`main` → `boot`) | 按启动参数选后端 + 打开设置桥 |
|
|
//! | [`App::with_presenter`](super::App::with_presenter) | 单测(后端与桥都由调用方给) | **不碰** |
|
|
//!
|
|
//! 两个都走 `with_presenter`:生产路径只是先造好后端与桥。**字段初始化表**也在这一个文件里
|
|
//! (构造 = 字段表 + 三件外部依赖),加字段时只改这一处。
|
|
|
|
use iced::window;
|
|
|
|
use unlucky_design::color::SchemeFlavor;
|
|
use unlucky_presenter::backend::{build_backend, config_path};
|
|
use unlucky_presenter::dto::Backend;
|
|
use unlucky_presenter::settings_bridge::SettingsBridge;
|
|
use unlucky_presenter::state::AppState;
|
|
|
|
use crate::cli::Options;
|
|
use crate::pages;
|
|
use crate::theme::{Families, Ui};
|
|
use crate::ui::lang::LangState;
|
|
|
|
use super::super::WINDOW_SIZE;
|
|
use super::boot_probe::app_options;
|
|
use super::App;
|
|
|
|
impl App {
|
|
/// 生产构造:按启动参数选后端、打开设置桥。
|
|
///
|
|
/// **截图通道强制 mock**:出图要可复现(D30),真 host 会按本机盘上的实例清单画面,
|
|
/// 同一份代码在两台机器上出不了同一张图。交互模式走真 host(数据根解析不出来时
|
|
/// [`build_backend`] 自己退回 mock)。
|
|
pub(in crate::app) fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
|
|
let backend = build_backend(&app_options(&options));
|
|
// P2-2:设置桥必须与后端指向**同一个数据根**。早先这里无条件 `SettingsBridge::open()`
|
|
// (按便携配置解析),于是 `--data-dir X` 会出现「实例来自 X、主题/设置读写默认根」的一进程
|
|
// 两个数据根。Slint 版是同一条口径:`app/src/ui_bind.rs` 的
|
|
// `Some(root) => SettingsBridge::with_path(config_path(root))`。
|
|
let bridge = match &options.data_dir {
|
|
Some(root) => SettingsBridge::with_path(config_path(root)),
|
|
None => SettingsBridge::open(),
|
|
};
|
|
Self::with_presenter(options, families, flavor, backend, bridge)
|
|
}
|
|
|
|
/// 注入式构造(单测用:后端与桥都由调用方给,**不碰磁盘**)。
|
|
pub(in crate::app) fn with_presenter(
|
|
options: Options,
|
|
families: Families,
|
|
flavor: SchemeFlavor,
|
|
backend: Box<dyn Backend>,
|
|
bridge: SettingsBridge,
|
|
) -> Self {
|
|
let width = WINDOW_SIZE.width;
|
|
let height = WINDOW_SIZE.height;
|
|
let theme_mode = bridge.load().1;
|
|
// 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 4 个只读下层键
|
|
// (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。M93 之前这些键在 app 侧**根本读不到**。
|
|
//
|
|
// 截图通道**不读用户配置**(D30:同一份代码在两台机器上要出同一张图):外观一律取出厂值,
|
|
// 矩阵需要的值由 `ULL_APPEARANCE_*` 显式灌进来。
|
|
let (appearance_prefs, appearance_base) = if options.screenshot.is_some() {
|
|
(
|
|
unlucky_presenter::prefs::AppearancePrefs::factory(),
|
|
unlucky_presenter::prefs::AppearanceBase::factory(),
|
|
)
|
|
} else {
|
|
(bridge.load().0, bridge.load_base())
|
|
};
|
|
// 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2):
|
|
// 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。
|
|
let backend_tier = super::super::backend::effective_backend(options.backend);
|
|
let mut appearance = pages::appearance::State::new(
|
|
&appearance_prefs,
|
|
&appearance_base,
|
|
backend_tier,
|
|
flavor,
|
|
);
|
|
// 取证开关(D30):`ULL_SETTINGS_GROUP=<下标>` 直达设置页的某一组(出图要看见语言行)。
|
|
if let Some(index) =
|
|
crate::ui::lang::screenshot_settings_group(options.screenshot.is_some())
|
|
{
|
|
appearance.selected_group = index;
|
|
}
|
|
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
|
|
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
|
|
let lang = LangState::boot(&bridge, options.screenshot.is_some());
|
|
let mut view = pages::Model::new(
|
|
pages::Screen::Instances,
|
|
width,
|
|
Some(pages::Screen::Instances.nav()),
|
|
);
|
|
view.lang = lang.lang();
|
|
|
|
let mut app = Self {
|
|
ui: Ui::new(flavor, families),
|
|
families,
|
|
options,
|
|
backend,
|
|
bridge,
|
|
state: AppState::new(),
|
|
ticks: 0,
|
|
config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS,
|
|
theme_mode,
|
|
lang,
|
|
last_tick: std::time::Instant::now(),
|
|
width,
|
|
view,
|
|
view_dirty: false,
|
|
watch_until: None,
|
|
window: None::<window::Id>,
|
|
frames: 0,
|
|
refreshes: 0,
|
|
auto_selected: false,
|
|
height,
|
|
appearance,
|
|
appearance_prefs,
|
|
hwnd: None,
|
|
settings: pages::settings::Groups::default(),
|
|
// 出厂是**空会话**(M119 删了 M104 的样本会话);取证开关 `ULL_AGENT_*` 的档位
|
|
// 与夹具在 `boot_agent` 里落地(它要先让 presenter 知道栏可见性,见那里)。
|
|
agent: crate::shell::agent::AgentUi::new(),
|
|
agent_host: super::presenter::AgentHost::from_env(),
|
|
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
|
|
download_draft: pages::InstallDraft::default(),
|
|
install_cancelled_seen: 0,
|
|
content: pages::content::PageState::default(),
|
|
account_form: pages::account::Form::new(),
|
|
account: pages::account::Page::build(
|
|
&AppState::new(),
|
|
&pages::account::Form::new(),
|
|
&mut pages::account::avatar::Cache::default(),
|
|
unlucky_i18n::Lang::En,
|
|
width,
|
|
),
|
|
avatars: pages::account::avatar::Cache::default(),
|
|
corner_applied: None,
|
|
};
|
|
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
|
app.rebuild_ui();
|
|
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
|
|
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
|
app.lang.log();
|
|
app.lang.log_cjk();
|
|
app
|
|
}
|
|
}
|