diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..9d1ccad83f2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -93,8 +93,13 @@ import { derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, - type PendingUserInputDraftAnswer, } from "../pendingUserInput"; +import { + clearPendingUserInputDraft, + usePendingUserInputDraftAnswers, + usePendingUserInputDraftStore, + usePendingUserInputQuestionIndex, +} from "../pendingUserInputDraftStore"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -301,7 +306,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; -const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_REQUEST_ID_SET: ReadonlySet = new Set(); function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); @@ -1259,11 +1264,12 @@ function ChatViewContent(props: ChatViewProps) { const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); - const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< - Record> - >({}); - const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = - useState>({}); + const updatePendingUserInputDraftAnswer = usePendingUserInputDraftStore( + (store) => store.updateAnswer, + ); + const setPendingUserInputDraftQuestionIndex = usePendingUserInputDraftStore( + (store) => store.setQuestionIndex, + ); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); @@ -1935,17 +1941,26 @@ function ChatViewContent(props: ChatViewProps) { [threadActivities], ); const activePendingUserInput = pendingUserInputs[0] ?? null; - const activePendingDraftAnswers = useMemo( - () => - activePendingUserInput - ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? - EMPTY_PENDING_USER_INPUT_ANSWERS) - : EMPTY_PENDING_USER_INPUT_ANSWERS, - [activePendingUserInput, pendingUserInputAnswersByRequestId], + // Drop a request's persisted draft only once the request itself is gone + // (resolved, cancelled, or failed as stale). Clearing on submit success would + // blank the panel during the window before the `user-input.resolved` activity + // lands, since the request is still listed as pending until then. + const seenPendingUserInputRequestIdsRef = useRef>(EMPTY_REQUEST_ID_SET); + useEffect(() => { + const currentRequestIds = new Set(pendingUserInputs.map((entry) => String(entry.requestId))); + for (const requestId of seenPendingUserInputRequestIdsRef.current) { + if (!currentRequestIds.has(requestId)) { + clearPendingUserInputDraft(requestId); + } + } + seenPendingUserInputRequestIdsRef.current = currentRequestIds; + }, [pendingUserInputs]); + const activePendingDraftAnswers = usePendingUserInputDraftAnswers( + activePendingUserInput?.requestId ?? null, + ); + const activePendingQuestionIndex = usePendingUserInputQuestionIndex( + activePendingUserInput?.requestId ?? null, ); - const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) - : 0; const activePendingProgress = useMemo( () => activePendingUserInput @@ -4903,12 +4918,9 @@ function ChatViewContent(props: ChatViewProps) { if (!activePendingUserInput) { return; } - setPendingUserInputQuestionIndexByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: nextQuestionIndex, - })); + setPendingUserInputDraftQuestionIndex(activePendingUserInput.requestId, nextQuestionIndex); }, - [activePendingUserInput], + [activePendingUserInput, setPendingUserInputDraftQuestionIndex], ); const onSelectActivePendingUserInputOption = useCallback( @@ -4916,32 +4928,26 @@ function ChatViewContent(props: ChatViewProps) { if (!activePendingUserInput) { return; } - setPendingUserInputAnswersByRequestId((existing) => { - const question = - (activePendingProgress?.activeQuestion?.id === questionId - ? activePendingProgress.activeQuestion - : undefined) ?? - activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question) { - return existing; - } + const question = + (activePendingProgress?.activeQuestion?.id === questionId + ? activePendingProgress.activeQuestion + : undefined) ?? activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question) { + return; + } - return { - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: togglePendingUserInputOptionSelection( - question, - existing[activePendingUserInput.requestId]?.[questionId], - optionLabel, - ), - }, - }; - }); + updatePendingUserInputDraftAnswer(activePendingUserInput.requestId, questionId, (previous) => + togglePendingUserInputOptionSelection(question, previous, optionLabel), + ); promptRef.current = ""; composerRef.current?.resetCursorState({ cursor: 0 }); }, - [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef], + [ + activePendingProgress?.activeQuestion, + activePendingUserInput, + composerRef, + updatePendingUserInputDraftAnswer, + ], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -4956,16 +4962,9 @@ function ChatViewContent(props: ChatViewProps) { return; } promptRef.current = value; - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[questionId], - value, - ), - }, - })); + updatePendingUserInputDraftAnswer(activePendingUserInput.requestId, questionId, (previous) => + setPendingUserInputCustomAnswer(previous, value), + ); const snapshot = composerRef.current?.readSnapshot(); if ( snapshot?.value !== value || @@ -4975,7 +4974,7 @@ function ChatViewContent(props: ChatViewProps) { composerRef.current?.focusAt(nextCursor); } }, - [activePendingUserInput, composerRef], + [activePendingUserInput, composerRef, updatePendingUserInputDraftAnswer], ); const onAdvanceActivePendingUserInput = useCallback(() => { diff --git a/apps/web/src/pendingUserInputDraftStore.test.ts b/apps/web/src/pendingUserInputDraftStore.test.ts new file mode 100644 index 00000000000..dad1c762cde --- /dev/null +++ b/apps/web/src/pendingUserInputDraftStore.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { setPendingUserInputCustomAnswer } from "./pendingUserInput"; +import { + clearPendingUserInputDraft, + usePendingUserInputDraftStore, + type PendingUserInputDraftAnswers, +} from "./pendingUserInputDraftStore"; + +const store = () => usePendingUserInputDraftStore.getState(); +const answersFor = (requestId: string) => store().draftsByRequestId[requestId]?.answers; +const questionIndexFor = (requestId: string) => store().draftsByRequestId[requestId]?.questionIndex; + +beforeEach(() => { + usePendingUserInputDraftStore.setState({ draftsByRequestId: {} }); +}); + +describe("pendingUserInputDraftStore", () => { + it("keeps a custom answer available after the owning view unmounts", () => { + store().updateAnswer("request-1", "question-1", (previous) => + setPendingUserInputCustomAnswer(previous, "this should survive"), + ); + + // A thread switch destroys `ChatView`; the store outlives it. + expect(answersFor("request-1")?.["question-1"]).toEqual({ + customAnswer: "this should survive", + }); + }); + + it("keeps drafts for different requests isolated", () => { + store().updateAnswer("request-1", "question-1", () => ({ customAnswer: "first" })); + store().updateAnswer("request-2", "question-1", () => ({ customAnswer: "second" })); + + expect(answersFor("request-1")?.["question-1"]?.customAnswer).toBe("first"); + expect(answersFor("request-2")?.["question-1"]?.customAnswer).toBe("second"); + }); + + it("tracks the active question index per request without disturbing answers", () => { + store().updateAnswer("request-1", "question-1", () => ({ customAnswer: "answer" })); + const answersBefore = answersFor("request-1"); + + store().setQuestionIndex("request-1", 2); + store().setQuestionIndex("request-2", 0); + + expect(questionIndexFor("request-1")).toBe(2); + expect(questionIndexFor("request-2")).toBe(0); + expect(answersFor("request-1")).toBe(answersBefore); + }); + + it("normalizes negative and fractional question indexes", () => { + store().setQuestionIndex("request-1", -3); + expect(questionIndexFor("request-1")).toBe(0); + + store().setQuestionIndex("request-1", 1.7); + expect(questionIndexFor("request-1")).toBe(1); + }); + + it("drops a request's draft once the request is no longer pending", () => { + store().updateAnswer("request-1", "question-1", () => ({ customAnswer: "answer" })); + store().setQuestionIndex("request-1", 1); + store().updateAnswer("request-2", "question-1", () => ({ customAnswer: "other thread" })); + + clearPendingUserInputDraft("request-1"); + + expect(store().draftsByRequestId["request-1"]).toBeUndefined(); + expect(answersFor("request-2")?.["question-1"]?.customAnswer).toBe("other thread"); + }); + + it("removes a question entry when the updater returns undefined", () => { + store().updateAnswer("request-1", "question-1", () => ({ customAnswer: "answer" })); + store().updateAnswer("request-1", "question-1", () => undefined); + + expect(answersFor("request-1")).toEqual({}); + }); + + it("stores a draft under a prototype-shadowing question id as an own property", () => { + store().updateAnswer("request-1", "__proto__", () => ({ customAnswer: "answer" })); + + const requestAnswers = answersFor("request-1") as PendingUserInputDraftAnswers; + expect(Object.hasOwn(requestAnswers, "__proto__")).toBe(true); + // Must survive the JSON round-trip the persist middleware performs. + expect(JSON.parse(JSON.stringify(requestAnswers))["__proto__"]).toEqual({ + customAnswer: "answer", + }); + + store().updateAnswer("request-1", "__proto__", () => undefined); + expect(Object.hasOwn(answersFor("request-1") ?? {}, "__proto__")).toBe(false); + }); + + it("evicts the oldest drafts past the retention cap but keeps the active one", () => { + for (let index = 0; index < 60; index += 1) { + store().updateAnswer(`request-${index}`, "question-1", () => ({ + customAnswer: `answer-${index}`, + })); + } + + const retained = Object.keys(store().draftsByRequestId); + expect(retained).toHaveLength(50); + expect(retained).not.toContain("request-0"); + expect(retained).toContain("request-59"); + }); + + it("evicts answers and question index together so neither half is orphaned", () => { + for (let index = 0; index < 60; index += 1) { + const requestId = `request-${index}`; + store().updateAnswer(requestId, "question-1", () => ({ customAnswer: `answer-${index}` })); + store().setQuestionIndex(requestId, 1); + } + + for (const draft of Object.values(store().draftsByRequestId)) { + expect(Object.keys(draft.answers)).toHaveLength(1); + expect(draft.questionIndex).toBe(1); + } + }); +}); diff --git a/apps/web/src/pendingUserInputDraftStore.ts b/apps/web/src/pendingUserInputDraftStore.ts new file mode 100644 index 00000000000..57429d741c4 --- /dev/null +++ b/apps/web/src/pendingUserInputDraftStore.ts @@ -0,0 +1,176 @@ +/** + * Persisted drafts for in-progress answers to a provider's user-input request. + * + * These used to live in `ChatView` component state, which is destroyed when the + * router swaps thread matches — switching threads silently discarded a typed + * (but unsent) answer. Keying by `requestId` in a store outside the component + * tree keeps the draft alive across thread switches and reloads until the + * request stops being pending. + */ + +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { resolveStorage } from "./lib/storage"; +import type { PendingUserInputDraftAnswer } from "./pendingUserInput"; + +const PENDING_USER_INPUT_DRAFT_STORAGE_KEY = "t3code:pending-user-input-drafts:v1"; + +/** + * Upper bound on retained request drafts. Drafts are dropped once their request + * stops being pending, but a request can also vanish while its thread isn't + * mounted (answered elsewhere, thread deleted), so evict the oldest entries to + * keep the persisted payload bounded. + */ +const MAX_RETAINED_REQUEST_DRAFTS = 50; + +export type PendingUserInputDraftAnswers = Record; + +/** + * Answers and cursor position for one request. Both live in a single record so + * eviction can never retain one half of a draft without the other. + */ +export interface PendingUserInputRequestDraft { + answers: PendingUserInputDraftAnswers; + questionIndex: number; +} + +export const EMPTY_PENDING_USER_INPUT_DRAFT_ANSWERS: PendingUserInputDraftAnswers = Object.freeze( + {}, +); + +const EMPTY_REQUEST_DRAFT: PendingUserInputRequestDraft = Object.freeze({ + answers: EMPTY_PENDING_USER_INPUT_DRAFT_ANSWERS, + questionIndex: 0, +}); + +interface PendingUserInputDraftStoreState { + draftsByRequestId: Record; + /** Applies `updater` to one question's draft answer within a request. */ + updateAnswer: ( + requestId: string, + questionId: string, + updater: ( + previous: PendingUserInputDraftAnswer | undefined, + ) => PendingUserInputDraftAnswer | undefined, + ) => void; + setQuestionIndex: (requestId: string, questionIndex: number) => void; + /** Drops a request's draft (called once the request stops being pending). */ + clearRequestDraft: (requestId: string) => void; +} + +function createPendingUserInputDraftStorage() { + return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); +} + +function removeRecordEntry(record: Record, key: string): Record { + if (record[key] === undefined) { + return record; + } + const { [key]: _removed, ...remaining } = record; + return remaining; +} + +function evictOldestRequestDrafts( + draftsByRequestId: Record, + keepRequestId: string, +): Record { + const keys = Object.keys(draftsByRequestId); + if (keys.length <= MAX_RETAINED_REQUEST_DRAFTS) { + return draftsByRequestId; + } + const evictCount = keys.length - MAX_RETAINED_REQUEST_DRAFTS; + const evicted = new Set(keys.filter((key) => key !== keepRequestId).slice(0, evictCount)); + if (evicted.size === 0) { + return draftsByRequestId; + } + const remaining: Record = {}; + for (const key of keys) { + if (evicted.has(key)) continue; + remaining[key] = draftsByRequestId[key] as PendingUserInputRequestDraft; + } + return remaining; +} + +function withRequestDraft( + state: PendingUserInputDraftStoreState, + requestId: string, + nextDraft: PendingUserInputRequestDraft, +): Pick { + return { + draftsByRequestId: evictOldestRequestDrafts( + { ...state.draftsByRequestId, [requestId]: nextDraft }, + requestId, + ), + }; +} + +export const usePendingUserInputDraftStore = create()( + persist( + (set) => ({ + draftsByRequestId: {}, + updateAnswer: (requestId, questionId, updater) => + set((state) => { + const draft = state.draftsByRequestId[requestId] ?? EMPTY_REQUEST_DRAFT; + const previousAnswer = draft.answers[questionId]; + const nextAnswer = updater(previousAnswer); + if (nextAnswer === previousAnswer) { + return state; + } + // Build via literal/rest rather than assignment: `record[key] = value` + // hits the prototype setter for a `"__proto__"` question id instead of + // creating an own property, which would drop the draft on serialize. + const answers: PendingUserInputDraftAnswers = + nextAnswer === undefined + ? removeRecordEntry(draft.answers, questionId) + : { ...draft.answers, [questionId]: nextAnswer }; + return withRequestDraft(state, requestId, { ...draft, answers }); + }), + setQuestionIndex: (requestId, questionIndex) => + set((state) => { + const draft = state.draftsByRequestId[requestId] ?? EMPTY_REQUEST_DRAFT; + const normalizedIndex = Math.max(0, Math.floor(questionIndex)); + if ( + draft.questionIndex === normalizedIndex && + state.draftsByRequestId[requestId] !== undefined + ) { + return state; + } + return withRequestDraft(state, requestId, { ...draft, questionIndex: normalizedIndex }); + }), + clearRequestDraft: (requestId) => + set((state) => { + const draftsByRequestId = removeRecordEntry(state.draftsByRequestId, requestId); + return draftsByRequestId === state.draftsByRequestId ? state : { draftsByRequestId }; + }), + }), + { + name: PENDING_USER_INPUT_DRAFT_STORAGE_KEY, + version: 1, + storage: createJSONStorage(createPendingUserInputDraftStorage), + partialize: (state) => ({ + draftsByRequestId: state.draftsByRequestId, + }), + }, + ), +); + +/** Draft answers for a request, or a stable empty record when there are none. */ +export function usePendingUserInputDraftAnswers( + requestId: string | null, +): PendingUserInputDraftAnswers { + return usePendingUserInputDraftStore((state) => + requestId + ? (state.draftsByRequestId[requestId]?.answers ?? EMPTY_PENDING_USER_INPUT_DRAFT_ANSWERS) + : EMPTY_PENDING_USER_INPUT_DRAFT_ANSWERS, + ); +} + +export function usePendingUserInputQuestionIndex(requestId: string | null): number { + return usePendingUserInputDraftStore((state) => + requestId ? (state.draftsByRequestId[requestId]?.questionIndex ?? 0) : 0, + ); +} + +export function clearPendingUserInputDraft(requestId: string): void { + usePendingUserInputDraftStore.getState().clearRequestDraft(requestId); +}