| author | |
| committer | |
| log | fd773172e7f26f2d98de3eb572f3317b0ad8d443 |
| tree | c3b411f7d1c27c5777cbfaef9786284bf859bc7d |
| parent | df8e0eb46bca3dc01c17cc74a53b19aaacbe252b |
| signature | Signed by PGP key B5690EEEBB952194 |
Press ArrowUp in an empty composer to get the last prompt sent in this thread. ArrowUp again goes further back, ArrowDown comes forward, and going past the newest prompt clears the composer.
History is per thread and derived from the thread's user messages on the keypress, so nothing new is stored or synced. Send-time appends (terminal and element context, preview annotations, review comments, the ultrathink prefix) and app-composed sends are stripped. With text in the composer, arrows move the caret unless the caret is on the first or last visual line of an unedited recalled prompt. Modifiers, IME composition, menus, approvals, and pending questions take priority. Attachments are not restored.
Prior art: #1778 by @PratyushChauhan, #4336 by @mfazekas, #7952 by @sethwebster.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>7 files changed, 641 insertions(+), 4 deletions(-)
apps/web/src/components/ChatView.tsx+2-2| ... | @@ -445,9 +445,8 @@ import { | ... | @@ -445,9 +445,8 @@ import { |
| 445 | supportsServerUpdateThreadContinuation, | 445 | supportsServerUpdateThreadContinuation, |
| 446 | } from "../versionSkew"; | 446 | } from "../versionSkew"; |
| 447 | import { useAssetUrls } from "../assets/assetUrls"; | 447 | import { useAssetUrls } from "../assets/assetUrls"; |
| 448 | import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; | ||
| 448 | 449 | ||
| 449 | const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = | ||
| 450 | "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; | ||
| 451 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | 450 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 452 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 451 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 453 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; | 452 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| ... | @@ -7931,6 +7930,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7931,6 +7930,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7931 | activeThreadId={activeThreadId} | 7930 | activeThreadId={activeThreadId} |
| 7932 | activeThreadEnvironmentId={activeThread?.environmentId} | 7931 | activeThreadEnvironmentId={activeThread?.environmentId} |
| 7933 | activeThread={activeThread} | 7932 | activeThread={activeThread} |
| 7933 | promptHistoryMessages={timelineMessages} | ||
| 7934 | isServerThread={isServerThread} | 7934 | isServerThread={isServerThread} |
| 7935 | isLocalDraftThread={isLocalDraftThread} | 7935 | isLocalDraftThread={isLocalDraftThread} |
| 7936 | forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} | 7936 | forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} |
apps/web/src/components/ComposerPromptEditor.tsx+97| ... | @@ -896,6 +896,13 @@ export interface ComposerPromptEditorHandle { | ... | @@ -896,6 +896,13 @@ export interface ComposerPromptEditorHandle { |
| 896 | expandedCursor: number; | 896 | expandedCursor: number; |
| 897 | terminalContextIds: string[]; | 897 | terminalContextIds: string[]; |
| 898 | }; | 898 | }; |
| 899 | /** | ||
| 900 | * True when a collapsed caret sits on the first ("start") or last ("end") | ||
| 901 | * visual line, counting soft wraps. Prompt history only claims ArrowUp and | ||
| 902 | * ArrowDown at these edges so arrows still move the caret inside multiline | ||
| 903 | * text. | ||
| 904 | */ | ||
| 905 | isCaretOnVisualEdge: (edge: "start" | "end") => boolean; | ||
| 899 | } | 906 | } |
| 900 | 907 | ||
| 901 | interface ComposerPromptEditorProps { | 908 | interface ComposerPromptEditorProps { |
| ... | @@ -929,6 +936,66 @@ interface ComposerPromptEditorProps { | ... | @@ -929,6 +936,66 @@ interface ComposerPromptEditorProps { |
| 929 | editorRef: React.RefObject<ComposerPromptEditorHandle | null>; | 936 | editorRef: React.RefObject<ComposerPromptEditorHandle | null>; |
| 930 | } | 937 | } |
| 931 | 938 | ||
| 939 | /** | ||
| 940 | * Client rect of the line the collapsed caret is on, as seen from `edge`. | ||
| 941 | * A caret at a soft-wrap boundary belongs to two visual lines and the | ||
| 942 | * range reports a rect for each, so take the one farthest from the edge | ||
| 943 | * under test: an ambiguous caret then never claims the key and the arrow | ||
| 944 | * moves the caret as usual. A collapsed range reports zero-height rects at | ||
| 945 | * some positions, so probe the adjacent character on the same side. When | ||
| 946 | * the range container is the paragraph itself (an empty line, or a caret | ||
| 947 | * beside an inline chip) measure the child next to the caret before | ||
| 948 | * falling back to the paragraph. | ||
| 949 | */ | ||
| 950 | function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { | ||
| 951 | const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); | ||
| 952 | const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; | ||
| 953 | if (collapsedRect) return collapsedRect; | ||
| 954 | |||
| 955 | const container = range.startContainer; | ||
| 956 | if (container.nodeType === Node.TEXT_NODE) { | ||
| 957 | const textNode = container as Text; | ||
| 958 | if (textNode.data.length === 0) return null; | ||
| 959 | const probeStart = Math.max( | ||
| 960 | 0, | ||
| 961 | Math.min( | ||
| 962 | edge === "start" ? range.startOffset : range.startOffset - 1, | ||
| 963 | textNode.data.length - 1, | ||
| 964 | ), | ||
| 965 | ); | ||
| 966 | const probeRange = document.createRange(); | ||
| 967 | probeRange.setStart(textNode, probeStart); | ||
| 968 | probeRange.setEnd(textNode, probeStart + 1); | ||
| 969 | const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); | ||
| 970 | if (probeRect) return probeRect; | ||
| 971 | const boundingRect = probeRange.getBoundingClientRect(); | ||
| 972 | return boundingRect.height > 0 ? boundingRect : null; | ||
| 973 | } | ||
| 974 | |||
| 975 | if (!(container instanceof HTMLElement)) return null; | ||
| 976 | // The caret sits between the paragraph's children, which is where Lexical | ||
| 977 | // puts it next to an inline chip. Measure the neighbouring child. | ||
| 978 | const neighbour = | ||
| 979 | container.childNodes[Math.max(0, range.startOffset - 1)] ?? | ||
| 980 | container.childNodes[range.startOffset]; | ||
| 981 | if (neighbour instanceof HTMLElement) { | ||
| 982 | const neighbourRect = neighbour.getBoundingClientRect(); | ||
| 983 | if (neighbourRect.height > 0) return neighbourRect; | ||
| 984 | } else if (neighbour instanceof Text && neighbour.data.length > 0) { | ||
| 985 | // Probe the character on the caret's side. A soft-wrapped text node's | ||
| 986 | // first rect is its first visual line, which may not be the caret's. | ||
| 987 | const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; | ||
| 988 | const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; | ||
| 989 | const probeRange = document.createRange(); | ||
| 990 | probeRange.setStart(neighbour, probeStart); | ||
| 991 | probeRange.setEnd(neighbour, probeStart + 1); | ||
| 992 | const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); | ||
| 993 | if (probeRect) return probeRect; | ||
| 994 | } | ||
| 995 | const containerRect = container.getBoundingClientRect(); | ||
| 996 | return containerRect.height > 0 ? containerRect : null; | ||
| 997 | } | ||
| 998 | |||
| 932 | function ComposerCommandKeyPlugin(props: { | 999 | function ComposerCommandKeyPlugin(props: { |
| 933 | onCommandKeyDown?: ( | 1000 | onCommandKeyDown?: ( |
| 934 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | 1001 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", |
| ... | @@ -1799,6 +1866,36 @@ function ComposerPromptEditorInner({ | ... | @@ -1799,6 +1866,36 @@ function ComposerPromptEditorInner({ |
| 1799 | if (target) setOpenCitationComment(target); | 1866 | if (target) setOpenCitationComment(target); |
| 1800 | }, | 1867 | }, |
| 1801 | readSnapshot, | 1868 | readSnapshot, |
| 1869 | isCaretOnVisualEdge: (edge) => { | ||
| 1870 | const snapshot = readSnapshot(); | ||
| 1871 | if (snapshot.value.length === 0) return true; | ||
| 1872 | const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); | ||
| 1873 | const afterCaret = snapshot.value.slice(snapshot.expandedCursor); | ||
| 1874 | if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { | ||
| 1875 | return false; | ||
| 1876 | } | ||
| 1877 | const rootElement = editor.getRootElement(); | ||
| 1878 | const selection = window.getSelection(); | ||
| 1879 | if ( | ||
| 1880 | !rootElement || | ||
| 1881 | !selection || | ||
| 1882 | !selection.isCollapsed || | ||
| 1883 | selection.rangeCount === 0 || | ||
| 1884 | !selection.anchorNode || | ||
| 1885 | !rootElement.contains(selection.anchorNode) | ||
| 1886 | ) { | ||
| 1887 | return false; | ||
| 1888 | } | ||
| 1889 | const caretRect = caretLineRect(selection.getRangeAt(0), edge); | ||
| 1890 | if (!caretRect) return false; | ||
| 1891 | const edgeElement = | ||
| 1892 | edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; | ||
| 1893 | const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); | ||
| 1894 | const threshold = caretRect.height / 2; | ||
| 1895 | return edge === "start" | ||
| 1896 | ? caretRect.top - edgeRect.top < threshold | ||
| 1897 | : edgeRect.bottom - caretRect.bottom < threshold; | ||
| 1898 | }, | ||
| 1802 | }), | 1899 | }), |
| 1803 | [editor, focusAt, readSnapshot], | 1900 | [editor, focusAt, readSnapshot], |
| 1804 | ); | 1901 | ); |
apps/web/src/components/chat/ChatComposer.tsx+99-1| ... | @@ -803,7 +803,12 @@ import { | ... | @@ -803,7 +803,12 @@ import { |
| 803 | } from "../../providerInstances"; | 803 | } from "../../providerInstances"; |
| 804 | import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; | 804 | import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; |
| 805 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; | 805 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 806 | import { type SessionPhase, type Thread, videoMimeType } from "../../types"; | 806 | import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types"; |
| 807 | import { | ||
| 808 | buildComposerPromptHistoryEntries, | ||
| 809 | stepComposerPromptHistory, | ||
| 810 | type ComposerPromptHistoryPosition, | ||
| 811 | } from "./composerPromptHistory"; | ||
| 807 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; | 812 | import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; |
| 808 | import type { PendingApproval, PendingUserInput } from "../../session-logic"; | 813 | import type { PendingApproval, PendingUserInput } from "../../session-logic"; |
| 809 | import type { ContextWindowSnapshot } from "../../lib/contextWindow"; | 814 | import type { ContextWindowSnapshot } from "../../lib/contextWindow"; |
| ... | @@ -1172,6 +1177,8 @@ export interface ChatComposerProps { | ... | @@ -1172,6 +1177,8 @@ export interface ChatComposerProps { |
| 1172 | activeThreadId: ThreadId | null; | 1177 | activeThreadId: ThreadId | null; |
| 1173 | activeThreadEnvironmentId: EnvironmentId | undefined; | 1178 | activeThreadEnvironmentId: EnvironmentId | undefined; |
| 1174 | activeThread: Thread | undefined; | 1179 | activeThread: Thread | undefined; |
| 1180 | /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */ | ||
| 1181 | promptHistoryMessages: ReadonlyArray<ChatMessage>; | ||
| 1175 | isServerThread: boolean; | 1182 | isServerThread: boolean; |
| 1176 | isLocalDraftThread: boolean; | 1183 | isLocalDraftThread: boolean; |
| 1177 | forceExpandedOnMobile: boolean; | 1184 | forceExpandedOnMobile: boolean; |
| ... | @@ -1312,6 +1319,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1312,6 +1319,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1312 | activeThreadId, | 1319 | activeThreadId, |
| 1313 | activeThreadEnvironmentId: _activeThreadEnvironmentId, | 1320 | activeThreadEnvironmentId: _activeThreadEnvironmentId, |
| 1314 | activeThread, | 1321 | activeThread, |
| 1322 | promptHistoryMessages, | ||
| 1315 | isServerThread: _isServerThread, | 1323 | isServerThread: _isServerThread, |
| 1316 | isLocalDraftThread: _isLocalDraftThread, | 1324 | isLocalDraftThread: _isLocalDraftThread, |
| 1317 | forceExpandedOnMobile, | 1325 | forceExpandedOnMobile, |
| ... | @@ -1781,6 +1789,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1781,6 +1789,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1781 | detectComposerTrigger(prompt, prompt.length), | 1789 | detectComposerTrigger(prompt, prompt.length), |
| 1782 | ); | 1790 | ); |
| 1783 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | 1791 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 1792 | // Active ArrowUp recall. Cleared on edit and on thread switch. | ||
| 1793 | const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null); | ||
| 1784 | const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>( | 1794 | const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>( |
| 1785 | null, | 1795 | null, |
| 1786 | ); | 1796 | ); |
| ... | @@ -2536,6 +2546,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2536,6 +2546,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2536 | } | 2546 | } |
| 2537 | promptRef.current = nextPrompt; | 2547 | promptRef.current = nextPrompt; |
| 2538 | setPrompt(nextPrompt); | 2548 | setPrompt(nextPrompt); |
| 2549 | // Any edit ends browsing, even one later undone by hand: typing a | ||
| 2550 | // character and deleting it leaves the text equal to the recall, and | ||
| 2551 | // ArrowDown must move the caret then, not clear the composer. | ||
| 2552 | if (promptHistoryPositionRef.current?.recalled !== nextPrompt) { | ||
| 2553 | promptHistoryPositionRef.current = null; | ||
| 2554 | } | ||
| 2539 | if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { | 2555 | if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { |
| 2540 | setComposerDraftTerminalContexts( | 2556 | setComposerDraftTerminalContexts( |
| 2541 | composerDraftTarget, | 2557 | composerDraftTarget, |
| ... | @@ -2957,6 +2973,85 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2957,6 +2973,85 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2957 | }); | 2973 | }); |
| 2958 | }, [setIsComposerFocused]); | 2974 | }, [setIsComposerFocused]); |
| 2959 | 2975 | ||
| 2976 | // ------------------------------------------------------------------ | ||
| 2977 | // Prompt history (ArrowUp / ArrowDown) | ||
| 2978 | // ------------------------------------------------------------------ | ||
| 2979 | // Entries are built on the keypress, not per render: the timeline changes | ||
| 2980 | // on every streamed delta and ArrowUp is rare. | ||
| 2981 | const promptHistoryMessagesRef = useRef(promptHistoryMessages); | ||
| 2982 | promptHistoryMessagesRef.current = promptHistoryMessages; | ||
| 2983 | |||
| 2984 | // The composer persists across threads. A recall from thread A must not | ||
| 2985 | // be treated as active in thread B, where the text-match fallback could | ||
| 2986 | // otherwise turn B's own draft into a browsing position. | ||
| 2987 | const promptHistoryTargetKey = composerTargetKey(composerDraftTarget); | ||
| 2988 | useEffect(() => { | ||
| 2989 | promptHistoryPositionRef.current = null; | ||
| 2990 | }, [promptHistoryTargetKey]); | ||
| 2991 | |||
| 2992 | const replacePromptFromHistory = useCallback( | ||
| 2993 | (nextPrompt: string) => { | ||
| 2994 | promptRef.current = nextPrompt; | ||
| 2995 | setComposerDraftPrompt(composerDraftTarget, nextPrompt); | ||
| 2996 | setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); | ||
| 2997 | setComposerTrigger(null); | ||
| 2998 | setComposerHighlightedItemId(null); | ||
| 2999 | }, | ||
| 3000 | [composerDraftTarget, promptRef, setComposerDraftPrompt], | ||
| 3001 | ); | ||
| 3002 | |||
| 3003 | const navigatePromptHistory = useCallback( | ||
| 3004 | (direction: "backward" | "forward", event: KeyboardEvent): boolean => { | ||
| 3005 | if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { | ||
| 3006 | return false; | ||
| 3007 | } | ||
| 3008 | if (isComposerApprovalState || pendingUserInputs.length > 0) return false; | ||
| 3009 | // A composer holding an image, file, picked element, preview | ||
| 3010 | // annotation, or review comment is not empty. Recalling text into it | ||
| 3011 | // would send the old prompt with the new context, which is never what | ||
| 3012 | // ArrowUp meant. | ||
| 3013 | if ( | ||
| 3014 | composerImagesRef.current.length > 0 || | ||
| 3015 | composerFilesRef.current.length > 0 || | ||
| 3016 | composerElementContextsRef.current.length > 0 || | ||
| 3017 | composerPreviewAnnotations.length > 0 || | ||
| 3018 | composerReviewComments.length > 0 | ||
| 3019 | ) { | ||
| 3020 | return false; | ||
| 3021 | } | ||
| 3022 | // A typed draft with no active recall can never step, so skip the | ||
| 3023 | // layout read and the entry build for that common case. | ||
| 3024 | if (promptHistoryPositionRef.current === null && promptRef.current.length > 0) { | ||
| 3025 | return false; | ||
| 3026 | } | ||
| 3027 | const editor = composerEditorRef.current; | ||
| 3028 | if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { | ||
| 3029 | return false; | ||
| 3030 | } | ||
| 3031 | const step = stepComposerPromptHistory({ | ||
| 3032 | direction, | ||
| 3033 | entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), | ||
| 3034 | position: promptHistoryPositionRef.current, | ||
| 3035 | currentPrompt: promptRef.current, | ||
| 3036 | }); | ||
| 3037 | if (!step) return false; | ||
| 3038 | promptHistoryPositionRef.current = step.position; | ||
| 3039 | replacePromptFromHistory(step.prompt); | ||
| 3040 | return true; | ||
| 3041 | }, | ||
| 3042 | [ | ||
| 3043 | composerElementContextsRef, | ||
| 3044 | composerFilesRef, | ||
| 3045 | composerImagesRef, | ||
| 3046 | composerPreviewAnnotations.length, | ||
| 3047 | composerReviewComments.length, | ||
| 3048 | isComposerApprovalState, | ||
| 3049 | pendingUserInputs.length, | ||
| 3050 | promptRef, | ||
| 3051 | replacePromptFromHistory, | ||
| 3052 | ], | ||
| 3053 | ); | ||
| 3054 | |||
| 2960 | // ------------------------------------------------------------------ | 3055 | // ------------------------------------------------------------------ |
| 2961 | // Callbacks: command key | 3056 | // Callbacks: command key |
| 2962 | // ------------------------------------------------------------------ | 3057 | // ------------------------------------------------------------------ |
| ... | @@ -2987,6 +3082,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2987,6 +3082,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2987 | return true; | 3082 | return true; |
| 2988 | } | 3083 | } |
| 2989 | } | 3084 | } |
| 3085 | if (key === "ArrowUp" || key === "ArrowDown") { | ||
| 3086 | return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); | ||
| 3087 | } | ||
| 2990 | const submissionIntent = | 3088 | const submissionIntent = |
| 2991 | key === "Enter" | 3089 | key === "Enter" |
| 2992 | ? composerSubmissionIntentForEnter({ | 3090 | ? composerSubmissionIntentForEnter({ |
apps/web/src/components/chat/composerPromptHistory.test.ts created+214| ... | @@ -0,0 +1,214 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { appendElementContextsToPrompt } from "../../lib/elementContext"; | ||
| 4 | import { | ||
| 5 | appendTerminalContextsToPrompt, | ||
| 6 | materializeInlineTerminalContextPrompt, | ||
| 7 | } from "../../lib/terminalContext"; | ||
| 8 | import { appendReviewCommentsToPrompt, buildFileReviewComment } from "../../reviewCommentContext"; | ||
| 9 | import { buildPlanImplementationPrompt } from "../../proposedPlan"; | ||
| 10 | import { | ||
| 11 | ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, | ||
| 12 | buildComposerPromptHistoryEntries, | ||
| 13 | recallableComposerPrompt, | ||
| 14 | stepComposerPromptHistory, | ||
| 15 | type ComposerPromptHistoryPosition, | ||
| 16 | } from "./composerPromptHistory"; | ||
| 17 | |||
| 18 | const entries = buildComposerPromptHistoryEntries([ | ||
| 19 | { id: "m1", role: "user", text: "first" }, | ||
| 20 | { id: "a1", role: "assistant", text: "reply" }, | ||
| 21 | { id: "m2", role: "user", text: "second" }, | ||
| 22 | { id: "m3", role: "user", text: "third" }, | ||
| 23 | ]); | ||
| 24 | |||
| 25 | function backward(position: ComposerPromptHistoryPosition | null, currentPrompt: string) { | ||
| 26 | return stepComposerPromptHistory({ direction: "backward", entries, position, currentPrompt }); | ||
| 27 | } | ||
| 28 | |||
| 29 | function forward(position: ComposerPromptHistoryPosition | null, currentPrompt: string) { | ||
| 30 | return stepComposerPromptHistory({ direction: "forward", entries, position, currentPrompt }); | ||
| 31 | } | ||
| 32 | |||
| 33 | describe("recallableComposerPrompt", () => { | ||
| 34 | it("strips send-time context blocks and the ultrathink prefix", () => { | ||
| 35 | const withTerminal = appendTerminalContextsToPrompt("Investigate this", [ | ||
| 36 | { | ||
| 37 | terminalId: "default", | ||
| 38 | terminalLabel: "Terminal 1", | ||
| 39 | lineStart: 12, | ||
| 40 | lineEnd: 13, | ||
| 41 | text: "git status\nOn branch main", | ||
| 42 | }, | ||
| 43 | ]); | ||
| 44 | const withElement = appendElementContextsToPrompt(withTerminal, [ | ||
| 45 | { | ||
| 46 | pageUrl: "https://example.com", | ||
| 47 | pageTitle: "Example", | ||
| 48 | tagName: "button", | ||
| 49 | selector: "button.submit", | ||
| 50 | htmlPreview: "<button>Save</button>", | ||
| 51 | componentName: null, | ||
| 52 | source: null, | ||
| 53 | styles: "", | ||
| 54 | }, | ||
| 55 | ]); | ||
| 56 | expect(recallableComposerPrompt(`Ultrathink:\n${withElement}`)).toBe("Investigate this"); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("removes inline terminal labels along with their trailing block", () => { | ||
| 60 | const context = { | ||
| 61 | terminalId: "default", | ||
| 62 | terminalLabel: "Terminal 1", | ||
| 63 | lineStart: 12, | ||
| 64 | lineEnd: 13, | ||
| 65 | text: "git status", | ||
| 66 | }; | ||
| 67 | const typed = materializeInlineTerminalContextPrompt("Look at \uFFFC please", [context]); | ||
| 68 | expect(typed).toBe("Look at @terminal-1:12-13 please"); | ||
| 69 | const sent = appendTerminalContextsToPrompt(typed, [context]); | ||
| 70 | expect(recallableComposerPrompt(sent)).toBe("Look at please"); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("removes one label per chip and leaves other whitespace alone", () => { | ||
| 74 | const context = { | ||
| 75 | terminalId: "default", | ||
| 76 | terminalLabel: "Terminal 1", | ||
| 77 | lineStart: 4, | ||
| 78 | lineEnd: 4, | ||
| 79 | text: "ls", | ||
| 80 | }; | ||
| 81 | const typed = "@terminal-1:4 typed twice: @terminal-1:4\n indented code"; | ||
| 82 | const sent = appendTerminalContextsToPrompt(typed, [context]); | ||
| 83 | expect(recallableComposerPrompt(sent)).toBe("typed twice: @terminal-1:4\n indented code"); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("does not strip a typed label that only starts with the chip label", () => { | ||
| 87 | const context = { | ||
| 88 | terminalId: "default", | ||
| 89 | terminalLabel: "Terminal 1", | ||
| 90 | lineStart: 4, | ||
| 91 | lineEnd: 4, | ||
| 92 | text: "ls", | ||
| 93 | }; | ||
| 94 | const typed = "see @terminal-1:40 and @terminal-1:4-12 then @terminal-1:4"; | ||
| 95 | const sent = appendTerminalContextsToPrompt(typed, [context]); | ||
| 96 | expect(recallableComposerPrompt(sent)).toBe("see @terminal-1:40 and @terminal-1:4-12 then"); | ||
| 97 | }); | ||
| 98 | |||
| 99 | it("strips only the review comments appended at the end", () => { | ||
| 100 | const comment = buildFileReviewComment({ | ||
| 101 | id: "comment-1", | ||
| 102 | filePath: "src/app.ts", | ||
| 103 | startLine: 2, | ||
| 104 | endLine: 3, | ||
| 105 | text: "Keep this configurable.", | ||
| 106 | contents: "one\ntwo\nthree", | ||
| 107 | }); | ||
| 108 | const sent = appendReviewCommentsToPrompt("Please update this.", [comment]); | ||
| 109 | expect(recallableComposerPrompt(sent)).toBe("Please update this."); | ||
| 110 | const midPrompt = appendReviewCommentsToPrompt("Before", [comment]) + "\n\nAfter"; | ||
| 111 | expect(recallableComposerPrompt(midPrompt)).toBe(midPrompt); | ||
| 112 | // A typed block earlier in the prompt survives when the trailing one goes. | ||
| 113 | const both = appendReviewCommentsToPrompt(midPrompt, [comment]); | ||
| 114 | expect(recallableComposerPrompt(both)).toBe(midPrompt); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("returns an empty string for app-composed sends", () => { | ||
| 118 | expect(recallableComposerPrompt(" ")).toBe(""); | ||
| 119 | expect(recallableComposerPrompt(ATTACHMENT_ONLY_BOOTSTRAP_PROMPT)).toBe(""); | ||
| 120 | expect(recallableComposerPrompt(buildPlanImplementationPrompt("# Plan\n1. do it"))).toBe(""); | ||
| 121 | }); | ||
| 122 | }); | ||
| 123 | |||
| 124 | describe("buildComposerPromptHistoryEntries", () => { | ||
| 125 | it("keeps user messages with text, oldest first", () => { | ||
| 126 | expect(entries.map((entry) => entry.prompt)).toEqual(["first", "second", "third"]); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("collapses consecutive duplicates onto the newest message id", () => { | ||
| 130 | const collapsed = buildComposerPromptHistoryEntries([ | ||
| 131 | { id: "m1", role: "user", text: "same" }, | ||
| 132 | { id: "m2", role: "user", text: "same" }, | ||
| 133 | { id: "m3", role: "user", text: "other" }, | ||
| 134 | { id: "m4", role: "user", text: "same" }, | ||
| 135 | ]); | ||
| 136 | expect(collapsed).toEqual([ | ||
| 137 | { id: "m2", prompt: "same" }, | ||
| 138 | { id: "m3", prompt: "other" }, | ||
| 139 | { id: "m4", prompt: "same" }, | ||
| 140 | ]); | ||
| 141 | }); | ||
| 142 | }); | ||
| 143 | |||
| 144 | describe("stepComposerPromptHistory", () => { | ||
| 145 | it("does not start browsing from a non-empty draft", () => { | ||
| 146 | expect(backward(null, "typing")).toBeNull(); | ||
| 147 | }); | ||
| 148 | |||
| 149 | it("walks back from the newest entry", () => { | ||
| 150 | const first = backward(null, ""); | ||
| 151 | expect(first).toEqual({ position: { entryId: "m3", recalled: "third" }, prompt: "third" }); | ||
| 152 | expect(backward(first!.position, "third")?.prompt).toBe("second"); | ||
| 153 | }); | ||
| 154 | |||
| 155 | it("is a no-op at the oldest entry so the caret keeps moving", () => { | ||
| 156 | expect(backward({ entryId: "m1", recalled: "first" }, "first")).toBeNull(); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("walks forward and empties the composer past the newest entry", () => { | ||
| 160 | const newer = forward({ entryId: "m2", recalled: "second" }, "second"); | ||
| 161 | expect(newer?.prompt).toBe("third"); | ||
| 162 | expect(forward(newer!.position, "third")).toEqual({ position: null, prompt: "" }); | ||
| 163 | }); | ||
| 164 | |||
| 165 | it("treats an edited recall as a fresh draft", () => { | ||
| 166 | const position: ComposerPromptHistoryPosition = { entryId: "m3", recalled: "third" }; | ||
| 167 | expect(backward(position, "third edited")).toBeNull(); | ||
| 168 | expect(forward(position, "third edited")).toBeNull(); | ||
| 169 | // Sent and cleared: ArrowUp starts over from the newest entry. | ||
| 170 | expect(backward(position, "")?.position).toEqual({ entryId: "m3", recalled: "third" }); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("does nothing on forward when not browsing", () => { | ||
| 174 | expect(forward(null, "")).toBeNull(); | ||
| 175 | }); | ||
| 176 | |||
| 177 | it("follows the entry by id when the list changes under it", () => { | ||
| 178 | const grown = buildComposerPromptHistoryEntries([ | ||
| 179 | { id: "m0", role: "user", text: "zeroth" }, | ||
| 180 | { id: "m1", role: "user", text: "A" }, | ||
| 181 | { id: "m2", role: "user", text: "B" }, | ||
| 182 | { id: "m3", role: "user", text: "A" }, | ||
| 183 | ]); | ||
| 184 | const older = stepComposerPromptHistory({ | ||
| 185 | direction: "backward", | ||
| 186 | entries: grown, | ||
| 187 | position: { entryId: "m1", recalled: "A" }, | ||
| 188 | currentPrompt: "A", | ||
| 189 | }); | ||
| 190 | expect(older?.prompt).toBe("zeroth"); | ||
| 191 | // Unknown id with no matching text: browsing is over. | ||
| 192 | const missing = stepComposerPromptHistory({ | ||
| 193 | direction: "forward", | ||
| 194 | entries: grown, | ||
| 195 | position: { entryId: "gone", recalled: "not sent" }, | ||
| 196 | currentPrompt: "not sent", | ||
| 197 | }); | ||
| 198 | expect(missing).toBeNull(); | ||
| 199 | }); | ||
| 200 | |||
| 201 | it("falls back to matching text when a duplicate collapse retires the id", () => { | ||
| 202 | const collapsed = buildComposerPromptHistoryEntries([ | ||
| 203 | { id: "m1", role: "user", text: "first" }, | ||
| 204 | { id: "m3", role: "user", text: "A" }, | ||
| 205 | ]); | ||
| 206 | const step = stepComposerPromptHistory({ | ||
| 207 | direction: "backward", | ||
| 208 | entries: collapsed, | ||
| 209 | position: { entryId: "m2", recalled: "A" }, | ||
| 210 | currentPrompt: "A", | ||
| 211 | }); | ||
| 212 | expect(step?.prompt).toBe("first"); | ||
| 213 | }); | ||
| 214 | }); | ||
apps/web/src/components/chat/composerPromptHistory.ts created+212| ... | @@ -0,0 +1,212 @@ | ||
| 1 | import { extractTrailingElementContexts } from "../../lib/elementContext"; | ||
| 2 | import { extractTrailingPreviewAnnotation } from "../../lib/previewAnnotation"; | ||
| 3 | import { extractTrailingTerminalContexts } from "../../lib/terminalContext"; | ||
| 4 | import { PLAN_IMPLEMENTATION_PROMPT_PREFIX } from "../../proposedPlan"; | ||
| 5 | |||
| 6 | /** | ||
| 7 | * Terminal-style prompt recall for the composer. ArrowUp on an empty | ||
| 8 | * composer walks back through the active thread's sent prompts, ArrowDown | ||
| 9 | * walks forward and restores the unsent draft past the newest entry. | ||
| 10 | * | ||
| 11 | * History is per thread and text only. It is derived from the thread's user | ||
| 12 | * messages on every keypress, so there is no store to persist or sync. | ||
| 13 | */ | ||
| 14 | |||
| 15 | const CLAUDE_ULTRATHINK_PREFIX = "Ultrathink:\n"; | ||
| 16 | const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b[^>]*>[\s\S]*?<\/review_comment>/g; | ||
| 17 | |||
| 18 | /** Text sent in place of an empty prompt when a message is attachments only. */ | ||
| 19 | export const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = | ||
| 20 | "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; | ||
| 21 | |||
| 22 | export interface ComposerPromptHistoryMessage { | ||
| 23 | readonly id: string; | ||
| 24 | readonly role: string; | ||
| 25 | readonly text: string; | ||
| 26 | } | ||
| 27 | |||
| 28 | export interface ComposerPromptHistoryEntry { | ||
| 29 | readonly id: string; | ||
| 30 | readonly prompt: string; | ||
| 31 | } | ||
| 32 | |||
| 33 | /** | ||
| 34 | * Active recall. `entryId` is resolved against the current entries on every | ||
| 35 | * step, so a server ack replacing an optimistic message or an older page | ||
| 36 | * loading cannot move the position. `recalled` is the text put in the | ||
| 37 | * composer; once the composer no longer matches it, the user has edited or | ||
| 38 | * sent and browsing is over. | ||
| 39 | */ | ||
| 40 | export interface ComposerPromptHistoryPosition { | ||
| 41 | readonly entryId: string; | ||
| 42 | readonly recalled: string; | ||
| 43 | } | ||
| 44 | |||
| 45 | /** | ||
| 46 | * Prefer the id. A consecutive duplicate collapse can retire the recalled | ||
| 47 | * id while the same text lives on under a newer one, so fall back to the | ||
| 48 | * newest entry with matching text. | ||
| 49 | */ | ||
| 50 | function findActive( | ||
| 51 | entries: ReadonlyArray<ComposerPromptHistoryEntry>, | ||
| 52 | position: ComposerPromptHistoryPosition, | ||
| 53 | ): number { | ||
| 54 | const byId = entries.findIndex((entry) => entry.id === position.entryId); | ||
| 55 | if (byId >= 0) return byId; | ||
| 56 | return entries.findLastIndex((entry) => entry.prompt === position.recalled); | ||
| 57 | } | ||
| 58 | |||
| 59 | export interface ComposerPromptHistoryStep { | ||
| 60 | readonly position: ComposerPromptHistoryPosition | null; | ||
| 61 | readonly prompt: string; | ||
| 62 | } | ||
| 63 | |||
| 64 | /** | ||
| 65 | * Drop only the review comments appended at send time, which sit at the | ||
| 66 | * end. Cuts the original string at the start of the trailing run of blocks | ||
| 67 | * so any review comment block the user typed earlier stays byte-for-byte. | ||
| 68 | */ | ||
| 69 | function stripTrailingReviewComments(prompt: string): string { | ||
| 70 | let cut = prompt.length; | ||
| 71 | for (const match of [...prompt.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)].toReversed()) { | ||
| 72 | const blockEnd = match.index + match[0].length; | ||
| 73 | if (prompt.slice(blockEnd, cut).trim().length > 0) break; | ||
| 74 | cut = match.index; | ||
| 75 | } | ||
| 76 | return cut === prompt.length ? prompt : prompt.slice(0, cut).trimEnd(); | ||
| 77 | } | ||
| 78 | |||
| 79 | /** | ||
| 80 | * Inline terminal chips are sent as `@terminal-1:12-13` labels in the text | ||
| 81 | * with their content in the trailing block. Once the block is stripped the | ||
| 82 | * label points at nothing, so remove it too. Each block entry removes one | ||
| 83 | * label (the first match) and the single space beside it. Nothing else in | ||
| 84 | * the prompt is touched, so indented code and typed labels survive. Block | ||
| 85 | * headers look like `Terminal 1 lines 12-13`. | ||
| 86 | */ | ||
| 87 | function escapeRegExp(value: string): string { | ||
| 88 | return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| 89 | } | ||
| 90 | |||
| 91 | function stripInlineTerminalLabels(prompt: string, headers: ReadonlyArray<string>): string { | ||
| 92 | let result = prompt; | ||
| 93 | for (const header of headers) { | ||
| 94 | const match = /^(.+?) lines? (\d+(?:-\d+)?)$/.exec(header); | ||
| 95 | if (!match) continue; | ||
| 96 | const label = `@${match[1]!.trim().toLowerCase().replace(/\s+/g, "-")}:${match[2]}`; | ||
| 97 | // Whole label only: `@terminal-1:4` must not match inside `@terminal-1:40` | ||
| 98 | // or `@terminal-1:4-12`. | ||
| 99 | const labelPattern = new RegExp(`${escapeRegExp(label)}(?![\\d-])`); | ||
| 100 | const index = result.search(labelPattern); | ||
| 101 | if (index < 0) continue; | ||
| 102 | let end = index + label.length; | ||
| 103 | let start = index; | ||
| 104 | if (result[end] === " ") end += 1; | ||
| 105 | else if (result[start - 1] === " ") start -= 1; | ||
| 106 | result = result.slice(0, start) + result.slice(end); | ||
| 107 | } | ||
| 108 | return result; | ||
| 109 | } | ||
| 110 | |||
| 111 | /** | ||
| 112 | * Reduce a sent message to the text the user typed. Send-time appends | ||
| 113 | * (terminal and element context blocks, preview annotations, review | ||
| 114 | * comments, the Claude ultrathink prefix) are stripped so a recalled prompt | ||
| 115 | * never carries stale context from another turn. | ||
| 116 | */ | ||
| 117 | export function recallableComposerPrompt(messageText: string): string { | ||
| 118 | let prompt = messageText.trim(); | ||
| 119 | if (prompt.startsWith(CLAUDE_ULTRATHINK_PREFIX)) { | ||
| 120 | prompt = prompt.slice(CLAUDE_ULTRATHINK_PREFIX.length); | ||
| 121 | } | ||
| 122 | |||
| 123 | while (prompt.length > 0) { | ||
| 124 | const withoutReviewComments = stripTrailingReviewComments(prompt); | ||
| 125 | if (withoutReviewComments !== prompt) { | ||
| 126 | prompt = withoutReviewComments; | ||
| 127 | continue; | ||
| 128 | } | ||
| 129 | const previewAnnotation = extractTrailingPreviewAnnotation(prompt); | ||
| 130 | if (previewAnnotation.annotation) { | ||
| 131 | prompt = previewAnnotation.promptText; | ||
| 132 | continue; | ||
| 133 | } | ||
| 134 | const elementContexts = extractTrailingElementContexts(prompt); | ||
| 135 | if (elementContexts.contextCount > 0) { | ||
| 136 | prompt = elementContexts.promptText; | ||
| 137 | continue; | ||
| 138 | } | ||
| 139 | const terminalContexts = extractTrailingTerminalContexts(prompt); | ||
| 140 | if (terminalContexts.contextCount > 0) { | ||
| 141 | prompt = stripInlineTerminalLabels( | ||
| 142 | terminalContexts.promptText, | ||
| 143 | terminalContexts.contexts.map((context) => context.header), | ||
| 144 | ); | ||
| 145 | continue; | ||
| 146 | } | ||
| 147 | break; | ||
| 148 | } | ||
| 149 | |||
| 150 | // App-composed sends are not text the user typed, so they are not history. | ||
| 151 | const trimmed = prompt.trim(); | ||
| 152 | if ( | ||
| 153 | trimmed === ATTACHMENT_ONLY_BOOTSTRAP_PROMPT || | ||
| 154 | trimmed.startsWith(PLAN_IMPLEMENTATION_PROMPT_PREFIX) | ||
| 155 | ) { | ||
| 156 | return ""; | ||
| 157 | } | ||
| 158 | return trimmed; | ||
| 159 | } | ||
| 160 | |||
| 161 | /** | ||
| 162 | * Oldest first. Consecutive identical prompts collapse into the newest one, | ||
| 163 | * matching shell `HISTCONTROL=ignoredups`. Image-only sends have no text and | ||
| 164 | * are skipped. | ||
| 165 | */ | ||
| 166 | export function buildComposerPromptHistoryEntries( | ||
| 167 | messages: ReadonlyArray<ComposerPromptHistoryMessage>, | ||
| 168 | ): ComposerPromptHistoryEntry[] { | ||
| 169 | const entries: ComposerPromptHistoryEntry[] = []; | ||
| 170 | for (const message of messages) { | ||
| 171 | if (message.role !== "user") continue; | ||
| 172 | const prompt = recallableComposerPrompt(message.text); | ||
| 173 | if (prompt.length === 0) continue; | ||
| 174 | const previous = entries[entries.length - 1]; | ||
| 175 | if (previous && previous.prompt === prompt) { | ||
| 176 | entries[entries.length - 1] = { id: message.id, prompt }; | ||
| 177 | continue; | ||
| 178 | } | ||
| 179 | entries.push({ id: message.id, prompt }); | ||
| 180 | } | ||
| 181 | return entries; | ||
| 182 | } | ||
| 183 | |||
| 184 | /** | ||
| 185 | * Returns null when the key should fall through to normal caret movement. | ||
| 186 | * Backward starts only from an empty composer and stops at the oldest entry. | ||
| 187 | * Forward past the newest entry empties the composer and ends browsing. An | ||
| 188 | * edited or sent recall no longer matches `recalled`, so browsing restarts | ||
| 189 | * from scratch on the next backward step. | ||
| 190 | */ | ||
| 191 | export function stepComposerPromptHistory(input: { | ||
| 192 | readonly direction: "backward" | "forward"; | ||
| 193 | readonly entries: ReadonlyArray<ComposerPromptHistoryEntry>; | ||
| 194 | readonly position: ComposerPromptHistoryPosition | null; | ||
| 195 | readonly currentPrompt: string; | ||
| 196 | }): ComposerPromptHistoryStep | null { | ||
| 197 | const { entries, position, currentPrompt } = input; | ||
| 198 | const activeIndex = | ||
| 199 | position && position.recalled === currentPrompt ? findActive(entries, position) : -1; | ||
| 200 | |||
| 201 | if (input.direction === "backward") { | ||
| 202 | if (activeIndex < 0 && currentPrompt.length > 0) return null; | ||
| 203 | const entry = entries[activeIndex < 0 ? entries.length - 1 : activeIndex - 1]; | ||
| 204 | if (!entry) return null; | ||
| 205 | return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; | ||
| 206 | } | ||
| 207 | |||
| 208 | if (activeIndex < 0) return null; | ||
| 209 | const entry = entries[activeIndex + 1]; | ||
| 210 | if (!entry) return { position: null, prompt: "" }; | ||
| 211 | return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; | ||
| 212 | } | ||
apps/web/src/proposedPlan.ts+4-1| ... | @@ -70,8 +70,11 @@ function sanitizePlanFileSegment(input: string): string { | ... | @@ -70,8 +70,11 @@ function sanitizePlanFileSegment(input: string): string { |
| 70 | return sanitized.length > 0 ? sanitized : "plan"; | 70 | return sanitized.length > 0 ? sanitized : "plan"; |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | /** Prefix of the message the app sends when the user approves a plan. */ | ||
| 74 | export const PLAN_IMPLEMENTATION_PROMPT_PREFIX = "PLEASE IMPLEMENT THIS PLAN:\n"; | ||
| 75 | |||
| 73 | export function buildPlanImplementationPrompt(planMarkdown: string): string { | 76 | export function buildPlanImplementationPrompt(planMarkdown: string): string { |
| 74 | return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; | 77 | return `${PLAN_IMPLEMENTATION_PROMPT_PREFIX}${planMarkdown.trim()}`; |
| 75 | } | 78 | } |
| 76 | 79 | ||
| 77 | export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): { | 80 | export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): { |
docs/user/composer.md+13| ... | @@ -56,6 +56,19 @@ is unavailable or has changed, the saved quote remains readable. | ... | @@ -56,6 +56,19 @@ is unavailable or has changed, the saved quote remains readable. |
| 56 | Mobile displays saved quotes and comments, but does not create citations or | 56 | Mobile displays saved quotes and comments, but does not create citations or |
| 57 | navigate to their sources. | 57 | navigate to their sources. |
| 58 | 58 | ||
| 59 | ## Recall a sent prompt | ||
| 60 | |||
| 61 | Press `ArrowUp` in an empty composer to bring back the last prompt you sent in this thread. Press | ||
| 62 | `ArrowUp` again to go further back, and `ArrowDown` to come forward. Moving forward past the newest | ||
| 63 | prompt clears the composer. Recall walks the prompts loaded in the thread. Attachments, terminal | ||
| 64 | context, and other extras from the original message are not restored, only the text you typed. A | ||
| 65 | composer that holds an attachment or a picked element does not count as empty. | ||
| 66 | |||
| 67 | When the composer has text, the arrow keys move the caret as usual. Recall takes over only while | ||
| 68 | the text is an unedited recalled prompt, with the caret on the first visual line for `ArrowUp` or | ||
| 69 | the last visual line for `ArrowDown`, counting wrapped lines. Editing a recalled prompt turns it | ||
| 70 | into a normal draft. | ||
| 71 | |||
| 59 | ## Prompt stash | 72 | ## Prompt stash |
| 60 | 73 | ||
| 61 | On web and desktop, press `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux to save | 74 | On web and desktop, press `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux to save |