R4.10 round 1 fixes: background copies, menu offer served once

Copy as Rich Text, HTML Code and Plain Text convert off the window
thread; a newer copy cancels an older one. Offer hands its request to
the menu host once, so a dismissed menu stays shut when its tab returns.
ElyMark Delete Image now targets the pressed picture (component a4bf696).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 21:17:10 -04:00
1 parent 5462b8fb0e
commit 67fa5bfc94
7 files changed
+111 -45

No files matched your search

+1 -1
View File
@@ -293,6 +293,6 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-14:ElyShow 的右键(R5.6)。画布右键照 PowerPoint 按目标出 Ely 的菜单(`apps/ely-show/src/menu.rs`,共用 `ely_office::menu`);`ely_lok::menu` 的目标另有幻灯片(有 `.uno:SlideLayoutMenu`)与 Writer 之外的图片(有 `.uno:ChangePicture`)。幻灯片为版式 ▸、新建批注;编辑文字为项目符号、编号、新建批注;图形与文本框为组合 ▸、置于顶层 ▸、置于底层 ▸、新建批注;图片另有替换…、裁剪;表格为插入 ▸、删除 ▸、合并单元格、新建批注;剪贴板同为图标行。每项可否取自内核对其命令此刻的状态(补丁 0045):整表选中时可插入行列,删除与合并要光标进单元格(内核的规则)。Impress 的 `LOK_CALLBACK_TABLE_SELECTED` 由 boost 属性树写出,数值与真假都是文字,空列表是空串,`table::read` 照收;拖动表格边线走 R5.3 的同一路径(`.uno:TableChangeCurrentBorderPosition`,偏移为 twips,内核换算)。补丁 `0047-lok-impress-table-edges.patch`:Impress 原只给内侧边线的上下限,行的下限是布局器写死的 600(上游待办),参考线与落点不符;补丁让两轴都带首尾边线的上下限(`leftMin`、`leftMax`、`rightMin`、`rightMax`,列只限从左到右),内侧行线取行自身的最小高度,`table::Lines::ends` 收下,有则按它夹住(内侧线也直接用内核的上限),Writer 不给时照旧留 40 twips;已在界外的线从不逆着拖动方向移动,原地按下松开不发命令;最左列边线原什么也不做却记一步撤销(上游待办),LOK 下改为改首列宽并随边线移动表格,同一步撤销,桌面的拖动手柄不变。正编辑的单元格按编辑器量最小高度,略大于内核真正允许的(约 2 像素),新表的行因此不能再缩,参考线与落点仍一致。正编辑表格单元格时在幻灯片空白处右键,内核只结束编辑、不给菜单,再按一次才有(BUGS P3-11)。
- 2026-10-14(Gitea #1,Linux Wayland 报):ElyShow 左侧缩略图条滚轮不滚。根因:列表包在 `ContextMenu` 宿主里,宿主无尺寸,列表随内容长高、从不溢出,滚轮无处可滚(右栏正常);宿主改为 `size_full`(同 ElyPDF 的三处),`tests/strip.rs` 以 24 张幻灯片核对滚轮滚到最后一张。各应用其余 `ContextMenu` 宿主已核:ElyPDF 三处本有尺寸,ElyTable 的工作表标签条与下拉选项不是滚动列表。
- 2026-10-14:ElyMark 的菜单与快捷键(R4.9,MARK-15)。项与键取自 Typora 1.14.10 主进程字节码的字符串表(`.work/typora-main-menus.txt`)与其 `zh-Hans.lproj/Menu.json`;实际大小在字节码里有 Ctrl+Shift+9 与 Ctrl+Shift+0 两串,照用户所定用 Ctrl+Shift+0,R4.11 在真窗口核对。表格是“段落”的子菜单(照 Typora),顶层不再有“表格”。原 Ctrl+1/2/3 切模式、Ctrl+=/-/0 字号、Ctrl+Shift+F 专注让给 Typora 的键;可视、源码、预览留在“视图”菜单,无键。Ctrl+/ 另绑在 `ElyEditor` 上(gpui 同深度后绑者胜),盖过代码编辑器的 `//` 注释切换(Markdown 没有这种注释);`MarkdownDocument` 的键上下文只在可视模式有。带 Shift 的标点:Windows 写键名(`ctrl-shift-[`),macOS 与 Linux 写字符(`cmd-{`),见 `blocks::shifted`。Ctrl+Enter 在打开的块里原是输入框的 Submit(文档不处理),改绑为下方插入行。拒绝一律成信息提示:当场的由 `Desk::edit` 说,后台读出的经 `DocumentEvent::EditRefused`、`view::Refused` 到宿主。菜单打开时,打开的块把焦点借给菜单,块与选区都留着,菜单命令照样作用于所选(组件 6d90351)。复制为 Markdown 与粘贴为纯文本只作用于文档:焦点在文档外的输入框(查找框、对话框)时交给该框自己的复制、粘贴。表格子菜单末项为删除表格(组件 `TableEdit::Delete`,一步撤销,插入点不在表格里即拒绝;在打开的块里删除后焦点回到文档,撤销键照用,组件 6c2d28c)。Typora 有而组件库暂缺的不进菜单:行内公式、上下标、注释、提示块子菜单、任务状态、列表缩进 Ctrl+[/]、代码工具。
- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。
- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。第 1 轮后:右键的目标按所点的字节读(组件 79e94f7),保留的选区只供剪贴板,删除图片删所点的那张;三种“复制为”在后台转换,界面线程只写剪贴板,新的复制取代未完成的旧复制(`Desk::copying`);`ely_office::menu::Offer` 只在首次绘制时把请求交给宿主(宿主按元素存的“已开”记录随标签切走而丢,切回会重开),ElyDoc、ElyTable、ElyShow 同受其益。
- 2026-10-14(用户报:整个软件中文显示问题很多):中文显示(R4.13)。Windows 上 gpui 以用户区域(本机为 en-US)排版全部文字,DirectWrite 按日文为汉字选回退字体,日文字体缺的简体字再落到另一字体,同一个词里字形与粗细不一(ElyTree 的“打开仓库”:“打开”粗、“仓库”细)。`vendor/gpui-pre-windows` 的 `text_locale`:用户区域不是中日韩时,改用 Windows 语言列表(WinRT `GlobalizationPreferences::Languages`,Win32 的界面语言只给 en-US)里第一个中日韩语言排版(日志 `text: laid out as …`);在 Windows 上打中文须把中文加进该列表,所以它就是用户读的汉字。只换排版的区域,字体族名与字体列表仍按用户区域。读不出语言列表即记警告、照用户区域。Ely 的界面语言是中文时(设置或 `ELY_LOCALE`),`ely_ui::i18n::install_as` 经 `gpui_windows::set_text_language` 让排版随它,不论 Windows 区域(日文区域下“打开仓库”原为 Yu Gothic UI 600 与 Microsoft JhengHei UI 700 两种字);语言下次启动生效,故启动时设一次。组件库 6df9ab0:Ely 用到的组件里写死的英文(下拉的“Choose…”、清除、关闭、移除、取消固定的提示,公式补全的“No suggestions”,设置里字号的重置提示与示例句)改走内建中英目录。a4bf696:查找框(计数、三个选项与上下一处的提示、替换与全部替换)、排序与筛选(添加规则、满足全部或任一、条件、升降序、添加排序)、密码框的显示与隐藏提示同此;筛选条件以目录键为值(`Test::key`);ElyMark 的删除图片按右键所点的字节(`delete_image(at)`)。仍为英文的是内核的文字(LibreOffice 界面语言固定英文:幻灯片占位“Click to add Title”、母版“Default”、配色名、数字格式代码“General”、函数名),以及启动失败对话框(出现在读入语言之前,中英并列)。
- 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。
+32
View File
@@ -161,3 +161,35 @@ fn html_code_keeps_picture_addresses(cx: &mut TestAppContext) {
"{html}"
);
}
#[gpui::test]
fn copies_convert_off_the_window_thread(cx: &mut TestAppContext) {
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
cx.write_to_clipboard(gpui::ClipboardItem::new_string("before".into()));
// The context's dispatch parks; this one does not.
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
assert_eq!(clipboard(cx).as_deref(), Some("before"), "not on the window thread");
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyPlain), cx));
cx.run_until_parked();
assert_eq!(clipboard(cx).as_deref(), Some("alpha it\n"));
}
#[gpui::test]
fn a_shut_menu_stays_shut_when_its_tab_returns(cx: &mut TestAppContext) {
let (desk, cx, files) = opened("alpha\n", cx);
right_on(0, 1, cx);
assert!(cx.debug_bounds("menu-button-0-0").is_some(), "opened");
cx.simulate_keystrokes("escape");
cx.run_until_parked();
assert!(cx.debug_bounds("menu-button-0-0").is_none(), "shut");
let other = files[1].path().join("b.md");
std::fs::write(&other, "beta\n").expect("b");
crate::desk::tests::open(&desk, &other, cx);
let first = desk.read_with(cx, |desk, _| desk.core.tabs[0].id);
desk.update(cx, |desk, cx| {
desk.core.active = Some(first);
cx.notify();
});
cx.run_until_parked();
assert!(cx.debug_bounds("menu-button-0-0").is_none(), "not replayed");
}
+3
View File
@@ -56,6 +56,8 @@ pub struct Desk {
asked_print: Option<ely_files::Fingerprint>,
/// The find box, while open.
pub(crate) finding: Option<crate::finding::Find>,
/// The copy being converted; a newer one drops it.
pub(crate) copying: Option<gpui::Task<()>>,
}
impl Host for Desk {
@@ -205,6 +207,7 @@ pub(crate) fn open_in(
asked: None,
asked_print: None,
finding: None,
copying: None,
}
});
let menus = crate::keys::menus(&look, cx);
+66 -39
View File
@@ -9,15 +9,30 @@ use ely_ui::i18n::text;
use gpui::{ClipboardItem, Context, Div, InteractiveElement as _, actions};
use crate::desk::Desk;
use crate::output::{self, Pictures};
use crate::output::{self, Page, Pictures};
actions!(mark, [CopyRich, CopyHtml, CopyPlain]);
/// What a copy turns the Markdown into.
#[derive(Clone, Copy)]
enum As {
Rich,
Html,
Plain,
}
/// A converted copy, ready for the clipboard.
enum Copied {
Rich(Clip, Page),
Html(String, Page),
Plain(String),
}
impl Desk {
pub(super) fn copy_actions(root: Div, cx: &mut Context<Self>) -> Div {
root.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy_rich(cx)))
.on_action(cx.listener(|desk, _: &CopyHtml, _, cx| desk.copy_html(cx)))
.on_action(cx.listener(|desk, _: &CopyPlain, _, cx| desk.copy_plain(cx)))
root.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy(As::Rich, cx)))
.on_action(cx.listener(|desk, _: &CopyHtml, _, cx| desk.copy(As::Html, cx)))
.on_action(cx.listener(|desk, _: &CopyPlain, _, cx| desk.copy(As::Plain, cx)))
}
/// The selection or whole text, and the file's folder.
@@ -44,52 +59,64 @@ impl Desk {
self.toast(said, Severity::Danger, cx);
}
/// HTML for pasting, the Markdown beside it.
fn copy_rich(&mut self, cx: &mut Context<Self>) {
/// Converts in the background, then writes the clipboard.
fn copy(&mut self, kind: As, cx: &mut Context<Self>) {
let Some((markdown, beside)) = self.copied_text(cx) else {
return;
};
let copied =
output::body(&markdown, beside.as_deref(), &Pictures::Inline).and_then(|mut page| {
let clip = Clip::Html {
html: std::mem::take(&mut page.html),
text: markdown,
};
clipboard::write(&clip)
.map(|()| page)
.map_err(|error| std::io::Error::other(format!("{error:#}")))
self.copying = Some(cx.spawn(async move |desk, cx| {
let converted = cx
.background_executor()
.spawn(async move { convert(kind, markdown, beside) })
.await;
let landed = desk.update(cx, |desk, cx| {
desk.copying = None;
desk.copied(converted, cx);
});
match copied {
Ok(page) => self.left_out(&page, cx),
Err(error) => self.copy_failed(&error, cx),
}
}
/// The HTML as text; pictures keep their addresses.
fn copy_html(&mut self, cx: &mut Context<Self>) {
let Some((markdown, beside)) = self.copied_text(cx) else {
return;
};
match output::body(&markdown, beside.as_deref(), &Pictures::Written) {
Ok(mut page) => {
log::info!("mark: {} bytes copied as HTML code", page.html.len());
cx.write_to_clipboard(ClipboardItem::new_string(std::mem::take(&mut page.html)));
self.left_out(&page, cx);
if landed.is_err() {
log::warn!("mark: a copy finished after its window closed");
}
Err(error) => self.copy_failed(&error, cx),
}
}));
}
fn copy_plain(&mut self, cx: &mut Context<Self>) {
let Some((markdown, _)) = self.copied_text(cx) else {
return;
};
match output::plain(&markdown) {
Ok(plain) => {
fn copied(&mut self, converted: std::io::Result<Copied>, cx: &mut Context<Self>) {
let written = converted.and_then(|copied| match copied {
Copied::Rich(clip, page) => clipboard::write(&clip)
.map(|()| Some(page))
.map_err(|error| std::io::Error::other(format!("{error:#}"))),
Copied::Html(html, page) => {
log::info!("mark: {} bytes copied as HTML code", html.len());
cx.write_to_clipboard(ClipboardItem::new_string(html));
Ok(Some(page))
}
Copied::Plain(plain) => {
log::info!("mark: {} bytes copied as plain text", plain.len());
cx.write_to_clipboard(ClipboardItem::new_string(plain));
Ok(None)
}
});
match written {
Ok(Some(page)) => self.left_out(&page, cx),
Ok(None) => {}
Err(error) => self.copy_failed(&error, cx),
}
}
}
/// The copy as `kind`, converted off the window thread.
fn convert(kind: As, markdown: String, beside: Option<PathBuf>) -> std::io::Result<Copied> {
let beside = beside.as_deref();
match kind {
As::Rich => output::body(&markdown, beside, &Pictures::Inline).map(|mut page| {
let html = std::mem::take(&mut page.html);
let clip = Clip::Html {
html,
text: markdown,
};
Copied::Rich(clip, page)
}),
As::Html => output::body(&markdown, beside, &Pictures::Written)
.map(|mut page| Copied::Html(std::mem::take(&mut page.html), page)),
As::Plain => output::plain(&markdown).map(Copied::Plain),
}
}
+1
View File
@@ -74,6 +74,7 @@ fn rich_copy_takes_the_selection_as_html(cx: &mut TestAppContext) {
.update(cx, |editor, cx| editor.select(std::iter::once(8..22), cx))
});
cx.dispatch_action(crate::export::CopyRich);
cx.run_until_parked();
let clip = ely_platform::clipboard::read()
.expect("reads")
.expect("a clip");
+7 -4
View File
@@ -1,6 +1,6 @@
//! Ely's right-click menus, gated by the kernel's (DOC-02).
use std::io;
use std::{cell::Cell, io};
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
@@ -15,18 +15,21 @@ pub struct Offer<W> {
pub what: W,
/// Bumps for each menu, so each opens once.
request: (u64, Point<Pixels>),
/// Handed to a host; a remounted host must not reopen.
served: Cell<bool>,
}
impl<W> Offer<W> {
/// The next menu after `last`.
pub fn new(last: Option<&Offer<W>>, tab: u64, what: W, at: Point<Pixels>) -> Self {
let number = last.map_or(1, |last| last.request.0 + 1);
Offer { tab, what, request: (number, at) }
Offer { tab, what, request: (number, at), served: Cell::new(false) }
}
/// Its host, opened at the point.
/// Its host; only the first render opens it.
pub fn host(&self, id: &'static str, menu: Menu) -> ContextMenu {
ContextMenu::new(id, menu).manual(Some(self.request))
let request = (!self.served.replace(true)).then_some(self.request);
ContextMenu::new(id, menu).manual(request)
}
}
+1 -1
View File
@@ -242,7 +242,7 @@
- [~] R4.8 组件库:表格行列上下左右移动、整理表格源码;代码块取代码内容;与已有的增删行列同一套拒绝。组件 bcee87c 至 6b033f3(已推送):`TableEdit` 加上下移行(表头与首行互换,超出表头列数的行不作表头,到边拒绝为 `TableRefusal::Edge`)、左右移列、整理;表格按显示宽度补齐(`unicode-width`,Tab 记 4、控制字符记 0,照 Typora 的 `wcswidth`),写回表格自身的换行;表格编辑并入后台执行器;右键代码块时 `DocumentTarget` 带出代码文字。审查三轮未过(`tasks/reviews/R4.8-r1..r3.md`):r1 两项(纯 CR 文本里整理表格把前文当作容器前缀、行内 Tab 宽度不合 Typora);r2 一项(End 后插入表格拆开 CRLF);r3 一项(r2 的修法让行辅助函数返回光标之前的位置,End 后的块命令崩溃)。r3 已修:编辑器只按 LF 分行,光标可落在 CR 与 LF 之间;执行器与插入表格在入口把选区移到 CR 前,行辅助函数改为断言不收这种位置;两项回归在 r3 的源码上失败(`.work/R4.8-fix3/red.md`);轮数用尽,未再审。遗留:编辑器自身的 CRLF 模型不变,End 仍落在 CR 之后;表情宽度按 `unicode-width` 记 2 列(Typora 记 1)
- [x] R4.9 ElyMark:“段落”“格式”菜单与 Typora 同项同键(Ctrl+1–6、Ctrl+0、Ctrl+=/-、Ctrl+B/I/U、Ctrl+Shift+`、Alt+Shift+5、Ctrl+K、Ctrl+Shift+I、Ctrl+\、Ctrl+T、Ctrl+Shift+K/M/Q/[/]);源码模式 Ctrl+/;字号 Ctrl+Shift+=/-/0;专注 F8、打字机 F9;表格菜单加移动行列;复制为 Markdown Ctrl+Shift+C、粘贴为纯文本 Ctrl+Shift+V。ElyMark 5392062、f84f990、09fcb0b,组件 6d90351、24ffc45、6c2d28c(已推送),pin 6c2d28c:键与菜单项取自 Typora 字节码与其中文菜单;表格为“段落”子菜单,加删除表格;拒绝成提示;菜单借打开的块的焦点,块与选区留着;复制为 Markdown、粘贴为纯文本只作用于文档。R1 FAIL 六项、R2 FAIL 四项已修,R3 PASS
- [x] R4.13 全部应用:中文显示(用户报,2026-10-14):Windows 上同一词里字形粗细不一(gpui 以 en-US 排版,汉字按日文回退);Ely 用到的组件里写死的英文改走中英目录;先于 R4.10 的修复做。第 1 轮未过(三项:排版语言不随 Ely 的中文设置、查找框英文、排序筛选英文),已修,见 `.work/R4.13-fix1`;第 2 轮通过。
- [ ] R4.10 ElyMark:右键菜单照 Typora 按目标变化:剪贴板行(剪切、复制、粘贴、删除)、复制/粘贴为 ▸(Markdown、HTML 代码、纯文本、粘贴为纯文本)、格式行、段落 ▸ 或表格 ▸ 或代码项、插入 ▸、链接(打开、复制地址)、图片(打开所在位置、删除图片)、删除块;预览只读时只留复制类
- [ ] R4.10 ElyMark:右键菜单照 Typora 按目标变化:剪贴板行(剪切、复制、粘贴、删除)、复制/粘贴为 ▸(Markdown、HTML 代码、纯文本、粘贴为纯文本)、格式行、段落 ▸ 或表格 ▸ 或代码项、插入 ▸、链接(打开、复制地址)、图片(打开所在位置、删除图片)、删除块;预览只读时只留复制类。第 1 轮未过(五项:保留选区时目标取选区起点、打开块里 HTML 链接读错、切回标签重开已关的菜单、复制为 HTML 与纯文本卡界面、长注释),已修,见 `.work/R4.10-fix1`
- [x] R4.12 全部应用:已开着时再打开文件,交给运行中的那份开成新标签并带到前台,不再弹“已在运行”(用户报,2026-10-14);先于 R4.11 做。第 1 轮 4 项:令牌被拒时连接重置使重试失效、慢速来者占住门、主窗口关后仍回执、ElyMail 与 ElyAgent 不到前台;已修,各有回归;第 2 轮通过
- [ ] R4.11 验收:真窗口逐项操作,每项功能可用(不求与 Typora 一一相同);帮助、PRD、`agents.md` 随之;逐条核对表