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
28 changes: 28 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,34 @@ export function resolveTimelineMinimapIndexFromPointer(input: {
return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1))));
}

export function resolveTimelineMinimapCurrentIndex(input: {
readonly scrollTop: number;
readonly scrollBottom: number;
readonly itemBounds: ReadonlyArray<{
readonly top: number | null;
readonly height: number | null;
}>;
}): number | null {
let precedingIndex: number | null = null;

for (const [index, item] of input.itemBounds.entries()) {
if (item.top === null) {
continue;
}
const inView =
item.top < input.scrollBottom && item.top + Math.max(1, item.height ?? 1) > input.scrollTop;
if (inView) {
// The first visible marker is the turn at the reader's current position.
return index;
}
if (item.top <= input.scrollTop) {
precedingIndex = index;
}
}

return precedingIndex;
}

export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean {
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
return false;
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,25 @@ function buildSnapShotTimelineEntry(previewUrl?: string) {
}

describe("MessagesTimeline", () => {
it("renders previous and next controls with the minimap", () => {
const first = buildUserTimelineEntry("First turn");
const secondBase = buildUserTimelineEntry("Second turn");
const second = {
...secondBase,
id: "entry-2",
message: {
...secondBase.message,
id: MessageId.make("message-2"),
},
};
const markup = renderToStaticMarkup(
<MessagesTimeline {...buildProps()} timelineEntries={[first, second]} />,
);

expect(markup).toContain('aria-label="Previous turn"');
expect(markup).toContain('aria-label="Next turn"');
});

// Expanding history uses this suite's existing test renderer, deprecated in
// React 19. Migrate these interaction tests together when a DOM test setup is added.
it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])(
Expand Down Expand Up @@ -524,6 +543,7 @@ describe("MessagesTimeline", () => {
const {
resolveTimelineIsAtEnd,
resolveTimelineMinimapHasPersistentGutter,
resolveTimelineMinimapCurrentIndex,
resolveTimelineMinimapHeightStyle,
resolveTimelineMinimapHitStripWidth,
resolveTimelineMinimapIndexFromPointer,
Expand Down Expand Up @@ -583,6 +603,35 @@ describe("MessagesTimeline", () => {
pointerY: 999,
}),
).toBe(100);
expect(
resolveTimelineMinimapCurrentIndex({
scrollTop: 100,
scrollBottom: 500,
itemBounds: [
{ top: 80, height: 20 },
{ top: 120, height: 20 },
{ top: 220, height: 20 },
],
}),
).toBe(1);
expect(
resolveTimelineMinimapCurrentIndex({
scrollTop: 150,
scrollBottom: 200,
itemBounds: [
{ top: 80, height: 20 },
{ top: 120, height: 20 },
{ top: 220, height: 20 },
],
}),
).toBe(1);
expect(
resolveTimelineMinimapCurrentIndex({
scrollTop: 0,
scrollBottom: 50,
itemBounds: [{ top: 80, height: 20 }],
}),
).toBeNull();
expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);
expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);
expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);
Expand Down
Loading
Loading