From 23189e001a638956586bb77d73184f0594fbcbcb Mon Sep 17 00:00:00 2001
From: Arul Sharma <31745423+arul28@users.noreply.github.com>
Date: Mon, 10 Aug 2026 16:46:22 -0400
Subject: [PATCH 1/4] ship: prepare prompt history and PR pane
---
.../chat/AgentChatComposer.test.tsx | 222 ++++++++++++++-
.../components/chat/AgentChatComposer.tsx | 261 ++++++++++++++++--
.../chat/AgentChatMessageList.test.tsx | 70 +++--
.../components/chat/AgentChatMessageList.tsx | 47 +++-
.../components/chat/AgentChatPane.test.tsx | 12 +-
.../components/chat/AgentChatPane.tsx | 99 +++----
.../components/chat/ChatUserMinimap.test.tsx | 83 +++++-
.../components/chat/ChatUserMinimap.tsx | 90 +++---
.../components/chat/ComposerPromptStash.tsx | 40 ++-
.../components/chat/chatPrPaneInset.ts | 146 ----------
.../components/chat/chatPromptHistory.test.ts | 71 +++++
.../components/chat/chatPromptHistory.ts | 44 +++
.../chat/chatUserMinimap.logic.test.ts | 23 +-
.../components/chat/chatUserMinimap.logic.ts | 29 +-
docs/features/chat/composer-and-ui.md | 11 +-
15 files changed, 849 insertions(+), 399 deletions(-)
delete mode 100644 apps/desktop/src/renderer/components/chat/chatPrPaneInset.ts
create mode 100644 apps/desktop/src/renderer/components/chat/chatPromptHistory.test.ts
create mode 100644 apps/desktop/src/renderer/components/chat/chatPromptHistory.ts
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
index 1e6dda5132..757a331df6 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,
@@ -1139,6 +1139,226 @@ 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 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("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",
+ });
+ (window as any).ade = {
+ agentChat: {
+ promptStashes: {
+ 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 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);
+ (window as any).ade = {
+ agentChat: {
+ promptStashes: {
+ 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..d1faab54f3 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,73 @@ 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 promptHistoryStashRef = useRef | 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;
+ if (wasNavigating) promptHistoryStashRef.current = null;
+ promptHistoryDraftBeforeRef.current = null;
+ promptHistoryAppliedDraftRef.current = null;
+ if (wasNavigating) onPromptHistoryNavigate?.(null);
+ }, [cancelPromptHistorySequence, onPromptHistoryNavigate]);
+
+ useEffect(() => {
+ clearPromptHistory();
+ }, [clearPromptHistory, sessionId]);
+
+ useEffect(() => {
+ const cancelIfActive = () => {
+ if (promptHistorySequenceActiveRef.current) cancelPromptHistorySequence();
+ };
+ window.addEventListener("pointerdown", cancelIfActive, true);
+ window.addEventListener("wheel", cancelIfActive, true);
+ window.addEventListener("touchstart", cancelIfActive, true);
+ return () => {
+ window.removeEventListener("pointerdown", cancelIfActive, true);
+ window.removeEventListener("wheel", cancelIfActive, true);
+ window.removeEventListener("touchstart", cancelIfActive, true);
+ };
+ }, [cancelPromptHistorySequence]);
+
+ useEffect(() => () => cancelPromptHistorySequence(), [cancelPromptHistorySequence]);
+
+ useEffect(() => {
+ const previousDraft = promptHistoryObservedDraftRef.current;
+ promptHistoryObservedDraftRef.current = draft;
+ if (
+ promptHistoryIndexRef.current !== null
+ && draft !== previousDraft
+ && draft !== promptHistoryAppliedDraftRef.current
+ ) {
+ clearPromptHistory();
+ }
+ }, [clearPromptHistory, draft]);
+
const useRichComposer = smartLinkEditorEnabled
|| iosElementContextItems.length > 0
|| appControlContextItems.length > 0
@@ -3748,9 +3822,137 @@ 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;
+ 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") {
+ promptHistoryStashRef.current = promptStashRef.current?.activatePreservingDraft() ?? null;
+ }
+ }, [attachments.length]);
+
+ const restorePromptHistoryDraft = useCallback(() => {
+ const promptHistoryStash = promptHistoryStashRef.current;
+ promptHistoryStashRef.current = null;
+ const text = promptHistoryDraftBeforeRef.current ?? "";
+ promptHistoryIndexRef.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.then((entry) => {
+ if (entry) void promptStashRef.current?.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 = currentText.lastIndexOf("\n", Math.max(0, 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") 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 +3964,7 @@ export function AgentChatComposer({
return;
}
if (promptStashRef.current?.handleMenuKeyDown(event)) {
+ cancelPromptHistorySequence();
event.preventDefault();
return;
}
@@ -3822,6 +4025,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 +4043,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 +4258,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 +4278,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 +5653,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 +5737,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 +5788,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({
diff --git a/apps/desktop/src/renderer/components/chat/chatPrPaneInset.ts b/apps/desktop/src/renderer/components/chat/chatPrPaneInset.ts
deleted file mode 100644
index d9ee68cfcd..0000000000
--- a/apps/desktop/src/renderer/components/chat/chatPrPaneInset.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
-
-/**
- * Measured VIEWPORT-space bottom edge (px) of the floating PR pane card, or
- * `null` when no PR pane is floating over the transcript.
- *
- * Why an edge and not a height: the pane is positioned against the chat surface
- * (`absolute top-3`) while the minimap rail is positioned against the
- * message-list root, which sits below the chat header and the sync hairline.
- * Publishing a height would force the consumer to bridge those two origins with
- * a constant — one that duplicates the pane's `top-3` and is wrong by the height
- * of any chrome between them. Viewport space is the one frame both boxes can be
- * measured in, so the consumer just subtracts its own rect.
- *
- * Why context and not a prop: the floating PR pane and the message list are
- * SIBLINGS mounted from different branches of `AgentChatPane`. Threading the
- * value down as a prop would have to pass through the memoized
- * `AgentChatMessageList`, so every PR-pane resize frame would re-render the
- * whole transcript. Context keeps the fan-out to the single consumer that
- * actually needs the number (the minimap rail).
- */
-export const ChatPrPaneInsetContext = createContext(null);
-
-/** Read the floating PR pane's viewport-space bottom; `null` when none is floating. */
-export function useChatPrPaneInset(): number | null {
- return useContext(ChatPrPaneInsetContext);
-}
-
-/**
- * Sub-pixel churn (zoom, backdrop-filter reflow, fractional layout) would
- * otherwise publish a new context value on frames where nothing visibly moved.
- */
-const PR_PANE_BOTTOM_DEADBAND_PX = 1;
-/**
- * How far up the offset-parent chain to watch for layout shifts. The card's own
- * offset parent is its animation wrapper (which moves with it and so carries no
- * signal); the chat surface that actually positions it sits one level above.
- * Two is enough today, with one spare level of slack for future chrome.
- */
-const PR_PANE_OFFSET_ANCESTOR_DEPTH = 3;
-
-export type ChatPrPaneInsetObserver = {
- /**
- * Ref callback for the floating PR pane card element. Attaching starts a
- * `ResizeObserver`; detaching (`null`) disconnects it and reports `null`.
- */
- ref: (node: HTMLElement | null) => void;
- /**
- * Latest measured viewport-space bottom edge, or `null` while nothing is
- * attached. Feed this straight into `ChatPrPaneInsetContext.Provider value=`.
- */
- bottomViewportPx: number | null;
-};
-
-/**
- * Measure the floating PR pane so the minimap rail can re-centre in the space
- * that is actually left below it.
- *
- * Why `ResizeObserver` rather than the alternatives:
- * - CSS-only (e.g. a sibling flex/grid track) cannot work, because the rail has
- * to both re-centre in the REMAINING band and go fully inert when that band
- * is too short — both decisions happen in JS and need the number.
- * - A fixed reserve cannot work either: the pane's height is content-driven
- * (~260px showing PR details vs ~430px showing the create form), so any
- * constant is wrong in one of the two states.
- * - It is not hidden polling: `ResizeObserver` fires only on real size changes,
- * and the 1px deadband keeps it from re-rendering on measurement noise.
- */
-export function usePrPaneInsetObserver(): ChatPrPaneInsetObserver {
- const [bottomViewportPx, setBottomViewportPx] = useState(null);
- const observerRef = useRef(null);
- const nodeRef = useRef(null);
- // Mirrors `bottomViewportPx` so the deadband can compare without re-creating
- // the observer callback on every measured change.
- const bottomRef = useRef(null);
-
- const report = useCallback((next: number | null) => {
- const current = bottomRef.current;
- if (next === null) {
- if (current === null) return;
- bottomRef.current = null;
- setBottomViewportPx(null);
- return;
- }
- if (!Number.isFinite(next)) return;
- if (current !== null && Math.abs(next - current) < PR_PANE_BOTTOM_DEADBAND_PX) return;
- bottomRef.current = next;
- setBottomViewportPx(next);
- }, []);
-
- // `getBoundingClientRect()` is already border-box, which is what the rail has
- // to clear: the card has a 1px border and the rail offsets from its outer edge.
- const measure = useCallback(() => {
- const node = nodeRef.current;
- report(node ? node.getBoundingClientRect().bottom : null);
- }, [report]);
-
- const ref = useCallback(
- (node: HTMLElement | null) => {
- // Re-attach must never leave the previous observer running.
- observerRef.current?.disconnect();
- observerRef.current = null;
- nodeRef.current = node;
- if (!node) {
- report(null);
- return;
- }
- // Seed synchronously so the rail is inset on the first painted frame
- // instead of jumping once the first observer callback lands.
- measure();
- if (typeof ResizeObserver === "undefined") return;
- const observer = new ResizeObserver(() => measure());
- observer.observe(node);
- // A viewport-space edge has to survive the card MOVING, not just resizing:
- // a banner mounting above the chat surface slides the card down without
- // ever changing its own size, and a `ResizeObserver` on the card alone
- // would never fire.
- //
- // Observing only `node.offsetParent` is NOT enough. The card's offset
- // parent is the framer-motion wrapper immediately above it, which is
- // itself absolutely positioned and stretches with the card — so it
- // resizes in lockstep and carries no new signal. The element that
- // actually defines where the card sits is the chat surface further up.
- // Walk a bounded slice of the offset-parent chain and observe each, so a
- // height change anywhere in it re-measures the card's edge.
- let ancestor: Element | null = node.offsetParent;
- for (let depth = 0; ancestor instanceof Element && depth < PR_PANE_OFFSET_ANCESTOR_DEPTH; depth += 1) {
- observer.observe(ancestor);
- ancestor = ancestor instanceof HTMLElement ? ancestor.offsetParent : null;
- }
- observerRef.current = observer;
- },
- [measure, report],
- );
-
- useEffect(
- () => () => {
- observerRef.current?.disconnect();
- observerRef.current = null;
- nodeRef.current = null;
- },
- [],
- );
-
- return { ref, bottomViewportPx };
-}
diff --git a/apps/desktop/src/renderer/components/chat/chatPromptHistory.test.ts b/apps/desktop/src/renderer/components/chat/chatPromptHistory.test.ts
new file mode 100644
index 0000000000..1decd4944c
--- /dev/null
+++ b/apps/desktop/src/renderer/components/chat/chatPromptHistory.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+import type { AgentChatEventEnvelope } from "../../../shared/types";
+import { collectAgentChatPromptHistory, promptHistoryEventKey } from "./chatPromptHistory";
+
+function userMessage(
+ sessionId: string,
+ timestamp: string,
+ text: string,
+ event: Partial> = {},
+): AgentChatEventEnvelope {
+ return {
+ sessionId,
+ timestamp,
+ event: {
+ ...event,
+ type: "user_message",
+ text,
+ deliveryState: event.deliveryState ?? "delivered",
+ },
+ };
+}
+
+describe("chat prompt history", () => {
+ it("collects only the visible prompts in the selected transcript", () => {
+ const selectedChatEvents: AgentChatEventEnvelope[] = [
+ userMessage("selected", "2026-08-10T10:00:00.000Z", "First prompt"),
+ userMessage("selected", "2026-08-10T10:00:01.000Z", "internal prompt", {
+ metadata: { hideFullPrompt: true },
+ }),
+ userMessage("selected", "2026-08-10T10:00:02.000Z", "queued steer", {
+ deliveryState: "queued",
+ steerId: "steer-1",
+ }),
+ userMessage("selected", "2026-08-10T10:00:03.000Z", "full prompt", {
+ metadata: { hideFullPrompt: true },
+ displayText: "Visible handoff brief",
+ }),
+ ];
+
+ expect(collectAgentChatPromptHistory(selectedChatEvents).map((entry) => entry.text)).toEqual([
+ "First prompt",
+ "Visible handoff brief",
+ ]);
+ expect(collectAgentChatPromptHistory([
+ userMessage("other-chat", "2026-08-10T11:00:00.000Z", "Other chat prompt"),
+ ]).map((entry) => entry.text)).toEqual(["Other chat prompt"]);
+ });
+
+ it("keeps the jump identity when transcript rendering adds display metadata", () => {
+ const original = userMessage("selected", "2026-08-10T10:00:00.000Z", "A steer", {
+ steerId: "steer-1",
+ deliveryState: "unprocessed",
+ });
+ if (original.event.type !== "user_message") throw new Error("test event must be a user message");
+ const originalEvent = original.event;
+ const decorated: typeof originalEvent = {
+ ...originalEvent,
+ metadata: {
+ ...(originalEvent.metadata ?? {}),
+ unprocessedMessageResolution: {
+ action: "run_next" as const,
+ state: "completed" as const,
+ resolvedAt: "2026-08-10T10:01:00.000Z",
+ },
+ },
+ };
+
+ expect(promptHistoryEventKey({ timestamp: original.timestamp, event: originalEvent }))
+ .toBe(promptHistoryEventKey({ timestamp: original.timestamp, event: decorated }));
+ });
+});
diff --git a/apps/desktop/src/renderer/components/chat/chatPromptHistory.ts b/apps/desktop/src/renderer/components/chat/chatPromptHistory.ts
new file mode 100644
index 0000000000..14186780b4
--- /dev/null
+++ b/apps/desktop/src/renderer/components/chat/chatPromptHistory.ts
@@ -0,0 +1,44 @@
+import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../shared/types";
+
+export type AgentChatPromptHistoryEntry = {
+ text: string;
+ eventKey: string;
+};
+
+/**
+ * Stable identity shared by the composer and transcript rows for a sent user
+ * message. Do not serialize the whole event here: transcript rendering may
+ * add display-only metadata to a row (for example, a resolved steer receipt)
+ * without changing the underlying prompt.
+ */
+export function promptHistoryEventKey(entry: {
+ timestamp: string;
+ event: Extract;
+}): string {
+ const messageId = entry.event.messageId?.trim();
+ if (messageId) return `${entry.timestamp}#user_message#message:${messageId}`;
+ const steerId = entry.event.steerId?.trim();
+ if (steerId) return `${entry.timestamp}#user_message#steer:${steerId}`;
+ const turnId = entry.event.turnId?.trim();
+ if (turnId) return `${entry.timestamp}#user_message#turn:${turnId}#${entry.event.text}`;
+ // Older transcripts may not have any runtime identity. The envelope
+ // timestamp plus the canonical prompt text is the least lossy fallback and
+ // remains unchanged when the renderer decorates the row.
+ return `${entry.timestamp}#user_message#text:${entry.event.text}`;
+}
+
+/** Collect only visible, delivered prompts from the transcript passed in. */
+export function collectAgentChatPromptHistory(
+ events: readonly AgentChatEventEnvelope[],
+): AgentChatPromptHistoryEntry[] {
+ return events.flatMap((envelope) => {
+ const event = envelope.event;
+ if (event.type !== "user_message") return [];
+ if (event.deliveryState === "queued" && event.steerId) return [];
+ if (event.metadata?.hideFullPrompt === true && !event.displayText?.trim()) return [];
+ const displayText = event.displayText?.trim();
+ const text = displayText?.length ? displayText : event.text.trim();
+ if (!text) return [];
+ return [{ text, eventKey: promptHistoryEventKey({ timestamp: envelope.timestamp, event }) }];
+ });
+}
diff --git a/apps/desktop/src/renderer/components/chat/chatUserMinimap.logic.test.ts b/apps/desktop/src/renderer/components/chat/chatUserMinimap.logic.test.ts
index bd6dd8df96..673fbf2c97 100644
--- a/apps/desktop/src/renderer/components/chat/chatUserMinimap.logic.test.ts
+++ b/apps/desktop/src/renderer/components/chat/chatUserMinimap.logic.test.ts
@@ -14,7 +14,6 @@ import {
resolveMinimapIndexFromPointer,
resolveMinimapPreviewTranslateY,
resolveMinimapRailHeightStyle,
- resolveMinimapRailTopInset,
resolveMinimapSideGutter,
resolveMinimapTopPercent,
resolveRowAnchorAtScrollTop,
@@ -314,26 +313,8 @@ describe("chatUserMinimap.logic", () => {
expect(minimapRailInert(0)).toBe(true);
});
- it("resolveMinimapRailTopInset is 0 with no floating PR pane", () => {
- expect(resolveMinimapRailTopInset(null, 200)).toBe(0);
- });
-
- it("resolveMinimapRailTopInset subtracts the two viewport rects", () => {
- // REGRESSION: the pane is positioned against the chat surface and the rail
- // against the message-list root, which sits ~200px lower (header +
- // hairline). The inset is the RECT DELTA plus the gap — never
- // `pane top constant + pane height + gap`, which would read 12 + 260 + 12
- // = 284 here and push the rail a whole header-height too far down.
- expect(resolveMinimapRailTopInset(300, 200)).toBe(112);
- // Same pane, taller header: the inset shrinks by exactly the extra chrome.
- expect(resolveMinimapRailTopInset(300, 260)).toBe(52);
- });
-
- it("resolveMinimapRailTopInset never goes negative or NaN", () => {
- // Pane bottom above the list root (short pane under a tall header).
- expect(resolveMinimapRailTopInset(100, 200)).toBe(0);
- expect(resolveMinimapRailTopInset(Number.NaN, 200)).toBe(0);
- expect(resolveMinimapRailTopInset(300, Number.NaN)).toBe(0);
+ it("sizes the rail from the full list height", () => {
+ expect(resolveMinimapRailHeightStyle(21, 300)).toBe("160px");
});
});
diff --git a/apps/desktop/src/renderer/components/chat/chatUserMinimap.logic.ts b/apps/desktop/src/renderer/components/chat/chatUserMinimap.logic.ts
index 3fea1d4396..0be24d8fac 100644
--- a/apps/desktop/src/renderer/components/chat/chatUserMinimap.logic.ts
+++ b/apps/desktop/src/renderer/components/chat/chatUserMinimap.logic.ts
@@ -25,9 +25,6 @@ export const CHAT_USER_MINIMAP_RAIL_PADDING_PX = 32;
/** Below this available height the rail is more noise than navigation. */
export const CHAT_USER_MINIMAP_RAIL_MIN_AVAILABLE_PX = 140;
-/** Breathing room kept between the floating PR pane's bottom edge and the rail. */
-export const CHAT_USER_MINIMAP_PR_PANE_GAP_PX = 12;
-
const PREVIEW_MAX_CHARS = 140;
const ASSISTANT_PREVIEW_MAX_CHARS = 220;
@@ -285,31 +282,7 @@ export function resolveMinimapPreviewTranslateY(index: number, itemCount: number
}
/**
- * Rail starts below the floating PR pane when one is mounted.
- *
- * BOTH inputs are viewport-space edges, and that is the whole point: the pane is
- * positioned against the chat SURFACE (`absolute top-3`) while the rail is
- * positioned against the message-list root, which sits below the chat header and
- * the sync hairline. Measuring a pane HEIGHT and adding its `top-3` back as a
- * constant would silently be wrong by the height of everything between those two
- * origins — a header, a banner, a hairline — and no constant can fix that. A
- * viewport-space subtraction stays correct for any future chrome above the list.
- */
-export function resolveMinimapRailTopInset(
- prPaneBottomViewportPx: number | null,
- listRootTopViewportPx: number,
-): number {
- if (prPaneBottomViewportPx === null) return 0;
- if (!Number.isFinite(prPaneBottomViewportPx) || !Number.isFinite(listRootTopViewportPx)) return 0;
- return Math.max(
- 0,
- prPaneBottomViewportPx - listRootTopViewportPx + CHAT_USER_MINIMAP_PR_PANE_GAP_PX,
- );
-}
-
-/**
- * Height in px (not vh): the rail centres in the space REMAINING below the PR
- * pane, which is not the viewport height.
+ * Height in px (not vh): the rail centres in the full message-list column.
*/
export function resolveMinimapRailHeightStyle(itemCount: number, availablePx: number): string {
const naturalHeight = (itemCount - 1) * CHAT_USER_MINIMAP_TICK_SPACING_PX;
diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md
index 49826c2a67..7b07960588 100644
--- a/docs/features/chat/composer-and-ui.md
+++ b/docs/features/chat/composer-and-ui.md
@@ -48,8 +48,8 @@ subagents, computer use). The pane derives all visible state from the
| `ChatGitToolbar.tsx` | Git status and quick-action toolbar above the composer. The changed-file count is the number of **distinct paths** across the staged and unstaged lists, not their lengths summed: `git status` reports a file with both a staged and an unstaged edit as `MM`, and the parser puts that one file in both lists. The PR action opens or toggles a linked PR when one exists, otherwise opens the PR creation handoff for the current lane targeting the primary branch. Opening the chat PR pane or compact PR menu performs a targeted, cooldown-bound refresh for that single linked PR. The toolbar is a **status strip only** — the manual PR-sync (↻) button moved into the PR pane's title bar, so surfaces that render this toolbar without a PR pane have no manual sync affordance and heal through reconcile-on-focus plus `prs-updated` instead. |
| `ChatPrPane.tsx` | Left floating PR pane for Work chat. Owns a title bar (`Pull request` + ↻ refresh + ✕ close): ↻ calls `prs.syncLanePr` and then re-reads the pane's PR, and spins for either a manual sync or a backend reconcile-on-focus (`pr-reconcile`, debounced 300 ms on the hide so a fast reconcile does not flicker). ✕ is wired to the parent's `onClose` (the header PR pill still toggles it). Shows cached lane PR details immediately, then refreshes the linked PR row with the same targeted refresh path so pane toggles surface current merged/closed/check state without a broad PR sync. An unmapped lane PR (projection-derived, `pr.unmapped`) skips the refresh and checks/reviews enrichment — there is no DB row behind its synthetic `gh:` id. With no PR it embeds `ChatPrInlineCreator` and forwards the chat's `sessionTitle`; under a `runtimePin` it points at the owning machine instead, since creation is not pinned. Reads, the ↻ sync, and the event subscription all take the pin so a chat on another machine sees its lane's real PR. |
| `ChatPrInlineCreator.tsx` | Inline create-PR form inside the PR pane. Laid out as a **flow** with no uppercase section captions: a flat, boxless source row (lane name + branch + lock glyph, immutable), a `↓` connector carrying `N ahead · N behind · clean`/`dirty` from `lane.status` (muted `comparing…` when the lane has no status yet), then the canonical `LaneCombobox` target dropdown (no free text), title, description, and Create. The title defaults to the chat session title whenever it is a real title (the placeholder `New chat` never wins), otherwise to the ` -> ` derivation. Linear magic words and the deeplink footer are added server-side by `prService`. On success it hands the created `PrSummary` up through `onCreated` so the pane swaps to details without waiting for `prs-updated`. |
-| `ChatUserMinimap.tsx`, `chatUserMinimap.logic.ts` | Tick rail down the transcript's **left** gutter, one hairline per user message, gated on the `chatUserMinimapEnabled` appearance setting and mouse pointers only (`[@media(pointer:fine)]`). Ticks are positioned by percentage of rail height, so they compress instead of overflowing and there is no marker cap or subsampling — the entry index stays 1:1 with the tick index, which is what pointer→index mapping depends on. The whole rail is a single `