fix(ui): keep work status PR checks current
This commit is contained in:
@@ -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;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user