Skip to content
Closed
60 changes: 18 additions & 42 deletions apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ import {
} from "./timeline-row-containment.js";
import { NESTED_TIMELINE_GROUP_LINE_CLASS_NAME } from "./timeline-nested-group-line.js";
import { getThreadRoutePath } from "@/lib/route-paths";
import { useThreadTimelineTurnSummaryDetails } from "@/hooks/queries/thread-queries";
import { type ThreadTimelineTurnSummaryDetailsQueryIdentity } from "@/hooks/queries/query-keys";
import { useThreadTimelineTurnDetails } from "@/hooks/queries/thread-queries";
import { type ThreadTimelineTurnDetailsQueryIdentity } from "@/hooks/queries/query-keys";
import {
useSenderThreadMetadataById,
type SenderThreadMetadata,
Expand Down Expand Up @@ -378,14 +378,6 @@ interface TimelineRowTitleRenderStateCache {
state: TimelineRowTitleRenderState;
}

interface BuildTurnSummaryDetailsIdentityArgs {
rowSourceSeqEnd: TimelineViewTurnRow["sourceSeqEnd"];
rowSourceSeqStart: TimelineViewTurnRow["sourceSeqStart"];
rowThreadId: TimelineViewTurnRow["threadId"];
rowTurnId: TimelineViewTurnRow["turnId"];
threadId: string | undefined;
}

interface TimelineRowsOwnerKeyArgs {
threadId: string | undefined;
timelineRows: readonly TimelineRow[];
Expand Down Expand Up @@ -646,21 +638,6 @@ function useTimelineSearchExpansionRowIds(
}, [inheritedRowIds, location.state, rows, threadId]);
}

function buildTurnSummaryDetailsIdentity({
rowSourceSeqEnd,
rowSourceSeqStart,
rowThreadId,
rowTurnId,
threadId,
}: BuildTurnSummaryDetailsIdentityArgs): ThreadTimelineTurnSummaryDetailsQueryIdentity {
return {
sourceSeqEnd: rowSourceSeqEnd,
sourceSeqStart: rowSourceSeqStart,
threadId: threadId ?? rowThreadId,
turnId: rowTurnId,
};
}

function timelineRowsOwnerKey({
threadId,
timelineRows,
Expand Down Expand Up @@ -1511,28 +1488,27 @@ function LazyTurnRowBody({
showAssistantMessageActions,
}: LazyTurnRowBodyProps) {
const { getViewRows, threadId } = useTimelineRendererStaticContext();
const {
sourceSeqEnd: rowSourceSeqEnd,
sourceSeqStart: rowSourceSeqStart,
threadId: rowThreadId,
turnId: rowTurnId,
} = row;
const identity = useMemo<ThreadTimelineTurnSummaryDetailsQueryIdentity>(
() =>
buildTurnSummaryDetailsIdentity({
rowSourceSeqEnd,
rowSourceSeqStart,
rowThreadId,
rowTurnId,
threadId,
}),
[rowSourceSeqEnd, rowSourceSeqStart, rowThreadId, rowTurnId, threadId],
const { threadId: rowThreadId, turnId: rowTurnId } = row;
const identity = useMemo<ThreadTimelineTurnDetailsQueryIdentity>(
() => ({
threadId: threadId ?? rowThreadId,
turnId: rowTurnId,
}),
[rowThreadId, rowTurnId, threadId],
);
const {
data: detail,
fetchNextPage,
hasNextPage,
isError,
isFetchingNextPage,
refetch,
} = useThreadTimelineTurnSummaryDetails(identity);
} = useThreadTimelineTurnDetails(identity);
useEffect(() => {
if (hasNextPage && !isFetchingNextPage && !isError) {
void fetchNextPage();
}
}, [fetchNextPage, hasNextPage, isError, isFetchingNextPage]);
const handleRetry = useCallback((): void => {
void refetch();
}, [refetch]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ afterEach(() => {
describe("previewed command output", () => {
it("loads the full output for an expanded finished row through row-scoped turn details", async () => {
timelineTurnSummaryDetails.mockResolvedValue({
page: null,
rows: [
commandRow({
id: "cmd_big",
Expand Down
22 changes: 22 additions & 0 deletions apps/app/src/hooks/queries/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,16 @@ export interface ThreadTimelineTurnSummaryDetailsQueryIdentity {
threadId: string;
turnId: string;
}
export interface ThreadTimelineTurnDetailsQueryIdentity {
threadId: string;
turnId: string;
}
type ThreadTimelineTurnDetailsQueryKey = readonly [
typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY,
string,
string,
"pages",
];
type ThreadTimelineTurnSummaryDetailsQueryKey = readonly [
typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY,
string,
Expand Down Expand Up @@ -932,6 +942,18 @@ export function threadTimelineTurnSummaryDetailsQueryKey({
];
}

export function threadTimelineTurnDetailsQueryKey({
threadId,
turnId,
}: ThreadTimelineTurnDetailsQueryIdentity): ThreadTimelineTurnDetailsQueryKey {
return [
THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY,
threadId,
turnId,
"pages",
];
}

export function threadTimelineQueryKeyPrefix(
threadId: string,
): ThreadTimelineQueryKeyPrefix {
Expand Down
68 changes: 68 additions & 0 deletions apps/app/src/hooks/queries/thread-queries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
SidebarBootstrapResponse,
ThreadTimelineResponse,
ThreadWithIncludesResponse,
TimelineTurnSummaryDetailsResponse,
} from "@bb/server-contract";
import { COMPACT_VIEWPORT_QUERY } from "@bb/shared-ui/hooks/use-compact-viewport";
import * as api from "@/lib/api";
Expand Down Expand Up @@ -34,6 +35,7 @@ import {
useThreadQueuedMessages,
useThreadStorageLocation,
useThreadTimeline,
useThreadTimelineTurnDetails,
} from "./thread-queries";

vi.mock("@/lib/api", async (importOriginal) => {
Expand All @@ -52,6 +54,7 @@ vi.mock("@/lib/sdk", () => ({
queuedMessages: { list: vi.fn() },
storageLocation: vi.fn(),
timeline: vi.fn(),
timelineTurnSummaryDetails: vi.fn(),
},
},
}));
Expand Down Expand Up @@ -179,6 +182,71 @@ beforeEach(() => {
});
});

describe("useThreadTimelineTurnDetails", () => {
it("loads opaque forward pages sequentially and joins their rows", async () => {
vi.mocked(sdk.threads.timelineTurnSummaryDetails).mockImplementation(
async (input) => {
const isFirstPage = input.mode === "page" && input.cursor === undefined;
return {
page: {
nextCursor: isFirstPage ? "cursor-2" : null,
},
rows: [
{
id: isFirstPage ? "work-1" : "work-5",
threadId: "thread-1",
turnId: "turn-1",
sourceSeqStart: isFirstPage ? 1 : 5,
sourceSeqEnd: isFirstPage ? 1 : 5,
startedAt: isFirstPage ? 1 : 5,
createdAt: isFirstPage ? 1 : 5,
kind: "system",
systemKind: "debug",
title: "Work",
detail: null,
status: null,
},
],
} satisfies TimelineTurnSummaryDetailsResponse;
},
);
const { wrapper } = createQueryClientTestHarness();

const result = renderHook(
() =>
useThreadTimelineTurnDetails({
threadId: "thread-1",
turnId: "turn-1",
}),
{ wrapper },
);

await waitFor(() => {
expect(result.result.current.data?.rows.map((row) => row.id)).toEqual([
"work-1",
]);
});
await act(async () => {
await result.result.current.fetchNextPage();
});
await waitFor(() => {
expect(result.result.current.data?.rows.map((row) => row.id)).toEqual([
"work-1",
"work-5",
]);
});
expect(sdk.threads.timelineTurnSummaryDetails).toHaveBeenCalledTimes(2);
const firstInput = vi.mocked(sdk.threads.timelineTurnSummaryDetails).mock
.calls[0]?.[0];
expect(firstInput).toMatchObject({ mode: "page", turnId: "turn-1" });
expect(firstInput && "cursor" in firstInput).toBe(false);
expect(sdk.threads.timelineTurnSummaryDetails).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ mode: "page", cursor: "cursor-2" }),
);
});
});

describe("useThreadDetailBootstrap", () => {
it("starts the timeline request before the thread bootstrap settles", async () => {
let resolveThread:
Expand Down
57 changes: 55 additions & 2 deletions apps/app/src/hooks/queries/thread-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
TimelineTurnSummaryDetailsResponse,
} from "@bb/server-contract";
import { applyTimelineDelta } from "@bb/server-contract";
import { coalesceTimelineTurnDetailPageRows } from "@bb/thread-view";
import type { ThreadListFilters } from "@bb/client-core";
import type { FilePreview } from "@bb/client-core";
import type { PathListOptions } from "@/lib/path-list-options";
Expand Down Expand Up @@ -78,8 +79,10 @@ import {
threadHostFilePreviewQueryKey,
threadConversationOutlineQueryKey,
threadTimelineQueryKey,
threadTimelineTurnDetailsQueryKey,
threadTimelineTurnSummaryDetailsQueryKey,
threadsQueryKey,
type ThreadTimelineTurnDetailsQueryIdentity,
type ThreadTimelineTurnSummaryDetailsQueryIdentity,
} from "./query-keys";
import { ARCHIVED_THREADS_PAGE_SIZE } from "./archived-threads-page-size";
Expand Down Expand Up @@ -1040,10 +1043,20 @@ export function useThreadTimelineTurnSummaryDetails(
identity: ThreadTimelineTurnSummaryDetailsQueryIdentity,
options?: ThreadTimelineTurnSummaryDetailsQueryOptions,
) {
return useQuery<TimelineTurnSummaryDetailsResponse>({
return useQuery<TimelineTurnSummaryDetailsResponse>(
threadTimelineTurnSummaryDetailsQueryOptions(identity, options),
);
}

function threadTimelineTurnSummaryDetailsQueryOptions(
identity: ThreadTimelineTurnSummaryDetailsQueryIdentity,
options?: ThreadTimelineTurnSummaryDetailsQueryOptions,
) {
return {
queryKey: threadTimelineTurnSummaryDetailsQueryKey(identity),
queryFn: ({ signal }) =>
queryFn: ({ signal }: { signal: AbortSignal }) =>
sdk.threads.timelineTurnSummaryDetails({
mode: "range",
threadId: requireThreadId(
identity.threadId,
"useThreadTimelineTurnSummaryDetails",
Expand All @@ -1064,7 +1077,47 @@ export function useThreadTimelineTurnSummaryDetails(
refetchOnMount: options?.refetchOnMount ?? true,
staleTime: options?.staleTime ?? Infinity,
...HEAVY_PAYLOAD_QUERY_POLICY,
};
}

/** Load a completed turn's details forward, one bounded server page at a time. */
export function useThreadTimelineTurnDetails(
identity: ThreadTimelineTurnDetailsQueryIdentity,
) {
const query = useInfiniteQuery({
queryKey: threadTimelineTurnDetailsQueryKey(identity),
queryFn: ({ pageParam, signal }) =>
sdk.threads.timelineTurnSummaryDetails({
...(pageParam ? { cursor: pageParam } : {}),
mode: "page",
signal,
threadId: requireThreadId(
identity.threadId,
"useThreadTimelineTurnDetails",
),
turnId: identity.turnId,
}),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.page?.nextCursor ?? undefined,
enabled: Boolean(identity.threadId) && Boolean(identity.turnId),
meta: {
errorMessage: "Failed to load turn details.",
showErrorToast: false,
},
refetchOnMount: true,
staleTime: Infinity,
...HEAVY_PAYLOAD_QUERY_POLICY,
});
return {
...query,
data: query.data
? {
rows: coalesceTimelineTurnDetailPageRows(
query.data.pages.map((page) => page.rows),
),
}
: undefined,
};
}

export function getLatestPendingInteraction(
Expand Down
57 changes: 57 additions & 0 deletions apps/cli/src/__tests__/command-output/thread-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import type { CommandRegistrar } from "../helpers/command-output-harness.js";
import * as fixtures from "../helpers/command-output-fixtures.js";
import { registerThreadCommands } from "../../commands/thread/index.js";
import type { TimelineTurnRow } from "@bb/server-contract";

describe("bb thread log command output", () => {
setupCommandOutputTestEnvironment();
Expand Down Expand Up @@ -465,6 +466,62 @@ describe("bb thread log command output", () => {
expect(output).not.toContain("older history omitted");
});

it("bb thread log --all renders byte-window fragments as one worked-for row", async () => {
const turnFragment = (start: number, end: number): TimelineTurnRow => ({
...fixtures.makeTimelineBase({
id: "thread-log:turn-1:turn",
sourceSeqStart: start,
sourceSeqEnd: end,
startedAt: 1_000,
createdAt: 9_000,
}),
turnId: "turn-1",
kind: "turn",
status: "completed",
summaryCount: 1,
completedAt: 9_000,
children: null,
});
const getTimeline = vi.fn(
async (input: { query: { beforeAnchorSeq?: string } }) => {
if (input.query.beforeAnchorSeq === undefined) {
return {
...fixtures.makeTimelineResponse([turnFragment(5, 8)]),
timelinePage: {
kind: "latest" as const,
segmentLimit: 100,
returnedSegmentCount: 1,
hasOlderRows: true,
olderCursor: {
anchorSeq: 5,
anchorId: "thread-log:byte-window:5",
},
},
};
}
return {
...fixtures.makeTimelineResponse([turnFragment(1, 4)]),
timelinePage: {
kind: "older" as const,
segmentLimit: 100,
returnedSegmentCount: 1,
hasOlderRows: false,
olderCursor: null,
},
};
},
);
stubServerApi({
"v1.threads.:id.timeline.$get": getTimeline,
});

await runCommand(["thread", "log", "thread-log", "--all"], register);

const output = String(vi.mocked(console.log).mock.calls[0]?.[0]);
expect(output.match(/Worked for/g)).toHaveLength(1);
expect(output).toContain("Worked for (8s)");
});

it("bb thread log rejects --all combined with --limit", async () => {
stubServerApi({
"v1.threads.:id.timeline.$get": vi.fn(async () =>
Expand Down
Loading
Loading