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
99 changes: 46 additions & 53 deletions apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
collectTimelineAutoExpansionRowIds,
isNonExpandableSummary,
isRowExpandable,
mergeTimelineTurnDetailPages,
} from "@bb/client-core";
import { isRunningThreadRuntimeDisplayStatus } from "@bb/client-core";
import type {
Expand Down Expand Up @@ -117,8 +118,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 @@ -382,14 +383,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 @@ -650,21 +643,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 @@ -1515,36 +1493,38 @@ 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>(
() => ({
sourceSeqEnd: row.sourceSeqEnd,
sourceSeqStart: row.sourceSeqStart,
threadId: threadId ?? rowThreadId,
turnId: rowTurnId,
}),
[row.sourceSeqEnd, row.sourceSeqStart, rowThreadId, rowTurnId, threadId],
);
const {
data: detail,
fetchNextPage,
hasNextPage,
isError,
isFetchingNextPage,
refetch,
} = useThreadTimelineTurnSummaryDetails(identity);
} = useThreadTimelineTurnDetails(identity);
const handleRetry = useCallback((): void => {
void refetch();
}, [refetch]);
const handleLoadMore = useCallback((): void => {
void fetchNextPage();
}, [fetchNextPage]);
const rows = detail
? // Lazy turn-detail children belong to a completed turn — flag the
// scope as closed so trailing work in the children collapses into a
// step-summary at end-of-input, matching the inline-children path.
getViewRows(detail.rows, { closedScope: true })
getViewRows(
mergeTimelineTurnDetailPages(detail.pages.map((page) => page.rows)),
{ closedScope: true },
)
: null;

if (!rows && isError) {
Expand All @@ -1566,16 +1546,29 @@ function LazyTurnRowBody({
}
if (rows) {
return (
<TimelineRowsList
rows={rows}
scopeActive={false}
showAssistantMessageActions={showAssistantMessageActions}
compactActivityIntents={compactActivityIntents}
spacing="nested"
className={NESTED_TIMELINE_GROUP_LINE_CLASS_NAME}
unreadDividerAutoScroll={false}
unreadDividerPlacement={null}
/>
<div className="space-y-2">
<TimelineRowsList
rows={rows}
scopeActive={false}
showAssistantMessageActions={showAssistantMessageActions}
compactActivityIntents={compactActivityIntents}
spacing="nested"
className={NESTED_TIMELINE_GROUP_LINE_CLASS_NAME}
unreadDividerAutoScroll={false}
unreadDividerPlacement={null}
/>
{hasNextPage ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={isFetchingNextPage}
onClick={handleLoadMore}
>
{isFetchingNextPage ? "Loading…" : "Load more work"}
</Button>
) : null}
</div>
);
}
return (
Expand Down
30 changes: 30 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,20 @@ export interface ThreadTimelineTurnSummaryDetailsQueryIdentity {
threadId: string;
turnId: string;
}
export interface ThreadTimelineTurnDetailsQueryIdentity {
sourceSeqEnd: number;
sourceSeqStart: number;
threadId: string;
turnId: string;
}
type ThreadTimelineTurnDetailsQueryKey = readonly [
typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY,
string,
string,
number,
number,
"pages",
];
type ThreadTimelineTurnSummaryDetailsQueryKey = readonly [
typeof THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY,
string,
Expand Down Expand Up @@ -932,6 +946,22 @@ export function threadTimelineTurnSummaryDetailsQueryKey({
];
}

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

export function threadTimelineQueryKeyPrefix(
threadId: string,
): ThreadTimelineQueryKeyPrefix {
Expand Down
70 changes: 70 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,
TimelineTurnDetailsResponse,
} 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(),
timelineTurnDetails: vi.fn(),
},
},
}));
Expand Down Expand Up @@ -179,6 +182,73 @@ beforeEach(() => {
});
});

describe("useThreadTimelineTurnDetails", () => {
it("stops after the first page until the caller requests the next one", async () => {
vi.mocked(sdk.threads.timelineTurnDetails).mockImplementation(
async (input) => {
const firstPage = input.cursor === undefined;
return {
nextCursor: firstPage ? "cursor-2" : null,
rows: [
{
id: firstPage ? "work-1" : "work-2",
threadId: "thread-1",
turnId: "turn-1",
sourceSeqStart: firstPage ? 1 : 2,
sourceSeqEnd: firstPage ? 1 : 2,
startedAt: firstPage ? 1 : 2,
createdAt: firstPage ? 1 : 2,
kind: "system",
systemKind: "debug",
title: "Work",
detail: null,
status: null,
},
],
} satisfies TimelineTurnDetailsResponse;
},
);
const { wrapper } = createQueryClientTestHarness();
const result = renderHook(
() =>
useThreadTimelineTurnDetails({
sourceSeqEnd: 2,
sourceSeqStart: 1,
threadId: "thread-1",
turnId: "turn-1",
}),
{ wrapper },
);

await waitFor(() => expect(result.result.current.isSuccess).toBe(true));
expect(sdk.threads.timelineTurnDetails).toHaveBeenCalledTimes(1);
expect(sdk.threads.timelineTurnDetails).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
sourceSeqEnd: "2",
sourceSeqStart: "1",
}),
);
expect(
result.result.current.data?.pages.flatMap((page) => page.rows),
).toHaveLength(1);

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

expect(sdk.threads.timelineTurnDetails).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ cursor: "cursor-2" }),
);
await waitFor(() =>
expect(
result.result.current.data?.pages.flatMap((page) => page.rows),
).toHaveLength(2),
);
});
});

describe("useThreadDetailBootstrap", () => {
it("starts the timeline request before the thread bootstrap settles", async () => {
let resolveThread:
Expand Down
32 changes: 32 additions & 0 deletions apps/app/src/hooks/queries/thread-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,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 @@ -1067,6 +1069,36 @@ export function useThreadTimelineTurnSummaryDetails(
});
}

export function useThreadTimelineTurnDetails(
identity: ThreadTimelineTurnDetailsQueryIdentity,
) {
return useInfiniteQuery({
queryKey: threadTimelineTurnDetailsQueryKey(identity),
queryFn: ({ pageParam, signal }) =>
sdk.threads.timelineTurnDetails({
...(pageParam ? { cursor: pageParam } : {}),
signal,
threadId: requireThreadId(
identity.threadId,
"useThreadTimelineTurnDetails",
),
sourceSeqEnd: String(identity.sourceSeqEnd),
sourceSeqStart: String(identity.sourceSeqStart),
turnId: identity.turnId,
}),
initialPageParam: null as string | null,
getNextPageParam: (page) => 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,
});
}

export function getLatestPendingInteraction(
interactions: readonly PendingInteraction[] | undefined,
): PendingInteraction | null {
Expand Down
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test": "vitest run"
},
"dependencies": {
"@bb/client-core": "workspace:*",
"@bb/config": "workspace:*",
"@bb/core-ui": "workspace:*",
"@bb/domain": "workspace:*",
Expand Down
6 changes: 5 additions & 1 deletion apps/cli/src/commands/thread/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type WorkspaceStatus,
} from "@bb/domain";
import type { BbSdk } from "@bb/sdk";
import { prependOlderTimelineRows } from "@bb/client-core";
import type {
EnvironmentDiffQuery,
ThreadTimelineResponse,
Expand Down Expand Up @@ -524,7 +525,10 @@ export function registerShowCommand(
beforeAnchorSeq: String(page.olderCursor.anchorSeq),
beforeAnchorId: page.olderCursor.anchorId,
});
rows = [...older.rows, ...rows];
rows = prependOlderTimelineRows({
olderRows: older.rows,
loadedRows: rows,
});
page = older.timelinePage;
}
const color = process.stdout.isTTY === true && !process.env.NO_COLOR;
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/data/thread-detail/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export {
useThreadDetailBootstrap,
useThreadPendingInteractions,
useThreadQueuedMessages,
useTimelineTurnSummaryDetails,
useTimelineTurnDetails,
} from "./thread-detail-queries";
export { useThreadTimelineController } from "./use-thread-timeline-controller";
export { useChildThreadSummary } from "./use-child-thread-summary";
Expand Down
Loading
Loading