Skip to content
Merged
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
12 changes: 10 additions & 2 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
import { readLocalApi } from "../localApi";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { useOpenPrLink, useOpenThreadPullRequestTabs } from "../lib/openPullRequestLink";
import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches";
import { usePaginatedBranches } from "../state/queries";
import { useProject, useThreadShell } from "../state/entities";
Expand Down Expand Up @@ -689,6 +689,7 @@ export function BranchToolbarBranchSelector({
const prNumber = currentLinkedPr?.number ?? displayedPr?.number;
const prUrl = currentLinkedPr?.url ?? displayedPr?.url;
const openPrLink = useOpenPrLink(threadRef);
const openThreadPullRequestTabs = useOpenThreadPullRequestTabs(threadRef);

function selectPickerItem(itemValue: string) {
highlightedBranchValueRef.current = null;
Expand Down Expand Up @@ -801,10 +802,17 @@ export function BranchToolbarBranchSelector({
<ThreadPullRequestBadgeControl
variant="ghost"
badge={prBadge}
pullRequests={serverThread?.pullRequests}
number={prNumber}
url={prUrl}
status={displayedPrStatus}
onOpenStack={() => useRightPanelStore.getState().open(threadRef, "pull-requests")}
onOpenList={() => {
// Unrelated PRs (one per repo, say) open a tab each; a stack opens its ordered list.
const openedTabs =
prBadge?.kind === "pull-request" &&
openThreadPullRequestTabs(serverThread?.pullRequests ?? [], prUrl);
if (!openedTabs) useRightPanelStore.getState().open(threadRef, "pull-requests");
}}
onOpenPullRequest={(event) => {
if (prUrl) openPrLink(event, prUrl);
}}
Expand Down
66 changes: 44 additions & 22 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
resolveProjectScripts,
} from "@t3tools/shared/projectScripts";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
import { threadWorkspaceFilePath } from "@t3tools/shared/path";
import { truncate } from "@t3tools/shared/String";
Expand Down Expand Up @@ -118,7 +119,7 @@ import {
import * as Cause from "effect/Cause";
import * as Schema from "effect/Schema";
import { AsyncResult } from "effect/unstable/reactivity";
import { useVcsStatusGroups } from "~/lib/vcsStatusState";
import { repoRootDisplayName, useVcsStatusGroups } from "~/lib/vcsStatusState";
import { isElectron } from "../env";
import { repoBaseBranchesForSend } from "../repoBaseBranchStore";
import { readLocalApi } from "../localApi";
Expand Down Expand Up @@ -3665,14 +3666,28 @@ export default function ChatView(props: ChatViewProps) {
? threadWorkspaceFilePath({ anchorWorktreePath, projectWorkspaceFile })
: null;
// For a multi-repo `.code-workspace` project, fan git status out over every
// repo root. For a single-repo project keep the worktree-aware status cwd so
// isolated runs report on the worktree (Phase 4 will make multi-repo
// worktree-aware too).
// repo root, swapping in the thread's worktree for roots an isolated run
// covers. A single-repo project uses the worktree-aware status cwd.
const isMultiRepo = (activeProject?.repoRoots?.length ?? 0) > 1;
const gitStatusCwd = activeThread?.worktreePath ?? gitCwd;
const threadWorktrees = activeThread?.worktrees;
const multiRepoStatusRoots = useMemo(() => {
if (!isMultiRepo || !activeProject?.repoRoots) return null;
return activeProject.repoRoots.map((repoRoot) => ({
repoRoot,
cwd:
threadWorktrees?.find((worktree) => worktree.repoRoot === repoRoot)?.worktreePath ??
repoRoot,
}));
}, [isMultiRepo, activeProject?.repoRoots, threadWorktrees]);
const gitStatusRoots = useMemo(
() => (isMultiRepo ? (activeProject?.repoRoots ?? null) : gitStatusCwd ? [gitStatusCwd] : null),
[isMultiRepo, activeProject?.repoRoots, gitStatusCwd],
() =>
multiRepoStatusRoots
? multiRepoStatusRoots.map((root) => root.cwd)
: gitStatusCwd
? [gitStatusCwd]
: null,
[multiRepoStatusRoots, gitStatusCwd],
);
// @-mention file search spans every repo root for a multi-repo workspace
// (#923); single-repo projects search the worktree-aware `gitCwd` alone.
Expand Down Expand Up @@ -3705,12 +3720,19 @@ export default function ChatView(props: ChatViewProps) {
);
const repoStatusGroups = useMemo(
() =>
gitRepoGroups.map((group) => ({
repoRoot: group.repoRoot,
displayName: group.displayName,
state: group.state,
})),
[gitRepoGroups],
gitRepoGroups.map((group) => {
// A worktree's basename is its branch slug; label the row by its repo.
const sourceRoot = multiRepoStatusRoots?.find((root) => root.cwd === group.repoRoot);
return {
repoRoot: group.repoRoot,
displayName:
sourceRoot && sourceRoot.cwd !== sourceRoot.repoRoot
? repoRootDisplayName(sourceRoot.repoRoot)
: group.displayName,
state: group.state,
};
}),
[gitRepoGroups, multiRepoStatusRoots],
);
// Per-root targets for the terminal surface picker; undefined for single-repo
// projects so the Terminal action opens directly (no dropdown).
Expand Down Expand Up @@ -4761,19 +4783,19 @@ export default function ChatView(props: ChatViewProps) {
readonly reference: ThreadLinkedPullRequest | null;
} | null>(null);
const openProjectPullRequest = useCallback(
(number: number) => {
if (
!supportsPullRequests ||
!activeThreadRef ||
!activeProject ||
activeProjectRepository === null
) {
return;
}
({ number, url }: { readonly number: number; readonly url: string }) => {
if (!supportsPullRequests || !activeThreadRef || !activeProject) return;
// A multi-repo row's PR can live in any of the project's repos; its URL
// names the repo. The primary repo covers URLs this cannot read.
const parsed = parseChangeRequestUrl(url);
const repository = parsed?.repository ?? activeProjectRepository;
if (repository === null) return;
useRightPanelStore.getState().openPullRequest(activeThreadRef, {
projectId: activeProject.id,
repository: activeProjectRepository,
...(parsed ? { host: parsed.host } : {}),
repository,
number,
url,
});
},
[activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests],
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ interface GitActionsControlProps {
* Opens the thread's own change request beside it. Absent when the thread has no project to
* place it against, in which case it still opens in the browser.
*/
onOpenPullRequest?: ((number: number) => void) | undefined;
onOpenPullRequest?:
| ((pullRequest: { readonly number: number; readonly url: string }) => void)
| undefined;
/**
* Whether this control should keep the thread's single `branch` field in sync
* with its repo's live branch. Defaults to true. Disabled for the per-repo
Expand Down Expand Up @@ -1234,7 +1236,7 @@ export default function GitActionsControl({
// Beside the thread where it was made, the way the browser opens beside it. Checked before
// the shell, which opening in the app does not need.
if (openPr && onOpenPullRequest) {
onOpenPullRequest(openPr.number);
onOpenPullRequest(openPr);
return;
}
const prUrl = openPr?.url ?? null;
Expand Down
31 changes: 23 additions & 8 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import { isTerminalFocused } from "../lib/terminalFocus";
import { isModelPickerOpen } from "../modelPickerVisibility";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
import { isMacPlatform } from "~/lib/utils";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { useOpenPrLink, useOpenThreadPullRequestTabs } from "../lib/openPullRequestLink";
import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
import { readLocalApi } from "../localApi";
import {
Expand Down Expand Up @@ -201,6 +201,7 @@ import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
import { createSidebarListMotion } from "./Sidebar.motion";
import {
ThreadPullRequestBadgeControl,
threadPullRequestBadgeOpensList,
ThreadPullRequestsMiniList,
ThreadWorktreeIndicator,
prStatusIndicator,
Expand Down Expand Up @@ -1504,24 +1505,38 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
</span>
);

// Stacks show their layer count; multiple unrelated links show their total count.
// Plain clicks open T3; individual PR links also support opening the host in a new tab.
// Stacks show their layer count; multiple unrelated links show their total count. A single PR
// link also supports opening the host in a new tab.
const prBadgeShape = supportsMultiplePullRequests
? resolveThreadPullRequestBadge(thread.pullRequests)
: null;
const handlePrStackClick = useCallback(() => {
useRightPanelStore.getState().open(threadRef, "pull-requests");
const openThreadPullRequestTabs = useOpenThreadPullRequestTabs(threadRef);
const handlePrListClick = useCallback(() => {
// Unrelated PRs (one per repo, say) open a tab each; a stack reads best as its ordered list.
const openedTabs =
prBadgeShape?.kind === "pull-request" &&
openThreadPullRequestTabs(thread.pullRequests, currentLinkedPr?.url);
if (!openedTabs) useRightPanelStore.getState().open(threadRef, "pull-requests");
if (!props.isActive) onThreadActivate(threadRef);
}, [onThreadActivate, props.isActive, threadRef]);
}, [
currentLinkedPr?.url,
onThreadActivate,
openThreadPullRequestTabs,
prBadgeShape?.kind,
props.isActive,
thread.pullRequests,
threadRef,
]);
const prBadge =
prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? (
threadPullRequestBadgeOpensList(prBadgeShape) || pr || currentLinkedPr ? (
<ThreadPullRequestBadgeControl
variant="underline"
badge={prBadgeShape}
pullRequests={thread.pullRequests}
number={pr?.number ?? currentLinkedPr?.number}
url={pr?.url ?? currentLinkedPr?.url}
status={prStatus}
onOpenStack={handlePrStackClick}
onOpenList={handlePrListClick}
onOpenPullRequest={handlePrClick}
/>
) : null;
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/components/ThreadStatusIndicators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ChangeRequestStatusIcon,
prStatusIndicator,
resolveThreadPullRequestBadgePresentation,
threadPullRequestBadgeOpensList,
} from "./ThreadStatusIndicators";
import { newestPullRequestSummary } from "../state/pullRequests";
import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
Expand Down Expand Up @@ -117,6 +118,16 @@ describe("prStatusIndicator", () => {
describe("resolveThreadPullRequestBadgePresentation", () => {
const url = "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/pingdotgg/t3code/pull/42";

it("summarizes unrelated links as a count instead of naming one of them", () => {
const badge = { kind: "pull-request", others: 1, state: "open" } as const;
expect(resolveThreadPullRequestBadgePresentation({ badge, status: null })).toMatchObject({
label: "2 linked pull requests, overall open",
text: "+2",
});
expect(threadPullRequestBadgeOpensList(badge)).toBe(true);
expect(threadPullRequestBadgeOpensList({ ...badge, others: 0 })).toBe(false);
});

it("returns the pending pull-request badge when no snapshot is available", () => {
expect(
resolveThreadPullRequestBadgePresentation({
Expand Down Expand Up @@ -232,7 +243,7 @@ describe("resolveThreadPullRequestBadgePresentation", () => {
).toEqual({
Icon: expectedIcon,
toneClassName: expectedToneClassName,
label: `PR #42 - Closed: PR branch, and 2 more linked; overall ${state}`,
label: `3 linked pull requests, overall ${state}`,
text: "+3",
});
},
Expand Down
41 changes: 29 additions & 12 deletions apps/web/src/components/ThreadStatusIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,19 +170,19 @@ export function resolveThreadPullRequestBadgePresentation({
text: badge.layers,
};
}
if (number === undefined || url === undefined) return null;

const tooltip = status?.tooltip ?? `PR #${number}, status pending`;
if (badge?.kind === "pull-request" && badge.others > 0) {
// Unrelated links fold into one state, so a count of merged PRs reads as merged.
const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state];
return {
Icon: aggregate.Icon,
toneClassName: aggregate.toneClassName,
label: `${tooltip}, and ${badge.others} more linked; overall ${aggregate.label.toLowerCase()}`,
label: `${badge.others + 1} linked pull requests, overall ${aggregate.label.toLowerCase()}`,
text: `+${badge.others + 1}`,
};
}
if (number === undefined || url === undefined) return null;

const tooltip = status?.tooltip ?? `PR #${number}, status pending`;
return {
Icon: status?.Icon ?? PullRequestGlyph.pullRequest,
toneClassName: status?.colorClass ?? "text-muted-foreground",
Expand All @@ -191,43 +191,54 @@ export function resolveThreadPullRequestBadgePresentation({
};
}

/** The complete linked-PR control shared by the sidebar and composer footer. */
/** Whether the badge stands for several pull requests, so it opens the list rather than one. */
export function threadPullRequestBadgeOpensList(badge: ThreadPullRequestBadge | null): boolean {
return badge?.kind === "stack" || (badge?.kind === "pull-request" && badge.others > 0);
}

/**
* The complete linked-PR control shared by the sidebar and composer footer. A badge for several
* pull requests opens the pull-requests panel and lists them all on hover; a single one opens
* that pull request.
*/
export function ThreadPullRequestBadgeControl({
variant,
badge,
pullRequests,
number,
url,
status,
onOpenStack,
onOpenList,
onOpenPullRequest,
}: {
variant: "underline" | "ghost";
badge: ThreadPullRequestBadge | null;
pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
number?: number | undefined;
url?: string | undefined;
status: PrStatusIndicator | null;
onOpenStack: () => void;
onOpenList: () => void;
onOpenPullRequest: (event: MouseEvent<HTMLElement>) => void;
}) {
const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status });
if (presentation === null) return null;
const isStack = badge?.kind === "stack";
const opensList = threadPullRequestBadgeOpensList(badge);
const content = (
<>
<presentation.Icon aria-hidden className="size-3 shrink-0" />
{presentation.text}
</>
);
const linkProps = isStack
const linkProps = opensList
? {
onClick: (event: MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
onOpenStack();
onOpenList();
},
}
: { onClick: onOpenPullRequest };
const element = isStack ? (
const element = opensList ? (
<button type="button" />
) : (
<a href={url} target="_blank" rel="noopener noreferrer" />
Expand Down Expand Up @@ -259,7 +270,13 @@ export function ThreadPullRequestBadgeControl({
>
{content}
</TooltipTrigger>
<TooltipPopup side="top">{presentation.label}</TooltipPopup>
<TooltipPopup side="top">
{opensList && pullRequests ? (
<ThreadPullRequestsMiniList pullRequests={pullRequests} />
) : (
presentation.label
)}
</TooltipPopup>
</Tooltip>
);
}
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ interface ChatHeaderProps {
availableEditors: ReadonlyArray<EditorId>;
rightPanelOpen: boolean;
gitCwd: string | null;
readonly onOpenPullRequest?: ((number: number) => void) | undefined;
readonly onOpenPullRequest?:
| ((pullRequest: { readonly number: number; readonly url: string }) => void)
| undefined;
onNewThreadInProject: () => void;
onOpenProjectSettings?: (() => void) | undefined;
/**
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/MultiRepoGitControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ interface MultiRepoGitControlProps {
activeThreadRef: ScopedThreadRef | null;
draftId?: DraftId;
/** Forwarded to each row so a repo's change request opens beside the thread. */
onOpenPullRequest?: ((number: number) => void) | undefined;
onOpenPullRequest?:
| ((pullRequest: { readonly number: number; readonly url: string }) => void)
| undefined;
/**
* `"menu"` when the chat header has collapsed its actions into the overflow
* menu, so the trigger renders as a full-width row instead of a toolbar chip.
Expand Down
Loading
Loading