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
5 changes: 4 additions & 1 deletion apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { readLocalApi } from "../localApi";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { ComposerContextLabel } from "./ComposerContextLabel";
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
import type { EnvironmentId, ProjectId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
import { FolderGit2Icon, FolderGitIcon, FolderIcon, PlusIcon, ScaleIcon } from "lucide-react";
import {
type Ref,
Expand Down Expand Up @@ -86,6 +86,7 @@ interface BranchToolbarProps {
isServerThread: boolean;
showGitControls: boolean;
draftId?: DraftId;
composerDraftTarget?: ScopedThreadRef | DraftId | null | undefined;
onEnvModeChange: (mode: EnvMode) => void;
envMode: EnvMode;
activeThreadBranchOverride?: string | null;
Expand Down Expand Up @@ -604,6 +605,7 @@ export const BranchToolbar = memo(function BranchToolbar({
isServerThread,
showGitControls,
draftId,
composerDraftTarget,
onEnvModeChange,
envMode,
activeThreadBranchOverride,
Expand Down Expand Up @@ -722,6 +724,7 @@ export const BranchToolbar = memo(function BranchToolbar({
environmentId={environmentId}
threadId={threadId}
{...(draftId ? { draftId } : {})}
composerDraftTarget={composerDraftTarget}
envLocked={envLocked}
effectiveEnvModeOverride={effectiveEnvMode}
{...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
AuthOrchestrationOperateScope,
AuthSourceControlWriteScope,
type ContextMenuItem,
type ScopedThreadRef,
type EnvironmentId,
type VcsRef,
type ThreadId,
Expand Down Expand Up @@ -80,6 +81,7 @@ interface BranchToolbarBranchSelectorProps {
environmentId: EnvironmentId;
threadId: ThreadId;
draftId?: DraftId;
composerDraftTarget?: ScopedThreadRef | DraftId | null | undefined;
envLocked: boolean;
effectiveEnvModeOverride?: "local" | "worktree";
activeThreadBranchOverride?: string | null;
Expand All @@ -102,6 +104,7 @@ export function BranchToolbarBranchSelector({
environmentId,
threadId,
draftId,
composerDraftTarget,
envLocked,
effectiveEnvModeOverride,
activeThreadBranchOverride,
Expand Down Expand Up @@ -751,6 +754,7 @@ export function BranchToolbarBranchSelector({
openAriaLabel={prUrl ?? `Open ${sourceControlPresentation.terminology.singular}`}
onOpen={(event) => openPrLink(event, prUrl)}
onActed={() => branchStatusQuery.refresh()}
composerDraftTarget={composerDraftTarget}
/>
) : null}
</div>
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3827,6 +3827,14 @@ export default function ChatView(props: ChatViewProps) {
worktreePath={activeThread.worktreePath}
isServerThread={isServerThread}
{...(draftId ? { draftId } : {})}
// Without a composer, handoffs fall back to opening a new thread.
composerDraftTarget={
!composerMounted
? undefined
: queuedRunEdit.isEditing
? null
: composerDraftTarget
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
activeProjectName={activeProject?.title}
activeProjectScripts={activeProject ? activeProjectScripts : undefined}
preferredScriptId={
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/ThreadDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ProjectId,
ProjectScript,
ResolvedKeybindingsConfig,
ScopedThreadRef,
ThreadId,
} from "@t3tools/contracts";

Expand Down Expand Up @@ -53,6 +54,7 @@ interface ThreadDetailsPanelProps extends Pick<
worktreePath: string | null;
isServerThread: boolean;
draftId?: DraftId;
composerDraftTarget: ScopedThreadRef | DraftId | null | undefined;
activeProjectName: string | undefined;
activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;
preferredScriptId: string | null;
Expand Down Expand Up @@ -102,6 +104,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
worktreePath: props.worktreePath,
isServerThread: props.isServerThread,
...(props.draftId ? { draftId: props.draftId } : {}),
composerDraftTarget: props.composerDraftTarget,
onEnvModeChange: props.onEnvModeChange,
startFromOrigin: props.startFromOrigin,
onStartFromOriginChange: props.onStartFromOriginChange,
Expand Down
87 changes: 85 additions & 2 deletions apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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";

const state = vi.hoisted(() => ({
status: "success" as PullRequestCheck["status"],
extraStatus: null as PullRequestCheck["status"] | null,
mergeability: "mergeable" as "mergeable" | "conflicting",
mergeMethods: ["merge"] as Array<"merge" | "squash" | "rebase">,
perform: vi.fn(),
newThread: vi.fn(),
Expand Down Expand Up @@ -34,7 +35,7 @@ const queryData = () => ({
title: "Test PR",
state: "open",
isDraft: false,
mergeability: "mergeable",
mergeability: state.mergeability,
headBranch: "feature",
baseBranch: "main",
changedFiles: 1,
Expand Down Expand Up @@ -177,6 +178,87 @@ it("requires a new merge click after passing checks become pending and pass agai
expect(state.perform).not.toHaveBeenCalled();
});

it("adds the failing checks to the current composer instead of opening a thread", async () => {
await using screen = await setup();
const draftId = DraftId.make("current-draft");
await act(async () => {
state.status = "failure";
await screen.rerender(
<ThreadDetailsPrRow
environmentId={EnvironmentId.make("environment")}
pr={null}
number={1}
status={null}
project={null}
label="Test PR"
openAriaLabel="Open PR"
onOpen={vi.fn()}
composerDraftTarget={null}
/>,
);
});
const blockedFix = [...screen.container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Fix",
)!;
expect(blockedFix.getAttribute("aria-disabled")).toBe("true");
await act(async () => blockedFix.click());
expect(state.newThread).not.toHaveBeenCalled();
await act(async () => {
state.status = "failure";
await screen.rerender(
<ThreadDetailsPrRow
environmentId={EnvironmentId.make("environment")}
pr={null}
number={1}
status={null}
project={null}
label="Test PR"
openAriaLabel="Open PR"
onOpen={vi.fn()}
composerDraftTarget={draftId}
/>,
);
});
await act(async () => {
[...screen.container.querySelectorAll("button")]
.find((button) => button.textContent?.trim() === "Fix")!
.click();
});
const fixDraft = useComposerDraftStore.getState().getComposerDraft(draftId);
expect(fixDraft?.prompt).toContain("Fix the actionable findings attached");
expect(fixDraft?.prompt).not.toContain("> CI-failure");
expect(fixDraft?.reviewComments.map((comment) => comment.rangeLabel)).toEqual([
"Test PR",
"CI-failure",
]);
await act(async () => {
state.mergeability = "conflicting";
await screen.rerender(
<ThreadDetailsPrRow
environmentId={EnvironmentId.make("environment")}
pr={null}
number={1}
status={null}
project={null}
label="Test PR"
openAriaLabel="Open PR"
onOpen={vi.fn()}
composerDraftTarget={draftId}
/>,
);
});
await act(async () => {
[...screen.container.querySelectorAll("button")]
.find((button) => button.textContent?.trim() === "Resolve")!
.click();
});
expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.prompt).toContain(
"Resolve the conflicts between the attached pull request",
);
expect(state.newThread).not.toHaveBeenCalled();
expect(state.prepareThread).not.toHaveBeenCalled();
});

async function setup() {
state.perform.mockReset();
state.checkoutError = null;
Expand All @@ -190,6 +272,7 @@ async function setup() {

state.status = "success";
state.extraStatus = null;
state.mergeability = "mergeable";
state.mergeMethods = ["merge"];
useUiStateStore.getState().setPullRequestMergeMethod("merge");
return renderUi(null);
Expand Down
48 changes: 32 additions & 16 deletions apps/web/src/components/chat/ThreadDetailsPrRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts";
import {
ArrowUpRightIcon,
EyeIcon,
Expand All @@ -20,6 +20,7 @@ import {
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 @@ -34,7 +35,7 @@ import {

import {
buildFixFindingsHandoff,
buildResolveConflictsPrompt,
buildResolveConflictsHandoff,
classifyPullRequestChecks,
describePullRequestChecks,
isPullRequestConflicting,
Expand Down Expand Up @@ -93,6 +94,7 @@ export function ThreadDetailsPrRow({
onOpen,
onActed,
onStopWatching,
composerDraftTarget,
}: {
environmentId: EnvironmentId;
pr: ThreadPr;
Expand All @@ -105,6 +107,7 @@ export function ThreadDetailsPrRow({
onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
onActed?: () => void;
onStopWatching?: (() => void) | undefined;
composerDraftTarget?: ScopedThreadRef | DraftId | null | undefined;
}) {
const serverConfigs = useServerConfigs();
const supportsPullRequests = serverConfigs.get(environmentId) !== undefined;
Expand Down Expand Up @@ -167,7 +170,15 @@ export function ThreadDetailsPrRow({
onActed?.();
},
});
const { handoff, startHandoff } = usePullRequestHandoffs({ environmentId, detail });
const { handoff, startHandoff } = usePullRequestHandoffs({
environmentId,
detail,
composerDraftTarget,
});
const handoffSuffix =
composerDraftTarget === undefined ? (
<ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />
) : null;
const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver(
environmentId,
Expand All @@ -191,14 +202,7 @@ export function ThreadDetailsPrRow({

const startResolveConflicts = () => {
if (detail === null) return;
void startHandoff("conflicts", {
prompt: buildResolveConflictsPrompt({
number: detail.number,
url: detail.url,
headBranch: detail.headBranch,
baseBranch: detail.baseBranch,
}),
});
void startHandoff("conflicts", buildResolveConflictsHandoff(detail));
};

const startFixChecks = () => {
Expand Down Expand Up @@ -325,8 +329,11 @@ export function ThreadDetailsPrRow({
pendingLabel: "Preparing...",
pending: handoff === "conflicts",
destructive: true,
suffix: <ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />,
tooltip: "Check the branch out and resolve the conflicts in a new thread",
suffix: handoffSuffix,
tooltip:
composerDraftTarget === undefined
? "Check the branch out and resolve the conflicts in a new thread"
: "Resolve the conflicts in this thread",
onClick: startResolveConflicts,
}
: rowAction === "ready"
Expand All @@ -345,8 +352,8 @@ export function ThreadDetailsPrRow({
pendingLabel: "Preparing...",
pending: handoff === "findings",
destructive: true,
suffix: <ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />,
tooltip: "Fix the failing checks in a new thread",
suffix: handoffSuffix,
tooltip: `Fix the failing checks in ${composerDraftTarget === undefined ? "a new" : "this"} thread`,
onClick: startFixChecks,
}
: rowAction === "merge"
Expand All @@ -361,6 +368,9 @@ export function ThreadDetailsPrRow({
}
: null;

const handoffBlocked =
composerDraftTarget === null && (rowAction === "fix" || rowAction === "resolve");

const watching =
onStopWatching !== undefined && (detail?.state ?? pr?.state ?? "open") === "open";
const watchControl = (part: "secondary" | "icon") =>
Expand Down Expand Up @@ -462,14 +472,20 @@ export function ThreadDetailsPrRow({
part="meta"
tone={trailingAction.destructive ? "destructive" : "primary"}
disabled={actionPending || handoff !== null}
// Disabled buttons take no pointer or focus, which would hide the reason.
aria-disabled={handoffBlocked || undefined}
onClick={trailingAction.onClick}
/>
}
>
{trailingAction.pending ? trailingAction.pendingLabel : trailingAction.label}
{trailingAction.suffix}
</TooltipTrigger>
<TooltipPopup side="top">{trailingAction.tooltip}</TooltipPopup>
<TooltipPopup side="top">
{handoffBlocked
? "Finish editing the queued message first"
: trailingAction.tooltip}
</TooltipPopup>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</Tooltip>
) : null}
</div>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/ThreadDetailsPrRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ function ThreadDetailsPrLinkRow({
onOpen,
onActed,
onStopWatching,
composerDraftTarget,
}: {
environmentId: EnvironmentId;
link: ThreadPullRequestLink;
onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
onActed?: (() => void) | undefined;
onStopWatching?: (() => void) | undefined;
composerDraftTarget?: ComponentProps<typeof ThreadDetailsPrRow>["composerDraftTarget"];
}) {
const projects = useProjects();
const parsed = sourceControlClients.parseChangeRequestUrl(link.url)?.link;
Expand All @@ -58,6 +60,7 @@ function ThreadDetailsPrLinkRow({
openAriaLabel={link.url}
onOpen={onOpen}
onStopWatching={onStopWatching}
composerDraftTarget={composerDraftTarget}
{...(onActed ? { onActed } : {})}
/>
);
Expand Down Expand Up @@ -113,6 +116,7 @@ export function ThreadDetailsPrRows({
onOpen={(event) => onOpenLink(event, link.url)}
onActed={row.onActed}
onStopWatching={stopWatching(link)}
composerDraftTarget={row.composerDraftTarget}
/>
))}
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/UserMessageContextChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry
kind={isPullRequest ? PULL_REQUEST_CHIP_KINDS[pullRequestState] : "review-comment"}
icon={isPullRequest ? <PullRequestGlyph.pullRequest /> : <MessageCircleIcon />}
label={label}
openOnHover
>
<UserMessageReviewCommentCard
comment={{
Expand Down
Loading
Loading