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. */