diff --git a/.lane/memory/apps/server/src/questions/anchors.ts/01M4T8QPPHVX0JGCQT2YE672VG-an-open-question-s-empty-anc.md b/.lane/memory/apps/server/src/questions/anchors.ts/01M4T8QPPHVX0JGCQT2YE672VG-an-open-question-s-empty-anc.md new file mode 100644 index 000000000..03b033a1d --- /dev/null +++ b/.lane/memory/apps/server/src/questions/anchors.ts/01M4T8QPPHVX0JGCQT2YE672VG-an-open-question-s-empty-anc.md @@ -0,0 +1,8 @@ +--- +id: 01M4T8QPPHVX0JGCQT2YE672VG +anchor: function answer +created: 2026-10-13T09:15:43Z +norm: '1' +--- + +An open question's empty anchor set gets persisted by Questions.rebase as {anchors:[], pending:false}, indistinguishable from a reviewed-empty set. Answering must convert empty sets to pending/missing (Anchors.answer) or a blank-document ask reads as 'No related text' forever. diff --git a/.lane/memory/e2e/decision-prose.e2e.ts/01M4TE639KWKCMD26E81A3NV5C-a-blank-document-automatical.md b/.lane/memory/e2e/decision-prose.e2e.ts/01M4TE639KWKCMD26E81A3NV5C-a-blank-document-automatical.md new file mode 100644 index 000000000..2fcef78a8 --- /dev/null +++ b/.lane/memory/e2e/decision-prose.e2e.ts/01M4TE639KWKCMD26E81A3NV5C-a-blank-document-automatical.md @@ -0,0 +1,8 @@ +--- +id: 01M4TE639KWKCMD26E81A3NV5C +anchor: function decide +created: 2026-10-13T10:50:57Z +norm: '1' +--- + +A blank document automatically opens new questions in Decisions. These prose tests explicitly return to Document before answering so their inline card, marker, and collapse assertions exercise the document rather than the hidden pane. diff --git a/.lane/memory/packages/editor/src/deck-leave.ts/01M4TGSVAG4K3CADXT761BB7SY-hidden-accepted-comment-deci.md b/.lane/memory/packages/editor/src/deck-leave.ts/01M4TGSVAG4K3CADXT761BB7SY-hidden-accepted-comment-deci.md new file mode 100644 index 000000000..2ac721499 --- /dev/null +++ b/.lane/memory/packages/editor/src/deck-leave.ts/01M4TGSVAG4K3CADXT761BB7SY-hidden-accepted-comment-deci.md @@ -0,0 +1,8 @@ +--- +id: 01M4TGSVAG4K3CADXT761BB7SY +anchor: function $caretAfterRun +created: 2026-10-13T11:36:42Z +norm: '1' +--- + +Hidden accepted-comment Decision projections are decorator nodes and can follow a questionnaire run. Continue to the next editable element when handing off the caret; stopping at a hidden decorator strands focus on the page. diff --git a/.lane/memory/packages/question/src/react/question-view.tsx/01M4TAWRQGEA1XA4YY0BK2R6D7-guard-pending-add-selection.md b/.lane/memory/packages/question/src/react/question-view.tsx/01M4TAWRQGEA1XA4YY0BK2R6D7-guard-pending-add-selection.md index 8fba3e880..216c735be 100644 --- a/.lane/memory/packages/question/src/react/question-view.tsx/01M4TAWRQGEA1XA4YY0BK2R6D7-guard-pending-add-selection.md +++ b/.lane/memory/packages/question/src/react/question-view.tsx/01M4TAWRQGEA1XA4YY0BK2R6D7-guard-pending-add-selection.md @@ -4,8 +4,9 @@ anchor: function QuestionView created: 2026-10-13T09:53:26Z norm: '1' sig: dfb307d59bab0014 -body_hash: cd15378d7927a778 -raw_hash: 505d1899c58e147b +body_hash: 786699c942c22472 +raw_hash: 2f2a6568bcdbe842 +vouched: 2026-10-13T12:08:28Z lines: 770-1308 --- diff --git a/apps/server/src/questions-service-durability-1.test.ts b/apps/server/src/questions-service-durability-1.test.ts index 214357bf4..c06c7171b 100644 --- a/apps/server/src/questions-service-durability-1.test.ts +++ b/apps/server/src/questions-service-durability-1.test.ts @@ -175,3 +175,50 @@ test("a discarded or unknown card cannot be discarded again", async () => { expect(after).toBeGreaterThan(before); expect(context.broadcasts).toHaveLength(after); }); + +test("a decision asked before any prose exists still owes a review once answered", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition(), { revision: plan.revision, blocks: [[]] }); + await asked.created; + let id = [...plan.records.keys()][0]!; + let question = plan.records.get(id)!.definition.questions[0]!.id; + // Any commit before the answer stores the open question's empty set as it reads. + Questions.rebase(plan); + expect(plan.records.get(id)!.anchors?.questions[question]).toEqual({ + anchors: [], + pending: false, + }); + let ana = member("ana"); + await selectFirstOption(plan, ana.ws, id); + await Questions.submit(plan, context.server, "test", ana.ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: Store.get(plan.questions, id)!.revision, + }); + await asked.waiting; + + // An empty document gave the question nothing to point at; that is not a review. + expect(Questions.anchors(plan)[0]!.questions[question]).toEqual({ + anchors: [], + pending: true, + reason: "missing", + }); + expect(Questions.outstanding(plan)).toEqual([{ widget: id, question, reason: "missing" }]); + let relayed = context.broadcasts.findLast(frame => frame.kind === "plan:anchors") as + | { widgets: Array<{ widget: string; questions: Record }> } + | undefined; + expect(relayed?.widgets.find(item => item.widget === id)?.questions[question]?.pending).toBe( + true, + ); + let reopened = await restart(context); + expect(Questions.anchors(reopened)[0]!.questions[question]).toEqual({ + anchors: [], + pending: true, + reason: "missing", + }); + expect(Questions.outstanding(reopened)).toEqual([{ widget: id, question, reason: "missing" }]); +}); diff --git a/apps/server/src/questions/anchors.test.ts b/apps/server/src/questions/anchors.test.ts index f0c4005bc..e1a1eb837 100644 --- a/apps/server/src/questions/anchors.test.ts +++ b/apps/server/src/questions/anchors.test.ts @@ -108,3 +108,25 @@ describe("what a question owes the agent", () => { expect(Anchors.pending({ ...open, anchors: value })).toEqual([]); }); }); + +describe("answering", () => { + it("owes a review for each question that points at nothing yet", () => { + let open = record({ anchors: Anchors.read(record()) }); + expect(Anchors.answer(open).questions).toEqual({ + q1: { anchors: [], pending: true, reason: "missing" }, + q2: { anchors: [], pending: true, reason: "missing" }, + }); + }); + + it("keeps a placement the question already has, and a review already owed", () => { + let placed = Anchors.set(Anchors.read(record()), "q1", [anchor("a")]); + let owed = { + ...placed, + questions: { + ...placed.questions, + q2: { anchors: [], pending: true, reason: "plan_changed" as const }, + }, + }; + expect(Anchors.answer(record({ anchors: owed })).questions).toEqual(owed.questions); + }); +}); diff --git a/apps/server/src/questions/anchors.ts b/apps/server/src/questions/anchors.ts index 0edd9272e..115b1d246 100644 --- a/apps/server/src/questions/anchors.ts +++ b/apps/server/src/questions/anchors.ts @@ -73,6 +73,21 @@ export function invalidate(record: Question, reason: Plan.AnchorReason): Plan.Wi return value; } +/** + * A decision's relationships once it is answered. + * + * A question asked before any prose existed, or never placed, points at + * nothing; that is not a review. Answering it puts it on the Planner's list, + * so the reader is told its prose is owed rather than that it has none. + */ +export function answer(record: Question): Plan.WidgetAnchors { + let value = read({ ...record, status: "answered" }); + for (let [id, set] of Object.entries(value.questions)) { + if (set.anchors.length === 0 && !set.pending) value.questions[id] = empty(true, "missing"); + } + return value; +} + /** Everything the agent still owes a review on. */ export function pending(record: Question): Pending[] { let value = read(record); diff --git a/apps/server/src/questions/service-submit.ts b/apps/server/src/questions/service-submit.ts index ec3d82584..4b63e51aa 100644 --- a/apps/server/src/questions/service-submit.ts +++ b/apps/server/src/questions/service-submit.ts @@ -4,6 +4,7 @@ import * as Y from "yjs"; import { activeSettleDeferral } from "../conversation-plan/preference"; import { ConversationCapacityError } from "../conversation-plan/events"; +import * as Anchors from "./anchors"; import { isOpenStatus } from "./records"; import * as room from "../plan/room"; @@ -123,6 +124,7 @@ export async function submit( decidedAt: at, choices: optionIds, editors: [...new Set([...record.editors, ...claimed.claim.entry.editors])], + anchors: Anchors.answer(record), }; records.set(msg.id, answered); let intent = proseIntent(answered); @@ -224,6 +226,8 @@ export async function submit( answers: claimed.answers, }), () => announce(plan, server, roomId, msg.id), + // An answer can put a review on the Planner's list; readers learn its prose is owed. + () => Service.anchors(plan, server, roomId), () => emit(plan, event), ] ) { diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 194eda248..bbc9c4637 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -689,6 +689,8 @@ export function RoomWorkspace( latestPlanVisible.current = planVisible; let latestBusy = useRef(chatActivity.busy); latestBusy.current = chatActivity.busy; + // A running turn is what lets the document say decisions are being written up. + useEffect(() => questions.setPlannerBusy(chatActivity.busy), [questions, chatActivity.busy]); let [documentWatch, setDocumentWatch] = useState(QUIET_DOCUMENT); // Read through a ref: the host's callback changes as its route does, and the socket below must // not reconnect (remounting the editor and losing its scroll) when a child opens over it. diff --git a/e2e/decision-marker.e2e.ts b/e2e/decision-marker.e2e.ts index 69ec1aeae..729606f11 100644 --- a/e2e/decision-marker.e2e.ts +++ b/e2e/decision-marker.e2e.ts @@ -363,7 +363,7 @@ test("a touch marker reaches 44px tall within the gutter, and a tap pins without } }); -test("a decision with no prose keeps a compact settled line instead of a marker", async ({ join, seed }) => { +test("a decision with no prose draws nothing in the document, beside a marked one", async ({ join, seed }) => { let orphan = { ...STATE.questions[0]!, anchors: { widget: WIDGET_A, questions: { [QUESTION_A]: { anchors: [], pending: false } } }, @@ -373,12 +373,10 @@ test("a decision with no prose keeps a compact settled line instead of a marker" await open(page, "Two weeks"); await expect(marker(page)).toHaveCount(0); - let settled = page.locator( - `[data-document-view="plan"] [data-card-settled][data-plan-sidecar-questionnaire="${WIDGET_A}"]`, - ); - await expect(settled).toBeVisible(); - await expect(settled.getByText("Decided: Team by team · @ana", { exact: true })) - .toBeVisible(); + await expect(page.locator('[data-document-view="plan"] [data-card-settled]')).toHaveCount(0); + await expect( + page.locator(`[data-document-view="plan"] [data-plan-sidecar-questionnaire="${WIDGET_A}"]`), + ).toBeHidden(); await expect(marker(page, "Two weeks")).toBeVisible(); }); @@ -476,7 +474,7 @@ function settle(page: Page) { ); } -test("a second click where the caret already is does not send typing on a selected card to the document start", async ({ join, seed }) => { +test("a second click where the caret already is never sends typing past a hidden card to the document start", async ({ join, seed }) => { let orphan = { ...STATE.questions[0]!, anchors: { widget: WIDGET_A, questions: { [QUESTION_A]: { anchors: [], pending: false } } }, @@ -497,7 +495,8 @@ test("a second click where the caret already is does not send typing on a select await page.keyboard.type("xyz"); await expect(content(page).locator("p").first()).toHaveText(FIRST); } - await expect(content(page)).not.toContainText("xyz"); + // The prose-less card draws nothing, so the caret passes it into the next paragraph. + await expect(content(page).locator("p").nth(1)).toContainText("xyz"); }); const hiddenCard = (page: Page, widget: string) => diff --git a/e2e/decision-prose.e2e.ts b/e2e/decision-prose.e2e.ts index f87ac0309..9201d46e3 100644 --- a/e2e/decision-prose.e2e.ts +++ b/e2e/decision-prose.e2e.ts @@ -63,6 +63,7 @@ async function decide(page: Page, room: string, option = OPTION) { await openJevWire(page, room); let source = await sendChat(page, QUESTION); await sendChat(page, OPTION); + await page.getByRole("button", { name: "Document", exact: true }).click(); let decision = card(page); await expect(decision).toBeVisible(); await decision.getByText(option, { exact: true }).click(); @@ -158,6 +159,7 @@ test("ordinary prose edits retain the marker; deletion orphans it for both reade let ben = await join("ben"); await openJevWire(ben, room); await decide(ana, room); + await ben.getByRole("button", { name: "Document", exact: true }).click(); await expect(marker(ben)).toBeVisible(); await prose(ana).click(); @@ -315,6 +317,7 @@ test("a failed prose job is visible on the opening message and Retry uses the co await openJevWire(page, room); let source = await sendChat(page, QUESTION); await sendChat(page, OPTION); + await page.getByRole("button", { name: "Document", exact: true }).click(); await card(page).getByText(OPTION, { exact: true }).click(); await card(page).getByRole("button", { name: "Save", exact: true }).click(); await waitForProseJob(page, "failed"); @@ -328,85 +331,105 @@ test("a failed prose job is visible on the opening message and Retry uses the co await expect(prose(page)).toBeVisible(); }); -test("the settled line follows its prose job through failure, Retry, writing, and done", async ({ join, room }) => { +test("a write-up shows one quiet line in the document; failure and Retry live in Decisions", async ({ join, room }) => { let page = await join("ana"); await scriptProse(OPTION, "another-card"); await openJevWire(page, room); await sendChat(page, QUESTION); await sendChat(page, OPTION); + await page.getByRole("button", { name: "Document", exact: true }).click(); await card(page).getByText(OPTION, { exact: true }).click(); await card(page).getByRole("button", { name: "Save", exact: true }).click(); await waitForProseJob(page, "failed"); - let settled = content(page).locator("[data-card-settled]"); - await expect(settled).toContainText(`Decided: ${OPTION} · @ana`); - await expect(settled).toContainText("Couldn't write this up"); - await expect(settled).not.toContainText("Writing up…"); + let writing = content(page).locator("[data-plan-writing-up]"); + await expect(content(page).locator("[data-card-settled]")).toHaveCount(0); + await expect(writing).toHaveCount(0); + let decisions = page.locator('[data-document-view="decisions"]'); + let show = async () => { + await page.getByRole("button", { name: /^Decisions/ }).click(); + let history = page.getByRole("button", { name: "1 resolved", exact: true }); + if (await history.getAttribute("aria-expanded") === "false") await history.click(); + await expect(history).toHaveAttribute("aria-expanded", "true"); + await expect(decisions.getByRole("button", { name: "Retry write-up", exact: true })) + .toBeVisible(); + await expect(decisions).toContainText("Couldn't write this up"); + }; + await show(); await page.reload(); await ready(page); - await expect(settled).toContainText("Couldn't write this up"); + await show(); await openJevWire(page, room); await scriptProse(OPTION, "$target", { hold: true }); try { - await settled.getByRole("button", { name: "Retry write-up", exact: true }).click(); - await expect(settled).toContainText("Writing up…"); - await expect(settled).not.toContainText("Couldn't write this up"); + await decisions.getByRole("button", { name: "Retry write-up", exact: true }).click(); + await expect(decisions).toContainText("Writing up…"); + await expect(decisions).not.toContainText("Couldn't write this up"); + await page.getByRole("button", { name: "Document", exact: true }).click(); + await expect(writing).toHaveText("Writing up a decision…"); } finally { await releasePlannerJob("prose"); } await waitForProseJob(page, "done"); await expect(prose(page)).toBeVisible(); - await expect(settled).toHaveCount(0); + await expect(writing).toHaveCount(0); }); -test("the collapse has an intermediate inert state, respects reduced motion, and rapid reopen remains usable", async ({ join, room }) => { +test("the collapse is inert, shrinks, and permits a collaborator to reopen before a reduced-motion Save", async ({ join, room }) => { let page = await join("ana"); - await scriptProse(OPTION); + let ben = await join("ben"); + await scriptProse(OPTION, "$target", { hold: true }); await openJevWire(page, room); await sendChat(page, QUESTION); await sendChat(page, OPTION); + await page.getByRole("button", { name: "Document", exact: true }).click(); let decision = card(page); await decision.getByText(OPTION, { exact: true }).click(); + // Hold the closing node while inspecting its transition and interrupting it with Reopen. + await page.clock.install(); + await page.clock.pauseAt(new Date()); + await page.evaluate(() => { + let observer = new MutationObserver(() => { + let closing = document.querySelector("[data-decision-collapsing]"); + if (!closing) return; + closing.getBoundingClientRect(); + for (let animation of closing.getAnimations({ subtree: true })) animation.pause(); + observer.disconnect(); + }); + observer.observe(document.body, { attributes: true, childList: true, subtree: true }); + }); await decision.getByRole("button", { name: "Save", exact: true }).click(); let closing = page.locator("[data-decision-collapsing]"); await expect(closing).toHaveCount(1); await expect(closing).toHaveAttribute("aria-hidden", "true"); await expect(closing).toHaveAttribute("inert", ""); - let heights = await closing.evaluate(async element => { + let heights = await closing.evaluate(element => { + let animations = element.getAnimations({ subtree: true }); + if (!animations.length) throw new Error("The closing card has no transition to sample."); let samples: number[] = []; - for (let index = 0; index < 4; index++) { - await new Promise(resolve => requestAnimationFrame(() => resolve())); + for (let at of [0, 0.25, 0.5, 0.75]) { + for (let animation of animations) { + animation.currentTime = at * Number(animation.effect!.getTiming().duration); + } samples.push(element.getBoundingClientRect().height); } return samples; }); expect(heights[0]).toBeGreaterThan(0); expect(heights.some(height => height > 0 && height < heights[0]!)).toBe(true); - await expect(marker(page)).toBeVisible(); - let popover = await openPopover(page); + await ben.getByRole("button", { name: /^Decisions/ }).click(); + let history = ben.getByRole("button", { name: "1 resolved", exact: true }); + if (await history.getAttribute("aria-expanded") === "false") await history.click(); + let reopen = ben.locator('[data-document-view="decisions"]') + .getByRole("button", { name: "Reopen", exact: true }); + await expect(reopen).toBeVisible(); await expect(closing).toHaveCount(1); - let reopen = popover.getByRole("button", { name: "Reopen", exact: true }); - await reopen.evaluate(button => { - document.addEventListener( - "click", - event => { - if (event.composedPath().includes(button)) { - (window as Window & { reopenedWhileClosing?: boolean }).reopenedWhileClosing = - document.querySelector("[data-decision-collapsing]") !== null; - } - }, - { capture: true, once: true }, - ); - }); - let reopenBox = await reopen.boundingBox(); - expect(reopenBox).toBeTruthy(); - await page.mouse.click(reopenBox!.x + reopenBox!.width / 2, reopenBox!.y + reopenBox!.height / 2); - expect( - await page.evaluate(() => - (window as Window & { reopenedWhileClosing?: boolean }).reopenedWhileClosing - ), - ) - .toBe(true); + try { + await reopen.click(); + } finally { + await releasePlannerJob("prose"); + } + await page.clock.resume(); await expect(card(page)).toBeVisible(); await card(page).getByText(OPTION, { exact: true }).click(); await expect(card(page).getByRole("button", { name: "Save", exact: true })).toBeEnabled(); diff --git a/e2e/harness-decisions-deck.e2e.ts b/e2e/harness-decisions-deck.e2e.ts index 042650991..938cb0b76 100644 --- a/e2e/harness-decisions-deck.e2e.ts +++ b/e2e/harness-decisions-deck.e2e.ts @@ -7,12 +7,18 @@ import { content, expect, test } from "./room"; import type { Page } from "@playwright/test"; -async function ask(page: Page) { +async function ask(page: Page, also = "") { let input = chatInput(page); - await input.fill("@chopin ASK-DECISIONS"); + await input.fill(`@chopin ASK-DECISIONS${also}`); await input.press("Enter"); } +const ASKED_PROMPTS = [ + "Where should room state live?", + "What belongs in the first cut?", + "How should we deploy?", +]; + function decisionsButton(page: Page) { return page.getByRole("group", { name: "Document view" }) .getByRole("button", { name: /^Decisions/ }); @@ -132,3 +138,57 @@ test("selecting Decisions resumes at the deck's current top card", async ({ join await expect(top(page).getByRole("heading", { name: "What belongs in the first cut?" })) .toBeVisible(); }); + +test("answered decisions with no prose leave the document blank and writable", async ({ join }) => { + let page = await join("ana"); + await ask(page); + await answerAll(page); + await expect(page.getByRole("button", { name: "3 resolved" })).toBeVisible(); + + await page.getByRole("button", { name: "Document", exact: true }).click(); + let plan = content(page); + await expect(plan).toHaveAttribute("data-plan-blank", ""); + await expect(page.locator('[data-document-view="plan"] [data-card-settled]')).toHaveCount(0); + await expect(page.getByRole("status").filter({ hasText: "Writing up" })).toHaveCount(0); + expect( + await plan.locator(":scope > p").first().evaluate(element => + getComputedStyle(element, "::before").content + ), + ).toBe('"Start writing, or ask Chopin to plan"'); + + await plan.click(); + await page.keyboard.type("Notes from the call."); + await expect(plan).toContainText("Notes from the call."); + await expect(plan).not.toHaveAttribute("data-plan-blank"); + + // Still answered, and still counted, in Decisions. + await decisionsButton(page).click(); + await page.getByRole("button", { name: "3 resolved" }).click(); + await expect(page.locator('[data-document-view="decisions"]')) + .toContainText("Where should room state live?"); +}); + +test("the document says once that decisions are being written up, then shows their prose", async ({ join }) => { + let page = await join("ana"); + await ask(page, " THEN-WRITE"); + await expect(deck(page)).toBeVisible(); + // Answered in the document, so the wait is seen where the prose will land. + await page.getByRole("button", { name: "Document", exact: true }).click(); + let inline = page.locator('[data-document-view="plan"] [data-deck-top]'); + for (let index = 0; index < 3; index++) { + await inline.getByText("First option", { exact: true }).click(); + await inline.getByRole("button", { name: "Save", exact: true }).click(); + if (index < 2) await expect(inline).toContainText(ASKED_PROMPTS[index + 1]!); + } + + let writing = page.locator('[data-document-view="plan"] [data-plan-writing-up]'); + await expect(writing).toHaveCount(1); + await expect(writing).toHaveText("Writing up 3 decisions…"); + await expect(page.locator('[data-document-view="plan"] [data-card-settled]')).toHaveCount(0); + await expect(content(page)).not.toHaveAttribute("data-plan-blank"); + + await expect(content(page)).toContainText("Storage: we chose the first option."); + await expect(page.getByRole("button", { name: "Decision: First option" }).first()).toBeVisible(); + await expect(page.locator('[data-document-view="plan"] [data-plan-collapsed]')).toHaveCount(3); + await expect(writing).toHaveCount(0); +}); diff --git a/e2e/harness/fake-harness.ts b/e2e/harness/fake-harness.ts index ee9308bdf..857e882a6 100644 --- a/e2e/harness/fake-harness.ts +++ b/e2e/harness/fake-harness.ts @@ -25,6 +25,8 @@ const COMMENT_THREAD = /using thread ([0-9A-HJKMNP-TV-Z]{26})/; const BACKSTOP_PROMPT = "COMMENT-BACKSTOP"; /** Makes the fake Planner ask three decisions, appended without linked prose. */ const ASK_PROMPT = "ASK-DECISIONS"; +/** With the ask, writes up and links the answered decisions before the turn ends. */ +const WRITE_PROMPT = "THEN-WRITE"; const ASKED = [ { header: "Storage", question: "Where should room state live?" }, { header: "Scope", question: "What belongs in the first cut?" }, @@ -147,6 +149,40 @@ export function createFakeHarness( }); }; + // Writes a paragraph per answered decision and links each, as a real Planner + // does after its ask returns. Paused first, so the wait is observable. + let writeUp = async () => { + await pause(); + let read = await callTool("read_plan"); + let current = (JSON.parse(String(read.output)) as { revision: number }).revision; + let edited = await callTool("edit_plan", undefined, { + revision: current, + operations: [{ + op: "insert", + index: 0, + source: ASKED.map(item => `${item.header}: we chose the first option.`) + .join("\n\n"), + }], + }); + let result = JSON.parse(String(edited.output)) as { + revision: number; + blocks: Array<{ index: number; type: string; digest: string }>; + anchors_pending: Array<{ widget: string; question: string }>; + }; + let paragraphs = result.blocks.filter(block => block.type === "paragraph"); + let anchored = await callTool("anchor_plan", undefined, { + revision: result.revision, + anchors: result.anchors_pending.map((item, index) => ({ + widget: item.widget, + question: item.question, + blocks: [{ index: paragraphs[index]!.index, digest: paragraphs[index]!.digest }], + })), + }); + if (anchored.isError || !(JSON.parse(String(anchored.output)) as { ok: boolean }).ok) { + throw new Error(`anchor_plan failed: ${String(anchored.output)}`); + } + }; + if (thread && !prompt.includes(SLOW_PROMPT)) { // A comment sent to Chopin: answer in the thread, unless the comment // asks to be answered only in Chat so the server's backstop is exercised. @@ -212,6 +248,7 @@ export function createFakeHarness( ], })), }); + if (prompt.includes(WRITE_PROMPT)) await writeUp(); finish(); settled.resolve(); } catch (err) { diff --git a/e2e/icon-size.e2e.ts b/e2e/icon-size.e2e.ts index 82d07f258..ef6bfc2c2 100644 --- a/e2e/icon-size.e2e.ts +++ b/e2e/icon-size.e2e.ts @@ -7,7 +7,7 @@ */ import { content, expect, test } from "./room"; -import { CHOSEN, SETTLED_ANSWER, SOURCE, STATE } from "./source/icon-size.fixture"; +import { CHOSEN, SOURCE, STATE } from "./source/icon-size.fixture"; import type { Locator, Page } from "@playwright/test"; @@ -49,12 +49,7 @@ test("icons keep their size and sit on the first line beside wrapped decision te await seed(SOURCE, STATE); let page = await join("ana"); await phone(page); - - let settled = content(page).locator("[data-card-settled]"); - await expect(settled).toContainText(SETTLED_ANSWER); - expect((await settled.boundingBox())!.height).toBeGreaterThan(30); - expect(await squeezed(settled)).toEqual([]); - expect(await firstLineOffset(settled)).toBeLessThan(1.5); + await expect(content(page).locator("[data-card-settled]")).toHaveCount(0); await page.getByRole("button", { name: `Decision: ${CHOSEN}` }).click(); let dialog = page.getByRole("dialog", { name: "Decision" }); diff --git a/e2e/inline-decision-deck.e2e.ts b/e2e/inline-decision-deck.e2e.ts index 81e9e8984..92f94a24b 100644 --- a/e2e/inline-decision-deck.e2e.ts +++ b/e2e/inline-decision-deck.e2e.ts @@ -219,8 +219,8 @@ test("the deck keeps its top card, focus and draft when its first card leaves", await expect(draft).toBeFocused(); }); -test("answering the last open card hands focus to its settled line", async ({ join, seed }) => { - let fixture = deckSeed({ before: "Intro paragraph." }, 2); +test("answering the last open card dissolves the deck and hands the caret to the prose after it", async ({ join, seed }) => { + let fixture = deckSeed({ before: "Intro paragraph.", after: "Closing paragraph." }, 2); await seed(fixture.source, fixture.state); let page = await join("ana"); @@ -233,10 +233,31 @@ test("answering the last open card hands focus to its settled line", async ({ jo await top(page).getByText("Option 2", { exact: true }).click(); await top(page).getByRole("button", { name: "Save", exact: true }).click(); await expect(deck(page)).toHaveCount(0); - let settled = content(page).locator( - `[data-plan-questionnaire="${fixture.ids[1]}"] [data-card-settled]`, - ); - await expect(settled).toBeFocused(); + // Without prose a decided card draws nothing in the document; Decisions keeps it. + await expect(content(page).locator("[data-card-settled]")).toHaveCount(0); + await expect(content(page)).toContainText("Intro paragraph."); + await expect(content(page)).toBeFocused(); + await expect(page.locator("[data-plan-announcer]")).toHaveText("Decision saved"); + await page.keyboard.type("Then "); + await expect(content(page).locator("p").filter({ hasText: "Closing paragraph." })) + .toHaveText("Then Closing paragraph."); +}); + +test("a dissolving deck in a blank document puts the caret on its first line", async ({ join, seed }) => { + let fixture = deckSeed({}, 2); + await seed(fixture.source, fixture.state); + let page = await join("ana"); + // A blank document opens on Decisions; answer in the document instead. + await page.getByRole("button", { name: "Document", exact: true }).click(); + for (let index = 0; index < 2; index++) { + await top(page).getByText(`Option ${index + 1}`, { exact: true }).click(); + await top(page).getByRole("button", { name: "Save", exact: true }).click(); + } + await expect(deck(page)).toHaveCount(0); + await expect(content(page)).toBeFocused(); + await expect(page.locator("[data-plan-announcer]")).toHaveText("Decision saved"); + await page.keyboard.type("First words"); + await expect(content(page).locator("p").first()).toHaveText("First words"); }); test("an answered decision settles to the back and focus follows the new top", async ({ join, seed }) => { @@ -310,7 +331,7 @@ test("answering every decision dissolves the deck", async ({ join, seed }) => { await top(page).getByRole("button", { name: "Save", exact: true }).click(); } await expect(deck(page)).toHaveCount(0); - await expect(page.locator('[data-document-view="plan"] [data-card-settled]')).toHaveCount(2); + await expect(page.locator('[data-document-view="plan"] [data-card-settled]')).toHaveCount(0); }); test("showing a back decision from Decisions brings it to the top and focuses it", async ({ join, seed }) => { diff --git a/e2e/sidecar-card-states.e2e.ts b/e2e/sidecar-card-states.e2e.ts index 881e3f04c..933a6d675 100644 --- a/e2e/sidecar-card-states.e2e.ts +++ b/e2e/sidecar-card-states.e2e.ts @@ -395,12 +395,17 @@ test("an orphaned prose anchor retains its authoritative state and resolved card .toContainText("Canary"); }); -test("conversation decisions settle inline without claiming a write-up no job is doing", async ({ join, seed }) => { +test("a conversation decision without prose stays out of the document and claims no write-up", async ({ join, seed }) => { await seed(`${PROSE}\n${DECIDED_CARD}`, { questions: [{ ...DECIDED_RECORD, origin: "conversation" }], }); let page = await join("ana"); - let settled = content(page).locator("[data-card-settled]"); - await expect(settled).toContainText("Decided: Canary · @ana"); - await expect(settled).not.toContainText("Writing up…"); + await expect(content(page)).toContainText("Room state lives on disk as MDX"); + await expect(content(page).locator("[data-card-settled]")).toHaveCount(0); + await expect(content(page).locator("[data-plan-writing-up]")).toHaveCount(0); + await page.getByRole("button", { name: /^Decisions/ }).click(); + await page.getByRole("button", { name: "1 resolved" }).click(); + let decisions = page.locator('[data-document-view="decisions"]'); + await expect(decisions).toContainText("Canary"); + await expect(decisions).not.toContainText("Writing up…"); }); diff --git a/e2e/sidecar-document-spacing.e2e.ts b/e2e/sidecar-document-spacing.e2e.ts index 93d3250f2..f94625bd8 100644 --- a/e2e/sidecar-document-spacing.e2e.ts +++ b/e2e/sidecar-document-spacing.e2e.ts @@ -16,18 +16,17 @@ const SECOND_OPTION = "01K0N4W3B7P27CBAEC7A8C8WEC"; const SECOND_WIDGET = "01K0N4TR8K7JGM4R1J7PW4R8YK"; -// Answered, so they settle as separate lines; adjacent open cards stack into a deck instead. +// Expired, so each stays a visible card of its own: adjacent open cards stack into a deck, and +// decided ones without prose draw nothing in the document. // The closing prose keeps the injected development sample out of their run. -const ADJACENT_CARDS = ` +const ADJACENT_CARDS = ` - + diff --git a/e2e/source/collapse-deletion.native.ts b/e2e/source/collapse-deletion.native.ts index b547fcdbd..06954655e 100644 --- a/e2e/source/collapse-deletion.native.ts +++ b/e2e/source/collapse-deletion.native.ts @@ -31,25 +31,13 @@ test("actual decided inline node hides while its list card remains expanded", as ]); }); -test("actual presentation transitions through a settled line without deleting the node", async ({ page }) => { +test("actual decided inline node stays hidden with or without prose, without deleting it", async ({ page }) => { await loadCollapse(page); await page.evaluate(() => window.collapseFixture.status("decided", false)); - await expect(inline(page)).toBeVisible(); - await expect(inline(page)).toContainText("Decided: GitHub Apps"); + await expect(inline(page)).toBeHidden(); await expect(summary(page)).toContainText("GitHub Apps"); - await page.clock.install(); - await page.clock.pauseAt(new Date()); await page.evaluate(() => window.collapseFixture.status("decided", true)); - await expect(inline(page)).toBeVisible(); - let closing = content(page).locator('[data-decision-collapsing="reader-card"]'); - await expect(closing).toHaveAttribute("inert", ""); - await expect(closing).toHaveAttribute("aria-hidden", "true"); - await page.clock.runFor(260); await expect(inline(page)).toBeHidden(); - expect(await page.evaluate(() => window.collapseFixture.signals)).toEqual([{ - kind: "question:presence", - payload: { id: "reader-card" }, - }]); expect((await page.evaluate(() => window.collapseFixture.snapshot())).cards).toContain( "reader-card", ); diff --git a/packages/editor/src/blank-behind-decisions.tsx b/packages/editor/src/blank-behind-decisions.tsx new file mode 100644 index 000000000..d81872efb --- /dev/null +++ b/packages/editor/src/blank-behind-decisions.tsx @@ -0,0 +1,73 @@ +/** + * The empty-document placeholder, for a document whose only blocks are decisions it hides. + * + * A decided card without prose keeps its projection node but draws nothing, and + * Lexical shows no placeholder beside a decorator node. This marks the editable + * root instead, and the stylesheet draws the same words in its first empty + * paragraph, where a click puts the caret. + */ + +import { useEffect, useSyncExternalStore } from "react"; +import { COMMAND_PRIORITY_HIGH, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND } from "lexical"; + +import { useRelations } from "./questionnaires"; +import { blankBehindDecisions, writingEntries } from "./widgets/questionnaire"; + +import type { CardMetaStore } from "./card-meta"; +import type { LexicalEditor } from "lexical"; +import type { QuestionnaireStore } from "./questionnaires"; + +const NO_CARDS = new Map(); +const noCards = () => NO_CARDS; +const noop = () => () => {}; + +export function BlankBehindDecisions( + { cardMeta, editor, placeholder, planner, questions }: { + cardMeta?: CardMetaStore; + editor?: LexicalEditor; + placeholder: string; + planner?: boolean; + questions: QuestionnaireStore; + }, +) { + let entries = useSyncExternalStore(questions.subscribe, questions.snapshot, questions.snapshot); + let content = useSyncExternalStore( + questions.subscribe, + questions.contentSnapshot, + questions.contentSnapshot, + ); + let cards = useSyncExternalStore( + cardMeta?.subscribe ?? noop, + cardMeta?.snapshot ?? noCards, + cardMeta?.snapshot ?? noCards, + ); + useRelations(questions); + let blank = !content + && blankBehindDecisions(entries, cards, writingEntries(entries, cards, questions, planner)); + useEffect(() => { + let root = editor?.getRootElement(); + if (!root || !blank) return; + root.setAttribute("data-plan-blank", ""); + root.style.setProperty("--plan-blank-placeholder", JSON.stringify(placeholder)); + return () => { + root.removeAttribute("data-plan-blank"); + root.style.removeProperty("--plan-blank-placeholder"); + }; + }, [blank, editor, placeholder]); + // Nothing visible to delete: Lexical would remove the caret's empty line and leave a block + // cursor beside a decision the reader cannot see. + useEffect(() => { + if (!editor || !blank) return; + let refuse = (event: KeyboardEvent | null) => { + event?.preventDefault(); + return true; + }; + let offBackspace = editor.registerCommand(KEY_BACKSPACE_COMMAND, refuse, COMMAND_PRIORITY_HIGH); + let offDelete = editor.registerCommand(KEY_DELETE_COMMAND, refuse, COMMAND_PRIORITY_HIGH); + return () => { + offBackspace(); + offDelete(); + }; + }, [blank, editor]); + return null; +} diff --git a/packages/editor/src/decisions.tsx b/packages/editor/src/decisions.tsx index 04dfce862..901a6c7f1 100644 --- a/packages/editor/src/decisions.tsx +++ b/packages/editor/src/decisions.tsx @@ -15,7 +15,7 @@ import { decisionsDeck } from "./decision-state"; import { Deck, revealDeckItem } from "./deck"; import { MotionDisclosure, MotionDisclosureIcon } from "./disclosure-motion"; import { usePhone } from "./pointer"; -import { useQuestionnaires } from "./questionnaires"; +import { useQuestionnaires, useRelations } from "./questionnaires"; import { PendingAnswersContext, PendingAnswersStore, SaveBar } from "./save-bar"; import { forget } from "./touched"; import { labels, QuestionnaireCard } from "./widgets/questionnaire"; @@ -233,6 +233,9 @@ export function Decisions( }: DecisionsProps, ) { let entries = useQuestionnaires(store); + useRelations(store); + // "Linking…" promises work; without a running turn a pending decision is plainly unlinked. + let linking = planner !== false && store.plannerBusy(); let readiness = useSyncExternalStore( store.subscribe, store.readinessSnapshot, @@ -390,7 +393,7 @@ export function Decisions( let question = (entry: QuestionnaireEntry) => (