Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4T5D2RGSH4BHWZVTQXS3SCA
anchor: function chooseAdded
created: 2026-10-13T08:17:29Z
norm: '1'
---

A just-added option is selected only after question:option acks with its id. Single choice: never replaces a collaborator's answer in the shared draft (own = touched). Escape/blur during pending abandons selection; unmount by reaching MAX options does not.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4T5D2NT0192D6ZFGQXJMNQA
anchor: function SidecarCard
created: 2026-10-13T08:17:29Z
norm: '1'
---

overflow-clip, not overflow-hidden: hidden still lets focus/caret scrollIntoView scroll the card sideways (a long add-option field shifted the whole card left). Clip forbids programmatic scroll.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
id: 01M4TAWRAA7GCY55P5HNWQRA1K
anchor: function chooseAdded
created: 2026-10-13T09:53:25Z
norm: '1'
supersedes: 01M4T5D2RGSH4BHWZVTQXS3SCA
---

A new option may replace a single choice only when the shared draft still matches this person’s last local pick for that specific question.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4TAWRQGEA1XA4YY0BK2R6D7
anchor: function QuestionView
created: 2026-10-13T09:53:26Z
norm: '1'
---

Guard pending add selection by the QuestionView lifetime and current question: AddOption unmounts at capacity before it receives the tenth option definition.
93 changes: 92 additions & 1 deletion apps/web/src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -1624,7 +1624,7 @@ button.chat-tool-call-toggle {
}

/* Coloured icon holders: the global icon colour would otherwise win. */
:is(.question-mark, .question-check) [data-nucleo-icon],
:is(.question-mark, .question-check, .question-add-confirm) [data-nucleo-icon],
.question-callout .plan-research-badge [data-nucleo-icon] {
color: inherit;
}
Expand Down Expand Up @@ -1767,6 +1767,7 @@ label.question-option:has(.question-input:not(:disabled)) {
display: grid;
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}

.question-label {
Expand Down Expand Up @@ -1856,10 +1857,99 @@ button.question-add:disabled {
}

.question-adding {
align-items: flex-start;
outline: var(--edge-width) solid var(--color-control-edge);
outline-offset: calc(-1 * var(--edge-width));
}

/* The leading tile: a quiet plus while the field is empty, the confirm check once it has text. */
.question-add-confirm {
position: relative;
display: grid;
place-items: center;
inline-size: 1.5rem;
block-size: 1.5rem;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: var(--color-inset);
color: var(--color-text-tertiary);
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out),
scale var(--duration-fast) var(--ease-out);
}

/* A touch-sized target around the small tile. */
.question-add-confirm::after {
content: "";
position: absolute;
inset: calc(var(--spacing) * -1.5);
}

.question-add-confirm:disabled {
cursor: default;
}

.question-add-confirm[data-ready] {
background: var(--color-brand);
color: var(--color-white);
}

.question-add-confirm[data-ready]:hover {
background: var(--color-brand-hover);
}

.question-add-confirm[data-ready]:active {
scale: 0.96;
}

/* Outside the tile: an inset brand ring would vanish on the filled brand check. */
.question-add-confirm:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-width);
}

.question-add-confirm > svg {
grid-area: 1 / 1;
transition: opacity var(--duration-fast) var(--ease-out);
}

.question-add-confirm > svg:last-child,
.question-add-confirm[data-ready] > svg:first-child {
opacity: 0;
}

.question-add-confirm[data-ready] > svg:last-child {
opacity: 1;
}

.question-adding[aria-busy] .question-add-confirm {
cursor: progress;
opacity: 0.6;
}

@media (prefers-reduced-motion: no-preference) {
.question-add-confirm > svg {
transition:
opacity var(--duration-fast) var(--ease-out),
scale var(--duration-fast) var(--ease-out),
filter var(--duration-fast) var(--ease-out);
}

.question-add-confirm > svg:last-child,
.question-add-confirm[data-ready] > svg:first-child {
scale: 0.25;
filter: blur(4px);
}

.question-add-confirm[data-ready] > svg:last-child {
scale: 1;
filter: none;
}
}

.question-adding:focus-within {
outline-color: var(--color-brand);
}
Expand Down Expand Up @@ -1890,6 +1980,7 @@ button.question-add:disabled {
outline: none;
resize: none;
field-sizing: content;
overflow-wrap: anywhere;
}

.question-field:focus-visible {
Expand Down
24 changes: 12 additions & 12 deletions e2e/conversation-plan-ui.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,22 +322,22 @@ test("a human choice overrides an advisory chat suggestion", async ({ join, room
await sendChat(ana, "Should we ship a small pilot?");
await waitForEvent(ana, "thread.opened");
await sendChat(bo, "Start with a small pilot.");
let withOption = await waitForEvent(ana, "option.added");
let humanOptionId = withOption.threads[0]!.contributions.find(item => item.kind === "option")!.id;
await waitForEvent(ana, "option.added");
let card = decisionCard(ana);
await card.getByRole("button", { name: "Add an option", exact: true }).click();
await card.getByRole("textbox", { name: "New option" }).fill("Ship to everyone");
await ana.keyboard.press("Enter");
let suggestion = card.getByRole("radio", { name: "Ship to everyone" });
await expect(suggestion).toBeVisible();
await sendChat(bo, "Let's just go with Ship to everyone.");
await sendChat(bo, "Let's just go with a small pilot.");
await waitForEvent(ana, "settle.suggested");
let suggestion = card.getByRole("radio", { name: "Start with a small pilot." });
await expect(suggestion).toBeChecked();
await expect(card.getByText("from chat", { exact: true })).toBeVisible();
let humanChoice = card.getByRole("radio", { name: "Start with a small pilot." });
await card.getByText("Start with a small pilot.", { exact: true }).click();
// Adding an option is choosing it, so it replaces the suggestion in one step.
await card.getByRole("button", { name: "Add an option", exact: true }).click();
await card.getByRole("textbox", { name: "New option" }).fill("Ship to everyone");
await ana.keyboard.press("Enter");
let humanChoice = card.getByRole("radio", { name: "Ship to everyone" });
await expect(humanChoice).toBeChecked();
await expect(suggestion).not.toBeChecked();
await expect(card.getByText("from chat", { exact: true })).toHaveCount(0);
let humanOptionId = await humanChoice.getAttribute("value");
await card.getByRole("button", { name: "Save", exact: true }).click();
let decided = await waitForEvent(ana, "decision.recorded");
expect(decided.threads[0]!.decision).toMatchObject({
Expand Down Expand Up @@ -373,7 +373,7 @@ test("typing an option before a suggestion keeps the composer intent until Escap
await expect(card.getByText("from chat", { exact: true })).toBeVisible();
});

test("adding an option does not suppress a later chat suggestion", async ({ join, room }) => {
test("an added option stays the adder's choice when chat later suggests it", async ({ join, room }) => {
let ana = await join("ana");
let bo = await join("bo");
await openJevWire(ana, room);
Expand Down Expand Up @@ -404,7 +404,7 @@ test("adding an option does not suppress a later chat suggestion", async ({ join
expect(label).toBeTruthy();
await expect(card.getByRole("radio", { name: label! })).toBeChecked();
await expect(card.getByRole("radio", { name: "Start with a small pilot." })).not.toBeChecked();
await expect(card.getByText("from chat", { exact: true })).toBeVisible();
await expect(card.getByText("from chat", { exact: true })).toHaveCount(0);
});

test("failed analysis retries in Chat while editor selection survives hiding Chat", async ({ join, room, seed }) => {
Expand Down
38 changes: 38 additions & 0 deletions e2e/inline-decision-deck.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,41 @@ test("on a phone, adjacent decisions stay chips instead of a deck", async ({ joi
}
await expect(deck(page)).toHaveCount(0);
});

test("a long new option wraps in place and one confirm adds and chooses it", async ({ join, seed }) => {
let fixture = deckSeed({ before: "Intro paragraph." }, 2);
await seed(fixture.source, fixture.state);
let page = await join("ana");
let card = top(page);
let save = card.getByRole("button", { name: "Save", exact: true });
await expect(save).toBeDisabled();

await card.getByRole("button", { name: "Add an option", exact: true }).click();
let field = card.getByRole("textbox", { name: "New option" });
let confirm = card.getByRole("button", { name: "Add option", exact: true });
await expect(confirm).toBeDisabled();
let label = "Keep room state in a small embedded database next to the server process, "
+ "so it survives restarts and needs no separate service";
await field.pressSequentially(label);
await expect(confirm).toBeEnabled();

// The field grows downwards; nothing around it scrolls sideways to follow the caret.
let geometry = await field.evaluate(node => {
let line = parseFloat(getComputedStyle(node).lineHeight);
let scrolled: string[] = [];
for (let at: Element | null = node.parentElement; at; at = at.parentElement) {
if (at.scrollLeft !== 0) scrolled.push(at.tagName);
}
return { height: node.getBoundingClientRect().height, line, scrolled };
});
expect(geometry.height).toBeGreaterThan(geometry.line * 1.5);
expect(geometry.scrolled).toEqual([]);
await expect(card.getByRole("heading", { name: fixture.prompts[0] })).toBeInViewport();

await confirm.click();
let added = card.getByRole("radio", { name: label, exact: true });
await expect(added).toBeChecked();
await expect(field).toHaveCount(0);
await expect(card.getByRole("button", { name: "Add an option", exact: true })).toBeVisible();
await expect(save).toBeEnabled();
});
6 changes: 5 additions & 1 deletion e2e/sidecar-option-async.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ test("adding an option keeps focus through a delayed definition update", async (
await expect(field).toHaveJSProperty("readOnly", true);
held.releaseReply();
await expect(field).toHaveCount(0);
await expect(trigger).toBeFocused();
// The confirmed option is chosen and takes focus; the add row is quiet again.
let added = card.getByRole("radio", { name: "A delayed refresh option", exact: true });
await expect(added).toBeChecked();
await expect(added).toBeFocused();
await expect(trigger).toBeVisible();
});

test("Escape during a delayed option reply restores focus and respects moving away", async ({ join, page, seed }) => {
Expand Down
Loading
Loading