From 8721695a42cae6e2e3f3e423dd49c39e3618b395 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:50:20 +0100 Subject: [PATCH 01/14] Route a blank document's Planner decisions to Decisions once Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/room-workspace.tsx | 2 +- packages/editor/src/decision-state.test.ts | 51 ++++++++++++---------- packages/editor/src/decision-state.ts | 36 ++++++++++----- 3 files changed, 53 insertions(+), 36 deletions(-) diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 443621ac7..8363e9e52 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -473,7 +473,7 @@ export function RoomWorkspace( let cardMetadata = useSyncExternalStore(cardMeta.subscribe, cardMeta.snapshot, cardMeta.snapshot); let unanswered = countUnanswered(entries, cardMetadata); let hasPlanProse = useHasPlanContent(questions); - let hasPlanContent = documentHasPlanningContent(hasPlanProse, entries); + let hasPlanContent = documentHasPlanningContent(hasPlanProse); let [decisionView, setDecisionView] = useState(() => { let stored = localStorage.getItem("chopin:view:document"); return { diff --git a/packages/editor/src/decision-state.test.ts b/packages/editor/src/decision-state.test.ts index d7b5a7109..5b4470756 100644 --- a/packages/editor/src/decision-state.test.ts +++ b/packages/editor/src/decision-state.test.ts @@ -116,36 +116,39 @@ describe("decision attention", () => { ); }); - it("keeps a chat-linked inline card in Document while Planner questions still open Decisions", () => { + it("routes a blank document to Decisions whether or not its cards have threads", () => { let plannerQuestion = entry([undefined]); let conversationQuestion = entry([undefined]); conversationQuestion.value = { ...conversationQuestion.value, thread: "thread-1" }; let initial: DecisionViewState = { phase: "initial", preferred: "plan" }; - let plannerHasContent = documentHasPlanningContent(false, [plannerQuestion]); - let conversationHasContent = documentHasPlanningContent(false, [conversationQuestion]); - - expect(visibleDecisionView( - initial, - plannerHasContent, - countUnanswered([plannerQuestion]), - )).toBe("decisions"); - expect(visibleDecisionView( - initial, - conversationHasContent, - countUnanswered([conversationQuestion]), - )).toBe("plan"); + let unanswered = countUnanswered([plannerQuestion, conversationQuestion]); - let state = advanceDecisionView( - initial, - plannerHasContent, - countUnanswered([plannerQuestion]), - ); + expect(documentHasPlanningContent(false)).toBe(false); + expect(visibleDecisionView(initial, false, unanswered)).toBe("decisions"); + expect(advanceDecisionView(initial, false, unanswered).phase).toBe("forced"); + }); + + it("does not route a reader who is editing the document", () => { + let initial: DecisionViewState = { phase: "initial", preferred: "plan" }; + expect(visibleDecisionView(initial, false, 2, true)).toBe("plan"); + expect(advanceDecisionView(initial, false, 2, true)) + .toEqual({ phase: "complete", preferred: "plan" }); + }); + + it("holds forced Decisions while prose arrives and decisions are still open", () => { + let state = advanceDecisionView({ phase: "initial", preferred: "plan" }, false, 3); + state = advanceDecisionView(state, true, 2); expect(state.phase).toBe("forced"); - state = advanceDecisionView( - state, - conversationHasContent, - countUnanswered([plannerQuestion, conversationQuestion]), - ); + expect(visibleDecisionView(state, true, 2)).toBe("decisions"); + state = advanceDecisionView(state, true, 0); + expect(state).toEqual({ phase: "complete", preferred: "plan" }); + expect(visibleDecisionView(state, true, 0)).toBe("plan"); + }); + + it("never routes twice in one entry", () => { + let state = advanceDecisionView({ phase: "initial", preferred: "plan" }, false, 1); + state = advanceDecisionView(state, true, 0); + state = advanceDecisionView(state, false, 3); expect(state).toEqual({ phase: "complete", preferred: "plan" }); }); diff --git a/packages/editor/src/decision-state.ts b/packages/editor/src/decision-state.ts index fc58d9126..6566d1007 100644 --- a/packages/editor/src/decision-state.ts +++ b/packages/editor/src/decision-state.ts @@ -37,26 +37,35 @@ export function firstOpenDecision( }); } -/** A conversation-linked card is authored document content even before prose exists. */ -export function documentHasPlanningContent( - hasPlanProse: boolean, - entries: QuestionnaireEntry[], -): boolean { - return hasPlanProse || entries.some(entry => !!entry.value.thread); +/** + * Only prose makes a document more than its questions. A card, even one linked + * to a conversation, is a question waiting for an answer, and a blank page full + * of them belongs in Decisions. + */ +export function documentHasPlanningContent(hasPlanProse: boolean): boolean { + return hasPlanProse; } -/** A forced opening yields to Plan only when prose first arrives. */ +/** + * The opening phase routes a blank document to Decisions once per entry. + * + * Editing declines routing for good: moving someone mid-thought costs more + * than one extra click. A forced view holds until prose exists and nothing is + * left to answer, so a Planner heading cannot pull the reader out mid-deck. + */ export function advanceDecisionView( state: DecisionViewState, hasPlanContent: boolean, unanswered: number, + editing = false, ): DecisionViewState { if (state.phase === "complete") return state; if (state.phase === "forced") { - return hasPlanContent ? { phase: "complete", preferred: "plan" } : state; + return hasPlanContent && unanswered === 0 ? { phase: "complete", preferred: "plan" } : state; } if (hasPlanContent) return { ...state, phase: "complete" }; - return unanswered > 0 ? { ...state, phase: "forced" } : state; + if (unanswered === 0) return state; + return editing ? { ...state, phase: "complete" } : { ...state, phase: "forced" }; } export function selectDecisionView( @@ -70,8 +79,13 @@ export function visibleDecisionView( state: DecisionViewState, hasPlanContent: boolean, unanswered: number, + editing = false, ): DecisionView { - if (state.phase === "forced") return hasPlanContent ? "plan" : "decisions"; - if (state.phase === "initial" && !hasPlanContent && unanswered > 0) return "decisions"; + if (state.phase === "forced") { + return hasPlanContent && unanswered === 0 ? "plan" : "decisions"; + } + if (state.phase === "initial" && !hasPlanContent && unanswered > 0 && !editing) { + return "decisions"; + } return state.preferred; } From 5413f1555fb950ba33f50038dd98848d57dafe4a Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:59:31 +0100 Subject: [PATCH 02/14] Leave an editing reader in Document and announce the Decisions route Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/document-editing.ts | 21 +++++++++++++++ apps/web/src/room-workspace.tsx | 16 ++++++++++-- e2e/sidecar.e2e.ts | 30 ++++++++++------------ packages/editor/src/decision-state.test.ts | 16 ++++++++++++ 4 files changed, 64 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/document-editing.ts diff --git a/apps/web/src/document-editing.ts b/apps/web/src/document-editing.ts new file mode 100644 index 000000000..73e76c2d9 --- /dev/null +++ b/apps/web/src/document-editing.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +/** Whether focus is in this room's document editor, so routing can leave the reader alone. */ +export function useDocumentEditing(room: string): boolean { + let [editing, setEditing] = useState(false); + useEffect(() => { + let selector = `[data-workspace-room="${CSS.escape(room)}"] [data-document-view="plan"] ` + + `[contenteditable="true"]`; + let read = () => setEditing(!!document.activeElement?.closest(selector)); + // activeElement is still the old element during focusout; read after it moves. + let blur = () => queueMicrotask(read); + read(); + document.addEventListener("focusin", read); + document.addEventListener("focusout", blur); + return () => { + document.removeEventListener("focusin", read); + document.removeEventListener("focusout", blur); + }; + }, [room]); + return editing; +} diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 8363e9e52..273ccb69c 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -33,6 +33,7 @@ import { Chat } from "./chat/chat"; import { ChildProvenance } from "./child-provenance"; import { shouldShowResearchActionError, useResearchOfferLinks } from "./chat/research-offer"; import type { ResearchDraftController } from "./chat/research-draft-controller"; +import { useDocumentEditing } from "./document-editing"; import { advanceConversationAnnouncement } from "./conversation-plan/announcements"; import type { ConversationAnnouncementSummary } from "./conversation-plan/announcements"; import { evidenceRows, hasEvidence } from "./conversation-plan/evidence"; @@ -481,9 +482,10 @@ export function RoomWorkspace( preferred: initialDocumentView(profile, stored), }; }); + let editing = useDocumentEditing(room); let view: WorkspaceDocumentView = showImplementation && profile.implementation ? "build" - : visibleDecisionView(decisionView, hasPlanContent, unanswered); + : visibleDecisionView(decisionView, hasPlanContent, unanswered, editing); let attention = useDecisionAttention(unanswered); let latestCanEdit = useRef(canEdit); let latestCanManage = useRef(canManage); @@ -543,8 +545,18 @@ export function RoomWorkspace( } }, [onDocumentChanged, room, userId]); + // Focus alone never routes: only new decisions or new prose re-evaluate the opening. useEffect(() => { - setDecisionView(state => advanceDecisionView(state, hasPlanContent, unanswered)); + let next = advanceDecisionView(decisionView, hasPlanContent, unanswered, editing); + if (next === decisionView) return; + setDecisionView(next); + if (decisionView.phase === "initial" && next.phase === "forced") { + setAnnouncement(current => ({ + text: `Showing Decisions: ${unanswered} to answer`, + sequence: current.sequence + 1, + })); + } + // oxlint-disable-next-line react-hooks/exhaustive-deps -- editing and decisionView are read, not triggers }, [hasPlanContent, unanswered]); useEffect(() => { diff --git a/e2e/sidecar.e2e.ts b/e2e/sidecar.e2e.ts index 62c814172..76f1f410c 100644 --- a/e2e/sidecar.e2e.ts +++ b/e2e/sidecar.e2e.ts @@ -285,26 +285,20 @@ test( ); test( - "an unseeded document keeps linked questions inline and reveals them in Decisions", + "an unseeded document opens its Planner questions in Decisions", async ({ baseURL, page, room }) => { await authenticate(page, "ana", baseURL!); await page.goto(roomPath(room)); - await ready(page); let decisions = page.getByRole("button", { name: /^Decisions/ }); - await expect(decisions).toHaveAttribute("aria-pressed", "false"); - await expect(page.getByRole("button", { name: "Document", exact: true })) - .toHaveAttribute("aria-pressed", "true"); - await expect(content(page).locator("article[data-plan-sidecar-questionnaire]")) - .toHaveCount(2); - await decisions.click(); await expect(decisions).toHaveAttribute("aria-pressed", "true"); - let card = questionnaire(page); - await expect(card).toHaveCount(2); - await expect(card.getByRole("heading", { name: "Where should room state live?" })) - .toBeVisible(); - await expect(card.getByRole("heading", { name: "Which of these belong in the first cut?" })) - .toBeVisible(); - await expect(card.getByRole("tablist")).toHaveCount(0); + await expect(page.getByRole("status").filter({ hasText: "Showing Decisions: 2 to answer" })) + .toHaveCount(1); + await expect(page.locator('[data-document-view="decisions"] [data-deck]')) + .toHaveAttribute("aria-label", "2 open decisions"); + await page.getByRole("button", { name: "Document", exact: true }).click(); + await ready(page); + await expect(decisions).toHaveAttribute("aria-pressed", "false"); + await expect(content(page).locator("[data-deck]")).toHaveCount(1); }, ); @@ -431,10 +425,12 @@ test("compact Chat returns to the document after a collaborator adds prose", asy { timeout: 20_000 }, ); let nav = ana.getByRole("navigation", { name: "Workspace view" }); + // A blank document opens in Decisions; choosing Document keeps the reader there. + await expect(nav.getByRole("button", { name: "Decisions, 2 unanswered", exact: true })) + .toHaveAttribute("aria-current", "page"); + await nav.getByRole("button", { name: "Document", exact: true }).click(); await expect(nav.getByRole("button", { name: "Document", exact: true })) .toHaveAttribute("aria-current", "page"); - await expect(nav.getByRole("button", { name: "Decisions, 2 unanswered", exact: true })) - .toBeVisible(); await nav.getByRole("button", { name: /Chat/ }).click(); let boContext = await browser.newContext({ baseURL, viewport: { width: 1280, height: 800 } }); diff --git a/packages/editor/src/decision-state.test.ts b/packages/editor/src/decision-state.test.ts index 5b4470756..000773a7d 100644 --- a/packages/editor/src/decision-state.test.ts +++ b/packages/editor/src/decision-state.test.ts @@ -181,6 +181,22 @@ describe("decision attention", () => { }, ); + it("keeps a forced view when nothing is unanswered and no prose exists", () => { + let state = advanceDecisionView({ phase: "initial", preferred: "plan" }, false, 2); + + state = advanceDecisionView(state, false, 0); + + expect(state.phase).toBe("forced"); + expect(visibleDecisionView(state, false, 0)).toBe("decisions"); + }); + + it("ignores editing once the opening is forced", () => { + let state = advanceDecisionView({ phase: "initial", preferred: "plan" }, false, 2); + + expect(advanceDecisionView(state, false, 2, true)).toBe(state); + expect(visibleDecisionView(state, false, 2, true)).toBe("decisions"); + }); + it("lets explicit Document navigation override a forced Decisions opening", () => { let state = advanceDecisionView( { phase: "initial", preferred: "plan" }, From 7158a1aa9479933a982a5d212805036dd03d9930 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:01:59 +0100 Subject: [PATCH 03/14] Decide which Decisions cards form a deck Co-Authored-By: Claude Sonnet 5.5 --- packages/editor/src/decision-state.test.ts | 23 ++++++++++++++++++++++ packages/editor/src/decision-state.ts | 18 +++++++++++++++++ packages/editor/src/index.ts | 1 + 3 files changed, 42 insertions(+) diff --git a/packages/editor/src/decision-state.test.ts b/packages/editor/src/decision-state.test.ts index 000773a7d..80fd54b44 100644 --- a/packages/editor/src/decision-state.test.ts +++ b/packages/editor/src/decision-state.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "bun:test"; import { advanceDecisionView, countUnanswered, + decisionsDeck, documentHasPlanningContent, firstOpenDecision, selectDecisionView, @@ -264,3 +265,25 @@ describe("Decisions opening target", () => { expect(firstOpenDecision([decided, discarded])).toBeUndefined(); }); }); + +describe("Decisions deck", () => { + it("stacks two or more open decisions", () => { + expect(decisionsDeck(["a", "b"], ["z"], new Set(["a", "b"]))) + .toEqual({ deck: ["a", "b"], answered: [], history: ["z"] }); + }); + + it("keeps decisions answered here at the back while one is still open", () => { + expect(decisionsDeck(["b"], ["a", "z"], new Set(["a", "b"]))) + .toEqual({ deck: ["b", "a"], answered: ["a"], history: ["z"] }); + }); + + it("dissolves once nothing is open", () => { + expect(decisionsDeck([], ["a", "b"], new Set(["a", "b"]))) + .toEqual({ deck: [], answered: [], history: ["a", "b"] }); + }); + + it("shows one reopened decision on its own", () => { + expect(decisionsDeck(["r"], ["z"], new Set(["r"]))) + .toEqual({ deck: [], answered: [], history: ["z"] }); + }); +}); diff --git a/packages/editor/src/decision-state.ts b/packages/editor/src/decision-state.ts index 6566d1007..82293a505 100644 --- a/packages/editor/src/decision-state.ts +++ b/packages/editor/src/decision-state.ts @@ -89,3 +89,21 @@ export function visibleDecisionView( } return state.preferred; } + +/** Which Decisions cards form a deck, and which stay in resolved history below it. */ +export function decisionsDeck( + pending: readonly string[], + settled: readonly string[], + seen: ReadonlySet, +): { deck: string[]; answered: string[]; history: string[] } { + let answered = pending.length > 0 ? settled.filter(id => seen.has(id)) : []; + if (pending.length + answered.length < 2) { + return { deck: [], answered: [], history: [...settled] }; + } + let kept = new Set(answered); + return { + deck: [...pending, ...answered], + answered, + history: settled.filter(id => !kept.has(id)), + }; +} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 90a459739..d255c7b48 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -14,6 +14,7 @@ export type { Cursor } from "./cursor"; export { advanceDecisionView, countUnanswered, + decisionsDeck, documentHasPlanningContent, firstOpenDecision, selectDecisionView, From ac2bd94cd247e9ba6a7b6a589c8678049d3e1fc3 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:01:59 +0100 Subject: [PATCH 04/14] Ignore child editors and stale state when detecting editing Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/document-editing.ts | 17 ++++++++++------- apps/web/src/room-workspace.tsx | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/web/src/document-editing.ts b/apps/web/src/document-editing.ts index 73e76c2d9..da8ff39a3 100644 --- a/apps/web/src/document-editing.ts +++ b/apps/web/src/document-editing.ts @@ -4,16 +4,19 @@ import { useEffect, useState } from "react"; export function useDocumentEditing(room: string): boolean { let [editing, setEditing] = useState(false); useEffect(() => { + // An open child's editor sits inside the parent's workspace but is not this document. let selector = `[data-workspace-room="${CSS.escape(room)}"] [data-document-view="plan"] ` - + `[contenteditable="true"]`; - let read = () => setEditing(!!document.activeElement?.closest(selector)); - // activeElement is still the old element during focusout; read after it moves. - let blur = () => queueMicrotask(read); - read(); - document.addEventListener("focusin", read); + + `[contenteditable="true"]:not(.workspace-child-slot *)`; + let inEditor = (target: EventTarget | null) => + target instanceof Element && !!target.closest(selector); + let focus = (event: FocusEvent) => setEditing(inEditor(event.target)); + // During focusout activeElement is not yet the new target; relatedTarget is. + let blur = (event: FocusEvent) => setEditing(inEditor(event.relatedTarget)); + setEditing(inEditor(document.activeElement)); + document.addEventListener("focusin", focus); document.addEventListener("focusout", blur); return () => { - document.removeEventListener("focusin", read); + document.removeEventListener("focusin", focus); document.removeEventListener("focusout", blur); }; }, [room]); diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 273ccb69c..97034e27a 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -549,7 +549,7 @@ export function RoomWorkspace( useEffect(() => { let next = advanceDecisionView(decisionView, hasPlanContent, unanswered, editing); if (next === decisionView) return; - setDecisionView(next); + setDecisionView(state => (state === decisionView ? next : state)); if (decisionView.phase === "initial" && next.phase === "forced") { setAnnouncement(current => ({ text: `Showing Decisions: ${unanswered} to answer`, From 01747bb0d78c550e5a9c2607e29f18125368cb41 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:05:37 +0100 Subject: [PATCH 05/14] Present open decisions in the desktop Decisions tab as a deck Choosing the tab focuses the deck's current top; an explicit reveal brings the named card forward. Phones keep the list and save bar. The routing announcement now speaks only once the forced view has applied, and a window blur no longer clears the editing state. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/document-editing.ts | 6 ++- apps/web/src/room-workspace.tsx | 24 ++++++--- packages/editor/src/decisions.test.tsx | 23 +++++++++ packages/editor/src/decisions.tsx | 67 ++++++++++++++++++++++---- 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/apps/web/src/document-editing.ts b/apps/web/src/document-editing.ts index da8ff39a3..7342f7981 100644 --- a/apps/web/src/document-editing.ts +++ b/apps/web/src/document-editing.ts @@ -11,7 +11,11 @@ export function useDocumentEditing(room: string): boolean { target instanceof Element && !!target.closest(selector); let focus = (event: FocusEvent) => setEditing(inEditor(event.target)); // During focusout activeElement is not yet the new target; relatedTarget is. - let blur = (event: FocusEvent) => setEditing(inEditor(event.relatedTarget)); + let blur = (event: FocusEvent) => { + // The window losing focus keeps the editor's focus for when it returns. + if (event.relatedTarget === null && !document.hasFocus()) return; + setEditing(inEditor(event.relatedTarget)); + }; setEditing(inEditor(document.activeElement)); document.addEventListener("focusin", focus); document.addEventListener("focusout", blur); diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 97034e27a..ff8ef9caf 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -468,7 +468,7 @@ export function RoomWorkspace( let [researchErrors, setResearchErrors] = useState>({}); let [researchExecution, setResearchExecution] = useState(false); let researchDrafts = useMemo(() => new Map(), [room]); - let [reveal, setReveal] = useState<{ widget: string; token: number }>(); + let [reveal, setReveal] = useState<{ widget: string; token: number; resume?: boolean }>(); let [planScrollTop, setPlanScrollTop] = useState(0); let entries = useQuestionnaires(questions); let cardMetadata = useSyncExternalStore(cardMeta.subscribe, cardMeta.snapshot, cardMeta.snapshot); @@ -550,15 +550,23 @@ export function RoomWorkspace( let next = advanceDecisionView(decisionView, hasPlanContent, unanswered, editing); if (next === decisionView) return; setDecisionView(state => (state === decisionView ? next : state)); - if (decisionView.phase === "initial" && next.phase === "forced") { - setAnnouncement(current => ({ - text: `Showing Decisions: ${unanswered} to answer`, - sequence: current.sequence + 1, - })); - } // oxlint-disable-next-line react-hooks/exhaustive-deps -- editing and decisionView are read, not triggers }, [hasPlanContent, unanswered]); + // Announce the route only once it has applied: a tab choice in the same + // batch makes the routing updater above a no-op. + let routedPhase = useRef(decisionView.phase); + useEffect(() => { + let before = routedPhase.current; + routedPhase.current = decisionView.phase; + if (before !== "initial" || decisionView.phase !== "forced") return; + setAnnouncement(current => ({ + text: `Showing Decisions: ${unanswered} to answer`, + sequence: current.sequence + 1, + })); + // oxlint-disable-next-line react-hooks/exhaustive-deps -- the count is read when routing applies + }, [decisionView.phase]); + useEffect(() => { if (!taskLink || !hasPlanContent) return; setTaskLink(false); @@ -624,7 +632,7 @@ export function RoomWorkspace( } if (next === "decisions" && revealFirst) { let first = firstOpenDecision(entries, cardMetadata); - setReveal({ widget: first?.id ?? "", token: Date.now() }); + setReveal({ widget: first?.id ?? "", token: Date.now(), resume: true }); } }; diff --git a/packages/editor/src/decisions.test.tsx b/packages/editor/src/decisions.test.tsx index bae05d025..ccb3e467d 100644 --- a/packages/editor/src/decisions.test.tsx +++ b/packages/editor/src/decisions.test.tsx @@ -108,3 +108,26 @@ test("a failed document open offers a retry instead of indefinite loading", () = expect(failed).toContain("Try again"); expect(failed).not.toContain("Loading decisions…"); }); + +function openEntry(id: string, header: string): QuestionnaireEntry { + return { + id, + value: { + id, + questions: [{ id: `${id}-q`, header, prompt: `${header}?`, multiple: false, options: [] }], + }, + }; +} + +test("two open decisions render as a desktop deck above resolved history", () => { + let html = markup(undefined, [openEntry("a", "Storage"), openEntry("b", "Scope"), RESOLVED]); + expect(html).toContain('aria-roledescription="carousel"'); + expect(html).toContain('aria-label="2 open decisions"'); + expect(html).toContain("1 of 2"); + expect(html.indexOf("data-deck=")).toBeLessThan(html.indexOf("resolved")); + expect(html).not.toContain("decision-save-bar"); +}); + +test("one open decision renders without a deck", () => { + expect(markup(undefined, [openEntry("a", "Storage")])).not.toContain("data-deck"); +}); diff --git a/packages/editor/src/decisions.tsx b/packages/editor/src/decisions.tsx index cf2db2ab5..c21c98a3a 100644 --- a/packages/editor/src/decisions.tsx +++ b/packages/editor/src/decisions.tsx @@ -11,6 +11,8 @@ import { cardStatus } from "@chopin/dialect"; import { CheckIcon, ChevronIcon, DecisionIcon } from "@chopin/icons"; import { EmptyState } from "@chopin/visuals"; +import { decisionsDeck } from "./decision-state"; +import { Deck, revealDeckItem } from "./deck"; import { MotionDisclosure, MotionDisclosureIcon } from "./disclosure-motion"; import { usePhone } from "./pointer"; import { useQuestionnaires } from "./questionnaires"; @@ -49,8 +51,10 @@ export type DecisionsProps = { * * Carries a token as well as an id so asking for the same one twice still * scrolls — naming it again means "show me", not "it is already open". + * `resume` means the tab itself was chosen: a deck keeps its current top + * rather than bringing the named card forward. */ - reveal?: { widget: string; token: number }; + reveal?: { widget: string; token: number; resume?: boolean }; }; let EMPTY_CARDS: ReadonlyMap = new Map(); @@ -260,9 +264,12 @@ export function Decisions( `[data-plan-sidecar-questionnaire="${id}"]`, ); if (target) { - target.scrollIntoView({ block: "center" }); - target.tabIndex = -1; - target.focus({ preventScroll: true }); + let inDeck = target.closest("[data-deck]"); + if (inDeck && !reveal.resume) revealDeckItem(target); + let focusTarget = inDeck?.querySelector("[data-deck-top]") ?? target; + focusTarget.scrollIntoView({ block: "center" }); + focusTarget.tabIndex = -1; + focusTarget.focus({ preventScroll: true }); } else heading.current?.focus(); }, [entries, metadata, reveal]); @@ -273,11 +280,15 @@ export function Decisions( if (!previous || previous.isConnected || document.activeElement !== document.body) return; focusedQuestionnaire.current = undefined; let next = entries.find(entry => waiting(entry, metadata)); - let target = next + let card = next ? content.current?.querySelector( `[data-plan-sidecar-questionnaire="${CSS.escape(next.id)}"]`, ) : undefined; + // A card behind a deck's top is inert, so the deck's top takes focus instead. + let target = card?.closest("[data-deck]")?.querySelector( + "[data-deck-top]", + ) ?? card; if (target) { target.tabIndex = -1; target.focus({ preventScroll: true }); @@ -332,7 +343,7 @@ export function Decisions( following.current = undefined; }; }, []); - let leaveSaveBar = useCallback(() => { + let focusRemaining = useCallback(() => { let next = content.current?.querySelector( "[data-plan-sidecar-questionnaire]", ); @@ -351,9 +362,29 @@ export function Decisions( let pending = entries.filter(entry => open.has(entry.id) || closing.has(entry.id)); let settled = entries.filter(entry => !open.has(entry.id) && !closing.has(entry.id)); + // Cards open at some point in this mount, so one answered here waits in the deck. + let [seen, setSeen] = useState>(() => new Set()); + let openKey = [...open].join("\n"); + useEffect(() => { + if ([...open].every(id => seen.has(id))) return; + setSeen(previous => new Set([...previous, ...open])); + // `openKey` stands for `open`, which is rebuilt every render. + }, [openKey]); + // Phones keep the one-pane list and its save bar. + let arranged = phone + ? { deck: [], answered: [], history: settled.map(entry => entry.id) } + : decisionsDeck( + pending.map(entry => entry.id), + settled.map(entry => entry.id), + new Set([...seen, ...open]), + ); + let byId = new Map(entries.map(entry => [entry.id, entry])); + let answeredHere = new Set(arranged.answered); + let listed = settled.filter(entry => !answeredHere.has(entry.id)); + let outstanding = pending.length; // A folding card already counts as resolved, so this agrees with the tab's badge. - let resolved = settled.length + closing.size; + let resolved = listed.length + closing.size; let empty = outstanding === 0 && resolved === 0 && !hasAdditional; let question = (entry: QuestionnaireEntry) => ( @@ -438,6 +469,24 @@ export function Decisions( /> ) + : arranged.deck.length > 0 + ? ( + focusRemaining()} + items={arranged.deck.flatMap(id => { + let entry = byId.get(id); + return entry + ? [{ + id, + settled: answeredHere.has(id), + label: entry.value.questions[0]?.header ?? "Decision", + card: question(entry), + }] + : []; + })} + /> + ) : (
{pending.map(entry => { @@ -484,7 +533,7 @@ export function Decisions( surface="decision-history" >
- {settled.map(question)} + {listed.map(question)}
@@ -493,7 +542,7 @@ export function Decisions( {phone && ( From dab3a3b63cd9890bb4d7e89a77106b9e4d59c5a3 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:18:00 +0100 Subject: [PATCH 06/14] Cover the desktop Decisions deck in the sidecar browser tests Cards behind the top are inert, so tests bring a decision forward before answering it. A card answered or discarded in this visit now waits at the back of the deck instead of joining resolved history until the next visit. Co-Authored-By: Claude Opus 5.5 --- e2e/decision-deck-fixtures.ts | 20 ++++++++ e2e/sidecar-card-states.e2e.ts | 48 ++++++++++-------- e2e/sidecar.e2e.ts | 89 ++++++++++++++++++---------------- 3 files changed, 96 insertions(+), 61 deletions(-) diff --git a/e2e/decision-deck-fixtures.ts b/e2e/decision-deck-fixtures.ts index 078a183fd..d3b8474db 100644 --- a/e2e/decision-deck-fixtures.ts +++ b/e2e/decision-deck-fixtures.ts @@ -1,6 +1,8 @@ import { storedQuestion } from "../apps/server/src/testing/plan"; +import { expect } from "./room"; import type { SeedState } from "../apps/server/src/testing/plan"; +import type { Page } from "@playwright/test"; /** The digest of a paragraph reading exactly `Anchored paragraph.` */ export const ANCHORED_DIGEST = @@ -86,3 +88,21 @@ ${choices.map(choice => `