Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,60 @@ describe("useThreadTimelineController", () => {
expect(sdk.threads.timeline).toHaveBeenCalledTimes(2);
});
});

it("uses the bounded app page size when loading older timeline rows", async () => {
const latest = {
...makeTimelineResponse(),
rows: [makeUserRow("thread-1:user-seed:10", 10)],
timelinePage: {
hasOlderRows: true,
kind: "latest" as const,
olderCursor: {
anchorId: "thread-1:user-seed:10",
anchorSeq: 10,
},
returnedSegmentCount: 1,
segmentLimit: 8,
},
};
const older = {
...makeTimelineResponse(),
rows: [makeUserRow("thread-1:user-seed:1", 1)],
timelinePage: {
hasOlderRows: false,
kind: "older" as const,
olderCursor: null,
returnedSegmentCount: 1,
segmentLimit: 8,
},
};
vi.mocked(sdk.threads.timeline)
.mockResolvedValueOnce(latest)
.mockResolvedValueOnce(older);

const { wrapper } = createQueryClientTestHarness();
const { result } = renderHook(
() => useThreadTimelineController({ threadId: "thread-1" }),
{ wrapper },
);

await waitFor(() => {
expect(result.current.hasOlderTimelineRows).toBe(true);
});

await act(async () => {
await result.current.loadOlderTimelineRows();
});

expect(vi.mocked(sdk.threads.timeline).mock.calls[1]?.[0]).toEqual({
beforeAnchorId: "thread-1:user-seed:10",
beforeAnchorSeq: "10",
segmentLimit: "8",
threadId: "thread-1",
});
expect(result.current.timelineRows.map((row) => row.id)).toEqual([
"thread-1:user-seed:1",
"thread-1:user-seed:10",
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isTransientReadError } from "@/hooks/queries/query-helpers";
import { useThreadTimeline } from "@/hooks/queries/thread-queries";
import { isOptimisticTimelineRowId } from "@/lib/optimistic-timeline-row";
import { BbHttpError, sdk } from "@/lib/sdk";
import { APP_THREAD_TIMELINE_SEGMENT_LIMIT } from "@/lib/thread-timeline-window";

export type ThreadTimelineRowFilter = (row: TimelineRow) => boolean;

Expand Down Expand Up @@ -488,6 +489,7 @@ export function useThreadTimelineController({
const response = await sdk.threads.timeline({
beforeAnchorId: nextOlderCursor.anchorId,
beforeAnchorSeq: String(nextOlderCursor.anchorSeq),
segmentLimit: APP_THREAD_TIMELINE_SEGMENT_LIMIT,
threadId,
});
const olderRows = filterTimelineRows({
Expand Down
263 changes: 260 additions & 3 deletions apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,33 @@ function userConversationRow(index = 1): TimelineRow {
};
}

function assistantConversationRow({
id,
index,
sourceSeqStart = index,
text,
}: {
id: string;
index: number;
sourceSeqStart?: number;
text: string;
}): TimelineRow {
return {
id,
threadId: "thr_toc_test",
turnId: `turn_${index}`,
sourceSeqStart,
sourceSeqEnd: index,
startedAt: index,
createdAt: index,
kind: "conversation",
role: "assistant",
text,
attachments: null,
turnRequest: null,
};
}

function TocHost({
hasOlderTimelineRows = false,
loadOlderTimelineRows = () => {},
Expand Down Expand Up @@ -161,13 +188,17 @@ function createScrollElement({

function outlineResponse(
items: ThreadConversationOutlineItem[],
maxSeq = items.length,
): ThreadConversationOutlineResponse {
return { items, maxSeq: items.length };
return { items, maxSeq };
}

function setOutline(items: ThreadConversationOutlineItem[] | undefined): void {
function setOutline(
items: ThreadConversationOutlineItem[] | undefined,
maxSeq?: number,
): void {
vi.mocked(useThreadConversationOutline).mockReturnValue({
data: items === undefined ? undefined : outlineResponse(items),
data: items === undefined ? undefined : outlineResponse(items, maxSeq),
} as ReturnType<typeof useThreadConversationOutline>);
}

Expand Down Expand Up @@ -611,6 +642,205 @@ describe("ThreadTableOfContents", () => {
expect(screen.getByText("Agent messages")).not.toBeNull();
});

it("overlays live assistant previews and rows on the stable full outline", async () => {
setOutline(
[
{
id: "u1",
role: "user",
preview: "First question",
attachmentSummary: null,
},
{
id: "u2",
role: "user",
preview: "Second question",
attachmentSummary: null,
},
{
id: "u3",
role: "user",
preview: "Third question",
attachmentSummary: null,
},
{
id: "a0",
role: "assistant",
preview: "Stored historical preview",
attachmentSummary: null,
},
{
id: "a1",
role: "assistant",
preview: "Stale streaming preview",
attachmentSummary: null,
},
],
10,
);

render(
<TocHost
timelineRows={[
assistantConversationRow({
id: "a0",
index: 10,
text: "Loaded historical text must not win",
}),
assistantConversationRow({
id: "a1",
index: 11,
sourceSeqStart: 9,
text: "Current streaming preview",
}),
assistantConversationRow({
id: "a2",
index: 12,
text: "New streaming row",
}),
]}
/>,
);
openTocPanel();
fireEvent.click(await screen.findByText("Agent messages"));

expect(screen.getByText("Stored historical preview")).not.toBeNull();
expect(
screen.queryByText("Loaded historical text must not win"),
).toBeNull();
expect(screen.getByText("Current streaming preview")).not.toBeNull();
expect(screen.getByText("New streaming row")).not.toBeNull();
expect(screen.queryByText("Stale streaming preview")).toBeNull();
});

it("does not rebind scroll-spy when only a live assistant label changes", async () => {
const storedOutline: ThreadConversationOutlineItem[] = [
{
id: "u1",
role: "user",
preview: "First question",
attachmentSummary: null,
},
{
id: "u2",
role: "user",
preview: "Second question",
attachmentSummary: null,
},
{
id: "u3",
role: "user",
preview: "Third question",
attachmentSummary: null,
},
];
setOutline(storedOutline, 10);
const addEventListener = vi.spyOn(scrollElement, "addEventListener");
const view = render(
<TocHost
timelineRows={[
assistantConversationRow({
id: "a-live",
index: 11,
text: "First live text",
}),
]}
/>,
);
openTocPanel();
fireEvent.click(await screen.findByText("Agent messages"));
expect(await screen.findByText("First live text")).not.toBeNull();
const scrollListenerAdds = () =>
addEventListener.mock.calls.filter(([type]) => type === "scroll").length;
expect(scrollListenerAdds()).toBe(1);

view.rerender(
<TocHost
timelineRows={[
assistantConversationRow({
id: "a-live",
index: 12,
text: "Second live text",
}),
]}
/>,
);

expect(await screen.findByText("Second live text")).not.toBeNull();
expect(scrollListenerAdds()).toBe(1);

setOutline(
[
...storedOutline,
{
id: "a-live",
role: "assistant",
preview: "Final stored text",
attachmentSummary: null,
},
],
12,
);
view.rerender(
<TocHost
timelineRows={[
assistantConversationRow({
id: "a-live",
index: 12,
text: "Second live text",
}),
]}
/>,
);

expect(await screen.findByText("Final stored text")).not.toBeNull();
expect(screen.getAllByText("Final stored text")).toHaveLength(1);
expect(screen.queryByText("Second live text")).toBeNull();
});

it("caps live assistant previews to the outline payload limit", async () => {
setOutline(
[
{
id: "u1",
role: "user",
preview: "First question",
attachmentSummary: null,
},
{
id: "u2",
role: "user",
preview: "Second question",
attachmentSummary: null,
},
{
id: "u3",
role: "user",
preview: "Third question",
attachmentSummary: null,
},
],
10,
);
const boundedPreview = "x".repeat(200);
render(
<TocHost
timelineRows={[
assistantConversationRow({
id: "a-live",
index: 11,
text: `${boundedPreview} overflow`,
}),
]}
/>,
);
openTocPanel();
fireEvent.click(await screen.findByText("Agent messages"));

expect(screen.getByText(boundedPreview)).not.toBeNull();
expect(screen.queryByText(`${boundedPreview} overflow`)).toBeNull();
});

it("renders an agent-to-agent message source as a thread mention", async () => {
setOutline([
{
Expand Down Expand Up @@ -896,6 +1126,33 @@ describe("ThreadTableOfContents", () => {
);
});

it("tracks a live assistant row that is newer than the outline", () => {
const scrollElement = createScrollElement({
clientHeight: 100,
scrollHeight: 1_000,
scrollTop: 400,
rows: [{ id: "agent-live", top: 20, bottom: 80 }],
});

expect(
findActiveItemIds({
agentItems: [],
scrollElement,
supplementalAgentItems: [
{
id: "agent-live",
label: "Streaming response",
role: "assistant",
},
],
userItems: [],
}),
).toEqual({
agent: "agent-live",
user: null,
});
});

it("does not mark a role active at the bottom when that role is offscreen", () => {
const scrollElement = createScrollElement({
clientHeight: 100,
Expand Down
Loading
Loading