diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index 0887039a..f0251e41 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -4,6 +4,7 @@ import { useUserVisibleModels } from 'state/useModels'; import { useChat, type UseChatType } from '../../../hooks/useChat'; import { useAttachments } from '../useAttachments'; import { useSlashCommands } from '../useSlashCommands'; +import { usePromptHistory } from './usePromptHistory'; type UseEmbeddableChatParams = { sessionId?: string; @@ -27,6 +28,10 @@ type UseEmbeddableChatParams = { export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) { const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params; + // Up/Down in the composer walk the last few prompts, shell-style. Per tab and shared by every chat in + // it — see usePromptHistory. + const history = usePromptHistory(); + const internalChat = useChat(params.sessionId, params.initialModel, { replaceUrl: params.replaceUrl ?? false, context: params.context, @@ -97,6 +102,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp const result = await slashCommands.execute(text); if (result.handled) { setCommandFeedback(result.feedback); + history.record(input); // a slash command is a prompt you sent — recallable like any other setInput(''); if (textareaRef.current) textareaRef.current.style.height = 'auto'; return; @@ -122,6 +128,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp ); attachmentManager.clearAttachments(); + history.record(input); // verbatim, not the trimmed prompt or the attachment-prefixed one setInput(''); userScrolledRef.current = false; if (textareaRef.current) textareaRef.current.style.height = 'auto'; @@ -153,6 +160,39 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp stopGeneration(); }; + /** + * Recall a previous prompt, the way a shell does. + * + * Up only reaches the history from the FIRST line and Down only from the last, so in a multi-line draft + * the arrows still move the caret — there is a line above to go to, and taking the key would strand the + * cursor. On the first line there is nowhere up to go, which is exactly when history is what you meant. + * `step` returning null (empty history, or already at the oldest) leaves the key alone too. + */ + const recallPrompt = (ev: KeyboardEvent, direction: -1 | 1): void => { + const field = ev.currentTarget; + const { selectionStart, selectionEnd, value } = field; + if (selectionStart !== selectionEnd) return; // a selection means the arrows are extending/collapsing it + const onEdge = + direction === -1 ? !value.slice(0, selectionStart).includes('\n') : !value.slice(selectionEnd).includes('\n'); + if (!onEdge) return; + + const next = history.step(direction, value); + if (next === null) return; + + ev.preventDefault(); + setInput(next); + // Caret to the end and the box resized, both after the value lands — a recalled prompt is usually + // taller than the one line the composer resets to, and a caret left at 0 would put the next + // keystroke in front of the text you just recalled. + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.setSelectionRange(next.length, next.length); + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }); + }; + const handleKeyDown = (ev: KeyboardEvent) => { if (ev.key === 'Escape') { // Handled here so a standalone InputArea still stops on Escape; stopped from bubbling so the @@ -161,6 +201,8 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp handleEscape(ev); return; } + if (ev.key === 'ArrowUp') return recallPrompt(ev, -1); + if (ev.key === 'ArrowDown') return recallPrompt(ev, 1); if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); handleSend(); diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/usePromptHistory.test.tsx b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/usePromptHistory.test.tsx new file mode 100644 index 00000000..c01f41ca --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/usePromptHistory.test.tsx @@ -0,0 +1,105 @@ +import type { ReactNode } from 'react'; +import { describe, expect, test, beforeEach } from 'bun:test'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import { usePromptHistory } from './usePromptHistory'; + +/** + * Shell-style prompt recall. The interesting state is two refs — where you are in the list, and the draft + * you were composing when you left it — and every bug this can have looks the same from outside: an arrow + * key that pastes the wrong thing over what you were typing. + */ + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const mount = () => renderHook(() => usePromptHistory(), { wrapper }).result; + +beforeEach(() => { + sessionStorage.clear(); +}); + +describe('walking back through what was sent', () => { + test('Up reaches the newest first, then older, and stops at the oldest', () => { + const { current } = mount(); + current.record('one'); + current.record('two'); + + expect(current.step(-1, '')).toBe('two'); + expect(current.step(-1, '')).toBe('one'); + // Null rather than sticking at 'one': the caller leaves the key alone, so it moves the caret instead + // of silently doing nothing. + expect(current.step(-1, '')).toBeNull(); + }); + + test('Down comes back, and hands over the draft that was interrupted', () => { + const { current } = mount(); + current.record('one'); + current.record('two'); + + expect(current.step(-1, 'half-typed')).toBe('two'); + expect(current.step(-1, 'half-typed')).toBe('one'); + expect(current.step(1, 'one')).toBe('two'); + // Past the newest is the composer you were in, not an empty box. Losing what you had typed to a key + // pressed to *get back to it* would be the worst version of this feature. + expect(current.step(1, 'two')).toBe('half-typed'); + }); + + test('Down while composing does nothing — it is only a caret move', () => { + const { current } = mount(); + current.record('one'); + + expect(current.step(1, 'typing')).toBeNull(); + }); + + test('an empty history never takes the key', () => { + const { current } = mount(); + + expect(current.step(-1, '')).toBeNull(); + expect(current.step(1, '')).toBeNull(); + }); +}); + +describe('what gets remembered', () => { + test('re-sending a prompt moves it to the front instead of holding it twice', () => { + const { current } = mount(); + current.record('one'); + current.record('two'); + current.record('one'); + + expect(current.step(-1, '')).toBe('one'); + expect(current.step(-1, '')).toBe('two'); + expect(current.step(-1, '')).toBeNull(); + }); + + test('it keeps ten, dropping the oldest', () => { + const { current } = mount(); + for (let i = 1; i <= 12; i++) current.record(`p${i}`); + + expect(current.step(-1, '')).toBe('p12'); + for (let i = 0; i < 9; i++) current.step(-1, ''); + expect(current.step(-1, '')).toBeNull(); // ten deep, so p2 and p1 are gone + }); + + test('blank sends are not remembered', () => { + const { current } = mount(); + current.record(' '); + + expect(current.step(-1, '')).toBeNull(); + }); + + test('sending resets the position, so the next Up starts from the newest again', () => { + const { current } = mount(); + current.record('one'); + current.record('two'); + current.step(-1, ''); + current.step(-1, ''); // sitting on 'one' + + current.record('three'); + + expect(current.step(-1, '')).toBe('three'); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/usePromptHistory.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/usePromptHistory.ts new file mode 100644 index 00000000..2daeeb55 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/usePromptHistory.ts @@ -0,0 +1,91 @@ +import { useCallback, useRef } from 'react'; +import { useSessionState } from 'hooks/useSessionState'; + +/** How many prompts back you can reach. A recall list, not an archive — the transcript is the archive. */ +const LIMIT = 10; + +/** + * Per TAB, and shared by every chat in it. + * + * Shell-like rather than conversation-like, deliberately: the prompt you most want to reach for is often + * the one you just sent somewhere else — re-asking in a fresh chat, or in the other panel. Scoping it per + * session would empty the history exactly when a new chat makes it most useful. + * + * sessionStorage rather than memory, so a reload does not drop it, and rather than localStorage, so two + * windows keep their own — the same split `useSessionState` exists for. + */ +const HISTORY_KEY = 'CHAT_PROMPT_HISTORY'; + +export type PromptHistory = { + /** Remember a prompt that was just sent, and leave navigation at the newest end. */ + record: (text: string) => void; + /** + * Step back (`-1`) or forward (`+1`), given what is in the composer now. Returns the text to show, or + * `null` when there is nowhere to go — the caller leaves the composer untouched and lets the key do + * whatever it normally does. + */ + step: (direction: -1 | 1, current: string) => string | null; +}; + +export function usePromptHistory(): PromptHistory { + // Newest first: index 0 is the last thing sent, which is what the first press of Up should reach. + const [entries, setEntries] = useSessionState(HISTORY_KEY, []); + + // Refs, not state: stepping must read the position set by the press before it, and a re-render in + // between is not guaranteed. Nothing renders from either. + // + // `null` means "composing" — not in the history. That is distinct from index 0, which is the newest + // entry, and the difference is what lets Down at the newest end return to the draft. + const indexRef = useRef(null); + const draftRef = useRef(''); + + // The ref is what `step` reads, and `record` writes it BEFORE the state — so a prompt sent and recalled + // in the same tick, with no render in between, is already there. Reading the state alone made that + // sequence walk the list as it was one prompt ago. The render-time assignment is still what carries a + // sibling panel's sends into this instance, since they arrive as a new `entries` and nothing else. + const entriesRef = useRef(entries); + entriesRef.current = entries; + + const record = useCallback( + (text: string) => { + indexRef.current = null; + draftRef.current = ''; + if (!text.trim()) return; + // Drop an identical earlier copy rather than keeping both: re-sending the same prompt is common, and + // a history holding it twice costs a slot and two presses to get past. + const deduped = entriesRef.current.filter((entry) => entry !== text); + const next = [text, ...deduped].slice(0, LIMIT); + entriesRef.current = next; + setEntries(next); + }, + [setEntries], + ); + + const step = useCallback((direction: -1 | 1, current: string): string | null => { + const list = entriesRef.current; + if (list.length === 0) return null; + const at = indexRef.current; + + if (direction === -1) { + // Entering the history stashes whatever was being composed, so Down can hand it back. + if (at === null) { + draftRef.current = current; + indexRef.current = 0; + return list[0] ?? null; + } + if (at >= list.length - 1) return null; // already at the oldest — let the key move the caret + indexRef.current = at + 1; + return list[at + 1] ?? null; + } + + if (at === null) return null; // not in the history; Down is just a caret move + if (at === 0) { + indexRef.current = null; + return draftRef.current; + } + indexRef.current = at - 1; + return list[at - 1] ?? null; + }, []); + + return { record, step }; +}