fix(ui): keep work status PR checks current

This commit is contained in:
Bohdan Triapitsyn
2026-08-20 01:38:43 +03:00
parent a2a9eabd04
commit 90d8868bfc
4 changed files with 78 additions and 26 deletions
@@ -89,7 +89,7 @@ which requests only providers enabled for this panel.
| Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this | | Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this |
| Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` and refreshed from Git mutation hints | | Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` and refreshed from Git mutation hints |
| Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits | | Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits |
| PR + checks | `usePrVisualSummary` | **read-only** | | PR + checks | `useFreshestPrVisualSummaryForBranch` | **read-only**; follows the freshest remote-keyed entry for the branch |
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | | | Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | |
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child | | Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not | | Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
@@ -145,6 +145,10 @@ The panel never calls `startWatching`. PR watching is owned by the background
tracker, and its concurrency gate exists because per-consumer PR fetches once tracker, and its concurrency gate exists because per-consumer PR fetches once
saturated the browser's connection pool and stalled startup for ~20s. A panel saturated the browser's connection pool and stalled startup for ~20s. A panel
that started a watch per open session would reintroduce exactly that fan-out. that started a watch per open session would reintroduce exactly that fan-out.
The PR surface can watch a concrete remote while passive readers initially know
only the automatic remote key, so the panel reads the freshest entry for the
directory and branch across remote keys. This keeps its PR and checks rows in
sync with the live PR surface without adding another request owner.
### Changed files come from git status, not the session ### Changed files come from git status, not the session
@@ -3,7 +3,7 @@ import { useI18n } from '@/lib/i18n';
import { useGitStore } from '@/stores/useGitStore'; import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { runBackgroundNetworkTask } from '@/lib/background-network'; import { runBackgroundNetworkTask } from '@/lib/background-network';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore'; import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
import { useSession, useSessionMessages } from '@/sync/sync-context'; import { useSession, useSessionMessages } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
@@ -107,11 +107,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
// Read-only: PR watching is owned by the background tracker. Starting a watch // Read-only: PR watching is owned by the background tracker. Starting a watch
// here would multiply GitHub requests per open session, which is exactly the // here would multiply GitHub requests per open session, which is exactly the
// fan-out the PR-status concurrency gate exists to prevent. // fan-out the PR-status concurrency gate exists to prevent.
const prKey = React.useMemo( const prSummary = useFreshestPrVisualSummaryForBranch(directory, branch);
() => (directory && branch ? getGitHubPrStatusKey(directory, branch) : null),
[directory, branch],
);
const prSummary = usePrVisualSummary(prKey);
// `getCurrentModel` is an imperative getter: its reference never changes, so // `getCurrentModel` is an imperative getter: its reference never changes, so
// calling it in render subscribes to nothing. Subscribe to the selected model // calling it in render subscribes to nothing. Subscribe to the selected model
@@ -4,7 +4,7 @@ import type { GitHubPullRequestStatus, RuntimeAPIs } from "@/lib/api/types"
let runtimeKey = "runtime-a" let runtimeKey = "runtime-a"
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey })) mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
const { getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore") const { getFreshestPrStatusForBranch, getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore")
const deferred = <T>() => { const deferred = <T>() => {
let resolve!: (value: T) => void let resolve!: (value: T) => void
@@ -38,6 +38,34 @@ describe("GitHub PR status cache ownership", () => {
expect(new Set([originA, upstreamA, originB]).size).toBe(3) expect(new Set([originA, upstreamA, originB]).size).toBe(3)
}) })
test("passive branch readers follow the freshest remote-keyed status", () => {
const automatic = getGitHubPrStatusKey("/repo", "feature")
const origin = getGitHubPrStatusKey("/repo", "feature", "origin")
useGitHubPrStatusStore.getState().ensureEntry(automatic)
useGitHubPrStatusStore.getState().ensureEntry(origin)
useGitHubPrStatusStore.getState().updateStatus(automatic, () => ({
connected: true,
pr: { number: 7, title: "old", url: "u7", state: "open", draft: false, base: "main", head: "feature" },
checks: { state: "pending", total: 3, success: 1, failure: 0, pending: 2 },
}))
useGitHubPrStatusStore.getState().updateStatus(origin, () => ({
connected: true,
pr: { number: 7, title: "current", url: "u7", state: "open", draft: false, base: "main", head: "feature" },
checks: { state: "success", total: 3, success: 3, failure: 0, pending: 0 },
}))
useGitHubPrStatusStore.setState((state) => ({
entries: {
...state.entries,
[automatic]: { ...state.entries[automatic], lastRefreshAt: 1 },
[origin]: { ...state.entries[origin], lastRefreshAt: 2 },
},
}))
const freshest = getFreshestPrStatusForBranch(useGitHubPrStatusStore.getState().entries, "/repo", "feature")
expect(freshest?.pr?.title).toBe("current")
expect(freshest?.checks?.pending).toBe(0)
})
test("rejects a response after params change", async () => { test("rejects a response after params change", async () => {
const request = deferred<GitHubPullRequestStatus>() const request = deferred<GitHubPullRequestStatus>()
const github = { prStatus: () => request.promise } as unknown as RuntimeAPIs["github"] const github = { prStatus: () => request.promise } as unknown as RuntimeAPIs["github"]
@@ -238,11 +238,11 @@ const findResolvedSiblingEntry = (
* instead of a single key: the entry being actively watched/refreshed may be * instead of a single key: the entry being actively watched/refreshed may be
* keyed by a concrete remote while the 'auto' entry goes stale. * keyed by a concrete remote while the 'auto' entry goes stale.
*/ */
export const getFreshestPrStatusForBranch = ( const getFreshestPrEntryForBranch = (
entries: Record<string, PrStatusEntry>, entries: Record<string, PrStatusEntry>,
directory: string, directory: string,
branch: string, branch: string,
): GitHubPullRequestStatus | null => { ): PrStatusEntry | null => {
const runtimeKey = getRuntimeKey(); const runtimeKey = getRuntimeKey();
let best: PrStatusEntry | null = null; let best: PrStatusEntry | null = null;
for (const [key, entry] of Object.entries(entries)) { for (const [key, entry] of Object.entries(entries)) {
@@ -260,7 +260,15 @@ export const getFreshestPrStatusForBranch = (
best = entry; best = entry;
} }
} }
return best?.status ?? null; return best;
};
export const getFreshestPrStatusForBranch = (
entries: Record<string, PrStatusEntry>,
directory: string,
branch: string,
): GitHubPullRequestStatus | null => {
return getFreshestPrEntryForBranch(entries, directory, branch)?.status ?? null;
}; };
const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => { const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => {
@@ -951,23 +959,39 @@ const summarySignature = (s: PrVisualSummary): string =>
const PR_SUMMARY_CACHE_MAX_ENTRIES = 300; const PR_SUMMARY_CACHE_MAX_ENTRIES = 300;
const prSummaryCacheByKey = new Map<string, { sig: string; summary: PrVisualSummary }>(); const prSummaryCacheByKey = new Map<string, { sig: string; summary: PrVisualSummary }>();
const getCachedPrSummary = (cacheKey: string, entry: PrStatusEntry | null | undefined): PrVisualSummary | null => {
const summary = entry ? deriveSummary(entry) : null;
if (!summary) {
prSummaryCacheByKey.delete(cacheKey);
return null;
}
const sig = summarySignature(summary);
const cached = prSummaryCacheByKey.get(cacheKey);
if (cached?.sig === sig) return cached.summary;
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) {
const oldestKey = prSummaryCacheByKey.keys().next().value;
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey);
}
prSummaryCacheByKey.set(cacheKey, { sig, summary });
return summary;
};
export const usePrVisualSummary = (key: string | null): PrVisualSummary | null => { export const usePrVisualSummary = (key: string | null): PrVisualSummary | null => {
return useGitHubPrStatusStore((state) => { return useGitHubPrStatusStore((state) => {
if (!key) return null; if (!key) return null;
const entry = state.entries[key]; return getCachedPrSummary(key, state.entries[key]);
const summary = entry ? deriveSummary(entry) : null; });
if (!summary) { };
prSummaryCacheByKey.delete(key);
return null; export const useFreshestPrVisualSummaryForBranch = (
} directory: string | null,
const sig = summarySignature(summary); branch: string | null,
const cached = prSummaryCacheByKey.get(key); ): PrVisualSummary | null => {
if (cached && cached.sig === sig) return cached.summary; const cacheKey = directory && branch ? JSON.stringify(['branch', getRuntimeKey(), directory, branch]) : null;
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) { return useGitHubPrStatusStore((state) => {
const oldestKey = prSummaryCacheByKey.keys().next().value; if (!directory || !branch || !cacheKey) return null;
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey); return getCachedPrSummary(cacheKey, getFreshestPrEntryForBranch(state.entries, directory, branch));
}
prSummaryCacheByKey.set(key, { sig, summary });
return summary;
}); });
}; };