Skip to content
Closed
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
31 changes: 13 additions & 18 deletions apps/app/src/components/thread/timeline/TimelineTitleView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useState } from "react";
import { Fragment } from "react";
import type { KeyboardEvent, MouseEvent, ReactNode } from "react";
import {
assertNever,
Expand All @@ -15,6 +15,7 @@ import {
import { cn } from "@bb/shared-ui/lib/utils";
import { DiffStatsTally } from "@/components/ui/diff-stats-tally.js";
import { RouteAnchor } from "@/components/ui/app-route-anchor.js";
import { useSecondTick } from "@/hooks/useSecondTick";

/**
* Resolves a title's declared action to a click callback. Return `null` to
Expand Down Expand Up @@ -209,26 +210,20 @@ function renderSegment(
}

/**
* Ticks the displayed elapsed time locally while the row is still active.
* The truth is `startedAt` (the wall-clock when the work began); the App
* derives `now - startedAt` and ticks once per second until the row reaches
* a terminal status (at which point a static `completedAt - startedAt` is
* shown by the caller instead). Stays empty until the elapsed time crosses
* the visible threshold (>1s) to avoid sub-second flicker on row entry.
* Ticks the displayed elapsed time while the row is still active. The truth
* is `startedAt` (the wall-clock when the work began); the App derives
* `now - startedAt` from the shared 1 Hz ticker — one interval for every
* in-flight row on screen, paused while the document is hidden — until the
* row reaches a terminal status (at which point a static
* `completedAt - startedAt` is shown by the caller instead). Stays empty
* until the elapsed time crosses the visible threshold (>1s) to avoid
* sub-second flicker on row entry.
*/
function LiveDurationText({ startedAt }: { startedAt: number }) {
const [tick, setTick] = useState(() => Date.now() - startedAt);
const elapsedMs = useSecondTick() - startedAt;

useEffect(() => {
setTick(Date.now() - startedAt);
const interval = window.setInterval(() => {
setTick(Date.now() - startedAt);
}, 1_000);
return () => window.clearInterval(interval);
}, [startedAt]);

if (tick <= 1_000) return null;
return <>{durationToCompactString(tick)}</>;
if (elapsedMs <= 1_000) return null;
return <>{durationToCompactString(elapsedMs)}</>;
}

function renderDecoration(
Expand Down
80 changes: 75 additions & 5 deletions apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,17 @@ interface ThrottledActiveRefetchArgs {
*/
const WORK_STATUS_REFETCH_MIN_INTERVAL_MS = 1_000;

/**
* A `status-changed` push without a row snapshot falls back to refetching the
* active thread lists. Bare pushes arrive in bursts — writers inside a
* transaction publish one per thread, and a host disconnect used to publish
* one per thread on the host — and every full list response is ~1 KB per
* unarchived thread, so the fallback refetch is throttled to one per second
* per query. Rows still go stale immediately; only the active refetch
* coalesces.
*/
const THREAD_LIST_STATUS_FALLBACK_REFETCH_MIN_INTERVAL_MS = 1_000;

/**
* The trailing refetch is self-clocking: it fires as soon as the in-flight
* fetch settles and any event arrived meanwhile. During a streaming turn events
Expand Down Expand Up @@ -831,6 +842,54 @@ function dirtyActiveThreadListQueries({
return [sidebarNavigationQueryKey(), threadSearchQueryKeyPrefix()];
}

/**
* Same scope as {@link dirtyActiveThreadListQueries}, but the active refetches
* run through the throttle machinery: everything goes stale immediately (a
* remount refetches), while active list/sidebar/search observers refetch at
* most once per {@link THREAD_LIST_STATUS_FALLBACK_REFETCH_MIN_INTERVAL_MS},
* with later changes coalescing into one trailing refetch and no fetch in
* flight ever cancelled. Archived pages keep their stale-only treatment.
*/
function dirtyActiveThreadListQueriesWithThrottledRefetch({
projectId,
queryClient,
}: ThreadRealtimeDirtyContext): void {
const listQueryKeys = projectId
? [
...getCachedProjectThreadListInvalidationQueryKeys({
projectId,
queryClient,
}),
...getCachedGlobalThreadListInvalidationQueryKeys({ queryClient }),
]
: getCachedThreadListQueryKeys(queryClient);
for (const queryKey of listQueryKeys) {
if (isArchivedThreadListQueryKey(queryKey)) {
queryClient.invalidateQueries({
exact: true,
queryKey,
refetchType: "none",
});
continue;
}
invalidateQueryKeyWithThrottledActiveRefetch({
minIntervalMs: THREAD_LIST_STATUS_FALLBACK_REFETCH_MIN_INTERVAL_MS,
queryClient,
queryKey,
});
}
for (const queryKey of [
sidebarNavigationQueryKey(),
threadSearchQueryKeyPrefix(),
]) {
invalidateQueryKeyWithThrottledActiveRefetch({
minIntervalMs: THREAD_LIST_STATUS_FALLBACK_REFETCH_MIN_INTERVAL_MS,
queryClient,
queryKey,
});
}
}

function dirtyThreadListQueriesForBackgroundActivity(
context: ThreadRealtimeDirtyContext,
): QueryKey[] {
Expand Down Expand Up @@ -1108,24 +1167,35 @@ function patchThreadListPendingInteractionState({
* patched in place. The alternative is what the fallback still does for
* pushes without the row (older servers, writers inside a transaction that
* cannot resolve the runtime): refetch every active thread list plus the
* sidebar bootstrap, which is ~1 KB per unarchived thread, twice per turn.
* sidebar bootstrap, which is ~1 KB per unarchived thread — throttled to one
* active refetch per second so a burst of bare pushes coalesces.
*
* A list fetch already in flight read the database before this transition
* and would overwrite the patch when it lands, so those queries are
* invalidated, which cancels and restarts them.
*/
function patchThreadListStatusState(
context: ThreadRealtimeDirtyContext,
): QueryKey[] {
const { queryClient, statusChange, threadId } = context;
): void {
const { flushOnce, queryClient, statusChange, threadId } = context;
if (!threadId || !statusChange) {
return dirtyActiveThreadListQueries(context);
dirtyActiveThreadListQueriesWithThrottledRefetch(context);
return;
}
updateCachedThreadListStatusState(queryClient, threadId, statusChange);
for (const queryKey of getFetchingThreadListQueryKeys(queryClient)) {
queryClient.invalidateQueries({ exact: true, queryKey });
}
return [threadSearchQueryKeyPrefix()]; // Result rows render status but are not list-shaped.
// Result rows render status but are not list-shaped, so search refreshes
// rather than patches — once per flush and without aborting a request in
// flight: status changes ride the immediate path, and the default
// cancelling invalidation could starve an open search on a slow link.
if (flushOnce("thread-search:status-changed")) {
queryClient.invalidateQueries(
{ queryKey: threadSearchQueryKeyPrefix() },
{ cancelRefetch: false },
);
}
}

function dirtyEnvironmentRecordQueries(
Expand Down
13 changes: 13 additions & 0 deletions apps/app/src/hooks/queries/query-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,25 @@ export const SERVER_SESSION_QUERY_POLICY = {
staleTime: SERVER_SESSION_STALE_TIME_MS,
} as const;

/**
* Live values with no realtime change kind (provider usage limits): focus and
* reconnect are their only freshness sources, so the explicit `true`s
* deliberately bypass the app-level lost-realtime-coverage gate that
* `createAppQueryClient` applies to the defaults.
*/
export const FOCUS_OWNED_LIVE_QUERY_POLICY = {
refetchOnReconnect: true,
refetchOnWindowFocus: true,
staleTime: FOCUS_OWNED_LIVE_STALE_TIME_MS,
} as const;

/**
* Explicit resume opt-in for queries whose realtime coverage has gaps: thread
* tabs are absent from the reconnect-watermark catch-up list, and the thread
* host file preview backs an open pane that must not keep stale bytes after
* an offline stretch. Deliberately bypasses the app-level
* lost-realtime-coverage gate (per-query options win over the defaults).
*/
export const RESUME_REFETCH_QUERY_POLICY = {
refetchOnReconnect: true,
refetchOnWindowFocus: true,
Expand Down
Loading
Loading