Skip to content

Commit ae6e187

Browse files
feat: prompt history (#260)
Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
1 parent 575f792 commit ae6e187

File tree

5 files changed

+509
-26
lines changed

5 files changed

+509
-26
lines changed

src/features/composer/components/Composer.tsx

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
normalizePastedText,
1818
} from "../../../utils/composerText";
1919
import { useComposerAutocompleteState } from "../hooks/useComposerAutocompleteState";
20+
import { usePromptHistory } from "../hooks/usePromptHistory";
2021
import { ComposerInput } from "./ComposerInput";
2122
import { ComposerMetaBar } from "./ComposerMetaBar";
2223
import { ComposerQueue } from "./ComposerQueue";
@@ -51,6 +52,7 @@ type ComposerProps = {
5152
sendLabel?: string;
5253
draftText?: string;
5354
onDraftChange?: (text: string) => void;
55+
historyKey?: string | null;
5456
attachedImages?: string[];
5557
onPickImages?: () => void;
5658
onAttachImages?: (paths: string[]) => void;
@@ -117,6 +119,7 @@ export function Composer({
117119
sendLabel = "Send",
118120
draftText = "",
119121
onDraftChange,
122+
historyKey = null,
120123
attachedImages = [],
121124
onPickImages,
122125
onAttachImages,
@@ -170,6 +173,51 @@ export function Composer({
170173
[onDraftChange],
171174
);
172175

176+
const {
177+
isAutocompleteOpen,
178+
autocompleteMatches,
179+
highlightIndex,
180+
setHighlightIndex,
181+
applyAutocomplete,
182+
handleInputKeyDown,
183+
handleTextChange,
184+
handleSelectionChange,
185+
} = useComposerAutocompleteState({
186+
text,
187+
selectionStart,
188+
disabled,
189+
skills,
190+
prompts,
191+
files,
192+
textareaRef,
193+
setText: setComposerText,
194+
setSelectionStart,
195+
});
196+
197+
const {
198+
handleHistoryKeyDown,
199+
handleHistoryTextChange,
200+
recordHistory,
201+
resetHistoryNavigation,
202+
} = usePromptHistory({
203+
historyKey,
204+
text,
205+
hasAttachments: attachedImages.length > 0,
206+
disabled,
207+
isAutocompleteOpen,
208+
textareaRef,
209+
setText: setComposerText,
210+
setSelectionStart,
211+
});
212+
213+
const handleTextChangeWithHistory = useCallback(
214+
(next: string, cursor: number | null) => {
215+
handleHistoryTextChange(next);
216+
handleTextChange(next, cursor);
217+
},
218+
[handleHistoryTextChange, handleTextChange],
219+
);
220+
173221
const handleSend = useCallback(() => {
174222
if (disabled) {
175223
return;
@@ -178,9 +226,21 @@ export function Composer({
178226
if (!trimmed && attachedImages.length === 0) {
179227
return;
180228
}
229+
if (trimmed) {
230+
recordHistory(trimmed);
231+
}
181232
onSend(trimmed, attachedImages);
233+
resetHistoryNavigation();
182234
setComposerText("");
183-
}, [attachedImages, disabled, onSend, setComposerText, text]);
235+
}, [
236+
attachedImages,
237+
disabled,
238+
onSend,
239+
recordHistory,
240+
resetHistoryNavigation,
241+
setComposerText,
242+
text,
243+
]);
184244

185245
const handleQueue = useCallback(() => {
186246
if (disabled) {
@@ -190,46 +250,39 @@ export function Composer({
190250
if (!trimmed && attachedImages.length === 0) {
191251
return;
192252
}
253+
if (trimmed) {
254+
recordHistory(trimmed);
255+
}
193256
onQueue(trimmed, attachedImages);
257+
resetHistoryNavigation();
194258
setComposerText("");
195-
}, [attachedImages, disabled, onQueue, setComposerText, text]);
196-
197-
const {
198-
isAutocompleteOpen,
199-
autocompleteMatches,
200-
highlightIndex,
201-
setHighlightIndex,
202-
applyAutocomplete,
203-
handleInputKeyDown,
204-
handleTextChange,
205-
handleSelectionChange,
206-
} = useComposerAutocompleteState({
207-
text,
208-
selectionStart,
259+
}, [
260+
attachedImages,
209261
disabled,
210-
skills,
211-
prompts,
212-
files,
213-
textareaRef,
214-
setText: setComposerText,
215-
setSelectionStart,
216-
});
262+
onQueue,
263+
recordHistory,
264+
resetHistoryNavigation,
265+
setComposerText,
266+
text,
267+
]);
217268

218269
useEffect(() => {
219270
if (!prefillDraft) {
220271
return;
221272
}
222273
setComposerText(prefillDraft.text);
274+
resetHistoryNavigation();
223275
onPrefillHandled?.(prefillDraft.id);
224-
}, [prefillDraft, onPrefillHandled, setComposerText]);
276+
}, [onPrefillHandled, prefillDraft, resetHistoryNavigation, setComposerText]);
225277

226278
useEffect(() => {
227279
if (!insertText) {
228280
return;
229281
}
230282
setComposerText(insertText.text);
283+
resetHistoryNavigation();
231284
onInsertHandled?.(insertText.id);
232-
}, [insertText, onInsertHandled, setComposerText]);
285+
}, [insertText, onInsertHandled, resetHistoryNavigation, setComposerText]);
233286

234287
useEffect(() => {
235288
if (!dictationTranscript) {
@@ -250,6 +303,7 @@ export function Composer({
250303
end,
251304
);
252305
setComposerText(nextText);
306+
resetHistoryNavigation();
253307
requestAnimationFrame(() => {
254308
if (!textareaRef.current) {
255309
return;
@@ -263,6 +317,7 @@ export function Composer({
263317
dictationTranscript,
264318
handleSelectionChange,
265319
onDictationTranscriptHandled,
320+
resetHistoryNavigation,
266321
selectionStart,
267322
setComposerText,
268323
text,
@@ -412,7 +467,7 @@ export function Composer({
412467
onAddAttachment={onPickImages}
413468
onAttachImages={onAttachImages}
414469
onRemoveAttachment={onRemoveImage}
415-
onTextChange={handleTextChange}
470+
onTextChange={handleTextChangeWithHistory}
416471
onSelectionChange={handleSelectionChange}
417472
onTextPaste={handleTextPaste}
418473
isExpanded={editorExpanded}
@@ -421,6 +476,10 @@ export function Composer({
421476
if (isComposingEvent(event)) {
422477
return;
423478
}
479+
handleHistoryKeyDown(event);
480+
if (event.defaultPrevented) {
481+
return;
482+
}
424483
if (
425484
expandFenceOnSpace &&
426485
event.key === " " &&
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// @vitest-environment jsdom
2+
import { act, renderHook } from "@testing-library/react";
3+
import { useRef, useState } from "react";
4+
import { describe, expect, it, vi } from "vitest";
5+
import { usePromptHistory } from "./usePromptHistory";
6+
7+
const STORAGE_PREFIX = "codexmonitor.promptHistory.";
8+
9+
function getStorageKey(key: string) {
10+
return `${STORAGE_PREFIX}${key}`;
11+
}
12+
13+
function createKeyEvent(key: "ArrowUp" | "ArrowDown") {
14+
let prevented = false;
15+
return {
16+
key,
17+
metaKey: false,
18+
ctrlKey: false,
19+
altKey: false,
20+
shiftKey: false,
21+
get defaultPrevented() {
22+
return prevented;
23+
},
24+
preventDefault() {
25+
prevented = true;
26+
},
27+
} as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
28+
}
29+
30+
describe("usePromptHistory", () => {
31+
it("stores and recalls history per workspace key", () => {
32+
globalThis.localStorage.clear();
33+
vi.useFakeTimers();
34+
const textarea = document.createElement("textarea");
35+
document.body.appendChild(textarea);
36+
37+
const { result, rerender, unmount } = renderHook(
38+
({ historyKey }) => {
39+
const [text, setText] = useState("");
40+
const [, setSelectionStart] = useState<number | null>(null);
41+
const textareaRef = useRef<HTMLTextAreaElement | null>(textarea);
42+
const history = usePromptHistory({
43+
historyKey,
44+
text,
45+
disabled: false,
46+
isAutocompleteOpen: false,
47+
textareaRef,
48+
setText,
49+
setSelectionStart,
50+
});
51+
return { text, ...history };
52+
},
53+
{ initialProps: { historyKey: "ws-1" } },
54+
);
55+
56+
act(() => {
57+
result.current.recordHistory("first prompt");
58+
});
59+
expect(globalThis.localStorage.getItem(getStorageKey("ws-1"))).toBe(
60+
JSON.stringify(["first prompt"]),
61+
);
62+
63+
rerender({ historyKey: "ws-2" });
64+
act(() => {
65+
result.current.recordHistory("second prompt");
66+
});
67+
expect(globalThis.localStorage.getItem(getStorageKey("ws-2"))).toBe(
68+
JSON.stringify(["second prompt"]),
69+
);
70+
expect(globalThis.localStorage.getItem(getStorageKey("ws-1"))).toBe(
71+
JSON.stringify(["first prompt"]),
72+
);
73+
74+
rerender({ historyKey: "ws-1" });
75+
act(() => {
76+
result.current.handleHistoryKeyDown(createKeyEvent("ArrowUp"));
77+
});
78+
act(() => {
79+
vi.runAllTimers();
80+
});
81+
expect(result.current.text).toBe("first prompt");
82+
83+
unmount();
84+
textarea.remove();
85+
vi.useRealTimers();
86+
});
87+
88+
it("does not clobber stored history when switching keys", () => {
89+
globalThis.localStorage.clear();
90+
globalThis.localStorage.setItem(
91+
getStorageKey("ws-a"),
92+
JSON.stringify(["alpha prompt"]),
93+
);
94+
globalThis.localStorage.setItem(
95+
getStorageKey("ws-b"),
96+
JSON.stringify(["beta prompt"]),
97+
);
98+
99+
const textarea = document.createElement("textarea");
100+
document.body.appendChild(textarea);
101+
102+
const { rerender, unmount } = renderHook(
103+
({ historyKey }) => {
104+
const [text, setText] = useState("");
105+
const [, setSelectionStart] = useState<number | null>(null);
106+
const textareaRef = useRef<HTMLTextAreaElement | null>(textarea);
107+
return usePromptHistory({
108+
historyKey,
109+
text,
110+
disabled: false,
111+
isAutocompleteOpen: false,
112+
textareaRef,
113+
setText,
114+
setSelectionStart,
115+
});
116+
},
117+
{ initialProps: { historyKey: "ws-a" } },
118+
);
119+
120+
rerender({ historyKey: "ws-b" });
121+
122+
expect(globalThis.localStorage.getItem(getStorageKey("ws-a"))).toBe(
123+
JSON.stringify(["alpha prompt"]),
124+
);
125+
expect(globalThis.localStorage.getItem(getStorageKey("ws-b"))).toBe(
126+
JSON.stringify(["beta prompt"]),
127+
);
128+
129+
unmount();
130+
textarea.remove();
131+
});
132+
});

0 commit comments

Comments
 (0)