diff --git a/Agent.md b/Agent.md index 09d241c7..d63ccd00 100644 --- a/Agent.md +++ b/Agent.md @@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg Python: `uv run pytest tests/ -v` (1146) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (95: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api + 4 boot-contract + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (467: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 8 TranscriptView + 15 history + 22 composer + 31 Composer + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 26 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (473: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 8 TranscriptView + 15 history + 22 composer + 31 Composer + 6 LinkDialog + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 26 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index d678b407..a8584676 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -1010,7 +1010,12 @@ vision = false // 记录/刷新一个打开会话(复用连接、恢复重开、recoverAll 均刷新) function touchOpenSession(sid, projectPath) { + // ⚠️ (rant 2026-08-28T22:28:47) 保留已有 v 字段(含 title): + // 每次 message_delta 重写时若丢弃 title,侧边栏会在「名字→id」间抖动; + // 异步 listSessions 拉回的 title 必须跨调用保留。 + const v = openSessions.get(sid) || {}; openSessions.set(sid, { + ...v, projectName: projectNameOf(projectPath), projectPath, lastActive: new Date().toISOString(), diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index 65e40b44..8526d4ec 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -367,17 +367,20 @@ outline: none; } -/* tiptap 内容可编辑区内部段落:默认 p 有 margin,输入框内收紧 */ -.tiptap-input .ProseMirror { +/* tiptap 内容可编辑区内部段落:默认 p 有 margin,输入框内收紧。 + ⚠️ (rant 2026-08-28T22:26:29) DOM 中 .tiptap-input 与 .ProseMirror 是同一元素 + (class="tiptap ProseMirror tiptap-input"),非嵌套关系—— + 旧选择器 .tiptap-input .ProseMirror 永不命中 → placeholder 不显示。 */ +.tiptap-input { min-height: 1.6em; } -.tiptap-input .ProseMirror p { +.tiptap-input p { margin: 0; } -.tiptap-input .ProseMirror p.is-editor-empty:first-child { +.tiptap-input p.is-editor-empty:first-child { position: relative; } -.tiptap-input .ProseMirror p.is-editor-empty:first-child::before { +.tiptap-input p.is-editor-empty:first-child::before { position: absolute; left: 0; top: 0; @@ -385,6 +388,13 @@ color: var(--text-3); pointer-events: none; } +/* 编辑器外层(EditorContent className="composer-editor")—— + ⚠️ (rant 2026-08-28T22:26:29) 无 CSS 定义时编辑器只占内容宽度, + send-btn 被文本推着走;须 flex:1 撑满 .composer-card。 */ +.composer-editor { + flex: 1; + min-width: 0; +} .composer-hint { text-align: center; diff --git a/emrg/gui/renderer/src/components/Composer.test.tsx b/emrg/gui/renderer/src/components/Composer.test.tsx index ac6acbc2..f7e27192 100644 --- a/emrg/gui/renderer/src/components/Composer.test.tsx +++ b/emrg/gui/renderer/src/components/Composer.test.tsx @@ -375,23 +375,26 @@ describe("Composer — 格式栏与快捷键(Stage 2, rant 14:07:29)", () => expect(mdOf(s, editor)).toContain("`const x`"); }); - it("链接按钮(window.prompt 输入 URL)→ [label](url);再次点击解除链接", async () => { + it("链接按钮(LinkDialog 输入 URL)→ [label](url);再次点击解除链接", async () => { const store = createTranscriptStore(); const s = setup(store); const editor = await waitEditor(s); - const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://example.com"); act(() => { editor.commands.insertContent("my link"); editor.commands.selectAll(); }); + // 点击链接按钮 → 弹出应用内 LinkDialog(非 window.prompt;Electron 禁用 prompt) await userEvent.click(screen.getByTestId("fmt-link")); + const urlInput = screen.getByTestId("link-url"); + expect(urlInput).toBeInTheDocument(); + await userEvent.clear(urlInput); + await userEvent.type(urlInput, "https://example.com"); + await userEvent.click(screen.getByTestId("link-ok")); expect(mdOf(s, editor)).toContain("[my link](https://example.com)"); - expect(promptSpy).toHaveBeenCalledTimes(1); - // 已激活链接 → 再点解除(不再弹 prompt) + // 已激活链接 → 再点解除(LinkDialog 关闭) await userEvent.click(screen.getByTestId("fmt-link")); expect(mdOf(s, editor)).not.toContain("[my link]"); - expect(promptSpy).toHaveBeenCalledTimes(1); - promptSpy.mockRestore(); + expect(screen.queryByTestId("link-dialog")).not.toBeInTheDocument(); }); it("无序列表按钮 → - item", async () => { @@ -472,19 +475,22 @@ describe("Composer — 格式栏与快捷键(Stage 2, rant 14:07:29)", () => expect(mdOf(s, editor)).toContain("**shortcut**"); }); - it("⌘K 快捷键(无内置绑定,显式补挂)→ 链接 prompt", async () => { + it("⌘K 快捷键(无内置绑定,显式补挂)→ 打开 LinkDialog", async () => { const store = createTranscriptStore(); const s = setup(store); const editor = await waitEditor(s); - const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://shortcut.dev"); act(() => { editor.commands.insertContent("kb"); editor.commands.selectAll(); }); s.press("k", { ctrlKey: true }); - await waitFor(() => expect(mdOf(s, editor)).toContain("[kb](https://shortcut.dev)")); - expect(promptSpy).toHaveBeenCalledTimes(1); - promptSpy.mockRestore(); + // ⌘K 打开应用内 LinkDialog(非 window.prompt);输入 URL 后确认 + const urlInput = await screen.findByTestId("link-url"); + expect(urlInput).toBeInTheDocument(); + await userEvent.clear(urlInput); + await userEvent.type(urlInput, "https://shortcut.dev"); + await userEvent.click(screen.getByTestId("link-ok")); + expect(mdOf(s, editor)).toContain("[kb](https://shortcut.dev)"); }); it("⌘⇧8 快捷键(无内置绑定)→ 无序列表", async () => { diff --git a/emrg/gui/renderer/src/components/Composer.tsx b/emrg/gui/renderer/src/components/Composer.tsx index 2cbc5c9b..2fdf030e 100644 --- a/emrg/gui/renderer/src/components/Composer.tsx +++ b/emrg/gui/renderer/src/components/Composer.tsx @@ -12,9 +12,10 @@ import { queueSend, type CmdMenuState, } from "../lib/composer"; -import { genRequestId, type TranslateFn } from "../lib/utils"; +import { genRequestId } from "../lib/utils"; import { useI18n } from "../lib/i18n"; import type { TranscriptStore } from "../lib/transcript"; +import { LinkDialog } from "./LinkDialog"; /** * Composer — 输入框 + / 补全菜单 + 发送流(tiptap 版,rant 2026-08-28T14:07:29 Stage 1)。 @@ -79,17 +80,6 @@ function cycleHeading(ed: Editor | null): void { else ed.chain().focus().toggleHeading({ level: next as 1 | 2 | 3 }).run(); } -function toggleLink(ed: Editor | null, t: TranslateFn): void { - if (!ed) return; - if (ed.isActive("link")) { - ed.chain().focus().extendMarkRange("link").unsetLink().run(); - return; - } - const href = window.prompt(t("composer.linkPrompt"), "https://"); - if (!href) return; - ed.chain().focus().extendMarkRange("link").setLink({ href }).run(); -} - /** 格式栏按钮定义(id → 命令 + 标签类 + i18n key) */ const FMT_BUTTONS = [ { id: "bold", label: "B", labelClass: "fmt-label-bold" }, @@ -118,6 +108,9 @@ export function Composer({ const [hasText, setHasText] = useState(false); const [internalBusy, setInternalBusy] = useState(false); const busy = busyProp ?? internalBusy; + // ⚠️ (rant 2026-08-28T22:27:01) 链接改用应用内对话框收集 URL(Electron 禁用 window.prompt) + const [linkDialogOpen, setLinkDialogOpen] = useState(false); + const [linkHref, setLinkHref] = useState(""); const queuedRef = useRef>>(new Map()); // tiptap 选项闭包只创建一次 → 变化值走 ref 桥接(menu/submit/selectCmd/t) const menuRef = useRef(menu); @@ -135,7 +128,7 @@ export function Composer({ ordered: () => editor?.chain().focus().toggleOrderedList().run(), quote: () => editor?.chain().focus().toggleBlockquote().run(), heading: () => cycleHeading(editor), - link: () => toggleLink(editor, tRef.current), + link: () => openLinkDialog(), }; // 发送函数解析(默认走 preload 桥) @@ -263,6 +256,33 @@ export function Composer({ heading: false, }; + // ⚠️ (rant 2026-08-28T22:27:01) 链接用应用内对话框收集 URL(Electron 禁用 window.prompt) + function openLinkDialog(): void { + if (!editor) return; + if (editor.isActive("link")) { + // 已激活链接 → 直接解除(不在对话框内处理,避免歧义) + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + return; + } + // 预填当前选区 href(若有)或默认 https:// + let cur = ""; + try { + cur = editor.getAttributes("link").href ?? ""; + } catch { + cur = ""; + } + setLinkHref(cur || "https://"); + setLinkDialogOpen(true); + } + function applyLink(href: string): void { + if (!editor) return; + editor.chain().focus().extendMarkRange("link").setLink({ href }).run(); + setLinkDialogOpen(false); + } + function cancelLink(): void { + setLinkDialogOpen(false); + } + async function submit(): Promise { if (!editor) return; // tiptap-markdown 的 storage 类型不并入 Editor.storage(Storage 泛型)——运行时存在,显式取值 @@ -375,6 +395,12 @@ export function Composer({ ↑ + ); } diff --git a/emrg/gui/renderer/src/components/LinkDialog.test.tsx b/emrg/gui/renderer/src/components/LinkDialog.test.tsx new file mode 100644 index 00000000..1f1fabd6 --- /dev/null +++ b/emrg/gui/renderer/src/components/LinkDialog.test.tsx @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LinkDialog } from "./LinkDialog"; +import { I18nProvider } from "../lib/i18n"; + +/** LinkDialog — 链接 URL 输入对话框(rant 2026-08-28T22:27:01) */ +describe("LinkDialog", () => { + it("关闭时不渲染", () => { + render( + + {}} onCancel={() => {}} /> + , + ); + expect(screen.queryByTestId("link-dialog")).not.toBeInTheDocument(); + }); + + it("打开时预填 currentHref//https:// 并聚焦", () => { + render( + + {}} onCancel={() => {}} /> + , + ); + const input = screen.getByTestId("link-url") as HTMLInputElement; + expect(input).toBeInTheDocument(); + expect(input.value).toBe("https://"); + }); + + it("输入 URL → 点确定 → onApply(href)", async () => { + const apply = vi.fn(); + render( + + {}} /> + , + ); + const input = screen.getByTestId("link-url") as HTMLInputElement; + await userEvent.clear(input); + await userEvent.type(input, "https://example.com"); + await userEvent.click(screen.getByTestId("link-ok")); + expect(apply).toHaveBeenCalledWith("https://example.com"); + }); + + it("Enter 提交 URL", async () => { + const apply = vi.fn(); + render( + + {}} /> + , + ); + const input = screen.getByTestId("link-url") as HTMLInputElement; + await userEvent.clear(input); + await userEvent.type(input, "https://enter.dev{enter}"); + expect(apply).toHaveBeenCalledWith("https://enter.dev"); + }); + + it("空 URL 不提交", async () => { + const apply = vi.fn(); + render( + + {}} /> + , + ); + const input = screen.getByTestId("link-url") as HTMLInputElement; + await userEvent.clear(input); + await userEvent.click(screen.getByTestId("link-ok")); + expect(apply).not.toHaveBeenCalled(); + }); + + it("取消 → onCancel,不提交", async () => { + const apply = vi.fn(); + const cancel = vi.fn(); + render( + + + , + ); + const input = screen.getByTestId("link-url") as HTMLInputElement; + await userEvent.clear(input); + await userEvent.type(input, "https://example.com"); + await userEvent.click(screen.getByTestId("link-cancel")); + expect(cancel).toHaveBeenCalled(); + expect(apply).not.toHaveBeenCalled(); + }); +}); diff --git a/emrg/gui/renderer/src/components/LinkDialog.tsx b/emrg/gui/renderer/src/components/LinkDialog.tsx new file mode 100644 index 00000000..227128f6 --- /dev/null +++ b/emrg/gui/renderer/src/components/LinkDialog.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef } from "react"; +import { useI18n } from "../lib/i18n"; +import { Dialog } from "./Dialog"; + +/** + * LinkDialog — 链接 URL 输入对话框(rant 2026-08-28T22:27:01)。 + * Electron 的 Chromium 禁用了 window.prompt()(安全考虑,直接抛错), + * tiptap 默认 Link 扩展的 prompt 路径必然报错。此对话框用应用内 UI + * 收集 URL:输入 → Enter/确定 → onApply(href);取消/ESC → onCancel。 + */ +export interface LinkDialogProps { + open: boolean; + /** 已激活链接的当前 href(编辑时预填);无则空 */ + currentHref?: string; + onApply?: (href: string) => void; + onCancel?: () => void; +} + +export function LinkDialog({ open, currentHref = "", onApply, onCancel }: LinkDialogProps) { + const { t } = useI18n(); + const inputRef = useRef(null); + + useEffect(() => { + if (open && inputRef.current) { + inputRef.current.value = currentHref || "https://"; + inputRef.current.focus(); + inputRef.current.select(); + } + }, [open, currentHref]); + + const submit = () => { + const input = inputRef.current; + if (!input) return; + const href = input.value.trim(); + if (!href) return; // 空 URL 不提交 + onApply?.(href); + }; + + if (!open) return null; + return ( + + + + + } + > + + + ); +} diff --git a/emrg/gui/renderer/src/lib/i18n-dicts.ts b/emrg/gui/renderer/src/lib/i18n-dicts.ts index 7c6bcf6f..cb0ce633 100644 --- a/emrg/gui/renderer/src/lib/i18n-dicts.ts +++ b/emrg/gui/renderer/src/lib/i18n-dicts.ts @@ -47,11 +47,14 @@ export const ZH_DICT: Record = { "composer.strike": "删除线 (⌘⇧S)", "composer.code": "行内代码 (⌘E)", "composer.link": "链接 (⌘K)", + "composer.bullet": "无序列表 (⌘⇧8)", "composer.bulletList": "无序列表 (⌘⇧8)", + "composer.ordered": "有序列表 (⌘⇧7)", "composer.orderedList": "有序列表 (⌘⇧7)", "composer.quote": "引用 (⌘⇧B)", "composer.heading": "标题 (⌘Alt 1-3)", "composer.linkPrompt": "输入链接地址", + "composer.linkDialogTitle": "插入链接", "result.collapse": "折叠/展开 (⌘\\)", "result.empty": "还没有产物,对话中生成的文件会出现在这里", "result.tabFiles": "文件", @@ -436,11 +439,14 @@ export const EN_DICT: Record = { "composer.strike": "Strikethrough (⌘⇧S)", "composer.code": "Inline code (⌘E)", "composer.link": "Link (⌘K)", + "composer.bullet": "Bullet list (⌘⇧8)", "composer.bulletList": "Bullet list (⌘⇧8)", + "composer.ordered": "Ordered list (⌘⇧7)", "composer.orderedList": "Ordered list (⌘⇧7)", "composer.quote": "Blockquote (⌘⇧B)", "composer.heading": "Heading (⌘Alt 1-3)", "composer.linkPrompt": "Enter link URL", + "composer.linkDialogTitle": "Insert link", "result.collapse": "Collapse/expand (⌘\\)", "result.empty": "No artifacts yet — files generated in the conversation will appear here", "result.tabFiles": "Files", diff --git a/emrg/gui/renderer/src/lib/i18n.test.ts b/emrg/gui/renderer/src/lib/i18n.test.ts index 643331c3..89099314 100644 --- a/emrg/gui/renderer/src/lib/i18n.test.ts +++ b/emrg/gui/renderer/src/lib/i18n.test.ts @@ -73,11 +73,11 @@ describe("getLocale / setLocale(localStorage 覆盖)", () => { }); describe("词典完整性守卫(防漂移)", () => { - it("zh/en 各 385 个 key 且完全对齐", () => { + it("zh/en 各 388 个 key 且完全对齐", () => { const zhKeys = Object.keys(ZH_DICT); const enKeys = Object.keys(EN_DICT); - expect(zhKeys.length).toBe(385); - expect(enKeys.length).toBe(385); + expect(zhKeys.length).toBe(388); + expect(enKeys.length).toBe(388); expect(zhKeys.sort()).toEqual(enKeys.sort()); // DICTS 聚合结构 expect(Object.keys(DICTS)).toEqual(["zh", "en"]);