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 |
| 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 |
| 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` | |
| 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 |
@@ -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
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.
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
@@ -3,7 +3,7 @@ import { useI18n } from '@/lib/i18n';
import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
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 { useConfigStore } from '@/stores/useConfigStore';
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
// here would multiply GitHub requests per open session, which is exactly the
// fan-out the PR-status concurrency gate exists to prevent.
const prKey = React.useMemo(
() => (directory && branch ? getGitHubPrStatusKey(directory, branch) : null),
[directory, branch],
);
const prSummary = usePrVisualSummary(prKey);
const prSummary = useFreshestPrVisualSummaryForBranch(directory, branch);
// `getCurrentModel` is an imperative getter: its reference never changes, so
// 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"
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
const { getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore")
const { getFreshestPrStatusForBranch, getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore")
const deferred = <T>() => {
let resolve!: (value: T) => void
@@ -38,6 +38,34 @@ describe("GitHub PR status cache ownership", () => {
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 () => {
const request = deferred<GitHubPullRequestStatus>()
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
* keyed by a concrete remote while the 'auto' entry goes stale.
*/
export const getFreshestPrStatusForBranch = (
const getFreshestPrEntryForBranch = (
entries: Record<string, PrStatusEntry>,
directory: string,
branch: string,
): GitHubPullRequestStatus | null => {
): PrStatusEntry | null => {
const runtimeKey = getRuntimeKey();
let best: PrStatusEntry | null = null;
for (const [key, entry] of Object.entries(entries)) {
@@ -260,7 +260,15 @@ export const getFreshestPrStatusForBranch = (
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[] => {
@@ -951,23 +959,39 @@ const summarySignature = (s: PrVisualSummary): string =>
const PR_SUMMARY_CACHE_MAX_ENTRIES = 300;
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 => {
return useGitHubPrStatusStore((state) => {
if (!key) return null;
const entry = state.entries[key];
const summary = entry ? deriveSummary(entry) : null;
if (!summary) {
prSummaryCacheByKey.delete(key);
return null;
}
const sig = summarySignature(summary);
const cached = prSummaryCacheByKey.get(key);
if (cached && 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(key, { sig, summary });
return summary;
return getCachedPrSummary(key, state.entries[key]);
});
};
export const useFreshestPrVisualSummaryForBranch = (
directory: string | null,
branch: string | null,
): PrVisualSummary | null => {
const cacheKey = directory && branch ? JSON.stringify(['branch', getRuntimeKey(), directory, branch]) : null;
return useGitHubPrStatusStore((state) => {
if (!directory || !branch || !cacheKey) return null;
return getCachedPrSummary(cacheKey, getFreshestPrEntryForBranch(state.entries, directory, branch));
});
};