| author | |
| committer | |
| log | a908a7ac8780c5b4d6fb0bed8945d640f69d2d1d |
| tree | 367babe6c013299f964edb766deecc55f8533bad |
| parent | 6cf05264257093476a41f73439b1fa745427a007 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <maria@kuuro.net>7 files changed, 216 insertions(+), 16 deletions(-)
apps/web/src/components/CommandPalette.tsx+16-2| ... | ... | @@ -211,7 +211,11 @@ import { |
| 211 | 211 | CommandPaletteMetaDot, |
| 212 | 212 | ThreadCommandSubtitle, |
| 213 | 213 | } from "./ThreadCommandSubtitle"; |
| 214 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; | |
| 214 | import { | |
| 215 | ThreadRowLeadingStatus, | |
| 216 | ThreadRowTrailingStatus, | |
| 217 | ThreadSearchPullRequestNumber, | |
| 218 | } from "./ThreadStatusIndicators"; | |
| 215 | 219 | import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; |
| 216 | 220 | import { |
| 217 | 221 | applyProviderInstanceSettings, |
| ... | ... | @@ -1323,7 +1327,16 @@ function OpenCommandPaletteDialog(props: { |
| 1323 | 1327 | sortOrder: clientSettings.sidebarThreadSortOrder, |
| 1324 | 1328 | icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />, |
| 1325 | 1329 | renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />, |
| 1326 | renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />, | |
| 1330 | renderTrailingContent: (thread) => ( | |
| 1331 | <> | |
| 1332 | <ThreadSearchPullRequestNumber | |
| 1333 | thread={thread} | |
| 1334 | query={threadSearchQuery} | |
| 1335 | settled={thread.settledOverride === "settled"} | |
| 1336 | /> | |
| 1337 | <ThreadRowTrailingStatus thread={thread} /> | |
| 1338 | </> | |
| 1339 | ), | |
| 1327 | 1340 | renderDescription: (thread, { projectTitle }) => { |
| 1328 | 1341 | const modelInstanceId = |
| 1329 | 1342 | thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; |
| ... | ... | @@ -1383,6 +1396,7 @@ function OpenCommandPaletteDialog(props: { |
| 1383 | 1396 | providerEntryByEnvironmentAndInstanceId, |
| 1384 | 1397 | threadContentMatchByKey, |
| 1385 | 1398 | threadSearch.query, |
| 1399 | threadSearchQuery, | |
| 1386 | 1400 | threads, |
| 1387 | 1401 | ], |
| 1388 | 1402 | ); |
apps/web/src/components/Sidebar.logic.test.ts+22| ... | ... | @@ -40,6 +40,7 @@ import { |
| 40 | 40 | resolveThreadRowClassName, |
| 41 | 41 | resolveThreadStatusPill, |
| 42 | 42 | resolveWorkingStartedAt, |
| 43 | pullRequestMatchesSearchQuery, | |
| 43 | 44 | searchSidebarThreads, |
| 44 | 45 | shouldClearThreadSelectionOnMouseDown, |
| 45 | 46 | shouldShowSidebarV2Duration, |
| ... | ... | @@ -994,6 +995,27 @@ describe("resolveSidebarThreadStatus", () => { |
| 994 | 995 | }); |
| 995 | 996 | }); |
| 996 | 997 | |
| 998 | describe("pullRequestMatchesSearchQuery", () => { | |
| 999 | const branchPullRequest = { | |
| 1000 | projectId: ProjectId.make("project"), | |
| 1001 | repository: "pingdotgg/t3code", | |
| 1002 | number: 123, | |
| 1003 | url: "https://github.com/pingdotgg/t3code/pull/123", | |
| 1004 | }; | |
| 1005 | ||
| 1006 | it("matches when every query token appears in the PR's terms", () => { | |
| 1007 | expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "123")).toBe(true); | |
| 1008 | expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "pingdotgg/t3code #123")).toBe( | |
| 1009 | true, | |
| 1010 | ); | |
| 1011 | }); | |
| 1012 | ||
| 1013 | it("rejects queries with a token the PR does not carry, and blank queries", () => { | |
| 1014 | expect(pullRequestMatchesSearchQuery({ branchPullRequest }, "rebase 123")).toBe(false); | |
| 1015 | expect(pullRequestMatchesSearchQuery({ branchPullRequest }, " ")).toBe(false); | |
| 1016 | }); | |
| 1017 | }); | |
| 1018 | ||
| 997 | 1019 | describe("searchSidebarThreads", () => { |
| 998 | 1020 | const searchThread = (id: string, title: string, project: string) => ({ |
| 999 | 1021 | environmentId: localEnvironmentId, |
apps/web/src/components/Sidebar.logic.ts+16-1| ... | ... | @@ -23,7 +23,7 @@ import { |
| 23 | 23 | type ThreadSortInput, |
| 24 | 24 | } from "../lib/threadSort"; |
| 25 | 25 | import type { SidebarThreadSummary, Thread } from "../types"; |
| 26 | import { cn } from "../lib/utils"; | |
| 26 | import { cn, normalizeSearchText } from "../lib/utils"; | |
| 27 | 27 | import { isLatestRunSettled } from "../session-logic"; |
| 28 | 28 | import { resolveServerBackedAppStageLabel } from "../branding.logic"; |
| 29 | 29 | |
| ... | ... | @@ -1110,6 +1110,21 @@ export function searchSidebarThreads< |
| 1110 | 1110 | return [...titleMatches, ...contentMatches]; |
| 1111 | 1111 | } |
| 1112 | 1112 | |
| 1113 | /** | |
| 1114 | * Whether a PR's search terms contain every whitespace-separated token of the | |
| 1115 | * query. Search results use this to pick the PR number to show, so it has to | |
| 1116 | * accept the multi-token queries the command palette matches on. | |
| 1117 | */ | |
| 1118 | export function pullRequestMatchesSearchQuery( | |
| 1119 | pullRequest: Parameters<typeof threadPullRequestSearchTerms>[0], | |
| 1120 | query: string, | |
| 1121 | ): boolean { | |
| 1122 | const normalizedQuery = normalizeSearchText(query); | |
| 1123 | if (normalizedQuery.length === 0) return false; | |
| 1124 | const haystack = normalizeSearchText(threadPullRequestSearchTerms(pullRequest).join(" ")); | |
| 1125 | return normalizedQuery.split(" ").every((token) => haystack.includes(token)); | |
| 1126 | } | |
| 1127 | ||
| 1113 | 1128 | export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: { |
| 1114 | 1129 | items: readonly TItem[]; |
| 1115 | 1130 | query: string; |
apps/web/src/components/Sidebar.tsx+36-4| ... | ... | @@ -248,6 +248,7 @@ import { |
| 248 | 248 | synchronizeTerminalPulse, |
| 249 | 249 | type TerminalStatusIndicator, |
| 250 | 250 | useLinkedThreadPullRequest, |
| 251 | ThreadSearchPullRequestNumber, | |
| 251 | 252 | } from "./ThreadStatusIndicators"; |
| 252 | 253 | import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; |
| 253 | 254 | import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; |
| ... | ... | @@ -2233,6 +2234,7 @@ function latestRunDiff( |
| 2233 | 2234 | } |
| 2234 | 2235 | |
| 2235 | 2236 | const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2237 | isSettled: boolean; | |
| 2236 | 2238 | thread: SidebarThreadSummary; |
| 2237 | 2239 | project: EnvironmentProject | null; |
| 2238 | 2240 | projectDisplayName: string | null; |
| ... | ... | @@ -2332,11 +2334,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2332 | 2334 | tabIndex={-1} |
| 2333 | 2335 | aria-selected={props.isHighlighted} |
| 2334 | 2336 | aria-current={accessibility.current} |
| 2337 | aria-describedby={`${props.resultId}-pr`} | |
| 2335 | 2338 | aria-label={accessibility.label} |
| 2336 | 2339 | onMouseMove={props.onHighlight} |
| 2337 | 2340 | onClick={props.onSelect} |
| 2338 | 2341 | className={cn( |
| 2339 | "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", | |
| 2342 | "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", | |
| 2340 | 2343 | props.isHighlighted || props.isRouteActive |
| 2341 | 2344 | ? "bg-sidebar-row-active text-sidebar-foreground" |
| 2342 | 2345 | : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", |
| ... | ... | @@ -2347,12 +2350,40 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2347 | 2350 | } |
| 2348 | 2351 | > |
| 2349 | 2352 | {props.project ? ( |
| 2350 | <ProjectFavicon project={props.project} className="size-4 shrink-0" /> | |
| 2353 | <ProjectFavicon | |
| 2354 | project={props.project} | |
| 2355 | className={cn( | |
| 2356 | "size-4 shrink-0 transition-opacity", | |
| 2357 | props.isSettled && | |
| 2358 | !props.isRouteActive && | |
| 2359 | !props.isHighlighted && | |
| 2360 | "opacity-40 grayscale group-hover/sidebar-row:opacity-100 group-hover/sidebar-row:grayscale-0", | |
| 2361 | )} | |
| 2362 | /> | |
| 2351 | 2363 | ) : null} |
| 2352 | 2364 | <span className="flex min-w-0 flex-1 flex-col"> |
| 2353 | 2365 | <span className="flex min-w-0 items-center gap-2.5"> |
| 2354 | <span className="min-w-0 flex-1 truncate">{thread.title}</span> | |
| 2355 | <span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums"> | |
| 2366 | <span | |
| 2367 | className={cn( | |
| 2368 | "min-w-0 flex-1 truncate", | |
| 2369 | props.isSettled | |
| 2370 | ? "font-normal text-secondary-label/70 group-hover/sidebar-row:text-foreground" | |
| 2371 | : "font-medium text-foreground", | |
| 2372 | (props.isHighlighted || props.isRouteActive) && "text-foreground", | |
| 2373 | thread.titleRegeneration != null && "opacity-55", | |
| 2374 | )} | |
| 2375 | > | |
| 2376 | {thread.title} | |
| 2377 | </span> | |
| 2378 | <span id={`${props.resultId}-pr`} className="contents"> | |
| 2379 | <ThreadSearchPullRequestNumber | |
| 2380 | thread={thread} | |
| 2381 | query={props.searchQuery} | |
| 2382 | enabled={leaseLiveStatus} | |
| 2383 | settled={props.isSettled} | |
| 2384 | /> | |
| 2385 | </span> | |
| 2386 | <span className="w-8 shrink-0 text-right text-xs text-muted-foreground/55 tabular-nums"> | |
| 2356 | 2387 | {threadTimeLabel(thread)} |
| 2357 | 2388 | </span> |
| 2358 | 2389 | </span> |
| ... | ... | @@ -5167,6 +5198,7 @@ export default function Sidebar() { |
| 5167 | 5198 | ); |
| 5168 | 5199 | return ( |
| 5169 | 5200 | <SidebarSearchResultRow |
| 5201 | isSettled={settledThreadKeys.has(threadKey)} | |
| 5170 | 5202 | key={threadKey} |
| 5171 | 5203 | thread={thread} |
| 5172 | 5204 | project={ |
apps/web/src/components/ThreadStatusIndicators.tsx+84-1| ... | ... | @@ -27,7 +27,13 @@ import { |
| 27 | 27 | type ThreadPullRequestBadge, |
| 28 | 28 | } from "@t3tools/shared/threadPullRequests"; |
| 29 | 29 | import { useRender } from "@base-ui/react/use-render"; |
| 30 | import { type ReactNode, type AnimationEvent, type MouseEvent, type ReactElement } from "react"; | |
| 30 | import { | |
| 31 | type ReactNode, | |
| 32 | type AnimationEvent, | |
| 33 | type MouseEvent, | |
| 34 | type ReactElement, | |
| 35 | type ComponentProps, | |
| 36 | } from "react"; | |
| 31 | 37 | import { cn } from "../lib/utils"; |
| 32 | 38 | |
| 33 | 39 | import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; |
| ... | ... | @@ -38,6 +44,7 @@ import { vcsEnvironment } from "../state/vcs"; |
| 38 | 44 | import { useUiStateStore } from "../uiStateStore"; |
| 39 | 45 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 40 | 46 | import { |
| 47 | pullRequestMatchesSearchQuery, | |
| 41 | 48 | resolveThreadLastVisitedAt, |
| 42 | 49 | resolveThreadStatusPill, |
| 43 | 50 | type ThreadStatusPill, |
| ... | ... | @@ -1037,3 +1044,79 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma |
| 1037 | 1044 | </span> |
| 1038 | 1045 | ); |
| 1039 | 1046 | } |
| 1047 | ||
| 1048 | /** Number presentation for compact thread search results. */ | |
| 1049 | function ThreadPullRequestNumber({ | |
| 1050 | number, | |
| 1051 | className, | |
| 1052 | ...props | |
| 1053 | }: ComponentProps<"span"> & { number: number }) { | |
| 1054 | return ( | |
| 1055 | <span {...props} className={cn("shrink-0 text-xs tabular-nums", className)}> | |
| 1056 | #{number} | |
| 1057 | </span> | |
| 1058 | ); | |
| 1059 | } | |
| 1060 | ||
| 1061 | function settledPrHoverColorClass(state: NonNullable<ThreadPr>["state"], isDraft = false): string { | |
| 1062 | switch (state) { | |
| 1063 | case "open": | |
| 1064 | if (isDraft) { | |
| 1065 | return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80"; | |
| 1066 | } | |
| 1067 | return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90"; | |
| 1068 | case "merged": | |
| 1069 | return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90"; | |
| 1070 | case "closed": | |
| 1071 | return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90"; | |
| 1072 | } | |
| 1073 | } | |
| 1074 | ||
| 1075 | export function ThreadSearchPullRequestNumber({ | |
| 1076 | thread, | |
| 1077 | query, | |
| 1078 | enabled = true, | |
| 1079 | settled = false, | |
| 1080 | }: { | |
| 1081 | thread: SidebarThreadSummary; | |
| 1082 | query: string; | |
| 1083 | enabled?: boolean; | |
| 1084 | settled?: boolean; | |
| 1085 | }) { | |
| 1086 | const matchedLink = visibleThreadPullRequests(thread.pullRequests).find((pr) => | |
| 1087 | pullRequestMatchesSearchQuery({ pullRequests: [pr] }, query), | |
| 1088 | ); | |
| 1089 | const fallbackCandidates = | |
| 1090 | thread.pullRequests.length === 0 | |
| 1091 | ? [thread.linkedPullRequest, thread.branchPullRequest] | |
| 1092 | : [thread.branchPullRequest]; | |
| 1093 | const legacyReference = matchedLink | |
| 1094 | ? undefined | |
| 1095 | : fallbackCandidates.find( | |
| 1096 | (pr) => pr != null && pullRequestMatchesSearchQuery({ linkedPullRequest: pr }, query), | |
| 1097 | ); | |
| 1098 | const legacyStatus = useLinkedThreadPullRequest( | |
| 1099 | thread.environmentId, | |
| 1100 | legacyReference, | |
| 1101 | enabled, | |
| 1102 | undefined, | |
| 1103 | legacyReference, | |
| 1104 | ); | |
| 1105 | const reference = matchedLink ?? legacyReference; | |
| 1106 | const linked = matchedLink ? linkedPullRequestSnapshotStatus(matchedLink) : legacyStatus; | |
| 1107 | const status = prStatusIndicator(linked?.pr ?? null, linked?.sourceControlProvider); | |
| 1108 | return reference ? ( | |
| 1109 | <ThreadPullRequestNumber | |
| 1110 | number={reference.number} | |
| 1111 | className={ | |
| 1112 | settled | |
| 1113 | ? cn( | |
| 1114 | "text-secondary-label transition-colors", | |
| 1115 | linked?.pr && settledPrHoverColorClass(linked.pr.state, linked.pr.isDraft), | |
| 1116 | ) | |
| 1117 | : (status?.colorClass ?? "text-secondary-label") | |
| 1118 | } | |
| 1119 | aria-label={status?.tooltip ?? `Pull request #${reference.number}`} | |
| 1120 | /> | |
| 1121 | ) : null; | |
| 1122 | } |
packages/shared/src/threadPullRequests.test.ts+25| ... | ... | @@ -445,6 +445,31 @@ describe("chain selection and badge state", () => { |
| 445 | 445 | }); |
| 446 | 446 | |
| 447 | 447 | describe("threadPullRequestSearchTerms", () => { |
| 448 | it.each([undefined, [], [link(34)]])( | |
| 449 | "searches the branch PR alongside existing links (%j)", | |
| 450 | (pullRequests) => { | |
| 451 | const branchPullRequest = { | |
| 452 | projectId: ProjectId.make("project"), | |
| 453 | repository: "pingdotgg/t3code", | |
| 454 | number: 11809, | |
| 455 | url: "https://github.com/pingdotgg/t3code/pull/11809", | |
| 456 | }; | |
| 457 | const terms = threadPullRequestSearchTerms({ | |
| 458 | pullRequests, | |
| 459 | branchPullRequest, | |
| 460 | linkedPullRequest: { | |
| 461 | ...branchPullRequest, | |
| 462 | number: 12, | |
| 463 | url: "https://github.com/pingdotgg/t3code/pull/12", | |
| 464 | }, | |
| 465 | }); | |
| 466 | for (const query of ["11809", "#11809", "pingdotgg/t3code#11809", branchPullRequest.url]) { | |
| 467 | expect(terms.some((term) => term.includes(query))).toBe(true); | |
| 468 | } | |
| 469 | expect(terms).toContain(pullRequests?.length ? "#34" : "#12"); | |
| 470 | }, | |
| 471 | ); | |
| 472 | ||
| 448 | 473 | it("includes completed and unsynced links but excludes dismissed links", () => { |
| 449 | 474 | const terms = threadPullRequestSearchTerms({ |
| 450 | 475 | pullRequests: [ |
packages/shared/src/threadPullRequests.ts+17-8| ... | ... | @@ -308,21 +308,30 @@ export function resolveThreadPullRequestBadge( |
| 308 | 308 | return { kind: "pull-request", others: visible.length - 1, state }; |
| 309 | 309 | } |
| 310 | 310 | |
| 311 | /** Search terms for visible PR links, including the legacy single-link projection. */ | |
| 311 | /** Search terms for visible PR links, the legacy projection, and the branch PR. */ | |
| 312 | 312 | export function threadPullRequestSearchTerms(thread: { |
| 313 | 313 | readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined; |
| 314 | 314 | readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; |
| 315 | readonly branchPullRequest?: ThreadLinkedPullRequest | null | undefined; | |
| 315 | 316 | }): string[] { |
| 317 | const branch = thread.branchPullRequest; | |
| 318 | const terms = branch | |
| 319 | ? [`#${branch.number}`, `${branch.repository}#${branch.number}`, branch.url] | |
| 320 | : []; | |
| 316 | 321 | if (thread.pullRequests !== undefined && thread.pullRequests.length > 0) { |
| 317 | return visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [ | |
| 318 | `#${link.number}`, | |
| 319 | `${link.repository}#${link.number}`, | |
| 320 | link.url, | |
| 321 | link.snapshot?.title ?? "", | |
| 322 | ]); | |
| 322 | return terms.concat( | |
| 323 | visibleThreadPullRequests(thread.pullRequests).flatMap((link) => [ | |
| 324 | `#${link.number}`, | |
| 325 | `${link.repository}#${link.number}`, | |
| 326 | link.url, | |
| 327 | link.snapshot?.title ?? "", | |
| 328 | ]), | |
| 329 | ); | |
| 323 | 330 | } |
| 324 | 331 | const legacy = thread.linkedPullRequest; |
| 325 | return legacy ? [`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url] : []; | |
| 332 | return legacy | |
| 333 | ? terms.concat([`#${legacy.number}`, `${legacy.repository}#${legacy.number}`, legacy.url]) | |
| 334 | : terms; | |
| 326 | 335 | } |
| 327 | 336 | |
| 328 | 337 | /** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */ |