diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52637695e..577481e5d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -46,6 +46,10 @@ import { parseStandaloneComposerSlashCommand, replaceTextRange, } from "../composer-logic"; +import { + navigateComposerPromptHistory, + resolveComposerPromptHistoryEntries, +} from "../composerPromptHistory"; import { derivePendingApprovals, derivePendingUserInputs, @@ -264,6 +268,11 @@ export default function ChatView({ threadId }: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); + const composerPromptHistoryNavigationRef = useRef<{ + draftPrompt: string; + historyIndex: number; + } | null>(null); + const isApplyingComposerPromptHistoryRef = useRef(false); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); @@ -321,6 +330,10 @@ export default function ChatView({ threadId }: ChatViewProps) { messagesScrollRef.current = element; setMessagesScrollElement(element); }, []); + const resetComposerPromptHistoryNavigation = useCallback(() => { + composerPromptHistoryNavigationRef.current = null; + isApplyingComposerPromptHistoryRef.current = false; + }, []); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), @@ -814,6 +827,19 @@ export default function ChatView({ threadId }: ChatViewProps) { deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); + const composerPromptHistoryEntries = useMemo( + () => + activeThread + ? resolveComposerPromptHistoryEntries({ + currentProjectId: activeThread.projectId, + currentThreadMessages: timelineMessages, + projects, + threads, + ignoredMessageTexts: [IMAGE_ONLY_BOOTSTRAP_PROMPT], + }) + : [], + [activeThread, projects, threads, timelineMessages], + ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { @@ -1761,7 +1787,12 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { promptRef.current = prompt; setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt]); + if (isApplyingComposerPromptHistoryRef.current) { + isApplyingComposerPromptHistoryRef.current = false; + return; + } + resetComposerPromptHistoryNavigation(); + }, [prompt, resetComposerPromptHistoryNavigation]); useEffect(() => { setOptimisticUserMessages((existing) => { @@ -1775,10 +1806,11 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + resetComposerPromptHistoryNavigation(); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); - }, [threadId]); + }, [resetComposerPromptHistoryNavigation, threadId]); useEffect(() => { let cancelled = false; @@ -2211,6 +2243,7 @@ export default function ChatView({ threadId }: ChatViewProps) { planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; + resetComposerPromptHistoryNavigation(); clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); setComposerCursor(0); @@ -2226,6 +2259,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (standaloneSlashCommand) { await handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; + resetComposerPromptHistoryNavigation(); clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); setComposerCursor(0); @@ -2293,6 +2327,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError(threadIdForSend, null); promptRef.current = ""; + resetComposerPromptHistoryNavigation(); clearComposerDraftContent(threadIdForSend); setComposerHighlightedItemId(null); setComposerCursor(0); @@ -2969,6 +3004,8 @@ export default function ChatView({ threadId }: ChatViewProps) { value: string; cursor: number; expandedCursor: number; + isOnFirstVisualLine: boolean; + isOnLastVisualLine: boolean; } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { @@ -2978,11 +3015,19 @@ export default function ChatView({ threadId }: ChatViewProps) { value: promptRef.current, cursor: composerCursor, expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + isOnFirstVisualLine: true, + isOnLastVisualLine: true, }; }, [composerCursor]); const resolveActiveComposerTrigger = useCallback((): { - snapshot: { value: string; cursor: number; expandedCursor: number }; + snapshot: { + value: string; + cursor: number; + expandedCursor: number; + isOnFirstVisualLine: boolean; + isOnLastVisualLine: boolean; + }; trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); @@ -3130,7 +3175,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return true; } - const { trigger } = resolveActiveComposerTrigger(); + const { snapshot, trigger } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; if (menuIsActive) { @@ -3152,6 +3197,34 @@ export default function ChatView({ threadId }: ChatViewProps) { } } + if ( + (key === "ArrowUp" || key === "ArrowDown") && + !isComposerApprovalState && + !activePendingProgress && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + (key === "ArrowUp" ? snapshot.isOnFirstVisualLine : snapshot.isOnLastVisualLine) + ) { + const navigation = navigateComposerPromptHistory({ + currentPrompt: snapshot.value, + direction: key === "ArrowUp" ? "up" : "down", + entries: composerPromptHistoryEntries, + navigationState: composerPromptHistoryNavigationRef.current, + }); + if (navigation.handled) { + composerPromptHistoryNavigationRef.current = navigation.nextNavigationState; + isApplyingComposerPromptHistoryRef.current = true; + promptRef.current = navigation.nextPrompt; + setPrompt(navigation.nextPrompt); + setComposerCursor( + collapseExpandedComposerCursor(navigation.nextPrompt, navigation.nextPrompt.length), + ); + setComposerTrigger(detectComposerTrigger(navigation.nextPrompt, navigation.nextPrompt.length)); + return true; + } + } + if (key === "Enter" && !event.shiftKey) { void onSend(); return true; diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index ab68f1fcb..a6de6b36e 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -458,7 +458,13 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; - readSnapshot: () => { value: string; cursor: number; expandedCursor: number }; + readSnapshot: () => { + value: string; + cursor: number; + expandedCursor: number; + isOnFirstVisualLine: boolean; + isOnLastVisualLine: boolean; + }; } interface ComposerPromptEditorProps { @@ -484,6 +490,74 @@ interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps { editorRef: Ref; } +function readComposerVisualLineState( + rootElement: HTMLElement | null, + fallback: { + isOnFirstVisualLine: boolean; + isOnLastVisualLine: boolean; + }, + value: string, +): { + isOnFirstVisualLine: boolean; + isOnLastVisualLine: boolean; +} { + if (!rootElement || value.length === 0 || typeof window === "undefined") { + return { + isOnFirstVisualLine: true, + isOnLastVisualLine: true, + }; + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) { + return fallback; + } + + const selectionRange = selection.getRangeAt(0); + if (!rootElement.contains(selectionRange.startContainer)) { + return fallback; + } + + const caretRange = selectionRange.cloneRange(); + caretRange.collapse(true); + const caretRect = caretRange.getClientRects()[0] ?? caretRange.getBoundingClientRect(); + if (!caretRect || (!caretRect.width && !caretRect.height && !caretRect.top && !caretRect.bottom)) { + return fallback; + } + + const contentRange = document.createRange(); + contentRange.selectNodeContents(rootElement); + const contentRect = contentRange.getBoundingClientRect(); + if ( + !contentRect || + (!contentRect.width && + !contentRect.height && + !contentRect.top && + !contentRect.bottom) + ) { + return { + isOnFirstVisualLine: true, + isOnLastVisualLine: true, + }; + } + + const rootRect = rootElement.getBoundingClientRect(); + const computedLineHeight = Number.parseFloat(window.getComputedStyle(rootElement).lineHeight); + const tolerance = Number.isFinite(computedLineHeight) + ? Math.max(2, computedLineHeight / 2) + : 4; + const scrollTop = rootElement.scrollTop; + const maxScrollTop = Math.max(0, rootElement.scrollHeight - rootElement.clientHeight); + const visibleTop = Math.max(contentRect.top, rootRect.top); + const visibleBottom = Math.min(contentRect.bottom, rootRect.bottom); + + return { + isOnFirstVisualLine: scrollTop <= tolerance && caretRect.top <= visibleTop + tolerance, + isOnLastVisualLine: + scrollTop >= maxScrollTop - tolerance && caretRect.bottom >= visibleBottom - tolerance, + }; +} + function ComposerCommandKeyPlugin(props: { onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", @@ -713,6 +787,8 @@ function ComposerPromptEditorInner({ value, cursor: initialCursor, expandedCursor: expandCollapsedComposerCursor(value, initialCursor), + isOnFirstVisualLine: true, + isOnLastVisualLine: true, }); const isApplyingControlledUpdateRef = useRef(false); @@ -735,6 +811,7 @@ function ComposerPromptEditorInner({ value, cursor: normalizedCursor, expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), + ...readComposerVisualLineState(editor.getRootElement(), snapshotRef.current, value), }; const rootElement = editor.getRootElement(); @@ -771,6 +848,11 @@ function ComposerPromptEditorInner({ value: snapshotRef.current.value, cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), + ...readComposerVisualLineState( + editor.getRootElement(), + snapshotRef.current, + snapshotRef.current.value, + ), }; onChangeRef.current( snapshotRef.current.value, @@ -786,6 +868,8 @@ function ComposerPromptEditorInner({ value: string; cursor: number; expandedCursor: number; + isOnFirstVisualLine: boolean; + isOnLastVisualLine: boolean; } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { @@ -807,6 +891,7 @@ function ComposerPromptEditorInner({ value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, + ...readComposerVisualLineState(editor.getRootElement(), snapshotRef.current, nextValue), }; }); snapshotRef.current = snapshot; @@ -864,13 +949,14 @@ function ComposerPromptEditorInner({ value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, + ...readComposerVisualLineState(editor.getRootElement(), snapshotRef.current, nextValue), }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right"); onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention); }); - }, []); + }, [editor]); return (
diff --git a/apps/web/src/composerPromptHistory.test.ts b/apps/web/src/composerPromptHistory.test.ts new file mode 100644 index 000000000..f852f8060 --- /dev/null +++ b/apps/web/src/composerPromptHistory.test.ts @@ -0,0 +1,362 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + navigateComposerPromptHistory, + resolveComposerPromptHistoryEntries, + resolveComposerPromptRecall, +} from "./composerPromptHistory"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type ChatMessage, + type Project, + type Thread, +} from "./types"; + +function makeProject(overrides: Partial = {}): Project { + return { + id: ProjectId.makeUnsafe("project-1"), + name: "Project", + cwd: "/tmp/project", + model: "gpt-5-codex", + expanded: true, + scripts: [], + ...overrides, + }; +} + +function makeMessage(overrides: Partial = {}): ChatMessage { + return { + id: "message-1" as ChatMessage["id"], + role: "user", + text: "latest prompt", + createdAt: "2026-03-16T10:00:00.000Z", + streaming: false, + ...overrides, + }; +} + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.makeUnsafe("thread-1"), + codexThreadId: null, + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + model: "gpt-5-codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-16T09:00:00.000Z", + latestTurn: null, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +describe("resolveComposerPromptRecall", () => { + it("prefers the latest sent user message in the current thread", () => { + const currentThreadMessages = [ + makeMessage({ + id: "message-1" as ChatMessage["id"], + text: "older current thread prompt", + createdAt: "2026-03-16T09:00:00.000Z", + }), + makeMessage({ + id: "message-2" as ChatMessage["id"], + role: "assistant", + text: "assistant reply", + createdAt: "2026-03-16T09:01:00.000Z", + }), + makeMessage({ + id: "message-3" as ChatMessage["id"], + text: "current thread prompt", + createdAt: "2026-03-16T09:02:00.000Z", + }), + ]; + + const recalled = resolveComposerPromptRecall({ + currentProjectId: ProjectId.makeUnsafe("project-1"), + currentThreadMessages, + projects: [makeProject()], + threads: [ + makeThread({ + messages: [ + makeMessage({ + id: "message-4" as ChatMessage["id"], + text: "other thread prompt", + createdAt: "2026-03-16T10:00:00.000Z", + }), + ], + }), + ], + }); + + expect(recalled).toBe("current thread prompt"); + }); + + it("falls back to the latest user message from the same project when the current thread is new", () => { + const recalled = resolveComposerPromptRecall({ + currentProjectId: ProjectId.makeUnsafe("project-1"), + currentThreadMessages: [], + projects: [makeProject()], + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-older"), + messages: [ + makeMessage({ + id: "message-1" as ChatMessage["id"], + text: "older repo prompt", + createdAt: "2026-03-16T08:00:00.000Z", + }), + ], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-newer"), + messages: [ + makeMessage({ + id: "message-2" as ChatMessage["id"], + text: "latest repo prompt", + createdAt: "2026-03-16T11:00:00.000Z", + }), + ], + }), + ], + }); + + expect(recalled).toBe("latest repo prompt"); + }); + + it("keeps the fallback repo-scoped instead of using other projects", () => { + const recalled = resolveComposerPromptRecall({ + currentProjectId: ProjectId.makeUnsafe("project-1"), + currentThreadMessages: [], + projects: [ + makeProject(), + makeProject({ + id: ProjectId.makeUnsafe("project-2"), + cwd: "/tmp/other-project", + }), + ], + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-same-project"), + messages: [ + makeMessage({ + id: "message-1" as ChatMessage["id"], + text: "same repo prompt", + createdAt: "2026-03-16T10:00:00.000Z", + }), + ], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-other-project"), + projectId: ProjectId.makeUnsafe("project-2"), + messages: [ + makeMessage({ + id: "message-2" as ChatMessage["id"], + text: "other repo prompt", + createdAt: "2026-03-16T11:00:00.000Z", + }), + ], + }), + ], + }); + + expect(recalled).toBe("same repo prompt"); + }); + + it("ignores assistant messages and internal bootstrap prompts", () => { + const recalled = resolveComposerPromptRecall({ + currentProjectId: ProjectId.makeUnsafe("project-1"), + currentThreadMessages: [], + projects: [makeProject()], + threads: [ + makeThread({ + messages: [ + makeMessage({ + id: "message-1" as ChatMessage["id"], + role: "assistant", + text: "assistant reply", + createdAt: "2026-03-16T10:00:00.000Z", + }), + makeMessage({ + id: "message-2" as ChatMessage["id"], + text: "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]", + createdAt: "2026-03-16T11:00:00.000Z", + }), + makeMessage({ + id: "message-3" as ChatMessage["id"], + text: "real prompt", + createdAt: "2026-03-16T09:00:00.000Z", + }), + ], + }), + ], + ignoredMessageTexts: [ + "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]", + ], + }); + + expect(recalled).toBe("real prompt"); + }); +}); + +describe("resolveComposerPromptHistoryEntries", () => { + it("returns current-thread entries newest first", () => { + const entries = resolveComposerPromptHistoryEntries({ + currentProjectId: ProjectId.makeUnsafe("project-1"), + currentThreadMessages: [ + makeMessage({ + id: "message-1" as ChatMessage["id"], + text: "one", + createdAt: "2026-03-16T09:00:00.000Z", + }), + makeMessage({ + id: "message-2" as ChatMessage["id"], + text: "two", + createdAt: "2026-03-16T10:00:00.000Z", + }), + makeMessage({ + id: "message-3" as ChatMessage["id"], + text: "three", + createdAt: "2026-03-16T11:00:00.000Z", + }), + ], + projects: [makeProject()], + threads: [], + }); + + expect(entries).toEqual(["three", "two", "one"]); + }); + + it("falls back to same-project entries newest first when the thread is new", () => { + const entries = resolveComposerPromptHistoryEntries({ + currentProjectId: ProjectId.makeUnsafe("project-1"), + currentThreadMessages: [], + projects: [makeProject()], + threads: [ + makeThread({ + id: ThreadId.makeUnsafe("thread-older"), + messages: [ + makeMessage({ + id: "message-1" as ChatMessage["id"], + text: "one", + createdAt: "2026-03-16T09:00:00.000Z", + }), + ], + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-newer"), + messages: [ + makeMessage({ + id: "message-2" as ChatMessage["id"], + text: "two", + createdAt: "2026-03-16T10:00:00.000Z", + }), + makeMessage({ + id: "message-3" as ChatMessage["id"], + text: "three", + createdAt: "2026-03-16T11:00:00.000Z", + }), + ], + }), + ], + }); + + expect(entries).toEqual(["three", "two", "one"]); + }); +}); + +describe("navigateComposerPromptHistory", () => { + it("walks backward through history and restores the saved draft on the way back down", () => { + const entries = ["three", "two", "one"]; + + const firstUp = navigateComposerPromptHistory({ + currentPrompt: "draft", + direction: "up", + entries, + navigationState: null, + }); + expect(firstUp).toEqual({ + handled: true, + nextNavigationState: { + draftPrompt: "draft", + historyIndex: 0, + }, + nextPrompt: "three", + }); + + const secondUp = navigateComposerPromptHistory({ + currentPrompt: firstUp.nextPrompt, + direction: "up", + entries, + navigationState: firstUp.nextNavigationState, + }); + expect(secondUp.nextPrompt).toBe("two"); + expect(secondUp.nextNavigationState?.historyIndex).toBe(1); + + const firstDown = navigateComposerPromptHistory({ + currentPrompt: secondUp.nextPrompt, + direction: "down", + entries, + navigationState: secondUp.nextNavigationState, + }); + expect(firstDown.nextPrompt).toBe("three"); + expect(firstDown.nextNavigationState?.historyIndex).toBe(0); + + const secondDown = navigateComposerPromptHistory({ + currentPrompt: firstDown.nextPrompt, + direction: "down", + entries, + navigationState: firstDown.nextNavigationState, + }); + expect(secondDown).toEqual({ + handled: true, + nextNavigationState: null, + nextPrompt: "draft", + }); + }); + + it("does not handle navigation when no history exists", () => { + expect( + navigateComposerPromptHistory({ + currentPrompt: "", + direction: "up", + entries: [], + navigationState: null, + }), + ).toEqual({ + handled: false, + nextNavigationState: null, + nextPrompt: "", + }); + }); + + it("does nothing when already at the oldest history entry and pressing up again", () => { + expect( + navigateComposerPromptHistory({ + currentPrompt: "one", + direction: "up", + entries: ["three", "two", "one"], + navigationState: { + draftPrompt: "draft", + historyIndex: 2, + }, + }), + ).toEqual({ + handled: false, + nextNavigationState: { + draftPrompt: "draft", + historyIndex: 2, + }, + nextPrompt: "one", + }); + }); +}); diff --git a/apps/web/src/composerPromptHistory.ts b/apps/web/src/composerPromptHistory.ts new file mode 100644 index 000000000..0e2e15e43 --- /dev/null +++ b/apps/web/src/composerPromptHistory.ts @@ -0,0 +1,188 @@ +import type { ChatMessage, Project, Thread } from "./types"; + +export interface ComposerPromptHistoryNavigationState { + draftPrompt: string; + historyIndex: number; +} + +interface ResolveComposerPromptHistoryEntriesInput { + currentProjectId: Project["id"] | null | undefined; + currentThreadMessages: ChatMessage[]; + projects: Project[]; + threads: Thread[]; + ignoredMessageTexts?: readonly string[]; +} + +interface ComposerPromptHistoryCandidate { + createdAt: string; + sequence: number; + text: string; +} + +interface NavigateComposerPromptHistoryInput { + currentPrompt: string; + direction: "up" | "down"; + entries: string[]; + navigationState: ComposerPromptHistoryNavigationState | null; +} + +interface NavigateComposerPromptHistoryResult { + handled: boolean; + nextNavigationState: ComposerPromptHistoryNavigationState | null; + nextPrompt: string; +} + +function isRecallableUserMessage( + message: ChatMessage, + ignoredMessageTexts: ReadonlySet, +): boolean { + return message.role === "user" && !ignoredMessageTexts.has(message.text); +} + +function collectThreadPromptHistoryEntries( + messages: ChatMessage[], + ignoredMessageTexts: ReadonlySet, +): string[] { + const entries: string[] = []; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message && isRecallableUserMessage(message, ignoredMessageTexts)) { + entries.push(message.text); + } + } + return entries; +} + +function comparePromptHistoryCandidates( + left: ComposerPromptHistoryCandidate, + right: ComposerPromptHistoryCandidate, +): number { + const leftCreatedAtMs = Date.parse(left.createdAt); + const rightCreatedAtMs = Date.parse(right.createdAt); + const leftHasTimestamp = Number.isFinite(leftCreatedAtMs); + const rightHasTimestamp = Number.isFinite(rightCreatedAtMs); + + if (leftHasTimestamp && rightHasTimestamp && leftCreatedAtMs !== rightCreatedAtMs) { + return rightCreatedAtMs - leftCreatedAtMs; + } + if (leftHasTimestamp !== rightHasTimestamp) { + return leftHasTimestamp ? -1 : 1; + } + return right.sequence - left.sequence; +} + +export function resolveComposerPromptHistoryEntries( + input: ResolveComposerPromptHistoryEntriesInput, +): string[] { + const ignoredMessageTexts = new Set(input.ignoredMessageTexts ?? []); + const currentThreadEntries = collectThreadPromptHistoryEntries( + input.currentThreadMessages, + ignoredMessageTexts, + ); + if (currentThreadEntries.length > 0) { + return currentThreadEntries; + } + + const currentProject = input.projects.find((project) => project.id === input.currentProjectId); + if (!currentProject) { + return []; + } + + const projectCwdById = new Map(input.projects.map((project) => [project.id, project.cwd] as const)); + const candidates: ComposerPromptHistoryCandidate[] = []; + let sequence = 0; + + for (const thread of input.threads) { + if (projectCwdById.get(thread.projectId) !== currentProject.cwd) { + continue; + } + for (const message of thread.messages) { + if (!isRecallableUserMessage(message, ignoredMessageTexts)) { + continue; + } + candidates.push({ + createdAt: message.createdAt, + sequence, + text: message.text, + }); + sequence += 1; + } + } + + return candidates.toSorted(comparePromptHistoryCandidates).map((candidate) => candidate.text); +} + +export function resolveComposerPromptRecall( + input: ResolveComposerPromptHistoryEntriesInput, +): string | null { + return resolveComposerPromptHistoryEntries(input)[0] ?? null; +} + +export function navigateComposerPromptHistory( + input: NavigateComposerPromptHistoryInput, +): NavigateComposerPromptHistoryResult { + if (input.entries.length === 0) { + return { + handled: false, + nextNavigationState: input.navigationState, + nextPrompt: input.currentPrompt, + }; + } + + if (input.direction === "up") { + if (!input.navigationState) { + return { + handled: true, + nextNavigationState: { + draftPrompt: input.currentPrompt, + historyIndex: 0, + }, + nextPrompt: input.entries[0] ?? input.currentPrompt, + }; + } + + if (input.navigationState.historyIndex >= input.entries.length - 1) { + return { + handled: false, + nextNavigationState: input.navigationState, + nextPrompt: input.currentPrompt, + }; + } + + const nextIndex = Math.min(input.navigationState.historyIndex + 1, input.entries.length - 1); + return { + handled: true, + nextNavigationState: { + ...input.navigationState, + historyIndex: nextIndex, + }, + nextPrompt: input.entries[nextIndex] ?? input.currentPrompt, + }; + } + + if (!input.navigationState) { + return { + handled: false, + nextNavigationState: null, + nextPrompt: input.currentPrompt, + }; + } + + const nextIndex = input.navigationState.historyIndex - 1; + if (nextIndex < 0) { + return { + handled: true, + nextNavigationState: null, + nextPrompt: input.navigationState.draftPrompt, + }; + } + + return { + handled: true, + nextNavigationState: { + ...input.navigationState, + historyIndex: nextIndex, + }, + nextPrompt: input.entries[nextIndex] ?? input.currentPrompt, + }; +}