diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx new file mode 100644 index 000000000000..c54b088f07d6 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx @@ -0,0 +1,119 @@ +import { EnvironmentId, ProjectId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../test/render"; +import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; +import { makeThreadFixture } from "../../test-fixtures"; +import { PopoverCreateHandle } from "../ui/popover"; +import { ChatCanvasContext } from "./ChatCanvasContext"; +import { resolveChatCanvasLayout } from "./chatCanvasLayout"; +import { ThreadDetailsPanel } from "./ThreadDetailsPanel"; + +const environmentId = EnvironmentId.make("environment-test"); +const threadId = ThreadId.make("thread-test"); + +const state = vi.hoisted(() => ({ pullRequests: [] as ThreadPullRequestLink[] })); + +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useQuietThreadShell: (() => + makeThreadFixture({ + environmentId, + id: threadId, + linkedPullRequest: null, + branchPullRequest: null, + pullRequests: state.pullRequests, + })) as never, + useServerConfigs: (() => + new Map([ + [environmentId, { environment: { capabilities: { threadPullRequests: true } } }], + ])) as never, +})); + +function link(repository: string, number: number, title: string): ThreadPullRequestLink { + return { + host: "github.com", + repository, + number, + url: `https://github.com/${repository}/pull/${number}`, + source: "created", + linkedAt: "2026-01-01T00:00:00Z", + stack: null, + snapshot: { + state: "open", + title, + headBranch: "fix", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-01-02T00:00:00Z", + }, + }; +} + +const container = { width: 1200, height: 800 }; +const placement = { x: 908, y: 0, right: 12, width: 280, height: 600 } as const; + +function Panel({ handle }: { handle: ReturnType }) { + return ( + + {}, + measureDetailsCard: () => {}, + detailsCardTopInset: 0, + preferredDetailsCard: placement, + detailsCardPlacement: placement, + }} + > + {}} + envMode="local" + startFromOrigin={false} + onStartFromOriginChange={() => {}} + onComposerFocusRequest={() => {}} + onRunProjectScript={() => {}} + onAddProjectScript={async () => ({ ok: true }) as never} + onUpdateProjectScript={async () => ({ ok: true }) as never} + onDeleteProjectScript={async () => ({ ok: true }) as never} + usage={{ context: null, usage: null } as never} + /> + + + ); +} + +it("lists PRs the agent opened in child repos when the project root is not a git repo", async () => { + state.pullRequests = [ + link("example/service-a", 200, "Batch identity calls"), + link("example/service-b", 31, "Fix export columns"), + ]; + await using screen = await render(); + + await expect.element(screen.getByText("#200: Batch identity calls")).toBeVisible(); + await screen.getByRole("button", { name: "Show 1 more" }).click(); + await expect.element(screen.getByText("#31: Fix export columns")).toBeVisible(); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index bb1546fd4b82..6af7ac4ed453 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -20,6 +20,10 @@ import { memo, useState, type ComponentProps } from "react"; import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; +import { ThreadDetailsLinkedPrRows } from "./ThreadDetailsPrRows"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; +import { useSupportsMultiplePullRequests } from "../../hooks/useSupportsMultiplePullRequests"; +import { useQuietThreadShell } from "../../state/entities"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; import { ThreadServersPanel } from "./ThreadServersPanel"; @@ -95,6 +99,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { props.activeProjectScripts && visible.scripts ? props.gitCwd : null, ); const activeThreadRef = { environmentId: props.environmentId, threadId: props.threadId }; + const linkedPullRequests = useQuietThreadShell(activeThreadRef)?.pullRequests ?? []; + const currentLinkedPullRequest = useSupportsMultiplePullRequests(props.environmentId) + ? resolveThreadCurrentPullRequestLink(linkedPullRequests) + : null; const branchToolbarProps = { showGitControls: props.isGitRepo, environmentId: props.environmentId, @@ -155,6 +163,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { openIn: density !== "essential" && visible.openIn && props.showOpenInPicker, scripts: visible.scripts && props.activeProjectScripts !== undefined, branch: props.gitCwd !== null && props.isGitRepo && visible.branch, + linkedPullRequests: + visible.branch && + !(props.gitCwd !== null && props.isGitRepo) && + currentLinkedPullRequest !== null, git: props.gitCwd !== null && props.activeProjectName !== undefined && @@ -213,7 +225,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { ) : null} - {props.gitCwd && (show.branch || show.git) ? ( + {(props.gitCwd && (show.branch || show.git)) || show.linkedPullRequests ? (
+ {show.linkedPullRequests && currentLinkedPullRequest ? ( + + ) : null} {show.branch ? ( ) : null} - {show.git ? ( + {show.git && props.gitCwd ? ( & { - threadRef: ScopedThreadRef; - links: ReadonlyArray; - currentLink: ThreadPullRequestLink | null; - onOpenLink: (event: ReactMouseEvent, url: string) => void; -}) { - const [expanded, setExpanded] = useState(false); +function useStopWatchingPullRequest(threadRef: ScopedThreadRef) { const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true }); - const stopWatching = (link: ThreadPullRequestLink | null) => + return (link: ThreadPullRequestLink | null) => link?.watch === undefined ? undefined : () => @@ -90,7 +84,26 @@ export function ThreadDetailsPrRows({ watching: false, }, }); - const currentRow = ; +} + +function PrRowsWithMore({ + environmentId, + links, + currentLink, + currentRow, + onOpenLink, + onActed, + stopWatching, +}: { + environmentId: EnvironmentId; + links: ReadonlyArray; + currentLink: ThreadPullRequestLink | null; + currentRow: ReactNode; + onOpenLink: (event: ReactMouseEvent, url: string) => void; + onActed?: (() => void) | undefined; + stopWatching: ReturnType; +}) { + const [expanded, setExpanded] = useState(false); const rest = currentLink === null ? [] @@ -107,10 +120,10 @@ export function ThreadDetailsPrRows({ {rest.map((link) => ( onOpenLink(event, link.url)} - onActed={row.onActed} + onActed={onActed} onStopWatching={stopWatching(link)} /> ))} @@ -135,3 +148,60 @@ export function ThreadDetailsPrRows({ ); } + +export function ThreadDetailsPrRows({ + threadRef, + links, + currentLink, + onOpenLink, + ...row +}: ComponentProps & { + threadRef: ScopedThreadRef; + links: ReadonlyArray; + currentLink: ThreadPullRequestLink | null; + onOpenLink: (event: ReactMouseEvent, url: string) => void; +}) { + const stopWatching = useStopWatchingPullRequest(threadRef); + return ( + } + onOpenLink={onOpenLink} + onActed={row.onActed} + stopWatching={stopWatching} + /> + ); +} + +/** The thread's linked pull requests for a workspace with no branch to hang them on. */ +export function ThreadDetailsLinkedPrRows({ + threadRef, + links, + currentLink, +}: { + threadRef: ScopedThreadRef; + links: ReadonlyArray; + currentLink: ThreadPullRequestLink; +}) { + const stopWatching = useStopWatchingPullRequest(threadRef); + const openPrLink = useOpenPrLink(threadRef); + return ( + openPrLink(event, currentLink.url)} + onStopWatching={stopWatching(currentLink)} + /> + } + onOpenLink={openPrLink} + stopWatching={stopWatching} + /> + ); +}