Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/appInit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -151,6 +152,7 @@ export function startAppServices(): AppRouter {
startLegacyThreadMigrationToast();
startPermissionUpdateNotices(router);
startThreadNotifications(appAtomRegistry, router);
startGuideRunNotifications(appAtomRegistry, router);
if (isElectron) startDesktopUpdateLaunchNotice();

return router;
Expand Down
72 changes: 39 additions & 33 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export function PullRequestDetailPanel({
reference: requestedReference,
listEntry = null,
refreshToken: forcedRefreshToken = 0,
tabOnPage = false,
onActed,
onClose,
context = "page",
Expand All @@ -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";
Expand Down Expand Up @@ -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<PullRequestDetailTab>(initialTab);
const {
canWriteSourceControl,
detailQuery,
activityQuery,
activity,
sharedSummary,
coreDetail,
detail,
} = usePullRequestDetail(environmentId, reference, matchingListEntry);
const [selectedTab, setTab] = useState<PullRequestDetailTab>(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");
Expand All @@ -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,
Expand Down Expand Up @@ -440,15 +461,6 @@ export function PullRequestDetailPanel({
};
const [handoff, setHandoff] = useState<string | null>(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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1741,7 +1743,11 @@ export function PullRequestDetailPanel({
</div>
) : null}
{mountedTabs.has("guide") ? (
<div className={cn("absolute inset-0", tab !== "guide" && "invisible")}>
<div
className={cn("absolute inset-0", tab !== "guide" && "invisible")}
// Read by the guide run notifier, which stays quiet for a guide on screen.
data-guide-shown={tab === "guide" ? guideKey : undefined}
>
<PullRequestGuideTab
environmentId={environmentId}
reference={reference}
Expand Down
11 changes: 0 additions & 11 deletions apps/web/src/components/pullRequest/PullRequestGuideTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { Alert, AlertAction, AlertDescription } from "../ui/alert";
import { Button } from "../ui/button";
import { Spinner } from "../ui/spinner";
import type { ExcerptCommenting } from "./guide/GuideExcerpt";
import { guideRunKey, useGuideRunStore } from "./guide/guideRunStore";
import { GuideProseContext } from "./guide/GuideProse";
import { GuideTour } from "./guide/GuideTour";
import { useReviewGuideWriter } from "./guide/useReviewGuideWriter";
Expand Down Expand Up @@ -53,7 +52,6 @@ export function PullRequestGuideTab({
reference,
title,
});
const runKey = guideRunKey(environmentId, reference);
const reviewKey = pullRequestReviewKey(reference);
const pending = usePendingReviewComments(reference);
const addComment = usePullRequestReviewStore((store) => store.addComment);
Expand All @@ -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?.());
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/pullRequest/PullRequestsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.AsyncResult<PullRequestReviewGuideResult, never>>(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();
});
Loading
Loading