Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`, 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 <N>` (did the merge land a tree its reviews named)
Expand Down
30 changes: 28 additions & 2 deletions emrg/gui/renderer/src/lib/composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,15 +266,41 @@ 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: [
{ kind: "file", type: "image/png", getAsFile: () => png },
{ 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)", () => {
Expand Down
41 changes: 21 additions & 20 deletions emrg/gui/renderer/src/lib/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading