Skip to content

Commit 7ff232c

Browse files
authored
Address review: fix step-nav race, avoid empty drafts
- Report the current-step draft imperatively from the edit handlers (typing/toggling) instead of a state effect. The effect keyed on internalStep could fire with the previous step's selections before restoreStepAnswer corrected them, briefly persisting wrong step data. - Only persist a draft while it holds real content, and clear it otherwise, so untouched questions (or ones the agent removes without a submit/cancel) don't leave empty entries accumulating in storage. - Tighten the test tool-call mock to `satisfies PermissionToolCall` and add a test that an untouched question leaves no draft. Generated-By: PostHog Code Task-Id: 624a18e6-9eac-4d18-8bd4-62491e117547
1 parent da2c049 commit 7ff232c

3 files changed

Lines changed: 71 additions & 55 deletions

File tree

packages/ui/src/features/permissions/QuestionPermission.test.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function questionToolCall(toolCallId: string): PermissionToolCall {
2121
},
2222
],
2323
},
24-
} as unknown as PermissionToolCall;
24+
} satisfies PermissionToolCall;
2525
}
2626

2727
const options: PermissionOption[] = [];
@@ -105,4 +105,11 @@ describe("QuestionPermission draft persistence", () => {
105105
expect(onSelect).toHaveBeenCalled();
106106
expect(useQuestionDraftStore.getState().actions.getDraft("q-1")).toBeNull();
107107
});
108+
109+
it("does not persist a draft for an untouched question", async () => {
110+
const { view } = renderQuestion(questionToolCall("q-1"));
111+
// Mounting alone must not leave an empty draft behind on unmount.
112+
view.unmount();
113+
expect(useQuestionDraftStore.getState().actions.getDraft("q-1")).toBeNull();
114+
});
108115
});

packages/ui/src/features/permissions/QuestionPermission.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,23 @@ export function QuestionPermission({
123123
},
124124
);
125125

126-
// Persist the draft on every change; cleared when the question is resolved.
126+
// Persist the draft on every change, but only while it holds real content, so
127+
// questions the user never answers (or that the agent later removes without a
128+
// submit/cancel) don't leave empty entries accumulating in storage.
127129
useEffect(() => {
128-
setDraft(questionId, {
129-
activeStep,
130-
stepAnswers: Object.fromEntries(stepAnswers),
131-
});
132-
}, [questionId, activeStep, stepAnswers, setDraft]);
130+
const hasContent = Array.from(stepAnswers.values()).some(
131+
(answer) =>
132+
answer.selectedIds.length > 0 || answer.customInput.trim() !== "",
133+
);
134+
if (hasContent) {
135+
setDraft(questionId, {
136+
activeStep,
137+
stepAnswers: Object.fromEntries(stepAnswers),
138+
});
139+
} else {
140+
clearDraft(questionId);
141+
}
142+
}, [questionId, activeStep, stepAnswers, setDraft, clearDraft]);
133143

134144
// Snapshot of persisted answers used to seed the selector's per-step state on
135145
// mount, so restored values are shown when navigating between steps.

packages/ui/src/primitives/action-selector/useActionSelectorState.ts

Lines changed: 47 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,23 @@ export function useActionSelectorState({
9797
const numSteps = steps?.length ?? 0;
9898
const showSubmitButton = !hideSubmitButton && (multiSelect || hasSteps);
9999

100+
// Refs let the edit handlers report the current draft imperatively without
101+
// re-subscribing on every keystroke.
102+
const activeStepRef = useRef(activeStep);
103+
activeStepRef.current = activeStep;
104+
const checkedOptionsRef = useRef(checkedOptions);
105+
checkedOptionsRef.current = checkedOptions;
106+
const onDraftChangeRef = useRef(onDraftChange);
107+
onDraftChangeRef.current = onDraftChange;
108+
109+
const reportDraft = useCallback((checked: Set<string>, input: string) => {
110+
onDraftChangeRef.current?.(
111+
activeStepRef.current,
112+
Array.from(checked),
113+
input,
114+
);
115+
}, []);
116+
100117
const allOptions = useMemo(() => {
101118
const opts = allowCustomInput
102119
? [...options, { id: OTHER_OPTION_ID, label: "Other", description: "" }]
@@ -206,24 +223,6 @@ export function useActionSelectorState({
206223
}
207224
}, [initialCustomInput, allOptions]);
208225

209-
// Report every edit to the current step's draft so callers can persist it.
210-
// A ref skips the initial mount, so restoring a draft does not immediately
211-
// echo it back.
212-
const onDraftChangeRef = useRef(onDraftChange);
213-
onDraftChangeRef.current = onDraftChange;
214-
const draftReportMountedRef = useRef(false);
215-
useEffect(() => {
216-
if (!draftReportMountedRef.current) {
217-
draftReportMountedRef.current = true;
218-
return;
219-
}
220-
onDraftChangeRef.current?.(
221-
internalStep,
222-
Array.from(checkedOptions),
223-
customInput,
224-
);
225-
}, [checkedOptions, customInput, internalStep]);
226-
227226
const moveUp = useCallback(() => {
228227
setHoveredIndex(null);
229228
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : numOptions - 1));
@@ -250,26 +249,25 @@ export function useActionSelectorState({
250249

251250
const toggleCheck = useCallback(
252251
(optionId: string) => {
253-
setCheckedOptions((prev) => {
254-
const next = new Set(prev);
255-
if (multiSelect) {
256-
if (next.has(optionId)) {
257-
next.delete(optionId);
258-
} else {
259-
next.add(optionId);
260-
}
252+
const next = new Set(checkedOptionsRef.current);
253+
if (multiSelect) {
254+
if (next.has(optionId)) {
255+
next.delete(optionId);
261256
} else {
262-
if (next.has(optionId)) {
263-
next.clear();
264-
} else {
265-
next.clear();
266-
next.add(optionId);
267-
}
257+
next.add(optionId);
268258
}
269-
return next;
270-
});
259+
} else {
260+
if (next.has(optionId)) {
261+
next.clear();
262+
} else {
263+
next.clear();
264+
next.add(optionId);
265+
}
266+
}
267+
setCheckedOptions(next);
268+
reportDraft(next, customInputRef.current);
271269
},
272-
[multiSelect],
270+
[multiSelect, reportDraft],
273271
);
274272

275273
const handleSubmitMulti = useCallback(() => {
@@ -475,28 +473,29 @@ export function useActionSelectorState({
475473
const handleCustomInputChange = useCallback(
476474
(value: string) => {
477475
setCustomInput(value);
476+
let nextChecked = checkedOptionsRef.current;
478477
if (
479478
showSubmitButton &&
480479
selectedOption &&
481480
needsCustomInput(selectedOption)
482481
) {
483-
setCheckedOptions((prev) => {
484-
const next = new Set(prev);
485-
if (value.trim()) {
486-
if (!prev.has(selectedOption.id)) {
487-
if (!multiSelect) {
488-
next.clear();
489-
}
490-
next.add(selectedOption.id);
482+
const next = new Set(checkedOptionsRef.current);
483+
if (value.trim()) {
484+
if (!next.has(selectedOption.id)) {
485+
if (!multiSelect) {
486+
next.clear();
491487
}
492-
} else {
493-
next.delete(selectedOption.id);
488+
next.add(selectedOption.id);
494489
}
495-
return next;
496-
});
490+
} else {
491+
next.delete(selectedOption.id);
492+
}
493+
nextChecked = next;
494+
setCheckedOptions(next);
497495
}
496+
reportDraft(nextChecked, value);
498497
},
499-
[showSubmitButton, selectedOption, multiSelect],
498+
[showSubmitButton, selectedOption, multiSelect, reportDraft],
500499
);
501500

502501
const ensureChecked = useCallback((optionId: string) => {

0 commit comments

Comments
 (0)