Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
383 changes: 373 additions & 10 deletions apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx

Large diffs are not rendered by default.

303 changes: 276 additions & 27 deletions apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -137,6 +137,7 @@ function renderMessageList(
onRunUnprocessedMessage?: (event: Extract<AgentChatEventEnvelope["event"], { type: "user_message" }>) => void | Promise<void>;
onRestoreCancelledQueue?: (recoveryId: string) => Promise<boolean>;
scrollToRowKeyRequest?: { key: string; requestId: number } | null;
scrollToPromptHistoryRequest?: { eventKey: string; requestId: number } | null;
hasOlderHistory?: boolean;
loadingOlderHistory?: boolean;
olderHistoryError?: string | null;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={[{ pathname: "/" }]}>
<ChatPrPaneInsetContext.Provider value={prPaneBottomViewportPx}>
<AgentChatMessageList events={events} />
</ChatPrPaneInsetContext.Provider>
</MemoryRouter>,
);
}

function makeRect(box: { top?: number; left?: number; width?: number; height?: number }): DOMRect {
const top = box.top ?? 0;
const left = box.left ?? 0;
Expand All @@ -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 });
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={[{ pathname: "/" }]}>
<AgentChatMessageList events={MINIMAP_TRANSCRIPT} scrollToPromptHistoryRequest={request} />
<LocationProbe />
</MemoryRouter>,
);

expect(transcript.scrollTop).toBeGreaterThan(0);
});

// "absorbs tool summaries" test removed: tested old ChatWorkLogBlock
// summary absorption rendering which changes with UI iterations.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -5230,6 +5231,7 @@ function AgentChatMessageListMain({
onReturnToLatest,
mosaic,
scrollToRowKeyRequest,
scrollToPromptHistoryRequest,
proofArtifacts = [],
allowLocalProofArtifactProtocol = false,
onOpenProofDrawer,
Expand Down Expand Up @@ -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. */
Expand All @@ -5294,6 +5298,7 @@ function AgentChatMessageListMain({
const contentWrapperRef = useRef<HTMLDivElement | null>(null);
const olderHistorySentinelRef = useRef<HTMLDivElement | null>(null);
const lastHandledScrollToRowRequestIdRef = useRef<number | null>(null);
const lastHandledPromptHistoryRequestIdRef = useRef<number | null>(null);
const location = useLocation();
const navigate = useNavigate();
// Carries the CollapseTranscriptContext alongside events/rows so appended
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
));
}, []);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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. */}
<ChatUserMinimap
entries={minimapSourceEntries}
activeIndex={activeFullUserOrdinal}
Expand All @@ -6707,8 +6729,9 @@ function AgentChatMessageListMain({
onRetryOlderHistory={onRetryOlderHistory}
listWidthPx={listRootBoxPx.width}
listHeightPx={listRootBoxPx.height}
listTopViewportPx={listRootBoxPx.top}
columnWidthPx={columnWidthPx}
keyboardFocusIndex={promptHistoryFocusIndex}
keyboardFocusRequestId={scrollToPromptHistoryRequest?.requestId ?? null}
/>
<div
ref={scrollRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1435,11 +1435,9 @@ describe("AgentChatPane remote startup", () => {
});

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(() => {
Expand Down Expand Up @@ -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();
Expand All @@ -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 () => {
Expand Down
Loading
Loading