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
94 changes: 94 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@
};
}

function getThreadDetailFromFixtureSnapshot(

Check warning on line 568 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Function 'getThreadDetailFromFixtureSnapshot' is declared but never used.
threadId: ThreadId,
): OrchestrationReadModel["threads"][number] {
const thread = fixture.snapshot.threads.find((entry) => entry.id === threadId);
Expand Down Expand Up @@ -824,7 +824,7 @@

return {
...snapshot,
threads: snapshot.threads.map((thread) =>

Check warning on line 827 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

oxc(no-map-spread)

Spreading to modify object properties in `map` calls is inefficient
thread.id === THREAD_ID
? {
...thread,
Expand Down Expand Up @@ -895,7 +895,7 @@

return {
...snapshot,
threads: snapshot.threads.map((thread) =>

Check warning on line 898 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

oxc(no-map-spread)

Spreading to modify object properties in `map` calls is inefficient
thread.id === THREAD_ID
? {
...thread,
Expand Down Expand Up @@ -941,7 +941,7 @@

return {
...snapshot,
threads: snapshot.threads.map((thread) =>

Check warning on line 944 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

oxc(no-map-spread)

Spreading to modify object properties in `map` calls is inefficient
thread.id === THREAD_ID
? {
...thread,
Expand Down Expand Up @@ -981,7 +981,7 @@

return {
...snapshot,
threads: snapshot.threads.map((thread) =>

Check warning on line 984 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

oxc(no-map-spread)

Spreading to modify object properties in `map` calls is inefficient
thread.id === THREAD_ID
? {
...thread,
Expand Down Expand Up @@ -1861,7 +1861,7 @@
scrollContainer.dispatchEvent(new Event("scroll"));
await waitForLayout();
row = host.querySelector<HTMLElement>(rowSelector);
expect(row, "Unable to locate targeted user message row.").toBeTruthy();

Check failure on line 1864 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

[chromium] src/components/ChatView.browser.tsx > ChatView timeline estimator parity (full app) > [geometry:linux] keeps user attachment estimate close at the 'desktop' viewport

AssertionError: Unable to locate targeted user message row.: expected null to be truthy - Expected: true + Received: null ❯ toBeTruthy src/components/ChatView.browser.tsx:1864:65
},
{
timeout: 8_000,
Expand Down Expand Up @@ -2662,6 +2662,100 @@
}
});

it("[geometry:linux] keeps the transcript-to-composer gap constant when a task card joins the composer stack", async () => {
// Regression guard for the oversized gap between the transcript tail and the
// composer when a stacked panel (active task list) is showing. The composer
// is a flex sibling below the transcript, so the flex layout already reserves
// the stack height once; the in-list bottom spacer must stay at its constant
// baseline (MessagesTimeline BOTTOM_CONTENT_INSET_PX) instead of also growing
// with the chrome. If it double-counts, the gap above the composer stack
// grows by ~the chrome height when the task card appears.
//
// We anchor on the bottom spacer's top (the true end of transcript content,
// after any live-turn activity indicator) rather than the last message row,
// so a running turn's working indicator does not confound the measurement.
// Mirrors MessagesTimeline BOTTOM_CONTENT_INSET_PX (the fixed tail spacer).
const TRANSCRIPT_BOTTOM_SPACER_PX = 64;
const measureComposerGap = async (
mounted: Awaited<ReturnType<typeof mountChatView>>,
{ expectTaskCard }: { expectTaskCard: boolean },
): Promise<number> => {
const scrollContainer = await waitForElement(
() => document.querySelector<HTMLElement>("[data-chat-scroll-container='true']"),
"Unable to find message scroll container.",
);
// The fixture overflows the viewport, so scrolling to the end pins the tail
// spacer against the viewport bottom where the gap is well defined.
const layout = await mounted.measureLayout();
expect(layout.scrollHeightPx).toBeGreaterThan(layout.scrollClientHeightPx);

let gapPx = 0;
await vi.waitFor(
async () => {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
scrollContainer.dispatchEvent(new Event("scroll"));
await waitForLayout();
expect(getScrollContainerDistanceFromBottom(scrollContainer)).toBeLessThanOrEqual(
AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
);

const taskCard = document.querySelector<HTMLElement>(
'[data-testid="active-task-list-card"]',
);
if (expectTaskCard) {
expect(taskCard, "Expected the active task list card to be present.").not.toBeNull();
} else {
expect(taskCard, "Expected no active task list card.").toBeNull();
}

const composerStack = document.querySelector<HTMLElement>(
'[data-chat-composer-stack="true"]',
);
expect(composerStack, "Unable to find composer stack wrapper.").not.toBeNull();
const bottomSpacer = document.querySelector<HTMLElement>(
'[data-testid="transcript-bottom-spacer"]',
);
expect(bottomSpacer, "Unable to find transcript bottom spacer.").not.toBeNull();

const transcriptContentBottom = bottomSpacer!.getBoundingClientRect().top;
const composerStackTop = composerStack!.getBoundingClientRect().top;
gapPx = composerStackTop - transcriptContentBottom;
// The composer stack sits just below the transcript tail, never a
// screenful away.
expect(gapPx).toBeGreaterThan(0);
expect(gapPx).toBeLessThan(TRANSCRIPT_BOTTOM_SPACER_PX);
},
{ timeout: 8_000, interval: 16 },
);
return gapPx;
};

const active = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotWithActiveInlinePlan(),
});
let gapWithTaskCard = 0;
try {
gapWithTaskCard = await measureComposerGap(active, { expectTaskCard: true });
} finally {
await active.cleanup();
}

const settled = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotWithSettledInlinePlan(),
});
let gapWithoutTaskCard = 0;
try {
gapWithoutTaskCard = await measureComposerGap(settled, { expectTaskCard: false });
} finally {
await settled.cleanup();
}

// The stacked task card must not widen the transcript-to-composer gap.
expect(Math.abs(gapWithTaskCard - gapWithoutTaskCard)).toBeLessThanOrEqual(3);

Check failure on line 2756 in apps/web/src/components/ChatView.browser.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

[chromium] src/components/ChatView.browser.tsx > ChatView timeline estimator parity (full app) > [geometry:linux] keeps the transcript-to-composer gap constant when a task card joins the composer stack

AssertionError: expected 28.5 to be less than or equal to 3 ❯ toBeLessThanOrEqual src/components/ChatView.browser.tsx:2756:59
});

it("stays pinned to the bottom after delayed attachment loads expand the timeline", async () => {
attachmentResponseDelayMs = 160;
const mounted = await mountChatView({
Expand Down
21 changes: 12 additions & 9 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2808,9 +2808,10 @@ export default function ChatView({
const planSidebarToggleLabel = planSidebarOpen ? `Hide ${planSidebarLabel}` : planSidebarLabel;
const planSidebarToggleTitle = `${planSidebarOpen ? "Hide" : "Show"} ${planSidebarLabel.toLowerCase()} sidebar`;
// Measured height of the whole stack of panels rendered above the composer input
// (live file changes, active task list, queued follow-ups). The composer overlaps the
// scrolling transcript, so the transcript reserves matching bottom space to keep its
// last rows clear of this chrome instead of letting them slide underneath and clip.
// (live file changes, active task list, queued follow-ups). The composer is a flex
// sibling below the transcript, so a taller stack shrinks the transcript viewport from
// the bottom; this height feeds the scroll compensation below (not any bottom inset)
// that keeps the transcript pinned to its end while the chrome grows.
const [composerStackedChromeHeight, setComposerStackedChromeHeight] = useState(0);
const composerStackedChromeObserverRef = useRef<ResizeObserver | null>(null);
const previousComposerStackedChromeHeightRef = useRef(0);
Expand Down Expand Up @@ -5118,6 +5119,10 @@ export default function ChatView({
autoFollowThreadIdRef.current = null;
animateNextAutoFollowScrollRef.current = false;
}, []);
// Keep the transcript pinned to its end while the stacked composer chrome grows. The
// composer is a flex sibling, so a taller chrome shrinks the transcript viewport from
// the bottom and would otherwise let the last rows drift up out of view. When already
// at the end, nudge scrollTop by the growth so the end stays put.
useLayoutEffect(() => {
const previousHeight = previousComposerStackedChromeHeightRef.current;
previousComposerStackedChromeHeightRef.current = composerStackedChromeHeight;
Expand Down Expand Up @@ -10709,9 +10714,9 @@ export default function ChatView({
>
<ComposerColumnFrame>
{/* Single measured wrapper around every panel stacked above the composer input.
Its height drives the transcript bottom inset and scroll compensation so the
last rows stay clear of this chrome (see measureComposerStackedChrome). A bare
div keeps the panels' -mb-px seam onto the input shell via margin collapse. */}
Its height drives the transcript scroll compensation so the last rows stay
pinned as this chrome grows (see measureComposerStackedChrome). A bare div
keeps the panels' -mb-px seam onto the input shell via margin collapse. */}
<div ref={measureComposerStackedChrome}>
{showComposerLiveChangesHeader ? (
<ComposerLiveChangesHeader
Expand Down Expand Up @@ -11525,9 +11530,6 @@ export default function ChatView({
onCloseAgentActivityDetail={() => setOpenAgentActivityId(null)}
scrollButtonVisible={showScrollToBottom}
onScrollToBottom={onScrollToBottom}
bottomContentInsetPx={
composerStackedChromeHeight > 0 ? composerStackedChromeHeight + 8 : undefined
}
contentInsetRightPx={
environmentAppliesContentInset
? ENVIRONMENT_DOCKED_CONTENT_INSET_PX
Expand All @@ -11537,6 +11539,7 @@ export default function ChatView({
</div>

<div
data-chat-composer-stack="true"
className={cn(
"relative z-10 -mt-5 w-full shrink-0 overflow-visible pt-0 sm:pt-0",
ENVIRONMENT_CONTENT_INSET_MOTION_CLASS,
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/chat/AgentActivityDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ import {
isReasoningUpdateWorkEntry,
} from "./agentActivity.logic";

const MIN_DETAIL_BOTTOM_INSET_PX = 64;
// The composer overlaps this detail view by design (same -mt-5 as the transcript), so a
// fixed tail inset keeps the last content clear of it.
const DETAIL_BOTTOM_INSET_PX = 64;

interface AgentActivityDetailViewProps {
detail: AgentActivityDetail;
bottomContentInsetPx?: number | undefined;
chatFontSizePx: number;
contentInsetRightPx?: number | undefined;
markdownCwd: string | undefined;
Expand All @@ -47,7 +48,6 @@ interface AgentActivityDetailViewProps {

export const AgentActivityDetailView = memo(function AgentActivityDetailView({
detail,
bottomContentInsetPx,
chatFontSizePx,
contentInsetRightPx,
markdownCwd,
Expand All @@ -67,9 +67,9 @@ export const AgentActivityDetailView = memo(function AgentActivityDetailView({
const scrollStyle = useMemo<CSSProperties>(
() => ({
...(contentInsetRightPx ? { paddingRight: contentInsetRightPx } : {}),
paddingBottom: Math.max(bottomContentInsetPx ?? 0, MIN_DETAIL_BOTTOM_INSET_PX),
paddingBottom: DETAIL_BOTTOM_INSET_PX,
}),
[bottomContentInsetPx, contentInsetRightPx],
[contentInsetRightPx],
);
const prompt = findPrompt(detail.entries);
const result = findResult(detail.entries);
Expand Down
4 changes: 0 additions & 4 deletions apps/web/src/components/chat/ChatTranscriptPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ interface ChatTranscriptPaneProps {
activeTurnInProgress: boolean;
activeTurnStartedAt: string | null;
agentActivityDetail?: AgentActivityDetail | null;
bottomContentInsetPx?: ComponentProps<typeof MessagesTimeline>["bottomContentInsetPx"];
contentInsetRightPx?: ComponentProps<typeof MessagesTimeline>["contentInsetRightPx"];
chatFontSizePx: number;
emptyStateContent?: ReactNode;
Expand Down Expand Up @@ -101,7 +100,6 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({
activeTurnInProgress,
activeTurnStartedAt,
agentActivityDetail,
bottomContentInsetPx,
contentInsetRightPx,
chatFontSizePx,
emptyStateContent,
Expand Down Expand Up @@ -193,7 +191,6 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({
{agentActivityDetail && onCloseAgentActivityDetail ? (
<AgentActivityDetailView
detail={agentActivityDetail}
bottomContentInsetPx={bottomContentInsetPx}
chatFontSizePx={chatFontSizePx}
contentInsetRightPx={contentInsetRightPx}
markdownCwd={markdownCwd}
Expand Down Expand Up @@ -251,7 +248,6 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({
chatFontSizePx={chatFontSizePx}
timestampFormat={timestampFormat}
workspaceRoot={workspaceRoot}
bottomContentInsetPx={bottomContentInsetPx}
contentInsetRightPx={contentInsetRightPx}
{...(onOpenAgentActivity ? { onOpenAgentActivity } : {})}
emptyStateContent={
Expand Down
23 changes: 12 additions & 11 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,17 @@ const MAX_VISIBLE_INLINE_TOOL_ENTRIES = 4;
// Changed-files list in the per-turn card is capped so large turns stay compact;
// the rest are revealed via an inline "Show more" row.
const MAX_VISIBLE_CHANGED_FILES = 5;
// The composer overlaps the transcript by design, so the list needs extra tail
// space beyond the overlap to keep final cards from sitting flush against it.
const MIN_BOTTOM_CONTENT_INSET_PX = 64;
// The composer overlaps the transcript by design (its -mt-5 pulls it up over the list's
// tail), so the list appends a fixed tail spacer beyond that overlap to keep final cards
// from sitting flush against the composer.
const BOTTOM_CONTENT_INSET_PX = 64;
const TIMELINE_LIST_FOOTER = (
<div
data-testid="transcript-bottom-spacer"
aria-hidden="true"
style={{ height: BOTTOM_CONTENT_INSET_PX }}
/>
);
const MESSAGE_HOVER_REVEAL_CLASS_NAME =
"opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto";
// Shared interaction tone for a work row's leading glyph and labels: muted by
Expand Down Expand Up @@ -449,7 +457,6 @@ interface MessagesTimelineProps {
chatFontSizePx?: number;
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
bottomContentInsetPx?: number | undefined;
/**
* Right padding (px) applied to the scroll viewport so transcript rows clear a right-edge
* overlay (e.g. the docked Environment card). The scrollbar stays pinned to the viewport's
Expand Down Expand Up @@ -510,7 +517,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
timestampFormat,
workspaceRoot,
emptyStateContent,
bottomContentInsetPx,
contentInsetRightPx,
}: MessagesTimelineProps) {
const normalizedChatFontSizePx = normalizeChatFontSizePx(chatFontSizePx);
Expand Down Expand Up @@ -605,11 +611,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
const fallbackListRef = useRef<LegendListRef | null>(null);
const resolvedListRef = listRef ?? fallbackListRef;
const timelineRootRef = useRef<HTMLDivElement | null>(null);
const bottomSpacerHeightPx = Math.max(bottomContentInsetPx ?? 0, MIN_BOTTOM_CONTENT_INSET_PX);
const listFooter = useMemo(
() => <div aria-hidden="true" style={{ height: bottomSpacerHeightPx }} />,
[bottomSpacerHeightPx],
);

const presentedWorktreeSetup = useWorktreeSetupPresentation(worktreeSetup);
const rawRows = useMemo(
Expand Down Expand Up @@ -1984,7 +1985,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onTouchStart={onMessagesTouchStart}
onWheel={onMessagesWheel}
data-chat-scroll-container="true"
ListFooterComponent={listFooter}
ListFooterComponent={TIMELINE_LIST_FOOTER}
// `scroll-fade-b` (vendored shadcn 4.12.0 util in index.css) masks the bottom
// edge so streamed content dissolves toward the composer. It is scroll-aware
// via `animation-timeline: scroll()`, so the fade clears at the live edge and a
Expand Down
Loading