mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
Clean up search page visual design (PAP-3468)
Drop the candy-colored Title/Comment/Doc chips that competed with the title and were redundant with the snippet field labels. Replace the all-caps snippet labels with subtle leading icons (MessageSquare, FileText, Quote) so each result reads as one tidy line of metadata under the title. Bump row padding, lighten the group section header treatment (no muted-bg slab), and switch row dividers to a softer border-border/50. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e35c533c5b
commit
0ae2bb0b60
3 files changed
+91
-100
No files matched your search
@@ -1,37 +1,26 @@
|
||||
import { useMemo } from "react";
|
||||
import { Bot, Hexagon } from "lucide-react";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import { Bot, FileText, Hexagon, MessageSquare, Quote } from "lucide-react";
|
||||
import type { Agent, CompanySearchResult, Project } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { StatusIcon } from "../StatusIcon";
|
||||
import { StatusBadge } from "../StatusBadge";
|
||||
import { Identity } from "../Identity";
|
||||
import { HighlightedText } from "./HighlightedText";
|
||||
import { MatchSourceChip, type MatchSourceChipKind } from "./MatchSourceChip";
|
||||
import { HighlightedText, type HighlightedTextProps } from "./HighlightedText";
|
||||
|
||||
const SNIPPET_FIELD_LABEL: Record<string, string> = {
|
||||
comment: "COMMENT",
|
||||
document: "DOC",
|
||||
identifier: "IDENTIFIER",
|
||||
description: "DESCRIPTION",
|
||||
title: "TITLE",
|
||||
type SnippetStyle = {
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function deriveChips(matchedFields: string[]): Array<{ kind: MatchSourceChipKind; count?: number }> {
|
||||
const counts = new Map<MatchSourceChipKind, number>();
|
||||
for (const field of matchedFields) {
|
||||
let kind: MatchSourceChipKind | null = null;
|
||||
if (field === "title" || field === "description") kind = "title";
|
||||
else if (field === "identifier") kind = "identifier";
|
||||
else if (field === "comment") kind = "comment";
|
||||
else if (field === "document") kind = "document";
|
||||
if (!kind) continue;
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||
}
|
||||
const order: MatchSourceChipKind[] = ["title", "identifier", "comment", "document"];
|
||||
return order
|
||||
.filter((kind) => counts.has(kind))
|
||||
.map((kind) => ({ kind, count: counts.get(kind) ?? 1 }));
|
||||
const SNIPPET_STYLES: Record<string, SnippetStyle> = {
|
||||
comment: { Icon: MessageSquare, label: "Comment" },
|
||||
document: { Icon: FileText, label: "Doc" },
|
||||
description: { Icon: Quote, label: "Description" },
|
||||
};
|
||||
|
||||
function snippetStyle(field: string, fallbackLabel: string): SnippetStyle {
|
||||
return SNIPPET_STYLES[field] ?? { Icon: Quote, label: fallbackLabel };
|
||||
}
|
||||
|
||||
function formatRelativeTime(input: string | null): string {
|
||||
@@ -70,20 +59,18 @@ export function SearchResultRow({
|
||||
isActive,
|
||||
className,
|
||||
}: SearchResultRowProps) {
|
||||
const chips = useMemo(() => deriveChips(result.matchedFields), [result.matchedFields]);
|
||||
|
||||
if (result.type === "agent") {
|
||||
return (
|
||||
<Link
|
||||
to={result.href}
|
||||
className={cn(
|
||||
"group flex items-start gap-3 px-3 py-2 hover:bg-accent/40",
|
||||
"group flex items-start gap-3 px-4 py-2.5 transition-colors hover:bg-accent/40",
|
||||
isActive && "bg-accent/40",
|
||||
className,
|
||||
)}
|
||||
data-result-type="agent"
|
||||
>
|
||||
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Bot className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -91,16 +78,12 @@ export function SearchResultRow({
|
||||
<span className="truncate text-sm font-medium">{result.title}</span>
|
||||
</div>
|
||||
{result.snippet ? (
|
||||
<div className="mt-0.5 flex min-w-0 items-baseline gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
|
||||
{result.sourceLabel ?? "Agent"}
|
||||
</span>
|
||||
<HighlightedText
|
||||
text={result.snippets[0]?.text ?? result.snippet}
|
||||
highlights={result.snippets[0]?.highlights}
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<SnippetLine
|
||||
text={result.snippets[0]?.text ?? result.snippet}
|
||||
highlights={result.snippets[0]?.highlights}
|
||||
field="agent"
|
||||
fallbackLabel={result.sourceLabel ?? "Agent"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -112,7 +95,7 @@ export function SearchResultRow({
|
||||
<Link
|
||||
to={result.href}
|
||||
className={cn(
|
||||
"group flex items-start gap-3 px-3 py-2 hover:bg-accent/40",
|
||||
"group flex items-start gap-3 px-4 py-2.5 transition-colors hover:bg-accent/40",
|
||||
isActive && "bg-accent/40",
|
||||
className,
|
||||
)}
|
||||
@@ -122,16 +105,12 @@ export function SearchResultRow({
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate text-sm font-medium">{result.title}</span>
|
||||
{result.snippet ? (
|
||||
<div className="mt-0.5 flex min-w-0 items-baseline gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
|
||||
{result.sourceLabel ?? "Project"}
|
||||
</span>
|
||||
<HighlightedText
|
||||
text={result.snippets[0]?.text ?? result.snippet}
|
||||
highlights={result.snippets[0]?.highlights}
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<SnippetLine
|
||||
text={result.snippets[0]?.text ?? result.snippet}
|
||||
highlights={result.snippets[0]?.highlights}
|
||||
field="project"
|
||||
fallbackLabel={result.sourceLabel ?? "Project"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -146,72 +125,57 @@ export function SearchResultRow({
|
||||
const projectName = issue.projectId ? projectsById?.get(issue.projectId)?.name ?? null : null;
|
||||
const updated = formatRelativeTime(result.updatedAt ?? issue.updatedAt);
|
||||
const titleHighlights = result.snippets.find((snippet) => snippet.field === "title")?.highlights;
|
||||
const bodySnippets = result.snippets.filter((snippet) => snippet.field !== "title").slice(0, 2);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={result.href}
|
||||
className={cn(
|
||||
"group flex items-start gap-3 px-3 py-2 hover:bg-accent/40",
|
||||
"group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-accent/40",
|
||||
isActive && "bg-accent/40",
|
||||
className,
|
||||
)}
|
||||
data-result-type="issue"
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<div className="mt-1 shrink-0">
|
||||
<StatusIcon status={issue.status} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<div className="flex min-w-0 items-baseline gap-x-2.5">
|
||||
{issue.identifier ? (
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
) : null}
|
||||
<HighlightedText
|
||||
text={issue.title}
|
||||
highlights={titleHighlights}
|
||||
className="line-clamp-2 min-w-0 flex-1 text-sm leading-snug"
|
||||
className="line-clamp-2 min-w-0 flex-1 text-sm font-medium leading-snug text-foreground"
|
||||
/>
|
||||
<div className="ml-auto hidden items-center gap-2 sm:flex">
|
||||
<div className="ml-2 hidden shrink-0 items-center gap-2.5 text-xs text-muted-foreground sm:flex">
|
||||
<StatusBadge status={issue.status} />
|
||||
{assigneeName ? <Identity name={assigneeName} size="sm" /> : null}
|
||||
{projectName ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Hexagon className="h-3 w-3" />
|
||||
<span className="max-w-[10ch] truncate">{projectName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{updated ? (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{updated}</span>
|
||||
<span className="tabular-nums">{updated}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{chips.length > 0 ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{chips.map((chip) => (
|
||||
<MatchSourceChip key={chip.kind} kind={chip.kind} count={chip.count} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{result.snippets
|
||||
.filter((snippet) => snippet.field !== "title")
|
||||
.slice(0, 2)
|
||||
.map((snippet, index) => (
|
||||
<div
|
||||
key={`${snippet.field}-${index}`}
|
||||
className="mt-1 flex min-w-0 items-baseline gap-2"
|
||||
>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
|
||||
{SNIPPET_FIELD_LABEL[snippet.field] ?? snippet.label.toUpperCase()}
|
||||
</span>
|
||||
<HighlightedText
|
||||
text={snippet.text}
|
||||
highlights={snippet.highlights}
|
||||
className="line-clamp-1 truncate text-xs text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground sm:hidden">
|
||||
{bodySnippets.map((snippet, index) => (
|
||||
<SnippetLine
|
||||
key={`${snippet.field}-${index}`}
|
||||
text={snippet.text}
|
||||
highlights={snippet.highlights}
|
||||
field={snippet.field}
|
||||
fallbackLabel={snippet.label}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1.5 flex items-center gap-2 text-xs text-muted-foreground sm:hidden">
|
||||
<StatusBadge status={issue.status} />
|
||||
{assigneeName ? <span className="truncate">{assigneeName}</span> : null}
|
||||
{projectName ? <span className="truncate">· {projectName}</span> : null}
|
||||
@@ -221,3 +185,25 @@ export function SearchResultRow({
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface SnippetLineProps {
|
||||
text: string;
|
||||
highlights?: HighlightedTextProps["highlights"];
|
||||
field: string;
|
||||
fallbackLabel: string;
|
||||
}
|
||||
|
||||
function SnippetLine({ text, highlights, field, fallbackLabel }: SnippetLineProps) {
|
||||
const { Icon, label } = snippetStyle(field, fallbackLabel);
|
||||
return (
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Icon className="h-3 w-3 shrink-0 text-muted-foreground/60" aria-hidden />
|
||||
<span className="sr-only">{label}: </span>
|
||||
<HighlightedText
|
||||
text={text}
|
||||
highlights={highlights}
|
||||
className="line-clamp-1 truncate"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
-7
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
@@ -593,16 +594,20 @@ function SearchTabContent({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" data-testid="search-results">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between px-4 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<span>
|
||||
{totalResults === 1 ? "1 result" : `${totalResults} results`} · sorted by relevance
|
||||
</span>
|
||||
{isFetching ? <span aria-live="polite">Updating…</span> : null}
|
||||
{isFetching ? <span aria-live="polite" className="normal-case tracking-normal">Updating…</span> : null}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{scope === "all" ? (
|
||||
subgroups.map((group) => (
|
||||
<section key={group.key} aria-label={SUBGROUP_LABELS[group.key]} className="flex flex-col">
|
||||
subgroups.map((group, groupIndex) => (
|
||||
<section
|
||||
key={group.key}
|
||||
aria-label={SUBGROUP_LABELS[group.key]}
|
||||
className={cn("flex flex-col", groupIndex > 0 && "mt-3 border-t border-border")}
|
||||
>
|
||||
<IssueGroupHeader
|
||||
label={SUBGROUP_LABELS[group.key]}
|
||||
trailing={
|
||||
@@ -610,9 +615,9 @@ function SearchTabContent({
|
||||
{group.results.length}
|
||||
</span>
|
||||
}
|
||||
className="border-b border-border bg-muted/30"
|
||||
className="px-4 pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
/>
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
{group.results.map((result) => (
|
||||
<SearchResultRow
|
||||
key={`${result.type}:${result.id}:${result.href}`}
|
||||
@@ -625,7 +630,7 @@ function SearchTabContent({
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
{subgroups
|
||||
.flatMap((group) => group.results)
|
||||
.map((result) => (
|
||||
|
||||
@@ -263,7 +263,7 @@ function SearchPagePreview({
|
||||
|
||||
{state === "results" ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between px-4 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<span>{response.results.length} results · sorted by relevance</span>
|
||||
</div>
|
||||
<section aria-label="Issues" className="flex flex-col">
|
||||
@@ -274,9 +274,9 @@ function SearchPagePreview({
|
||||
{fixtureResults.length}
|
||||
</span>
|
||||
}
|
||||
className="border-b border-border bg-muted/30"
|
||||
className="px-4 pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
/>
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
{fixtureResults.map((result) => (
|
||||
<SearchResultRow
|
||||
key={result.id}
|
||||
@@ -287,7 +287,7 @@ function SearchPagePreview({
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section aria-label="Agents" className="flex flex-col">
|
||||
<section aria-label="Agents" className="mt-3 flex flex-col border-t border-border">
|
||||
<IssueGroupHeader
|
||||
label="Agents"
|
||||
trailing={
|
||||
@@ -295,15 +295,15 @@ function SearchPagePreview({
|
||||
{fixtureAgents.length}
|
||||
</span>
|
||||
}
|
||||
className="border-b border-border bg-muted/30"
|
||||
className="px-4 pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
/>
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
{fixtureAgents.map((result) => (
|
||||
<SearchResultRow key={result.id} result={result} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section aria-label="Projects" className="flex flex-col">
|
||||
<section aria-label="Projects" className="mt-3 flex flex-col border-t border-border">
|
||||
<IssueGroupHeader
|
||||
label="Projects"
|
||||
trailing={
|
||||
@@ -311,9 +311,9 @@ function SearchPagePreview({
|
||||
{fixtureProjects.length}
|
||||
</span>
|
||||
}
|
||||
className="border-b border-border bg-muted/30"
|
||||
className="px-4 pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
|
||||
/>
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
{fixtureProjects.map((result) => (
|
||||
<SearchResultRow key={result.id} result={result} />
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user