fix(app-iced): M119 r1 —— 常驻「模拟」标识 + 确认条的键盘通路

复审 r1 的两条 p2,都是「说成真的、其实是假的」与「能走进死路」这个级别的问题。

## P2-1:界面必须自己说清「这是模拟」

原判:生产默认后端是**脚本化 rig**(`ULL_AGENT_SCRIPT` 的 6 种剧情),而「后端未接 / 模型还没
接上」两句诚实文案被我改成了中性话,理由写着「由端点标签说」——**那条端点标签在壳里根本不存在**。
于是默认配置下用户敲一句话得到一段像是模型编的回答。判据:没读过代码的用户,打开右栏、敲一句话,
必须能自己看出来这不是真模型(不接受 tooltip、不接受藏在设置里)。

修法(①恢复诚实文案 + ②常驻可见标记,并用):

| 位置 | 文案 / 形态 | 谁给的 |
|---|---|---|
| 标题右侧**常驻胶囊**(`SurfaceVariant` tonal)| `Key::AgentSimulated`(Simulated / 模拟)| `AgentHost::is_simulated()` → `Session.simulated` → 视图 |
| 标题下副标题 | 「模拟助手 · 还没接真实模型」 | `Key::AgentSubtitle`(原本是「未接」被改成中性的「会话」,现在写回来且更明确)|
| 空会话正文 | 「…当前是模拟助手,没有接真实模型。」 | `Key::AgentEmptyBody` |
| 输入框占位符 | 「给模拟助手发消息…」 | `Key::AgentPlaceholder` |

**标记不是硬编码在视图里**:`AgentHost.simulated` 是事实字段(今天 transport 只有 rig ⇒ 恒 true)。
接真 `AgentTransport` 那天把它传 `false`,四个位置的「模拟」措辞会一起消失——这是为什么用字段而不是
写死一句话。

## P2-2:确认条必须有键盘通路

原判:Allow/Deny 只有鼠标路径 ⇒ 键盘用户在写操作确认处**永久卡死**(`busy` 锁发送、
`Stop` 走主后端、清不掉 presenter 的 pending confirm)。

- 焦点环多两格:`Focus::ConfirmAllow` / `Focus::ConfirmDeny`,**只在有待确认时**存在
  (鼠标用户不会 Tab 到不存在的按钮);
- 待确认出现 ⇒ `sync_confirm_focus()` 把焦点直接落到「允许」并**打开键盘巡航**(阻塞态主动亮环),
  消失 ⇒ 焦点收回输入框、巡航关闭;
- `Enter` 与 `Space` 都映射到 `Activate`(`Space` 不抢输入框:`keyboard_mode` 那道闸在
  鼠标点进输入框时是关的);
- 焦点环不是 10% 状态层——在**实心主色**按钮上那层几乎看不见(出图实测过一版就是没有)。
  改成 2dp `Primary` 描边 + 3px 留白,两个按钮恒定加这层 padding,所以焦点变化不移动布局。

测试(`crates/app-iced/src/app/update/agent/tests.rs`):
`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` —— 全程只有键盘消息:
确认到达后断言 `focus == ConfirmAllow && keyboard_mode`,发一条 `KeyPressed(Enter)` 完成允许;
第二轮 `Tab`(走到 Deny)+ `KeyPressed(Space)` 完成拒绝;两轮各记一条审计、焦点都收回 Composer。
另有 `the_default_backend_reports_itself_as_simulated` 钉住 P2-1 的事实来源。

## 视图入参顺带收成结构体

`docked`/`page` 本来是 6 个形参(`ui, agent, rail, failure, catalog, width`),这一轮还要加
`simulated` ⇒ 7 个。按塔台登记的可维护性口径改成 `Session { agent, rail, failure, simulated }`:
两个宿主各 3 个形参,以后再加展示事实只改 `Session`。

## 出图(18 张,浅深各一)

`DevDocs/artifacts/m119/`:新增 `08-agent-simulated-*`(空会话 + 四处「模拟」措辞)与
`09-agent-confirm-focus-*`(确认条 + 焦点环落在「允许」);其余 7 组全部重出(文案与焦点环都变了)。

## 门禁(真实退出码)

| 命令 | 退出码 |
|---|---|
| `cargo fmt --all --check` | 0 |
| `cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-agent -p unlucky-i18n --all-targets -- -D warnings` | 0(0 warning)|
| `cargo test -p unlucky-app-iced --lib` | 0(601 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2277 passed / 0 failed / 11 ignored)|

`golden.rs` 400 行(+1 条 `AgentSimulated` 正好顶线);全树受检文件无 >400。
This commit is contained in:
AskaEth committed 2026-11-05 09:17:50 +08:00
1 parent b5e96c61ac
commit a22baebe70
13 files changed
+383 -122

No files matched your search

+36 -5
View File
@@ -106,13 +106,16 @@ impl App {
self.view_dirty = true;
}
agent::Message::Tab { shift } => {
let busy = self.agent_host.rail().busy;
self.agent.step_focus(shift, busy);
let rail = self.agent_host.rail();
let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(shift, busy, pending);
}
agent::Message::Activate => self.activate_focused(catalog),
agent::Message::Key(event) => return self.on_agent_key(event),
}
// 待确认请求出现/消失时,把键盘焦点挪到(或挪离)确认条的按钮上
self.sync_confirm_focus();
// 档位 / 路由变了就要把「栏可见吗」写回 presenter(未读口径的输入)
self.sync_agent_presenter();
if geometry_changed {
@@ -184,6 +187,27 @@ impl App {
})
}
/// 待确认请求出现 ⇒ 焦点直接落在「允许」并打开键盘巡航;消失 ⇒ 收回输入框。
///
/// 为什么要**打开** `keyboard_mode`(而不是只挪焦点):待确认时 `busy` 锁着发送键,
/// `Stop` 又走主后端、清不掉 presenter 的 pending——键盘用户若够不着这两个按钮,这一轮对话
/// 就再也推不动(复审 P2-2 的原话)。所以这是一个**阻塞态**:焦点环主动亮出来,
/// 按 `Enter` 或 `Space` 就能裁决。
///
/// 唯一写入口:`on_agent` 末尾(任何会改动 rail 的分支之后都要经过它)。
fn sync_confirm_focus(&mut self) {
let pending = self.agent_host.rail().confirm.is_some();
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
if pending && !on_confirm {
self.agent.focus = Focus::ConfirmAllow;
self.agent.keyboard_mode = true;
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Enter/Space 裁决)");
} else if !pending && on_confirm {
self.agent.focus = Focus::Composer;
self.agent.keyboard_mode = false;
}
}
/// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。
///
/// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变,
@@ -235,8 +259,9 @@ impl App {
self.drain_agent();
}
Fixture::Focused => {
let busy = self.agent_host.rail().busy;
self.agent.step_focus(false, busy);
let rail = self.agent_host.rail();
let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(false, busy, pending);
}
}
}
@@ -269,7 +294,11 @@ impl App {
},
// `Activate` 只在软焦点落在**按钮**上时才动手(输入框的 Enter 由
// `text_editor::key_binding` 分流成「发送」,不能在这里重复发一次)。
Key::Named(key::Named::Enter) => agent::Message::Activate,
//
// M119 r1 / P2-2:`Space` 与 `Enter` 等价(键盘用户对确认条的习惯动作是空格)。
// 它不会抢输入框里的空格:`activate_focused` 头一行就是 `keyboard_mode` 闸,
// 而用鼠标点进输入框时那道闸是关的(`AgentUi::edit` 的 `Click` 分支)。
Key::Named(key::Named::Enter | key::Named::Space) => agent::Message::Activate,
_ => return Task::none(),
};
@@ -315,6 +344,8 @@ impl App {
agent::Message::Detach
})
}
(_, Focus::ConfirmAllow) => Some(agent::Message::Confirm(true)),
(_, Focus::ConfirmDeny) => Some(agent::Message::Confirm(false)),
(_, Focus::Send) => Some(agent::Message::Submit),
(_, Focus::Stop) => Some(agent::Message::Stop),
// 折叠档没有「拆出」、独立页没有「收起」:焦点环按 `Focus::order` 不会落到这两格,
+83 -1
View File
@@ -22,11 +22,14 @@ use unlucky_presenter::dto::{AgentEvent, AppEvent, ChatRole};
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use iced::keyboard::key::{Code, Named, Physical};
use iced::keyboard::{key, Location, Modifiers};
use crate::app::state::presenter::transport::Script;
use crate::app::state::presenter::AgentHost;
use crate::app::Message;
use crate::cli::Options;
use crate::shell::agent::{Message as AgentMessage, RailState};
use crate::shell::agent::{Focus, Message as AgentMessage, RailState};
use crate::theme::Families;
use super::App;
@@ -202,6 +205,27 @@ fn a_turn_level_failure_keeps_the_partial_answer_and_the_reason() {
}
/// 输入框的草稿会同步给 presenter(切形态不丢的语义在 presenter 那边)。
/// **P2-1**:默认配置下后端是脚本化 rig ⇒ 视图必须知道「这是模拟」(界面据此常驻标记)。
///
/// 抓得住:`is_simulated` 被写死成 `false`、或者视图拿不到这个事实(用户就会以为模型接上了)。
/// 抓不住:标记在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/08-*`)。
#[test]
fn the_default_backend_reports_itself_as_simulated() {
let app = app_with(Script::Qa);
assert!(
app.agent_host.is_simulated(),
"脚本化 rig 必须自报「模拟」,否则界面就能假装模型接上了"
);
// 空会话(最容易被误读成一屏)也要能拿到:视图入参 `Session.simulated` 就是它
let session = crate::shell::agent::Session {
agent: &app.agent,
rail: app.agent_host.rail(),
failure: app.agent_failure(),
simulated: app.agent_host.is_simulated(),
};
assert!(session.simulated);
}
#[test]
fn typing_syncs_the_draft_into_the_presenter() {
let mut app = app_with(Script::Qa);
@@ -220,3 +244,61 @@ fn typing_syncs_the_draft_into_the_presenter() {
assert!(app.agent.draft_is_blank(), "编辑器清空");
assert!(app.agent_host.rail().draft.is_empty(), "presenter 侧也清空");
}
/// 手搓一条按键事件(订阅层搬过来的就是它;`on_agent_key` 只看 `KeyPressed`)。
fn press(named: key::Named) -> iced::keyboard::Event {
let key = key::Key::Named(named);
iced::keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key: Physical::Code(Code::Enter),
location: Location::Standard,
modifiers: Modifiers::default(),
text: None,
repeat: false,
}
}
/// **P2-2 的回归闸**:不碰鼠标、只用键盘消息,能完成一次写操作确认。
///
/// 为什么这条重要:确认条原本只有鼠标路径,而待确认时 `busy` 锁着发送、`Stop` 又走主后端
/// (清不掉 presenter 的 pending)——键盘用户会在这一步**永久卡死**。
///
/// 抓得住:①焦点没主动落到确认条(`Enter` 够不着);②`Enter` / `Space` 没映射成 `Activate`;
/// ③`Tab` 环里没有确认条那两格;④裁决完焦点没收回输入框(下一句话打不进去)。
/// 抓不住:焦点环在像素上的样子(那要出图,见 `DevDocs/artifacts/m119/09-*`)。
#[test]
fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() {
let mut app = app_with(Script::WriteOp);
submit(&mut app, "把下载并发改小");
assert!(
app.agent_host.rail().confirm.is_some(),
"先要有一个待确认请求"
);
// ① 焦点主动落在「允许」,并且键盘巡航已经打开(否则 Enter 那道闸过不去)
assert_eq!(
app.agent.focus,
Focus::ConfirmAllow,
"焦点要落在可操作元素上"
);
assert!(app.agent.keyboard_mode, "阻塞态要主动亮出焦点环");
// ② Enter = 允许
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
assert!(
app.agent_host.rail().confirm.is_none(),
"Enter 必须真的裁决掉"
);
assert_eq!(app.agent_host.audit_len(), 1, "裁决进审计");
assert_eq!(app.agent.focus, Focus::Composer, "裁决完焦点收回输入框");
assert!(!app.agent.keyboard_mode, "阻塞态结束就不再亮焦点环");
// ③ 第二轮走「Tab 到拒绝 + Space 触发」:键盘用户不必背 Enter 是哪一个
submit(&mut app, "那把并发改回来");
assert_eq!(app.agent.focus, Focus::ConfirmAllow);
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
assert_eq!(app.agent.focus, Focus::ConfirmDeny, "Tab 要能走到「拒绝」");
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
assert!(app.agent_host.rail().confirm.is_none(), "Space 也要能裁决");
assert_eq!(app.agent_host.audit_len(), 2);
}
+10 -20
View File
@@ -58,10 +58,15 @@ impl App {
let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
// M119:会话内容 / 未读 / busy / 待确认全部来自 presenter 的 rail,失败横幅是 app 层
// 备好的**展示输入**(标题 catalog 键 + 已脱敏正文)。壳在这里不做任何脱敏。
let rail = self.agent_host.rail();
let failure = self.agent_failure();
// 界面语言的真值源是 `App::lang`(M106):右栏的静态文案也走它,不再有 `const LANG`
let agent_catalog = self.lang().catalog();
// 会话入参:rail 借 presenter、失败横幅由 app 层备好、`simulated` 是后端的事实
let session = agent::Session {
agent: &self.agent,
rail: self.agent_host.rail(),
failure: self.agent_failure(),
simulated: self.agent_host.is_simulated(),
};
let account = self.rail_account();
match pages::Screen::of(self.state.route()) {
@@ -85,15 +90,8 @@ impl App {
self.language(),
)
.map(Message::Page),
agent::docked(
&self.ui,
&self.agent,
rail,
failure,
agent_catalog,
self.width,
)
.map(|element| element.map(Message::Agent)),
agent::docked(&self.ui, session, agent_catalog, self.width)
.map(|element| element.map(Message::Agent)),
overlay,
self.options.force_state,
self.lang(),
@@ -112,15 +110,7 @@ impl App {
},
APP_TITLE,
background,
agent::page(
&self.ui,
&self.agent,
rail,
failure,
agent_catalog,
self.width,
)
.map(Message::Agent),
agent::page(&self.ui, session, agent_catalog, self.width).map(Message::Agent),
None,
overlay,
self.options.force_state,
+6 -3
View File
@@ -55,9 +55,12 @@ impl App {
// M119:右栗的会话内容 / 未读 / busy 全部来自 presenter 的 rail(`agent_host`)
agent::docked(
&self.ui,
&self.agent,
self.agent_host.rail(),
self.agent_failure(),
agent::Session {
agent: &self.agent,
rail: self.agent_host.rail(),
failure: self.agent_failure(),
simulated: self.agent_host.is_simulated(),
},
self.lang().catalog(),
self.width,
)
+41 -12
View File
@@ -90,6 +90,11 @@ pub struct Conversation<'a> {
pub confirm: Option<&'a ConfirmRequest>,
/// 失败横幅(`Some` = 画具名错误;见 [`Failure`])。
pub failure: Option<Failure<'a>>,
/// 这套后端是不是模拟的(`true` ⇒ 标题旁常驻「模拟」标记 + 副标题说清楚)。
///
/// M119 r1 / 复审 P2-1:生产默认后端是脚本化 rig,而界面上曾**没有任何模拟标识**——
/// 用户敲一句话得到一段像是模型编的回答。这个字段就是那个事实的渲染入口(不是硬编码)。
pub simulated: bool,
/// 三档状态(决定头部画哪组动作)。
pub rail: RailState,
/// 宿主。
@@ -121,7 +126,7 @@ pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element
// 确认条贴着输入区(动作在下面,读完就能按)。
if let Some(request) = spec.confirm {
body = body.push(banners::confirm(ui, request, catalog));
body = body.push(banners::confirm(ui, request, catalog, spec.focus));
}
body.push(divider(ui))
@@ -132,19 +137,21 @@ pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element
// ---------------------------------------------------------------- 头部
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),
]
let mut title = row![text_role(
ui,
TypeRole::TitleLarge,
true,
catalog.text(Key::AgentTitle),
ui.color(Role::OnSurface),
Length::Shrink,
)]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
// 常驻标记:与标题同一行,不点开、不 hover、不藏在设置里(P2-1 的判据)
if spec.simulated {
title = title.push(simulated_chip(ui, catalog));
}
title = title.push(unread_badge(ui, spec.unread, catalog));
let titles = column![
title,
@@ -268,6 +275,28 @@ pub(super) fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> B
}
}
/// 「模拟」标记:`SurfaceVariant` / `OnSurfaceVariant` 的 tonal 小胶囊(中性信息,不是告警)。
fn simulated_chip<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
let style = ui.style(TypeRole::LabelMedium);
container(
text(catalog.text(Key::AgentSimulated))
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
)
.padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)]))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SurfaceVariant))),
border: Border {
radius: ui.radius(ShapeScale::Full, ui.metrics().control_height / 2.0),
..Border::default()
},
..container::Style::default()
})
.into()
}
fn unread_badge<'a>(ui: &'a Ui, unread: usize, catalog: Catalog) -> Element<'a, Message> {
if unread == 0 {
return space::horizontal().into();
@@ -35,7 +35,9 @@ use unlucky_presenter::dto::ConfirmRequest;
use crate::app::components::buttons::{self, Button};
use crate::theme::Ui;
use super::super::state::Focus;
use super::super::{Failure, Message};
use super::focused_state;
use super::text_role_owned;
/// 失败横幅:`ErrorContainer` 图标块 + 标题/正文(M3 内联 alert 的形状,与账户页横幅同源)。
@@ -57,6 +59,7 @@ pub(super) fn confirm<'a>(
ui: &'a Ui,
request: &'a ConfirmRequest,
catalog: Catalog,
focus: Option<Focus>,
) -> Element<'a, Message> {
// 摘要来自 presenter(已过 `scrub_text`):它是「要改什么」的唯一描述,必须显示,
// 否则用户是在盲签。句框走 catalog,摘要本身是正文。
@@ -64,21 +67,67 @@ pub(super) fn confirm<'a>(
Key::AgentConfirmRequest,
&[("summary", request.summary.as_str())],
);
// 两个按钮都在 Tab 环上(`Focus::ConfirmAllow` / `Focus::ConfirmDeny`):键盘用户
// 用 Enter/Space 就能裁决,不必去找鼠标(P2-2 的那条死路)
let deny = focus == Some(Focus::ConfirmDeny);
let allow = focus == Some(Focus::ConfirmAllow);
let actions: Vec<Element<'a, Message>> = vec![
buttons::outlined(
ring(
ui,
Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false))
.leading(IconKind::Close),
buttons::outlined(
ui,
focused_state(
Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false))
.leading(IconKind::Close),
deny,
),
),
deny,
),
buttons::filled(
ring(
ui,
Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true))
.leading(IconKind::Check),
buttons::filled(
ui,
focused_state(
Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true))
.leading(IconKind::Check),
allow,
),
),
allow,
),
];
banner(ui, title, String::new(), actions)
}
/// 焦点环:`Primary` 的 2dp 描边(与输入框聚焦同一套语言)。
///
/// 为什么不能只用 `Interaction::Focused`(10% 状态层):在**实心主色**按钮上那层几乎看不出来
/// ——M119 r1 复审要求「焦点确实落在可操作元素上」且要能出图证明,10% 层在浅色图里等于没有。
/// 所以键盘焦点在这两个按钮上额外加一圈描边(鼠标用户的界面不受影响:只有 `focused` 才画)。
fn ring<'a>(ui: &'a Ui, button: Element<'a, Message>, focused: bool) -> Element<'a, Message> {
// 外圈留 3px 空气:环与按钮之间不贴边,才看得成「一圈」而不是「两条重合的描边」。
// 两个按钮**都**加这层 padding(`focused` 与否一样),所以焦点变化不移动布局。
let gap = ui.space(Space::X1) * 0.75;
let outer = ui.metrics().control_height + gap * 2.0;
container(button)
.padding(gap)
.style(move |_theme| container::Style {
border: if focused {
iced::Border {
color: ui.color(Role::Primary),
width: crate::theme::STROKE_PX * 2.0,
// 与按钮同一种「药丸」:`Full` 的半径 = 边长 / 2(这里用加过 padding 的边长)
radius: ui.radius(ShapeScale::Full, outer),
}
} else {
iced::Border::default()
},
..container::Style::default()
})
.into()
}
/// 横幅本体:图标块 + 标题/正文 + 右侧动作。
///
/// 形状与 `pages::account::widgets::alert` 同源(面板底走卡片层 + 1dp 描边,
+49 -46
View File
@@ -126,23 +126,38 @@ pub struct Failure<'a> {
pub body: &'a str,
}
/// 两个宿主共用的**会话入参**(`Session` = 从 presenter 借来的那一份 + 两个展示事实)。
///
/// 为什么是一个结构体而不是继续加形参:`docked`/`page` 已经收到 6 个形参,这一轮还要加
/// 「是不是模拟后端」——那会变成 7 个,正是塔台登记过的可维护性观察项。收成结构体之后两个宿主
/// 各 3 个形参,且以后再加展示事实只改这个结构体。
#[derive(Clone, Copy)]
pub struct Session<'a> {
/// 三档/草稿/焦点(纯 UI 态)。
pub agent: &'a AgentUi,
/// 会话内容 / 未读 / busy / 待确认(presenter 的唯一真相)。
pub rail: &'a AgentRail,
/// 失败横幅的展示输入。
pub failure: Option<Failure<'a>>,
/// 这套后端是不是模拟的(M119 r1 / 复审 P2-1)。`true` ⇒ 头部常驻「模拟」标记。
pub simulated: bool,
}
/// 停靠档的右栗(`None` = 已拆成独立页,右栗不占地)。
///
/// 两档共用同一条 `Element` 通道交给外壳的 `row!`:折叠是 48 的条,展开是「8 拖拽缝 + 面板」。
pub fn docked<'a>(
ui: &'a Ui,
agent: &'a AgentUi,
rail: &'a AgentRail,
failure: Option<Failure<'a>>,
session: Session<'a>,
catalog: Catalog,
window_width: f32,
) -> Option<Element<'a, Message>> {
// 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条
// (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。
match agent.effective_rail(window_width) {
match session.agent.effective_rail(window_width) {
RailState::Detached => None,
RailState::Collapsed => Some(strip::strip(ui, rail, catalog)),
RailState::Expanded => Some(panel(ui, agent, rail, failure, catalog, window_width)),
RailState::Collapsed => Some(strip::strip(ui, session.rail, catalog)),
RailState::Expanded => Some(panel(ui, session, catalog, window_width)),
}
}
@@ -151,15 +166,13 @@ pub fn docked<'a>(
/// 页级骨架(页边距 + 正文列宽)与其它页面同源:列宽取 [`layout::frame_metrics`],单列 ≤424。
pub fn page<'a>(
ui: &'a Ui,
agent: &'a AgentUi,
rail: &'a AgentRail,
failure: Option<Failure<'a>>,
session: Session<'a>,
catalog: Catalog,
window_width: f32,
) -> Element<'a, Message> {
let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width);
let body = conversation::view(ui, spec(agent, rail, failure, Host::Page), catalog);
let body = conversation::view(ui, spec(session, Host::Page), catalog);
container(
container(body)
@@ -211,31 +224,25 @@ pub fn drag_overlay<'a>(agent: &AgentUi) -> Option<Element<'a, Message>> {
fn panel<'a>(
ui: &'a Ui,
agent: &'a AgentUi,
rail: &'a AgentRail,
failure: Option<Failure<'a>>,
session: Session<'a>,
catalog: Catalog,
window_width: f32,
) -> Element<'a, Message> {
let width = agent.rail_width(window_width);
let width = session.agent.rail_width(window_width);
let body = container(conversation::view(
ui,
spec(agent, rail, failure, Host::Rail),
catalog,
))
.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()
});
let body = container(conversation::view(ui, spec(session, Host::Rail), catalog))
.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, width, catalog), body]
.width(Length::Shrink)
@@ -293,24 +300,20 @@ fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message
}
/// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。
fn spec<'a>(
agent: &'a AgentUi,
rail: &'a AgentRail,
failure: Option<Failure<'a>>,
host: Host,
) -> Conversation<'a> {
fn spec<'a>(session: Session<'a>, host: Host) -> Conversation<'a> {
Conversation {
// 会话内容、未读、busy、待确认:**全部**来自 presenter(M119)
messages: &rail.messages,
busy: rail.busy,
unread: rail.unread,
confirm: rail.confirm.as_ref(),
failure,
draft: &agent.draft,
blank: agent.draft_is_blank(),
rail: agent.rail,
messages: &session.rail.messages,
busy: session.rail.busy,
unread: session.rail.unread,
confirm: session.rail.confirm.as_ref(),
failure: session.failure,
simulated: session.simulated,
draft: &session.agent.draft,
blank: session.agent.draft_is_blank(),
rail: session.agent.rail,
host,
// 只有键盘巡航过才画焦点环(鼠标操作时那是噪音,而且会被误读成 tonal 按钮)
focus: agent.keyboard_mode.then_some(agent.focus),
focus: session.agent.keyboard_mode.then_some(session.agent.focus),
}
}
+23 -7
View File
@@ -72,6 +72,13 @@ pub enum Focus {
Collapse,
/// `⇥` 拆出(独立页上则是 `⇤` 收回,同一个焦点位)。
Detach,
/// 确认条的「允许」(只在有待确认写操作时存在于环上)。
///
/// M119 r1 / 复审 P2-2:确认条原本只有鼠标路径——键盘用户在写操作确认这一步**卡死**
/// (`busy` 锁住发送,`Stop` 走的是主后端、清不掉 presenter 的待确认)。这两格就是那条键盘通路。
ConfirmAllow,
/// 确认条的「拒绝」。
ConfirmDeny,
/// 发送。
Send,
/// 中断(只在流式中出现)。
@@ -86,14 +93,19 @@ impl Focus {
match self {
Focus::Collapse => "collapse",
Focus::Detach => "detach",
Focus::ConfirmAllow => "confirm-allow",
Focus::ConfirmDeny => "confirm-deny",
Focus::Send => "send",
Focus::Stop => "stop",
Focus::Composer => "composer",
}
}
/// Tab 环上「这一档真正存在的控件」(流式中才多一个中断键;折叠档没有会话动作)。
fn order(state: RailState, busy: bool) -> Vec<Focus> {
/// Tab 环上「这一档真正存在的控件」。
///
/// `confirm_pending` = 有待确认的写操作(那时条上多两格:允许 / 拒绝)。`busy` 在等确认时
/// 也是真(presenter 的 `busy = streaming || confirm.is_some()`),所以环上还会多一格中断。
fn order(state: RailState, busy: bool, confirm_pending: bool) -> Vec<Focus> {
let mut order = Vec::new();
match state {
RailState::Collapsed => order.push(Focus::Collapse),
@@ -103,6 +115,10 @@ impl Focus {
}
RailState::Detached => order.push(Focus::Detach),
}
if confirm_pending {
order.push(Focus::ConfirmAllow);
order.push(Focus::ConfirmDeny);
}
if state != RailState::Collapsed {
order.push(Focus::Send);
if busy {
@@ -116,8 +132,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);
pub fn step(self, shift: bool, state: RailState, busy: bool, confirm_pending: bool) -> Focus {
let order = Self::order(state, busy, confirm_pending);
let Some(at) = order.iter().position(|focus| *focus == self) else {
return if shift { order.last() } else { order.first() }
.copied()
@@ -283,9 +299,9 @@ impl AgentUi {
self.pointer = None;
}
/// Tab 走一格(走一次就把键盘巡航打开)。`busy` 由 presenter 的 rail 给。
pub fn step_focus(&mut self, shift: bool, busy: bool) {
self.focus = self.focus.step(shift, self.rail, busy);
/// Tab 走一格(走一次就把键盘巡航打开)。`busy` / `confirm_pending` 都由 presenter 的 rail 给。
pub fn step_focus(&mut self, shift: bool, busy: bool, confirm_pending: bool) {
self.focus = self.focus.step(shift, self.rail, busy, confirm_pending);
self.keyboard_mode = true;
}
}
+46 -10
View File
@@ -192,6 +192,9 @@ fn every_action_has_a_readable_label() {
Key::AgentErrorTitle,
Key::AgentErrorUnavailable,
Key::AgentErrorNotImplemented,
// M119 r1 / P2-1:「这是模拟」的常驻标记也必须有词(用户不点就要能看到)
Key::AgentSimulated,
Key::AgentSubtitle,
] {
assert!(
!catalog.text(key).trim().is_empty(),
@@ -234,7 +237,7 @@ 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);
focus = focus.step(false, RailState::Expanded, false, false);
walked.push(focus);
}
assert_eq!(
@@ -250,33 +253,53 @@ fn the_focus_ring_walks_the_controls_that_exist() {
// 反向:Collapse 往回走是 Composer
assert_eq!(
Focus::Collapse.step(true, RailState::Expanded, false),
Focus::Collapse.step(true, RailState::Expanded, false, false),
Focus::Composer
);
// 流式中多一档中断键(`busy` 由 presenter 的 rail 给)
assert_eq!(
Focus::Send.step(false, RailState::Expanded, true),
Focus::Send.step(false, RailState::Expanded, true, false),
Focus::Stop
);
// 独立页只有「收回 + 输入区」
assert_eq!(
Focus::Detach.step(false, RailState::Detached, false),
Focus::Detach.step(false, RailState::Detached, false, false),
Focus::Send
);
// 折叠档没有会话动作:环上只有「展开」一格
assert_eq!(
Focus::Collapse.step(false, RailState::Collapsed, false),
Focus::Collapse.step(false, RailState::Collapsed, false, false),
Focus::Collapse
);
// `Tab` 真的把键盘巡航打开(`Enter` 的闸门)
let mut agent = AgentUi::new();
assert!(!agent.keyboard_mode);
agent.step_focus(false, false);
agent.step_focus(false, false, false);
assert!(agent.keyboard_mode);
// M119 r1 / P2-2:有待确认写操作时,环上多两格(允许 / 拒绝),且**排在发送之前**——
// 阻塞态该先被看见。这也是键盘用户唯一能推进这一轮的路径。
assert_eq!(
Focus::Detach.step(false, RailState::Expanded, true, true),
Focus::ConfirmAllow
);
assert_eq!(
Focus::ConfirmAllow.step(false, RailState::Expanded, true, true),
Focus::ConfirmDeny
);
assert_eq!(
Focus::ConfirmDeny.step(false, RailState::Expanded, true, true),
Focus::Send
);
// 没有待确认时这两格不在环上(鼠标用户不该 Tab 到不存在的按钮)
assert_eq!(
Focus::Detach.step(false, RailState::Expanded, false, false),
Focus::Send
);
}
/// 视图契约:`spec()` 把 presenter 的三件事**原样借**给视图(零拷贝、不复制)。
@@ -288,12 +311,19 @@ fn the_spec_borrows_the_presenter_rail_verbatim() {
let mut agent = AgentUi::new();
agent.expand();
let rail = conversation_rail();
let spec = super::spec(&agent, &rail, None, Host::Rail);
let session = Session {
agent: &agent,
rail: &rail,
failure: None,
simulated: true,
};
let spec = super::spec(session, Host::Rail);
assert_eq!(spec.messages.len(), rail.messages.len());
assert_eq!(spec.messages[0].text, rail.messages[0].text, "同一条消息");
assert_eq!(spec.unread, rail.unread);
assert_eq!(spec.busy, rail.busy);
assert!(spec.failure.is_none());
assert!(spec.simulated, "模拟事实要原样传给视图(标记不是硬编码)");
}
/// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。
@@ -330,9 +360,14 @@ fn every_state_builds_in_both_hosts() {
let mut agent = AgentUi::new();
agent.rail = state;
for shown in [None, Some(failure)] {
let _: iced::Element<'_, Message> =
page(&ui, &agent, rail, shown, catalog, window);
let _ = docked(&ui, &agent, rail, shown, catalog, window);
let session = Session {
agent: &agent,
rail,
failure: shown,
simulated: true,
};
let _: iced::Element<'_, Message> = page(&ui, session, catalog, window);
let _ = docked(&ui, session, catalog, window);
let spec = Conversation {
messages: &rail.messages,
draft: &agent.draft,
@@ -341,6 +376,7 @@ fn every_state_builds_in_both_hosts() {
blank: agent.draft_is_blank(),
confirm: rail.confirm.as_ref(),
failure: shown,
simulated: true,
rail: agent.rail,
host: Host::Rail,
focus: agent.keyboard_mode.then_some(agent.focus),
@@ -32,11 +32,15 @@ fn the_detached_state_frees_the_rail_slot() {
let ui = ui();
let mut agent = AgentUi::new();
agent.detach();
let session = super::super::Session {
agent: &agent,
rail: &AgentRail::new(),
failure: None,
simulated: true,
};
assert!(docked(
&ui,
&agent,
&AgentRail::new(),
None,
session,
unlucky_i18n::Catalog::of(unlucky_i18n::Lang::En),
1180.0
)
+13
View File
@@ -85,6 +85,12 @@ pub(crate) enum SubmitOutcome {
/// AI 助手栏的 presenter 句柄。
pub(crate) struct AgentHost {
presenter: Presenter,
/// 这套后端是不是**模拟**的(M119 r1 / 复审 P2-1)。
///
/// 今天恒 `true`:transport 只有 `RigTransport` 一种(脚本化,没有真模型)。接真 `AgentTransport`
/// 的那一天,这里传 `false`,界面上的「模拟」标记会自动消失——**标记不是硬编码在视图里的**,
/// 它是这个事实字段的渲染。
simulated: bool,
queue: EventQueue,
failure: FailureSlot,
/// 最近一次提交失败(具名;下一次成功提交或重试用尽时清掉)。
@@ -101,6 +107,8 @@ impl AgentHost {
let failure = transport.failure();
Self {
presenter: Presenter::new(Box::new(transport)),
// 脚本化 rig ⇒ 一定是模拟的(见字段文档)
simulated: true,
queue,
failure,
last_failure: None,
@@ -118,6 +126,11 @@ impl AgentHost {
self.presenter.agent_rail()
}
/// 这套后端是不是模拟的(界面据此常驻「模拟」标记;见字段文档)。
pub(crate) fn is_simulated(&self) -> bool {
self.simulated
}
/// 最近一次提交失败的具名原因。
pub(crate) fn last_failure(&self) -> Option<AgentFailure> {
self.last_failure