From 260c0ec953c10da5644fd4f6b56c5411ddc0041e Mon Sep 17 00:00:00 2001 From: Nassim Najjar Date: Sun, 8 Mar 2026 09:35:23 +0100 Subject: [PATCH] fix(web): compact plan questionnaire navigation buttons --- apps/web/src/components/ChatView.browser.tsx | 151 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 30 +++- 2 files changed, 174 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e2fd573fe8..e171f39e9f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,12 +2,15 @@ import "../index.css"; import { + EventId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, + type OrchestrationThreadActivity, type ProjectId, type ServerConfig, type ThreadId, + TurnId, type WsWelcomePayload, WS_CHANNELS, WS_METHODS, @@ -87,6 +90,29 @@ interface MountedChatView { setViewport: (viewport: ViewportSpec) => Promise; } +function makeActivity(overrides: { + id?: string; + createdAt?: string; + kind?: string; + summary?: string; + tone?: OrchestrationThreadActivity["tone"]; + payload?: Record; + turnId?: string; + sequence?: number; +}): OrchestrationThreadActivity { + const payload = overrides.payload ?? {}; + return { + id: EventId.makeUnsafe(overrides.id ?? crypto.randomUUID()), + createdAt: overrides.createdAt ?? NOW_ISO, + kind: overrides.kind ?? "tool.started", + summary: overrides.summary ?? "Tool call", + tone: overrides.tone ?? "tool", + payload, + turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null, + ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}), + }; +} + function isoAt(offsetSeconds: number): string { return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); } @@ -256,6 +282,62 @@ function createDraftOnlySnapshot(): OrchestrationReadModel { }; } +function createPendingUserInputSnapshot(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-pending-input-target" as MessageId, + targetText: "pending input target", + }); + const thread = snapshot.threads[0]; + if (!thread) { + throw new Error("expected snapshot thread"); + } + + return { + ...snapshot, + threads: [ + { + ...thread, + activities: [ + makeActivity({ + id: "pending-user-input-open", + createdAt: NOW_ISO, + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-browser", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + { + id: "approval_policy", + header: "Approvals", + question: "How should approvals work?", + options: [ + { + label: "never", + description: "Do not ask for approvals", + }, + ], + }, + ], + }, + }), + ], + }, + ], + }; +} + function resolveWsRpc(tag: string): unknown { if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; @@ -867,4 +949,73 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("renders compact questionnaire navigation controls within the composer at narrow widths", async () => { + const mounted = await mountChatView({ + viewport: TEXT_VIEWPORT_MATRIX[3], + snapshot: createPendingUserInputSnapshot(), + }); + + try { + const composerForm = await waitForElement( + () => document.querySelector('[data-chat-composer-form="true"]'), + "Unable to find composer form.", + ); + const firstOptionButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "workspace-write", + ) as HTMLButtonElement | null, + "Unable to find first questionnaire option.", + ); + firstOptionButton.click(); + await waitForLayout(); + + const nextButton = await waitForElement( + () => document.querySelector('button[aria-label="Next question"]'), + "Unable to find next question button.", + ); + expect(nextButton.textContent?.trim() ?? "").toBe(""); + expect( + Array.from(document.querySelectorAll("button")).some( + (button) => button.textContent?.trim() === "Next question", + ), + ).toBe(false); + expect(nextButton.getBoundingClientRect().right).toBeLessThanOrEqual( + composerForm.getBoundingClientRect().right + 1, + ); + + nextButton.click(); + await waitForLayout(); + + const previousButton = await waitForElement( + () => document.querySelector('button[aria-label="Previous question"]'), + "Unable to find previous question button.", + ); + const submitButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Submit", + ) as HTMLButtonElement | null, + "Unable to find submit button.", + ); + + expect( + Array.from(document.querySelectorAll("button")).some( + (button) => + button.textContent?.trim() === "Previous" || button.textContent?.trim() === "Submit answers", + ), + ).toBe(false); + expect(previousButton.textContent?.trim() ?? "").toBe(""); + expect(previousButton.getBoundingClientRect().right).toBeLessThanOrEqual( + composerForm.getBoundingClientRect().right + 1, + ); + expect(submitButton.querySelector("svg")).toBeTruthy(); + expect(submitButton.getBoundingClientRect().right).toBeLessThanOrEqual( + composerForm.getBoundingClientRect().right + 1, + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1693f4199..686b4e6cb6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3701,34 +3701,50 @@ export default function ChatView({ threadId }: ChatViewProps) { Preparing worktree... ) : null} {activePendingProgress ? ( -
+
{activePendingProgress.questionIndex > 0 ? ( ) : null}
) : phase === "running" ? (