feat(sdk): stage 7 step 2 — setSelection API (#1442)

* feat(sdk): stage 7 step 2 — setSelection API

Adds setSelection(ids: string[]) to Composition interface and CompositionImpl.
Fires selectionchange; does not touch undo stack or patch stream.
11 contract tests: get/set/clear, event firing, copy semantics, no undo/patch side-effects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): guard setSelection against same-id no-ops

Skip event dispatch when ids are identical (same length, same order)
to prevent double-firing selectionchange from callers that call
setSelection with the same list. Two new tests (RED→GREEN verified).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): de-duplicate ids in setSelection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* fix(sdk): document PreviewAdapter.on("selection") as stage 8 prep

Reviewer noted it is dead surface in this stack — no caller uses it.
Add comment explaining it is wired up in stage 8 when the preview host
pushes selection events up to the SDK session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-15 13:55:53 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Miguel Ángel
parent c19898e799
commit 30ce1f35a2
4 changed files with 137 additions and 0 deletions
+2
View File
@@ -70,5 +70,7 @@ export interface PreviewAdapter {
/** Set preview selection; fires selectionchange on the session */
select(ids: string[], opts?: { additive?: boolean }): void;
// Stage 8 prep: fired when the preview host changes selection (e.g. user clicks an element).
// Not wired up in stage 7 — callers listen to the session's own selectionchange event instead.
on(event: "selection", handler: (ids: string[]) => void): () => void;
}
+122
View File
@@ -297,3 +297,125 @@ describe("override-set orphan cleanup on removeElement", () => {
expect(overrides["hf-sub.style.opacity"]).toBe("1");
});
});
// ─── setSelection / getSelection / selectionchange ───────────────────────────
describe("setSelection", () => {
it("getSelection returns empty array before any setSelection call", async () => {
const comp = await openComposition(BASE_HTML);
expect(comp.getSelection()).toEqual([]);
});
it("setSelection updates getSelection", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title"]);
expect(comp.getSelection()).toEqual(["hf-title"]);
});
it("setSelection with multiple ids", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title", "hf-sub"]);
expect(comp.getSelection()).toEqual(["hf-title", "hf-sub"]);
});
it("setSelection([]) clears selection", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title"]);
comp.setSelection([]);
expect(comp.getSelection()).toEqual([]);
});
it("setSelection fires selectionchange with new ids", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
comp.on("selectionchange", (ids) => calls.push(ids));
comp.setSelection(["hf-title"]);
expect(calls).toEqual([["hf-title"]]);
});
it("setSelection fires selectionchange with empty array when clearing", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title"]);
const calls: string[][] = [];
comp.on("selectionchange", (ids) => calls.push(ids));
comp.setSelection([]);
expect(calls).toEqual([[]]);
});
it("selectionchange listener receives a fresh copy each call", async () => {
const comp = await openComposition(BASE_HTML);
const snapshots: string[][] = [];
comp.on("selectionchange", (ids) => snapshots.push(ids));
comp.setSelection(["hf-title"]);
comp.setSelection(["hf-sub"]);
expect(snapshots[0]).toEqual(["hf-title"]);
expect(snapshots[1]).toEqual(["hf-sub"]);
});
it("unsubscribed listener does not fire", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
const off = comp.on("selectionchange", (ids) => calls.push(ids));
off();
comp.setSelection(["hf-title"]);
expect(calls).toHaveLength(0);
});
it("selection() proxy operates on ids at call time", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title"]);
const proxy = comp.selection();
expect(proxy.ids).toEqual(["hf-title"]);
});
it("setSelection does not affect undo stack", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#ff0000" });
comp.setSelection(["hf-sub"]);
expect(comp.canUndo()).toBe(true);
comp.undo();
// selection must not have been pushed to history
expect(comp.canUndo()).toBe(false);
});
it("setSelection does not emit a patch event", async () => {
const comp = await openComposition(BASE_HTML);
const patches: unknown[] = [];
comp.on("patch", (e) => patches.push(e));
comp.setSelection(["hf-title"]);
expect(patches).toHaveLength(0);
});
it("setSelection with same ids does not fire selectionchange again", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
comp.on("selectionchange", (ids) => calls.push(ids));
comp.setSelection(["hf-title"]);
comp.setSelection(["hf-title"]); // same ids — must be a no-op
expect(calls).toHaveLength(1);
});
it("setSelection with same ids in different order fires selectionchange", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
comp.on("selectionchange", (ids) => calls.push(ids));
comp.setSelection(["hf-title", "hf-sub"]);
comp.setSelection(["hf-sub", "hf-title"]); // order differs — must fire
expect(calls).toHaveLength(2);
});
it("setSelection de-duplicates repeated ids", async () => {
const comp = await openComposition(BASE_HTML);
comp.setSelection(["hf-title", "hf-title", "hf-sub", "hf-title"]);
expect(comp.getSelection()).toEqual(["hf-title", "hf-sub"]);
});
it("setSelection with duplicates matching stored selection does not fire selectionchange", async () => {
const comp = await openComposition(BASE_HTML);
const calls: string[][] = [];
comp.on("selectionchange", (ids) => calls.push(ids));
comp.setSelection(["hf-title"]);
comp.setSelection(["hf-title", "hf-title"]); // de-duped = ["hf-title"] — no change
expect(calls).toHaveLength(1);
});
});
+11
View File
@@ -224,6 +224,17 @@ class CompositionImpl implements Composition {
return [...this.currentSelection];
}
setSelection(ids: string[]): void {
const deduped = Array.from(new Set(ids));
if (
deduped.length === this.currentSelection.length &&
deduped.every((id, i) => id === this.currentSelection[i])
) {
return;
}
this.updateSelection(deduped);
}
private updateSelection(ids: readonly string[]): void {
this.currentSelection = [...ids];
for (const handler of this.selectionHandlers) {
+2
View File
@@ -253,6 +253,8 @@ export interface Composition {
/** Curried handle — holds only the id, no stale-ref hazard */
element(id: HfId): ElementHandle;
getSelection(): string[];
/** Replace the current selection; fires selectionchange. Pass [] to clear. */
setSelection(ids: string[]): void;
// ── Advanced / agent layer (F10 layer 2) ──────────────────────────────────
dispatch(op: EditOp, opts?: { origin?: unknown }): void;