Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const clientSettings: ClientSettings = {
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
stickyUserMessageCount: 2,
stickyUserMessageMaxLines: 3,
timestampFormat: "24-hour",
};

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3574,6 +3574,8 @@ export default function ChatView(props: ChatViewProps) {
markdownCwd={gitCwd ?? undefined}
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
stickyUserMessageCount={settings.stickyUserMessageCount}
stickyUserMessageMaxLines={settings.stickyUserMessageMaxLines}
workspaceRoot={activeWorkspaceRoot}
skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
onIsAtEndChange={onIsAtEndChange}
Expand Down
151 changes: 146 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ function buildProps() {
markdownCwd: undefined,
resolvedTheme: "dark" as const,
timestampFormat: "24-hour" as const,
stickyUserMessageCount: 0,
stickyUserMessageMaxLines: 2,
workspaceRoot: undefined,
onIsAtEndChange: vi.fn(),
};
Expand All @@ -79,16 +81,27 @@ function buildLongUserMessageText(tail = "deep hidden detail only after expand")
).join("\n");
}

function buildUserTimelineEntry(text: string) {
function buildUserTimelineEntry(
text: string,
{
entryId = "entry-1",
messageId = "message-1",
createdAt = MESSAGE_CREATED_AT,
}: {
entryId?: string;
messageId?: string;
createdAt?: string;
} = {},
) {
return {
id: "entry-1",
id: entryId,
kind: "message" as const,
createdAt: MESSAGE_CREATED_AT,
createdAt,
message: {
id: "message-1" as never,
id: messageId as never,
role: "user" as const,
text,
createdAt: MESSAGE_CREATED_AT,
createdAt,
streaming: false,
},
};
Expand Down Expand Up @@ -205,6 +218,134 @@ describe("MessagesTimeline", () => {
}
});

it("shows a sticky user message only after its source row is above the transcript viewport", async () => {
let sourceAboveViewport = true;
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function (this: HTMLElement) {
if (this.dataset.testid === "legend-list") {
return DOMRect.fromRect({ x: 0, y: 0, width: 640, height: 240 });
}
if (this.dataset.messageId === "message-1") {
return sourceAboveViewport
? DOMRect.fromRect({ x: 0, y: -80, width: 480, height: 60 })
: DOMRect.fromRect({ x: 0, y: 80, width: 480, height: 60 });
}
return originalGetBoundingClientRect.call(this);
},
);

const screen = await render(
<MessagesTimeline
{...buildProps()}
stickyUserMessageCount={1}
stickyUserMessageMaxLines={2}
timelineEntries={[buildUserTimelineEntry("Keep this request visible.")]}
/>,
);

try {
await expect
.element(page.getByRole("button", { name: "Scroll to original user message" }))
.toBeVisible();

sourceAboveViewport = false;
document.querySelector("[data-testid='legend-list']")?.dispatchEvent(new Event("scroll"));

await expect
.element(page.getByRole("button", { name: "Scroll to original user message" }))
.not.toBeInTheDocument();
} finally {
await screen.unmount();
}
});

it("scrolls back to the original user message when the sticky copy is clicked", async () => {
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function (this: HTMLElement) {
if (this.dataset.testid === "legend-list") {
return DOMRect.fromRect({ x: 0, y: 0, width: 640, height: 240 });
}
if (this.dataset.messageId === "message-1") {
return DOMRect.fromRect({ x: 0, y: -80, width: 480, height: 60 });
}
return originalGetBoundingClientRect.call(this);
},
);
const scrollIntoViewSpy = vi
.spyOn(HTMLElement.prototype, "scrollIntoView")
.mockImplementation(() => undefined);

const screen = await render(
<MessagesTimeline
{...buildProps()}
stickyUserMessageCount={1}
timelineEntries={[buildUserTimelineEntry("Jump back to this prompt.")]}
/>,
);

try {
const stickyButton = page.getByRole("button", { name: "Scroll to original user message" });
await expect.element(stickyButton).toBeVisible();
await stickyButton.click();

expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "start", behavior: "smooth" });
} finally {
await screen.unmount();
}
});

it("shows sticky metadata only on the newest visible sticky user message", async () => {
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function (this: HTMLElement) {
if (this.dataset.testid === "legend-list") {
return DOMRect.fromRect({ x: 0, y: 0, width: 640, height: 240 });
}
if (this.dataset.messageId === "message-1" || this.dataset.messageId === "message-2") {
return DOMRect.fromRect({ x: 0, y: -80, width: 480, height: 60 });
}
return originalGetBoundingClientRect.call(this);
},
);

const screen = await render(
<MessagesTimeline
{...buildProps()}
stickyUserMessageCount={2}
stickyUserMessageMaxLines={2}
timelineEntries={[
buildUserTimelineEntry("First sticky prompt.", {
entryId: "entry-1",
messageId: "message-1",
}),
buildUserTimelineEntry("Second sticky prompt.", {
entryId: "entry-2",
messageId: "message-2",
}),
]}
/>,
);

try {
await vi.waitFor(() => {
expect(document.querySelector("[data-sticky-user-message-id='message-1']")).not.toBeNull();
expect(document.querySelector("[data-sticky-user-message-id='message-2']")).not.toBeNull();

const metaRows = document.querySelectorAll("[data-sticky-user-message-meta='true']");
expect(metaRows).toHaveLength(1);
expect(
metaRows[0]
?.closest("[data-sticky-user-message-id]")
?.getAttribute("data-sticky-user-message-id"),
).toBe("message-2");
});
} finally {
await screen.unmount();
}
});

it("expands and re-collapses long user messages from the toggle", async () => {
const screen = await render(
<MessagesTimeline
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ function buildProps() {
markdownCwd: undefined,
resolvedTheme: "light" as const,
timestampFormat: "locale" as const,
stickyUserMessageCount: 0,
stickyUserMessageMaxLines: 2,
workspaceRoot: undefined,
onIsAtEndChange: () => {},
};
Expand Down
76 changes: 57 additions & 19 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,16 @@ import {
import { cn } from "~/lib/utils";
import { useUiStateStore } from "~/uiStateStore";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import type {
StickyUserMessageCount,
StickyUserMessageMaxLines,
} from "@t3tools/contracts/settings";
import { formatTimestamp } from "../../timestampFormat";
import {
deriveStickyUserMessageEntries,
StickyUserMessagesOverlay,
useHiddenStickyUserMessageIds,
} from "./StickyUserMessagesOverlay";

import {
buildInlineTerminalContextText,
Expand Down Expand Up @@ -123,6 +132,8 @@ interface MessagesTimelineProps {
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
timestampFormat: TimestampFormat;
stickyUserMessageCount: StickyUserMessageCount;
stickyUserMessageMaxLines: StickyUserMessageMaxLines;
workspaceRoot: string | undefined;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
onIsAtEndChange: (isAtEnd: boolean) => void;
Expand Down Expand Up @@ -152,6 +163,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
markdownCwd,
resolvedTheme,
timestampFormat,
stickyUserMessageCount,
stickyUserMessageMaxLines,
workspaceRoot,
skills = EMPTY_TIMELINE_SKILLS,
onIsAtEndChange,
Expand Down Expand Up @@ -182,6 +195,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({
],
);
const rows = useStableRows(rawRows);
const timelineViewportRef = useRef<HTMLDivElement>(null);
const stickyUserMessageEntries = useMemo(
() => deriveStickyUserMessageEntries(rows, stickyUserMessageCount),
[rows, stickyUserMessageCount],
);
const hiddenStickyUserMessageIds = useHiddenStickyUserMessageIds({
entries: stickyUserMessageEntries,
listRef,
timelineViewportRef,
enabled: stickyUserMessageCount > 0,
});

const handleScroll = useCallback(() => {
const state = listRef.current?.getState?.();
Expand Down Expand Up @@ -266,21 +290,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({
return (
<TimelineRowCtx value={sharedState}>
<TimelineRowActivityCtx value={activityState}>
<LegendList<MessagesTimelineRow>
ref={listRef}
data={rows}
keyExtractor={keyExtractor}
renderItem={renderItem}
estimatedItemSize={90}
initialScrollAtEnd
maintainScrollAtEnd
maintainScrollAtEndThreshold={0.1}
maintainVisibleContentPosition
onScroll={handleScroll}
className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"
ListHeaderComponent={TIMELINE_LIST_HEADER}
ListFooterComponent={TIMELINE_LIST_FOOTER}
/>
<div ref={timelineViewportRef} className="relative h-full min-h-0">
<LegendList<MessagesTimelineRow>
ref={listRef}
data={rows}
keyExtractor={keyExtractor}
renderItem={renderItem}
estimatedItemSize={90}
initialScrollAtEnd
maintainScrollAtEnd
maintainScrollAtEndThreshold={0.1}
maintainVisibleContentPosition
onScroll={handleScroll}
className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"
ListHeaderComponent={TIMELINE_LIST_HEADER}
ListFooterComponent={TIMELINE_LIST_FOOTER}
/>
<StickyUserMessagesOverlay
entries={stickyUserMessageEntries}
hiddenMessageIds={hiddenStickyUserMessageIds}
listRef={listRef}
maxLines={stickyUserMessageMaxLines}
skills={skills}
timestampFormat={timestampFormat}
timelineViewportRef={timelineViewportRef}
/>
</div>
</TimelineRowActivityCtx>
</TimelineRowCtx>
);
Expand All @@ -290,15 +325,15 @@ function keyExtractor(item: MessagesTimelineRow) {
return item.id;
}

// ---------------------------------------------------------------------------
// TimelineRowContent — the actual row component
// ---------------------------------------------------------------------------

type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number];
type TimelineRow = MessagesTimelineRow;

// ---------------------------------------------------------------------------
// TimelineRowContent — the actual row component
// ---------------------------------------------------------------------------

const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
return (
<div
Expand All @@ -310,6 +345,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
data-timeline-row-kind={row.kind}
data-message-id={row.kind === "message" ? row.message.id : undefined}
data-message-role={row.kind === "message" ? row.message.role : undefined}
data-sticky-user-message-source={
row.kind === "message" && row.message.role === "user" ? "true" : undefined
}
>
{row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null}
{row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
Expand Down
Loading
Loading