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
8 changes: 4 additions & 4 deletions .hermes/plans/2026-09-20_131317-debate-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,10 @@ Each numbered slice is independently reviewable. Before implementation, write it

**P0.2 Suggestions and import/export fidelity spike.** Proposed new paths: `src/test/workspace/review.test.ts`, `src/test/workspace/document-conversion.test.ts`, and small fixture files under `src/test/workspace/fixtures/` during implementation.

- [ ] Build one representative bilingual/hungarian speech fixture with headings, nested lists, links, a table, citation, and Unicode/IME edits.
- [ ] Test human suggested insert/delete/format, overlapping real edits, accepting one hunk, rejecting all, and undo/redo. Prove quote/comment anchors survive unrelated insertions and detach on deletion.
- [ ] Round-trip the fixture through supported Markdown, DOCX, and the PDF renderer. Compare structured semantics and extracted visible text, not binary file equality.
- [ ] Decide whether the open-source suggestion implementation is maintainable before committing the UI architecture. Record the tested ceiling and package choices in the implementation PR.
- [x] Build one representative bilingual/hungarian speech fixture with headings, nested lists, links, a table, citation, and Unicode/IME edits.
- [x] Test human suggested insert/delete/format, overlapping real edits, accepting one hunk, rejecting all, and undo/redo. Prove quote/comment anchors survive unrelated insertions and detach on deletion.
- [x] Round-trip the fixture through supported Markdown, DOCX, and the PDF renderer. Compare structured semantics and extracted visible text, not binary file equality.
- [x] Decide whether the open-source suggestion implementation is maintainable before committing the UI architecture. Record the tested ceiling and package choices in the implementation PR.

**Exit:** Architecture is selected using runnable evidence. A failed gate stops dependent work, not the entire vision. No live rollout yet.

Expand Down
102 changes: 102 additions & 0 deletions e2e/fixtures/workspace-review-ime.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workspace IME review gate</title>
</head>
<body>
<div id="suggestion-editor"></div>
<script type="module">
import * as Y from "yjs";
import { createWorkspaceEditor } from "/src/lib/workspace/editor-schema.ts";
import * as review from "/src/lib/workspace/review.ts";

const canonicalDocument = new Y.Doc();
const suggestionDocument = new Y.Doc();
const canonical = createWorkspaceEditor(canonicalDocument);
const suggestion = createWorkspaceEditor(suggestionDocument);
canonical.commands.setContent("<p>Accepted:</p>");
suggestion.commands.setContent("<p></p>");
document.querySelector("#suggestion-editor").append(suggestion.view.dom);

let phase = "idle";
let proposal = null;
let canonicalUpdates = 0;
const events = [];
canonicalDocument.on("update", () => canonicalUpdates += 1);

for (const type of ["compositionstart", "beforeinput", "input", "compositionend"]) {
suggestion.view.dom.addEventListener(type, (event) => {
if (type === "compositionstart") phase = "composing";
if (type === "compositionend") phase = "settled";
events.push({
type,
data: event.data ?? null,
inputType: event.inputType ?? null,
isComposing: event.isComposing ?? null,
});
});
}

const snapshot = () => ({
canonical: canonical.getText(),
canonicalUpdates,
suggestion: suggestion.getText(),
editorComposing: suggestion.view.composing,
phase,
events: [...events],
proposal: proposal && {
status: proposal.hunks[0].status,
replacement: proposal.hunks[0].replacement,
},
});

window.workspaceIme = {
snapshot,
capture(id) {
const at = canonical.state.doc.content.size - 1;
proposal = review.createProposalAfterComposition({
compositionState: phase,
proposal: {
id,
authorId: "ime-member",
documentEpoch: 1,
editor: canonical,
hunks: [{
id: `${id}-insert`,
kind: "insert",
at,
content: [{ type: "text", text: suggestion.getText() }],
}],
},
});
return snapshot();
},
accept() {
proposal = review.acceptProposalHunks({
editor: canonical,
documentEpoch: 1,
proposal,
hunkIds: [proposal.hunks[0].id],
});
return snapshot();
},
reject() {
proposal = review.rejectProposalHunks({
proposal,
hunkIds: [proposal.hunks[0].id],
});
return snapshot();
},
resetSuggestion() {
suggestion.commands.clearContent();
phase = "idle";
proposal = null;
events.length = 0;
return snapshot();
},
};
</script>
</body>
</html>
114 changes: 114 additions & 0 deletions e2e/workspace-review-ime.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { expect, test, type CDPSession, type Page } from "@playwright/test";

interface ImeSnapshot {
canonical: string;
canonicalUpdates: number;
suggestion: string;
editorComposing: boolean;
phase: "idle" | "composing" | "settled";
events: Array<{
type: string;
data: string | null;
inputType: string | null;
isComposing: boolean | null;
}>;
proposal: null | {
status: string;
replacement: Array<{ type: string; text?: string }>;
};
}

interface WorkspaceImeHarness {
snapshot(): ImeSnapshot;
capture(id: string): ImeSnapshot;
accept(): ImeSnapshot;
reject(): ImeSnapshot;
resetSuggestion(): ImeSnapshot;
}

declare global {
interface Window {
workspaceIme: WorkspaceImeHarness;
}
}

async function snapshot(page: Page) {
return page.evaluate(() => window.workspaceIme.snapshot());
}

async function compose(
page: Page,
cdp: CDPSession,
id: string,
steps: [string, ...string[]],
accepted: string,
acceptedUpdates: number,
) {
await page.locator(".ProseMirror").focus();
for (const text of steps) {
await cdp.send("Input.imeSetComposition", {
text,
selectionStart: text.length,
selectionEnd: text.length,
});
const during = await page.evaluate((proposalId) =>
window.workspaceIme.capture(proposalId), id,
);
expect(during.phase).toBe("composing");
expect(during.proposal).toBeNull();
expect(during.canonical).toBe(accepted);
expect(during.canonicalUpdates).toBe(acceptedUpdates);
}
await cdp.send("Input.insertText", { text: steps[steps.length - 1] });
await expect.poll(async () => (await snapshot(page)).phase).toBe("settled");
await expect.poll(async () => (await snapshot(page)).editorComposing).toBe(false);
}

test("captures Spanish and Hungarian IME text only after composition settles", async ({
page,
context,
}, testInfo) => {
test.skip(testInfo.project.name !== "desktop-chromium", "Chromium CDP provides native IME composition");

await page.goto("/e2e/fixtures/workspace-review-ime.html");
await expect.poll(() => page.evaluate(() => Boolean(window.workspaceIme))).toBe(true);
const cdp = await context.newCDPSession(page);

await compose(page, cdp, "spanish", ["n", "ni", "niñ", "niño"], "Accepted:", 0);
const spanishSettled = await page.evaluate(() => window.workspaceIme.capture("spanish"));
expect(spanishSettled.events.map((event) => event.type)).toEqual(expect.arrayContaining([
"compositionstart",
"beforeinput",
"input",
"compositionend",
]));
expect(spanishSettled.events.filter((event) => event.type === "beforeinput").every((event) =>
event.inputType === "insertCompositionText" && event.isComposing,
)).toBe(true);
expect(spanishSettled.proposal).toEqual({
status: "pending",
replacement: [{ type: "text", text: "niño" }],
});
expect(spanishSettled.canonical).toBe("Accepted:");
expect(spanishSettled.canonicalUpdates).toBe(0);
expect((await page.evaluate(() => window.workspaceIme.accept())).canonical).toBe("Accepted:niño");

await page.evaluate(() => window.workspaceIme.resetSuggestion());
await compose(
page,
cdp,
"hungarian",
["t", "tű", "tűr", "tűrő"],
"Accepted:niño",
1,
);
const hungarianSettled = await page.evaluate(() => window.workspaceIme.capture("hungarian"));
expect(hungarianSettled.proposal).toEqual({
status: "pending",
replacement: [{ type: "text", text: "tűrő" }],
});
expect(hungarianSettled.canonical).toBe("Accepted:niño");
const rejected = await page.evaluate(() => window.workspaceIme.reject());
expect(rejected.proposal?.status).toBe("rejected");
expect(rejected.canonical).toBe("Accepted:niño");
});
Loading
Loading