Skip to content

Commit 483f47c

Browse files
authored
feat(workspace): prove suggestions and document fidelity (#53)
* feat(workspace): prove suggestion and document fidelity * fix(workspace): gate IME and concurrent proposals
1 parent 7b1e20c commit 483f47c

9 files changed

Lines changed: 1810 additions & 7 deletions

File tree

‎.hermes/plans/2026-09-20_131317-debate-workspace.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,10 +264,10 @@ Each numbered slice is independently reviewable. Before implementation, write it
264264

265265
**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.
266266

267-
- [ ] Build one representative bilingual/hungarian speech fixture with headings, nested lists, links, a table, citation, and Unicode/IME edits.
268-
- [ ] 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.
269-
- [ ] Round-trip the fixture through supported Markdown, DOCX, and the PDF renderer. Compare structured semantics and extracted visible text, not binary file equality.
270-
- [ ] 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.
267+
- [x] Build one representative bilingual/hungarian speech fixture with headings, nested lists, links, a table, citation, and Unicode/IME edits.
268+
- [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.
269+
- [x] Round-trip the fixture through supported Markdown, DOCX, and the PDF renderer. Compare structured semantics and extracted visible text, not binary file equality.
270+
- [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.
271271

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Workspace IME review gate</title>
7+
</head>
8+
<body>
9+
<div id="suggestion-editor"></div>
10+
<script type="module">
11+
import * as Y from "yjs";
12+
import { createWorkspaceEditor } from "/src/lib/workspace/editor-schema.ts";
13+
import * as review from "/src/lib/workspace/review.ts";
14+
15+
const canonicalDocument = new Y.Doc();
16+
const suggestionDocument = new Y.Doc();
17+
const canonical = createWorkspaceEditor(canonicalDocument);
18+
const suggestion = createWorkspaceEditor(suggestionDocument);
19+
canonical.commands.setContent("<p>Accepted:</p>");
20+
suggestion.commands.setContent("<p></p>");
21+
document.querySelector("#suggestion-editor").append(suggestion.view.dom);
22+
23+
let phase = "idle";
24+
let proposal = null;
25+
let canonicalUpdates = 0;
26+
const events = [];
27+
canonicalDocument.on("update", () => canonicalUpdates += 1);
28+
29+
for (const type of ["compositionstart", "beforeinput", "input", "compositionend"]) {
30+
suggestion.view.dom.addEventListener(type, (event) => {
31+
if (type === "compositionstart") phase = "composing";
32+
if (type === "compositionend") phase = "settled";
33+
events.push({
34+
type,
35+
data: event.data ?? null,
36+
inputType: event.inputType ?? null,
37+
isComposing: event.isComposing ?? null,
38+
});
39+
});
40+
}
41+
42+
const snapshot = () => ({
43+
canonical: canonical.getText(),
44+
canonicalUpdates,
45+
suggestion: suggestion.getText(),
46+
editorComposing: suggestion.view.composing,
47+
phase,
48+
events: [...events],
49+
proposal: proposal && {
50+
status: proposal.hunks[0].status,
51+
replacement: proposal.hunks[0].replacement,
52+
},
53+
});
54+
55+
window.workspaceIme = {
56+
snapshot,
57+
capture(id) {
58+
const at = canonical.state.doc.content.size - 1;
59+
proposal = review.createProposalAfterComposition({
60+
compositionState: phase,
61+
proposal: {
62+
id,
63+
authorId: "ime-member",
64+
documentEpoch: 1,
65+
editor: canonical,
66+
hunks: [{
67+
id: `${id}-insert`,
68+
kind: "insert",
69+
at,
70+
content: [{ type: "text", text: suggestion.getText() }],
71+
}],
72+
},
73+
});
74+
return snapshot();
75+
},
76+
accept() {
77+
proposal = review.acceptProposalHunks({
78+
editor: canonical,
79+
documentEpoch: 1,
80+
proposal,
81+
hunkIds: [proposal.hunks[0].id],
82+
});
83+
return snapshot();
84+
},
85+
reject() {
86+
proposal = review.rejectProposalHunks({
87+
proposal,
88+
hunkIds: [proposal.hunks[0].id],
89+
});
90+
return snapshot();
91+
},
92+
resetSuggestion() {
93+
suggestion.commands.clearContent();
94+
phase = "idle";
95+
proposal = null;
96+
events.length = 0;
97+
return snapshot();
98+
},
99+
};
100+
</script>
101+
</body>
102+
</html>

‎e2e/workspace-review-ime.spec.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { expect, test, type CDPSession, type Page } from "@playwright/test";
2+
3+
interface ImeSnapshot {
4+
canonical: string;
5+
canonicalUpdates: number;
6+
suggestion: string;
7+
editorComposing: boolean;
8+
phase: "idle" | "composing" | "settled";
9+
events: Array<{
10+
type: string;
11+
data: string | null;
12+
inputType: string | null;
13+
isComposing: boolean | null;
14+
}>;
15+
proposal: null | {
16+
status: string;
17+
replacement: Array<{ type: string; text?: string }>;
18+
};
19+
}
20+
21+
interface WorkspaceImeHarness {
22+
snapshot(): ImeSnapshot;
23+
capture(id: string): ImeSnapshot;
24+
accept(): ImeSnapshot;
25+
reject(): ImeSnapshot;
26+
resetSuggestion(): ImeSnapshot;
27+
}
28+
29+
declare global {
30+
interface Window {
31+
workspaceIme: WorkspaceImeHarness;
32+
}
33+
}
34+
35+
async function snapshot(page: Page) {
36+
return page.evaluate(() => window.workspaceIme.snapshot());
37+
}
38+
39+
async function compose(
40+
page: Page,
41+
cdp: CDPSession,
42+
id: string,
43+
steps: [string, ...string[]],
44+
accepted: string,
45+
acceptedUpdates: number,
46+
) {
47+
await page.locator(".ProseMirror").focus();
48+
for (const text of steps) {
49+
await cdp.send("Input.imeSetComposition", {
50+
text,
51+
selectionStart: text.length,
52+
selectionEnd: text.length,
53+
});
54+
const during = await page.evaluate((proposalId) =>
55+
window.workspaceIme.capture(proposalId), id,
56+
);
57+
expect(during.phase).toBe("composing");
58+
expect(during.proposal).toBeNull();
59+
expect(during.canonical).toBe(accepted);
60+
expect(during.canonicalUpdates).toBe(acceptedUpdates);
61+
}
62+
await cdp.send("Input.insertText", { text: steps[steps.length - 1] });
63+
await expect.poll(async () => (await snapshot(page)).phase).toBe("settled");
64+
await expect.poll(async () => (await snapshot(page)).editorComposing).toBe(false);
65+
}
66+
67+
test("captures Spanish and Hungarian IME text only after composition settles", async ({
68+
page,
69+
context,
70+
}, testInfo) => {
71+
test.skip(testInfo.project.name !== "desktop-chromium", "Chromium CDP provides native IME composition");
72+
73+
await page.goto("/e2e/fixtures/workspace-review-ime.html");
74+
await expect.poll(() => page.evaluate(() => Boolean(window.workspaceIme))).toBe(true);
75+
const cdp = await context.newCDPSession(page);
76+
77+
await compose(page, cdp, "spanish", ["n", "ni", "niñ", "niño"], "Accepted:", 0);
78+
const spanishSettled = await page.evaluate(() => window.workspaceIme.capture("spanish"));
79+
expect(spanishSettled.events.map((event) => event.type)).toEqual(expect.arrayContaining([
80+
"compositionstart",
81+
"beforeinput",
82+
"input",
83+
"compositionend",
84+
]));
85+
expect(spanishSettled.events.filter((event) => event.type === "beforeinput").every((event) =>
86+
event.inputType === "insertCompositionText" && event.isComposing,
87+
)).toBe(true);
88+
expect(spanishSettled.proposal).toEqual({
89+
status: "pending",
90+
replacement: [{ type: "text", text: "niño" }],
91+
});
92+
expect(spanishSettled.canonical).toBe("Accepted:");
93+
expect(spanishSettled.canonicalUpdates).toBe(0);
94+
expect((await page.evaluate(() => window.workspaceIme.accept())).canonical).toBe("Accepted:niño");
95+
96+
await page.evaluate(() => window.workspaceIme.resetSuggestion());
97+
await compose(
98+
page,
99+
cdp,
100+
"hungarian",
101+
["t", "tű", "tűr", "tűrő"],
102+
"Accepted:niño",
103+
1,
104+
);
105+
const hungarianSettled = await page.evaluate(() => window.workspaceIme.capture("hungarian"));
106+
expect(hungarianSettled.proposal).toEqual({
107+
status: "pending",
108+
replacement: [{ type: "text", text: "tűrő" }],
109+
});
110+
expect(hungarianSettled.canonical).toBe("Accepted:niño");
111+
const rejected = await page.evaluate(() => window.workspaceIme.reject());
112+
expect(rejected.proposal?.status).toBe("rejected");
113+
expect(rejected.canonical).toBe("Accepted:niño");
114+
});

0 commit comments

Comments
 (0)