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
119 changes: 119 additions & 0 deletions apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof PopoverCreateHandle> }) {
return (
<AppAtomRegistryProvider>
<ChatCanvasContext
value={{
container,
lane: { padding: 48, minChatWidth: 640 },
layout: resolveChatCanvasLayout({ container, preview: null }),
previewKey: null,
registerTimeline: () => {},
measureDetailsCard: () => {},
detailsCardTopInset: 0,
preferredDetailsCard: placement,
detailsCardPlacement: placement,
}}
>
<ThreadDetailsPanel
anchor={{ current: null }}
handle={handle}
environmentId={environmentId}
threadId={threadId}
projectId={ProjectId.make("project-test")}
worktreePath={null}
isServerThread
activeProjectName="multi-repo-folder"
activeProjectScripts={undefined}
preferredScriptId={null}
keybindings={[]}
availableEditors={[]}
showOpenInPicker={false}
openInShortcutEnabled={false}
gitCwd="/workspace/multi-repo-folder"
workspaceMutationId={null}
isGitRepo={false}
envLocked
availableEnvironments={[]}
onEnvModeChange={() => {}}
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}
/>
</ChatCanvasContext>
</AppAtomRegistryProvider>
);
}

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(<Panel handle={PopoverCreateHandle()} />);

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();
});
23 changes: 21 additions & 2 deletions apps/web/src/components/chat/ThreadDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -213,18 +225,25 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
</ThreadDetailsSection>
) : null}

{props.gitCwd && (show.branch || show.git) ? (
{(props.gitCwd && (show.branch || show.git)) || show.linkedPullRequests ? (
<ThreadDetailsSection
headingId="thread-details-version-control-heading"
title="Version Control"
showHeading={false}
separated={density === "full"}
>
<div className="flex flex-col">
{show.linkedPullRequests && currentLinkedPullRequest ? (
<ThreadDetailsLinkedPrRows
threadRef={activeThreadRef}
links={linkedPullRequests}
currentLink={currentLinkedPullRequest}
/>
) : null}
{show.branch ? (
<BranchToolbar layout="panel" panelSection="branch" {...branchToolbarProps} />
) : null}
{show.git ? (
{show.git && props.gitCwd ? (
<GitActionsControl
compact={density !== "full"}
showActions={visible.gitActions}
Expand Down
106 changes: 88 additions & 18 deletions apps/web/src/components/chat/ThreadDetailsPrRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@ import {
visibleThreadPullRequests,
} from "@t3tools/client-runtime/thread-pull-requests";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } from "react";
import {
useState,
type ComponentProps,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";

import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import { findProjectOnChangeRequestHost } from "@t3tools/client-runtime/change-request-links";

import { useOpenPrLink } from "~/lib/openPullRequestLink";
import { useProjects } from "~/state/entities";
import { threadEnvironment } from "~/state/threads";
import { useAtomCommand } from "~/state/use-atom-command";
Expand Down Expand Up @@ -62,21 +68,9 @@ function ThreadDetailsPrLinkRow({
);
}

export function ThreadDetailsPrRows({
threadRef,
links,
currentLink,
onOpenLink,
...row
}: ComponentProps<typeof ThreadDetailsPrRow> & {
threadRef: ScopedThreadRef;
links: ReadonlyArray<ThreadPullRequestLink>;
currentLink: ThreadPullRequestLink | null;
onOpenLink: (event: ReactMouseEvent<HTMLElement>, 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
: () =>
Expand All @@ -90,7 +84,26 @@ export function ThreadDetailsPrRows({
watching: false,
},
});
const currentRow = <ThreadDetailsPrRow {...row} onStopWatching={stopWatching(currentLink)} />;
}

function PrRowsWithMore({
environmentId,
links,
currentLink,
currentRow,
onOpenLink,
onActed,
stopWatching,
}: {
environmentId: EnvironmentId;
links: ReadonlyArray<ThreadPullRequestLink>;
currentLink: ThreadPullRequestLink | null;
currentRow: ReactNode;
onOpenLink: (event: ReactMouseEvent<HTMLElement>, url: string) => void;
onActed?: (() => void) | undefined;
stopWatching: ReturnType<typeof useStopWatchingPullRequest>;
}) {
const [expanded, setExpanded] = useState(false);
const rest =
currentLink === null
? []
Expand All @@ -107,10 +120,10 @@ export function ThreadDetailsPrRows({
{rest.map((link) => (
<ThreadDetailsPrLinkRow
key={threadPullRequestKeyOf(link)}
environmentId={row.environmentId}
environmentId={environmentId}
link={link}
onOpen={(event) => onOpenLink(event, link.url)}
onActed={row.onActed}
onActed={onActed}
onStopWatching={stopWatching(link)}
/>
))}
Expand All @@ -135,3 +148,60 @@ export function ThreadDetailsPrRows({
</>
);
}

export function ThreadDetailsPrRows({
threadRef,
links,
currentLink,
onOpenLink,
...row
}: ComponentProps<typeof ThreadDetailsPrRow> & {
threadRef: ScopedThreadRef;
links: ReadonlyArray<ThreadPullRequestLink>;
currentLink: ThreadPullRequestLink | null;
onOpenLink: (event: ReactMouseEvent<HTMLElement>, url: string) => void;
}) {
const stopWatching = useStopWatchingPullRequest(threadRef);
return (
<PrRowsWithMore
environmentId={row.environmentId}
links={links}
currentLink={currentLink}
currentRow={<ThreadDetailsPrRow {...row} onStopWatching={stopWatching(currentLink)} />}
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<ThreadPullRequestLink>;
currentLink: ThreadPullRequestLink;
}) {
const stopWatching = useStopWatchingPullRequest(threadRef);
const openPrLink = useOpenPrLink(threadRef);
return (
<PrRowsWithMore
environmentId={threadRef.environmentId}
links={links}
currentLink={currentLink}
currentRow={
<ThreadDetailsPrLinkRow
environmentId={threadRef.environmentId}
link={currentLink}
onOpen={(event) => openPrLink(event, currentLink.url)}
onStopWatching={stopWatching(currentLink)}
/>
}
onOpenLink={openPrLink}
stopWatching={stopWatching}
/>
);
}
Loading