diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 3356d51b98374..8e5068b0fc54e 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -14,6 +14,7 @@ import { startAgentOpenedPreviews } from "./components/preview/agentOpenedPrevie import { startMainAppLocationTracking } from "./components/sidebar/mainAppLocation"; import { startDesktopUpdateLaunchNotice } from "./components/sidebar/updateEverythingRun"; import { startThreadNotifications } from "./components/ThreadNotificationCoordinator"; +import { startGuideRunNotifications } from "./components/pullRequest/guide/PullRequestGuideRunCoordinator"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; import { resolveServerBackedAppDisplayName } from "./branding.logic"; import { isElectron } from "./env"; @@ -151,6 +152,7 @@ export function startAppServices(): AppRouter { startLegacyThreadMigrationToast(); startPermissionUpdateNotices(router); startThreadNotifications(appAtomRegistry, router); + startGuideRunNotifications(appAtomRegistry, router); if (isElectron) startDesktopUpdateLaunchNotice(); return router; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 9c3faaf31677b..2fd7bacb66d63 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -260,6 +260,7 @@ export function PullRequestDetailPanel({ reference: requestedReference, listEntry = null, refreshToken: forcedRefreshToken = 0, + tabOnPage = false, onActed, onClose, context = "page", @@ -278,6 +279,11 @@ export function PullRequestDetailPanel({ reference: PullRequestRef; listEntry?: PullRequestListEntry | null; refreshToken?: number; + /** + * The pull requests page's panel: its tab lives in the guide run store, where a review guide + * notification can switch it. + */ + tabOnPage?: boolean; onActed?: (action?: PullRequestAction, phase?: "sent" | "done" | "failed") => void; onClose?: () => void; context?: "page" | "thread"; @@ -334,9 +340,33 @@ export function PullRequestDetailPanel({ useEnvironmentQuery(pullRequestEnvironment.changeBreakdown({ environmentId, input: reference })); const [threadPickerOpen, setThreadPickerOpen] = useState(false); const [openReviewRequest, setOpenReviewRequest] = useState(0); - const [tab, setTab] = useState(initialTab); + const { + canWriteSourceControl, + detailQuery, + activityQuery, + activity, + sharedSummary, + coreDetail, + detail, + } = usePullRequestDetail(environmentId, reference, matchingListEntry); + const [selectedTab, setTab] = useState(initialTab); + const reviewGuidesEnabled = useEnvironmentSettings( + environmentId, + (settings) => settings.reviewGuidesEnabled, + ); + const guideKey = guideRunKey(environmentId, reference); + const pageTab = useGuideRunStore((state) => + tabOnPage && state.pageDetailTab?.key === guideKey ? state.pageDetailTab.tab : null, + ); + const requestedTab = tabOnPage ? (pageTab ?? initialTab) : selectedTab; + const isTabAvailable = (value: PullRequestDetailTab) => + (value !== "code" || detail === null || detail.capabilities.diff) && + (value !== "guide" || reviewGuidesEnabled); + // An unavailable tab falls back without overwriting the saved selection. + const tab = isTabAvailable(requestedTab) ? requestedTab : "summary"; const selectTab = (next: PullRequestDetailTab) => { - setTab(next); + if (tabOnPage) useGuideRunStore.getState().showPageDetailTab(guideKey, next); + else setTab(next); onTabChange?.(next); }; const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); @@ -363,15 +393,6 @@ export function PullRequestDetailPanel({ void loadCodeTab(); selectTab("code"); }; - const guideOpenRequested = useGuideRunStore( - (state) => state.openRequestKey === guideRunKey(environmentId, reference), - ); - if (guideOpenRequested && tab !== "guide") setTab("guide"); - useEffect(() => { - if (!guideOpenRequested) return; - useGuideRunStore.getState().requestOpen(null); - onTabChange?.("guide"); - }, [guideOpenRequested, onTabChange]); const tabScopeKey = `${environmentId}:${pullRequestKey}`; const [tabMountState, setTabMountState] = useState(() => ({ key: tabScopeKey, @@ -440,15 +461,6 @@ export function PullRequestDetailPanel({ }; const [handoff, setHandoff] = useState(null); const turnRefresh = usePullRequestTurnRefresh(environmentId); - const { - canWriteSourceControl, - detailQuery, - activityQuery, - activity, - sharedSummary, - coreDetail, - detail, - } = usePullRequestDetail(environmentId, reference, matchingListEntry); const handoffSummary = detail ?? sharedSummary; const terminology = sourceControlClients.get( handoffSummary?.provider ?? listEntry?.provider ?? repositoryIdentity?.provider, @@ -811,8 +823,8 @@ export function PullRequestDetailPanel({ repository: reference.repository, number: reference.number, url: detail.url, + detailTab: "guide", }); - useGuideRunStore.getState().requestOpen(guideRunKey(environmentId, reference)); } } if (opened === null) { @@ -1103,17 +1115,7 @@ export function PullRequestDetailPanel({ const workflowApprovalsRequired = detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0; const freshness = detail === null ? null : resolveBaseFreshness(detail); - const reviewGuidesEnabled = useEnvironmentSettings( - environmentId, - (settings) => settings.reviewGuidesEnabled, - ); - const visibleTabs = TABS.filter( - (item) => - (item.value !== "code" || detail === null || detail.capabilities.diff) && - (item.value !== "guide" || reviewGuidesEnabled), - ); - // Keep unavailable-tab fallbacks out of the saved selection. - if (!visibleTabs.some((item) => item.value === tab)) setTab("summary"); + const visibleTabs = TABS.filter((item) => isTabAvailable(item.value)); const can = (action: PullRequestAction) => detail?.capabilities.actions.includes(action) === true && detail.viewerPermissions.actions.includes(action); @@ -1741,7 +1743,11 @@ export function PullRequestDetailPanel({ ) : null} {mountedTabs.has("guide") ? ( -
+
store.addComment); @@ -77,15 +75,6 @@ export function PullRequestGuideTab({ onRemove: (commentId) => removeComment(reviewKey, commentId), onAskAgent: onAskAboutLines, }; - useEffect(() => { - if (!active) return; - useGuideRunStore.getState().setShown(runKey); - return () => { - if (useGuideRunStore.getState().shownKey === runKey) - useGuideRunStore.getState().setShown(null); - }; - }, [active, runKey]); - const guide = data?.guide ?? null; const tourShown = active && guide !== null; const reportTourShown = useEffectEvent(() => onTourShown?.()); diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 4ea00dbace6bd..7d4fee18c92d6 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -1523,6 +1523,7 @@ export function PullRequestsPage() { ) ?? null } refreshToken={detailRefreshToken} + tabOnPage onActed={(action, phase = "done") => { // From every row held, not the ones on screen: a pull request closed from an open // list has left the screen, and reopening it has to find it anyway. diff --git a/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.test.ts b/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.test.ts new file mode 100644 index 0000000000000..a764c3c221d34 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.test.ts @@ -0,0 +1,122 @@ +import type { AnyRouter } from "@tanstack/react-router"; +import { + EnvironmentId, + ProjectId, + type PullRequestRef, + type PullRequestReviewGuideResult, +} from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { guideRunKey, useGuideRunStore } from "./guideRunStore"; +import { startGuideRunNotifications } from "./PullRequestGuideRunCoordinator"; + +const guides = vi.hoisted(() => ({ + family: null as unknown as (key: string) => unknown, +})); +vi.mock(import("~/state/pullRequests"), async (original) => { + const actual = await original(); + return { + ...actual, + pullRequestEnvironment: { + ...actual.pullRequestEnvironment, + reviewGuide: ((target: { environmentId: string; input: PullRequestRef }) => + guides.family(`${target.environmentId}:${target.input.number}`)) as never, + }, + }; +}); + +const guideAtom = Atom.family((_key: string) => + Atom.make>(AsyncResult.initial()), +); +guides.family = guideAtom; + +const environmentId = EnvironmentId.make("env-1"); +function reference(number: number): PullRequestRef { + return { projectId: ProjectId.make("project-1"), repository: "acme/app", number }; +} +const running = { + guide: null, + run: { _tag: "Running" }, +} as unknown as PullRequestReviewGuideResult; +const idle = { guide: null, run: null } as unknown as PullRequestReviewGuideResult; + +function setup() { + useGuideRunStore.setState({ watches: {} }); + const registry = AtomRegistry.make(); + const refresh = vi.spyOn(registry, "refresh"); + const refreshed = () => + refresh.mock.calls.flatMap(([atom]) => + [1, 2].filter((number) => atom === guideAtom(`${environmentId}:${number}`)).map(String), + ); + const stop = startGuideRunNotifications(registry, { navigate: vi.fn() } as unknown as AnyRouter); + const watch = (number: number, since = 0) => + useGuideRunStore.getState().watch({ + environmentId, + reference: reference(number), + title: null, + previousGuideAt: null, + previousFailedAt: null, + since, + }); + return { registry, refreshed, stop, watch }; +} + +it("polls each watched run once, however often the watches change", () => { + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + vi.useFakeTimers(); + const { registry, refreshed, stop, watch } = setup(); + registry.set(guideAtom(`${environmentId}:1`), AsyncResult.success(running)); + registry.set(guideAtom(`${environmentId}:2`), AsyncResult.success(running)); + watch(1); + watch(2); + vi.advanceTimersByTime(5_000); + expect(refreshed().toSorted()).toEqual(["1", "2"]); + stop(); +}); + +it("does not keep polling a run that settles on its first read", () => { + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + vi.useFakeTimers(); + const { registry, refreshed, stop, watch } = setup(); + // Long unseen and not running: the first read retires the watch. + registry.set(guideAtom(`${environmentId}:1`), AsyncResult.success(idle)); + watch(1, 0); + expect(useGuideRunStore.getState().watches[guideRunKey(environmentId, reference(1))]).toBe( + undefined, + ); + vi.advanceTimersByTime(5_000); + expect(refreshed()).toEqual([]); + stop(); +}); + +it("does not report the previous run's failure when a Running answer starts a watch", () => { + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + vi.useFakeTimers(); + const { registry, stop } = setup(); + const atom = guideAtom(`${environmentId}:1`); + const key = guideRunKey(environmentId, reference(1)); + registry.set( + atom, + AsyncResult.success({ + guide: null, + run: { _tag: "Failed", failedAt: "2026-10-01T00:00:00.000Z", detail: "old failure" }, + } as unknown as PullRequestReviewGuideResult), + ); + // What the reviewGuide answer handler does before the Running answer replaces the atom's value. + useGuideRunStore.getState().watch({ + environmentId, + reference: reference(1), + title: null, + previousGuideAt: null, + previousFailedAt: null, + since: 0, + answeredRunning: true, + }); + expect(useGuideRunStore.getState().watches[key]).toBeDefined(); + registry.set(atom, AsyncResult.success(running)); + expect(useGuideRunStore.getState().watches[key]).toBeDefined(); + registry.set(atom, AsyncResult.success(idle)); + expect(useGuideRunStore.getState().watches[key]).toBe(undefined); + stop(); +}); diff --git a/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx b/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx index 0f6d3d7d8e1b0..d9c05ae1a9917 100644 --- a/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx +++ b/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx @@ -1,10 +1,11 @@ -import { useNavigate } from "@tanstack/react-router"; +import type { AnyRouter } from "@tanstack/react-router"; +import type { PullRequestReviewGuideResult } from "@t3tools/contracts"; import { CircleAlertIcon, CircleCheckIcon } from "lucide-react"; -import { useEffect, useRef } from "react"; +import * as Option from "effect/Option"; +import { AsyncResult, type AtomRegistry } from "effect/reactivity"; import { getClientSettings } from "~/hooks/useSettings"; -import { pullRequestEnvironment } from "~/state/pullRequests"; -import { useEnvironmentQuery } from "~/state/query"; +import { observedPullRequestTitle, pullRequestEnvironment } from "~/state/pullRequests"; import { hasDesktopNotifications, hasNotificationSound, @@ -18,33 +19,58 @@ import { useGuideRunStore, type GuideRunWatch } from "./guideRunStore"; const RUN_POLL_MS = 5_000; const UNSEEN_RUN_MS = 60_000; -export function PullRequestGuideRunCoordinator() { - const watches = useGuideRunStore((state) => state.watches); - return Object.entries(watches).map(([key, watch]) => ( - - )); +// The detail panel marks the guide tab it has on screen. +function isGuideShown(runKey: string): boolean { + return document.querySelector(`[data-guide-shown="${CSS.escape(runKey)}"]`) !== null; } -function GuideRunWatcher({ runKey, watch }: { runKey: string; watch: GuideRunWatch }) { - const query = useEnvironmentQuery( - pullRequestEnvironment.reviewGuide({ - environmentId: watch.environmentId, - input: watch.reference, - }), - ); - const navigate = useNavigate(); - const refresh = query.refresh; - useEffect(() => { - const timer = setInterval(refresh, RUN_POLL_MS); - return () => clearInterval(timer); - }, [refresh]); +/** Polls every watched guide run and announces each one when it settles. App-lifetime. */ +export function startGuideRunNotifications( + registry: AtomRegistry.AtomRegistry, + router: AnyRouter, +): () => void { + const running = new Map void>(); + const sync = (watches: Readonly>) => { + for (const [key, stop] of running) { + if (watches[key] !== undefined) continue; + running.delete(key); + stop(); + } + for (const [key, watch] of Object.entries(watches)) { + if (running.has(key)) continue; + const stop = watchGuideRun(registry, router, key, watch); + // The first read can settle the run before it is recorded as running. + if (useGuideRunStore.getState().watches[key] === watch) running.set(key, stop); + else stop(); + } + }; + sync(useGuideRunStore.getState().watches); + const unsubscribe = useGuideRunStore.subscribe((state, previous) => { + if (state.watches !== previous.watches) sync(state.watches); + }); + return () => { + unsubscribe(); + for (const stop of running.values()) stop(); + running.clear(); + }; +} - const data = query.data; - const sawRunning = useRef(false); - useEffect(() => { - if (data === null) return; +function watchGuideRun( + registry: AtomRegistry.AtomRegistry, + router: AnyRouter, + runKey: string, + watch: GuideRunWatch, +): () => void { + const atom = pullRequestEnvironment.reviewGuide({ + environmentId: watch.environmentId, + input: watch.reference, + }); + let sawRunning = watch.answeredRunning === true; + let subscribing = true; + let seen: PullRequestReviewGuideResult | null = null; + const onGuide = (data: PullRequestReviewGuideResult) => { if (data.run?._tag === "Running") { - sawRunning.current = true; + sawRunning = true; return; } const failure = @@ -54,7 +80,7 @@ function GuideRunWatcher({ runKey, watch }: { runKey: string; watch: GuideRunWat const written = failure === null && data.guide !== null && data.guide.generatedAt !== watch.previousGuideAt; if (failure === null && !written) { - if (sawRunning.current || Date.now() - watch.since > UNSEEN_RUN_MS) { + if (sawRunning || Date.now() - watch.since > UNSEEN_RUN_MS) { useGuideRunStore.getState().settle(runKey); } return; @@ -62,13 +88,16 @@ function GuideRunWatcher({ runKey, watch }: { runKey: string; watch: GuideRunWat const store = useGuideRunStore.getState(); store.settle(runKey); const looking = document.visibilityState === "visible" && document.hasFocus(); - if (looking && store.shownKey === runKey) return; + if (looking && isGuideShown(runKey)) return; const title = failure === null ? "Review guide ready" : "Review guide failed"; - const body = watch.title ?? `${watch.reference.repository} #${watch.reference.number}`; + const body = + watch.title ?? + observedPullRequestTitle(registry, watch.environmentId, watch.reference) ?? + `${watch.reference.repository} #${watch.reference.number}`; const open = () => { - useGuideRunStore.getState().requestOpen(runKey); - void navigate({ + useGuideRunStore.getState().showPageDetailTab(runKey, "guide"); + void router.navigate({ to: "/pull-requests", search: { ...readPullRequestListPreferences(), @@ -126,7 +155,22 @@ function GuideRunWatcher({ runKey, watch }: { runKey: string; watch: GuideRunWat } catch { // Some browsers expose Notification but reject desktop presentation. } - }, [data, navigate, runKey, watch]); - - return null; + }; + const unsubscribe = registry.subscribe( + atom, + (result) => { + const data = Option.getOrNull(AsyncResult.value(result)); + if (data === null || data === seen) return; + seen = data; + if (subscribing && watch.answeredRunning === true) return; + onGuide(data); + }, + { immediate: true }, + ); + subscribing = false; + const timer = setInterval(() => registry.refresh(atom), RUN_POLL_MS); + return () => { + clearInterval(timer); + unsubscribe(); + }; } diff --git a/apps/web/src/components/pullRequest/guide/guideRunStore.ts b/apps/web/src/components/pullRequest/guide/guideRunStore.ts index 35ac7da7c11c7..693f194e9e560 100644 --- a/apps/web/src/components/pullRequest/guide/guideRunStore.ts +++ b/apps/web/src/components/pullRequest/guide/guideRunStore.ts @@ -1,6 +1,8 @@ import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; import { create } from "zustand"; +import type { PullRequestDetailTab } from "~/rightPanelStore"; + import { pullRequestReviewKey } from "../pullRequestReviewStore"; export interface GuideRunWatch { @@ -10,6 +12,11 @@ export interface GuideRunWatch { readonly previousGuideAt: string | null; readonly previousFailedAt: string | null; readonly since: number; + /** + * Registered from a server answer that reports the run as Running. The guide atom still holds + * the previous answer then, and its outcome belongs to an earlier run. + */ + readonly answeredRunning?: boolean; } export interface GuideProgress { @@ -21,14 +28,13 @@ const NO_PROGRESS: GuideProgress = { position: 0, seen: new Set() }; interface GuideRunStoreState { readonly watches: Readonly>; - readonly shownKey: string | null; - readonly openRequestKey: string | null; + /** The tab the pull requests page shows, for the pull request (run key) it was chosen on. */ + readonly pageDetailTab: { readonly key: string; readonly tab: PullRequestDetailTab } | null; readonly progress: Readonly>; readonly setProgress: (key: string, change: (current: GuideProgress) => GuideProgress) => void; readonly watch: (watch: GuideRunWatch) => void; readonly settle: (key: string) => void; - readonly setShown: (key: string | null) => void; - readonly requestOpen: (key: string | null) => void; + readonly showPageDetailTab: (key: string, tab: PullRequestDetailTab) => void; } export function guideRunKey(environmentId: EnvironmentId, reference: PullRequestRef): string { @@ -37,8 +43,7 @@ export function guideRunKey(environmentId: EnvironmentId, reference: PullRequest export const useGuideRunStore = create()((set) => ({ watches: {}, - shownKey: null, - openRequestKey: null, + pageDetailTab: null, progress: {}, setProgress: (key, change) => set((state) => ({ @@ -56,8 +61,7 @@ export const useGuideRunStore = create()((set) => ({ const { [key]: _settled, ...watches } = state.watches; return { watches }; }), - setShown: (shownKey) => set({ shownKey }), - requestOpen: (openRequestKey) => set({ openRequestKey }), + showPageDetailTab: (key, tab) => set({ pageDetailTab: { key, tab } }), })); export function guideProgressKey(runKey: string, headSha: string): string { diff --git a/apps/web/src/components/pullRequest/guide/useReviewGuideWriter.ts b/apps/web/src/components/pullRequest/guide/useReviewGuideWriter.ts index a56de1f1f3d2e..90d145834aae4 100644 --- a/apps/web/src/components/pullRequest/guide/useReviewGuideWriter.ts +++ b/apps/web/src/components/pullRequest/guide/useReviewGuideWriter.ts @@ -1,7 +1,7 @@ import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useEffectEvent, useState } from "react"; +import { useState } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; @@ -33,7 +33,10 @@ export function useReviewGuideWriter({ const data = query.data; const running = starting || data?.run?._tag === "Running"; - const watchRun = useEffectEvent(() => + // A run reported by the server is watched where its answer lands (state/pullRequests.ts). + const write = async () => { + if (running) return; + setStarting(true); useGuideRunStore.getState().watch({ environmentId, reference, @@ -41,16 +44,7 @@ export function useReviewGuideWriter({ previousGuideAt: data?.guide?.generatedAt ?? null, previousFailedAt: data?.run?._tag === "Failed" ? data.run.failedAt : null, since: Date.now(), - }), - ); - useEffect(() => { - if (data?.run?._tag === "Running") watchRun(); - }, [data?.run?._tag]); - - const write = async () => { - if (running) return; - setStarting(true); - watchRun(); + }); const result = await generate({ environmentId, input: reference }); setStarting(false); if (result._tag === "Failure") { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 0873dad15900f..9188aaaffc2e6 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -31,7 +31,6 @@ import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkC import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; -import { PullRequestGuideRunCoordinator } from "../components/pullRequest/guide/PullRequestGuideRunCoordinator"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; @@ -245,7 +244,6 @@ function RootRouteBranch() { - {primaryEnvironmentAuthenticated ? ( diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index bd92bd88e0568..1a5742a63df2d 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -37,6 +37,7 @@ import { } from "../components/pullRequest/pullRequestList.logic"; import { writePullRequestDetailSnapshot } from "../components/pullRequest/pullRequestDetail.logic"; import { pullRequestChecksState } from "../components/pullRequest/pullRequestPresentation"; +import { useGuideRunStore } from "../components/pullRequest/guide/guideRunStore"; import { formatEnvironmentQueryError } from "./query"; import { environmentThreadShells } from "./threads"; @@ -403,6 +404,20 @@ export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connecti }), ); }), + // Covers runs started elsewhere (another client, an agent) as well as this client's own. + reviewGuide: ({ environmentId, input }, guide) => + Effect.map(Clock.currentTimeMillis, (since) => { + if (guide.run?._tag !== "Running") return; + useGuideRunStore.getState().watch({ + environmentId, + reference: input, + title: null, + previousGuideAt: guide.guide?.generatedAt ?? null, + previousFailedAt: null, + since, + answeredRunning: true, + }); + }), }); export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily( @@ -467,6 +482,17 @@ const savedPullRequestSummaryAtom = Atom.family((key: string) => ), ); +export function observedPullRequestTitle( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + reference: PullRequestRef, +): string | null { + return ( + registry.get(pullRequestObservationsAtom(pullRequestSummaryKey(environmentId, reference))) + .metadata?.summary.title ?? null + ); +} + /** The newest of every list, detail, checks and linked-summary answer and the thread's saved link. */ export function useSharedPullRequestSummary( environmentId: EnvironmentId | null, diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 382f0ca47f653..94b509280934d 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -146,6 +146,7 @@ export interface PullRequestAnswerHandlers { readonly list?: QueryAnswerHandler; readonly detail?: QueryAnswerHandler; readonly checks?: QueryAnswerHandler; + readonly reviewGuide?: QueryAnswerHandler; } export function createPullRequestStackAtomFamily( @@ -251,6 +252,7 @@ export function createPullRequestEnvironmentAtoms( execute: (input) => routedRequest(WS_METHODS.pullRequestsReviewGuide, input), staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), + ...(onAnswer.reviewGuide === undefined ? {} : { onAnswer: onAnswer.reviewGuide }), }), ); const reviewerCandidates = writableQueryFamily(