From ea05a6e2256bf065a3407153561458623cc7b44c Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:57:11 +0100 Subject: [PATCH 1/6] Add a phone comments list, a Build empty state and a Chopin chip The phone top bar gains a comments button with the open-thread count that opens the comment sheet as a list of every open thread, in reading order and quoting each passage. A thread opened from the list leads back to it, and a thread's "2 of 4" position opens the same list. A phone's blocked Build tab is now an EmptyState with a "Go to Decisions" action, and the phone composer shows a removable Chopin chip while a message addresses the Planner. Why: apps/web/src/room-workspace.tsx | the top bar's open-comment count filters open, not orphaned, not drifted threads so it matches the sheet's list of placed threads Co-Authored-By: Claude Opus 5.5 --- apps/web/src/build-view.css | 24 ++++++++ apps/web/src/build-view.tsx | 53 +++++++++++++++++ apps/web/src/chat/chat.tsx | 22 ++++++- apps/web/src/chat/composer.css | 36 ++++++++++++ apps/web/src/phone-header.tsx | 25 +++++++- apps/web/src/phone-workspace.css | 25 ++++++++ apps/web/src/room-workspace.tsx | 12 ++++ e2e/chat-phone.e2e.ts | 52 ++++++++++++++++ e2e/comment-sheet.e2e.ts | 75 +++++++++++++++++++++++ e2e/implementation-launcher.e2e.ts | 33 ++++++++++- packages/editor/src/comment-layer.tsx | 85 +++++++++++++++++++++++---- packages/editor/src/comment-sheet.tsx | 18 +++++- packages/editor/src/comments.tsx | 7 ++- packages/editor/src/index.ts | 1 + packages/editor/src/styles.css | 22 +++++++ packages/editor/src/threads.ts | 8 +++ 16 files changed, 480 insertions(+), 18 deletions(-) diff --git a/apps/web/src/build-view.css b/apps/web/src/build-view.css index 396004643..84655230b 100644 --- a/apps/web/src/build-view.css +++ b/apps/web/src/build-view.css @@ -179,6 +179,10 @@ gap: calc(var(--spacing) * 2); } +.build-blocked-actions .btn { + min-height: var(--control-target-coarse); +} + .build-task-toggle { display: flex; min-width: 0; @@ -316,3 +320,23 @@ transition: none; } } + +/* A phone's blocked Build tab: one empty state, centred in the space between the bars. */ +.build-blocked { + display: flex; + flex-direction: column; + justify-content: center; + /* Centred between a phone's bars, with no trailing space to scroll past. */ + padding-block: var(--phone-top-chrome, 0px) var(--phone-bottom-chrome, 0px); +} + +.build-blocked-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: calc(var(--spacing) * 2); +} + +.build-blocked-actions .btn { + min-height: var(--control-target-coarse); +} diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index b7b5ce5d3..7d62e8aea 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -1,4 +1,7 @@ import { useEffect, useId, useRef, useState } from "react"; +import { usePhone } from "@chopin/editor/pointer"; +import { WrenchIcon } from "@chopin/icons"; +import { EmptyState } from "@chopin/visuals"; import { ApiError } from "./api"; import { @@ -85,6 +88,7 @@ export function BuildView( let [returning, setReturning] = useState(false); let [reason, setReason] = useState(""); let [open, setOpen] = useState>({}); + let phone = usePhone(); let [linked, setLinked] = useState(); let [now, setNow] = useState(() => Date.now()); let [request, setRequestState] = useState(() => requests.get(room)); @@ -433,6 +437,55 @@ export function BuildView( status = `Built · ${plural(phase.pullRequests, "pull request", "pull requests")}`; } + // A phone's Build tab is otherwise empty while blocked; say why and lead to what unblocks it. + if (phone && phase.kind === "blocked" && (phase.decisions || phase.comments)) { + let decisions = unanswered > 0 + ? `${plural(unanswered, "decision", "decisions")} ${ + unanswered === 1 ? "is" : "are" + } still open.` + : "Some decisions are still open."; + let accepted = comments > 0 + ? `${plural(comments, "accepted comment", "accepted comments")} still ${ + comments === 1 ? "needs" : "need" + } to reach the document.` + : "Accepted comments still need to reach the document."; + return ( +
+
+ + + {phase.decisions && phase.comments && ( + + )} +
+ } + density="compact" + description={phase.decisions && phase.comments + ? `${decisions} ${accepted}` + : phase.decisions + ? decisions + : accepted} + icon={WrenchIcon} + measure="short" + title={phase.decisions + ? "Build unlocks after decisions" + : "Build unlocks after comments"} + /> +
+ + ); + } + return (
diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index 11913c5d3..448796d4d 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -10,14 +10,16 @@ import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from "react"; import { + ChopinMark, CONNECTION_GRACE, MAX_RESEARCH_BRIEF, SendAction, useConnectionNotice, usePopoverDismissal, } from "@chopin/editor"; +import { usePhone } from "@chopin/editor/pointer"; import { MENTION } from "@chopin/protocol/address"; -import { ArchiveIcon, InfoIcon, LockIcon, PlusIcon, WarningIcon } from "@chopin/icons"; +import { ArchiveIcon, CloseIcon, InfoIcon, LockIcon, PlusIcon, WarningIcon } from "@chopin/icons"; import { DraftInput } from "./draft-input"; import type { DraftInputHandle } from "./draft-input"; import { ModeSwitch } from "./mode-switch"; @@ -240,6 +242,7 @@ export function Chat( let textarea = useRef(null); let composerRoot = useRef(null); let [mode, setMode] = useState(false); + let phone = usePhone(); let [historyKey, setHistoryKey] = useState(0); let pendingCaret = useRef(undefined); let submission = useRef(undefined); @@ -798,6 +801,23 @@ export function Chat( ) : ( <> + {/* A phone's switch shows only an icon; the chip says where the message goes. */} + {phone && effectiveMode && ( + + + + )} void; + onComments?: () => void; onEditingChange: (editing?: TitleEdit) => void; onRenamed: (channel: DocumentMetadata) => void; presentation: WorkspacePresentation; @@ -123,6 +131,19 @@ export function PhoneHeader( )}
+ {comments > 0 && onComments && ( + + )} {canManage && ( item.thread.status === "accepted" && !item.applied).length; + // The threads a phone's list of comments can show: open, with a passage left to stand on. + let openComments = + threadState.threads.filter(item => + item.thread.status === "open" && !item.orphaned && !item.drifted + ).length; let statusNotices = ( <> @@ -1479,10 +1485,16 @@ export function RoomWorkspace( onDocumentAction(room, action)} + onComments={() => { + // The list stands over the document, so it opens from any tab onto it. + selectDestination("plan"); + openCommentList(room); + }} onEditingChange={setTitleEdit} onRenamed={updateMetadata} presentation={presentation} diff --git a/e2e/chat-phone.e2e.ts b/e2e/chat-phone.e2e.ts index 112310207..0c70eb461 100644 --- a/e2e/chat-phone.e2e.ts +++ b/e2e/chat-phone.e2e.ts @@ -218,3 +218,55 @@ test("the desktop composer keeps its labelled mode switch, footer and dropdown p ]); expect(list!.width).toBeLessThan(pill!.width / 2); }); + +test("a phone composer shows a removable Chopin chip while a message addresses Chopin", async ({ join }) => { + let page = await join("ana", PHONE); + // The Planner is off in this suite; the session says it is on, as the composer reads it. + await page.context().route("**/api/session", async route => { + let response = await route.fetch(); + await route.fulfill({ response, json: { ...await response.json(), agent: true } }); + }); + await page.reload(); + await openPhoneChat(page); + let chat = chatPane(page); + let draft = chatInput(chat); + let chip = chat.getByRole("button", { name: "Remove Chopin", exact: true }); + let mode = chat.getByRole("button", { name: "Talk to Chopin", exact: true }); + await expect(chip).toHaveCount(0); + + // A mention addresses Chopin; the chip says so above the draft, inside the pill. + await draft.click(); + await page.keyboard.type("@chopin what is left? "); + await expect(chip).toBeVisible(); + await expect(mode).toHaveAttribute("aria-pressed", "true"); + let [chipBox, draftBox, pill] = await Promise.all([ + chip.boundingBox(), + draft.boundingBox(), + chat.locator(".composer-surface").boundingBox(), + ]); + expect(chipBox!.y + chipBox!.height).toBeLessThanOrEqual(draftBox!.y + 1); + expect(chipBox!.y).toBeGreaterThanOrEqual(pill!.y); + // Drawn small, but a full touch target. + let reach = await chip.evaluate(element => { + let box = element.getBoundingClientRect(); + let x = box.left + box.width / 2; + let y = box.top + box.height / 2; + return [y - 21, y + 21].every(at => + document.elementFromPoint(x, at)?.closest("button") === element + ); + }); + expect(reach).toBe(true); + + // Removing the chip removes the address, as switching to Chat does. + await chip.tap(); + await expect(chip).toHaveCount(0); + await expect(mode).toHaveAttribute("aria-pressed", "false"); + expect(await draft.textContent()).not.toContain("@chopin"); + + // The switch turns it back on without a mention. + await mode.tap(); + await expect(chip).toBeVisible(); + await chip.tap(); + await expect(mode).toHaveAttribute("aria-pressed", "false"); + await expectNoHorizontalOverflow(page); +}); diff --git a/e2e/comment-sheet.e2e.ts b/e2e/comment-sheet.e2e.ts index 5fd35e49b..a41a6271e 100644 --- a/e2e/comment-sheet.e2e.ts +++ b/e2e/comment-sheet.e2e.ts @@ -584,3 +584,78 @@ test("a phone marker reaches 44px into the empty margin without covering the nex }, { chip, paragraph }); expect(hits).toEqual([true, true, false]); }); + +test("the phone top bar lists every open thread, leads into each one and back", async ({ join, seed }) => { + await seed(SOURCE, { + threads: [ + await open("review the pilot", ["Is two weeks enough?"], 1), + await open("starting with the docs team", ["Which team is next?", "Support."], 0), + ], + }); + let page = await join("ana", IPHONE); + let comments = page.locator(".phone-top-bar").getByRole("button", { name: "2 open comments" }); + let commentsBox = await size(comments); + expect(commentsBox.width).toBeGreaterThanOrEqual(44); + expect(commentsBox.height).toBeGreaterThanOrEqual(44); + // The title stays centred with a second button on the right. + let [documents, actions] = await Promise.all([ + size(page.locator(".phone-top-bar").getByRole("button", { name: "Documents", exact: true })), + size(page.locator(".phone-top-bar").getByRole("button", { name: /^Actions for/ })), + ]); + expect(documents.x).toBeLessThan(commentsBox.x); + expect(commentsBox.x + commentsBox.width).toBeLessThanOrEqual(actions.x + 1); + + await comments.tap(); + let list = page.getByRole("dialog", { name: "Comments" }); + await settled(list); + await expect(list.getByText("2 comments", { exact: true })).toHaveCount(1); + // In reading order, each item saying which passage it is about. + let items = list.locator("[data-plan-comment-group-item]"); + await expect(items).toHaveCount(2); + await expect(items.nth(0)).toContainText("starting with the docs team"); + await expect(items.nth(0)).toContainText("Which team is next?"); + await expect(items.nth(0)).toContainText("1 reply"); + await expect(items.nth(1)).toContainText("review the pilot"); + // The list opens at the medium detent, leaving the document in view above it. + expect((await size(list)).y).toBeGreaterThan(844 * 0.3); + expect((await size(items.nth(1))).height).toBeGreaterThanOrEqual(44); + + await items.nth(1).click(); + let thread = page.getByRole("dialog", { name: "Comment thread" }); + await expect(thread).toContainText("Is two weeks enough?"); + let back = thread.getByRole("button", { name: "All 2 comments" }); + await expect(back).toBeFocused(); + await back.click(); + let again = page.getByRole("dialog", { name: "Comments" }); + await expect(again.getByRole("button", { name: /Is two weeks enough\?/ })).toBeFocused(); + + await again.getByRole("button", { name: "Close comment" }).click(); + await expect(again).toHaveCount(0); + await expect(comments).toBeFocused(); +}); + +test("a phone thread's position in the pager opens the list of every thread", async ({ join, seed }) => { + await seed(SOURCE, { + threads: [ + await open("starting with the docs team", ["Which team is next?"], 0), + await open("review the pilot", ["Is two weeks enough?"], 1), + ], + }); + let page = await join("ana", IPHONE); + await page.getByRole("button", { name: /^Comment on “review the pilot”/ }).tap(); + let sheet = page.getByRole("dialog", { name: "Comment thread" }); + let position = sheet.getByRole("button", { name: "2 of 2, show all comments" }); + await expect(position).toHaveText("2 of 2"); + expect((await size(position)).height).toBeGreaterThanOrEqual(44); + await position.click(); + let list = page.getByRole("dialog", { name: "Comments" }); + await expect(list.locator("[data-plan-comment-group-item]")).toHaveCount(2); + await expect(list.getByRole("button", { name: /Is two weeks enough\?/ })).toBeFocused(); +}); + +test("the desktop header has no comments list button", async ({ join, seed }) => { + await seed(SOURCE, { threads: [await open("review the pilot", ["Is two weeks enough?"])] }); + let page = await join("ana", { viewport: { width: 1440, height: 900 } }); + await expect(page.getByRole("button", { name: /^Comment on “review the pilot”/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /open comments?$/ })).toHaveCount(0); +}); diff --git a/e2e/implementation-launcher.e2e.ts b/e2e/implementation-launcher.e2e.ts index 254b72afb..b92a970fd 100644 --- a/e2e/implementation-launcher.e2e.ts +++ b/e2e/implementation-launcher.e2e.ts @@ -7,6 +7,7 @@ import { seedChildChannel } from "./database"; import { ROOT } from "./servers"; import { storedQuestion } from "../apps/server/src/testing/plan"; import type { Graph } from "../apps/server/src/tasks/graphs"; +import type { SeedState } from "../apps/server/src/testing/plan"; function preparedGraph(): Graph { return { @@ -340,7 +341,8 @@ test("Build without a running local agent says how to start one", async ({ seed, await expect(view.getByRole("button", { name: "Build on my laptop", exact: true })).toBeEnabled(); }); -test("Build names unanswered decisions as a way to Decisions", async ({ seed, join: enter }) => { +/** A document with one open decision, so Build is blocked on it. */ +async function seedOpenDecision(seed: (source: string, state?: SeedState) => Promise) { let widget = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; let question = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; let option = "01K0N4W3B7P27CBAEC7A8C8WEA"; @@ -380,6 +382,10 @@ test("Build names unanswered decisions as a way to Decisions", async ({ seed, jo }], }, ); +} + +test("Build names unanswered decisions as a way to Decisions", async ({ seed, join: enter }) => { + await seedOpenDecision(seed); let page = await enter("ana"); await openBuildView(page); let view = buildView(page); @@ -394,6 +400,31 @@ test("Build names unanswered decisions as a way to Decisions", async ({ seed, jo ).toHaveAttribute("aria-pressed", "true"); }); +test("a phone's blocked Build is an empty state that leads to Decisions", async ({ seed, join: enter }) => { + await seedOpenDecision(seed); + let page = await enter("ana", { + viewport: { width: 375, height: 667 }, + hasTouch: true, + isMobile: true, + }); + let navigation = page.getByRole("navigation", { name: "Workspace view" }); + await navigation.getByRole("button", { name: "Build", exact: true }).tap(); + let view = buildView(page); + await expect(view.getByRole("heading", { name: "Build unlocks after decisions" })).toBeVisible(); + await expect(view.getByText("1 decision is still open.", { exact: true })).toBeVisible(); + let go = view.getByRole("button", { name: "Go to Decisions", exact: true }); + await expect(go).toBeInViewport({ ratio: 1 }); + let box = (await go.boundingBox())!; + expect(box.height).toBeGreaterThanOrEqual(44); + // Centred in the space between the bars, not left at the top of the pane. + expect(Math.abs(box.x + box.width / 2 - 375 / 2)).toBeLessThanOrEqual(2); + expect(box.y).toBeGreaterThan(667 * 0.3); + await go.tap(); + await expect(view).toBeHidden(); + await expect(navigation.getByRole("button", { name: /^Decisions/ })) + .toHaveAttribute("aria-current", "page"); +}); + test("a child document has no Build view", async ({ seed, join: enter, room, baseURL }) => { await seed("# Parent\n\nThe parent can be built.\n", { graph: preparedGraph() }); let child = await seedChildChannel( diff --git a/packages/editor/src/comment-layer.tsx b/packages/editor/src/comment-layer.tsx index 8c71195f8..e9ad65e23 100644 --- a/packages/editor/src/comment-layer.tsx +++ b/packages/editor/src/comment-layer.tsx @@ -22,7 +22,14 @@ import { useCommentSheetReveal } from "./comment-sheet-reveal"; import { $rangeOf } from "./marks"; import { blockElement } from "./scroll"; import { COARSE_POINTER_QUERY, PRIMARY_COARSE_POINTER_QUERY, usePhone } from "./pointer"; -import { handlesOpenComment, OPEN_COMMENT, openOutcome, openRequest, useThreads } from "./threads"; +import { + handlesOpenComment, + OPEN_COMMENT, + OPEN_COMMENTS, + openOutcome, + openRequest, + useThreads, +} from "./threads"; import type { OpenRequest } from "./threads"; import { useTransitionPresence } from "./transition-presence"; import { widgets$ } from "./widget-options"; @@ -72,6 +79,8 @@ type MeasuredThread = { /** A pinned list of one line's threads, as opposed to a pinned thread id. */ const LIST = "list:"; +/** A phone sheet listing every open thread in the document, asked for from outside it. */ +const ALL = "all"; /** How long a resolve can be undone from its toast, while nobody is pointing at it. */ const UNDO_WINDOW = 6_000; @@ -377,6 +386,8 @@ export function CommentLayer({ store }: { store: ThreadStore }) { let [previewMeasurement, setPreviewMeasurement] = useState(); let [pinned, setPinned] = useState(); let [returnTo, setReturnTo] = useState(); + /** The thread shown was chosen from the list of every thread, so its header leads back there. */ + let [fromAll, setFromAll] = useState(false); let [notice, setNotice] = useState(); let [noticeHeld, setNoticeHeld] = useState(false); let [linked, setLinked] = useState(() => @@ -410,6 +421,8 @@ export function CommentLayer({ store }: { store: ThreadStore }) { // The comment sheet belongs to the phone class, portrait or landscape. let compact = options.commentPresentation === "sheet" && phone; let previousCompact = useRef(compact); + let compactRef = useRef(compact); + compactRef.current = compact; let immediately = options.motionImmediately?.() ?? false; let draft = canEdit ? state.draft : undefined; let measurePreview = useCallback((request: PreviewRequest, height: number) => { @@ -461,6 +474,12 @@ export function CommentLayer({ store }: { store: ThreadStore }) { dialog?.querySelector("[data-plan-comment-close], button")?.focus(); }, [compact, pinned]); + useEffect(() => { + if (!pinned || pinned === ALL || pinned === "orphans" || pinned.startsWith(LIST)) { + setFromAll(false); + } + }, [pinned]); + // A preview belongs to the thread it was raised for; another pinned thread clears it. useEffect(() => { if (pinned) setPreview(current => current === pinned ? current : undefined); @@ -623,7 +642,7 @@ export function CommentLayer({ store }: { store: ThreadStore }) { let openIds = pinned?.startsWith(LIST) ? markers.find(marker => `${LIST}${marker.key}` === pinned)?.views.map(view => view.thread.id) ?? [] - : pinned && pinned !== "orphans" + : pinned && pinned !== "orphans" && pinned !== ALL ? [pinned] : []; let openKey = openIds.join(" "); @@ -846,11 +865,23 @@ export function CommentLayer({ store }: { store: ThreadStore }) { waiting.current = undefined; settle(pending.request, true); }); + // Only a phone's sheet has a list of every thread; elsewhere each thread keeps its margin. + let list = (event: Event) => { + let detail = (event as CustomEvent<{ channel?: string } | undefined>).detail; + if (!compactRef.current || !handlesOpenComment(store.channel, detail?.channel)) return; + let active = document.activeElement; + origin.current = active instanceof HTMLElement ? active : undefined; + setReturnTo(undefined); + setPreview(undefined); + setPinned(ALL); + }; addEventListener("hashchange", follow); addEventListener(OPEN_COMMENT, open); + addEventListener(OPEN_COMMENTS, list); return () => { removeEventListener("hashchange", follow); removeEventListener(OPEN_COMMENT, open); + removeEventListener(OPEN_COMMENTS, list); arrived(); clearTimeout(waiting.current?.timer); waiting.current = undefined; @@ -879,6 +910,8 @@ export function CommentLayer({ store }: { store: ThreadStore }) { if (!pinned) return; let available = pinned === "orphans" ? state.threads.some(view => view.thread.status === "open" && view.orphaned) + : pinned === ALL + ? state.threads.some(view => view.thread.status === "open" && !view.orphaned) : pinned.startsWith(LIST) ? markersRef.current.some(marker => `${LIST}${marker.key}` === pinned) : state.threads.some(view => @@ -917,7 +950,7 @@ export function CommentLayer({ store }: { store: ThreadStore }) { ?.focus(); }, [pinned]); - let sheetId = compact && pinned !== "orphans" ? pinned : undefined; + let sheetId = compact && pinned !== "orphans" && pinned !== ALL ? pinned : undefined; let sheetPassages = useMemo(() => { if (!sheetId) return undefined; let list = sheetId.startsWith(LIST) ? sheetId.slice(LIST.length) : undefined; @@ -979,7 +1012,26 @@ export function CommentLayer({ store }: { store: ThreadStore }) { selected.current = id; setPinned(id); }; + // The document's open threads in reading order, for the list of them all and to step through. + let order = [ + ...new Set( + [...placed].sort((a, b) => passageTop(a) - passageTop(b)).map(entry => entry.view.thread.id), + ), + ]; + let allViews = order.flatMap(id => { + let view = placed.find(entry => entry.view.thread.id === id)?.view; + return view ? [view] : []; + }); let backTo = (view: ThreadView) => { + if (fromAll && allViews.length > 1) { + return { + count: allViews.length, + go: () => { + setReturnTo(view.thread.id); + setPinned(ALL); + }, + }; + } let group = markerOf(view.thread.id); let back = group && group.views.length > 1 ? group : undefined; return back @@ -1092,8 +1144,10 @@ export function CommentLayer({ store }: { store: ThreadStore }) { }); compactClose = dismiss; compactContent = orphaned.map(view => card(view)); - } else if (compact && pinned && (pinnedView || pinnedList)) { - // One sheet serves a line's list and every thread, so moving between them does not reopen it. + } else if ( + compact && pinned && (pinnedView || pinnedList || (pinned === ALL && allViews.length > 0)) + ) { + // One sheet serves the lists and every thread, so moving between them does not reopen it. compactKey = "pinned"; compactId = dialogId(pinned); compactLabel = pinnedView ? "Comment thread" : "Comments"; @@ -1104,13 +1158,12 @@ export function CommentLayer({ store }: { store: ThreadStore }) { quote: pinnedView.quote, siblings: back?.count ?? 1, }) - : commentSheetHeading({ kind: "list", count: pinnedList!.views.length }); + : commentSheetHeading({ + kind: "list", + count: pinnedList ? pinnedList.views.length : allViews.length, + }); compactBack = back?.go; compactClose = dismiss; - // The document's open threads in reading order, so the sheet can step through them. - let order = [...placed] - .sort((a, b) => passageTop(a) - passageTop(b)) - .map(entry => entry.view.thread.id); let at = pinnedView ? order.indexOf(pinnedView.thread.id) : -1; // Paging moves the way back too: closing returns focus to the marker of the thread shown. let step = (by: number) => { @@ -1128,6 +1181,10 @@ export function CommentLayer({ store }: { store: ThreadStore }) { ? { count: order.length, index: at, + onList: () => { + setReturnTo(pinnedView!.thread.id); + setPinned(ALL); + }, onNext: at < order.length - 1 ? () => step(1) : undefined, onPrevious: at > 0 ? () => step(-1) : undefined, } @@ -1136,9 +1193,13 @@ export function CommentLayer({ store }: { store: ThreadStore }) { { + setFromAll(!pinnedList); + selectThread(id); + }} + quotes={!pinnedList} returnTo={returnTo} - views={pinnedList!.views} + views={pinnedList ? pinnedList.views : allViews} /> ); } diff --git a/packages/editor/src/comment-sheet.tsx b/packages/editor/src/comment-sheet.tsx index e0a9826c5..e8b185faf 100644 --- a/packages/editor/src/comment-sheet.tsx +++ b/packages/editor/src/comment-sheet.tsx @@ -119,6 +119,8 @@ export type CommentSheetPager = { /** Absent at the first thread: the pager stops at its ends rather than wrapping. */ onPrevious?: () => void; onNext?: () => void; + /** Show every open thread as a list, from the count between the steps. */ + onList?: () => void; }; export function CommentSheet( @@ -235,7 +237,21 @@ export function CommentSheet( >
- ); - } - return (
diff --git a/e2e/implementation-launcher.e2e.ts b/e2e/implementation-launcher.e2e.ts index c393ac6f4..254b72afb 100644 --- a/e2e/implementation-launcher.e2e.ts +++ b/e2e/implementation-launcher.e2e.ts @@ -7,7 +7,6 @@ import { seedChildChannel } from "./database"; import { ROOT } from "./servers"; import { storedQuestion } from "../apps/server/src/testing/plan"; import type { Graph } from "../apps/server/src/tasks/graphs"; -import type { SeedState } from "../apps/server/src/testing/plan"; function preparedGraph(): Graph { return { @@ -341,8 +340,7 @@ test("Build without a running local agent says how to start one", async ({ seed, await expect(view.getByRole("button", { name: "Build on my laptop", exact: true })).toBeEnabled(); }); -/** A document with one open decision, so Build is blocked on it. */ -async function seedOpenDecision(seed: (source: string, state?: SeedState) => Promise) { +test("Build names unanswered decisions as a way to Decisions", async ({ seed, join: enter }) => { let widget = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; let question = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; let option = "01K0N4W3B7P27CBAEC7A8C8WEA"; @@ -382,10 +380,6 @@ async function seedOpenDecision(seed: (source: string, state?: SeedState) => Pro }], }, ); -} - -test("Build names unanswered decisions as a way to Decisions", async ({ seed, join: enter }) => { - await seedOpenDecision(seed); let page = await enter("ana"); await openBuildView(page); let view = buildView(page); @@ -400,34 +394,6 @@ test("Build names unanswered decisions as a way to Decisions", async ({ seed, jo ).toHaveAttribute("aria-pressed", "true"); }); -test("a phone's blocked Build is an empty state that leads to Decisions", async ({ seed, join: enter }) => { - await seedOpenDecision(seed); - let page = await enter("ana", { - viewport: { width: 375, height: 667 }, - hasTouch: true, - isMobile: true, - }); - let navigation = page.getByRole("navigation", { name: "Workspace view" }); - await navigation.getByRole("button", { name: "Build", exact: true }).tap(); - let view = buildView(page); - await expect(view.getByRole("heading", { name: "Build unlocks after decisions" })).toBeVisible(); - await expect(view.getByText("1 decision is still open.", { exact: true })).toBeVisible(); - let go = view.getByRole("button", { name: "Go to Decisions", exact: true }); - await expect(go).toBeInViewport({ ratio: 1 }); - // Centred in the space between the bars once the pane has slid in, not left at the top. - await expect.poll(async () => { - let box = (await go.boundingBox())!; - return Math.abs(box.x + box.width / 2 - 375 / 2) <= 2; - }).toBe(true); - let box = (await go.boundingBox())!; - expect(box.height).toBeGreaterThanOrEqual(44); - expect(box.y).toBeGreaterThan(667 * 0.3); - await go.tap(); - await expect(view).toBeHidden(); - await expect(navigation.getByRole("button", { name: /^Decisions/ })) - .toHaveAttribute("aria-current", "page"); -}); - test("a child document has no Build view", async ({ seed, join: enter, room, baseURL }) => { await seed("# Parent\n\nThe parent can be built.\n", { graph: preparedGraph() }); let child = await seedChildChannel(