Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 54 additions & 55 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, PendingUserInputDraftAnswer> = {};
const EMPTY_REQUEST_ID_SET: ReadonlySet<string> = new Set<string>();
function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
const transitionGroupRef = useRef<HTMLDivElement | null>(null);
const composerAnchorRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -1259,11 +1264,12 @@ function ChatViewContent(props: ChatViewProps) {
const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
ApprovalRequestId[]
>([]);
const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState<
Record<string, Record<string, PendingUserInputDraftAnswer>>
>({});
const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
useState<Record<string, number>>({});
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<string | null>(null);
Expand Down Expand Up @@ -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<ReadonlySet<string>>(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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread switch clears other drafts

High Severity

The pending-input cleanup effect compares the previous pendingUserInputs snapshot to the current thread’s list, but seenPendingUserInputRequestIdsRef is not reset when routeThreadKey / threadId changes. Navigating to another thread (same mounted ChatView) treats the prior thread’s pending requestIds as removed and calls clearPendingUserInputDraft, wiping persisted drafts while those requests are still open on the original thread.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 753fff0. Configure here.

const activePendingDraftAnswers = usePendingUserInputDraftAnswers(
activePendingUserInput?.requestId ?? null,
);
const activePendingQuestionIndex = usePendingUserInputQuestionIndex(
activePendingUserInput?.requestId ?? null,
);
const activePendingQuestionIndex = activePendingUserInput
? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0)
: 0;
const activePendingProgress = useMemo(
() =>
activePendingUserInput
Expand Down Expand Up @@ -4903,45 +4918,36 @@ function ChatViewContent(props: ChatViewProps) {
if (!activePendingUserInput) {
return;
}
setPendingUserInputQuestionIndexByRequestId((existing) => ({
...existing,
[activePendingUserInput.requestId]: nextQuestionIndex,
}));
setPendingUserInputDraftQuestionIndex(activePendingUserInput.requestId, nextQuestionIndex);
},
[activePendingUserInput],
[activePendingUserInput, setPendingUserInputDraftQuestionIndex],
);

const onSelectActivePendingUserInputOption = useCallback(
(questionId: string, optionLabel: string) => {
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(
Expand All @@ -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 ||
Expand All @@ -4975,7 +4974,7 @@ function ChatViewContent(props: ChatViewProps) {
composerRef.current?.focusAt(nextCursor);
}
},
[activePendingUserInput, composerRef],
[activePendingUserInput, composerRef, updatePendingUserInputDraftAnswer],
);

const onAdvanceActivePendingUserInput = useCallback(() => {
Expand Down
115 changes: 115 additions & 0 deletions apps/web/src/pendingUserInputDraftStore.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading
Loading