Skip to content
1 change: 1 addition & 0 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,7 @@ export function BranchToolbarBranchSelector({
{displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (
<ThreadDetailsPrRows
threadRef={threadRef}
composerTarget={draftId ?? threadRef}
links={serverThread?.pullRequests ?? []}
currentLink={currentLinkedPr}
onOpenLink={openPrLink}
Expand Down
57 changes: 56 additions & 1 deletion apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EnvironmentId, ProjectId, ThreadId, type PullRequestCheck } from "@t3tools/contracts";
import { DraftId } from "~/composerDraftStore";
import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { AsyncResult } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";

Expand Down Expand Up @@ -120,6 +120,7 @@ it("merges with the last selected merge method", async () => {
label="Test PR"
openAriaLabel="Open PR"
onOpen={vi.fn()}
composerTarget={DraftId.make("draft")}
/>,
);
});
Expand All @@ -136,6 +137,59 @@ it("merges with the last selected merge method", async () => {
expect(state.perform).toHaveBeenCalledWith("merge", "squash");
});

it("adds fixes from the row and checks popover to this thread's composer without a new thread", async () => {
await using screen = await setup();
state.status = "failure";
const composerTarget = {
environmentId: EnvironmentId.make("environment"),
threadId: ThreadId.make("thread"),
};
await act(
async () =>
await screen.rerender(
<ThreadDetailsPrRow
environmentId={EnvironmentId.make("environment")}
pr={null}
number={1}
reference={{ repository: "owner/repo", number: 1 }}
status={null}
project={{ id: ProjectId.make("project") } as never}
label="Test PR"
openAriaLabel="Open PR"
onOpen={vi.fn()}
composerTarget={composerTarget}
/>,
),
);
const insertAtCaret = vi.fn(() => true);
const removeHandler = useComposerDraftStore
.getState()
.setContextInsertionHandler(composerTarget, insertAtCaret);
using _handler = { [Symbol.dispose]: () => removeHandler?.() };
await act(async () =>
[...screen.container.querySelectorAll("button")]
.find((button) => button.textContent?.trim() === "Fix")!
.click(),
);
await act(async () =>
screen.container.querySelector<HTMLElement>('[aria-label^="Open checks"]')!.click(),
);
await act(async () =>
document.querySelector<HTMLElement>('[aria-label="Fix CI-failure"]')!.click(),
);

const draft = useComposerDraftStore.getState().getComposerDraft(composerTarget);
const comments = draft?.reviewComments;
expect(comments?.map((comment) => comment.filePath)).toEqual([
"Failing checks on #1",
"CI-failure",
"PR #1",
]);
expect(insertAtCaret).not.toHaveBeenCalled();
expect(draft?.prompt).toContain("Failing checks on #1");
expect(state.newThread).not.toHaveBeenCalled();
});

it("requires a new merge click after passing checks become pending and pass again", async () => {
await using screen = await setup();
const { container } = screen;
Expand All @@ -149,6 +203,7 @@ it("requires a new merge click after passing checks become pending and pass agai
label="Test PR"
openAriaLabel="Open PR"
onOpen={vi.fn()}
composerTarget={DraftId.make("draft")}
/>
);
const clickMerge = async () =>
Expand Down
73 changes: 49 additions & 24 deletions apps/web/src/components/chat/ThreadDetailsPrRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,26 @@ import { ThreadDetailsControl } from "./ThreadDetailsControl";
import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
import { selectEnvironmentQueryData } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts";
import type {
EnvironmentId,
ProjectId,
PullRequestCheck,
PullRequestRef,
ScopedThreadRef,
} from "@t3tools/contracts";
import {
ArrowUpRightIcon,
EyeIcon,
EyeOffIcon,
FileDiffIcon,
GitBranchIcon,
HammerIcon,
TriangleAlertIcon,
} from "lucide-react";
import { AsyncResult } from "effect/reactivity";
import { useState, type MouseEvent as ReactMouseEvent } from "react";

import type { DraftId } from "~/composerDraftStore";
import { useLiveRefresh } from "~/hooks/useLiveRefresh";
import { usePullRequestChecksRefresh } from "~/hooks/usePullRequestChecksRefresh";
import { cn } from "~/lib/utils";
Expand All @@ -33,14 +41,16 @@ import {
} from "~/state/query";

import {
buildFixFindingsHandoff,
buildFixChecksHandoff,
buildFixFindingHandoff,
buildResolveConflictsPrompt,
classifyPullRequestChecks,
describePullRequestChecks,
isPullRequestConflicting,
resolvePullRequestMergeMethod,
allowedPullRequestMergeMethods,
resolveThreadPanelPullRequestAction,
type PullRequestFinding,
} from "../pullRequest/pullRequestDetail.logic";
import { PullRequestChecksPopover } from "../pullRequest/PullRequestChecksPopover";
import {
Expand All @@ -53,6 +63,8 @@ import {
usePullRequestActionRunner,
usePullRequestDefaultMergeMethodResolver,
usePullRequestHandoffs,
writeHandoffToComposer,
type PullRequestThreadTask,
} from "../pullRequest/usePullRequestActions";
import {
ChangeRequestStatusIcon,
Expand All @@ -69,6 +81,7 @@ import {
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Button } from "../ui/button";
import { toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import {
THREAD_DETAILS_PANEL_ICON_CLASS,
Expand All @@ -93,6 +106,8 @@ export function ThreadDetailsPrRow({
onOpen,
onActed,
onStopWatching,
composerTarget,
threadRef = null,
}: {
environmentId: EnvironmentId;
pr: ThreadPr;
Expand All @@ -105,6 +120,8 @@ export function ThreadDetailsPrRow({
onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
onActed?: () => void;
onStopWatching?: (() => void) | undefined;
composerTarget: ScopedThreadRef | DraftId;
threadRef?: ScopedThreadRef | null;
}) {
const serverConfigs = useServerConfigs();
const supportsPullRequests = serverConfigs.get(environmentId) !== undefined;
Expand Down Expand Up @@ -201,25 +218,29 @@ export function ThreadDetailsPrRow({
});
};

const startFixChecks = () => {
const addToComposer = (task: PullRequestThreadTask) => {
writeHandoffToComposer(composerTarget, task);
toastManager.add({
type: "success",
title: "Added to the composer",
description: "The task is in the composer. Read it over, then send.",
});
};
const fixAllChecks = () => {
if (detail === null) return;
void startHandoff(
"findings",
buildFixFindingsHandoff({
number: detail.number,
title: detail.title,
url: detail.url,
headBranch: detail.headBranch,
baseBranch: detail.baseBranch,
state: detail.state,
isDraft: detail.isDraft,
reviewThreads: [],
comments: [],
checks: detail.checks,
commentsTruncated: false,
}),
);
addToComposer(buildFixChecksHandoff(detail));
};
const checksFix =
detail !== null && open
? {
onFixCheck: (check: PullRequestCheck) => {
const finding: PullRequestFinding = { kind: "check", check };
addToComposer(buildFixFindingHandoff({ ...detail, finding }));
},
onFixAll: fixAllChecks,
pending: null,
}
: undefined;

const statePresentation =
detail === null
Expand Down Expand Up @@ -342,12 +363,13 @@ export function ThreadDetailsPrRow({
: rowAction === "fix"
? {
label: "Fix",
pendingLabel: "Preparing...",
pending: handoff === "findings",
pendingLabel: "Fix",
pending: false,
destructive: true,
suffix: <ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />,
tooltip: "Fix the failing checks in a new thread",
onClick: startFixChecks,
icon: <HammerIcon aria-hidden className="size-3 shrink-0" />,
suffix: null,
tooltip: "Add the failing checks to the composer",
onClick: fixAllChecks,
}
: rowAction === "merge"
? {
Expand Down Expand Up @@ -445,6 +467,8 @@ export function ThreadDetailsPrRow({
<PullRequestChecksPopover
checksState={checksRollup}
checks={detail.checks}
threadRef={threadRef}
fix={checksFix}
variant="count"
render={<ThreadDetailsControl part="meta" />}
/>
Expand All @@ -466,6 +490,7 @@ export function ThreadDetailsPrRow({
/>
}
>
{trailingAction.icon}
{trailingAction.pending ? trailingAction.pendingLabel : trailingAction.label}
{trailingAction.suffix}
</TooltipTrigger>
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/components/chat/ThreadDetailsPrRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ function ThreadDetailsPrLinkRow({
onOpen,
onActed,
onStopWatching,
composerTarget,
threadRef,
}: {
environmentId: EnvironmentId;
link: ThreadPullRequestLink;
onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
onActed?: (() => void) | undefined;
onStopWatching?: (() => void) | undefined;
composerTarget: ComponentProps<typeof ThreadDetailsPrRow>["composerTarget"];
threadRef: ScopedThreadRef;
}) {
const projects = useProjects();
const parsed = sourceControlClients.parseChangeRequestUrl(link.url)?.link;
Expand All @@ -58,6 +62,8 @@ function ThreadDetailsPrLinkRow({
openAriaLabel={link.url}
onOpen={onOpen}
onStopWatching={onStopWatching}
composerTarget={composerTarget}
threadRef={threadRef}
{...(onActed ? { onActed } : {})}
/>
);
Expand Down Expand Up @@ -91,7 +97,9 @@ export function ThreadDetailsPrRows({
watching: false,
},
});
const currentRow = <ThreadDetailsPrRow {...row} onStopWatching={stopWatching(currentLink)} />;
const currentRow = (
<ThreadDetailsPrRow {...row} threadRef={threadRef} onStopWatching={stopWatching(currentLink)} />
);
const rest =
currentLink === null
? []
Expand All @@ -113,6 +121,8 @@ export function ThreadDetailsPrRows({
onOpen={(event) => onOpenLink(event, link.url)}
onActed={row.onActed}
onStopWatching={stopWatching(link)}
composerTarget={row.composerTarget}
threadRef={threadRef}
/>
))}
</div>
Expand Down
29 changes: 25 additions & 4 deletions apps/web/src/components/chat/UserMessageContextChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
import ChatMarkdown from "../ChatMarkdown";
import { EnvironmentContextChip } from "../EnvironmentContextChip";
import { ThreadContextChip } from "../ThreadContextChip";
import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon } from "lucide-react";
import {
ListChecksIcon,
MessageCircleIcon,
MousePointerClickIcon,
PaintbrushIcon,
} from "lucide-react";
import type { ComposerContextId, KnownComposerContextRecord } from "@t3tools/contracts";
import { buildExpandedImagePreview } from "./ExpandedImagePreview";
import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon";
Expand All @@ -39,6 +44,7 @@ import {
UnresolvedChip,
} from "../contextChipParts";
import {
isPullRequestCheckContext,
isPullRequestSummaryContext,
pullRequestContextDisplayState,
pullRequestContextKindLabel,
Expand Down Expand Up @@ -446,7 +452,12 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
}
const isPullRequest = isPullRequestSummaryContext(record);
const label = reviewCommentContextLabel(record);
const kindLabel = isPullRequest ? pullRequestContextKindLabel(record) : "Review comment";
const isCheck = isPullRequestCheckContext(record);
const kindLabel = isPullRequest
? pullRequestContextKindLabel(record)
: isCheck
? "Check"
: "Review comment";
const pullRequestState = pullRequestContextDisplayState(record) ?? "unknown";
if (isPullRequest && record.pullRequest !== undefined) {
return (
Expand All @@ -462,7 +473,15 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
copyMarkdown={context.copyMarkdown}
accessibleLabel={`${kindLabel}, ${label}${record.pullRequest ? `, ${record.pullRequest.title}` : ""}`}
kind={isPullRequest ? PULL_REQUEST_CHIP_KINDS[pullRequestState] : "review-comment"}
icon={isPullRequest ? <PullRequestGlyph.pullRequest /> : <MessageCircleIcon />}
icon={
isPullRequest ? (
<PullRequestGlyph.pullRequest />
) : isCheck ? (
<ListChecksIcon />
) : (
<MessageCircleIcon />
)
}
label={label}
>
<UserMessageReviewCommentCard
Expand Down Expand Up @@ -549,7 +568,9 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
<div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3">
<div className="space-y-1">
<div className="text-xs font-medium text-foreground">
{formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
{isPullRequestCheckContext(comment)
? comment.filePath
: formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
</div>
<div className="text-2xs text-muted-foreground">
{comment.sectionTitle} · {comment.rangeLabel}
Expand Down
Loading
Loading