Skip to content
59 changes: 16 additions & 43 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
import { ComposerContextLabel } from "./ComposerContextLabel";
import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests";
import { useRightPanelStore } from "../rightPanelStore";
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import {
Expand Down Expand Up @@ -40,7 +38,7 @@ import { useAtomCommand } from "../state/use-atom-command";
import { vcsEnvironment } from "../state/vcs";
import { cn } from "../lib/utils";
import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./chat/threadDetailsPanelStyles";
import { ThreadDetailsPrRows } from "./chat/ThreadDetailsPrRows";
import { ThreadDetailsThreadPrRows } from "./chat/ThreadDetailsPrRows";
import { parsePullRequestReference } from "@t3tools/client-runtime/pull-request-link-input";
import { getSourceControlPresentation } from "../sourceControlPresentation";
import { useComposerMenuProps } from "./chat/composerEventScope";
Expand All @@ -58,10 +56,8 @@ import {
} from "./BranchToolbar.logic";
import {
ThreadPullRequestBadgeControl,
prStatusIndicator,
resolveDisplayedThreadPr,
resolveThreadPullRequestBadge,
useLinkedThreadPullRequest,
useThreadDisplayedPullRequest,
} from "./ThreadStatusIndicators";

import { ComboboxItem, ComboboxTrigger } from "./ui/combobox";
Expand Down Expand Up @@ -600,32 +596,18 @@ export function BranchToolbarBranchSelector({
threadBranch: branchPrBranch,
gitStatus: branchStatusQuery.data,
});
const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId);
const linkedStatus = useLinkedThreadPullRequest(
const {
badge: prBadge,
number: prNumber,
url: prUrl,
status: prStatus,
} = useThreadDisplayedPullRequest(
environmentId,
serverThread?.linkedPullRequest,
true,
serverThread?.pullRequests,
serverThread?.branchPullRequest,
);
const currentLinkedPr = supportsMultiplePullRequests
? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? [])
: null;
const prBadge = supportsMultiplePullRequests
? resolveThreadPullRequestBadge(serverThread?.pullRequests)
: null;
const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null);
const displayedPrStatus = prStatusIndicator(
displayedPr,
linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider,
serverThread,
branchPr,
branchStatusQuery.data?.sourceControlProvider,
);
const prNumber = currentLinkedPr?.number ?? displayedPr?.number;
const prUrl = currentLinkedPr?.url ?? displayedPr?.url;
const openPrLink = useOpenPrLink(threadRef);
const panelPrLabel =
prNumber === undefined
? ""
: `#${prNumber}${displayedPr?.title.trim() ? `: ${displayedPr.title}` : ""}`;

function selectPickerItem(itemValue: string) {
if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) {
Expand Down Expand Up @@ -757,7 +739,7 @@ export function BranchToolbarBranchSelector({
pullRequests={serverThread?.pullRequests ?? []}
number={prNumber}
url={prUrl}
status={displayedPrStatus}
status={prStatus}
onOpenList={() => useRightPanelStore.getState().open(threadRef, "pull-requests")}
onOpenPullRequest={(event, targetUrl = prUrl) => {
if (targetUrl) openPrLink(event, targetUrl);
Expand Down Expand Up @@ -795,21 +777,12 @@ export function BranchToolbarBranchSelector({
</ComposerContextLabel>
</ComboboxTrigger>
</span>
{displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? (
<ThreadDetailsPrRows
{displayMode === "panel" ? (
<ThreadDetailsThreadPrRows
threadRef={threadRef}
links={serverThread?.pullRequests ?? []}
currentLink={currentLinkedPr}
onOpenLink={openPrLink}
environmentId={environmentId}
pr={displayedPr}
number={prNumber}
reference={currentLinkedPr}
status={displayedPrStatus}
project={activeProject}
label={panelPrLabel}
openAriaLabel={prUrl ?? `Open ${sourceControlPresentation.terminology.singular}`}
onOpen={(event) => openPrLink(event, prUrl)}
branchPr={branchPr}
branchSourceControlProvider={branchStatusQuery.data?.sourceControlProvider}
onActed={() => branchStatusQuery.refresh()}
/>
) : null}
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/components/ThreadStatusIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePull
import { useStableCallback } from "~/hooks/useStableCallback";

import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import {
resolveEnvironmentMachineKind,
type EnvironmentId,
Expand Down Expand Up @@ -116,6 +117,41 @@ export function useLinkedThreadPullRequest(
};
}

/** The pull request a thread shows: its current link, else the one found for its branch. */
export function useThreadDisplayedPullRequest(
environmentId: EnvironmentId,
thread: Pick<
EnvironmentThreadShell,
"pullRequests" | "linkedPullRequest" | "branchPullRequest"
> | null,
branchPr: ThreadPr = null,
branchSourceControlProvider?: VcsStatusResult["sourceControlProvider"],
) {
const supportsLinks = useSupportsMultiplePullRequests(environmentId);
const linkedStatus = useLinkedThreadPullRequest(
environmentId,
thread?.linkedPullRequest,
true,
thread?.pullRequests,
thread?.branchPullRequest,
);
const currentLink = supportsLinks
? resolveThreadCurrentPullRequestLink(thread?.pullRequests ?? [])
: null;
const pr = linkedStatus?.pr ?? (currentLink === null ? branchPr : null);
return {
currentLink,
badge: supportsLinks ? resolveThreadPullRequestBadge(thread?.pullRequests) : null,
pr,
status: prStatusIndicator(
pr,
linkedStatus?.sourceControlProvider ?? branchSourceControlProvider,
),
number: currentLink?.number ?? pr?.number,
url: currentLink?.url ?? pr?.url,
};
}

function linkedPullRequestReference(fallback: ThreadLinkedPullRequest) {
const host = sourceControlClients.parseChangeRequestUrl(fallback.url)?.link.host;
return { ...fallback, ...(host === undefined ? {} : { host }) };
Expand Down
195 changes: 195 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,195 @@
import {
EnvironmentId,
ProjectId,
ThreadId,
type ScopedThreadRef,
type ThreadPullRequestLink,
} from "@t3tools/contracts";
import { AsyncResult, Atom } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";
import { page } from "vite-plus/test/browser";

const SCRATCH_ROOT = "/home/user/.t3/scratch";

const state = vi.hoisted(() => ({
workspaceRoot: "",
folder: "",
pullRequests: [] as unknown[],
}));

vi.mock(import("~/state/entities"), async (importOriginal) => ({
...(await importOriginal()),
useProject: ((ref: unknown) =>
ref === null
? null
: {
id: "project",
environmentId: "environment",
title: state.workspaceRoot === SCRATCH_ROOT ? "No project" : "plain",
workspaceRoot: state.workspaceRoot,
scripts: [],
}) as never,
useProjects: (() => []) as never,
useQuietThreadShell: ((ref: unknown) =>
ref === null
? null
: {
id: "thread",
environmentId: "environment",
projectId: "project",
worktreePath: state.folder,
branch: null,
pullRequests: state.pullRequests,
}) as never,
useServerConfigs: (() =>
new Map([
[
EnvironmentId.make("environment"),
{
scratchWorkspaceRoot: SCRATCH_ROOT,
environment: { capabilities: { pullRequests: true } },
},
],
])) as never,
}));
// Neither folder is a repository, which is what the server reports for both.
vi.mock(import("~/state/vcs"), async (importOriginal) => {
const actual = await importOriginal();
const notARepo = Atom.make(
AsyncResult.success({
isRepo: false,
hasPrimaryRemote: false,
isDefaultRef: false,
refName: null,
hasWorkingTreeChanges: false,
workingTree: { files: [], insertions: 0, deletions: 0 },
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
aheadOfDefaultCount: 0,
pr: null,
}),
);
return {
...actual,
vcsEnvironment: { ...actual.vcsEnvironment, status: (() => notARepo) as never },
};
});

import { render } from "../../../test/render";
import { useRightPanelStore } from "../../rightPanelStore";
import { PopoverCreateHandle } from "../ui/popover";
import { ChatCanvasContext } from "./ChatCanvasContext";
import { resolveChatCanvasLayout } from "./chatCanvasLayout";
import { ThreadDetailsPanel } from "./ThreadDetailsPanel";

const threadRef: ScopedThreadRef = {
environmentId: EnvironmentId.make("environment"),
threadId: ThreadId.make("thread"),
};
const container = { width: 1200, height: 800 };
const placement = { x: 908, y: 0, right: 12, width: 280, height: 400 } as const;
const linkedPullRequest: ThreadPullRequestLink = {
host: "github.com",
repository: "pingdotgg/t3code",
number: 27513,
url: "https://github.com/pingdotgg/t3code/pull/27513",
source: "manual",
linkedAt: "2026-10-30T00:00:00.000Z",
snapshot: null,
stack: null,
};

function setup(input: {
workspaceRoot: string;
folder: string;
pullRequests?: ThreadPullRequestLink[];
}) {
state.workspaceRoot = input.workspaceRoot;
state.folder = input.folder;
state.pullRequests = input.pullRequests ?? [];
useRightPanelStore.setState({ threadPanelVisibilityByThreadKey: {} });
const anchor = document.createElement("div");
document.body.append(anchor);
return {
anchor: { current: anchor },
handle: PopoverCreateHandle(),
[Symbol.dispose]: () => anchor.remove(),
};
}

function Panel({ fixture }: { fixture: ReturnType<typeof setup> }) {
const noop = () => {};
const scriptResult = async () => ({ ok: true }) as never;
return (
<ChatCanvasContext
value={{
container,
lane: { padding: 48, minChatWidth: 640 },
layout: resolveChatCanvasLayout({ container, preview: null }),
previewKey: null,
registerTimeline: noop,
measureDetailsCard: noop,
detailsCardTopInset: 0,
preferredDetailsCard: placement,
detailsCardPlacement: placement,
}}
>
<ThreadDetailsPanel
anchor={fixture.anchor}
handle={fixture.handle}
environmentId={threadRef.environmentId}
threadId={threadRef.threadId}
projectId={ProjectId.make("project")}
worktreePath={state.folder}
isServerThread
activeProjectName={state.workspaceRoot === SCRATCH_ROOT ? "No project" : "plain"}
activeProjectScripts={undefined}
preferredScriptId={null}
keybindings={[]}
availableEditors={[]}
showOpenInPicker={false}
openInShortcutEnabled={false}
gitCwd={state.folder}
workspaceMutationId={null}
isGitRepo={false}
envLocked
availableEnvironments={[]}
onEnvModeChange={noop}
envMode="local"
startFromOrigin={false}
onStartFromOriginChange={noop}
onComposerFocusRequest={noop}
onRunProjectScript={noop}
onAddProjectScript={scriptResult}
onUpdateProjectScript={scriptResult}
onDeleteProjectScript={scriptResult}
usage={{} as never}
/>
</ChatCanvasContext>
);
}

it("offers no git controls in a No project thread, but shows its linked pull request", async () => {
using fixture = setup({
workspaceRoot: SCRATCH_ROOT,
folder: `${SCRATCH_ROOT}/thread`,
pullRequests: [linkedPullRequest],
});
await using _screen = await render(<Panel fixture={fixture} />);
await expect.element(page.getByText("#27513")).toBeVisible();
await expect
.element(page.getByRole("button", { name: "Initialize Git" }))
.not.toBeInTheDocument();
});

it("shows a linked pull request in a folder that is not a git repository", async () => {
using fixture = setup({
workspaceRoot: "/work/plain",
folder: "/work/plain",
pullRequests: [linkedPullRequest],
});
await using _screen = await render(<Panel fixture={fixture} />);
await expect.element(page.getByRole("region", { name: "Workspace" })).toBeVisible();
await expect.element(page.getByText("#27513")).toBeVisible();
});
Loading