From 01a8db0aad27fa18010c552340e1b36168707ae8 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 5 Nov 2026 02:35:42 -0600 Subject: [PATCH 001/184] fix(server): refresh divergence after a merged branch ref changes (#31342) --- .../src/vcs/VcsStatusBroadcaster.test.ts | 19 +++++++++++-------- apps/server/src/vcs/VcsStatusBroadcaster.ts | 10 +--------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index be3a286ffc32b..45b130f9a2b4b 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -382,13 +382,14 @@ describe("VcsStatusBroadcaster", () => { ); it.effect("refreshes a loaded cwd without reusing a previous branch's PR", () => { - const state = { + const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, + currentRemoteStatus: { ...baseRemoteStatus, aheadCount: 3, behindCount: 5 }, localStatusCalls: 0, remoteStatusCalls: 0, localInvalidationCalls: 0, remoteInvalidationCalls: 0, + remoteStatusRefreshUpstreamValues: [], }; return Effect.gen(function* () { @@ -400,9 +401,14 @@ describe("VcsStatusBroadcaster", () => { yield* broadcaster.getStatus({ cwd: "/repo" }); assert.equal(state.remoteStatusCalls, 1); - state.currentRemoteStatus = remoteStatusWithPr; + state.backgroundWorkEnabled = false; + state.currentRemoteStatus = { ...remoteStatusWithPr, aheadCount: 7 }; const refreshed = yield* broadcaster.refreshPullRequestStatus("/repo"); - assert.deepStrictEqual(refreshed, remoteStatusWithPr); + assert.deepStrictEqual(refreshed, state.currentRemoteStatus); + const merged = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(merged.aheadCount, 7); + assert.equal(merged.behindCount, 0); + assert.equal(state.remoteStatusRefreshUpstreamValues?.at(-1), false); assert.equal(state.remoteStatusCalls, 2); assert.equal(state.remoteInvalidationCalls, 2); @@ -944,7 +950,7 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect("recovers a discarded branch lookup on the next permitted turn-end refresh", () => { + it.effect("recovers a discarded branch lookup on the next turn-end refresh", () => { const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: { ...remoteStatusWithPr, aheadCount: 3, behindCount: 5 }, @@ -983,9 +989,6 @@ describe("VcsStatusBroadcaster", () => { pr: { ...remoteStatusWithPr.pr!, number: 2979, headRef: "feature/next-status" }, }; state.backgroundWorkEnabled = false; - yield* broadcaster.refreshPullRequestStatus("/repo"); - assert.equal(state.remoteStatusCalls, 1); - state.backgroundWorkEnabled = true; const recovered = yield* broadcaster.refreshPullRequestStatus("/repo"); assert.deepStrictEqual(recovered, state.currentRemoteStatus); const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 327e0bcb1bb41..7fee5379e4a3b 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -540,15 +540,7 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const cached = yield* getCachedStatus(cwd); if (!cached?.local || cached.remote?.value === null) return null; - const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); - const demandCwds = poller ? [...(yield* Ref.get(poller.demandCwds)).keys()] : [rawCwd]; - const shouldRefresh = (yield* Effect.forEach( - demandCwds, - (demandCwd) => - backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: demandCwd }), - { concurrency: "unbounded" }, - )).some(Boolean); - if (!shouldRefresh) return null; + // Turn completion must update divergence even when background polling is paused. const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream: false, refreshMissingPullRequest: true, From 8a14762c9093ea11e1e58d65cfe788f458a0d6b5 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:16:54 +0530 Subject: [PATCH 002/184] fix(mobile): one aligned notice for ChatGPT sharing limits (#31277) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../threads/ChatGptUsageLimitNotice.tsx | 69 --------- .../features/threads/ThreadDetailScreen.tsx | 5 - .../threads/UsageLimitRecoveryCard.test.tsx | 96 ++++++++++++- .../threads/UsageLimitRecoveryCard.tsx | 133 ++++++++++++++---- 4 files changed, 200 insertions(+), 103 deletions(-) delete mode 100644 apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx diff --git a/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx b/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx deleted file mode 100644 index faebfa5ddf927..0000000000000 --- a/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { - CHATGPT_USAGE_LIMIT_CODE, - CHATGPT_USAGE_URL, - CHATGPT_WORKSPACE_ADMIN_REQUEST, - CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, -} from "@t3tools/shared/usageLimits"; -import * as Clipboard from "expo-clipboard"; -import { useState } from "react"; -import { Linking, View } from "react-native"; -import { ProviderIcon } from "../../components/ProviderIcon"; -import { AppText as Text } from "../../components/ui/AppText"; -import { Button } from "../../components/ui/Button"; - -export function ChatGptUsageLimitNotice({ thread }: { thread: EnvironmentThreadShell }) { - const [status, setStatus] = useState<"copied" | "copy-failed" | "open-failed" | null>(null); - const code = thread.runtime?.lastErrorCode; - const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; - if (thread.runtime?.status !== "failed" || (!workspaceLimit && code !== CHATGPT_USAGE_LIMIT_CODE)) - return null; - return ( - - - - ChatGPT usage limit reached - - - {workspaceLimit - ? status === "copied" - ? "Request copied. Send it to your workspace admin." - : "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits." - : "Review your usage settings in ChatGPT."} - - {workspaceLimit ? ( - + ), View: ({ children }: { children: ReactNode }) =>
{children}
, })); +vi.mock("expo-clipboard", () => ({ setStringAsync: vi.fn() })); +vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null })); vi.mock("../../components/ui/AppText", () => ({ AppText: ({ children }: { children: ReactNode }) => {children}, })); @@ -27,18 +40,97 @@ import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; const DAY_MS = 24 * 60 * 60 * 1000; -function failedThread(resetAt: string): EnvironmentThreadShell { +function failedThread( + resetAt: string | null, + lastErrorCode?: string, + lastErrorClass = "usage_limit", +): EnvironmentThreadShell { return { id: "thread-1", updatedAt: new Date(0).toISOString(), snoozedUntil: null, latestRun: { runId: "run-1", completedAt: new Date(0).toISOString() }, limitRecovery: null, - runtime: { status: "failed", lastErrorClass: "usage_limit", usageLimitResetAt: resetAt }, + runtime: { status: "failed", lastErrorClass, lastErrorCode, usageLimitResetAt: resetAt }, } as unknown as EnvironmentThreadShell; } describe("UsageLimitRecoveryCard", () => { + // Older servers report ChatGPT sharing limits as provider errors and may store a reset. + it.each([ + [CHATGPT_USAGE_LIMIT_CODE, "usage_limit", null, ["Manage usage"]], + [CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, "usage_limit", null, ["Copy request for admin"]], + [CHATGPT_USAGE_LIMIT_CODE, "provider_error", null, ["Manage usage"]], + [ + CHATGPT_USAGE_LIMIT_CODE, + "usage_limit", + new Date(DAY_MS).toISOString(), + ["Manage usage", "Resume at reset", "Snooze until reset"], + ], + ])("shows one ChatGPT notice for %s (%s, reset %s)", (code, errorClass, resetAt, actions) => { + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + expect(container.textContent).toContain("ChatGPT usage limit reached"); + expect(container.textContent).not.toContain("did not report a reset time"); + expect([...container.querySelectorAll("button")].map((button) => button.textContent)).toEqual( + actions, + ); + }); + + it("drops a failed copy's error once a retry copies the request", async () => { + vi.mocked(Clipboard.setStringAsync) + .mockRejectedValueOnce(new Error("denied")) + .mockResolvedValueOnce(true); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + const copy = () => container.querySelector("button")!.click(); + await act(async () => copy()); + expect(container.textContent).toContain("Could not copy the request"); + await act(async () => copy()); + expect(container.textContent).toContain("Request copied"); + expect(container.textContent).not.toContain("Could not copy the request"); + }); + + it("ignores a second copy while the first is still in flight", async () => { + const first = Promise.withResolvers(); + vi.mocked(Clipboard.setStringAsync).mockReset().mockReturnValueOnce(first.promise); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + const copy = () => container.querySelector("button")!.click(); + act(() => copy()); + act(() => copy()); + expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(1); + await act(async () => first.resolve(true)); + expect(container.textContent).toContain("Request copied"); + }); + it("disables Snooze once a reset more than 24.8 days away passes", () => { using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; vi.useFakeTimers({ now: 0 }); diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 791dc2ab5d847..27fece0272be6 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -3,9 +3,17 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" import { AsyncResult } from "effect/reactivity"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId } from "@t3tools/contracts"; +import { + CHATGPT_USAGE_LIMIT_CODE, + CHATGPT_USAGE_URL, + CHATGPT_WORKSPACE_ADMIN_REQUEST, + CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, +} from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; +import * as Clipboard from "expo-clipboard"; import { useState } from "react"; -import { View } from "react-native"; +import { Linking, Pressable, View } from "react-native"; +import { ProviderIcon } from "../../components/ProviderIcon"; import { AppText as Text } from "../../components/ui/AppText"; import { useDeadlinePassed } from "../../lib/clock"; import { threadEnvironment } from "../../state/threads"; @@ -30,12 +38,22 @@ export function UsageLimitRecoveryCard({ const recovery = thread.limitRecovery; const scheduled = recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume; + const [copied, setCopied] = useState(false); + const [actionPending, setActionPending] = useState(false); + const code = thread.runtime?.lastErrorCode; + // OpenAI's partner guidelines specify the ChatGPT notice and its action. + const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; + const chatGptLimit = workspaceLimit || code === CHATGPT_USAGE_LIMIT_CODE; if ( thread.runtime?.status !== "failed" || - thread.runtime.lastErrorClass !== "usage_limit" || + // Older servers report ChatGPT sharing limits as provider errors. + (thread.runtime.lastErrorClass !== "usage_limit" && !chatGptLimit) || !runId ) return null; + const resetText = resetAt + ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` + : null; const snoozed = recovery?.snooze === true && recovery.runId === runId && @@ -68,34 +86,95 @@ export function UsageLimitRecoveryCard({ setError(cause instanceof Error ? cause.message : "Could not change limit recovery."); } } + const action = workspaceLimit + ? { + label: "Copy request for admin", + role: "button" as const, + run: () => { + setError(null); + setCopied(false); + setActionPending(true); + void Clipboard.setStringAsync(CHATGPT_WORKSPACE_ADMIN_REQUEST) + .then( + () => setCopied(true), + () => setError("Could not copy the request. Try again."), + ) + .finally(() => setActionPending(false)); + }, + } + : { + label: "Manage usage", + role: "link" as const, + run: () => { + setError(null); + setActionPending(true); + void Linking.openURL(CHATGPT_USAGE_URL) + .catch(() => setError("Could not open ChatGPT. Go to chatgpt.com → Settings → Usage.")) + .finally(() => setActionPending(false)); + }, + }; return ( - - - {resetAt - ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` - : "The provider did not report a reset time. Retry manually when your limit is available."} - - {canSchedule ? ( - - + + ); +} + /** Permissions change rarely, so the footer names the mode and nests the choice in a menu. */ function PermissionsMenu(props: { runtimeMode: RuntimeMode; @@ -647,6 +681,7 @@ export function ComposerPickerFooter(props: { return (
{props.traits ? : null} + {props.traits ? : null} ; -// Ultracode changes the workflow, not only intensity, so it keeps its name like Ultrathink. +// Older servers offer Ultracode as a level. It changes the workflow, so it keeps its name. // Max is named too, so the full scale stops short of it. const NAMED_LEVELS = new Set(["ultracode", "max"]); const OFF_LEVELS = new Set(["off", "none"]); diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index a965e89efd728..5b5f8b3477420 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -50,16 +50,16 @@ export function SubagentTooltipContent(props: { result?: string | null | undefined; progress?: string | null | undefined; }) { - const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props); - const preview = subagentDetailPreview(props); - const model = props.model?.trim(); - const modelSlug = props.provider - ? resolveSelectableModel(props.provider.driver, model, props.provider.models) - : model; - const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); const childSelection = props.modelSelection ?? (props.origin === "app_owned" ? props.childThread?.modelSelection : undefined); + const { + modelLabel, + modelSlug, + workspace: metadata, + } = resolveSubagentMetadata({ ...props, modelSelection: childSelection }); + const preview = subagentDetailPreview(props); + const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); const childModel = props.provider ? (resolveSelectableModel( props.provider.driver, @@ -69,7 +69,7 @@ export function SubagentTooltipContent(props: { : childSelection?.model.trim(); const provider = props.provider; const matchingSelection = - childModel === (modelSlug ?? model) && childSelection?.instanceId === props.providerInstanceId + childModel === modelSlug && childSelection?.instanceId === props.providerInstanceId ? childSelection : undefined; const effort = REASONING_EFFORT_OPTION_IDS.map((id) => diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index 9d31ec35761e9..4cfb23350e912 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -1,6 +1,7 @@ -import type { - SubagentWorkflowGroup, - RuntimeSubagent, +import { + workflowMemberModelSelection, + type SubagentWorkflowGroup, + type RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; import { ChevronDownIcon, SquareIcon } from "lucide-react"; @@ -101,6 +102,7 @@ function WorkflowMemberRow({ model={member.model} providerInstanceId={providerInstanceId} origin="provider_native" + modelSelection={workflowMemberModelSelection(member, providerInstanceId)} provider={provider} providers={providers} driver={driver} diff --git a/apps/web/src/components/chat/WorkflowOpeningBlock.tsx b/apps/web/src/components/chat/WorkflowOpeningBlock.tsx index 3ab51f077e060..c6be9690e9940 100644 --- a/apps/web/src/components/chat/WorkflowOpeningBlock.tsx +++ b/apps/web/src/components/chat/WorkflowOpeningBlock.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isTerminalSubagentStatus, + workflowMemberModelSelection, workflowOpeningHeadline, type RuntimeSubagent, type WorkflowOpening, @@ -183,6 +184,7 @@ function WorkflowMemberRow(props: { model={member.model} providerInstanceId={props.providerInstanceId} origin="provider_native" + modelSelection={workflowMemberModelSelection(member, props.providerInstanceId)} provider={props.providers.find( (provider) => provider.instanceId === props.providerInstanceId, )} diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index b05f9226defb3..9a3dc06a15173 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -120,10 +120,12 @@ export function buildTraitsTriggerDisplay(input: { label: string; /** "Fast" or "Ultrafast" when a speed option is on. */ speedLabel: string | null; + ultracode: boolean; } { let fastModeFallbackLabel: string | null = null; let speedLabel: string | null = null; let reasoningLabelIndex = -1; + let ultracodeLabelIndex = -1; const labels: Array = []; for (const descriptor of input.descriptors) { if (descriptor.id === "fastMode" && descriptor.type === "boolean") { @@ -131,6 +133,13 @@ export function buildTraitsTriggerDisplay(input: { fastModeFallbackLabel = speedLabel ?? "Normal"; continue; } + if (descriptor.id === "ultracode" && descriptor.type === "boolean") { + if (descriptor.currentValue === true) { + ultracodeLabelIndex = labels.length; + labels.push(descriptor.label); + } + continue; + } if ( input.provider === "codex" && descriptor.id === "serviceTier" && @@ -173,8 +182,9 @@ export function buildTraitsTriggerDisplay(input: { } } + const ultracode = ultracodeLabelIndex >= 0; if (labels.length === 0 && fastModeFallbackLabel !== null) { - return { label: fastModeFallbackLabel, speedLabel }; + return { label: fastModeFallbackLabel, speedLabel, ultracode }; } if (speedLabel) { if (reasoningLabelIndex >= 0) { @@ -183,5 +193,5 @@ export function buildTraitsTriggerDisplay(input: { labels.push(speedLabel); } } - return { label: labels.join(" · "), speedLabel }; + return { label: labels.join(" · "), speedLabel, ultracode }; } diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts index 124c45e264f79..c0763fa4c2f41 100644 --- a/packages/client-runtime/src/state/subagentDisplay.ts +++ b/packages/client-runtime/src/state/subagentDisplay.ts @@ -1,4 +1,5 @@ import type { + ModelSelection, OrchestrationV2TurnItemStatus, OrchestrationV2ThreadShell, OrchestrationProjectShell, @@ -50,6 +51,7 @@ export function formatSubagentDisplayTitle(title: string): string { export function resolveSubagentMetadata(input: { readonly model: string | null; + readonly modelSelection?: Pick | undefined; readonly provider?: Pick | null | undefined; readonly parentThread?: | Pick @@ -65,7 +67,7 @@ export function resolveSubagentMetadata(input: { | null | undefined; }) { - const model = input.model?.trim(); + const model = input.model?.trim() || input.modelSelection?.model.trim(); const slug = input.provider ? resolveSelectableModel(input.provider.driver, model, input.provider.models) : model; @@ -108,7 +110,7 @@ export function resolveSubagentMetadata(input: { ] : []), ]; - return { modelLabel, workspace }; + return { modelLabel, modelSlug: slug ?? model ?? null, workspace }; } export function subagentDetailPreview(input: { diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index c40c9fe42d81e..93d15b016d755 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -13,7 +13,10 @@ import { isWorkflowScriptMessage, projectedSubagentsToRuntime, resolveWorkflowOpening, + workflowMemberModelSelection, } from "./subagentRuntime.ts"; +import { resolveSubagentMetadata } from "./subagentDisplay.ts"; +import { formatSubagentEffort } from "./threadExecution.ts"; const at = (iso: string) => DateTime.makeUnsafe(iso); @@ -82,6 +85,42 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { expect(settled?.speed).toBe("standard"); expect(settled?.progress).toBe("Reply with exactly: A1"); expect(settled?.usage).toEqual({ totalTokens: 71_141, toolUses: 3, durationMs: 1500 }); + const instanceId = ProviderInstanceId.make("claudeAgent"); + expect(workflowMemberModelSelection(settled!, instanceId)).toEqual({ + instanceId, + model: "claude-opus-5[1m]", + options: [ + { id: "effort", value: "xhigh" }, + { id: "fastMode", value: false }, + ], + }); + expect(workflowMemberModelSelection({ model: null, effort: "xhigh" }, instanceId)).toBe( + undefined, + ); + const provider = { + driver: ProviderDriverKind.make("claudeAgent"), + models: [ + { slug: "claude-opus-5", name: "Claude Opus 5", isCustom: false, capabilities: null }, + ], + }; + const modelSelection = workflowMemberModelSelection(settled!, instanceId); + const { modelSlug } = resolveSubagentMetadata({ + model: settled!.model, + modelSelection, + provider, + }); + expect(formatSubagentEffort({ modelSlug, modelSelection, provider })).toBe("xhigh"); + }); + + it("names a workflow by the model it runs on when the provider reports none", () => { + const [workflow] = projectedSubagentsToRuntime([subagent({ model: null })]); + expect( + resolveSubagentMetadata({ + model: workflow!.model, + modelSelection: { model: "claude-opus-5-5" }, + }).modelLabel, + ).toBe("Claude Opus 5.5"); + expect(resolveSubagentMetadata({ model: workflow!.model }).modelLabel).toBe("Not reported"); }); it.each(["completed", "cancelled", "failed"] as const)( diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index e3807362fbdf6..fac58a029ce62 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -1,6 +1,7 @@ import { groupBy } from "effect/Array"; import * as DateTime from "effect/DateTime"; import type { + ModelSelection, OrchestrationV2AppThread, OrchestrationV2Subagent, OrchestrationV2SubagentWorkflow, @@ -175,6 +176,21 @@ function workflowMembersToRuntime( }); } +export function workflowMemberModelSelection( + member: Pick, + instanceId: ModelSelection["instanceId"], +): ModelSelection | undefined { + if (!member.model) return undefined; + return { + instanceId, + model: member.model, + options: [ + ...(member.effort ? [{ id: "effort", value: member.effort }] : []), + ...(member.speed ? [{ id: "fastMode", value: member.speed === "fast" }] : []), + ], + }; +} + export function projectedSubagentsToRuntime( subagents: ReadonlyArray<{ readonly id: string; diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index ed60963ce7ef2..fd03b06b32017 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -14,6 +14,7 @@ import { type OrchestrationV2RunStatus, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { usageLimitBlockedRun } from "@t3tools/shared/orchestrationV2ThreadError"; import { describe, expect, it } from "vite-plus/test"; @@ -26,6 +27,7 @@ import { deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, + formatModelSelectionEffort, isBackgroundWorkStopPending, presentPendingBackgroundWork, presentProviderGoal, @@ -473,6 +475,39 @@ describe("provider-reported model selection", () => { }); }); +describe("formatModelSelectionEffort", () => { + it("labels an old Ultracode effort by the effort it runs at", () => { + const models = [ + { + slug: "claude-opus-5-5", + name: "Claude Opus 5.5", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + ], + }, + { id: "ultracode", label: "Ultracode", type: "boolean" }, + ], + }), + }, + ]; + const selection = createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-5-5", + [{ id: "effort", value: "ultracode" }], + ); + + expect(formatModelSelectionEffort(selection, models)).toBe("Extra High"); + }); +}); + describe("isBackgroundWorkStopPending", () => { const tasks = [{ taskId: "dev-server" }]; const sent = { threadKey: "env:thread", taskIds: ["dev-server"], retryAt: null }; diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index b6151c7c875be..f4335256e98fb 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -13,6 +13,7 @@ import { type OrchestrationV2NotificationSource, type OrchestrationV2PendingBackgroundTask, type OrchestrationV2ProviderGoal, + type ServerProvider, type ServerProviderModel, type OrchestrationV2ExecutionNode, type OrchestrationV2ThreadProjection, @@ -24,7 +25,13 @@ import { backgroundWorkHoldsCompletion, derivePendingBackgroundWork, } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; -import { getProviderOptionCurrentLabel, getProviderOptionStatedValue } from "@t3tools/shared/model"; +import { + getModelSelectionStringOptionValue, + getProviderOptionCurrentLabel, + getProviderOptionStatedValue, + resolveSelectableModel, + withLegacyUltracodeEffort, +} from "@t3tools/shared/model"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import * as DateTime from "effect/DateTime"; @@ -164,16 +171,18 @@ export function formatModelSelectionEffort( ): string | null { const caps = models.find((model) => model.slug === selection.model)?.capabilities; if (!caps) return null; + const options = withLegacyUltracodeEffort(caps.optionDescriptors, selection.options); + const current = options ? { ...selection, options } : selection; for (const id of REASONING_EFFORT_OPTION_IDS) { const descriptor = caps.optionDescriptors?.find((candidate) => candidate.id === id); if (descriptor?.type !== "select") continue; - const value = getProviderOptionStatedValue(id, selection, reportedSelection); + const value = getProviderOptionStatedValue(id, current, reportedSelection); if (value === undefined && id !== "variant") continue; if (typeof value === "boolean") continue; // Bypass picker normalization so an obsolete choice cannot become a catalog default. const label = getProviderOptionCurrentLabel( { ...descriptor, currentValue: value }, - selection, + current, reportedSelection, ); if (label) return label; @@ -181,6 +190,28 @@ export function formatModelSelectionEffort( return null; } +/** A reported model the picker catalog does not know, such as `claude-opus-5[1m]`, keeps its raw effort. */ +export function formatSubagentEffort(input: { + readonly modelSlug: string | null; + readonly modelSelection: ModelSelection | undefined; + readonly provider: Pick | undefined; +}): string | null { + const { modelSlug, modelSelection: selection, provider } = input; + if (!provider || !selection || !modelSlug) return null; + const selectionSlug = + resolveSelectableModel(provider.driver, selection.model, provider.models) ?? + selection.model.trim(); + if (selectionSlug !== modelSlug) return null; + if (provider.models.some((model) => model.slug === modelSlug)) { + return formatModelSelectionEffort({ ...selection, model: modelSlug }, provider.models); + } + return ( + REASONING_EFFORT_OPTION_IDS.map((id) => getModelSelectionStringOptionValue(selection, id)).find( + Boolean, + ) ?? null + ); +} + const SUBAGENT_STATUS_LABELS: Record = { idle: "Idle", pending: "Working", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 396d4d9c86c26..af6af0254a94d 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -50,6 +50,32 @@ describe("descriptor helpers", () => { ).toEqual([{ id: "fastMode", value: true }]); }); + it("dispatches an old Ultracode effort as Extra High with the Ultracode toggle", () => { + const legacy = [{ id: "effort", value: "ultracode" }]; + const descriptors = getProviderOptionDescriptors({ + caps: createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + ], + }, + { id: "ultracode", label: "Ultracode", type: "boolean" }, + ], + }), + selections: legacy, + }); + + expect(buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, legacy)).toEqual([ + { id: "effort", value: "xhigh" }, + { id: "ultracode", value: true }, + ]); + }); + it("compares complete model selections independent of option ordering", () => { const left = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ { id: "reasoningEffort", value: "high" }, diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 51cc1430321d8..0fdb2bc71ec4f 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -196,12 +196,40 @@ function withDescriptorCurrentValue( }; } +// Ultracode was a Claude effort level before it became its own toggle; that level ran at xhigh. +export function withLegacyUltracodeEffort< + Selections extends ReadonlyArray | null | undefined, +>( + descriptors: ReadonlyArray | null | undefined, + selections: Selections, +): Selections | Array { + if ( + getRawSelectionValueById(selections, "effort") !== "ultracode" || + !descriptors?.some( + (descriptor) => descriptor.id === "ultracode" && descriptor.type === "boolean", + ) + ) { + return selections; + } + return [ + ...(selections ?? []).map((selection) => + selection.id === "effort" && selection.value === "ultracode" + ? { id: "effort", value: "xhigh" } + : selection, + ), + ...(getRawSelectionValueById(selections, "ultracode") === undefined + ? [{ id: "ultracode", value: true }] + : []), + ]; +} + export function getProviderOptionDescriptors(input: { caps: ModelCapabilities; selections?: ReadonlyArray | null | undefined; }): ReadonlyArray { - const { caps, selections } = input; + const { caps } = input; const baseDescriptors = (caps.optionDescriptors ?? []).map(cloneDescriptor); + const selections = withLegacyUltracodeEffort(baseDescriptors, input.selections); return baseDescriptors.map((descriptor) => withDescriptorCurrentValue( @@ -312,7 +340,9 @@ export function buildExplicitProviderOptionSelectionsFromDescriptors( if (!selections || selections.length === 0) { return undefined; } - const explicitIds = new Set(selections.map((selection) => selection.id)); + const explicitIds = new Set( + withLegacyUltracodeEffort(descriptors, selections).map(({ id }) => id), + ); const normalized = buildProviderOptionSelectionsFromDescriptors(descriptors)?.filter( (selection) => explicitIds.has(selection.id), ); From 0ea3c0243fb5b3c07bb8c2bad01c1d3d0f5fde35 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:53:37 +0300 Subject: [PATCH 040/184] perf(pr): pull requests paint from data already held, on web, desktop and mobile (#27798) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../layout/AdaptiveWorkspaceLayout.tsx | 2 + .../PullRequestOverviewLoading.tsx | 11 +- .../pull-requests/PullRequestRouteScreen.tsx | 16 ++- .../pull-requests/PullRequestStackCard.tsx | 5 +- .../pull-requests/usePullRequestDiff.ts | 9 +- .../workspace/workspace-tab-navigation.ts | 10 +- apps/mobile/src/native/useOpenPullRequest.ts | 6 +- .../src/state/thread-pr-presentation.ts | 59 +++++++- apps/mobile/src/state/use-thread-pr.test.ts | 51 ++++++- apps/mobile/src/state/use-thread-pr.ts | 18 +++ .../pullRequest/PullRequestService.test.ts | 67 +++++++++ .../src/pullRequest/PullRequestService.ts | 19 ++- .../PullRequestLinkPreview.browser.test.tsx | 92 ++++++++++++ .../pullRequest/PullRequestLinkPreview.tsx | 51 ++++--- .../pullRequest/PullRequestListGroups.tsx | 36 ++++- .../components/pullRequest/PullRequestRow.tsx | 11 ++ .../pullRequest/PullRequestsPage.tsx | 16 ++- .../pullRequest/usePullRequestDetail.ts | 16 +++ ...PullRequestIntentPrefetch.browser.test.tsx | 86 +++++++++++ .../usePullRequestIntentPrefetch.ts | 51 +++++++ .../src/connection/storage.browser.test.ts | 72 ++++++++++ apps/web/src/connection/storage.ts | 135 +++++++++++++++++- .../src/state/pullRequestStatsPersistence.ts | 6 +- .../client-runtime/src/state/pullRequests.ts | 3 +- .../client-runtime/src/state/runtime.test.ts | 32 ++++- packages/client-runtime/src/state/runtime.ts | 6 +- .../src/server/GitHubPullRequestApi.test.ts | 85 ++++++++++- .../src/server/GitHubPullRequestApi.ts | 83 +++++++---- 28 files changed, 958 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestLinkPreview.browser.test.tsx create mode 100644 apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx create mode 100644 apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts create mode 100644 apps/web/src/connection/storage.browser.test.ts diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 354d6413549fe..02c18801445a1 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -58,6 +58,7 @@ import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; +import { withKnownPullRequestSummary } from "../../state/use-thread-pr"; import { mobilePreferencesAtom } from "../../state/preferences"; import { DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, @@ -589,6 +590,7 @@ function AdaptiveWorkspaceLayoutContent( columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), check: workspaceSelectionCheck, overlays, + seedPullRequest: withKnownPullRequestSummary, }, ); if (layout.variant === "medium") { diff --git a/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx b/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx index 647aa26e46373..2494dd93ea0fb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx @@ -1,6 +1,6 @@ import { Chip } from "../../components/ui/Chip"; import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; -import type { PullRequestListEntry } from "@t3tools/contracts"; +import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts"; import { View } from "react-native"; import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; import { PullRequestStackCard } from "./PullRequestStackCard"; @@ -14,11 +14,14 @@ export function PullRequestOverviewLoading({ params, stack, }: { - readonly summary: PullRequestListEntry; + readonly summary: PullRequestListEntry | PullRequestSummary; readonly params: PullRequestParams; readonly stack: PullRequestStackState; }) { - const review = pullRequestListReviewStatus(summary); + const review = pullRequestListReviewStatus({ + reviewDecision: summary.reviewDecision ?? undefined, + viewerReviewRequested: "viewerReviewRequested" in summary && summary.viewerReviewRequested, + }); const conflicting = summary.mergeability === "conflicting"; const clean = summary.mergeability === "mergeable"; return ( @@ -89,7 +92,7 @@ export function PullRequestOverviewLoading({ - {summary.labels.length ? ( + {"labels" in summary && summary.labels.length ? ( {summary.labels.map((label) => ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 522a5eaf9a745..306864ecd237e 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -47,7 +47,11 @@ import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-na import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; -import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts"; +import { + type PullRequestDetail, + type PullRequestListEntry, + type PullRequestSummary, +} from "@t3tools/contracts"; import { PullRequestOverviewLoading } from "./PullRequestOverviewLoading"; import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink"; import { pullRequestStatus } from "./pullRequestStatus"; @@ -180,11 +184,11 @@ function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityF } function PullRequestIdentity(props: { - readonly pr: PullRequestDetail | PullRequestListEntry; + readonly pr: PullRequestDetail | PullRequestListEntry | PullRequestSummary; readonly params: PullRequestParams; }) { const { pr } = props; - const status = pullRequestStatus(pr); + const status = pullRequestStatus({ state: pr.state, isDraft: pr.isDraft === true }); return ( @@ -196,8 +200,8 @@ function PullRequestIdentity(props: { {status.label} )} - - {"changedFiles" in pr ? ( + + {"changedFiles" in pr && pr.changedFiles !== undefined ? ( {pr.changedFiles} {pr.changedFiles === 1 ? "file" : "files"} @@ -209,7 +213,7 @@ function PullRequestIdentity(props: { {pr.title} - + {pr.author?.login ?? "Unknown author"} diff --git a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx index cbb751c74c4c7..bdf4399f1e6cb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx @@ -9,6 +9,7 @@ import { pullRequestStatus } from "./pullRequestStatus"; import type { PullRequestParams } from "./usePullRequestDiff"; import type { PullRequestStackState } from "./usePullRequestStack"; import { PullRequestGlyph } from "./pullRequestGlyphs"; +import { withKnownPullRequestSummary } from "../../state/use-thread-pr"; export function PullRequestStackCard(props: { readonly params: PullRequestParams; @@ -67,7 +68,9 @@ export function PullRequestStackCard(props: { selected ? undefined : () => { - const params = pullRequestLayerParams(props.params, layer.number); + const params = withKnownPullRequestSummary( + pullRequestLayerParams(props.params, layer.number), + ); if (Platform.OS === "android") selectDetail({ name: "PullRequest", params }); else navigation.dispatch(StackActions.push("PullRequest", params)); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts index 92e36f4486944..6f5180d8ab6bc 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts @@ -1,5 +1,10 @@ import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; -import { EnvironmentId, ProjectId, type PullRequestListEntry } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + type PullRequestListEntry, + type PullRequestSummary, +} from "@t3tools/contracts"; import { useAtomRefresh } from "@effect/atom-react"; import { useLayoutEffect, useMemo, useState } from "react"; @@ -18,7 +23,7 @@ export type PullRequestParams = { readonly repository: string; readonly number: number; readonly host?: string; - readonly initialSummary?: PullRequestListEntry; + readonly initialSummary?: PullRequestListEntry | PullRequestSummary; }; export type PullRequestDiffParams = PullRequestParams & { readonly commit?: string }; diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/workspace/workspace-tab-navigation.ts index 3674c52972e03..bee1efc005fdc 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts +++ b/apps/mobile/src/features/workspace/workspace-tab-navigation.ts @@ -150,6 +150,7 @@ function detailRoute( tab: "threads" | "pullRequests", selections: WorkspaceSelections, check: WorkspaceSelectionCheck, + seedPullRequest?: (identity: Omit) => object, ): RouteLike | undefined { if (tab === "threads") { const draft = selections.threadsDraft; @@ -184,7 +185,11 @@ function detailRoute( (route) => route.name === "PullRequest" && samePullRequest(pullRequestSelection(route.params), pullRequest), - ) ?? { key: pullRequest.key, name: "PullRequest", params: identityOf(pullRequest) } + ) ?? { + key: pullRequest.key, + name: "PullRequest", + params: seedPullRequest?.(identityOf(pullRequest)) ?? identityOf(pullRequest), + } ); } @@ -204,6 +209,7 @@ export function workspaceTabNavigationState< readonly columns: boolean; readonly check: WorkspaceSelectionCheck; readonly overlays?: readonly RouteLike[]; + readonly seedPullRequest?: (identity: Omit) => object; }, ) { const overlays = options.overlays ?? []; @@ -221,7 +227,7 @@ export function workspaceTabNavigationState< const home = state.routes.find((route) => route.name === "Home") ?? { name: "Home" }; const detail = options.columns && tab !== "usage" - ? detailRoute(state, tab, selections, options.check) + ? detailRoute(state, tab, selections, options.check, options.seedPullRequest) : undefined; const routes = [...[home, ...(detail ? [detail] : [])].map(keyed), ...overlays]; return { ...state, index: routes.length - 1, routes }; diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index 755f0b965ee09..fa7d437155b5e 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -7,6 +7,7 @@ import { useProjects, useThreadShell } from "../state/entities"; import { tryOpenExternalUrl } from "../lib/openExternalUrl"; import { appAtomRegistry } from "../state/atom-registry"; import { environmentServerConfigsAtom } from "../state/server"; +import { withKnownPullRequestSummary } from "../state/use-thread-pr"; type PullRequestContext = { readonly environmentId: string; readonly projectId: string }; @@ -19,10 +20,11 @@ export function useNavigateToPullRequest(options?: { readonly replace?: boolean return async (url: string, context: PullRequestContext | null) => { // Read at press time so a config push does not hand every link a new handler. const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); - const params = nativePullRequestDestination(url, context, projects, (environmentId) => + const destination = nativePullRequestDestination(url, context, projects, (environmentId) => serverConfigs.has(environmentId), ); - if (!params) return tryOpenExternalUrl(url, "pull-request"); + if (!destination) return tryOpenExternalUrl(url, "pull-request"); + const params = withKnownPullRequestSummary(destination); // Android shows one page at a time; iOS opens it over the current page, so Back returns. if (Platform.OS === "android") selectDetail({ name: "PullRequest", params }); else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params)); diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 2c151676f5c6c..a44f410b54ca3 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -1,13 +1,20 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import type { ThreadPullRequestLink, VcsStatusResult } from "@t3tools/contracts"; +import type { + PullRequestSummary, + ThreadPullRequestKey, + ThreadPullRequestLink, + VcsStatusResult, +} from "@t3tools/contracts"; import { sourceControlClients, type ChangeRequestTerminology, } from "@t3tools/client-runtime/source-control-clients"; import { + legacyThreadPullRequestKey, resolveThreadCurrentPullRequestLink, resolveThreadPullRequestBadge, + threadPullRequestKeyOf, } from "@t3tools/client-runtime/thread-pull-requests"; export type ThreadPr = NonNullable; @@ -142,3 +149,53 @@ export function rememberBounded( } return next; } + +/** The newest summary held for `pullRequest`, matched strictly by host, repository and number. */ +export function knownPullRequestSummary( + pullRequest: ThreadPullRequestKey, + threads: Iterable>, + summaries: Iterable, +): PullRequestSummary | undefined { + const key = threadPullRequestKeyOf(pullRequest); + let newest: + | { readonly summary: PullRequestSummary; readonly updatedAt: number; readonly readAt: number } + | undefined; + // Checks and reviews change without moving `updatedAt`, so equal versions prefer the later read. + const consider = (summary: PullRequestSummary, readAt = Number.NEGATIVE_INFINITY) => { + const updatedAt = Date.parse(summary.updatedAt); + if ( + newest === undefined || + updatedAt > newest.updatedAt || + (updatedAt === newest.updatedAt && readAt > newest.readAt) + ) { + newest = { summary, updatedAt, readAt }; + } + }; + for (const summary of summaries) { + if (threadPullRequestKeyOf(legacyThreadPullRequestKey(summary)) === key) { + consider(summary, summary.observedAt); + } + } + for (const thread of threads) { + for (const link of thread.pullRequests) { + const snapshot = link.snapshot; + if (snapshot?.updatedAt == null || threadPullRequestKeyOf(link) !== key) continue; + consider( + { + ...snapshot, + updatedAt: snapshot.updatedAt, + provider: ( + sourceControlClients.findByChangeRequestUrl(link.url) ?? + sourceControlClients.get(undefined) + ).kind, + projectId: thread.projectId, + repository: link.repository, + number: link.number, + url: link.url, + }, + Date.parse(snapshot.syncedAt), + ); + } + } + return newest?.summary; +} diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index 309953ddbdc3b..4edbb903dbebf 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -1,7 +1,13 @@ -import { ProjectId, type ThreadPullRequestLink } from "@t3tools/contracts"; +import { + ProjectId, + type PullRequestSummary, + SourceControlProviderKind, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + knownPullRequestSummary, presentThreadLinkedPullRequests, rememberBounded, resolveThreadPrSource, @@ -113,6 +119,49 @@ describe("resolveThreadPrSource compatibility", () => { }); }); +describe("knownPullRequestSummary", () => { + it("seeds from the newest summary of the same pull request, never another's", () => { + const projectId = ProjectId.make("project"); + const link = linkedPr(7); + const threads = [ + { + projectId, + pullRequests: [ + { ...link, snapshot: { ...link.snapshot!, updatedAt: "2026-09-08T00:00:00.000Z" } }, + ], + }, + ]; + const listed: PullRequestSummary = { + provider: SourceControlProviderKind.make("github"), + projectId, + repository: "t3tools/t3code", + number: 7, + title: "Listed title", + url: "https://github.com/t3tools/t3code/pull/7", + state: "open", + headBranch: "change-7", + baseBranch: "main", + updatedAt: "2026-09-09T00:00:00.000Z", + }; + const lookup = (host: string, number: number, summaries = [listed]) => + knownPullRequestSummary({ host, repository: "t3tools/t3code", number }, threads, summaries); + + expect(lookup("github.com", 7, [])?.title).toBe("Change 7"); + expect(lookup("github.com", 7)?.title).toBe("Listed title"); + expect(lookup("github.com", 8)).toBeUndefined(); + expect(lookup("gitlab.com", 7)).toBeUndefined(); + + const sameVersion = { ...listed, updatedAt: "2026-09-08T00:00:00.000Z" }; + const syncedAt = Date.parse(link.snapshot!.syncedAt); + expect(lookup("github.com", 7, [{ ...sameVersion, observedAt: syncedAt - 1 }])?.title).toBe( + "Change 7", + ); + expect(lookup("github.com", 7, [{ ...sameVersion, observedAt: syncedAt + 1 }])?.title).toBe( + "Listed title", + ); + }); +}); + describe("rememberBounded", () => { it("moves a re-answered entry to the newest slot and evicts the oldest past the limit", () => { let snapshots: ReadonlyMap = new Map(); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index af14ce3827b4e..6c67e9f18f6b5 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -10,10 +10,13 @@ import { Atom } from "effect/reactivity"; import { use, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; import { serverEnvironment } from "./server"; +import { environmentThreadShells } from "./threads"; import { useEnvironmentQuery } from "./query"; import { ThreadPrDemandContext } from "./thread-pr-demand"; import { + knownPullRequestSummary, rememberBounded, resolveThreadPrSource, presentThreadPr, @@ -59,6 +62,21 @@ const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily( export { type ThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; +/** Seeds a pull request page with a summary already in memory, never another pull request's. */ +export function withKnownPullRequestSummary< + Params extends { readonly host?: string; readonly repository: string; readonly number: number }, +>(params: Params) { + const initialSummary = + params.host === undefined + ? undefined + : knownPullRequestSummary( + { host: params.host, repository: params.repository, number: params.number }, + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + appAtomRegistry.get(pullRequestSnapshotsAtom).values(), + ); + return initialSummary === undefined ? params : { ...params, initialSummary }; +} + export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { const demand = use(ThreadPrDemandContext); const supportsLinks = useAtomValue( diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 93aa60cd2c97c..50581506ea1e3 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -202,6 +202,73 @@ it.effect("does not wait for an in-flight detail read to display a preview", () }), ); +it.effect("shows a listed pull request's preview without asking the host again", () => + Effect.gen(function* () { + let previewReads = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: false, + }), + getChangeRequestPreview: () => + Effect.sync(() => { + previewReads++; + return { ...changeRequest(1, "2026-07-02T00:00:00Z"), title: "Renamed" }; + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + yield* service.list({ state: "open" }); + assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); + assert.strictEqual(previewReads, 0); + yield* service.refreshAfterTurn(ref.projectId); + assert.strictEqual((yield* service.preview(ref)).title, "Renamed"); + assert.strictEqual(previewReads, 1); + yield* service.list({ state: "open", limit: 1 }); + assert.strictEqual((yield* service.preview(ref)).title, "Renamed"); + }), +); + +it.effect("a list read under a pinned account leaves the shared preview to the host", () => + Effect.gen(function* () { + let previewReads = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }), + withVerifiedCredential: (_, use) => + use({ accountId: "101", viewer: "octocat", credentialFingerprint: "pinned" }), + listChangeRequests: () => + Effect.succeed({ + items: [{ ...changeRequest(1, "2026-07-02T00:00:00Z"), title: "Pinned view" }], + truncated: false, + continues: false, + }), + getChangeRequestPreview: () => + Effect.sync(() => { + previewReads++; + return changeRequest(1, "2026-07-02T00:00:00Z"); + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + yield* service.withRoutingCredential( + { ...ref, host: "github.com", expectedAccountId: "101" }, + service.list({ state: "open" }), + ); + assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); + assert.strictEqual(previewReads, 1); + }), +); + function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { return new PullRequestProviderError({ provider, diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index e77434c1697b3..304ce2d3fa52b 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1262,6 +1262,7 @@ export const make = Effect.gen(function* () { "PullRequestService.listUncached", )(function* (input) { const involvement = input.involvement ?? "all"; + const epochAtStart = epochCounter; const continuation = yield* decodeCursors(input.cursors); const { supported: projects, unimplemented, viewerRoots } = yield* listWorkspaceProjects(input); const projectCounts = new Map(); @@ -1549,13 +1550,25 @@ export const make = Effect.gen(function* () { for (const batch of batches) { if (batch.nextCursor !== null) nextCursors[batch.key] = batch.nextCursor; } + const entries = batches + .flatMap((batch) => batch.entries) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + // Hover cards on listed rows read from here. A pinned account or a mid-read refresh is another + // scope; half the cache stays free for previews read directly. + if ((yield* routingCredential) === null && epochCounter === epochAtStart) { + const seeds = entries + .slice(0, DETAIL_CACHE_CAPACITY / 2) + .map((entry) => [refCacheKey(entry), previewFields(entry)] as const) + .toReversed(); + for (const [key, value] of seeds) { + if (!(yield* Cache.has(previewCache, key))) yield* Cache.set(previewCache, key, value); + } + } return { viewers: viewers as PullRequestListResult["viewers"], providers, - entries: batches - .flatMap((batch) => batch.entries) - .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + entries, errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], truncated: batches.some((batch) => batch.truncated), nextCursors, diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.browser.test.tsx new file mode 100644 index 0000000000000..29f0211eaef83 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.browser.test.tsx @@ -0,0 +1,92 @@ +import { EnvironmentId, ProjectId, type PullRequestSummary } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +const known = vi.hoisted( + () => + ({ + provider: "github", + projectId: "project", + repository: "owner/repo", + number: 1, + title: "Known title", + url: "https://github.com/owner/repo/pull/1", + state: "open", + headBranch: "feature", + baseBranch: "main", + updatedAt: "2026-09-01T00:00:00Z", + }) as PullRequestSummary, +); +const state = vi.hoisted(() => ({ restoredPreview: null as unknown })); +vi.mock(import("~/state/query"), async (original) => ({ + ...(await original()), + // The host has not answered the preview read yet. + useEnvironmentQuery: ((atom: unknown) => ({ + data: state.restoredPreview, + dataUpdatedAt: 0, + error: null, + failure: null, + isPending: atom !== null, + isSuccess: false, + refresh: () => undefined, + })) as never, +})); +vi.mock(import("~/state/pullRequests"), async (original) => ({ + ...(await original()), + useSharedPullRequestSummary: (() => known) as never, +})); + +import { render } from "../../../test/render"; +import { PullRequestLinkPreview } from "./PullRequestLinkPreview"; + +function renderPreview() { + return render( + #1} + originalUrl={known.url} + target={{ + environmentId: EnvironmentId.make("environment"), + input: { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }, + }} + />, + ); +} + +it("keeps a refreshing preview's state and title over an older known summary", async () => { + state.restoredPreview = { + projectId: "project", + repository: "owner/repo", + number: 1, + title: "Renamed title", + url: known.url, + author: { login: "octocat" }, + state: "closed", + isDraft: false, + createdAt: "2026-08-01T00:00:00Z", + }; + await using screen = await renderPreview(); + + await userEvent.hover(screen.getByRole("link")); + + await expect.element(page.getByText("Renamed title")).toBeVisible(); + await expect.element(page.getByText("Closed", { exact: true })).toBeVisible(); + expect(page.getByText("Known title").query()).toBeNull(); +}); + +it("shows the known summary while the preview loads", async () => { + state.restoredPreview = null; + await using screen = await render( + #1} + originalUrl={known.url} + target={{ + environmentId: EnvironmentId.make("environment"), + input: { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }, + }} + />, + ); + + await userEvent.hover(screen.getByRole("link")); + + await expect.element(page.getByText("Known title")).toBeVisible(); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index f5ed4ea6553bf..8e3ee75602342 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -12,7 +12,7 @@ import { } from "react"; import { RelativeTime } from "~/components/RelativeTime"; -import { pullRequestEnvironment } from "~/state/pullRequests"; +import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { useEnvironmentQuery } from "~/state/query"; @@ -57,6 +57,10 @@ export function PullRequestLinkPreview({ }) : null, ); + const summary = useSharedPullRequestSummary( + open ? target.environmentId : null, + open ? target.input : null, + ); const readPreview = useAtomQueryRunner(pullRequestEnvironment.preview, { reportFailure: false, reportDefect: false, @@ -85,18 +89,19 @@ export function PullRequestLinkPreview({ }) : link; const preview = previewQuery.data; - const showCard = preview !== null || (previewQuery.error !== null && fallback !== undefined); + const card = preview ?? summary; + const showCard = card !== null || (previewQuery.error !== null && fallback !== undefined); const showUrlTooltip = open && previewQuery.error !== null && !showCard; const state = - preview === null + card === null ? null - : resolvePullRequestState({ state: preview.state, isDraft: preview.isDraft }); + : resolvePullRequestState({ state: card.state, isDraft: card.isDraft ?? false }); const authorLabel = - preview?.author === null + card?.author === null ? "ghost" - : preview?.author.name && preview.author.name !== preview.author.login - ? `${preview.author.name} (@${preview.author.login})` - : (preview?.author.login ?? null); + : card?.author?.name && card.author.name !== card.author.login + ? `${card.author.name} (@${card.author.login})` + : (card?.author?.login ?? null); return ( @@ -116,13 +121,13 @@ export function PullRequestLinkPreview({ {showCard ? (
- {preview === null ? ( + {card === null ? ( fallback ) : (
- {preview.repository} - #{preview.number} + {card.repository} + #{card.number} · {state === null ? null : ( @@ -132,16 +137,22 @@ export function PullRequestLinkPreview({ )}

- {preview.title} + {card.title}

-
- - {authorLabel} - · - - opened - -
+ {card.author === undefined ? null : ( +
+ + {authorLabel} + {preview === null ? null : ( + <> + · + + opened + + + )} +
+ )}
)}
diff --git a/apps/web/src/components/pullRequest/PullRequestListGroups.tsx b/apps/web/src/components/pullRequest/PullRequestListGroups.tsx index 7e8c4e3a26f10..4eb994f7c6627 100644 --- a/apps/web/src/components/pullRequest/PullRequestListGroups.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListGroups.tsx @@ -1,6 +1,6 @@ import type { EnvironmentId, PullRequestAction } from "@t3tools/contracts"; import { EyeIcon, LayersIcon, PenLineIcon, UsersIcon, type LucideIcon } from "lucide-react"; -import type { RefCallback } from "react"; +import { useDeferredValue, type RefCallback } from "react"; import { cn } from "~/lib/utils"; import { Separator } from "../ui/separator"; @@ -23,18 +23,32 @@ const GROUP_ICONS: Record = { }; const MATCHED_ELSEWHERE_SCORE = 10; +const FIRST_PAINT_ROWS = 24; + +function firstPaintGroups }>( + groups: ReadonlyArray, +): ReadonlyArray { + let left = FIRST_PAINT_ROWS; + return groups.flatMap((group) => { + const entries = group.entries.slice(0, left); + left -= entries.length; + return entries.length === 0 ? [] : [{ ...group, entries }]; + }); +} function PullRequestGroupHeader({ group, + count, }: { - group: { key: string; label: string; entries: ReadonlyArray }; + group: { key: string; label: string }; + count: number; }) { const Icon = GROUP_ICONS[group.key] ?? LayersIcon; return (

{group.label}

- {group.entries.length} + {count}
); @@ -48,6 +62,7 @@ export function PullRequestListGroups({ showProvider, statsRef, onSelect, + onIntent, hostActionsOf, onActed, closingKeys, @@ -67,18 +82,28 @@ export function PullRequestListGroups({ showProvider: boolean; statsRef: RefCallback; onSelect: (entry: PullRequestRowTarget) => void; + onIntent: (entry: PullRequestRowTarget | null) => void; hostActionsOf: (entry: EnvironmentPullRequestEntry) => ReadonlySet; onActed: (result: PullRequestSpeedActionResult) => void; closingKeys: ReadonlySet; sweepingKeys: ReadonlySet; onCloseSweepStart: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void; }) { + // Every row mounts tooltips and actions; painting a first screenful before the rest keeps + // opening the page from blocking on the whole list. + const shownGroups = useDeferredValue(groups, firstPaintGroups(groups)); + const counts = new Map(groups.map((group) => [group.key, group.entries.length])); return (
0 && "**:pointer-events-none")}> - {groups.map((group) => ( + {shownGroups.map((group) => (
- {group.label ? : null} + {group.label ? ( + + ) : null} {group.entries.map((entry) => { const entryKey = pullRequestEntryKey(entry); const environmentLabel = environmentLabels?.get(entry.environmentId); @@ -102,6 +127,7 @@ export function PullRequestListGroups({ selected.number === entry.number } onSelect={onSelect} + onIntent={onIntent} hostActions={hostActionsOf(entry)} onActed={onActed} closing={closingKeys.has(entryKey)} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 3b4d1f84c01d9..71fbf1100d554 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -78,6 +78,7 @@ function PullRequestRowImpl({ statsKey, statsRef, onSelect, + onIntent, hostActions, onActed, closing = false, @@ -94,6 +95,8 @@ function PullRequestRowImpl({ statsKey?: string; statsRef?: RefCallback; onSelect: (entry: PullRequestRowTarget) => void; + /** The row the pointer or focus rests on, or null when it leaves. */ + onIntent?: (entry: PullRequestRowTarget | null) => void; hostActions: ReadonlySet; onActed: (result: PullRequestSpeedActionResult) => void; closing?: boolean; @@ -115,6 +118,14 @@ function PullRequestRowImpl({ type="button" aria-current={selected ? "true" : undefined} onClick={() => onSelect(entry)} + onPointerEnter={() => onIntent?.(entry)} + onPointerLeave={(event) => { + if (event.currentTarget !== document.activeElement) onIntent?.(null); + }} + onFocus={() => onIntent?.(entry)} + onBlur={(event) => { + if (!event.currentTarget.matches(":hover")) onIntent?.(null); + }} className={cn( PULL_REQUEST_ROW_CLASS, PAGE_ROW_CLASS, diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 1b3d1bc74c186..0fc32bce3257a 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -64,6 +64,8 @@ import { PullRequestCompactFilterMenu } from "./PullRequestCompactFilterMenu"; import { INVOLVEMENT_TABS, SORT_OPTIONS, STATE_TABS } from "./pullRequestListOptions"; import { PullRequestsColumn } from "./PullRequestsColumn"; import { PullRequestListGroups } from "./PullRequestListGroups"; +import { pullRequestPanelReference } from "./usePullRequestDetail"; +import { usePullRequestIntentPrefetch } from "./usePullRequestIntentPrefetch"; import { usePullRequestDisplayGroups, usePullRequestQueryFilters, @@ -1080,6 +1082,9 @@ export function PullRequestsPage() { ), ); + const prefetchEntry = usePullRequestIntentPrefetch((entry) => + listedPullRequestsBySurface.has(pullRequestListEntryId(entry)), + ); const selectEntry = (entry: PullRequestRowTarget) => { if (rightPanelRef === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, entry); @@ -1184,6 +1189,7 @@ export function PullRequestsPage() { showProvider={showProvider} statsRef={registerStatsRow} onSelect={selectEntry} + onIntent={prefetchEntry} hostActionsOf={hostActionsOf} onActed={onSpeedAction} closingKeys={closingKeys} @@ -1528,14 +1534,10 @@ export function PullRequestsPage() { selectedEnvironmentId: panelEnvironmentId, }); }} - reference={{ + reference={pullRequestPanelReference({ + ...renderedPullRequestSurface, projectId: renderedPullRequestSurface.projectId as ProjectId, - repository: renderedPullRequestSurface.repository, - number: renderedPullRequestSurface.number, - ...(renderedPullRequestSurface.host - ? { host: renderedPullRequestSurface.host } - : {}), - }} + })} listEntry={ listedPullRequestsBySurface.get( pullRequestListEntryId(renderedPullRequestSurface), diff --git a/apps/web/src/components/pullRequest/usePullRequestDetail.ts b/apps/web/src/components/pullRequest/usePullRequestDetail.ts index 7451ee38c5fee..7d6ab0c7a6154 100644 --- a/apps/web/src/components/pullRequest/usePullRequestDetail.ts +++ b/apps/web/src/components/pullRequest/usePullRequestDetail.ts @@ -4,6 +4,7 @@ import { type EnvironmentId, type PullRequestListEntry, type PullRequestRef, + type ProjectId, } from "@t3tools/contracts"; import { useState } from "react"; @@ -22,6 +23,21 @@ import { resolveDisplayedPullRequestDetail, } from "./pullRequestDetail.logic"; +// Query atoms key on JSON order, so the panel and the row prefetch must build the ref here. +export function pullRequestPanelReference(target: { + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + readonly host?: string | undefined; +}): PullRequestRef { + return { + projectId: target.projectId, + repository: target.repository, + number: target.number, + ...(target.host ? { host: target.host } : {}), + }; +} + export function pullRequestRefKey(reference: PullRequestRef) { return `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; } diff --git a/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx new file mode 100644 index 0000000000000..366fb4cfe76e4 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx @@ -0,0 +1,86 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ reads: [] as Array })); +vi.mock(import("~/state/pullRequests"), async (original) => { + const actual = await original(); + return { + ...actual, + pullRequestEnvironment: { + ...actual.pullRequestEnvironment, + detail: ((target: unknown) => target) as never, + }, + }; +}); +vi.mock(import("~/state/query"), async (original) => ({ + ...(await original()), + useEnvironmentQuery: ((target: unknown) => { + if (target !== null || state.reads.length > 0) state.reads.push(target); + return {}; + }) as never, +})); + +import { act, fakeTimers, render } from "../../../test/render"; +import { usePullRequestIntentPrefetch } from "./usePullRequestIntentPrefetch"; + +const environmentId = EnvironmentId.make("environment"); +const row = { + environmentId, + projectId: ProjectId.make("project"), + host: "GitHub.com", + repository: "owner/repo", + number: 7, +}; + +function Rows() { + const prefetch = usePullRequestIntentPrefetch(() => true); + return ( + <> + + + + ); +} + +it("reads the panel's detail once intent rests on a row for 150 ms", async () => { + state.reads = []; + await using screen = await render(, { + // A bare hook probe; nothing here navigates. + router: false, + }); + using _timers = fakeTimers({ now: 0 }); + const rest = screen.getByRole("button", { name: "rest" }).element() as HTMLElement; + const leave = screen.getByRole("button", { name: "leave" }).element() as HTMLElement; + + await act(() => { + rest.click(); + leave.click(); + }); + await act(() => vi.advanceTimersByTime(1_000)); + expect(state.reads).toEqual([]); + + await act(() => rest.click()); + await act(() => vi.advanceTimersByTime(149)); + expect(state.reads).toEqual([]); + await act(() => vi.advanceTimersByTime(1)); + // Query atoms key on JSON order: this is the order the PRs page panel reads. + expect(JSON.stringify(state.reads.at(-1))).toBe( + JSON.stringify({ + environmentId, + input: { + projectId: row.projectId, + repository: "owner/repo", + number: 7, + host: "github.com", + allowStale: false, + }, + }), + ); + + await act(() => leave.click()); + expect(state.reads.at(-1)).toBeNull(); +}); diff --git a/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts new file mode 100644 index 0000000000000..a078ce2c94ff7 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts @@ -0,0 +1,51 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { useRef, useState } from "react"; + +import { useStableCallback } from "~/hooks/useStableCallback"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; + +import { pullRequestPanelReference } from "./usePullRequestDetail"; + +const INTENT_DELAY_MS = 150; + +interface PullRequestIntentTarget { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly host: string; + readonly repository: string; + readonly number: number; +} + +/** + * Reads the detail a row's panel will ask for once the pointer or focus rests on it, so the + * click paints from it. Passing over rows asks the host nothing, and leaving a row stops reading it. + */ +export function usePullRequestIntentPrefetch( + isListed: (target: PullRequestIntentTarget) => boolean, +) { + const [target, setTarget] = useState(null); + const timer = useRef | undefined>(undefined); + // A removed row fires no leave event. + const listed = target !== null && isListed(target); + if (target !== null && !listed) setTarget(null); + useEnvironmentQuery( + target === null || !listed + ? null + : pullRequestEnvironment.detail({ + environmentId: target.environmentId, + input: { + ...pullRequestPanelReference({ ...target, host: target.host.toLowerCase() }), + allowStale: false, + }, + }), + ); + return useStableCallback((next: PullRequestIntentTarget | null) => { + clearTimeout(timer.current); + if (next === null) { + setTarget(null); + return; + } + timer.current = setTimeout(() => setTarget(next), INTENT_DELAY_MS); + }); +} diff --git a/apps/web/src/connection/storage.browser.test.ts b/apps/web/src/connection/storage.browser.test.ts new file mode 100644 index 0000000000000..2b36f9827c84f --- /dev/null +++ b/apps/web/src/connection/storage.browser.test.ts @@ -0,0 +1,72 @@ +import { Persistence } from "@t3tools/client-runtime/platform"; +import { EnvironmentQueryStore } from "@t3tools/client-runtime/state/query-persistence"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { TestClock } from "effect/testing"; + +import * as ConnectionStorage from "./storage"; + +describe("IndexedDB query cache", () => { + it.effect("round trips per environment and clears with the environment", () => + Effect.gen(function* () { + const store = (yield* EnvironmentQueryStore)!; + const first = EnvironmentId.make("query-first"); + yield* store.save(first, "pr", "cached"); + expect(yield* store.load(first, "pr")).toEqual(Option.some("cached")); + expect(yield* store.load(EnvironmentId.make("query-second"), "pr")).toEqual(Option.none()); + yield* (yield* Persistence.EnvironmentCacheStore).clear(first); + expect(yield* store.load(first, "pr")).toEqual(Option.none()); + }).pipe(Effect.provide(ConnectionStorage.layer)), + ); + + it.effect("two tabs' bursts of saves end within 256 rows and 32 MiB", () => + Effect.gen(function* () { + const openTab = Layer.build(Layer.fresh(ConnectionStorage.layer)).pipe( + Effect.map((context) => Context.get(context, EnvironmentQueryStore)!), + ); + const tabs = [yield* openTab, yield* openTab]; + const environmentId = EnvironmentId.make("query-bounded"); + const saveAll = (keys: ReadonlyArray, payload: (key: string) => string) => + Effect.forEach(keys, (key, index) => + tabs[index % 2]!.save(environmentId, key, payload(key)).pipe( + Effect.andThen(TestClock.adjust("1 millis")), + ), + ); + const loadAll = (keys: ReadonlyArray) => + Effect.forEach(keys, (key) => tabs[0]!.load(environmentId, key)).pipe( + Effect.map((loaded) => loaded.filter(Option.isSome).map((stored) => stored.value)), + ); + + const rows = Array.from({ length: 300 }, (_, index) => `row-${index}`); + yield* saveAll(rows, (key) => key); + const keptRows = yield* loadAll(rows); + expect(keptRows.length).toBeLessThanOrEqual(256); + expect(keptRows).not.toContain("row-0"); + expect(keptRows).toContain("row-299"); + + const answer = "x".repeat(4 * 1024 * 1024); + const large = Array.from({ length: 9 }, (_, index) => `large-${index}`); + yield* saveAll(large, () => answer); + const keptBytes = (yield* loadAll([...rows, ...large])).reduce( + (total, payload) => total + payload.length, + 0, + ); + expect(keptBytes).toBeLessThanOrEqual(32 * 1024 * 1024); + expect(yield* tabs[1]!.load(environmentId, "large-8")).toEqual(Option.some(answer)); + }).pipe(Effect.scoped), + ); + + it.effect("drops the older copy when an answer grows too large to keep", () => + Effect.gen(function* () { + const store = (yield* EnvironmentQueryStore)!; + const environmentId = EnvironmentId.make("query-oversize"); + yield* store.save(environmentId, "diff", "small"); + yield* store.save(environmentId, "diff", "x".repeat(4 * 1024 * 1024 + 1)); + expect(yield* store.load(environmentId, "diff")).toEqual(Option.none()); + }).pipe(Effect.provide(ConnectionStorage.layer)), + ); +}); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 7d9db4afdc494..78b01fc6d4695 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -28,7 +28,9 @@ import { pullRequestRoutingConnectionKey, pullRequestRoutingPermissionFor, } from "@t3tools/client-runtime/connection"; +import { EnvironmentQueryStore } from "@t3tools/client-runtime/state/query-persistence"; import { EnvironmentId, ServerConfig, ThreadId, VcsListRefsResult } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -41,12 +43,23 @@ import * as Stream from "effect/Stream"; import { projectFaviconCache } from "../assets/projectFaviconCache"; const DATABASE_NAME = "t3code:connection-runtime"; -const DATABASE_VERSION = 4; +const DATABASE_VERSION = 5; const CATALOG_STORE_NAME = "catalog"; const SHELL_STORE_NAME = "shell"; const THREAD_STORE_NAME = "thread"; const SERVER_CONFIG_STORE_NAME = "server-config"; const VCS_REFS_STORE_NAME = "vcs-refs"; +const QUERY_STORE_NAME = "query"; +const QUERY_UPDATED_INDEX_NAME = "environment-updated"; +const QUERY_MAX_ROWS = 256; +const QUERY_MAX_BYTES = 32 * 1024 * 1024; +const QUERY_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024; +const QUERY_MAX_AGE_MS = 30 * 24 * 60 * 60_000; +// A stats answer saves one row per listed PR, so the bounds index is walked once per batch of +// saves. Each walk trims a batch below the caps, so the caps hold between walks. The batch count +// lives in the database, so tabs sharing it share one count. +const QUERY_PRUNE_BATCH_ROWS = 32; +const QUERY_PRUNE_BATCH_BYTES = 1024 * 1024; const CATALOG_KEY = "document"; const StoredShellSnapshot = StoredOrchestrationShellSnapshot; const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); @@ -65,6 +78,10 @@ const StoredVcsRefs = Schema.Struct({ refs: VcsListRefsResult, }); const StoredVcsRefsJson = Schema.fromJsonString(StoredVcsRefs); +const isStoredQuery = Schema.is( + Schema.Struct({ environmentId: Schema.String, payload: Schema.String, updatedAt: Schema.Number }), +); +const isQueryBatch = Schema.is(Schema.Struct({ rows: Schema.Number, bytes: Schema.Number })); const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); @@ -137,6 +154,11 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) { request.result.createObjectStore(VCS_REFS_STORE_NAME); } + if (!request.result.objectStoreNames.contains(QUERY_STORE_NAME)) { + request.result + .createObjectStore(QUERY_STORE_NAME) + .createIndex(QUERY_UPDATED_INDEX_NAME, ["environmentId", "updatedAt", "bytes"]); + } }); request.addEventListener("error", () => { resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); @@ -326,6 +348,75 @@ function removeDatabaseValuesInRangeOnConnection( }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange")); } +// Mirrors mobile's pull-request cache bounds, per environment. +function saveQueryOnConnection( + database: IDBDatabase, + environmentId: EnvironmentId, + key: string, + payload: string, + updatedAt: number, +) { + return Effect.callback((resume) => { + try { + const bytes = new TextEncoder().encode(payload).length; + const transaction = database.transaction(QUERY_STORE_NAME, "readwrite"); + transaction.addEventListener("abort", () => { + resume( + Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + const store = transaction.objectStore(QUERY_STORE_NAME); + // An answer too large to keep must not leave its older copy to paint on the next start. + if (bytes > QUERY_MAX_PAYLOAD_BYTES) { + store.delete(queryCacheKey(environmentId, key)); + return; + } + store.put({ environmentId, payload, updatedAt, bytes }, queryCacheKey(environmentId, key)); + const batchKey = queryBatchKey(environmentId); + const batchRequest = store.get(batchKey); + batchRequest.addEventListener("success", () => { + const batch: unknown = batchRequest.result; + const rows = (isQueryBatch(batch) ? batch.rows : 0) + 1; + const written = (isQueryBatch(batch) ? batch.bytes : 0) + bytes; + if ( + isQueryBatch(batch) && + rows < QUERY_PRUNE_BATCH_ROWS && + written < QUERY_PRUNE_BATCH_BYTES + ) { + store.put({ rows, bytes: written }, batchKey); + return; + } + store.put({ rows: 0, bytes: 0 }, batchKey); + const request = store + .index(QUERY_UPDATED_INDEX_NAME) + .openKeyCursor(IDBKeyRange.bound([environmentId], [environmentId, []]), "prev"); + let kept = 0; + let totalBytes = 0; + request.addEventListener("success", () => { + const cursor = request.result; + if (cursor === null) return; + const [, rowUpdatedAt, rowBytes] = cursor.key as [string, number, number]; + kept += 1; + totalBytes += rowBytes; + if ( + kept > QUERY_MAX_ROWS - QUERY_PRUNE_BATCH_ROWS || + totalBytes > QUERY_MAX_BYTES - QUERY_PRUNE_BATCH_BYTES || + rowUpdatedAt < updatedAt - QUERY_MAX_AGE_MS + ) { + store.delete(cursor.primaryKey); + } + cursor.continue(); + }); + }); + } catch (cause) { + resume(Effect.fail(catalogError("write", cause))); + } + }).pipe(Effect.withSpan("web.connectionStorage.saveQuery")); +} + function readDatabaseValue(database: DatabaseHandle, storeName: string, key: IDBValidKey) { return withDatabase(database, (opened) => readDatabaseValueOnConnection(opened, storeName, key)); } @@ -361,6 +452,14 @@ function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { return `${environmentId}:${threadId}`; } +function queryCacheKey(environmentId: EnvironmentId, key: string) { + return `${environmentId}:v1:${key}`; +} + +function queryBatchKey(environmentId: EnvironmentId) { + return `${environmentId}:batch`; +} + function vcsRefsCacheKey(environmentId: EnvironmentId, cwd: string) { return `${environmentId}:${cwd}`; } @@ -955,12 +1054,45 @@ export const layer = Layer.effectContext( VCS_REFS_STORE_NAME, IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), ), + removeDatabaseValuesInRange( + database, + QUERY_STORE_NAME, + IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), + ), ], { concurrency: "unbounded", discard: true }, ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))); }), }); + const queryStore: NonNullable = { + load: Effect.fn("EnvironmentQueryStore.load")( + function* (environmentId, key) { + const stored = yield* readDatabaseValue( + database, + QUERY_STORE_NAME, + queryCacheKey(environmentId, key), + ); + const now = yield* Clock.currentTimeMillis; + return isStoredQuery(stored) && + stored.environmentId === environmentId && + stored.updatedAt >= now - QUERY_MAX_AGE_MS + ? Option.some(stored.payload) + : Option.none(); + }, + Effect.catch(() => Effect.succeedNone), + ), + save: (environmentId, key, payload) => + Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => + withDatabase(database, (opened) => + saveQueryOnConnection(opened, environmentId, key, payload, now), + ), + ), + Effect.catch(() => Effect.logWarning("Offline query database save failed")), + ), + }; + return Context.make(Persistence.ConnectionTargetStore, targetStore).pipe( Context.add(PullRequestRoutingPermissions, pullRequestRoutingPermissions), Context.add(Persistence.ConnectionRegistrationStore, registrationStore), @@ -968,6 +1100,7 @@ export const layer = Layer.effectContext( Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), Context.add(Persistence.EnvironmentCacheStore, cacheStore), + Context.add(EnvironmentQueryStore, queryStore), ); }), ); diff --git a/packages/client-runtime/src/state/pullRequestStatsPersistence.ts b/packages/client-runtime/src/state/pullRequestStatsPersistence.ts index 2d97ce23d1c7d..75cba63031a2f 100644 --- a/packages/client-runtime/src/state/pullRequestStatsPersistence.ts +++ b/packages/client-runtime/src/state/pullRequestStatsPersistence.ts @@ -19,8 +19,10 @@ export function persistPullRequestStats(): QueryPersistence - row.load(environmentId, statKey(ref)), + const rows = yield* Effect.forEach( + input.refs, + (ref) => row.load(environmentId, statKey(ref)), + { concurrency: "unbounded" }, ); const stats = rows.flatMap((value) => (Option.isSome(value) ? [value.value] : [])); return stats.length ? Option.some({ stats }) : Option.none(); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 64ae5ae3df56e..b92f00c8a3695 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -21,7 +21,7 @@ import { type PullRequestRef, type PullRequestMergeMethod, PullRequestOperationError, - type PullRequestSummary, + PullRequestSummary, type VcsStatusResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -147,6 +147,7 @@ export function createLinkedPullRequestSummaryAtomFamily( const routedRequest = createPullRequestRouter(); return createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:linked-summary", + persistence: persistQuery(PullRequestSummary), tag: WS_METHODS.pullRequestsSummary, execute: (input) => routedRequest(WS_METHODS.pullRequestsSummary, input), staleTimeMs: 60_000, diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 381623f23207a..f79f00cdaa80e 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1370,19 +1370,20 @@ describe("persisted environment queries", () => { Effect.scoped( Effect.gen(function* () { let saved = Option.none(); + const saves = yield* Queue.unbounded(); const persistence: QueryPersistence = { load: () => Effect.sync(() => saved), save: (_environmentId, _key, value) => Effect.sync(() => { saved = Option.some(value); - }), + }).pipe(Effect.andThen(Queue.offer(saves, value))), }; const first = yield* makeEnvironmentQueryHarness(Effect.succeed("first"), { persistence, }); const firstRegistry = yield* mountEnvironmentQuery(first.atom); expect(yield* readEnvironmentQuery(firstRegistry, first.atom)).toBe("first"); - expect(saved).toEqual(Option.some("first")); + expect(yield* Queue.take(saves)).toBe("first"); const restarted = yield* makeEnvironmentQueryHarness(Effect.succeed("updated"), { persistence, @@ -1393,11 +1394,36 @@ describe("persisted environment queries", () => { yield* SubscriptionRef.set(restarted.supervisorState, queryConnectionState()); yield* Effect.yieldNow; expect(yield* readEnvironmentQuery(registry, restarted.atom)).toBe("updated"); - expect(saved).toEqual(Option.some("updated")); + expect(yield* Queue.take(saves)).toBe("updated"); }), ), ); + it.effect("answers readers while its save is pending, and ends the save with the query", () => + Effect.scoped( + Effect.gen(function* () { + const saveEnded = Latch.makeUnsafe(); + const harness = yield* makeEnvironmentQueryHarness(Effect.succeed("fresh"), { + persistence: { + load: () => Effect.succeedNone, + // The registry's scheduler stops on dispose, so signal synchronously. + save: () => + Effect.never.pipe( + Effect.onInterrupt(() => Effect.sync(() => saveEnded.openUnsafe())), + ), + }, + }); + yield* Effect.scoped( + Effect.gen(function* () { + const registry = yield* mountEnvironmentQuery(harness.atom); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe("fresh"); + }), + ); + yield* saveEnded.await; + }), + ), + ); + it.effect("hands each server answer to onAnswer before readers see it", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 58c37070fd8d6..78e0cec6437a3 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -781,8 +781,12 @@ export function createEnvironmentQueryAtomFamily( return cached.value; } if (Option.isNone(previous) && Option.isSome(cached)) yield* emit(cached.value); + // Readers must not wait on the disk: web saves one IndexedDB row per listed PR's stats. + // The save ends with the query, so a removed environment's cache stays cleared. return yield* answer().pipe( - Effect.tap((value) => persistence.save(target.environmentId, cacheKey, value)), + Effect.tap((value) => + persistence.save(target.environmentId, cacheKey, value).pipe(Effect.forkScoped), + ), ); }); }) diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 5a5c46f28d99a..b676a91f0cab4 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -820,6 +820,74 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("reads commit stats alongside later activity pages", () => + Effect.gen(function* () { + const laterPageStarted = yield* Deferred.make(); + const statsStarted = yield* Deferred.make(); + const activityPage = (head: boolean) => ({ + data: { + repository: { + pullRequest: { + ...(head + ? { + author: null, + commits: { + nodes: [ + { + commit: { + id: "node-regular", + oid: "regular", + messageHeadline: "regular", + committedDate: "2026-07-05T00:00:00Z", + parents: { totalCount: 1 }, + authors: { nodes: [] }, + }, + }, + ], + }, + } + : {}), + comments: { + nodes: [], + pageInfo: head ? { hasNextPage: true, endCursor: "c1" } : lastPage, + }, + reviews: { nodes: [], pageInfo: lastPage }, + }, + }, + }, + }); + mockedExecute.mockImplementation((call) => { + if (matches(call, "PullRequestCommitStats")) { + return Deferred.succeed(statsStarted, undefined).pipe( + Effect.andThen(Deferred.await(laterPageStarted)), + Effect.as( + output( + encodeJson({ + data: { + nodes: [{ __typename: "Commit", oid: "regular", additions: 3, deletions: 1 }], + }, + }), + ), + ), + ); + } + const head = call.kind === "graphql" && call.variables?.["head"] === true; + return head + ? Effect.succeed(output(encodeJson(activityPage(true)))) + : Deferred.succeed(laterPageStarted, undefined).pipe( + Effect.andThen(Deferred.await(statsStarted)), + Effect.as(output(encodeJson(activityPage(false)))), + ); + }); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const activity = yield* cli.getPullRequestActivity(pullRequestInput); + + expect(activity.commits[0]).toMatchObject({ additions: 3, deletions: 1 }); + expect(mockedExecute).toHaveBeenCalledTimes(3); + }), + ); + it.effect("loads the complete hover card with one GraphQL request", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -3299,7 +3367,10 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.succeed(output(reviewThreadsPage([thread("T3", "c3")], null))), ); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - const read = cli.listReviewThreadComments(pullRequestInput); + const read = cli.listReviewThreadComments({ + ...pullRequestInput, + repository: `acme/refused-threads-${later}`, + }); const conversation = yield* read; expect( variablesOf("ReviewThreads").map((v) => [v["after"], v["requestedReviewers"]]), @@ -3327,7 +3398,7 @@ layer("GitHubPullRequestApi.layer", (it) => { yield* read; expect(variablesOf("ReviewThreads").at(-1)).toMatchObject({ after: null, - requestedReviewers: 50, + requestedReviewers: 0, }); }), ); @@ -3460,7 +3531,10 @@ layer("GitHubPullRequestApi.layer", (it) => { ), ); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - const read = cli.getPullRequestDetail(pullRequestInput); + const read = cli.getPullRequestDetail({ + ...pullRequestInput, + repository: `acme/refused-checks-${refused}`, + }); const detail = yield* read; expect(detail.reviewRequestLogins).toEqual([]); expect(detail.reviewRequestTeams).toEqual([]); @@ -3480,7 +3554,8 @@ layer("GitHubPullRequestApi.layer", (it) => { if (refused) { mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(coreResponse())))); yield* read; - expect(variablesOf("PullRequestCore").at(-1)).toMatchObject({ requestedReviewers: 100 }); + expect(variablesOf("PullRequestCore").at(-1)).toMatchObject({ requestedReviewers: 0 }); + expect(mockedExecute).toHaveBeenCalledTimes(4); } }), ); @@ -3514,7 +3589,7 @@ layer("GitHubPullRequestApi.layer", (it) => { const error = yield* Effect.flip( cli.getPullRequestDetail({ cwd: "/w", - repository: "acme/web", + repository: `acme/head-changed-${refused}`, host: "github.com", number: 7, }), diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 2defbb6684919..cb96b97d8556c 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -1422,6 +1422,31 @@ export const make = Effect.gen(function* () { }; }); + // GitHub refuses a requested-reviewer list holding a team the viewer cannot see. Remember the + // refusal so later reads of that pull request skip straight to the read without reviewers. + const refusedReviewers = yield* Cache.make({ + capacity: 512, + timeToLive: "10 minutes", + lookup: (_key: string) => Effect.void, + }); + const readRequestedReviewers = Effect.fn("GitHubPullRequestApi.readRequestedReviewers")( + function* ( + input: { readonly host: string; readonly repository: string; readonly number: number }, + wanted: number, + read: (requestedReviewers: number) => Effect.Effect, + ) { + const host = normalizeHost(input.host); + const { fingerprint } = yield* api.credential(host); + const key = `${fingerprint}\0${host}\0${input.repository.toLowerCase()}\0${input.number}`; + if (yield* Cache.has(refusedReviewers, key)) return yield* read(0); + return yield* read(wanted).pipe( + Effect.catchIf(isRefusedRequestedReviewersOnly, () => + Cache.set(refusedReviewers, key, undefined).pipe(Effect.andThen(read(0))), + ), + ); + }, + ); + const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); const operation = "getPullRequestDetail"; @@ -1436,8 +1461,7 @@ export const make = Effect.gen(function* () { }); return SourceControlRateLimit.Interactive.pipe( Effect.flatMap((allowReserve) => - read(100).pipe( - Effect.catchIf(isRefusedRequestedReviewersOnly, () => read(0)), + readRequestedReviewers(input, 100, read).pipe( graphql({ ...input, operation, allowReserve }), Effect.map(toPullRequestCore), Effect.flatMap(found({ ...input, operation })), @@ -2031,7 +2055,9 @@ export const make = Effect.gen(function* () { let withComments = true; let withReviews = true; const target = { ...input, operation: "getPullRequestActivity" }; - for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { + const readPage = Effect.fn("GitHubPullRequestApi.readActivityPage")(function* ( + page: number, + ) { const read: GitHubPullRequestActivityPage = yield* client .PullRequestActivity({ owner, @@ -2058,16 +2084,28 @@ export const make = Effect.gen(function* () { reviewsAfter = read.nextReviewsCursor; withComments = withComments && commentsAfter !== null; withReviews = withReviews && reviewsAfter !== null; - } + }); + yield* readPage(0); + const laterPages = Effect.gen(function* () { + for (let page = 1; page < REVIEW_THREAD_PAGES; page++) { + if (!withComments && !withReviews) return; + yield* readPage(page); + } + }); const emptyStats = toPullRequestCommitStats({ nodes: [] }); - const commitStats = - nonMergeCommitIds.length === 0 - ? emptyStats - : yield* client.PullRequestCommitStats({ ids: nonMergeCommitIds }).pipe( - graphql(target), - Effect.map(toPullRequestCommitStats), - Effect.orElseSucceed(() => emptyStats), - ); + const [, commitStats] = yield* Effect.all( + [ + laterPages, + nonMergeCommitIds.length === 0 + ? Effect.succeed(emptyStats) + : client.PullRequestCommitStats({ ids: nonMergeCommitIds }).pipe( + graphql(target), + Effect.map(toPullRequestCommitStats), + Effect.orElseSucceed(() => emptyStats), + ), + ], + { concurrency: 2 }, + ); return { author, comments: remarks.toSorted((left, right) => @@ -2157,25 +2195,12 @@ export const make = Effect.gen(function* () { function* (input) { const { owner, name } = parseRepositorySelector(input.repository); const target = { ...input, operation: "listReviewThreadComments" }; - let requestedReviewers = 50; const threadPage = ( after: string | null, - ): Effect.Effect => { - const read = () => - client.ReviewThreads({ owner, name, number: input.number, after, requestedReviewers }); - return read().pipe( - Effect.catchIf( - (error) => requestedReviewers > 0 && isRefusedRequestedReviewersOnly(error), - () => { - requestedReviewers = 0; - return read(); - }, - ), - graphql(target), - Effect.map(toReviewThreadPage), - Effect.flatMap(found(target)), - ); - }; + ): Effect.Effect => + readRequestedReviewers(input, 50, (requestedReviewers) => + client.ReviewThreads({ owner, name, number: input.number, after, requestedReviewers }), + ).pipe(graphql(target), Effect.map(toReviewThreadPage), Effect.flatMap(found(target))); const entries: GitHubReviewThreadEntry[] = []; const avatarsByLogin = new Map(); const botLogins = new Set(); From e71e4d2a020c221312f1f4d175e382f8915a1324 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:42:37 +0530 Subject: [PATCH 041/184] fix(mobile): approval card buttons line up (#31415) Co-authored-by: Claude Opus 5.5 --- .../features/threads/PendingApprovalCard.tsx | 3 +- .../features/threads/ThreadDetailScreen.tsx | 29 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index b5a9d4ed27eb2..f9ec44d5d4ee5 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -80,7 +80,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { {warning ? ( {warning} ) : null} - + {options.map((option) => (