diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 696e740ab..b08068a8d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -180,6 +180,7 @@ interface ChatMarkdownProps { onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + extraRemarkPlugins?: NonNullable; } export function canUseMarkdownFileShellActions( @@ -229,6 +230,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: { } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; +const EMPTY_REMARK_PLUGINS: NonNullable = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, @@ -378,6 +380,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], + a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], }, protocols: { @@ -1830,6 +1833,7 @@ function ChatMarkdown({ onUseArtifactTemplate, imageBaseDir, onImageExpand, + extraRemarkPlugins = EMPTY_REMARK_PLUGINS, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -2237,6 +2241,16 @@ function ChatMarkdown({ : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); + const pullRequestAutolink = String( + (props as Record)["data-pull-request-autolink"] ?? "", + ); + const pullRequestCopy = + pullRequestAutolink === "commit" + ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1] + : pullRequestAutolink === "reference" + ? plainHastText(node) + : undefined; + const isPullRequestAutolink = pullRequestCopy !== undefined; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -2244,6 +2258,8 @@ function ChatMarkdown({ const link = ( - {faviconHost && hastHasText(node) ? ( + {faviconHost && hastHasText(node) && !isPullRequestAutolink ? ( // The provider wraps the result rather than the children: // MarkdownExternalLinkContent inspects its first child for the // leading text it splits with , and an element there @@ -2479,6 +2495,14 @@ function ChatMarkdown({ ]); /* eslint-enable react/no-unstable-nested-components */ + const remarkPlugins = useMemo( + () => [ + ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), + ...extraRemarkPlugins, + ], + [extraRemarkPlugins, lineBreaks], + ); + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the // complete source token instead of dropping it from the rendered message. @@ -2491,9 +2515,7 @@ function ChatMarkdown({ onCopy={handleCopy} > ) : detail ? ( - <> + {mountedTabs.has("summary") ? (
) : null} - +
) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx index a6e51987b..4ffc71e24 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx @@ -34,7 +34,8 @@ function findLabeledGroup(node: ReactNode, label: string): ReactNode { if (!isValidElement(child)) continue; const props = child.props as { readonly children?: ReactNode; readonly label?: string }; if (props.label === label && typeof child.type === "function") { - return (child.type as (properties: unknown) => ReactNode)(child.props); + const rendered = (child.type as (properties: unknown) => ReactNode)(child.props); + return findLabeledGroup(rendered, label) ?? rendered; } const nested = findLabeledGroup(props.children, label); if (nested !== undefined) return nested; @@ -126,7 +127,7 @@ describe("pull request filters menu", () => { projectEnvironmentId: environmentId, onProject, }); - const radioGroup = findValueChange(view); + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); expect(radioGroup).toBeDefined(); radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId })); @@ -156,7 +157,7 @@ describe("pull request filters menu", () => { ], onProject, }); - const radioGroup = findValueChange(view); + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); expect(radioGroup).toBeDefined(); radioGroup?.props.onValueChange( diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 67d2d77e4..9c3bfbab0 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -18,10 +18,11 @@ import { ListFilterIcon, LoaderIcon, SearchIcon, + TagIcon, + UserRoundIcon, } from "lucide-react"; -import type { ElementType } from "react"; +import { type ElementType, useState } from "react"; -import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; @@ -29,27 +30,56 @@ import { Button } from "../ui/button"; import { Menu, + MenuCheckboxItem, MenuGroupLabel, + MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, MenuTrigger, } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + pullRequestLabelColor, + type PullRequestAuthorFacet, + type PullRequestLabelFacet, +} from "./pullRequestList.logic"; +import { PullRequestActorAvatar } from "./pullRequestPresentation"; export interface PullRequestFilterOption { readonly value: Value; readonly label: string; - /** - * Carries the option's own tone, so an icon reads the same here as it does on a row. Left - * uncoloured, which lets the item's selected state stay the thing the eye follows. - */ + /** Uses the option's native icon tone. */ readonly Icon: ElementType<{ className?: string }>; + readonly favicon?: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + }; /** Why it cannot be chosen, carried onto the item as its title. */ readonly unavailable?: string | undefined; } +export function PullRequestFilterOptionIcon({ + option, +}: { + option: PullRequestFilterOption; +}) { + return option.favicon ? ( + + ) : ( + + ); +} + export interface PullRequestExpectedHost { readonly host: string; readonly kind: SourceControlProviderKind; @@ -98,10 +128,8 @@ export function PullRequestSearchInput({ } /** - * Every list filter lives behind the one filter icon so the control row stays two controls - * wide: the search and this. The trigger carries a dot whenever any filter is off its - * default, so a narrowed list is never a mystery. Same menu chrome as the detail panel's - * actions, which also owns its own spacing. + * List narrowings live behind one filter control, separate from sorting. The trigger carries a + * count whenever any filter is off its default, so a narrowed list is never a mystery. */ const ALL_PROJECTS_VALUE = "all"; /** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */ @@ -169,8 +197,9 @@ function PullRequestFilterRadioGroup({ disabled={option.unavailable !== undefined} > - - {option.label} + + {option.label} + {option.unavailable ? · Unavailable : null} ); @@ -188,7 +217,185 @@ function PullRequestFilterRadioGroup({ ); } +function PullRequestFilterRadioSubmenu({ + label, + value, + options, + onChange, +}: { + label: string; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; +}) { + const current = options.find((option) => option.value === value) ?? options[0]; + if (!current) return null; + return ( + + + + {label} + + {current.label} + + + + + + + ); +} + +function PullRequestAuthorFilter({ + value, + options, + onChange, +}: { + value: string | undefined; + options: ReadonlyArray; + onChange: (author: string | undefined) => void; +}) { + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const login = value?.toLowerCase() ?? ""; + const selected = options.find((option) => option.actor.login.toLowerCase() === login); + const visible = [ + ...(selected ? [selected] : []), + ...options.filter( + (option) => + option !== selected && + (needle.length === 0 || + option.actor.login.toLowerCase().includes(needle) || + option.actor.name?.toLowerCase().includes(needle)), + ), + ].slice(0, 10); + const select = (next: string) => next.toLowerCase() !== login && onChange(next || undefined); + return ( + + + + Author + + {value ?? "Anyone"} + + + +
+ + + + + setQuery(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== "ArrowDown" && event.key !== "Escape") event.stopPropagation(); + }} + placeholder="Search authors" + aria-label="Search authors" + /> + +
+ + + + + Anyone + + + {visible.map((option) => ( + + + + {option.actor.login} + + {option.mergedCount} merges loaded + + + + ))} + {visible.length === 0 ? No authors found : null} + +
+
+ ); +} + +function PullRequestLabelFilter({ + value, + options, + onChange, +}: { + value: ReadonlyArray; + options: ReadonlyArray; + onChange: (labels: ReadonlyArray) => void; +}) { + const selected = new Set(value.map((name) => name.toLowerCase())); + const visible = [ + ...value + .filter((name) => !options.some((option) => option.name.toLowerCase() === name.toLowerCase())) + .map((name) => ({ name, color: null, count: 0 })), + ...options, + ]; + return ( + + + + Labels + + {value.length === 0 ? "Any" : `${value.length} selected`} + + + + {visible.length === 0 ? ( + No labels in this view + ) : ( + visible.map((option) => { + const key = option.name.toLowerCase(); + const checked = selected.has(key); + const dot = pullRequestLabelColor(option.color); + return ( + + onChange( + next + ? [...value, option.name] + : value.filter((name) => name.toLowerCase() !== option.name.toLowerCase()), + ) + } + > + + + {option.name} + + {option.count} + + + + ); + }) + )} + + + ); +} + export function PullRequestFiltersMenu({ + onOpenChange, state, stateOptions, onState, @@ -197,6 +404,8 @@ export function PullRequestFiltersMenu({ onInvolvement, filters, onFilters, + authorOptions = [], + labelOptions = [], host, hostOptions, onHost, @@ -209,6 +418,7 @@ export function PullRequestFiltersMenu({ unavailable, onProject, }: { + onOpenChange?: (open: boolean) => void; state: PullRequestListState; stateOptions: ReadonlyArray>; onState: (state: PullRequestListState) => void; @@ -218,6 +428,8 @@ export function PullRequestFiltersMenu({ /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */ filters: PullRequestListFilters; onFilters: (filters: PullRequestListFilters) => void; + authorOptions?: ReadonlyArray; + labelOptions?: ReadonlyArray; host: string | undefined; /** * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real @@ -254,82 +466,119 @@ export function PullRequestFiltersMenu({ /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */ onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void; }) { - const filtered = - state !== "open" || - involvement !== "all" || - host !== undefined || - server !== undefined || - projectId !== undefined || - Object.keys(filters).length > 0; - /** - * Rebuilt rather than spread so an unfiltered group leaves the record instead of lingering in - * it as an explicit `undefined`, which the listing input does not accept. - */ - const withFilter = (key: keyof PullRequestListFilters, value: string): PullRequestListFilters => - Object.fromEntries( - Object.entries({ ...filters, [key]: value === UNFILTERED_VALUE ? undefined : value }).filter( - ([, held]) => held !== undefined, - ), - ) as PullRequestListFilters; + const selectedLabels = (filters.labels ?? []).flatMap((group) => group); + const filterCount = [ + state !== "open", + involvement !== "all", + host, + server, + projectId, + filters.draft, + filters.review, + filters.checks, + filters.author, + ...selectedLabels, + ].filter(Boolean).length; + const updateFilters = (next: Partial) => + onFilters( + Object.fromEntries( + Object.entries({ ...filters, ...next }).filter(([, value]) => value !== undefined), + ) as PullRequestListFilters, + ); + const updateFilter = (key: keyof PullRequestListFilters, value: string) => + updateFilters({ + [key]: value === UNFILTERED_VALUE ? undefined : value, + } as Partial); + const projectValue = + projectId === undefined || projectEnvironmentId === undefined + ? ALL_PROJECTS_VALUE + : pullRequestProjectKey({ id: projectId, environmentId: projectEnvironmentId }); + const projectOptions: ReadonlyArray> = [ + { value: ALL_PROJECTS_VALUE, label: "All projects", Icon: LayersIcon }, + ...projects + .toSorted( + (left, right) => + Number(unavailable.has(pullRequestProjectKey(left))) - + Number(unavailable.has(pullRequestProjectKey(right))), + ) + .map((project) => ({ + value: pullRequestProjectKey(project), + label: project.title, + Icon: FolderGit2Icon, + favicon: { environmentId: project.environmentId, cwd: project.workspaceRoot }, + ...(unavailable.has(pullRequestProjectKey(project)) + ? { unavailable: unavailable.get(pullRequestProjectKey(project)) } + : {}), + })), + ]; return ( -
+ 0 ? "[--control-icon-color:currentColor]" : undefined} variant="outline" - aria-label="Filter pull requests" /> } > - {filtered ? ( - + Filters + {filterCount > 0 ? ( + + {filterCount} + ) : null} - - + - - - updateFilters({ author })} + /> + + updateFilters({ + labels: labels.length === 0 ? undefined : labels.slice(0, 10).map((label) => [label]), + }) + } + /> + onFilters(withFilter("draft", next))} + onChange={(draft) => updateFilter("draft", draft)} /> - - onFilters(withFilter("review", next))} + onChange={(review) => updateFilter("review", review)} /> - - onFilters(withFilter("checks", next))} + onChange={(checks) => updateFilter("checks", checks)} /> {hostOptions.length > 2 ? ( <> - 2 ? ( <> - ) : null} - { - if (next === ALL_PROJECTS_VALUE) { - if (projectId !== undefined) onProject(undefined, undefined); - return; - } - // The value carries both halves, since the id alone cannot tell two servers' rows - // apart once they share one. + { const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next); - if ( - project !== undefined && - (project.id !== projectId || project.environmentId !== projectEnvironmentId) - ) { - onProject(project.id, project.environmentId); - } + if (project) onProject(project.id, project.environmentId); + else if (projectId !== undefined) onProject(undefined, undefined); }} - > - Project - - - - All projects - - - {/* The ones that can be chosen first: a list that opens with three disabled rows reads - as a broken menu rather than as a workspace with three unreadable repositories. */} - {projects - .toSorted( - (left, right) => - Number(unavailable.has(pullRequestProjectKey(left))) - - Number(unavailable.has(pullRequestProjectKey(right))), - ) - .map((project) => { - const reason = unavailable.get(pullRequestProjectKey(project)); - const item = ( - - - - {project.title} - {reason === undefined ? null : ( - - Unavailable - - )} - - - ); - if (reason === undefined) return item; - return ( - - - - {reason} - - - ); - })} - + /> ); diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index f782e5be1..ad06eb21e 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,10 +1,14 @@ import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; import type { EnvironmentId } from "@t3tools/contracts"; +import { createContext, useContext, useMemo } from "react"; +import type { Options as ReactMarkdownOptions } from "react-markdown"; import { cn } from "~/lib/utils"; import ChatMarkdown from "../ChatMarkdown"; -import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; +import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; + +export const PullRequestMarkdownContext = createContext(null); /** * A pull request body, rendered with the app's markdown renderer plus a card for each upload @@ -27,6 +31,11 @@ export function PullRequestMarkdown({ className?: string; }) { const segments = splitPullRequestBody(text); + const repositoryUrl = useContext(PullRequestMarkdownContext); + const extraRemarkPlugins = useMemo>( + () => (repositoryUrl ? [[remarkPullRequestAutolinks, { repositoryUrl }]] : []), + [repositoryUrl], + ); return (
{segments.map((segment) => { @@ -37,6 +46,7 @@ export function PullRequestMarkdown({ text={segment.text} cwd={cwd} environmentId={environmentId} + extraRemarkPlugins={extraRemarkPlugins} /> ); } diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index c7f731f9b..2284144d7 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,5 +1,5 @@ import { SearchIcon } from "lucide-react"; -import { memo } from "react"; +import { memo, type RefCallback } from "react"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; @@ -7,7 +7,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; -import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; +import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; import { PullRequestActorLabel, @@ -16,6 +16,23 @@ import { PullRequestStateGlyph, } from "./pullRequestPresentation"; +function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry["labels"] }) { + const label = labels[0]; + if (!label) return null; + const dot = pullRequestLabelColor(label.color); + return ( + + + {label.name} + {labels.length > 1 ? +{labels.length - 1} : null} + + ); +} + function PullRequestRowImpl({ entry, selected, @@ -23,6 +40,8 @@ function PullRequestRowImpl({ showProvider, environmentLabel, matchedElsewhere, + statsKey, + statsRef, onSelect, }: { entry: EnvironmentPullRequestEntry; @@ -37,11 +56,16 @@ function PullRequestRowImpl({ * commit message. Saying so is the difference between a result and an apparently random row. */ matchedElsewhere?: boolean; + /** Used by the list's shared visibility observer to defer optional line-count reads. */ + statsKey?: string; + statsRef?: RefCallback; onSelect: (entry: EnvironmentPullRequestEntry) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); return (