From ec8cdabeec464fe62a753b6be9d525b63a9ec286 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 14:18:15 +0000 Subject: [PATCH 1/3] test(web): reproduce #27626 thread details git controls in No project threads These tests reproduce #27626: the thread details card offers Initialize Git in a No project (scratch) thread, and drops a linked pull request's row when the thread's folder is not a git repository. Co-Authored-By: Claude Opus 5.5 --- .../chat/ThreadDetailsPanel.browser.test.tsx | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx 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..60fe68aae71ae --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx @@ -0,0 +1,191 @@ +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; + +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", async () => { + using fixture = setup({ workspaceRoot: SCRATCH_ROOT, folder: `${SCRATCH_ROOT}/thread` }); + await using _screen = await render(); + await expect.element(page.getByRole("region", { name: "Workspace" })).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: [ + { + 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, + }, + ], + }); + await using _screen = await render(); + await expect.element(page.getByRole("region", { name: "Workspace" })).toBeVisible(); + await expect.element(page.getByText("#27513")).toBeVisible(); +}); From 22163f4894faac53a2e3cecaf68b2e33255c4397 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 14:25:40 +0000 Subject: [PATCH 2/3] fix(web): No project threads offer no git, and linked PRs show without a repo The thread details card offered Initialize Git in a No project thread, and for a draft that ran git init in the shared scratch root. It also dropped a linked pull request whenever the thread's folder was not a git repository, because the rows only rendered inside the branch selector. The card now withholds branch and git controls when the project is the scratch project, and shows the thread's pull request rows on their own when there is no branch selector. The linked-PR derivation moves into useThreadDisplayedPullRequest and ThreadDetailsThreadPrRows, shared by the branch selector and the card. Fixes pingdotgg/t3code#27626 Co-Authored-By: Claude Opus 5.5 --- .../BranchToolbarBranchSelector.tsx | 59 +++++------------ .../src/components/ThreadStatusIndicators.tsx | 36 +++++++++++ .../components/chat/ThreadDetailsPanel.tsx | 22 +++++-- .../components/chat/ThreadDetailsPrRows.tsx | 63 +++++++++++++++++-- 4 files changed, 129 insertions(+), 51 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 95848c209d8c5..562cb75232095 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 { @@ -41,7 +39,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,9 +56,7 @@ import { } from "./BranchToolbar.logic"; import { ThreadPullRequestBadgeControl, - prStatusIndicator, - resolveThreadPullRequestBadge, - useLinkedThreadPullRequest, + useThreadDisplayedPullRequest, } from "./ThreadStatusIndicators"; import { ComboboxItem, ComboboxTrigger } from "./ui/combobox"; @@ -542,32 +538,18 @@ export function BranchToolbarBranchSelector({ branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch ? (branchStatusQuery.data.pr ?? null) : null; - 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) { @@ -692,7 +674,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); @@ -734,21 +716,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 0af0e063bdea3..b1b52550041e8 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.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 35a954db9d43e..aabccc6f2ecf0 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"; @@ -27,6 +30,8 @@ import { ThreadDetailsUsage, type ThreadDetailsUsageInput } from "./ThreadDetail 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"; @@ -90,6 +95,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, @@ -149,9 +158,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)), }; @@ -208,7 +219,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 } : {})} + /> + ); +} From 5de75a884a182a215ae8b53e2764bcd4c95a6d02 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 14:36:23 +0000 Subject: [PATCH 3/3] test(web): cover a linked pull request in a No project thread Co-Authored-By: Claude Opus 5.5 --- .../chat/ThreadDetailsPanel.browser.test.tsx | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx index 60fe68aae71ae..feca09b6e3ed6 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.browser.test.tsx @@ -89,6 +89,16 @@ const threadRef: ScopedThreadRef = { }; 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; @@ -159,10 +169,14 @@ function Panel({ fixture }: { fixture: ReturnType }) { ); } -it("offers no git controls in a No project thread", async () => { - using fixture = setup({ workspaceRoot: SCRATCH_ROOT, folder: `${SCRATCH_ROOT}/thread` }); +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.getByRole("region", { name: "Workspace" })).toBeVisible(); + await expect.element(page.getByText("#27513")).toBeVisible(); await expect .element(page.getByRole("button", { name: "Initialize Git" })) .not.toBeInTheDocument(); @@ -172,18 +186,7 @@ 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: [ - { - 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, - }, - ], + pullRequests: [linkedPullRequest], }); await using _screen = await render(); await expect.element(page.getByRole("region", { name: "Workspace" })).toBeVisible();