diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 1e6dda5132..b96ecc42a1 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor, within, type RenderResult } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within, type RenderResult } from "@testing-library/react"; import type { ComponentProps } from "react"; import type { IosElementContextItem, @@ -128,6 +128,17 @@ function renderComposer(overrides: Partial; } +function installPromptStashBridge(promptStashes: Record) { + const previousAde = (window as any).ade ?? {}; + (window as any).ade = { + ...previousAde, + agentChat: { + ...(previousAde.agentChat ?? {}), + promptStashes, + }, + }; +} + const CAPTION_FREE_PERMISSION_CASES: Array<{ provider: string; triggerName: string; @@ -343,15 +354,11 @@ describe("AgentChatComposer", () => { createdAt: "2026-07-28T12:00:00.000Z", }; const create = vi.fn().mockResolvedValue(created); - (window as any).ade = { - agentChat: { - promptStashes: { - list: vi.fn().mockResolvedValue([]), - create, - delete: vi.fn().mockResolvedValue(true), - }, - }, - }; + installPromptStashBridge({ + list: vi.fn().mockResolvedValue([]), + create, + delete: vi.fn().mockResolvedValue(true), + }); const props = renderComposer(); expect(screen.queryByRole("button", { name: "Stash prompt" })).toBeNull(); @@ -1139,6 +1146,362 @@ describe("AgentChatComposer", () => { expect(document.activeElement).toBe(textbox); }); + it("cycles the selected chat's prompt history and restores the draft", () => { + const onDraftChange = vi.fn(); + const onPromptHistoryNavigate = vi.fn(); + const promptHistory = [ + { text: "First prompt", eventKey: "prompt-1" }, + { text: "Second prompt", eventKey: "prompt-2" }, + ] as const; + const props = buildComposerProps({ + draft: "unfinished draft", + onDraftChange, + onPromptHistoryNavigate, + promptHistory, + turnActive: false, + }); + const view = render(); + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + + textbox.focus(); + textbox.setSelectionRange(0, 0); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + expect(onDraftChange).toHaveBeenLastCalledWith("Second prompt"); + expect(onPromptHistoryNavigate).toHaveBeenLastCalledWith(promptHistory[1]); + + view.rerender(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + expect(onDraftChange).toHaveBeenLastCalledWith("First prompt"); + expect(onPromptHistoryNavigate).toHaveBeenLastCalledWith(promptHistory[0]); + + view.rerender(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowDown" }); + expect(onDraftChange).toHaveBeenLastCalledWith("Second prompt"); + + view.rerender(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowDown" }); + expect(onDraftChange).toHaveBeenLastCalledWith("unfinished draft"); + expect(onPromptHistoryNavigate).toHaveBeenLastCalledWith(null); + }); + + it("keeps the selected prompt anchored when older history is prepended", () => { + const onDraftChange = vi.fn(); + const initialHistory = [ + { text: "Older prompt", eventKey: "prompt-1" }, + { text: "Latest prompt", eventKey: "prompt-2" }, + ] as const; + const props = buildComposerProps({ + draft: "unfinished draft", + onDraftChange, + promptHistory: initialHistory, + turnActive: false, + }); + const view = render(); + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + + textbox.focus(); + textbox.setSelectionRange(0, 0); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + expect(onDraftChange).toHaveBeenLastCalledWith("Latest prompt"); + + view.rerender( + , + ); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + + expect(onDraftChange).toHaveBeenLastCalledWith("Older prompt"); + }); + + it("keeps multiline caret motion after an interrupted history sequence", () => { + const onDraftChange = vi.fn(); + const promptHistory = [ + { text: "Older line one\nOlder line two", eventKey: "prompt-1" }, + { text: "Latest line one\nLatest line two", eventKey: "prompt-2" }, + ] as const; + const props = buildComposerProps({ + draft: "", + onDraftChange, + promptHistory, + turnActive: false, + }); + const view = render(); + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + + textbox.focus(); + textbox.setSelectionRange(0, 0); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + expect(onDraftChange).toHaveBeenLastCalledWith(promptHistory[1].text); + + view.rerender(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.pointerDown(document.body); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + + // The click canceled the rapid-history sequence. At the end of a multiline + // prompt, a single ArrowUp belongs to native caret movement. + expect(onDraftChange).toHaveBeenCalledTimes(1); + + textbox.setSelectionRange(0, 0); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + expect(onDraftChange).toHaveBeenLastCalledWith(promptHistory[0].text); + }); + + it("keeps native caret motion on a multiline new draft", () => { + const onDraftChange = vi.fn(); + const props = buildComposerProps({ + draft: "line one\nline two", + onDraftChange, + promptHistory: [{ text: "Latest prompt", eventKey: "prompt-1" }], + turnActive: false, + }); + render(); + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + + textbox.focus(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + + expect(onDraftChange).not.toHaveBeenCalled(); + }); + + it("treats a direction change as an interruption of the rapid sequence", () => { + const onDraftChange = vi.fn(); + const promptHistory = [ + { text: "Oldest line one\nOldest line two", eventKey: "prompt-1" }, + { text: "Middle line one\nMiddle line two", eventKey: "prompt-2" }, + { text: "Latest line one\nLatest line two", eventKey: "prompt-3" }, + ] as const; + const props = buildComposerProps({ + draft: "unfinished draft", + onDraftChange, + promptHistory, + turnActive: false, + }); + const view = render(); + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + + textbox.focus(); + textbox.setSelectionRange(0, 0); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + view.rerender(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + view.rerender(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowDown" }); + view.rerender(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + + // The Up after a Down is no longer part of the Down sequence. At the end + // of a multiline prompt it therefore belongs to native caret movement. + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + expect(onDraftChange).toHaveBeenCalledTimes(3); + }); + + it("expires the rapid-history window after three seconds", () => { + vi.useFakeTimers(); + try { + const onDraftChange = vi.fn(); + const promptHistory = [ + { text: "Older line one\nOlder line two", eventKey: "prompt-1" }, + { text: "Latest line one\nLatest line two", eventKey: "prompt-2" }, + ] as const; + const props = buildComposerProps({ + draft: "", + onDraftChange, + promptHistory, + turnActive: false, + }); + const view = render(); + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + + textbox.focus(); + textbox.setSelectionRange(0, 0); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + view.rerender(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + act(() => vi.advanceTimersByTime(3_001)); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + + expect(onDraftChange).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("auto-stashes an unsent draft before selecting the latest prompt", async () => { + const create = vi.fn().mockResolvedValue({ + id: "stash-auto-1", + text: "unfinished draft", + provider: "codex", + modelId: "openai/gpt-5.4", + createdAt: "2026-08-10T12:00:00.000Z", + }); + installPromptStashBridge({ + list: vi.fn().mockResolvedValue([]), + create, + delete: vi.fn().mockResolvedValue(true), + }); + const onDraftChange = vi.fn(); + const props = buildComposerProps({ + draft: "unfinished draft", + onDraftChange, + promptHistory: [{ text: "Latest prompt", eventKey: "prompt-1" }], + turnActive: false, + }); + render(); + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + + textbox.focus(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + + await waitFor(() => expect(create).toHaveBeenCalledWith({ + text: "unfinished draft", + provider: "codex", + modelId: "openai/gpt-5.4", + }, null)); + expect(onDraftChange).toHaveBeenCalledWith("Latest prompt"); + }); + + it("consumes an auto-stash that finishes after history navigation is interrupted", async () => { + let resolveCreate: ((entry: { + id: string; + text: string; + provider: string; + modelId: string; + createdAt: string; + }) => void) | undefined; + const create = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveCreate = resolve; + })); + const remove = vi.fn().mockResolvedValue(true); + installPromptStashBridge({ + list: vi.fn().mockResolvedValue([]), + create, + delete: remove, + }); + const props = buildComposerProps({ + draft: "unfinished draft", + promptHistory: [{ text: "Latest prompt", eventKey: "prompt-1" }], + turnActive: false, + }); + const view = render(); + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + + textbox.focus(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); + + view.rerender(); + resolveCreate?.({ + id: "stash-auto-interrupted-1", + text: "unfinished draft", + provider: "codex", + modelId: "openai/gpt-5.4", + createdAt: "2026-08-10T12:00:00.000Z", + }); + + await waitFor(() => expect(remove).toHaveBeenCalledWith( + { id: "stash-auto-interrupted-1" }, + null, + )); + }); + + it("consumes an auto-stash that finishes after the composer unmounts", async () => { + let resolveCreate: ((entry: { + id: string; + text: string; + provider: string; + modelId: string; + createdAt: string; + }) => void) | undefined; + const create = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveCreate = resolve; + })); + const remove = vi.fn().mockResolvedValue(true); + installPromptStashBridge({ + list: vi.fn().mockResolvedValue([]), + create, + delete: remove, + }); + const props = buildComposerProps({ + draft: "unfinished draft", + promptHistory: [{ text: "Latest prompt", eventKey: "prompt-1" }], + turnActive: false, + }); + const view = render(); + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + + textbox.focus(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); + + view.unmount(); + resolveCreate?.({ + id: "stash-auto-unmounted-1", + text: "unfinished draft", + provider: "codex", + modelId: "openai/gpt-5.4", + createdAt: "2026-08-10T12:00:00.000Z", + }); + + await waitFor(() => expect(remove).toHaveBeenCalledWith( + { id: "stash-auto-unmounted-1" }, + null, + )); + }); + + it("consumes the auto-stash when history restores the original draft", async () => { + const create = vi.fn().mockResolvedValue({ + id: "stash-auto-restore-1", + text: "unfinished draft", + provider: "codex", + modelId: "openai/gpt-5.4", + createdAt: "2026-08-10T12:00:00.000Z", + }); + const remove = vi.fn().mockResolvedValue(true); + installPromptStashBridge({ + list: vi.fn().mockResolvedValue([]), + create, + delete: remove, + }); + const props = buildComposerProps({ + draft: "unfinished draft", + promptHistory: [{ text: "Latest prompt", eventKey: "prompt-1" }], + turnActive: false, + }); + const view = render(); + const textbox = screen.getByRole("textbox") as HTMLTextAreaElement; + + textbox.focus(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowUp" }); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); + + view.rerender(); + textbox.setSelectionRange(textbox.value.length, textbox.value.length); + fireEvent.keyDown(textbox, { key: "ArrowDown" }); + + await waitFor(() => expect(remove).toHaveBeenCalledWith( + { id: "stash-auto-restore-1" }, + null, + )); + }); + it("stop only interrupts the active turn", () => { const props = renderComposer(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index bb49e689e4..e7442b9b52 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -29,6 +29,7 @@ import { type OpenProjectBinding, type PendingInputRequest, type AgentChatModelCatalogRefreshProvider, + type PromptStashEntry, } from "../../../shared/types"; import { buildChatContextAttachmentPrompt, @@ -106,9 +107,12 @@ import { type ComposerPromptStashHandle, } from "./ComposerPromptStash"; import { settingsRouteFor } from "../settings/settingsManifest"; +import type { AgentChatPromptHistoryEntry } from "./chatPromptHistory"; const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; const CLIPBOARD_IMAGE_PASTE_FALLBACK_DELAY_MS = 80; +const PROMPT_HISTORY_SEQUENCE_TIMEOUT_MS = 3_000; +type PromptHistoryArrowKey = "ArrowUp" | "ArrowDown"; const BASE64_ENCODE_CHUNK_SIZE = 0x8000; const ISSUE_CONTEXT_MENU_WIDTH = 256; const ISSUE_CONTEXT_MENU_GAP = 8; @@ -1474,7 +1478,8 @@ export function AgentChatComposer({ usageViewModel = null, compactionPulse = false, draft, - lastSentUserMessage = null, + promptHistory = [], + onPromptHistoryNavigate, attachments, composerMachineBinding = null, attachmentPersistenceUnavailableReason = null, @@ -1612,8 +1617,10 @@ export function AgentChatComposer({ usageViewModel?: ContextUsageViewModel | null; compactionPulse?: boolean; draft: string; - /** Last message the user sent in this chat — recalled by ArrowUp on line 1. */ - lastSentUserMessage?: string | null; + /** Chronological prompts from the selected chat, oldest first. */ + promptHistory?: readonly AgentChatPromptHistoryEntry[]; + /** Called when keyboard history selects a prompt so the transcript can follow it. */ + onPromptHistoryNavigate?: (entry: AgentChatPromptHistoryEntry | null) => void; attachments: AgentChatFileRef[]; /** Effective runtime owning this composer and its prompt stashes. */ composerMachineBinding?: OpenProjectBinding | null; @@ -1892,6 +1899,107 @@ export function AgentChatComposer({ // freeze trigger/menu re-evaluation so half-composed text can't open or // retarget the command menu; detection re-runs once on compositionend. const imeComposingRef = useRef(false); + const promptHistoryIndexRef = useRef(null); + const promptHistorySelectedKeyRef = useRef(null); + const promptHistoryStashRef = useRef<{ + promise: Promise; + consume: ComposerPromptStashHandle["consume"]; + } | null>(null); + const promptHistoryDraftBeforeRef = useRef(null); + const promptHistoryAppliedDraftRef = useRef(null); + const promptHistoryObservedDraftRef = useRef(draft); + const promptHistorySequenceTimerRef = useRef(null); + const promptHistorySequenceActiveRef = useRef(false); + const promptHistorySequenceDirectionRef = useRef(null); + const cancelPromptHistorySequence = useCallback(() => { + if (promptHistorySequenceTimerRef.current !== null) { + window.clearTimeout(promptHistorySequenceTimerRef.current); + promptHistorySequenceTimerRef.current = null; + } + promptHistorySequenceActiveRef.current = false; + promptHistorySequenceDirectionRef.current = null; + }, []); + const armPromptHistorySequence = useCallback((direction: PromptHistoryArrowKey) => { + cancelPromptHistorySequence(); + promptHistorySequenceActiveRef.current = true; + promptHistorySequenceDirectionRef.current = direction; + promptHistorySequenceTimerRef.current = window.setTimeout(() => { + promptHistorySequenceTimerRef.current = null; + promptHistorySequenceActiveRef.current = false; + }, PROMPT_HISTORY_SEQUENCE_TIMEOUT_MS); + }, [cancelPromptHistorySequence]); + const clearPromptHistory = useCallback(() => { + const wasNavigating = promptHistoryIndexRef.current !== null; + cancelPromptHistorySequence(); + promptHistoryIndexRef.current = null; + promptHistorySelectedKeyRef.current = null; + if (wasNavigating) { + const pendingStash = promptHistoryStashRef.current; + promptHistoryStashRef.current = null; + if (pendingStash) { + void pendingStash.promise.then((entry) => { + if (entry) void pendingStash.consume(entry); + }); + } + } + promptHistoryDraftBeforeRef.current = null; + promptHistoryAppliedDraftRef.current = null; + if (wasNavigating) onPromptHistoryNavigate?.(null); + }, [cancelPromptHistorySequence, onPromptHistoryNavigate]); + + useEffect(() => { + clearPromptHistory(); + }, [clearPromptHistory, sessionId]); + + useEffect(() => { + const cancelIfActive = () => { + if (promptHistorySequenceActiveRef.current) cancelPromptHistorySequence(); + }; + const listenerOptions = { capture: true, passive: true } as const; + window.addEventListener("pointerdown", cancelIfActive, listenerOptions); + window.addEventListener("wheel", cancelIfActive, listenerOptions); + window.addEventListener("touchstart", cancelIfActive, listenerOptions); + return () => { + window.removeEventListener("pointerdown", cancelIfActive, listenerOptions); + window.removeEventListener("wheel", cancelIfActive, listenerOptions); + window.removeEventListener("touchstart", cancelIfActive, listenerOptions); + }; + }, [cancelPromptHistorySequence]); + + useEffect(() => () => { + cancelPromptHistorySequence(); + const pendingStash = promptHistoryStashRef.current; + promptHistoryStashRef.current = null; + if (pendingStash) { + void pendingStash.promise.then((entry) => { + if (entry) void pendingStash.consume(entry); + }); + } + }, [cancelPromptHistorySequence]); + + useEffect(() => { + const previousDraft = promptHistoryObservedDraftRef.current; + promptHistoryObservedDraftRef.current = draft; + if ( + promptHistoryIndexRef.current !== null + && draft !== previousDraft + && draft !== promptHistoryAppliedDraftRef.current + ) { + clearPromptHistory(); + } + }, [clearPromptHistory, draft]); + + useLayoutEffect(() => { + const selectedKey = promptHistorySelectedKeyRef.current; + if (promptHistoryIndexRef.current === null || selectedKey === null) return; + const nextIndex = promptHistory.findIndex((entry) => entry.eventKey === selectedKey); + if (nextIndex < 0) { + clearPromptHistory(); + return; + } + promptHistoryIndexRef.current = nextIndex; + }, [clearPromptHistory, promptHistory]); + const useRichComposer = smartLinkEditorEnabled || iosElementContextItems.length > 0 || appControlContextItems.length > 0 @@ -3748,9 +3856,145 @@ export function AgentChatComposer({ return orchestratorModeActive ? "rgba(217, 70, 239, 0.36)" : null; }, [orchestratorModeActive]); + const applyPromptHistoryEntry = useCallback(( + entry: AgentChatPromptHistoryEntry, + index: number, + currentText: string, + direction: PromptHistoryArrowKey, + ) => { + if (promptHistoryIndexRef.current === null) { + promptHistoryDraftBeforeRef.current = currentText; + } + promptHistoryIndexRef.current = index; + promptHistorySelectedKeyRef.current = entry.eventKey; + promptHistoryAppliedDraftRef.current = entry.text; + armPromptHistorySequence(direction); + if (useRichComposer) { + setRichEditorText(entry.text); + onDraftChange(entry.text); + requestAnimationFrame(() => richEditorRef.current?.focus({ preventScroll: true })); + } else { + onDraftChange(entry.text); + restoreTextareaCaret(entry.text.length); + } + onPromptHistoryNavigate?.(entry); + }, [armPromptHistorySequence, onDraftChange, onPromptHistoryNavigate, restoreTextareaCaret, setRichEditorText, useRichComposer]); + + const stashDraftBeforeHistory = useCallback((currentText: string) => { + if (!currentText.trim() && attachments.length === 0) return; + promptHistoryDraftBeforeRef.current = currentText; + if (typeof window.ade?.agentChat?.promptStashes?.create === "function") { + const stashHandle = promptStashRef.current; + if (stashHandle) { + promptHistoryStashRef.current = { + promise: stashHandle.activatePreservingDraft(), + consume: stashHandle.consume, + }; + } + } + }, [attachments.length]); + + const restorePromptHistoryDraft = useCallback(() => { + const promptHistoryStash = promptHistoryStashRef.current; + promptHistoryStashRef.current = null; + const text = promptHistoryDraftBeforeRef.current ?? ""; + promptHistoryIndexRef.current = null; + promptHistorySelectedKeyRef.current = null; + promptHistoryDraftBeforeRef.current = null; + promptHistoryAppliedDraftRef.current = null; + if (useRichComposer) { + setRichEditorText(text); + onDraftChange(text); + requestAnimationFrame(() => richEditorRef.current?.focus({ preventScroll: true })); + } else { + onDraftChange(text); + restoreTextareaCaret(text.length); + } + onPromptHistoryNavigate?.(null); + if (promptHistoryStash) { + void promptHistoryStash.promise.then((entry) => { + if (entry) void promptHistoryStash.consume(entry); + }); + } + }, [onDraftChange, onPromptHistoryNavigate, restoreTextareaCaret, setRichEditorText, useRichComposer]); + + const handlePromptHistoryNavigation = useCallback((event: React.KeyboardEvent): boolean => { + if (!promptHistory.length || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return false; + const target = event.currentTarget; + const currentText = target instanceof HTMLTextAreaElement ? target.value : serializeRichEditor(); + const currentIndex = promptHistoryIndexRef.current; + const selection = target instanceof HTMLTextAreaElement ? null : window.getSelection(); + const selectionCollapsed = target instanceof HTMLTextAreaElement + ? target.selectionStart === target.selectionEnd + : !selection || !selection.rangeCount || selection.getRangeAt(0).collapsed; + if (!selectionCollapsed) { + cancelPromptHistorySequence(); + return false; + } + + const cursorOffset = target instanceof HTMLTextAreaElement + ? target.selectionStart ?? currentText.length + : getRichCursorTextOffset(); + const atFirstLine = cursorOffset <= 0 || currentText.lastIndexOf("\n", cursorOffset - 1) < 0; + const atLastLine = currentText.indexOf("\n", cursorOffset) < 0; + if ( + promptHistorySequenceActiveRef.current + && promptHistorySequenceDirectionRef.current !== event.key + ) { + // A direction change is an action of its own. It ends the rapid sequence + // before the normal line-boundary rules decide whether this arrow can + // enter history in the opposite direction. + cancelPromptHistorySequence(); + } + const sequenceActive = promptHistorySequenceActiveRef.current + && promptHistorySequenceDirectionRef.current === event.key; + + if (currentIndex === null) { + // Up from a new draft is the history gesture. Preserve the draft in the + // existing per-chat stash flow before replacing it with the latest sent + // prompt. Down from a new draft remains native textarea behavior. + if (event.key !== "ArrowUp" || !atFirstLine) return false; + const latestIndex = promptHistory.length - 1; + const entry = promptHistory[latestIndex]; + if (!entry) return false; + event.preventDefault(); + stashDraftBeforeHistory(currentText); + applyPromptHistoryEntry(entry, latestIndex, currentText, event.key); + return true; + } + + // While the user is actively holding a three-second same-direction arrow + // sequence, that direction always means history. Once the sequence expires + // or is interrupted, native multiline caret motion wins until the caret + // reaches the relevant line boundary. + if (!sequenceActive && (event.key === "ArrowUp" ? !atFirstLine : !atLastLine)) return false; + + event.preventDefault(); + const nextIndex = event.key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1; + if (nextIndex < 0) { + // There is no history before the oldest prompt. Do not wrap or move the + // caret when the user keeps pressing ArrowUp at the top. + return true; + } + if (nextIndex >= promptHistory.length) { + restorePromptHistoryDraft(); + return true; + } + const entry = promptHistory[nextIndex]; + if (!entry) return true; + applyPromptHistoryEntry(entry, nextIndex, currentText, event.key); + return true; + }, [applyPromptHistoryEntry, cancelPromptHistorySequence, getRichCursorTextOffset, promptHistory, restorePromptHistoryDraft, serializeRichEditor, stashDraftBeforeHistory]); + /* ── Keyboard handler for composer input ── */ const handleKeyDown = (event: React.KeyboardEvent) => { const commandModified = event.metaKey || event.ctrlKey; + const isPlainHistoryArrow = + (event.key === "ArrowUp" || event.key === "ArrowDown") + && !commandModified + && !event.shiftKey + && !event.altKey; + if (!isPlainHistoryArrow) cancelPromptHistorySequence(); if ( event.key.toLowerCase() === "s" && commandModified @@ -3762,6 +4006,7 @@ export function AgentChatComposer({ return; } if (promptStashRef.current?.handleMenuKeyDown(event)) { + cancelPromptHistorySequence(); event.preventDefault(); return; } @@ -3822,6 +4067,7 @@ export function AgentChatComposer({ /* Command menu keyboard navigation */ if (commandMenuTrigger) { + if (event.key === "ArrowUp" || event.key === "ArrowDown") cancelPromptHistorySequence(); if (event.key === "Escape") { event.preventDefault(); setCommandMenuTrigger(null); return; } if (event.key === "ArrowDown") { event.preventDefault(); commandMenuRef.current?.moveDown(); return; } if (event.key === "ArrowUp") { event.preventDefault(); commandMenuRef.current?.moveUp(); return; } @@ -3839,30 +4085,23 @@ export function AgentChatComposer({ ? target.selectionStart === 0 && target.selectionEnd === 0 : getRichCursorTextOffset() === 0; if (atPromptStart && focusLastImageAttachment()) { + cancelPromptHistorySequence(); event.preventDefault(); return; } - // Terminal-style recall: ArrowUp on the first line fills the last message - // you sent (so you can re-run or tweak it). Skipped for multi-line drafts - // (so ArrowUp still navigates between lines) and when nothing was sent yet. - if (target instanceof HTMLTextAreaElement) { - const recall = lastSentUserMessage?.trim() ?? ""; - const isMultiLine = target.value.indexOf("\n") !== -1; - const onFirstLine = target.selectionStart === target.selectionEnd - && target.value.slice(0, target.selectionStart).indexOf("\n") === -1; - if (recall && !isMultiLine && onFirstLine && recall !== draft) { - event.preventDefault(); - onDraftChange(recall); - requestAnimationFrame(() => { - const el = textareaRef.current; - if (el) { - el.focus({ preventScroll: true }); - el.selectionStart = el.selectionEnd = el.value.length; - } - }); - return; - } - } + // Terminal-style recall: ArrowUp on the first line enters this chat's + // prompt history while multiline drafts keep normal text editing. + if (handlePromptHistoryNavigation(event)) return; + } + + if ( + event.key === "ArrowDown" + && !commandModified + && !event.shiftKey + && !event.altKey + && handlePromptHistoryNavigation(event) + ) { + return; } if (event.key === "@" && !commandModified && !event.altKey) { @@ -4061,6 +4300,7 @@ export function AgentChatComposer({ const handleRichEditorInput = useCallback((event?: React.FormEvent) => { const editor = richEditorRef.current; if (!editor) return; + clearPromptHistory(); const inputType = (event?.nativeEvent as InputEvent | undefined)?.inputType ?? ""; if (!imeComposingRef.current && (inputType === "insertParagraph" || /\s$/.test(editor.textContent ?? ""))) { if (tokenizeSmartLinksInEditor()) return; @@ -4080,7 +4320,7 @@ export function AgentChatComposer({ setCommandMenuTrigger(null); } captureRichSelection(); - }, [captureRichSelection, getRichTriggerContext, onDraftChange, serializeRichEditor, tokenizeSmartLinksInEditor]); + }, [captureRichSelection, clearPromptHistory, getRichTriggerContext, onDraftChange, serializeRichEditor, tokenizeSmartLinksInEditor]); const singleModelBlockedMessage = modelUnavailableMessage?.trim() ? modelUnavailableMessage : null; const singleModelReady = Boolean(modelId) && !singleModelBlockedMessage; @@ -5455,8 +5695,14 @@ export function AgentChatComposer({ onKeyDown={handleKeyDown} onPaste={handlePaste} onKeyUp={captureRichSelection} - onMouseUp={captureRichSelection} - onBlur={captureRichSelection} + onMouseUp={() => { + cancelPromptHistorySequence(); + captureRichSelection(); + }} + onBlur={() => { + cancelPromptHistorySequence(); + captureRichSelection(); + }} onClick={(event) => { const target = event.target as HTMLElement | null; const smartLinkChip = target?.closest?.("[data-smart-link-url]") as HTMLElement | null; @@ -5533,6 +5779,7 @@ export function AgentChatComposer({ value={draft} onChange={(event) => { const val = event.target.value; + clearPromptHistory(); onDraftChange(val); if (/\s$/.test(val) && findSmartLinks(val).length > 0) { setSmartLinkEditorEnabled(true); @@ -5583,6 +5830,8 @@ export function AgentChatComposer({ placeholder={composerInputLockMessage ?? (turnActive ? "Steer the active turn..." : (promptSuggestion || messagePlaceholder || "Type to vibecode..."))} onKeyDown={handleKeyDown} onPaste={handlePaste} + onMouseUp={cancelPromptHistorySequence} + onBlur={cancelPromptHistorySequence} /> )} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 473de42de3..b3a6c2c6c4 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -84,7 +84,7 @@ import { collapseChatTranscriptEvents, groupConsecutiveWorkLogRows, } from "./chatTranscriptRows"; -import { ChatPrPaneInsetContext } from "./chatPrPaneInset"; +import { promptHistoryEventKey } from "./chatPromptHistory"; import { resetFilesWorkspaceCacheForTests } from "./chatWorkspacePaths"; import { mixedIdToolActivityBoundaryEvents } from "../../../shared/testFixtures/chatToolActivity"; @@ -137,6 +137,7 @@ function renderMessageList( onRunUnprocessedMessage?: (event: Extract) => void | Promise; onRestoreCancelledQueue?: (recoveryId: string) => Promise; scrollToRowKeyRequest?: { key: string; requestId: number } | null; + scrollToPromptHistoryRequest?: { eventKey: string; requestId: number } | null; hasOlderHistory?: boolean; loadingOlderHistory?: boolean; olderHistoryError?: string | null; @@ -165,6 +166,7 @@ function renderMessageList( onRunUnprocessedMessage={options?.onRunUnprocessedMessage} onRestoreCancelledQueue={options?.onRestoreCancelledQueue} scrollToRowKeyRequest={options?.scrollToRowKeyRequest} + scrollToPromptHistoryRequest={options?.scrollToPromptHistoryRequest} hasOlderHistory={options?.hasOlderHistory} loadingOlderHistory={options?.loadingOlderHistory} olderHistoryError={options?.olderHistoryError} @@ -200,20 +202,6 @@ const transcriptProofArtifact: ComputerUseArtifactView = { reviewNote: null, }; -/** The message list under a floating PR pane publishing `prPaneBottomViewportPx`. */ -function renderMessageListUnderPrPane( - events: AgentChatEventEnvelope[], - prPaneBottomViewportPx: number | null, -) { - return render( - - - - - , - ); -} - function makeRect(box: { top?: number; left?: number; width?: number; height?: number }): DOMRect { const top = box.top ?? 0; const left = box.left ?? 0; @@ -235,25 +223,22 @@ function makeRect(box: { top?: number; left?: number; width?: number; height?: n /** * jsdom has no layout, so every box measures 0×0: the minimap rail decides it * is inert and `resolveMinimapIndexFromPointer` returns null for every pointer - * Y. Stub the two boxes the rail actually reads — the list root it is - * positioned against, and its own hit strip. + * Y. Stub the two boxes the rail actually reads — the list root and its own + * hit strip. */ function stubMinimapLayout(options?: { listWidth?: number; listHeight?: number; - /** Viewport-space top edge of the list root — the frame the PR pane converts into. */ - listTop?: number; railTop?: number; railHeight?: number; }): { railTop: number; railHeight: number } { const listWidth = options?.listWidth ?? 960; const listHeight = options?.listHeight ?? 600; - const listTop = options?.listTop ?? 0; const railTop = options?.railTop ?? 100; const railHeight = options?.railHeight ?? 400; vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) { if (this.hasAttribute("data-chat-message-list-root")) { - return makeRect({ width: listWidth, height: listHeight, top: listTop }); + return makeRect({ width: listWidth, height: listHeight }); } if (this.tagName === "BUTTON" && this.closest("[data-testid='chat-user-minimap']")) { return makeRect({ top: railTop, height: railHeight, width: 24 }); @@ -2021,22 +2006,11 @@ describe("AgentChatMessageList transcript rendering", () => { expect(transcript.scrollTop).toBe(0); }); - it("insets the rail by the PR pane's rect delta, not by its height", () => { - // REGRESSION: the floating PR pane is positioned against the chat surface - // while the rail is positioned against the message-list root, which sits - // 200px lower (chat header + sync hairline). Converting a published HEIGHT - // with the pane's `top-3` constant would read 12 + 240 + 12 = 264 here and - // push the rail a whole header below where it belongs. - stubMinimapLayout({ listTop: 200 }); - renderMessageListUnderPrPane(MINIMAP_TRANSCRIPT, 300); - - // 300 (pane bottom) - 200 (list root top) + 12 (gap). - expect(screen.getByTestId("chat-user-minimap").style.top).toBe("112px"); - }); - - it("drops the rail inset entirely when no PR pane is floating", () => { - stubMinimapLayout({ listTop: 200 }); - renderMessageListUnderPrPane(MINIMAP_TRANSCRIPT, null); + it("keeps the rail anchored when a PR pane is floating", () => { + // The PR pane is an overlay. Its presence must not move the transcript's + // history markers down into the space below the card. + stubMinimapLayout(); + renderMessageList(MINIMAP_TRANSCRIPT); expect(screen.getByTestId("chat-user-minimap").style.top).toBe("0px"); }); @@ -2326,6 +2300,28 @@ describe("AgentChatMessageList transcript rendering", () => { expect(transcript.scrollTop).toBe(0); }); + it("scrolls to the prompt selected by composer history", () => { + const view = renderMessageList(MINIMAP_TRANSCRIPT); + const transcript = document.querySelector(".ade-chat-timeline-pane") as HTMLDivElement; + Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1_000 }); + Object.defineProperty(transcript, "clientHeight", { configurable: true, value: 200 }); + + const target = MINIMAP_TRANSCRIPT[2]!; + if (target.event.type !== "user_message") throw new Error("test target must be a user message"); + const request = { + eventKey: promptHistoryEventKey({ timestamp: target.timestamp, event: target.event }), + requestId: 1, + }; + view.rerender( + + + + , + ); + + expect(transcript.scrollTop).toBeGreaterThan(0); + }); + // "absorbs tool summaries" test removed: tested old ChatWorkLogBlock // summary absorption rendering which changes with UI iterations. diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 222fc16d70..9236922017 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -121,6 +121,7 @@ import { BackgroundJobLine, SubagentResultCard, SubagentSpawnCard, SubagentStopp import { AdeCard } from "./AdeCard"; import { navigateToSpawnedChat } from "./spawnNavigation"; import { ChatUserMinimap } from "./ChatUserMinimap"; +import { promptHistoryEventKey } from "./chatPromptHistory"; import { AgentCliAuthCard, type AgentCliAuthCardInfo } from "./AgentCliAuthCard"; import { ChatContinuityRecoveryCard } from "./ChatContinuityRecoveryCard"; import { classifyProviderFailure, ProviderFailureRecoveryCard } from "./ProviderFailureRecoveryCard"; @@ -5230,6 +5231,7 @@ function AgentChatMessageListMain({ onReturnToLatest, mosaic, scrollToRowKeyRequest, + scrollToPromptHistoryRequest, proofArtifacts = [], allowLocalProofArtifactProtocol = false, onOpenProofDrawer, @@ -5280,6 +5282,8 @@ function AgentChatMessageListMain({ mosaic?: MosaicRenderContext; /** Imperative jump request used by the while-you-were-away wake digest. */ scrollToRowKeyRequest?: { key: string; requestId: number } | null; + /** Imperative jump request emitted when composer history selects a prompt. */ + scrollToPromptHistoryRequest?: { eventKey: string; requestId: number } | null; /** Intentional proof linked to this chat, rendered at the transcript tail. */ proofArtifacts?: ComputerUseArtifactView[]; /** Local Electron can stream larger artifacts through its range protocol. */ @@ -5294,6 +5298,7 @@ function AgentChatMessageListMain({ const contentWrapperRef = useRef(null); const olderHistorySentinelRef = useRef(null); const lastHandledScrollToRowRequestIdRef = useRef(null); + const lastHandledPromptHistoryRequestIdRef = useRef(null); const location = useLocation(); const navigate = useNavigate(); // Carries the CollapseTranscriptContext alongside events/rows so appended @@ -5330,11 +5335,9 @@ function AgentChatMessageListMain({ restoredScrollMemory?.wasPinnedToBottom === false ? (restoredScrollMemory.lastSeenRowKey ?? null) : null, ); // Measured geometry the minimap rail needs. Kept as two pieces of state so a - // width-only change (pane resize) and a height-only change don't invalidate - // each other. `top` is viewport-space: it is what converts the floating PR - // pane's published bottom edge into the rail's own coordinate frame. - const [listRootBoxPx, setListRootBoxPx] = useState<{ width: number; height: number; top: number }>( - { width: 0, height: 0, top: 0 }, + // width-only change and a height-only change don't invalidate each other. + const [listRootBoxPx, setListRootBoxPx] = useState<{ width: number; height: number }>( + { width: 0, height: 0 }, ); const [columnWidthPx, setColumnWidthPx] = useState(0); // Track the single pending rAF handle for scroll-to-bottom writes so we @@ -5787,12 +5790,10 @@ function AgentChatMessageListMain({ const rect = el.getBoundingClientRect(); const width = Math.max(el.clientWidth, rect.width); const height = Math.max(el.clientHeight, rect.height); - const top = rect.top; setListRootBoxPx((current) => ( movedByAPixel(current.width, width) || movedByAPixel(current.height, height) - || movedByAPixel(current.top, top) - ? { width, height, top } + ? { width, height } : current )); }, []); @@ -6012,6 +6013,17 @@ function AgentChatMessageListMain({ scrollToRowKey(scrollToRowKeyRequest.key); }, [scrollToRowKey, scrollToRowKeyRequest]); + useEffect(() => { + if (!scrollToPromptHistoryRequest?.eventKey) return; + if (lastHandledPromptHistoryRequestIdRef.current === scrollToPromptHistoryRequest.requestId) return; + lastHandledPromptHistoryRequestIdRef.current = scrollToPromptHistoryRequest.requestId; + const rowIndex = groupedRows.findIndex((row) => ( + row.event.type === "user_message" + && promptHistoryEventKey({ timestamp: row.timestamp, event: row.event }) === scrollToPromptHistoryRequest.eventKey + )); + if (rowIndex >= 0) scrollToRowIndexNearTop(rowIndex); + }, [groupedRows, scrollToPromptHistoryRequest, scrollToRowIndexNearTop]); + const scheduleAnchoredRowCorrection = useCallback((rowKey: string) => { if (anchorCorrectionRafRef.current !== null) { cancelAnimationFrame(anchorCorrectionRafRef.current); @@ -6440,6 +6452,17 @@ function AgentChatMessageListMain({ [groupedRows], ); + const promptHistoryFocusIndex = useMemo(() => { + if (!scrollToPromptHistoryRequest?.eventKey) return null; + const rowIndex = groupedRows.findIndex((row) => ( + row.event.type === "user_message" + && promptHistoryEventKey({ timestamp: row.timestamp, event: row.event }) === scrollToPromptHistoryRequest.eventKey + )); + if (rowIndex < 0) return null; + const minimapIndex = minimapSourceEntries.findIndex((entry) => entry.rowIndex === rowIndex); + return minimapIndex >= 0 ? minimapIndex : null; + }, [groupedRows, minimapSourceEntries, scrollToPromptHistoryRequest]); + const rowStartOffsetsForMinimap = useMemo(() => { void measurementTick; return computeRowStartOffsets(groupedRows.length, rowHeight, timelineRowGapPx); @@ -6693,9 +6716,8 @@ function AgentChatMessageListMain({ {/* Direct child of the list root on purpose: the rail's `left-0` and all of its gutter maths assume the offset parent is the element whose width is `listWidthPx`. An intermediate max-width wrapper would silently shift - the rail into the message column. The PR pane's edge is NOT passed — - the rail reads it from context (see chatPrPaneInset.ts) and subtracts - the list-root top measured here to land in its own frame. */} + the rail into the message column. Floating panes stay independent of + this fixed transcript anchor. */}
{ }); describe("AgentChatPane pane reserve", () => { - // Wide enough that a floating pane does NOT fit in the centered column's own - // side margin ((1000 - 832) / 2 = 84px < the 276px pane), so the chat must - // reserve a gutter — the only regime where this bug is visible at all. + // Wide enough that a right chat-actions pane does NOT fit in the centered + // column's own side margin ((1000 - 832) / 2 = 84px < the 276px pane). const OBSERVED_WIDTH_PX = 1000; - const EXPECTED_RESERVE = "276px"; let originalResizeObserver: unknown; beforeEach(() => { @@ -1473,7 +1471,7 @@ describe("AgentChatPane pane reserve", () => { return shell.style.getPropertyValue("--chat-pane-reserve-left").trim(); } - it("reserves a left gutter for the floating PR pane on the session surface", async () => { + it("keeps the left reserve at zero for the floating PR pane", async () => { const session = buildSession("session-1", { title: "PR pane chat" }); installAdeMocks({ sessions: [session] }); seedDrawerStore(); @@ -1482,9 +1480,7 @@ describe("AgentChatPane pane reserve", () => { const { container } = renderPane(session); - await waitFor(() => { - expect(readLeftReserve(container)).toBe(EXPECTED_RESERVE); - }); + await waitFor(() => expect(readLeftReserve(container)).toBe("0px")); }); it("reserves nothing on the draft surface, which renders no floating panes", async () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 185fd23638..224a1b3bd5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -110,6 +110,7 @@ import { filterChatModelIdsForSession } from "../../../shared/chatModelSwitching import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { cn } from "../ui/cn"; import { AgentChatComposer, type ParallelComposerControlSlot } from "./AgentChatComposer"; +import { collectAgentChatPromptHistory, type AgentChatPromptHistoryEntry } from "./chatPromptHistory"; import { ChatLifecycleBanner } from "./ChatLifecycleBanner"; import { resolveModelDescriptorWithRuntimeCatalog, descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog"; import { latestContextUsageInput, toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel"; @@ -189,7 +190,6 @@ import { ChatActionsDrawerPanel, type ChatActionsTab } from "./ChatActionsDrawer import { ChatSourcesPanel } from "./ChatSourcesPanel"; import { CrossMachineHandoffModal } from "./CrossMachineHandoffModal"; import { ChatPrPane } from "./ChatPrPane"; -import { ChatPrPaneInsetContext, usePrPaneInsetObserver } from "./chatPrPaneInset"; import { useChatPrAutoPop } from "./useChatPrAutoPop"; import { patchChatCompanionUiState, @@ -950,30 +950,22 @@ function staleDraftLaunchJobMessage(job: DraftLaunchJob): string { } /** - * 3-quadrant reserve. The chat reserves horizontal space for whichever floating - * side panes are open (when there is room), so the centered transcript + composer - * re-center in the remaining area rather than leaving an empty gutter opposite an - * open pane. On a narrow surface it stops reserving so the chat keeps full width - * (the pane then overlays). Right is preferred over left when space is tight. + * The right chat-actions pane may reserve space when it would otherwise cover the + * centered transcript. The PR pane is deliberately excluded: it is a true left + * overlay, so opening it must not move the transcript or its minimap rail. */ const PANE_RESERVE_RIGHT_PX = 276; // 16.5rem pane + 12px gutter -const PANE_RESERVE_LEFT_PX = 276; // 16.5rem pane + 12px gutter const CHAT_MIN_WIDTH_PX = 360; // recenter the chat as soon as a normal screen allows // The centered chat column's width. NOT a constant any more: it is the JS half // of the `--chat-content-width` token (chatAppearance.ts), so this maths and the // CSS that lays the column out can never disagree. /** - * Reserve gutter space for the floating panes — but ONLY when they'd otherwise - * overlap the centered chat column. When the window is wide enough that a pane - * fits in the chat's natural side margin, reserve nothing so the chat does NOT - * shift (the pane just overlays the empty margin). When the window is too narrow - * for the pane to fit beside the column, reserve the pane's width so the chat - * shifts over instead of being covered. Right is preferred over left when tight. + * Reserve right gutter space only when the chat-actions pane would otherwise + * overlap the centered chat column. The left PR pane always overlays. */ const ZERO_PANE_RESERVE = { left: "0px", right: "0px" } as const; function computePaneReserve( width: number, - leftOpen: boolean, rightOpen: boolean, ): { left: string; right: string } { if (width <= 0) return { left: "0px", right: "0px" }; @@ -987,15 +979,7 @@ function computePaneReserve( ) { right = PANE_RESERVE_RIGHT_PX; } - let left = 0; - if ( - leftOpen - && naturalSideMargin < PANE_RESERVE_LEFT_PX - && width - right - PANE_RESERVE_LEFT_PX >= CHAT_MIN_WIDTH_PX - ) { - left = PANE_RESERVE_LEFT_PX; - } - return { left: `${left}px`, right: `${right}px` }; + return { left: "0px", right: `${right}px` }; } type AiStatusSnapshot = AiSettingsStatus & { @@ -3768,9 +3752,6 @@ export function AgentChatPane({ companionStateKey === WORK_START_DRAFT_COMPANION_STATE_KEY && legacyWorkDraftLaneId ? `draft:${legacyWorkDraftLaneId}` : null; - // Measured height of the floating PR pane card, published to the minimap rail - // through ChatPrPaneInsetContext so it can re-centre in the band left below. - const prPaneInset = usePrPaneInsetObserver(); const composerDraftStorageKeyValues = useMemo(() => { const primary = composerDraftStorageKeys({ projectRoot, @@ -4272,6 +4253,22 @@ export function AgentChatPane({ dismissed: boolean; } | null>(null); const [wakeJumpRequest, setWakeJumpRequest] = useState<{ key: string; requestId: number } | null>(null); + const promptHistoryJumpSequenceRef = useRef(0); + const [promptHistoryJumpRequest, setPromptHistoryJumpRequest] = useState<{ + eventKey: string; + requestId: number; + } | null>(null); + const handlePromptHistoryNavigate = useCallback((entry: AgentChatPromptHistoryEntry | null) => { + if (!entry) { + setPromptHistoryJumpRequest(null); + return; + } + promptHistoryJumpSequenceRef.current += 1; + setPromptHistoryJumpRequest({ + eventKey: entry.eventKey, + requestId: promptHistoryJumpSequenceRef.current, + }); + }, []); useEffect(() => { if (!selectedSessionId) { setWakeAwayWindow(null); @@ -4429,18 +4426,13 @@ export function AgentChatPane({ // orchestrator run that has surfaced mission events — non-AGI chats stay null // and the Missions tab never appears. const selectedMission = useMemo(() => deriveMissionSnapshot(selectedEvents), [selectedEvents]); - // Last message the user actually sent in this chat — fed to the composer so - // ArrowUp on line 1 recalls it (terminal-style). - const lastSentUserMessage = useMemo(() => { - for (let i = selectedEvents.length - 1; i >= 0; i -= 1) { - const event = selectedEvents[i]?.event; - if (event?.type === "user_message") { - const text = userMessageVisibleText(event).trim(); - if (text) return text; - } - } - return null; - }, [selectedEvents]); + // Keep keyboard recall scoped to the transcript currently selected in Work. + // The sidebar contains other sessions, but none of those prompts belong in + // this composer's history. + const promptHistory = useMemo( + () => collectAgentChatPromptHistory(selectedEventsForDisplay), + [selectedEventsForDisplay], + ); const [killingWorkerIds, setKillingWorkerIds] = useState>(() => new Set()); const killDroidWorker = useCallback( (workerSessionId: string) => { @@ -12025,7 +12017,8 @@ export function AgentChatPane({ usageViewModel={selectedUsageViewModel} compactionPulse={contextCompactionPulse} draft={draft} - lastSentUserMessage={lastSentUserMessage} + promptHistory={promptHistory} + onPromptHistoryNavigate={handlePromptHistoryNavigate} attachments={attachments} composerMachineBinding={composerMachineBinding} attachmentPersistenceUnavailableReason={draftAttachmentUnavailableReason} @@ -12667,9 +12660,9 @@ export function AgentChatPane({ const chatActionsFloating = chatActionsOpen && supportsSplit && !heavyRightPaneOpen; const chatActionsRightPaneOpen = chatActionsOpen && !chatActionsFloating; const prFloating = prPaneOpen && Boolean(laneId) && supportsSplit; - // The chat reserves gutter space and shifts over to make room for each open - // floating pane (no overlap); the panes themselves fade in/out (opacity) — the - // two are independent. + // Only the right chat-actions pane may reserve gutter space. The PR pane stays + // a fixed overlay so the transcript and minimap never shift when it opens; + // its z-30 card wins any intentional overlap. // // Gate the reserve on the surface that actually renders those panes. Both the // PR pane and the chat-actions pane live in the `selectedSessionId` branch @@ -12679,7 +12672,7 @@ export function AgentChatPane({ // the hero composer sideways to clear a pane that is not on screen. const sessionSurfaceMounted = Boolean(selectedSessionId); const paneReserve = sessionSurfaceMounted - ? computePaneReserve(chatAreaWidth, prFloating, chatActionsFloating) + ? computePaneReserve(chatAreaWidth, chatActionsFloating) : ZERO_PANE_RESERVE; // When a pane doesn't force the chat to shift (reserve 0), center it within its // side margin so all three zones (left pane / chat / right pane) read as @@ -12744,17 +12737,14 @@ export function AgentChatPane({ const renderFloatingLeftPane = (content: React.ReactNode) => ( - {/* The ref goes on the CARD, not the motion.div: the card is what the - rail has to clear, and the motion.div's opacity animation would - otherwise be the thing being observed. */} -
+
{content}
@@ -12891,17 +12881,6 @@ export function AgentChatPane({ transition={{ duration: 0.12, ease: "easeOut" }} className="absolute inset-0 flex min-h-0 overflow-hidden" > - {/* The chat surface — message list and the floating left PR - pane are siblings here, so the measured pane height - reaches the minimap rail by context, not by a prop through - the memoized transcript. - - Gate the value on the OPEN FLAG, not on the pane element: - AnimatePresence keeps the card mounted through its exit - fade, so observing the element alone would hold the rail - inset for a whole animation after the user already closed - the pane. */} - {/* Chat column. `data-chat-sync-pending` is the seam for the catch-up affordance: the transcript below is real but may be behind because the bound runtime could not be reached @@ -13084,6 +13063,7 @@ export function AgentChatPane({ onChooseProviderFailureModel={handleListChooseProviderFailureModel} mosaic={subagentView ? undefined : mosaicContext} scrollToRowKeyRequest={subagentView ? null : wakeJumpRequest} + scrollToPromptHistoryRequest={subagentView ? null : promptHistoryJumpRequest} proofArtifacts={subagentView ? EMPTY_PROOF_ARTIFACTS : computerUseSnapshot?.artifacts ?? EMPTY_PROOF_ARTIFACTS} allowLocalProofArtifactProtocol={!isRemoteProject} onOpenProofDrawer={subagentView ? undefined : openProofDrawer} @@ -13135,7 +13115,6 @@ export function AgentChatPane({ {effectiveCursorCloudPaneOpen ? renderRightPane(cursorCloudPanelContent) : null} {terminalRightPaneOpen && terminalPanelContent ? renderRightPane(terminalPanelContent) : null} {orchestrationPanelOpen && orchestrationPanelContent ? renderRightPane(orchestrationPanelContent) : null} - ) : ( { afterEach(() => { cleanup(); vi.restoreAllMocks(); + vi.useRealTimers(); useAppStore.setState({ chatUserMinimapEnabled: originalMinimapEnabled }); }); @@ -46,7 +47,6 @@ describe("ChatUserMinimap", () => { onJumpToRow={vi.fn()} listWidthPx={960} listHeightPx={600} - listTopViewportPx={0} columnWidthPx={720} />, ); @@ -65,6 +65,81 @@ describe("ChatUserMinimap", () => { expect(hoveredTick?.className).toContain("w-6"); }); + it("does not draw a guide line between the history ticks", () => { + render( + , + ); + + const rail = screen.getByRole("button", { name: "Jump to message: User message" }); + expect(rail.children).toHaveLength(2); + expect([...rail.children].every((child) => child.hasAttribute("data-minimap-tick"))).toBe(true); + }); + + it("briefly previews and highlights a keyboard-selected tick", () => { + vi.useFakeTimers(); + render( + , + ); + + expect(screen.getByTestId("chat-user-minimap").querySelector("[data-minimap-preview]")?.textContent) + .toContain("Second checkpoint"); + expect(screen.getAllByTestId("chat-user-minimap")[0]?.querySelectorAll("[data-minimap-tick]")[1]?.className) + .toContain("bg-[var(--chat-accent)]"); + + act(() => vi.advanceTimersByTime(901)); + + expect(document.querySelector("[data-minimap-preview]")).toBeNull(); + }); + + it("clears the keyboard preview when history navigation is reset", () => { + const view = render( + , + ); + + expect(screen.getByTestId("chat-user-minimap").querySelector("[data-minimap-preview]")) + .not.toBeNull(); + + view.rerender( + , + ); + + expect(document.querySelector("[data-minimap-preview]")).toBeNull(); + }); + it("keeps the paging marker visible and stateful before the loaded cutoff", () => { const onLoadOlderHistory = vi.fn(); const view = render( @@ -76,7 +151,6 @@ describe("ChatUserMinimap", () => { onLoadOlderHistory={onLoadOlderHistory} listWidthPx={960} listHeightPx={600} - listTopViewportPx={0} columnWidthPx={720} />, ); @@ -97,7 +171,6 @@ describe("ChatUserMinimap", () => { onLoadOlderHistory={vi.fn()} listWidthPx={960} listHeightPx={600} - listTopViewportPx={0} columnWidthPx={720} />, ); @@ -122,7 +195,6 @@ describe("ChatUserMinimap", () => { onRetryOlderHistory={onRetryOlderHistory} listWidthPx={960} listHeightPx={600} - listTopViewportPx={0} columnWidthPx={720} />, ); @@ -144,7 +216,6 @@ describe("ChatUserMinimap", () => { onRetryOlderHistory={onRetryOlderHistory} listWidthPx={960} listHeightPx={600} - listTopViewportPx={0} columnWidthPx={720} />, ); diff --git a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx index aa35fa5ca4..55aae7d03c 100644 --- a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx @@ -1,7 +1,6 @@ -import { useCallback, useState, type MouseEvent } from "react"; +import { useCallback, useEffect, useState, type MouseEvent } from "react"; import { cn } from "../ui/cn"; import { useAppStore } from "../../state/appStore"; -import { useChatPrPaneInset } from "./chatPrPaneInset"; import { CHAT_USER_MINIMAP_EXPANDED_HIT_STRIP_WIDTH, CHAT_USER_MINIMAP_HIT_STRIP_LEFT_PX, @@ -11,7 +10,6 @@ import { resolveMinimapIndexFromPointer, resolveMinimapPreviewTranslateY, resolveMinimapRailHeightStyle, - resolveMinimapRailTopInset, resolveMinimapTopPercent, type ChatUserMinimapSourceEntry, type ChatUserMinimapTurnOutcome, @@ -36,13 +34,12 @@ type ChatUserMinimapProps = { listWidthPx: number; /** Measured height of the message-list root. */ listHeightPx: number; - /** - * Measured viewport-space top edge of the message-list root — the rail's own - * origin, and the frame the PR pane's published bottom edge converts into. - */ - listTopViewportPx: number; /** Measured width of the centered content wrapper. */ columnWidthPx: number; + /** Brief keyboard-navigation preview for a prompt selected from the composer. */ + keyboardFocusIndex?: number | null; + /** Changes for every keyboard-navigation request, including repeated entries. */ + keyboardFocusRequestId?: number | null; }; /** Lens widths by distance from the hovered tick; index 3+ is "everything else". */ @@ -105,13 +102,27 @@ export function ChatUserMinimap({ onRetryOlderHistory, listWidthPx, listHeightPx, - listTopViewportPx, columnWidthPx, + keyboardFocusIndex = null, + keyboardFocusRequestId = null, }: ChatUserMinimapProps) { const chatUserMinimapEnabled = useAppStore((s) => s.chatUserMinimapEnabled); - // Read from context, never a prop: see `chatPrPaneInset.ts` for why. - const prPaneBottomViewportPx = useChatPrPaneInset(); const [hoverIndex, setHoverIndex] = useState(null); + const [keyboardPreview, setKeyboardPreview] = useState<{ index: number; requestId: number } | null>(null); + + useEffect(() => { + if (keyboardFocusIndex === null || keyboardFocusRequestId === null) { + setKeyboardPreview(null); + return; + } + setKeyboardPreview({ index: keyboardFocusIndex, requestId: keyboardFocusRequestId }); + const timer = window.setTimeout(() => { + setKeyboardPreview((current) => ( + current?.requestId === keyboardFocusRequestId ? null : current + )); + }, 900); + return () => window.clearTimeout(timer); + }, [keyboardFocusIndex, keyboardFocusRequestId]); const itemCount = entries.length; @@ -153,16 +164,17 @@ export function ChatUserMinimap({ const hitStripWidth = resolveMinimapHitStripWidth(listWidthPx, columnWidthPx); const hasPersistentGutter = minimapHasPersistentGutter(listWidthPx, columnWidthPx); - // Both edges are viewport-space, so the difference is the rail's own frame — - // no constant stands in for the chrome between the two boxes' origins. - const topInset = resolveMinimapRailTopInset(prPaneBottomViewportPx, listTopViewportPx); - // The rail centres in the band left BELOW the floating PR pane, not in the - // full list height. - const availablePx = listHeightPx - topInset; + // Keep the rail anchored to the message-list root. Floating panes are + // intentionally independent overlays and must not move the history markers. + const availablePx = listHeightPx; const resolvedHoverIndex = hoverIndex !== null && hoverIndex < itemCount ? hoverIndex : null; - const hoverEntry = resolvedHoverIndex === null ? null : (entries[resolvedHoverIndex] ?? null); - const hoverOutcomeLabel = turnOutcomeLabel(hoverEntry?.turnOutcome ?? null); + const resolvedKeyboardIndex = keyboardPreview?.index !== undefined && keyboardPreview.index < itemCount + ? keyboardPreview.index + : null; + const resolvedPreviewIndex = resolvedHoverIndex ?? resolvedKeyboardIndex; + const previewEntry = resolvedPreviewIndex === null ? null : (entries[resolvedPreviewIndex] ?? null); + const previewOutcomeLabel = turnOutcomeLabel(previewEntry?.turnOutcome ?? null); // Keep a durable continuation marker when the resident tail has fewer than // two user turns. Otherwise the whole rail disappears at the transcript @@ -175,8 +187,8 @@ export function ChatUserMinimap({ return null; } - const ariaLabel = `Jump to message: ${hoverEntry?.preview ?? "User message"}${ - hoverOutcomeLabel ? ` (${hoverOutcomeLabel})` : "" + const ariaLabel = `Jump to message: ${previewEntry?.preview ?? "User message"}${ + previewOutcomeLabel ? ` (${previewOutcomeLabel})` : "" }`; const continuationLabel = olderHistoryError ? "Retry loading earlier message markers" @@ -188,14 +200,14 @@ export function ChatUserMinimap({