diff --git a/Agent.md b/Agent.md index 4569459c..03a444e0 100644 --- a/Agent.md +++ b/Agent.md @@ -55,7 +55,7 @@ One line per command: what it answers. Run the daemon in the foreground with `py - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (161: 98 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (714: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 38 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 56 daemonBridge + 9 DaemonBridgeProvider + 48 Shell + 24 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (716: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 56 daemonBridge + 9 DaemonBridgeProvider + 48 Shell + 24 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each answers a question no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (two PRs together; every step of an order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) diff --git a/emrg/gui/renderer/src/lib/composer.test.ts b/emrg/gui/renderer/src/lib/composer.test.ts index 83a9bfc8..20a91a12 100644 --- a/emrg/gui/renderer/src/lib/composer.test.ts +++ b/emrg/gui/renderer/src/lib/composer.test.ts @@ -266,7 +266,9 @@ describe("collectImageFiles — 粘贴/拖拽事件的两个面都取", () => { expect(collectImageFiles(carrier)).toEqual([png]); }); - it("unions both faces without duplicating the same File object", () => { + it("reads `files` and adds nothing from `items` on top of it", () => { + // `items` is the fallback, not a second pile: when `files` carries an image, `items` is + // the same file seen from another face and must not contribute an extra entry. const carrier = { files: [png], items: [ @@ -274,7 +276,31 @@ describe("collectImageFiles — 粘贴/拖拽事件的两个面都取", () => { { kind: "file", type: "image/tiff", getAsFile: () => tiff }, ], }; - expect(collectImageFiles(carrier)).toEqual([png, tiff]); + expect(collectImageFiles(carrier)).toEqual([png]); + }); + + it("counts one pasted image once, though the two faces hand back different File objects", () => { + // The real paste shape, and the defect it caused: Chromium exposes the clipboard image + // through both faces as **two distinct `File` objects with the same content**, so + // identity de-duplication let both through and one paste became two attachments + // (`attach 2 file(s)` on every paste in the log). Same name, size and type, two objects. + const fromFiles = new File(["x"], "image.png", { type: "image/png" }); + const fromItems = new File(["x"], "image.png", { type: "image/png" }); + expect(fromFiles).not.toBe(fromItems); + const carrier = { + files: [fromFiles], + items: [{ kind: "file", type: "image/png", getAsFile: () => fromItems }], + }; + const out = collectImageFiles(carrier); + expect(out).toHaveLength(1); + expect(out[0]).toBe(fromFiles); + }); + + it("keeps a genuine multi-file drag, including two files with the same name and type", () => { + // Nothing here de-duplicates by content: `files` is what the user handed over, so two + // distinct files stay two. + const second = new File(["x"], "shot.png", { type: "image/png" }); + expect(collectImageFiles({ files: [png, second] })).toEqual([png, second]); }); it("ignores non-image carriers (string items, text kinds, nulls)", () => { diff --git a/emrg/gui/renderer/src/lib/composer.ts b/emrg/gui/renderer/src/lib/composer.ts index 59547a84..38856503 100644 --- a/emrg/gui/renderer/src/lib/composer.ts +++ b/emrg/gui/renderer/src/lib/composer.ts @@ -180,35 +180,36 @@ export interface ImageCarrier { } /** - * 从一次粘贴/拖拽事件里取图片文件 —— **两个面都取**:`files` 与 `items`。 + * 从一次粘贴/拖拽事件里取图片文件:`files` 里有图就用 `files`;只有当 `files` 一个图都没有时, + * 才去读 `items[i].getAsFile()`。 * - * 为什么要两个面(rant 2026-09-30T09:35:04,宿主报障「GUI 输入框里 Cmd+V 现在没有任何 - * 效果」,实现在且从未走到过一次 IPC): + * 为什么不「两个都读、按对象去重」(2026-10-21 在 EMRG.app 里跑探针应用实测):同一次粘贴里, + * `files[0]` 和 `items[0].getAsFile()` 是**两个不同的 File 对象、内容相同**,而且每次调 + * `getAsFile()` 都会新造一个对象 —— 按对象同一性去重挡不住它们,于是粘贴一张图会进来两张 + * (日志里 7 次粘贴、7 次都是 `attach 2 file(s): image/png, image/png`,两次落盘还是同一个 + * 文件),图片字节在请求体里也跟着翻倍。实测粘贴时 `files.length === 1`,所以「`files` 有值 + * 就够用」这件事本身成立。 * - * - `DataTransfer.files` 是**拖拽**一路可靠的来源; - * - 粘贴一路在 Chromium 里由 `items`(kind="file" → getAsFile())承载,只读 `files` - * 会得到空数组 —— 于是 `handlePaste` 里 `imgs.length === 0` 成立、处理器 `return - * false` 把事件交回浏览器,浏览器对没有文本的图片剪贴板什么也不做 ⇒ 宿主看到的就是 - * 「没有任何效果」,而且**一行日志都没有**(IPC 从未被调用)。 + * ⚠️ 这里顺带推翻一条沿用很久的前提:2026-09-30「GUI 输入框里 Cmd+V 没有任何效果」的真因是 + * 主进程用 Electron 不支持的 `blake2b512` 算摘要抛错(#2363 已修),**不是**「粘贴时 `files` + * 为空」。旧注释据此要求把 `items` 与 `files` 合并读,那是错的,也是双份的来源。 * - * 因此这里的取舍是:多读一个面是**纯增量**(`files` 里有的仍然会拿到),而少读一个面 - * 是静默失效。返回顺序 = `files` 在前、`items` 补齐其后,按对象同一性去重。 + * `items` 仍然留作兜底:`files` 为空时它是唯一的取法。这个形状在本机没有被复现过,保留它是 + * 为了不重新引入一次静默丢弃,代价是零 —— `files` 有值时这里根本不读它。 * - * ⚠️ 诚实边界:本机无法实跑真实粘贴(见 PR 的「诚实边界」),所以这不是「实测到的根因」, - * 而是「两个面都收」——它对**任一面**为真的情况都成立,而调用方对空结果必须**出声** - * (见 Composer 的可见拒绝),所以下一个宿主复现会把真正的层直接写进日志。 + * 拖入两个同名同大小的不同文件仍然返回两个:这里不按内容去重,`files` 里有几个就是几个。 */ export function collectImageFiles(carrier: ImageCarrier | null | undefined): File[] { if (!carrier) return []; + const isImage = (f: File | null | undefined): f is File => + !!f && typeof f.type === "string" && f.type.startsWith("image/"); + const fromFiles = Array.from(carrier.files ?? []).filter(isImage); + if (fromFiles.length > 0) return fromFiles; const out: File[] = []; - const take = (f: File | null | undefined): void => { - if (f && typeof f.type === "string" && f.type.startsWith("image/") && !out.includes(f)) { - out.push(f); - } - }; - for (const f of Array.from(carrier.files ?? [])) take(f); for (const it of Array.from(carrier.items ?? [])) { - if (it && it.kind === "file" && typeof it.getAsFile === "function") take(it.getAsFile()); + if (!it || it.kind !== "file" || typeof it.getAsFile !== "function") continue; + const f = it.getAsFile(); + if (isImage(f) && !out.includes(f)) out.push(f); } return out; }