diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51..494ab2aa0 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { + EventId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -21,12 +22,14 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; +import { usePendingUserInputDraftStore } from "../pendingUserInputDraftStore"; import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; const THREAD_ID = "thread-browser-test" as ThreadId; +const SECOND_THREAD_ID = "thread-browser-test-2" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; @@ -356,6 +359,127 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithPendingUserInputThreads(): OrchestrationReadModel { + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-pending-target" as MessageId, + targetText: "pending target", + }); + + return { + ...baseSnapshot, + threads: [ + { + ...baseSnapshot.threads[0]!, + id: THREAD_ID, + title: "Pending input thread", + activities: [ + { + id: EventId.makeUnsafe("user-input-open-1"), + turnId: null, + createdAt: isoAt(500), + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + { + label: "danger-full-access", + description: "Allow unrestricted edits", + }, + ], + }, + ], + }, + }, + ], + }, + { + ...baseSnapshot.threads[0]!, + id: SECOND_THREAD_ID, + title: "Secondary thread", + activities: [], + messages: [ + createUserMessage({ + id: "msg-user-second-thread" as MessageId, + text: "secondary thread", + offsetSeconds: 700, + }), + ], + updatedAt: isoAt(701), + }, + ], + }; +} + +function createSnapshotWithMultiQuestionPendingUserInputThread(): OrchestrationReadModel { + const baseSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-multi-pending-target" as MessageId, + targetText: "multi pending target", + }); + + return { + ...baseSnapshot, + threads: [ + { + ...baseSnapshot.threads[0]!, + id: THREAD_ID, + title: "Multi-question pending input thread", + activities: [ + { + id: EventId.makeUnsafe("user-input-open-multi"), + turnId: null, + createdAt: isoAt(510), + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-multi", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + { + label: "danger-full-access", + description: "Allow unrestricted edits", + }, + ], + }, + { + id: "reasoning", + header: "Reasoning", + question: "How should the agent reason?", + options: [ + { + label: "Keep it concise", + description: "Prefer shorter responses", + }, + ], + }, + ], + }, + }, + ], + }, + ], + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { @@ -531,6 +655,135 @@ async function waitForComposerEditor(): Promise { ); } +async function waitForComposerText(expectedText: string): Promise { + await vi.waitFor( + async () => { + expect((await waitForComposerEditor()).textContent ?? "").toBe(expectedText); + }, + { + timeout: 8_000, + interval: 16, + }, + ); +} + +async function waitForButtonWithText(label: string): Promise { + return waitForElement( + () => + Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes(label), + ) as HTMLButtonElement | null, + `Unable to find button with text "${label}".`, + ); +} + +async function waitForPendingUserInputResponse(): Promise> { + let answers: Record | null = null; + await vi.waitFor( + () => { + const request = wsRequests.find((wsRequest) => { + if (wsRequest._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand) { + return false; + } + const command = + "command" in wsRequest && wsRequest.command && typeof wsRequest.command === "object" + ? (wsRequest.command as Record) + : null; + return command?.type === "thread.user-input.respond"; + }); + expect(request, "Unable to find thread.user-input.respond request.").toBeTruthy(); + if ( + !request || + !("command" in request) || + !request.command || + typeof request.command !== "object" + ) { + throw new Error("thread.user-input.respond request did not include a command payload."); + } + const command = request.command as Record; + answers = + command.answers && typeof command.answers === "object" + ? (command.answers as Record) + : null; + expect(answers, "Unable to read thread.user-input.respond answers.").toBeTruthy(); + }, + { + timeout: 8_000, + interval: 16, + }, + ); + if (!answers) { + throw new Error("Unable to find pending user-input response payload."); + } + return answers; +} + +function setComposerSelectionOffset(root: HTMLElement, offset: number): void { + const selection = document.getSelection(); + if (!selection) { + throw new Error("Unable to read browser selection."); + } + + root.focus(); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let remaining = offset; + let currentNode = walker.nextNode(); + while (currentNode) { + if (currentNode instanceof Text) { + const textLength = currentNode.data.length; + if (remaining <= textLength) { + const range = document.createRange(); + range.setStart(currentNode, remaining); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + document.dispatchEvent(new Event("selectionchange")); + return; + } + remaining -= textLength; + } + currentNode = walker.nextNode(); + } + + const fallbackRange = document.createRange(); + fallbackRange.selectNodeContents(root); + fallbackRange.collapse(false); + selection.removeAllRanges(); + selection.addRange(fallbackRange); + document.dispatchEvent(new Event("selectionchange")); +} + +function insertComposerText(text: string): void { + if (document.execCommand("insertText", false, text)) { + return; + } + + const selection = document.getSelection(); + if (!selection || selection.rangeCount === 0) { + throw new Error("Unable to insert text without an active composer selection."); + } + const range = selection.getRangeAt(0); + range.deleteContents(); + const textNode = document.createTextNode(text); + range.insertNode(textNode); + range.setStart(textNode, text.length); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + const commonAncestor = + range.commonAncestorContainer instanceof HTMLElement + ? range.commonAncestorContainer + : range.commonAncestorContainer.parentElement; + commonAncestor?.dispatchEvent( + new InputEvent("input", { + bubbles: true, + cancelable: true, + data: text, + inputType: "insertText", + }), + ); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -721,6 +974,9 @@ describe("ChatView timeline estimator parity (full app)", () => { draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); + usePendingUserInputDraftStore.setState({ + draftsByThreadId: {}, + }); useStore.setState({ projects: [], threads: [], @@ -1247,4 +1503,165 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("keeps pending plan answer edits in place when moving the caret", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithPendingUserInputThreads(), + }); + + try { + const composerEditor = await waitForComposerEditor(); + const composerEditorLocator = page.elementLocator(composerEditor); + await composerEditorLocator.click(); + await composerEditorLocator.fill("abcde"); + await waitForComposerText("abcde"); + + setComposerSelectionOffset(composerEditor, 3); + insertComposerText("X"); + + await waitForComposerText("abcXde"); + } finally { + await mounted.cleanup(); + } + }); + + it("preserves pending plan answer drafts across thread navigation", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithPendingUserInputThreads(), + }); + + try { + const composerEditor = await waitForComposerEditor(); + const composerEditorLocator = page.elementLocator(composerEditor); + await composerEditorLocator.click(); + await composerEditorLocator.fill("Keep the current flow"); + await waitForComposerText("Keep the current flow"); + + await mounted.router.navigate({ + to: "/$threadId", + params: { threadId: SECOND_THREAD_ID }, + }); + await waitForLayout(); + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("secondary thread"); + }, + { timeout: 8_000, interval: 16 }, + ); + + await mounted.router.navigate({ + to: "/$threadId", + params: { threadId: THREAD_ID }, + }); + await waitForLayout(); + await waitForComposerText("Keep the current flow"); + } finally { + await mounted.cleanup(); + } + }); + + it("submits a custom pending answer when the user clicks Submit answers", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithPendingUserInputThreads(), + }); + + try { + const composerEditor = await waitForComposerEditor(); + const composerEditorLocator = page.elementLocator(composerEditor); + await composerEditorLocator.click(); + await composerEditorLocator.fill("Keep the custom answer"); + + const submitButton = await waitForButtonWithText("Submit answers"); + submitButton.click(); + + expect(await waitForPendingUserInputResponse()).toMatchObject({ + sandbox_mode: "Keep the custom answer", + }); + } finally { + await mounted.cleanup(); + } + }); + + it("submits a preset pending answer when the user selects an option without custom text", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithPendingUserInputThreads(), + }); + + try { + const presetButton = await waitForButtonWithText("workspace-write"); + presetButton.click(); + + expect(await waitForPendingUserInputResponse()).toMatchObject({ + sandbox_mode: "workspace-write", + }); + } finally { + await mounted.cleanup(); + } + }); + + it("submits the selected preset instead of stale custom text", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithPendingUserInputThreads(), + }); + + try { + const composerEditor = await waitForComposerEditor(); + const composerEditorLocator = page.elementLocator(composerEditor); + await composerEditorLocator.click(); + await composerEditorLocator.fill("stale custom answer"); + await waitForComposerText("stale custom answer"); + + const presetButton = await waitForButtonWithText("workspace-write"); + presetButton.click(); + + await waitForComposerText(""); + await waitForLayout(); + await waitForComposerText(""); + + expect(await waitForPendingUserInputResponse()).toMatchObject({ + sandbox_mode: "workspace-write", + }); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps preset and custom answers separate across a multi-question prompt", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithMultiQuestionPendingUserInputThread(), + }); + + try { + const presetButton = await waitForButtonWithText("workspace-write"); + presetButton.click(); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("How should the agent reason?"); + }, + { timeout: 8_000, interval: 16 }, + ); + + const composerEditor = await waitForComposerEditor(); + const composerEditorLocator = page.elementLocator(composerEditor); + await composerEditorLocator.click(); + await composerEditorLocator.fill("Use a longer custom explanation"); + + const submitButton = await waitForButtonWithText("Submit answers"); + submitButton.click(); + + expect(await waitForPendingUserInputResponse()).toMatchObject({ + sandbox_mode: "workspace-write", + reasoning: "Use a longer custom explanation", + }); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52637695e..30e359698 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -64,8 +64,13 @@ import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, + setPendingUserInputSelectedOption, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; +import { + usePendingUserInputDraftStore, + usePendingUserInputThreadDraft, +} from "../pendingUserInputDraftStore"; import { useStore } from "../store"; import { buildPlanImplementationThreadTitle, @@ -241,6 +246,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); + const pendingUserInputDraftThread = usePendingUserInputThreadDraft(threadId); + const setPendingUserInputDraftQuestionIndex = usePendingUserInputDraftStore( + (store) => store.setQuestionIndex, + ); + const setPendingUserInputDraftAnswer = usePendingUserInputDraftStore((store) => store.setAnswer); + const clearInactivePendingUserInputDraftRequests = usePendingUserInputDraftStore( + (store) => store.clearInactiveRequests, + ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isDragOverComposer, setIsDragOverComposer] = useState(false); @@ -259,11 +272,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); - const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< - Record> - >({}); - const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = - useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); @@ -593,17 +601,21 @@ export default function ChatView({ threadId }: ChatViewProps) { () => derivePendingUserInputs(threadActivities), [threadActivities], ); + const activePendingUserInputRequestIds = useMemo( + () => pendingUserInputs.map((pendingUserInput) => pendingUserInput.requestId), + [pendingUserInputs], + ); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( () => activePendingUserInput - ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? + ? (pendingUserInputDraftThread.answersByRequestId[activePendingUserInput.requestId] ?? EMPTY_PENDING_USER_INPUT_ANSWERS) : EMPTY_PENDING_USER_INPUT_ANSWERS, - [activePendingUserInput, pendingUserInputAnswersByRequestId], + [activePendingUserInput, pendingUserInputDraftThread.answersByRequestId], ); const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) + ? (pendingUserInputDraftThread.questionIndexByRequestId[activePendingUserInput.requestId] ?? 0) : 0; const activePendingProgress = useMemo( () => @@ -651,14 +663,45 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + useEffect(() => { + clearInactivePendingUserInputDraftRequests(threadId, activePendingUserInputRequestIds); + }, [activePendingUserInputRequestIds, clearInactivePendingUserInputDraftRequests, threadId]); const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; } | null>(null); + const pendingAnswerWriteRef = useRef<{ + requestId: string | null; + questionId: string | null; + source: "option" | "custom" | null; + }>({ + requestId: null, + questionId: null, + source: null, + }); + const suppressedPendingCustomRestoreRef = useRef<{ + requestId: string | null; + value: string | null; + expiresAtMs: number; + }>({ + requestId: null, + value: null, + expiresAtMs: 0, + }); useEffect(() => { const nextCustomAnswer = activePendingProgress?.customAnswer; if (typeof nextCustomAnswer !== "string") { lastSyncedPendingInputRef.current = null; + pendingAnswerWriteRef.current = { + requestId: null, + questionId: null, + source: null, + }; + suppressedPendingCustomRestoreRef.current = { + requestId: null, + value: null, + expiresAtMs: 0, + }; return; } const nextRequestId = activePendingUserInput?.requestId ?? null; @@ -672,6 +715,16 @@ export default function ChatView({ threadId }: ChatViewProps) { requestId: nextRequestId, questionId: nextQuestionId, }; + if ( + pendingAnswerWriteRef.current.requestId !== nextRequestId || + pendingAnswerWriteRef.current.questionId !== nextQuestionId + ) { + pendingAnswerWriteRef.current = { + requestId: nextRequestId, + questionId: nextQuestionId, + source: activePendingProgress?.activeDraft?.answerSource ?? null, + }; + } if (!questionChanged && !textChangedExternally) { return; @@ -688,6 +741,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); setComposerHighlightedItemId(null); }, [ + activePendingProgress?.activeDraft?.answerSource, activePendingProgress?.customAnswer, activePendingUserInput?.requestId, activePendingProgress?.activeQuestion?.id, @@ -1759,9 +1813,12 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); useEffect(() => { + if (activePendingProgress?.activeQuestion) { + return; + } promptRef.current = prompt; setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt]); + }, [activePendingProgress?.activeQuestion, prompt]); useEffect(() => { setOptimisticUserMessages((existing) => { @@ -2546,34 +2603,83 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingUserInput) { return; } - setPendingUserInputQuestionIndexByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: nextQuestionIndex, - })); + setPendingUserInputDraftQuestionIndex( + threadId, + activePendingUserInput.requestId, + nextQuestionIndex, + ); }, - [activePendingUserInput], + [activePendingUserInput, setPendingUserInputDraftQuestionIndex, threadId], ); const onSelectActivePendingUserInputOption = useCallback( - (questionId: string, optionLabel: string) => { + ( + questionId: string, + optionLabel: string, + options?: { + advanceToNextQuestion?: boolean; + submitIfComplete?: boolean; + }, + ) => { if (!activePendingUserInput) { return; } - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: { - selectedOptionLabel: optionLabel, - customAnswer: "", - }, - }, - })); + const previousDraft = activePendingDraftAnswers[questionId]; + const nextDraftAnswer = setPendingUserInputSelectedOption(previousDraft, optionLabel); + pendingAnswerWriteRef.current = { + requestId: activePendingUserInput.requestId, + questionId, + source: "option", + }; + suppressedPendingCustomRestoreRef.current = { + requestId: activePendingUserInput.requestId, + value: + typeof previousDraft?.customAnswer === "string" && previousDraft.customAnswer.length > 0 + ? previousDraft.customAnswer + : null, + expiresAtMs: performance.now() + 500, + }; + setPendingUserInputDraftAnswer( + threadId, + activePendingUserInput.requestId, + questionId, + nextDraftAnswer, + ); promptRef.current = ""; setComposerCursor(0); setComposerTrigger(null); + setComposerHighlightedItemId(null); + if (options?.submitIfComplete) { + const nextResolvedAnswers = buildPendingUserInputAnswers(activePendingUserInput.questions, { + ...activePendingDraftAnswers, + [questionId]: nextDraftAnswer, + }); + if (nextResolvedAnswers) { + void onRespondToUserInput(activePendingUserInput.requestId, nextResolvedAnswers); + } + return; + } + if ( + options?.advanceToNextQuestion && + activePendingProgress && + !activePendingProgress.isLastQuestion + ) { + setPendingUserInputDraftQuestionIndex( + threadId, + activePendingUserInput.requestId, + activePendingProgress.questionIndex + 1, + ); + } }, - [activePendingUserInput], + [ + activePendingDraftAnswers, + activePendingProgress, + activePendingUserInput, + onRespondToUserInput, + setPendingUserInputDraftAnswer, + setPendingUserInputDraftQuestionIndex, + threadId, + ], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -2587,23 +2693,48 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingUserInput) { return; } + if ( + suppressedPendingCustomRestoreRef.current.requestId === activePendingUserInput.requestId && + suppressedPendingCustomRestoreRef.current.value !== null && + suppressedPendingCustomRestoreRef.current.value === value && + suppressedPendingCustomRestoreRef.current.expiresAtMs >= performance.now() + ) { + return; + } + suppressedPendingCustomRestoreRef.current = { + requestId: null, + value: null, + expiresAtMs: 0, + }; + if ( + pendingAnswerWriteRef.current.requestId === activePendingUserInput.requestId && + pendingAnswerWriteRef.current.questionId === questionId && + pendingAnswerWriteRef.current.source === "option" && + value === activePendingDraftAnswers[questionId]?.customAnswer + ) { + return; + } + pendingAnswerWriteRef.current = { + requestId: activePendingUserInput.requestId, + questionId, + source: + value.trim().length > 0 + ? "custom" + : (activePendingDraftAnswers[questionId]?.answerSource ?? null), + }; promptRef.current = value; - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[questionId], - value, - ), - }, - })); + setPendingUserInputDraftAnswer( + threadId, + activePendingUserInput.requestId, + questionId, + setPendingUserInputCustomAnswer(activePendingDraftAnswers[questionId], value), + ); setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), ); }, - [activePendingUserInput], + [activePendingDraftAnswers, activePendingUserInput, setPendingUserInputDraftAnswer, threadId], ); const onAdvanceActivePendingUserInput = useCallback(() => { @@ -2940,16 +3071,15 @@ export default function ChatView({ threadId }: ChatViewProps) { promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [activePendingQuestion.id]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], - next.text, - ), - }, - })); + setPendingUserInputDraftAnswer( + threadId, + activePendingUserInput.requestId, + activePendingQuestion.id, + setPendingUserInputCustomAnswer( + activePendingDraftAnswers[activePendingQuestion.id], + next.text, + ), + ); } else { setPrompt(next.text); } @@ -2962,7 +3092,14 @@ export default function ChatView({ threadId }: ChatViewProps) { }); return true; }, - [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], + [ + activePendingDraftAnswers, + activePendingProgress?.activeQuestion, + activePendingUserInput, + setPendingUserInputDraftAnswer, + setPrompt, + threadId, + ], ); const readComposerSnapshot = useCallback((): { @@ -3351,7 +3488,6 @@ export default function ChatView({ threadId }: ChatViewProps) { answers={activePendingDraftAnswers} questionIndex={activePendingQuestionIndex} onSelectOption={onSelectActivePendingUserInputOption} - onAdvance={onAdvanceActivePendingUserInput} /> ) : showPlanFollowUpPrompt && activeProposedPlan ? ( diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index c8cad7bf3..e8a30a6ad 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,5 +1,5 @@ import { type ApprovalRequestId } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useRef } from "react"; +import { memo, useCallback, useEffect } from "react"; import { type PendingUserInput } from "../../session-logic"; import { derivePendingUserInputProgress, @@ -13,8 +13,14 @@ interface PendingUserInputPanelProps { respondingRequestIds: ApprovalRequestId[]; answers: Record; questionIndex: number; - onSelectOption: (questionId: string, optionLabel: string) => void; - onAdvance: () => void; + onSelectOption: ( + questionId: string, + optionLabel: string, + options?: { + advanceToNextQuestion?: boolean; + submitIfComplete?: boolean; + }, + ) => void; } export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ @@ -23,7 +29,6 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn answers, questionIndex, onSelectOption, - onAdvance, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; const activePrompt = pendingUserInputs[0]; @@ -37,7 +42,6 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn answers={answers} questionIndex={questionIndex} onSelectOption={onSelectOption} - onAdvance={onAdvance} /> ); }); @@ -48,40 +52,32 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( answers, questionIndex, onSelectOption, - onAdvance, }: { prompt: PendingUserInput; isResponding: boolean; answers: Record; questionIndex: number; - onSelectOption: (questionId: string, optionLabel: string) => void; - onAdvance: () => void; + onSelectOption: ( + questionId: string, + optionLabel: string, + options?: { + advanceToNextQuestion?: boolean; + submitIfComplete?: boolean; + }, + ) => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; - const autoAdvanceTimerRef = useRef(null); - - // Clear auto-advance timer on unmount - useEffect(() => { - return () => { - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - }; - }, []); const selectOptionAndAutoAdvance = useCallback( (questionId: string, optionLabel: string) => { - onSelectOption(questionId, optionLabel); - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - autoAdvanceTimerRef.current = window.setTimeout(() => { - autoAdvanceTimerRef.current = null; - onAdvance(); - }, 200); + onSelectOption( + questionId, + optionLabel, + progress.isLastQuestion ? { submitIfComplete: true } : { advanceToNextQuestion: true }, + ); }, - [onSelectOption, onAdvance], + [onSelectOption, progress.isLastQuestion], ); // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 153b31535..f83b32bca 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -7,10 +7,31 @@ import { findFirstUnansweredPendingUserInputQuestionIndex, resolvePendingUserInputAnswer, setPendingUserInputCustomAnswer, + setPendingUserInputSelectedOption, } from "./pendingUserInput"; describe("resolvePendingUserInputAnswer", () => { - it("prefers a custom answer over a selected option", () => { + it("resolves a custom answer when the explicit source is custom", () => { + expect( + resolvePendingUserInputAnswer({ + answerSource: "custom", + selectedOptionLabel: "Keep current envelope", + customAnswer: "Keep the existing envelope for one release", + }), + ).toBe("Keep the existing envelope for one release"); + }); + + it("resolves a selected option when the explicit source is option", () => { + expect( + resolvePendingUserInputAnswer({ + answerSource: "option", + selectedOptionLabel: "Scaffold only", + customAnswer: "Old custom answer", + }), + ).toBe("Scaffold only"); + }); + + it("prefers a custom answer over a selected option for legacy drafts", () => { expect( resolvePendingUserInputAnswer({ selectedOptionLabel: "Keep current envelope", @@ -31,15 +52,48 @@ describe("resolvePendingUserInputAnswer", () => { expect( setPendingUserInputCustomAnswer( { + answerSource: "option", selectedOptionLabel: "Preserve existing tags", }, "doesn't matter", ), ).toEqual({ - selectedOptionLabel: undefined, + answerSource: "custom", customAnswer: "doesn't matter", }); }); + + it("keeps the selected option active when custom text is cleared", () => { + expect( + setPendingUserInputCustomAnswer( + { + answerSource: "option", + selectedOptionLabel: "Preserve existing tags", + }, + "", + ), + ).toEqual({ + answerSource: "option", + selectedOptionLabel: "Preserve existing tags", + customAnswer: "", + }); + }); + + it("sets the selected option as the explicit answer source", () => { + expect( + setPendingUserInputSelectedOption( + { + answerSource: "custom", + customAnswer: "Keep the old custom answer", + }, + "Preserve existing tags", + ), + ).toEqual({ + answerSource: "option", + selectedOptionLabel: "Preserve existing tags", + customAnswer: "", + }); + }); }); describe("buildPendingUserInputAnswers", () => { @@ -72,9 +126,11 @@ describe("buildPendingUserInputAnswers", () => { ], { scope: { + answerSource: "option", selectedOptionLabel: "Orchestration-first", }, compat: { + answerSource: "custom", customAnswer: "Keep the current envelope for one release window", }, }, @@ -137,6 +193,7 @@ describe("pending user input question progress", () => { expect( countAnsweredPendingUserInputQuestions(questions, { scope: { + answerSource: "option", selectedOptionLabel: "Orchestration-first", }, }), @@ -147,6 +204,7 @@ describe("pending user input question progress", () => { expect( findFirstUnansweredPendingUserInputQuestionIndex(questions, { scope: { + answerSource: "option", selectedOptionLabel: "Orchestration-first", }, }), @@ -157,9 +215,11 @@ describe("pending user input question progress", () => { expect( findFirstUnansweredPendingUserInputQuestionIndex(questions, { scope: { + answerSource: "option", selectedOptionLabel: "Orchestration-first", }, compat: { + answerSource: "custom", customAnswer: "Keep it for one release window", }, }), @@ -172,6 +232,7 @@ describe("pending user input question progress", () => { questions, { scope: { + answerSource: "option", selectedOptionLabel: "Orchestration-first", }, }, @@ -183,10 +244,32 @@ describe("pending user input question progress", () => { selectedOptionLabel: "Orchestration-first", customAnswer: "", resolvedAnswer: "Orchestration-first", + usingCustomAnswer: false, answeredQuestionCount: 1, isLastQuestion: false, isComplete: false, canAdvance: true, }); }); + + it("marks preset selections as active even if a stale custom value exists", () => { + expect( + derivePendingUserInputProgress( + questions, + { + scope: { + answerSource: "option", + selectedOptionLabel: "Orchestration-first", + customAnswer: "stale custom answer", + }, + }, + 0, + ), + ).toMatchObject({ + selectedOptionLabel: "Orchestration-first", + customAnswer: "stale custom answer", + resolvedAnswer: "Orchestration-first", + usingCustomAnswer: false, + }); + }); }); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 86e41285a..b0e4401c0 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,6 +1,9 @@ import type { UserInputQuestion } from "@t3tools/contracts"; +export type PendingUserInputAnswerSource = "option" | "custom"; + export interface PendingUserInputDraftAnswer { + answerSource?: PendingUserInputAnswerSource; selectedOptionLabel?: string; customAnswer?: string; } @@ -32,26 +35,50 @@ export function resolvePendingUserInputAnswer( draft: PendingUserInputDraftAnswer | undefined, ): string | null { const customAnswer = normalizeDraftAnswer(draft?.customAnswer); + const selectedOptionLabel = normalizeDraftAnswer(draft?.selectedOptionLabel); + if (draft?.answerSource === "option") { + return selectedOptionLabel; + } + if (draft?.answerSource === "custom") { + return customAnswer; + } if (customAnswer) { return customAnswer; } - return normalizeDraftAnswer(draft?.selectedOptionLabel); + return selectedOptionLabel; } export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, ): PendingUserInputDraftAnswer { - const selectedOptionLabel = - customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; + const normalizedCustomAnswer = normalizeDraftAnswer(customAnswer); + const selectedOptionLabel = normalizedCustomAnswer ? undefined : draft?.selectedOptionLabel; + const answerSource = normalizedCustomAnswer + ? "custom" + : selectedOptionLabel + ? "option" + : undefined; return { + ...(answerSource ? { answerSource } : {}), customAnswer, ...(selectedOptionLabel ? { selectedOptionLabel } : {}), }; } +export function setPendingUserInputSelectedOption( + _draft: PendingUserInputDraftAnswer | undefined, + selectedOptionLabel: string, +): PendingUserInputDraftAnswer { + return { + answerSource: "option", + selectedOptionLabel, + customAnswer: "", + }; +} + export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, @@ -100,6 +127,13 @@ export function derivePendingUserInputProgress( const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined; const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft); const customAnswer = activeDraft?.customAnswer ?? ""; + const normalizedCustomAnswer = normalizeDraftAnswer(customAnswer); + const usingCustomAnswer = + activeDraft?.answerSource === "custom" + ? normalizedCustomAnswer !== null + : activeDraft?.answerSource === "option" + ? false + : normalizedCustomAnswer !== null; const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers); const isLastQuestion = questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1; @@ -111,7 +145,7 @@ export function derivePendingUserInputProgress( selectedOptionLabel: activeDraft?.selectedOptionLabel, customAnswer, resolvedAnswer, - usingCustomAnswer: customAnswer.trim().length > 0, + usingCustomAnswer, answeredQuestionCount, isLastQuestion, isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, diff --git a/apps/web/src/pendingUserInputDraftStore.ts b/apps/web/src/pendingUserInputDraftStore.ts new file mode 100644 index 000000000..f03f5dee6 --- /dev/null +++ b/apps/web/src/pendingUserInputDraftStore.ts @@ -0,0 +1,189 @@ +import type { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { createDebouncedStorage } from "./composerDraftStore"; +import type { PendingUserInputDraftAnswer } from "./pendingUserInput"; + +export const PENDING_USER_INPUT_DRAFT_STORAGE_KEY = "t3code:pending-user-input-drafts:v1"; + +const pendingUserInputDebouncedStorage = + typeof localStorage !== "undefined" + ? createDebouncedStorage(localStorage) + : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} }; + +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + pendingUserInputDebouncedStorage.flush(); + }); +} + +interface PendingUserInputThreadDraftState { + answersByRequestId: Record>; + questionIndexByRequestId: Record; +} + +interface PendingUserInputDraftStoreState { + draftsByThreadId: Record; + setQuestionIndex: ( + threadId: ThreadId, + requestId: ApprovalRequestId, + questionIndex: number, + ) => void; + setAnswer: ( + threadId: ThreadId, + requestId: ApprovalRequestId, + questionId: string, + answer: PendingUserInputDraftAnswer, + ) => void; + clearInactiveRequests: ( + threadId: ThreadId, + activeRequestIds: ReadonlyArray, + ) => void; +} + +const EMPTY_PENDING_USER_INPUT_THREAD_DRAFT = Object.freeze({ + answersByRequestId: {}, + questionIndexByRequestId: {}, +}) as PendingUserInputThreadDraftState; + +function shouldRemoveThreadDraft(draft: PendingUserInputThreadDraftState | undefined): boolean { + if (!draft) { + return true; + } + return ( + Object.keys(draft.answersByRequestId).length === 0 && + Object.keys(draft.questionIndexByRequestId).length === 0 + ); +} + +export const usePendingUserInputDraftStore = create()( + persist( + (set) => ({ + draftsByThreadId: {}, + setQuestionIndex: (threadId, requestId, questionIndex) => { + if (threadId.length === 0 || requestId.length === 0) { + return; + } + set((state) => { + const threadDraft = + state.draftsByThreadId[threadId] ?? EMPTY_PENDING_USER_INPUT_THREAD_DRAFT; + const nextQuestionIndex = Math.max(0, Math.floor(questionIndex)); + if (threadDraft.questionIndexByRequestId[requestId] === nextQuestionIndex) { + return state; + } + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: { + answersByRequestId: threadDraft.answersByRequestId, + questionIndexByRequestId: { + ...threadDraft.questionIndexByRequestId, + [requestId]: nextQuestionIndex, + }, + }, + }, + }; + }); + }, + setAnswer: (threadId, requestId, questionId, answer) => { + if (threadId.length === 0 || requestId.length === 0 || questionId.length === 0) { + return; + } + set((state) => { + const threadDraft = + state.draftsByThreadId[threadId] ?? EMPTY_PENDING_USER_INPUT_THREAD_DRAFT; + const requestAnswers = threadDraft.answersByRequestId[requestId] ?? {}; + const currentAnswer = requestAnswers[questionId]; + if ( + currentAnswer?.answerSource === answer.answerSource && + currentAnswer?.customAnswer === answer.customAnswer && + currentAnswer?.selectedOptionLabel === answer.selectedOptionLabel + ) { + return state; + } + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: { + answersByRequestId: { + ...threadDraft.answersByRequestId, + [requestId]: { + ...requestAnswers, + [questionId]: answer, + }, + }, + questionIndexByRequestId: threadDraft.questionIndexByRequestId, + }, + }, + }; + }); + }, + clearInactiveRequests: (threadId, activeRequestIds) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const threadDraft = state.draftsByThreadId[threadId]; + if (!threadDraft) { + return state; + } + const activeRequestIdSet = new Set(activeRequestIds); + let answersChanged = false; + const nextAnswersByRequestId = Object.fromEntries( + Object.entries(threadDraft.answersByRequestId).filter(([requestId]) => { + const keep = activeRequestIdSet.has(requestId as ApprovalRequestId); + answersChanged ||= !keep; + return keep; + }), + ) as PendingUserInputThreadDraftState["answersByRequestId"]; + let indexChanged = false; + const nextQuestionIndexByRequestId = Object.fromEntries( + Object.entries(threadDraft.questionIndexByRequestId).filter(([requestId]) => { + const keep = activeRequestIdSet.has(requestId as ApprovalRequestId); + indexChanged ||= !keep; + return keep; + }), + ) as PendingUserInputThreadDraftState["questionIndexByRequestId"]; + if (!answersChanged && !indexChanged) { + return state; + } + const nextThreadDraft: PendingUserInputThreadDraftState = { + answersByRequestId: nextAnswersByRequestId, + questionIndexByRequestId: nextQuestionIndexByRequestId, + }; + if (shouldRemoveThreadDraft(nextThreadDraft)) { + const { [threadId]: _removed, ...restDraftsByThreadId } = state.draftsByThreadId; + return { draftsByThreadId: restDraftsByThreadId }; + } + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: nextThreadDraft, + }, + }; + }); + }, + }), + { + name: PENDING_USER_INPUT_DRAFT_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => pendingUserInputDebouncedStorage), + partialize: (state) => ({ + draftsByThreadId: Object.fromEntries( + Object.entries(state.draftsByThreadId).filter( + ([, draft]) => !shouldRemoveThreadDraft(draft), + ), + ) as Record, + }), + }, + ), +); + +export function usePendingUserInputThreadDraft( + threadId: ThreadId, +): PendingUserInputThreadDraftState { + return usePendingUserInputDraftStore( + (state) => state.draftsByThreadId[threadId] ?? EMPTY_PENDING_USER_INPUT_THREAD_DRAFT, + ); +}