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
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,83 @@ describe("ThreadTimelineSurface load-older control", () => {
expect(onLoadOlderRows).toHaveBeenCalledTimes(2);
});
});

describe("ThreadTimelineSurface catch-up indicator", () => {
const props = {
activeThinking: null,
contextBoundarySeq: null,
isThreadTimelinePending: false,
showOngoingIndicator: false,
threadId: "catch-up",
threadRuntimeDisplayStatus: "idle" as const,
timelineError: false,
timelineRows: [],
workspaceRootPath: undefined,
};

it("never shows loading for a catch-up that finishes within one second", async () => {
vi.useFakeTimers();
const view = render(
<ThreadTimelineSurface {...props} isCatchingUpTimeline />,
);
expect(screen.queryByRole("status")).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(900);
});
expect(screen.queryByRole("status")).toBeNull();
view.rerender(
<ThreadTimelineSurface {...props} isCatchingUpTimeline={false} />,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(screen.queryByRole("status")).toBeNull();
});

it("delays each thread's indicator and hides immediately when caught up", async () => {
vi.useFakeTimers();
const view = render(
<ThreadTimelineSurface {...props} isCatchingUpTimeline />,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(999);
});
expect(screen.queryByRole("status")).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(screen.getByRole("status").textContent).toBe(
"Loading latest messages…",
);
view.rerender(
<ThreadTimelineSurface
{...props}
threadId="another-thread"
isCatchingUpTimeline
/>,
);
expect(screen.queryByRole("status")).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(screen.getByRole("status").textContent).toBe(
"Loading latest messages…",
);
view.rerender(
<ThreadTimelineSurface
{...props}
threadId="another-thread"
isCatchingUpTimeline={false}
/>,
);
expect(screen.queryByRole("status")).toBeNull();
view.rerender(
<ThreadTimelineSurface
{...props}
threadId="another-thread"
isCatchingUpTimeline
/>,
);
expect(screen.queryByRole("status")).toBeNull();
});
});
47 changes: 31 additions & 16 deletions apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/pr
import { Button } from "@bb/shared-ui/button";
import { ConversationTimeline } from "@/components/ui/conversation.js";
import { HeightTransition } from "@/components/ui/height-transition.js";
import { useDelayedBusyIndicator } from "@/components/ui/route-navigation-indicator";
import { Icon } from "@bb/shared-ui/icon";
import { Skeleton } from "@bb/shared-ui/skeleton";
import { toUserAttachmentImageSrc } from "@/lib/user-attachment-images";
Expand Down Expand Up @@ -173,9 +172,10 @@ export function ThreadTimelineSurface({
workspaceRootPath,
}: ThreadTimelineSurfaceProps) {
const showCatchUpIndicator =
useDelayedBusyIndicator(
isCatchingUpTimeline && !isThreadTimelinePending && !timelineError,
) && !showOngoingIndicator;
isCatchingUpTimeline &&
!isThreadTimelinePending &&
!timelineError &&
!showOngoingIndicator;
const showActiveThinking =
activeThinking !== null && ongoingIndicatorLabel === undefined;
const activeThinkingText = activeThinking?.text.trim() ?? "";
Expand All @@ -202,6 +202,9 @@ export function ThreadTimelineSurface({
return (
<TimelineReasoningExpansionProvider key={threadId}>
<ConversationTimeline className="flex-1">
<div className="pointer-events-none sticky top-2 z-10 h-0 self-end">
{showCatchUpIndicator ? <DelayedCatchUpIndicator /> : null}
</div>
{leadingContent}
{showLoadOlderRows ? (
<LoadOlderMessages
Expand Down Expand Up @@ -253,18 +256,6 @@ export function ThreadTimelineSurface({
className="mt-4 text-destructive"
/>
) : null}
<HeightTransition visible={showCatchUpIndicator}>
{showCatchUpIndicator ? (
<TimelineStatusIndicator
label={
<span role="status" className="animate-shine">
{CATCH_UP_INDICATOR_LABEL}
</span>
}
className="mt-4 flex min-h-7 items-center"
/>
) : null}
</HeightTransition>
<HeightTransition visible={showOngoingIndicator}>
<TimelineWorkingIndicator
key={ongoingIndicatorKey}
Expand Down Expand Up @@ -326,6 +317,30 @@ function LoadOlderMessages({
const LOADING_INDICATOR_REVEAL_DELAY_MS = 200;
const CATCH_UP_INDICATOR_LABEL = "Loading latest messages…";

const CATCH_UP_INDICATOR_REVEAL_DELAY_MS = 1_000;

function DelayedCatchUpIndicator() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const id = window.setTimeout(
() => setVisible(true),
CATCH_UP_INDICATOR_REVEAL_DELAY_MS,
);
return () => window.clearTimeout(id);
}, []);

if (!visible) return null;

return (
<div
role="status"
className="absolute right-2 top-0 whitespace-nowrap rounded-md border border-border bg-background px-2 py-1 text-sm text-muted-foreground shadow-sm"
>
{CATCH_UP_INDICATOR_LABEL}
</div>
);
}

function DelayedThreadLoadingIndicator() {
const [visible, setVisible] = useState(false);
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ function startTimelineRefetch(queryClient: QueryClient): void {
}

function markTimelineHasNewEvents(queryClient: QueryClient): void {
markThreadTimelineUnseenEvents(queryClient, "thread-1");
markThreadTimelineUnseenEvents(queryClient, "thread-1", 2);
void queryClient.invalidateQueries({
queryKey: TIMELINE_QUERY_KEY,
refetchType: "none",
Expand Down Expand Up @@ -1480,6 +1480,22 @@ describe("useThreadTimelineController commits", () => {
expect(view.latest().isCatchingUpTimeline).toBe(false);
});

it("keeps a timeline stale when a completed fetch does not include the known event", async () => {
vi.mocked(sdk.threads.timeline).mockResolvedValueOnce(
makeTimelineResponse({ maxSeq: 1, rows: [newestLoadedRow] }),
);
const { queryClient, wrapper } = createQueryClientTestHarness();
queryClient.setQueryData(
TIMELINE_QUERY_KEY,
makeTimelineResponse({ maxSeq: 1, rows: [newestLoadedRow] }),
);
markTimelineHasNewEvents(queryClient);
const view = renderProfiledController(wrapper);
await waitFor(() => expect(queryClient.isFetching()).toBe(0));
expect(view.latest().isCatchingUpTimeline).toBe(true);
expect(hasThreadTimelineUnseenEvents(queryClient, "thread-1")).toBe(true);
});

it("clears unseen events once the catch-up fetch succeeds", async () => {
vi.mocked(sdk.threads.timeline).mockResolvedValueOnce(
makeTimelineResponse({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CommitOlderTimelineRows } from "./load-older-timeline-rows.js";
import { useCallback, useRef, useState } from "react";
import { useCallback, useState } from "react";
import {
useQueryClient,
type QueryObserverResult,
Expand Down Expand Up @@ -59,12 +59,6 @@ export interface UseThreadTimelineControllerResult {
timelineRows: TimelineRow[];
}

interface MountFetchState {
hasNewEvents: boolean;
key: string | null;
settled: boolean;
}

interface LoadedTimelineTracker {
latestTimeline: ThreadTimelineResponse | undefined;
loaded: LoadedTimelineState;
Expand Down Expand Up @@ -119,25 +113,8 @@ export function useThreadTimelineController({
threadId,
}: UseThreadTimelineControllerArgs): UseThreadTimelineControllerResult {
const queryClient = useQueryClient();
const mountFetchKey = enabled ? threadId : null;
const [mountFetch, setMountFetch] = useState<MountFetchState>({
hasNewEvents: false,
key: null,
settled: false,
});
const isNewMountFetch = mountFetch.key !== mountFetchKey;
const mountFetchHasNewEvents = isNewMountFetch
? mountFetchKey !== null &&
hasThreadTimelineUnseenEvents(queryClient, mountFetchKey)
: mountFetch.hasNewEvents;
const isTrackingMountFetch =
mountFetchKey !== null &&
mountFetchHasNewEvents &&
(isNewMountFetch || !mountFetch.settled);
const isTrackingMountFetchRef = useRef(isTrackingMountFetch);
isTrackingMountFetchRef.current = isTrackingMountFetch;
const notifyOnChangeProps = useCallback((): TimelineQueryResultProp[] => {
if (isTrackingMountFetchRef.current) {
if (hasThreadTimelineUnseenEvents(queryClient, threadId)) {
return TIMELINE_CONTROLLER_PROPS_WITHOUT_ROWS;
}
const cachedTimeline = queryClient.getQueryData<ThreadTimelineResponse>(
Expand Down Expand Up @@ -294,21 +271,9 @@ export function useThreadTimelineController({
latestTimelineQuery.isLoading ||
(timelineQueryState.status === "loading" && timelineRows.length === 0) ||
(latestTimelineQuery.isFetching && timelineRows.length === 0);
const isMountFetchIdle =
mountFetchKey !== null && !latestTimelineQuery.isFetching;
if (
isNewMountFetch ||
(mountFetch.hasNewEvents && isMountFetchIdle && !mountFetch.settled)
) {
setMountFetch({
hasNewEvents: mountFetchHasNewEvents,
key: mountFetchKey,
settled: isMountFetchIdle,
});
}
const isCatchingUpTimeline =
isTrackingMountFetch &&
latestTimelineQuery.isFetching &&
enabled &&
hasThreadTimelineUnseenEvents(queryClient, threadId) &&
timelineRows.length > 0;
const timelineError =
timelineLoading || timelineQueryState.status !== "unavailable"
Expand Down
6 changes: 5 additions & 1 deletion apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ interface RealtimeDirtyContext {
interface ThreadRealtimeDirtyContext extends RealtimeDirtyContext {
backgroundActivityChanged: boolean | undefined;
eventTypes: readonly ThreadEventType[] | undefined;
timelineSequence: number | undefined;
flushOnce: (key: string) => boolean;
hasPendingInteraction: boolean | undefined;
projectId: string | undefined;
Expand Down Expand Up @@ -847,6 +848,7 @@ function dirtyThreadSearchQueriesForCompletedTurn({

function dirtyThreadTimelineQueries({
eventTypes,
timelineSequence,
queryClient,
threadId,
}: ThreadRealtimeDirtyContext): void {
Expand All @@ -858,11 +860,13 @@ function dirtyThreadTimelineQueries({
});
const outlineMayHaveChanged =
eventTypes === undefined || eventTypes.includes("turn/completed");
if (threadId !== undefined && timelineSequence !== undefined) {
markThreadTimelineUnseenEvents(queryClient, threadId, timelineSequence);
}
if (
threadId !== undefined &&
!hasActiveQueries(queryClient, threadTimelineQueryKeyPrefix(threadId))
) {
markThreadTimelineUnseenEvents(queryClient, threadId);
for (const queryKey of [...timelineQueryKeys, ...outlineQueryKeys]) {
queryClient.invalidateQueries({ queryKey, refetchType: "none" });
}
Expand Down
32 changes: 21 additions & 11 deletions apps/app/src/hooks/cache-owners/thread-timeline-unseen-events.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,45 @@
import type { QueryClient } from "@tanstack/react-query";
import type { ThreadTimelineResponse } from "@bb/server-contract";
import { threadTimelineQueryKey } from "../queries/query-keys";

const unseenTimelineEventThreadIdsByClient = new WeakMap<
const unseenTimelineSequencesByClient = new WeakMap<
QueryClient,
Set<string>
Map<string, number>
>();

export function markThreadTimelineUnseenEvents(
queryClient: QueryClient,
threadId: string,
sequence: number,
): void {
let threadIds = unseenTimelineEventThreadIdsByClient.get(queryClient);
if (!threadIds) {
threadIds = new Set();
unseenTimelineEventThreadIdsByClient.set(queryClient, threadIds);
let sequences = unseenTimelineSequencesByClient.get(queryClient);
if (!sequences) {
sequences = new Map();
unseenTimelineSequencesByClient.set(queryClient, sequences);
}
threadIds.add(threadId);
sequences.set(threadId, Math.max(sequences.get(threadId) ?? 0, sequence));
}

export function clearThreadTimelineUnseenEvents(
queryClient: QueryClient,
threadId: string,
loadedSequence: number,
): void {
unseenTimelineEventThreadIdsByClient.get(queryClient)?.delete(threadId);
const sequences = unseenTimelineSequencesByClient.get(queryClient);
if (loadedSequence >= (sequences?.get(threadId) ?? 0)) {
sequences?.delete(threadId);
}
}

export function hasThreadTimelineUnseenEvents(
queryClient: QueryClient,
threadId: string,
): boolean {
return (
unseenTimelineEventThreadIdsByClient.get(queryClient)?.has(threadId) ===
true
const sequence = unseenTimelineSequencesByClient
.get(queryClient)
?.get(threadId);
const timeline = queryClient.getQueryData<ThreadTimelineResponse>(
threadTimelineQueryKey(threadId),
);
return sequence !== undefined && sequence > (timeline?.maxSeq ?? 0);
}
2 changes: 1 addition & 1 deletion apps/app/src/hooks/queries/thread-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,7 +977,7 @@ async function fetchThreadTimeline({
const timeline = await mergeThreadTimelineDelta(previous, response, () =>
sdk.threads.timeline({ threadId, signal, ...pageArgs }),
);
clearThreadTimelineUnseenEvents(queryClient, threadId);
clearThreadTimelineUnseenEvents(queryClient, threadId, timeline.maxSeq);
return timeline;
}

Expand Down
Loading
Loading