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
53 changes: 37 additions & 16 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay";
const TIMELINE_MINIMAP_ITEM_SPACING = 8;
export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
const TIMELINE_CONTENT_MAX_WIDTH = 768;
const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;

function singleToolCallLabel(entry: WorkLogEntry): string {
Expand Down Expand Up @@ -225,14 +224,25 @@ export function resolveTimelineMinimapCurrentIndex(input: {
return precedingIndex;
}

export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean {
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
return false;
/**
* Side gutter between the viewport edge and the centered content column.
* `contentWidth` is the rendered column width, which follows the Chat width
* setting, so callers measure it rather than assume a fixed maximum.
*/
function resolveTimelineSideGutter(viewportWidth: number, contentWidth: number): number {
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0 || !Number.isFinite(contentWidth)) {
return 0;
}
return Math.max(0, (viewportWidth - Math.min(viewportWidth, contentWidth)) / 2);
}

const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);
const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);
return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER;
export function resolveTimelineMinimapHasPersistentGutter(
viewportWidth: number,
contentWidth: number,
): boolean {
return (
resolveTimelineSideGutter(viewportWidth, contentWidth) >= TIMELINE_MINIMAP_PERSISTENT_GUTTER
);
}

const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12;
Expand All @@ -241,18 +251,16 @@ const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem";

/**
* The minimap overlays the viewport's left edge while the content column is
* centered, so the side gutter between them shrinks under browser zoom or a
* narrow pane. A fixed-width hover strip would then sit on top of the message
* centered, so the side gutter between them shrinks under browser zoom, a
* narrow pane, or a wider Chat width setting. A fixed-width hover strip would then sit on top of the message
* text and swallow its pointer events. Cap the strip's width so it never
* extends past the gutter into the content column; 0 disables the strip.
*/
export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): number {
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
return 0;
}

const contentWidth = Math.min(viewportWidth, TIMELINE_CONTENT_MAX_WIDTH);
const sideGutter = Math.max(0, (viewportWidth - contentWidth) / 2);
export function resolveTimelineMinimapHitStripWidth(
viewportWidth: number,
contentWidth: number,
): number {
const sideGutter = resolveTimelineSideGutter(viewportWidth, contentWidth);
return Math.max(
0,
Math.min(
Expand All @@ -262,6 +270,19 @@ export function resolveTimelineMinimapHitStripWidth(viewportWidth: number): numb
);
}

// The prev/next buttons are centered 4px into the strip and 20px wide, so
// their hitbox reaches 14px past the strip's left edge.
const TIMELINE_MINIMAP_NAVIGATION_REACH = 14;

/**
* The prev/next buttons hang outside the strip's height, so the strip's own
* width cap does not cover them. Keep them inert to the pointer unless the
* gutter can hold them; keyboard focus still reaches them.
*/
export function resolveTimelineMinimapNavigationInteractive(collapsedWidth: number): boolean {
return collapsedWidth >= TIMELINE_MINIMAP_NAVIGATION_REACH;
}

/**
* Once the preview is open, keep the full preview and the space leading to it
* interactive. The collapsed strip remains gutter-capped so it cannot block
Expand Down
38 changes: 28 additions & 10 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ describe("MessagesTimeline", () => {
resolveTimelineMinimapHitStripWidth,
resolveTimelineMinimapIndexFromPointer,
resolveTimelineMinimapInteractiveWidth,
resolveTimelineMinimapNavigationInteractive,
resolveTimelineMinimapTopPercent,
} = await import("./MessagesTimeline.logic");

Expand Down Expand Up @@ -644,22 +645,39 @@ describe("MessagesTimeline", () => {
itemBounds: [{ top: 80, height: 20 }],
}),
).toBeNull();
expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);
expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);
expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);
// Comfortable width: the column is capped at 768px.
expect(resolveTimelineMinimapHasPersistentGutter(832, 768)).toBe(false);
expect(resolveTimelineMinimapHasPersistentGutter(863, 768)).toBe(false);
expect(resolveTimelineMinimapHasPersistentGutter(864, 768)).toBe(true);
// Wider Chat width settings consume the gutter the minimap relies on.
expect(resolveTimelineMinimapHasPersistentGutter(1400, 1152)).toBe(true);
expect(resolveTimelineMinimapHasPersistentGutter(1200, 1152)).toBe(false);
expect(resolveTimelineMinimapHasPersistentGutter(2560, 2560)).toBe(false);

// No usable gutter (zoomed in / narrow pane): the strip must go inert
// instead of overlaying the centered content column.
expect(resolveTimelineMinimapHitStripWidth(768)).toBe(0);
expect(resolveTimelineMinimapHitStripWidth(792)).toBe(0);
expect(resolveTimelineMinimapHitStripWidth(768, 768)).toBe(0);
expect(resolveTimelineMinimapHitStripWidth(792, 768)).toBe(0);
// Partial gutter: strip shrinks to what fits between the viewport edge
// and the content column.
expect(resolveTimelineMinimapHitStripWidth(820)).toBe(14);
expect(resolveTimelineMinimapHitStripWidth(820, 768)).toBe(14);
// Full gutter: unchanged 40px-wide strip.
expect(resolveTimelineMinimapHitStripWidth(872)).toBe(40);
expect(resolveTimelineMinimapHitStripWidth(1400)).toBe(40);
expect(resolveTimelineMinimapHitStripWidth(0)).toBe(0);
expect(resolveTimelineMinimapHitStripWidth(Number.NaN)).toBe(0);
expect(resolveTimelineMinimapHitStripWidth(872, 768)).toBe(40);
expect(resolveTimelineMinimapHitStripWidth(1400, 768)).toBe(40);
// Full Chat width: the column spans the viewport, so the strip is inert
// however wide the window gets.
expect(resolveTimelineMinimapHitStripWidth(2560, 2560)).toBe(0);
// Wide Chat width on a window just wider than the column: partial strip.
expect(resolveTimelineMinimapHitStripWidth(1204, 1152)).toBe(14);
expect(resolveTimelineMinimapHitStripWidth(0, 0)).toBe(0);
expect(resolveTimelineMinimapHitStripWidth(Number.NaN, 768)).toBe(0);

// Prev/next buttons reach 14px past the strip's left edge; a narrower
// strip means they would sit on the content column.
expect(resolveTimelineMinimapNavigationInteractive(40)).toBe(true);
expect(resolveTimelineMinimapNavigationInteractive(14)).toBe(true);
expect(resolveTimelineMinimapNavigationInteractive(8)).toBe(false);
expect(resolveTimelineMinimapNavigationInteractive(0)).toBe(false);

// The collapsed target stays narrow, but an open preview keeps its full
// 20rem width plus the 2rem offset from the minimap rail interactive.
Expand Down
26 changes: 22 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ import {
resolveTimelineMinimapHitStripWidth,
resolveTimelineMinimapIndexFromPointer,
resolveTimelineMinimapInteractiveWidth,
resolveTimelineMinimapNavigationInteractive,
resolveTimelineMinimapTopPercent,
resolveWorkGroupScrollIndex,
shouldFollowWorkGroupAppend,
Expand Down Expand Up @@ -226,6 +227,7 @@ import {
} from "../composerInlineChip";
import { createContextPresentationRegistry } from "../contextPresentationRegistry";
import { useOpenPrLink } from "~/lib/openPullRequestLink";
import { useClientSettings } from "~/hooks/useSettings";
import type { ChatMarkdownContextReference } from "../ChatMarkdown";
import { cn } from "~/lib/utils";
import { useUiStateStore } from "~/uiStateStore";
Expand Down Expand Up @@ -691,6 +693,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
null,
);
// Re-measure the minimap gutter when the chat column changes width without a viewport resize.
const chatWidth = useClientSettings((settings) => settings.chatWidth);
const {
target: readyCitationRequest,
positioning: citationPositioning,
Expand Down Expand Up @@ -830,11 +834,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({

const measure = () => {
const viewportWidth = timelineViewportElement.getBoundingClientRect().width;
const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(viewportWidth);
// Without a mounted row, treat the column as full width so the strip stays inert.
const contentWidth =
timelineViewportElement
.querySelector<HTMLElement>("[data-timeline-root]")
?.getBoundingClientRect().width ?? viewportWidth;
const nextHasPersistentGutter = resolveTimelineMinimapHasPersistentGutter(
viewportWidth,
contentWidth,
);
setMinimapHasPersistentGutter((current) =>
current === nextHasPersistentGutter ? current : nextHasPersistentGutter,
);
setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth));
setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth, contentWidth));
reportContentOverflow();
};

Expand All @@ -847,7 +859,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
cancelAnimationFrame(frame);
observer.disconnect();
};
}, [timelineViewportElement, rows.length, reportContentOverflow]);
}, [timelineViewportElement, rows.length, reportContentOverflow, chatWidth]);

const sharedState = useMemo<TimelineRowSharedState>(
() => ({
Expand Down Expand Up @@ -1084,6 +1096,7 @@ function TimelineMinimap({
),
[items, resolvedActiveIndex],
);
const navigationInteractive = resolveTimelineMinimapNavigationInteractive(hitStripWidth);
const activeTopPercent =
resolvedActiveIndex === null
? 0
Expand Down Expand Up @@ -1164,6 +1177,7 @@ function TimelineMinimap({
<TimelineMinimapNavigationButton
direction="previous"
disabled={previousItem === null}
interactive={navigationInteractive}
onClick={() => {
if (previousItem) onSelect(previousItem);
}}
Expand Down Expand Up @@ -1279,6 +1293,7 @@ function TimelineMinimap({
<TimelineMinimapNavigationButton
direction="next"
disabled={nextItem === null}
interactive={navigationInteractive}
onClick={() => {
if (nextItem) onSelect(nextItem);
}}
Expand All @@ -1292,10 +1307,12 @@ function TimelineMinimap({
function TimelineMinimapNavigationButton({
direction,
disabled,
interactive,
onClick,
}: {
direction: "previous" | "next";
disabled: boolean;
interactive: boolean;
onClick: () => void;
}) {
const previous = direction === "previous";
Expand All @@ -1308,7 +1325,8 @@ function TimelineMinimapNavigationButton({
render={
<span
className={cn(
"absolute left-1 z-10 inline-flex -translate-x-1/2 opacity-0 pointer-events-auto transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
"absolute left-1 z-10 inline-flex -translate-x-1/2 opacity-0 transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
interactive ? "pointer-events-auto" : "pointer-events-none",
previous ? "bottom-[calc(100%+2px)]" : "top-[calc(100%+2px)]",
)}
/>
Expand Down
Loading