authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 21:57:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 21:57:49-07:00
logfd773172e7f26f2d98de3eb572f3317b0ad8d443
treec3b411f7d1c27c5777cbfaef9786284bf859bc7d
parentdf8e0eb46bca3dc01c17cc74a53b19aaacbe252b
signature Signed by PGP key B5690EEEBB952194

feat(web): recall sent prompts with the up arrow (#9173)

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 {
445445 supportsServerUpdateThreadContinuation,
446446} from "../versionSkew";
447447import { useAssetUrls } from "../assets/assetUrls";
448import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory";
448449
449const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT =
450 "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]";
451450const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
452451const EMPTY_PROVIDERS: ServerProvider[] = [];
453452const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
......@@ -7931,6 +7930,7 @@ export default function ChatView(props: ChatViewProps) {
79317930 activeThreadId={activeThreadId}
79327931 activeThreadEnvironmentId={activeThread?.environmentId}
79337932 activeThread={activeThread}
7933 promptHistoryMessages={timelineMessages}
79347934 isServerThread={isServerThread}
79357935 isLocalDraftThread={isLocalDraftThread}
79367936 forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState}
apps/web/src/components/ComposerPromptEditor.tsx+97
......@@ -896,6 +896,13 @@ export interface ComposerPromptEditorHandle {
896896 expandedCursor: number;
897897 terminalContextIds: string[];
898898 };
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;
899906}
900907
901908interface ComposerPromptEditorProps {
......@@ -929,6 +936,66 @@ interface ComposerPromptEditorProps {
929936 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
930937}
931938
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 */
950function 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
932999function ComposerCommandKeyPlugin(props: {
9331000 onCommandKeyDown?: (
9341001 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
......@@ -1799,6 +1866,36 @@ function ComposerPromptEditorInner({
17991866 if (target) setOpenCitationComment(target);
18001867 },
18011868 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 },
18021899 }),
18031900 [editor, focusAt, readSnapshot],
18041901 );
apps/web/src/components/chat/ChatComposer.tsx+99-1
......@@ -803,7 +803,12 @@ import {
803803} from "../../providerInstances";
804804import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
805805import type { UnifiedSettings } from "@t3tools/contracts/settings";
806import { type SessionPhase, type Thread, videoMimeType } from "../../types";
806import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types";
807import {
808 buildComposerPromptHistoryEntries,
809 stepComposerPromptHistory,
810 type ComposerPromptHistoryPosition,
811} from "./composerPromptHistory";
807812import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
808813import type { PendingApproval, PendingUserInput } from "../../session-logic";
809814import type { ContextWindowSnapshot } from "../../lib/contextWindow";
......@@ -1172,6 +1177,8 @@ export interface ChatComposerProps {
11721177 activeThreadId: ThreadId | null;
11731178 activeThreadEnvironmentId: EnvironmentId | undefined;
11741179 activeThread: Thread | undefined;
1180 /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */
1181 promptHistoryMessages: ReadonlyArray<ChatMessage>;
11751182 isServerThread: boolean;
11761183 isLocalDraftThread: boolean;
11771184 forceExpandedOnMobile: boolean;
......@@ -1312,6 +1319,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13121319 activeThreadId,
13131320 activeThreadEnvironmentId: _activeThreadEnvironmentId,
13141321 activeThread,
1322 promptHistoryMessages,
13151323 isServerThread: _isServerThread,
13161324 isLocalDraftThread: _isLocalDraftThread,
13171325 forceExpandedOnMobile,
......@@ -1781,6 +1789,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17811789 detectComposerTrigger(prompt, prompt.length),
17821790 );
17831791 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);
17841794 const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
17851795 null,
17861796 );
......@@ -2536,6 +2546,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25362546 }
25372547 promptRef.current = nextPrompt;
25382548 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 }
25392555 if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {
25402556 setComposerDraftTerminalContexts(
25412557 composerDraftTarget,
......@@ -2957,6 +2973,85 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29572973 });
29582974 }, [setIsComposerFocused]);
29592975
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
29603055 // ------------------------------------------------------------------
29613056 // Callbacks: command key
29623057 // ------------------------------------------------------------------
......@@ -2987,6 +3082,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29873082 return true;
29883083 }
29893084 }
3085 if (key === "ArrowUp" || key === "ArrowDown") {
3086 return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event);
3087 }
29903088 const submissionIntent =
29913089 key === "Enter"
29923090 ? composerSubmissionIntentForEnter({
apps/web/src/components/chat/composerPromptHistory.test.ts created+214
......@@ -0,0 +1,214 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { appendElementContextsToPrompt } from "../../lib/elementContext";
4import {
5 appendTerminalContextsToPrompt,
6 materializeInlineTerminalContextPrompt,
7} from "../../lib/terminalContext";
8import { appendReviewCommentsToPrompt, buildFileReviewComment } from "../../reviewCommentContext";
9import { buildPlanImplementationPrompt } from "../../proposedPlan";
10import {
11 ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
12 buildComposerPromptHistoryEntries,
13 recallableComposerPrompt,
14 stepComposerPromptHistory,
15 type ComposerPromptHistoryPosition,
16} from "./composerPromptHistory";
17
18const 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
25function backward(position: ComposerPromptHistoryPosition | null, currentPrompt: string) {
26 return stepComposerPromptHistory({ direction: "backward", entries, position, currentPrompt });
27}
28
29function forward(position: ComposerPromptHistoryPosition | null, currentPrompt: string) {
30 return stepComposerPromptHistory({ direction: "forward", entries, position, currentPrompt });
31}
32
33describe("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
124describe("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
144describe("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 @@
1import { extractTrailingElementContexts } from "../../lib/elementContext";
2import { extractTrailingPreviewAnnotation } from "../../lib/previewAnnotation";
3import { extractTrailingTerminalContexts } from "../../lib/terminalContext";
4import { 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
15const CLAUDE_ULTRATHINK_PREFIX = "Ultrathink:\n";
16const 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. */
19export 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
22export interface ComposerPromptHistoryMessage {
23 readonly id: string;
24 readonly role: string;
25 readonly text: string;
26}
27
28export 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 */
40export 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 */
50function 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
59export 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 */
69function 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 */
87function escapeRegExp(value: string): string {
88 return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
89}
90
91function 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 */
117export 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 */
166export 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 */
191export 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 {
7070 return sanitized.length > 0 ? sanitized : "plan";
7171}
7272
73/** Prefix of the message the app sends when the user approves a plan. */
74export const PLAN_IMPLEMENTATION_PROMPT_PREFIX = "PLEASE IMPLEMENT THIS PLAN:\n";
75
7376export function buildPlanImplementationPrompt(planMarkdown: string): string {
74 return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`;
77 return `${PLAN_IMPLEMENTATION_PROMPT_PREFIX}${planMarkdown.trim()}`;
7578}
7679
7780export 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.
5656Mobile displays saved quotes and comments, but does not create citations or
5757navigate to their sources.
5858
59## Recall a sent prompt
60
61Press `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
63prompt clears the composer. Recall walks the prompts loaded in the thread. Attachments, terminal
64context, and other extras from the original message are not restored, only the text you typed. A
65composer that holds an attachment or a picked element does not count as empty.
66
67When the composer has text, the arrow keys move the caret as usual. Recall takes over only while
68the text is an unedited recalled prompt, with the caret on the first visual line for `ArrowUp` or
69the last visual line for `ArrowDown`, counting wrapped lines. Editing a recalled prompt turns it
70into a normal draft.
71
5972## Prompt stash
6073
6174On web and desktop, press `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux to save