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
1 change: 1 addition & 0 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an
- **Document:** The authored page is white, readable, and wider only where content requires it. Headings, lists, tables, code, and callouts retain document semantics inside the editor.
- **Document images:** Click opens a full-screen preview with zoom and close controls. Hover or keyboard focus reveals metadata editing and corner resize handles. Resizing preserves the image ratio, shares the chosen width with collaborators, and remains bounded by the document or column on narrow screens.
- **Decision decks:** Adjacent decisions stack as a deck. The top card is live; up to two cards behind it show their header edges and are inert. Previous and next controls, Left and Right arrows, and a count of the top card's place cycle the deck with a short shuffle; answered decisions settle to the back. Reduced motion reorders without animation. Deck order is each reader's own.
On desktop, Decisions shows open decisions as the same deck when two or more wait, with resolved decisions listed below; phones keep the single-pane list and save bar. A blank document whose Planner asks decisions opens Decisions once per visit, unless the reader is editing the document.
- **Unfold:** One disclosure for accordions and card previews. A button with `aria-expanded` and `aria-controls` folds a mounted panel open through its grid row while the content fades and tips down from its top edge, with the shared ease-out: card previews take the linger duration, plain accordions the accordion duration. A second press reverses from the current frame. Closed content is inert and hidden once the fold ends. Reduced motion snaps the height and keeps a short crossfade. A card preview never replaces the card's own open action.

## Do's and Don'ts
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/document-editing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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(() => {
// 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"]: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) => {
// 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);
return () => {
document.removeEventListener("focusin", focus);
document.removeEventListener("focusout", blur);
};
}, [room]);
return editing;
}
30 changes: 25 additions & 5 deletions apps/web/src/room-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -467,23 +468,24 @@ export function RoomWorkspace(
let [researchErrors, setResearchErrors] = useState<Record<string, string>>({});
let [researchExecution, setResearchExecution] = useState(false);
let researchDrafts = useMemo(() => new Map<string, ResearchDraftController>(), [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);
let unanswered = countUnanswered(entries, cardMetadata);
let hasPlanProse = useHasPlanContent(questions);
let hasPlanContent = documentHasPlanningContent(hasPlanProse, entries);
let hasPlanContent = documentHasPlanningContent(hasPlanProse);
let [decisionView, setDecisionView] = useState<DecisionViewState>(() => {
let stored = localStorage.getItem("chopin:view:document");
return {
phase: "initial",
preferred: initialDocumentView(profile, stored),
};
});
let typing = useDocumentEditing(room);
let view: WorkspaceDocumentView = showImplementation && profile.implementation
? "build"
: visibleDecisionView(decisionView, hasPlanContent, unanswered);
: visibleDecisionView(decisionView, hasPlanContent, unanswered, typing);
let attention = useDecisionAttention(unanswered);
let latestCanEdit = useRef(canEdit);
let latestCanManage = useRef(canManage);
Expand Down Expand Up @@ -543,10 +545,28 @@ 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, typing);
if (next === decisionView) return;
setDecisionView(state => (state === decisionView ? next : state));
// oxlint-disable-next-line react-hooks/exhaustive-deps -- typing 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);
Expand Down Expand Up @@ -612,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 });
}
};

Expand Down
20 changes: 20 additions & 0 deletions e2e/decision-deck-fixtures.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -86,3 +88,21 @@ ${choices.map(choice => `<Option id="${choice.id}" label="${choice.label}" />`).
ids,
};
}

/** The desktop Decisions deck slot holding a decision, on top or waiting behind. */
export function deckSlot(page: Page, prompt: string) {
return page.locator('[data-document-view="decisions"] [data-deck-slot]').filter({
hasText: prompt,
});
}

/** Cycle the desktop Decisions deck until a decision is on top and answerable. */
export async function bringForward(page: Page, prompt: string) {
let slot = deckSlot(page, prompt);
let next = page.locator('[data-document-view="decisions"] [data-deck]')
.getByRole("button", { name: "Next decision" });
await expect(async () => {
if (await slot.getAttribute("data-deck-top") === null) await next.click();
await expect(slot).toHaveAttribute("data-deck-top", "", { timeout: 1_000 });
}).toPass();
}
11 changes: 11 additions & 0 deletions e2e/decisions-phone.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deckSeed } from "./decision-deck-fixtures";
import { expect, test } from "./room";

import type { Page } from "@playwright/test";
Expand Down Expand Up @@ -220,3 +221,13 @@ test("desktop keeps the in-card Save and the inline card", async ({ join, seed }
await expect(card.getByRole("button", { name: "Discard", exact: true })).toBeVisible();
await expect(page.locator(".decision-save-bar")).toHaveCount(0);
});

test("phone: several open decisions stay a list with the save bar, not a deck", async ({ join, seed }) => {
let fixture = deckSeed({ before: "Decisions on a phone." }, 3);
await seed(fixture.source, fixture.state);
let page = await join("ana", PHONE);
await showDecisions(page);
let view = page.locator('[data-document-view="decisions"]:visible');
await expect(view.locator("article[data-plan-sidecar-questionnaire]")).toHaveCount(3);
await expect(view.locator("[data-deck]")).toHaveCount(0);
});
127 changes: 127 additions & 0 deletions e2e/harness-decisions-deck.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Planner questions reaching the Decisions tab, through the real server, sockets
* and host tools, against the isolated `AGENT=on` fake-harness project.
*/
import { chatInput } from "./chat-input";
import { content, expect, test } from "./room";

import type { Page } from "@playwright/test";

async function ask(page: Page) {
let input = chatInput(page);
await input.fill("@chopin ASK-DECISIONS");
await input.press("Enter");
}

function decisionsButton(page: Page) {
return page.getByRole("group", { name: "Document view" })
.getByRole("button", { name: /^Decisions/ });
}

function deck(page: Page) {
return page.locator('[data-document-view="decisions"] [data-deck]');
}

function top(page: Page) {
return deck(page).locator("[data-deck-top]");
}

/** Answering ends the Planner's open ask, so the next @chopin message starts a new turn. */
async function answerAll(page: Page) {
for (let index = 0; index < 3; index++) {
await top(page).getByText("First option", { exact: true }).click();
await top(page).getByRole("button", { name: "Save", exact: true }).click();
if (index < 2) await expect(deck(page).getByText(`${index + 1} decided`)).toBeVisible();
}
await expect(deck(page)).toHaveCount(0);
}

function routed(page: Page) {
return page.getByRole("status").filter({ hasText: "Showing Decisions:" });
}

test("a blank document routes the Planner's questions to a Decisions deck", async ({ join }) => {
let page = await join("ana");
await chatInput(page).click();
await ask(page);

await expect(decisionsButton(page)).toHaveAttribute("aria-pressed", "true");
await expect(routed(page).filter({ hasText: "Showing Decisions: 3 to answer" }))
.toHaveCount(1);
await expect(chatInput(page)).toBeFocused();
await expect(deck(page)).toHaveAttribute("aria-label", "3 open decisions");
await expect(deck(page).getByText("1 of 3")).toBeVisible();

await answerAll(page);
await expect(page.getByRole("button", { name: "3 resolved" })).toBeVisible();
// No prose yet, so the forced view holds.
await expect(decisionsButton(page)).toHaveAttribute("aria-pressed", "true");
});

test("a reader editing the blank document is not moved", async ({ join }) => {
let ana = await join("ana");
let ben = await join("ben");
await content(ana).click();
await expect(content(ana)).toBeFocused();
await ask(ben);

await expect(decisionsButton(ben)).toHaveAttribute("aria-pressed", "true");
await expect(decisionsButton(ana)).toContainText("3");
await expect(decisionsButton(ana)).toHaveAttribute("aria-pressed", "false");
await expect(content(ana)).toBeFocused();
await expect(routed(ana)).toHaveCount(0);
});

test("an explicit tab pick keeps a blank document in Document when more decisions arrive", async ({ join }) => {
let page = await join("ana");
// The status region is one persistent element, so count announcements as they are inserted.
await page.evaluate(() => {
let seen = { count: 0 };
(window as unknown as { routings: typeof seen }).routings = seen;
new MutationObserver(records => {
for (let record of records) {
for (let node of record.addedNodes) {
if (node.textContent?.includes("Showing Decisions:")) seen.count++;
}
}
}).observe(document.body, { childList: true, subtree: true });
});
let routings = () =>
page.evaluate(() => (window as unknown as { routings: { count: number } }).routings.count);
await ask(page);
await expect(decisionsButton(page)).toHaveAttribute("aria-pressed", "true");
await expect.poll(routings).toBe(1);
await answerAll(page);
// The document stays blank: only the once-per-entry latch can stop a second route.
await page.getByRole("button", { name: "Document", exact: true }).click();
await expect(decisionsButton(page)).toHaveAttribute("aria-pressed", "false");
await chatInput(page).click();
await ask(page);
await expect(decisionsButton(page)).toContainText("3");
await expect(decisionsButton(page)).toHaveAttribute("aria-pressed", "false");
expect(await routings()).toBe(1);
});

test("arrow keys shuffle the Decisions deck; reduced motion reorders instantly", async ({ join }) => {
let page = await join("ana");
await page.emulateMedia({ reducedMotion: "reduce" });
await ask(page);
await deck(page).getByRole("button", { name: "Next decision" }).focus();
await page.keyboard.press("ArrowRight");
await expect(top(page).getByRole("heading", { name: "What belongs in the first cut?" }))
.toBeVisible();
expect(await deck(page).evaluate(element => element.getAnimations({ subtree: true }).length))
.toBe(0);
await expect(deck(page).getByRole("status")).toHaveText("Decision 2 of 3: Scope");
});

test("selecting Decisions resumes at the deck's current top card", async ({ join }) => {
let page = await join("ana");
await ask(page);
await deck(page).getByRole("button", { name: "Next decision" }).click();
await page.getByRole("button", { name: "Document", exact: true }).click();
await decisionsButton(page).click();
await expect(top(page)).toBeFocused();
await expect(top(page).getByRole("heading", { name: "What belongs in the first cut?" }))
.toBeVisible();
});
35 changes: 35 additions & 0 deletions e2e/harness/fake-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ const SLOW_PROMPT = "SLOW-LIVE";
const COMMENT_THREAD = /using thread ([0-9A-HJKMNP-TV-Z]{26})/;
/** A comment saying this is answered only in Chat, so the server's backstop posts it. */
const BACKSTOP_PROMPT = "COMMENT-BACKSTOP";
/** Makes the fake Planner ask three decisions, appended without linked prose. */
const ASK_PROMPT = "ASK-DECISIONS";
const ASKED = [
{ header: "Storage", question: "Where should room state live?" },
{ header: "Scope", question: "What belongs in the first cut?" },
{ header: "Rollout", question: "How should we deploy?" },
];
const STEP_MS = 2_500;

function promptText(prompt: HarnessV1PromptTurnOptions["prompt"]): string {
Expand Down Expand Up @@ -171,6 +178,34 @@ export function createFakeHarness(
turn.abortSignal?.removeEventListener("abort", onAbort);
}
})();
} else if (prompt.includes(ASK_PROMPT)) {
void (async () => {
try {
if (!available.has("ask")) throw new Error("e2e-fake harness expected ask");
let plan = await callTool("read_plan");
let revision = (JSON.parse(String(plan.output)) as { revision: number }).revision;
// The turn stays open until people answer, as a real ask does.
await callTool("ask", crypto.randomUUID(), {
revision,
questions: ASKED.map(item => ({
...item,
multiple: false,
blocks: [],
options: [
{ label: "First option", description: "" },
{ label: "Second option", description: "" },
],
})),
});
finish();
settled.resolve();
} catch (err) {
turn.emit({ type: "error", error: err });
settled.reject(err);
} finally {
turn.abortSignal?.removeEventListener("abort", onAbort);
}
})();
} else if (prompt.includes(SLOW_PROMPT)) {
void (async () => {
try {
Expand Down
18 changes: 12 additions & 6 deletions e2e/inline-decision-deck.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ function deck(page: Page) {
return page.locator('[data-document-view="plan"] [data-deck]');
}

function slotOf(page: Page, id: string) {
return page.locator(`[data-document-view="plan"] [data-deck-slot="${id}"]`);
}

function top(page: Page) {
return deck(page).locator("[data-deck-top]");
}
Expand Down Expand Up @@ -35,7 +39,7 @@ type Frame = { at: number; y: number; layer: number; opacity: number };

/** Sample a card's shuffle at fractions of its duration, then let it finish. */
async function sampleShuffle(page: Page, id: string, fractions: number[]) {
let slot = page.locator(`[data-deck-slot="${id}"]`);
let slot = slotOf(page, id);
await expect.poll(() =>
slot.evaluate(element => element.getAnimations().some(item => item.id === "deck-shuffle"))
).toBe(true);
Expand Down Expand Up @@ -196,7 +200,7 @@ test("an answered decision settles to the back and focus follows the new top", a
await expect(top(page)).toBeFocused();
// The answered card went to the back, and the rest moved up one place each.
await expect.poll(() => order(page)).toEqual([fixture.ids[1], fixture.ids[2], fixture.ids[0]]);
await expect(page.locator(`[data-deck-slot="${fixture.ids[0]}"]`)).toHaveAttribute(
await expect(slotOf(page, fixture.ids[0]!)).toHaveAttribute(
"data-deck-settled",
"",
);
Expand Down Expand Up @@ -234,7 +238,7 @@ test("a collaborator's answer to my top card settles it and leaves my focus alon
await expect(top(ana).getByRole("heading", { name: fixture.prompts[1] })).toBeVisible();
await expect(deck(ana).getByText("1 of 3 · 1 decided")).toBeVisible();
await expect.poll(() => order(ana)).toEqual([fixture.ids[1], fixture.ids[2], fixture.ids[0]]);
await expect(ana.locator(`[data-deck-slot="${fixture.ids[0]}"]`)).toHaveAttribute(
await expect(slotOf(ana, fixture.ids[0]!)).toHaveAttribute(
"data-deck-settled",
"",
);
Expand Down Expand Up @@ -262,9 +266,11 @@ test("showing a back decision from Decisions brings it to the top and focuses it
await expect.poll(() => order(page)).toEqual(fixture.ids);

await page.getByRole("button", { name: /^Decisions/ }).click();
let card = page.locator(
'[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]',
)
// The Decisions tab is a deck too; bring the anchored card to its top first.
let decisionsDeck = page.locator('[data-document-view="decisions"] [data-deck]');
await decisionsDeck.getByRole("button", { name: "Next decision" }).click();
await decisionsDeck.getByRole("button", { name: "Next decision" }).click();
let card = decisionsDeck.locator("[data-deck-top] article[data-plan-sidecar-questionnaire]")
.filter({ hasText: fixture.prompts[2]! });
await card.getByRole("button", { name: /show in document/i }).click();

Expand Down
Loading
Loading