Skip to content
Closed
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
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/RightPanelSurfaceContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ export function RightPanelSurfaceContent(props: {
onTabChange={(tab) => {
useRightPanelStore.getState().setPullRequestDetailTab(threadRef, surface.id, tab);
}}
revealUrl={surface.revealUrl}
onRevealSettled={(revealUrl) => {
useRightPanelStore.getState().settlePullRequestReveal(threadRef, surface.id, revealUrl);
}}
context={pullRequestPanelContext(
{
projectId: pullRequest.projectId,
Expand Down
93 changes: 91 additions & 2 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ import {
isStackedPullRequestBase,
pullRequestActionNeedsHostRefresh,
pullRequestCheckoutCommand,
pullRequestCommentLinkMayLoad,
pullRequestCommentLinkTarget,
pullRequestConversationItems,
pullRequestFindingKey,
pullRequestHandoffLabels,
PULL_REQUEST_MERGE_METHOD_LABELS,
Expand Down Expand Up @@ -273,6 +276,8 @@ export function PullRequestDetailPanel({
onGuideShown,
initialTab = "summary",
onTabChange,
revealUrl,
onRevealSettled,
}: {
environmentId: EnvironmentId;
shortcutsEnabled: boolean;
Expand All @@ -299,6 +304,10 @@ export function PullRequestDetailPanel({
onGuideShown?: (() => void) | undefined;
initialTab?: PullRequestDetailTab;
onTabChange?: ((tab: PullRequestDetailTab) => void) | undefined;
/** A link that may name one of this pull request's comments, waiting to be shown. */
revealUrl?: string | undefined;
/** Reports the link it was given shown or missing, so a newer link is left pending. */
onRevealSettled?: ((revealUrl: string) => void) | undefined;
}) {
const environmentConfigs = useServerConfigs();
const projects = useProjects();
Expand Down Expand Up @@ -356,6 +365,16 @@ export function PullRequestDetailPanel({
detail,
} = usePullRequestDetail(environmentId, reference, matchingListEntry);
const [selectedTab, setTab] = useState<PullRequestDetailTab>(initialTab);
const revealItems =
revealUrl === undefined || detail === null
? null
: pullRequestConversationItems(detail.comments, detail.reviewThreads);
const revealCommentId =
revealItems === null || revealUrl === undefined
? null
: (pullRequestCommentLinkTarget(revealItems, revealUrl)?.commentId ?? null);
// A comment link shows the conversation holding it.
if (revealCommentId !== null && !tabOnPage && selectedTab !== "summary") setTab("summary");
const reviewGuidesEnabled = useEnvironmentSettings(
environmentId,
(settings) => settings.reviewGuidesEnabled,
Expand Down Expand Up @@ -561,6 +580,15 @@ export function PullRequestDetailPanel({
: "Checking stack membership…";
const activityPending = activityQuery.isPending && activity === null;
const activityError = activity === null ? activityQuery.error : null;
// Loaded activity without the comment, keyed by load so a refetch is checked again.
const revealMissedAt =
revealUrl !== undefined &&
revealCommentId === null &&
detail !== null &&
!activityQuery.isPending
? activityQuery.dataUpdatedAt
: null;
const revealReadFailed = revealMissedAt !== null && activityQuery.error !== null;
const refreshDetail = () => {
detailQuery.refresh();
activityQuery.refresh();
Expand Down Expand Up @@ -601,13 +629,18 @@ export function PullRequestDetailPanel({
const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });
const [isInvalidating, setIsInvalidating] = useState(false);
const refreshing = isInvalidating || detailQuery.isPending;
const refreshFromHost = async () => {
/** Resolves whether the host cache was invalidated before the refetch. */
const refetchFromHost = async () => {
setIsInvalidating(true);
// Atom commands settle without throwing, so reset always runs.
await invalidate({ environmentId, input: { reference } });
const invalidated = await invalidate({ environmentId, input: { reference } });
refreshDetail();
setRefreshToken((token) => token + 1);
setIsInvalidating(false);
return invalidated._tag === "Success";
};
const refreshFromHost = async () => {
await refetchFromHost();
};
const refreshFromHostOnRequest = useEffectEvent(() => void refreshFromHost());
const appliedForcedToken = useRef(forcedRefreshToken);
Expand All @@ -617,6 +650,58 @@ export function PullRequestDetailPanel({
appliedForcedToken.current = forcedRefreshToken;
refreshFromHostOnRequest();
}, [forcedRefreshToken]);
// Cached activity can predate the comment, so a miss waits for one host read. `after` is when
// that read's invalidation finished; null while it runs.
const revealHostRead = useRef<{ readonly url: string; readonly after: number | null } | null>(
null,
);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a host read outliving the panel must not settle a later link
useEffect(
() => () => {
revealHostRead.current = null;
},
[],
);
const settleReveal = (url: string) => {
revealHostRead.current = null;
onRevealSettled?.(url);
};
const showRevealedComment = useEffectEvent(() => {
if (revealCommentId === null) {
if (revealUrl === undefined || revealItems === null) return;
const read = revealHostRead.current;
if (read?.url !== revealUrl) {
if (!pullRequestCommentLinkMayLoad(revealItems, revealUrl)) return settleReveal(revealUrl);
const request = { url: revealUrl, after: null };
revealHostRead.current = request;
void refetchFromHost().then((invalidated) => {
if (revealHostRead.current !== request) return;
// Without an invalidation the refetch could only repeat the cached read.
if (!invalidated) return settleReveal(request.url);
revealHostRead.current = { url: request.url, after: Date.now() };
});
return;
}
// A read that finished before the invalidation cannot show the comment is missing. `after` is
// stamped once the refetch was requested, so a read in that same millisecond is the new one.
if (read.after !== null && (revealReadFailed || (revealMissedAt ?? 0) >= read.after)) {
settleReveal(revealUrl);
}
return;
}
// A later click on the same link gets its own host read.
revealHostRead.current = null;
// selectTab's store writes; the local tab already switched during render.
if (tabOnPage) useGuideRunStore.getState().showPageDetailTab(guideKey, "summary");
onTabChange?.("summary");
});
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the tab and the pending link live in stores outside the panel
useEffect(() => {
if (revealCommentId !== null || revealMissedAt !== null || revealReadFailed) {
// oxlint-disable-next-line react/set-state-in-effect -- a missed comment starts a host read, which marks itself refreshing before it awaits
showRevealedComment();
}
}, [revealCommentId, revealMissedAt, revealReadFailed]);
const runAction = useAtomCommand(pullRequestEnvironment.runAction, {
reportFailure: false,
});
Expand Down Expand Up @@ -1751,6 +1836,10 @@ export function PullRequestDetailPanel({
reference={reference}
detail={detail}
activityPending={activityPending}
revealCommentId={tab === "summary" ? revealCommentId : null}
{...(onRevealSettled && revealUrl !== undefined
? { onCommentRevealed: () => onRevealSettled(revealUrl) }
: {})}
checksStale={checksStale}
activityError={activityError}
pendingFinding={handoff}
Expand Down
Loading
Loading