diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index da845e95fc0b6..ac1496b04a5c6 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -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 { @@ -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"; @@ -58,10 +56,8 @@ import { } from "./BranchToolbar.logic"; import { ThreadPullRequestBadgeControl, - prStatusIndicator, resolveDisplayedThreadPr, - resolveThreadPullRequestBadge, - useLinkedThreadPullRequest, + useThreadDisplayedPullRequest, } from "./ThreadStatusIndicators"; import { ComboboxItem, ComboboxTrigger } from "./ui/combobox"; @@ -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) { @@ -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); @@ -795,21 +777,12 @@ export function BranchToolbarBranchSelector({ - {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? ( - openPrLink(event, prUrl)} + branchPr={branchPr} + branchSourceControlProvider={branchStatusQuery.data?.sourceControlProvider} onActed={() => branchStatusQuery.refresh()} /> ) : null} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 99e0e01dfe03f..317b4f505c0dd 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -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, @@ -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 }) }; 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 0000000000000..86d67cd79db45 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx @@ -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 }) { + const noop = () => {}; + const scriptResult = async () => ({ ok: true }) as never; + return ( + + + + ); +} + +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(); + 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(); + await expect.element(page.getByRole("region", { name: "Workspace" })).toBeVisible(); + await expect.element(page.getByText("#27513")).toBeVisible(); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index bb1546fd4b824..31ea680200e5c 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -7,6 +7,9 @@ import type { ThreadId, } from "@t3tools/contracts"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; + import type { DraftId } from "../../composerDraftStore"; import { useT3ProjectFileScripts } from "../../hooks/useT3ProjectFileScripts"; import { type EnvMode, type EnvironmentOption } from "../BranchToolbar.logic"; @@ -31,6 +34,8 @@ import { import { ThreadDetailsItemsMenuItems } from "./ThreadDetailsItemsMenu"; import { resolveThreadDetailsVisibility, THREAD_DETAILS_ITEMS } from "./threadDetailsItems"; import { useClientSettings } from "../../hooks/useSettings"; +import { useProject, useServerConfigs } from "../../state/entities"; +import { ThreadDetailsThreadPrRows } from "./ThreadDetailsPrRows"; import { cn } from "../../lib/utils"; import { SlidersHorizontalIcon } from "lucide-react"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; @@ -95,6 +100,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { props.activeProjectScripts && visible.scripts ? props.gitCwd : null, ); const activeThreadRef = { environmentId: props.environmentId, threadId: props.threadId }; + const project = useProject(scopeProjectRef(props.environmentId, props.projectId)); + const scratchRoot = useServerConfigs().get(props.environmentId)?.scratchWorkspaceRoot ?? null; + // A No project thread's folder sits inside the shared scratch root, so it must never get git. + const offersGit = props.gitCwd !== null && !(project && isScratchProject(project, scratchRoot)); const branchToolbarProps = { showGitControls: props.isGitRepo, environmentId: props.environmentId, @@ -154,9 +163,11 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { environment: density === "full" && visible.environment, openIn: density !== "essential" && visible.openIn && props.showOpenInPicker, scripts: visible.scripts && props.activeProjectScripts !== undefined, - branch: props.gitCwd !== null && props.isGitRepo && visible.branch, + branch: offersGit && props.isGitRepo && visible.branch, + // Without a branch selector, linked pull requests still need somewhere to show. + pullRequests: !(offersGit && props.isGitRepo) && visible.branch, git: - props.gitCwd !== null && + offersGit && props.activeProjectName !== undefined && (visible.gitActions || (visible.changes && props.isGitRepo)), }; @@ -213,7 +224,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { ) : null} - {props.gitCwd && (show.branch || show.git) ? ( + {show.branch || show.pullRequests || show.git ? ( ) : null} - {show.git ? ( + {show.pullRequests ? ( + + ) : null} + {show.git && props.gitCwd ? ( ); } + +/** Linked pull requests need no repository; only the branch's own pull request comes from git. */ +export function ThreadDetailsThreadPrRows({ + threadRef, + project, + branchPr = null, + branchSourceControlProvider, + onActed, +}: { + threadRef: ScopedThreadRef; + project: EnvironmentProject | null; + branchPr?: VcsStatusResult["pr"]; + branchSourceControlProvider?: VcsStatusResult["sourceControlProvider"]; + onActed?: () => void; +}) { + const thread = useQuietThreadShell(threadRef); + const displayed = useThreadDisplayedPullRequest( + threadRef.environmentId, + thread, + branchPr, + branchSourceControlProvider, + ); + const openPrLink = useOpenPrLink(threadRef); + const { currentLink, pr, number, url } = displayed; + if (number === undefined || url === undefined) return null; + return ( + openPrLink(event, url)} + {...(onActed ? { onActed } : {})} + /> + ); +}