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
93 changes: 93 additions & 0 deletions apps/app/src/hooks/queries/system-queries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ import {
useHostProviderCliStatus,
useOnboardingAgents,
useSystemExecutionOptions,
useSystemProviderInfo,
useSystemUsageLimits,
} from "./system-queries";

vi.mock("@/lib/sdk", () => ({
BbHttpError: class BbHttpError extends Error {},
sdk: {
hosts: { providerCliStatus: vi.fn() },
providers: { list: vi.fn() },
system: {
executionOptions: vi.fn(),
onboardingAgents: vi.fn(),
Expand Down Expand Up @@ -78,6 +80,97 @@ afterEach(() => {
window.localStorage.clear();
});

describe("useSystemProviderInfo", () => {
it("uses capabilities already loaded by the composer while the provider roster loads", async () => {
const provider: ProviderInfo = {
id: "codex",
displayName: "Codex",
logoUrl: null,
available: true,
composerActions: [],
capabilities: {
supportsThreadArchive: true,
supportsThreadRename: true,
supportsServiceTier: true,
supportsNativeUserQuestion: false,
supportsFork: true,
supportsSessionRewind: true,
permissionModes: ["accept-edits", "auto", "full"],
},
};
vi.mocked(sdk.providers.list).mockImplementation(
() => new Promise(() => undefined),
);
const { queryClient, wrapper } = createQueryClientTestHarness();
queryClient.setQueryData(
systemExecutionOptionsQueryKey({
environmentId: "env-remote",
hostId: null,
providerId: "codex",
}),
{ ...EXECUTION_OPTIONS_RESPONSE, providers: [provider] },
);

const { result } = renderHook(
() =>
useSystemProviderInfo({
environmentId: "env-remote",
providerId: "codex",
}),
{ wrapper },
);

expect(result.current).toBe(provider);
await waitFor(() => {
expect(sdk.providers.list).toHaveBeenCalledOnce();
});
});

it("loads routed provider capabilities without waiting for model discovery", async () => {
const providers: ProviderInfo[] = [
{
id: "codex",
displayName: "Codex",
logoUrl: null,
available: true,
composerActions: [],
capabilities: {
supportsThreadArchive: true,
supportsThreadRename: true,
supportsServiceTier: true,
supportsNativeUserQuestion: false,
supportsFork: true,
supportsSessionRewind: true,
permissionModes: ["accept-edits", "auto", "full"],
},
},
];
vi.mocked(sdk.providers.list).mockResolvedValue(providers);
vi.mocked(sdk.system.executionOptions).mockImplementation(
() => new Promise(() => undefined),
);
const { wrapper } = createQueryClientTestHarness();

const { result } = renderHook(
() =>
useSystemProviderInfo({
environmentId: "env-remote",
providerId: "codex",
}),
{ wrapper },
);

await waitFor(() => {
expect(result.current?.capabilities.supportsSessionRewind).toBe(true);
});
expect(sdk.providers.list).toHaveBeenCalledWith({
environmentId: "env-remote",
signal: expect.any(AbortSignal),
});
expect(sdk.system.executionOptions).not.toHaveBeenCalled();
});
});

describe("useSystemExecutionOptions", () => {
it("preloads built-in provider identities while their models are loading", () => {
vi.mocked(sdk.system.executionOptions).mockImplementation(
Expand Down
85 changes: 53 additions & 32 deletions apps/app/src/hooks/queries/system-queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useCallback, useSyncExternalStore } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { QueryKey } from "@tanstack/react-query";
import type { AvailableModel, PermissionMode, ProviderInfo } from "@bb/domain";
Expand Down Expand Up @@ -69,6 +68,17 @@ interface QueryOptions {
enabled?: boolean;
}

type SystemProviderRoutingArgs =
| { environmentId: string; hostId?: never }
| { environmentId?: never; hostId: string }
| { environmentId?: never; hostId?: never };

export type UseSystemProvidersArgs = QueryOptions & SystemProviderRoutingArgs;

export type UseSystemProviderInfoArgs = UseSystemProvidersArgs & {
providerId?: string;
};

const SYSTEM_EXECUTION_OPTIONS_RETRY_DELAY_MS = 250;
const SYSTEM_EXECUTION_OPTIONS_RETRY_COUNT = 1;
const CLAUDE_CODE_PROVIDER_ID = "claude-code";
Expand Down Expand Up @@ -346,33 +356,6 @@ export function findCachedProviderInfo(
return null;
}

/**
* Reactive form of {@link findCachedProviderInfo}. The cache read alone is a
* render-time snapshot, and a component that does not mount the
* execution-options query itself never re-renders when that query lands — so a
* capability-gated affordance would stay hidden until some unrelated query
* happened to re-render the tree. Subscribing to the query cache makes it
* appear as soon as the data arrives, without mounting a second request.
*/
export function useCachedProviderInfo(
providerId: string | undefined,
): ProviderInfo | null {
const queryClient = useQueryClient();
const subscribe = useCallback(
(onStoreChange: () => void) =>
queryClient.getQueryCache().subscribe(onStoreChange),
[queryClient],
);
const getSnapshot = useCallback(
() =>
providerId === undefined
? null
: findCachedProviderInfo(queryClient, providerId),
[providerId, queryClient],
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

function isAbortLikeError(error: unknown): boolean {
return toRecord(error)?.name === "AbortError";
}
Expand All @@ -399,19 +382,57 @@ function shouldRetrySystemExecutionOptions(
/**
* The provider roster with the server's display names. Cheaper than the full
* execution-options query (no model probe), which is what surfaces that only
* need to name a provider — the skills library's provider filter — should use.
* need provider metadata or capabilities should use.
*/
export function useSystemProviders(args: { enabled?: boolean } = {}) {
export function useSystemProviders(args: UseSystemProvidersArgs = {}) {
const environmentId = args.environmentId ?? null;
const hostId = args.hostId ?? null;
const enabled = args.enabled ?? true;
useSystemRealtimeSubscription({ enabled });
return useQuery<ProviderInfo[]>({
queryKey: systemProvidersQueryKey(),
queryFn: ({ signal }) => sdk.providers.list({ signal }),
queryKey: systemProvidersQueryKey({ environmentId, hostId }),
queryFn: ({ signal }) => {
if (args.environmentId !== undefined) {
return sdk.providers.list({
environmentId: args.environmentId,
signal,
});
}
if (args.hostId !== undefined) {
return sdk.providers.list({ hostId: args.hostId, signal });
}
return sdk.providers.list({ signal });
},
enabled,
staleTime: 60_000,
});
}

/**
* Resolve one provider from the lightweight provider roster. Unlike the full
* execution-options request, this does not wait for model discovery, so
* capability-gated controls can render as soon as provider metadata arrives.
* A just-submitted composer has already loaded the same provider facts through
* execution options, so reuse that warm cache synchronously during navigation
* while the lightweight roster fills its own route-scoped cache.
*/
export function useSystemProviderInfo({
providerId,
...args
}: UseSystemProviderInfoArgs): ProviderInfo | null {
const queryClient = useQueryClient();
const providersQuery = useSystemProviders({
...args,
enabled: (args.enabled ?? true) && providerId !== undefined,
});
return (
providersQuery.data?.find((provider) => provider.id === providerId) ??
(providerId === undefined
? null
: findCachedProviderInfo(queryClient, providerId))
);
}

export function useSystemExecutionOptions(
args: UseSystemExecutionOptionsArgs = {},
) {
Expand Down
57 changes: 0 additions & 57 deletions apps/app/src/hooks/queries/useCachedProviderInfo.test.tsx

This file was deleted.

22 changes: 16 additions & 6 deletions apps/app/src/views/thread-detail/ThreadDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type ReactNode,
} from "react";
import { createSentMessageEditOperationId } from "./sent-message-edit-operation-id";
import { useCachedProviderInfo } from "@/hooks/queries/system-queries";
import { useSystemProviderInfo } from "@/hooks/queries/system-queries";
import { useNavigate } from "react-router-dom";
import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
Expand Down Expand Up @@ -973,11 +973,21 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) {
},
[forkThreadFromMessage],
);
// Subscribed, not a bare cache read: this view never mounts the
// execution-options query itself (its composer child does), so a render-time
// snapshot would leave capability-gated affordances hidden until an
// unrelated query re-rendered the tree.
const threadProviderInfo = useCachedProviderInfo(thread?.providerId);
// Provider capabilities do not depend on model discovery. Load them from the
// lightweight provider roster so fork/edit affordances are not held behind
// the composer's slower execution-options probe.
const threadProviderInfo = useSystemProviderInfo(
thread?.environmentId
? {
enabled: true,
environmentId: thread.environmentId,
providerId: thread.providerId,
}
: {
enabled: thread !== undefined,
providerId: thread?.providerId,
},
);
const isForkAvailable = isThreadForkable(
thread ?? null,
threadProviderInfo?.capabilities.supportsFork ?? false,
Expand Down
8 changes: 8 additions & 0 deletions packages/client-core/src/timeline/timeline-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ function appendTimelineRowsPreservingOrder(
}

function timelineRowIdentitySignature(row: TimelineRow): string {
const turnRequest =
row.kind === "conversation" && row.role === "user" ? row.turnRequest : null;
return [
row.kind,
row.id,
Expand All @@ -163,6 +165,12 @@ function timelineRowIdentitySignature(row: TimelineRow): string {
row.sourceSeqEnd,
row.startedAt,
row.createdAt,
// Acceptance is projected onto the original message row without extending
// its source sequence range. Include the request fields so a refetch swaps
// a pending row for the accepted one instead of preserving stale identity.
turnRequest?.isGrouped,
turnRequest?.kind,
turnRequest?.status,
].join("\u001f");
}

Expand Down
29 changes: 28 additions & 1 deletion packages/client-core/test/timeline-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface TimelineTestRowArgs {
endSequence?: number;
id: string;
sequence: number;
turnRequestStatus?: "accepted" | "pending" | "rejected";
}

interface TimelineTurnTestRowArgs extends TimelineTestRowArgs {
Expand Down Expand Up @@ -51,7 +52,11 @@ function userRow(args: TimelineTestRowArgs): TimelineUserConversationRow {
text: args.id,
mentions: [],
attachments: null,
turnRequest: { isGrouped: false, kind: "message", status: "accepted" },
turnRequest: {
isGrouped: false,
kind: "message",
status: args.turnRequestStatus ?? "accepted",
},
};
}

Expand Down Expand Up @@ -363,6 +368,28 @@ describe("timeline page row merging", () => {
expect(merge.rows[1]).toBe(updatedTail);
});

it("replaces a pending message row when the server accepts it", () => {
const pendingMessage = userRow({
id: "submitted-message",
sequence: 1,
turnRequestStatus: "pending",
});
const acceptedMessage = userRow({
id: "submitted-message",
sequence: 1,
turnRequestStatus: "accepted",
});

const merge = mergeLatestTimelineRows({
latestWindowStartSequence: 0,
loadedRows: [pendingMessage],
latestRows: [acceptedMessage],
});

expect(merge.rows).toEqual([acceptedMessage]);
expect(merge.rows[0]).toBe(acceptedMessage);
});

it("rebuilds when latest advances past the loaded rows with a gap between", () => {
const oldestCursor = timelineCursor({ id: "oldest", sequence: 1 });
const latestCursor = timelineCursor({ id: "latest-page", sequence: 40 });
Expand Down
Loading