diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aad0e71a06b0..e1f670474696 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -189,7 +189,11 @@ import { CommandPaletteMetaDot, ThreadCommandSubtitle, } from "./ThreadCommandSubtitle"; -import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; +import { + ThreadRowLeadingStatus, + ThreadRowTrailingStatus, + ThreadSearchPullRequestNumber, +} from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { applyProviderInstanceSettings, @@ -1387,7 +1391,16 @@ function OpenCommandPaletteDialog(props: { sortOrder: clientSettings.sidebarThreadSortOrder, icon: , renderLeadingContent: (thread) => , - renderTrailingContent: (thread) => , + renderTrailingContent: (thread) => ( + <> + + + + ), renderDescription: (thread, { projectTitle }) => { const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 33bdb2129bf9..e81806309f28 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -37,6 +37,7 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, resolveWorkingStartedAt, + pullRequestMatchesSearchQuery, searchSidebarThreads, shouldClearThreadSelectionOnMouseDown, shouldShowSidebarV2Duration, @@ -991,6 +992,27 @@ describe("resolveSidebarThreadStatus", () => { }); }); +describe("pullRequestMatchesSearchQuery", () => { + const branchPullRequest = { + projectId: ProjectId.make("project"), + repository: "pingdotgg/t3code", + number: 123, + url: "https://github.com/pingdotgg/t3code/pull/123", + }; + + it("matches when every query token appears in the PR's terms", () => { + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "123")).toBe(true); + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "pingdotgg/t3code #123")).toBe( + true, + ); + }); + + it("rejects queries with a token the PR does not carry, and blank queries", () => { + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "rebase 123")).toBe(false); + expect(pullRequestMatchesSearchQuery({ branchPullRequest }, " ")).toBe(false); + }); +}); + describe("searchSidebarThreads", () => { const searchThread = (id: string, title: string, project: string) => ({ environmentId: localEnvironmentId, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ad2c9a1fedc8..24e16995055f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -22,7 +22,7 @@ import { type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; -import { cn } from "../lib/utils"; +import { cn, normalizeSearchText } from "../lib/utils"; import { isLatestRunSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; @@ -1083,6 +1083,21 @@ export function searchSidebarThreads< return [...titleMatches, ...contentMatches]; } +/** + * Whether a PR's search terms contain every whitespace-separated token of the + * query. Search results use this to pick the PR number to show, so it has to + * accept the multi-token queries the command palette matches on. + */ +export function pullRequestMatchesSearchQuery( + pullRequest: Parameters[0], + query: string, +): boolean { + const normalizedQuery = normalizeSearchText(query); + if (normalizedQuery.length === 0) return false; + const haystack = normalizeSearchText(threadPullRequestSearchTerms(pullRequest).join(" ")); + return normalizedQuery.split(" ").every((token) => haystack.includes(token)); +} + export function filterSidebarProjectScopeItems(input: { items: readonly TItem[]; query: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9280947c5e47..febc50f98506 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -233,6 +233,7 @@ import { synchronizeTerminalPulse, type TerminalStatusIndicator, useLinkedThreadPullRequest, + ThreadSearchPullRequestNumber, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; @@ -2129,6 +2130,7 @@ function latestRunDiff( } const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { + isSettled: boolean; thread: SidebarThreadSummary; project: EnvironmentProject | null; projectDisplayName: string | null; @@ -2228,11 +2230,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { tabIndex={-1} aria-selected={props.isHighlighted} aria-current={accessibility.current} + aria-describedby={`${props.resultId}-pr`} aria-label={accessibility.label} onMouseMove={props.onHighlight} onClick={props.onSelect} className={cn( - "flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none", + "group/sidebar-row group/v2-row flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -2243,12 +2246,40 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { } > {props.project ? ( - + ) : null} - {thread.title} - + + {thread.title} + + + + + {threadTimeLabel(thread)} @@ -4857,6 +4888,7 @@ export default function Sidebar() { ); return ( ); } + +/** Number presentation for compact thread search results. */ +function ThreadPullRequestNumber({ + number, + className, + ...props +}: ComponentProps<"span"> & { number: number }) { + return ( + + #{number} + + ); +} + +function settledPrHoverColorClass(state: NonNullable["state"], isDraft = false): string { + switch (state) { + case "open": + if (isDraft) { + return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80"; + } + return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90"; + case "merged": + return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90"; + case "closed": + return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90"; + } +} + +export function ThreadSearchPullRequestNumber({ + thread, + query, + enabled = true, + settled = false, +}: { + thread: SidebarThreadSummary; + query: string; + enabled?: boolean; + settled?: boolean; +}) { + const matchedLink = visibleThreadPullRequests(thread.pullRequests).find((pr) => + pullRequestMatchesSearchQuery({ pullRequests: [pr] }, query), + ); + const fallbackCandidates = + thread.pullRequests.length === 0 + ? [thread.linkedPullRequest, thread.branchPullRequest] + : [thread.branchPullRequest]; + const legacyReference = matchedLink + ? undefined + : fallbackCandidates.find( + (pr) => pr != null && pullRequestMatchesSearchQuery({ linkedPullRequest: pr }, query), + ); + const legacyStatus = useLinkedThreadPullRequest( + thread.environmentId, + legacyReference, + enabled, + undefined, + legacyReference, + ); + const reference = matchedLink ?? legacyReference; + const linked = matchedLink ? linkedPullRequestSnapshotStatus(matchedLink) : legacyStatus; + const status = prStatusIndicator(linked?.pr ?? null, linked?.sourceControlProvider); + return reference ? ( + + ) : null; +} diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index a7042a7c8828..2ac94fce121d 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -445,6 +445,31 @@ describe("chain selection and badge state", () => { }); describe("threadPullRequestSearchTerms", () => { + it.each([undefined, [], [link(34)]])( + "searches the branch PR alongside existing links (%j)", + (pullRequests) => { + const branchPullRequest = { + projectId: ProjectId.make("project"), + repository: "pingdotgg/t3code", + number: 11809, + url: "https://github.com/pingdotgg/t3code/pull/11809", + }; + const terms = threadPullRequestSearchTerms({ + pullRequests, + branchPullRequest, + linkedPullRequest: { + ...branchPullRequest, + number: 12, + url: "https://github.com/pingdotgg/t3code/pull/12", + }, + }); + for (const query of ["11809", "#11809", "pingdotgg/t3code#11809", branchPullRequest.url]) { + expect(terms.some((term) => term.includes(query))).toBe(true); + } + expect(terms).toContain(pullRequests?.length ? "#34" : "#12"); + }, + ); + it("includes completed and unsynced links but excludes dismissed links", () => { const terms = threadPullRequestSearchTerms({ pullRequests: [ diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index d74927237534..97b800d8682f 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -308,21 +308,30 @@ export function resolveThreadPullRequestBadge( return { kind: "pull-request", others: visible.length - 1, state }; } -/** Search terms for visible PR links, including the legacy single-link projection. */ +/** Search terms for visible PR links, the legacy projection, and the branch PR. */ export function threadPullRequestSearchTerms(thread: { readonly pullRequests?: ReadonlyArray | undefined; readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + readonly branchPullRequest?: ThreadLinkedPullRequest | null | undefined; }): string[] { + const branch = thread.branchPullRequest; + const terms = branch + ? [`#${branch.number}`, `${branch.repository}#${branch.number}`, branch.url] + : []; if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) { - return visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [ - `#${link.number}`, - `${link.repository}#${link.number}`, - link.url, - link.snapshot?.title ?? "", - ]); + return terms.concat( + visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [ + `#${link.number}`, + `${link.repository}#${link.number}`, + link.url, + link.snapshot?.title ?? "", + ]), + ); } const legacy = thread.linkedPullRequest; - return legacy ? [`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url] : []; + return legacy + ? terms.concat([`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url]) + : terms; } /** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */