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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/questions-service-durability-1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { pending: boolean }> }> }
| 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" }]);
});
22 changes: 22 additions & 0 deletions apps/server/src/questions/anchors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
15 changes: 15 additions & 0 deletions apps/server/src/questions/anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/questions/service-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
]
) {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/room-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 8 additions & 9 deletions e2e/decision-marker.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } },
Expand All @@ -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();
});

Expand Down Expand Up @@ -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 } } },
Expand All @@ -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) =>
Expand Down
101 changes: 62 additions & 39 deletions e2e/decision-prose.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand All @@ -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<void>(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();
Expand Down
Loading
Loading