import type { Meta, StoryObj } from "@storybook/react-vite"; import type { CompanySearchResult, CompanySearchResponse } from "@paperclipai/shared"; import { Badge } from "@/components/ui/badge"; import { IssueGroupHeader } from "@/components/IssueGroupHeader"; import { Input } from "@/components/ui/input"; import { PageTabBar, type PageTabItem } from "@/components/PageTabBar"; import { MatchSourceChip } from "@/components/search/MatchSourceChip"; import { SearchResultRow } from "@/components/search/SearchResultRow"; import { Tabs } from "@/components/ui/tabs"; import { Bot, CircleDot, DollarSign, Hexagon, History, Inbox, LayoutDashboard, Plus, Search as SearchIcon, SquarePen, Target, } from "lucide-react"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@/components/ui/command"; import { StatusBadge } from "@/components/StatusBadge"; import { storybookAgents, storybookProjects, storybookIssues } from "../fixtures/paperclipData"; const agentsById = new Map(storybookAgents.map((agent) => [agent.id, agent])); type IssueResultOverrides = Omit, "issue"> & { issue?: Partial>; }; function buildIssueResult(overrides: IssueResultOverrides): CompanySearchResult { const baseIssue = { id: overrides.issue?.id ?? "issue-1", identifier: overrides.issue?.identifier ?? "PAP-3142", title: overrides.issue?.title ?? "Auth middleware flakes on cold-start when session token is rotated", status: overrides.issue?.status ?? "in_progress", priority: overrides.issue?.priority ?? "high", assigneeAgentId: overrides.issue?.assigneeAgentId ?? storybookAgents[0]?.id ?? null, assigneeUserId: overrides.issue?.assigneeUserId ?? null, projectId: overrides.issue?.projectId ?? storybookProjects[0]?.id ?? null, updatedAt: overrides.issue?.updatedAt ?? new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(), } satisfies NonNullable; return { id: overrides.id ?? baseIssue.id, type: "issue", score: 100, title: `${baseIssue.identifier} ${baseIssue.title}`, href: `/PAP/issues/${baseIssue.identifier}`, matchedFields: overrides.matchedFields ?? ["title"], sourceLabel: overrides.sourceLabel ?? null, snippet: overrides.snippet ?? null, snippets: overrides.snippets ?? [], issue: baseIssue, updatedAt: baseIssue.updatedAt, previewImageUrl: overrides.previewImageUrl ?? null, }; } const fixtureResults: CompanySearchResult[] = [ buildIssueResult({ id: "issue-1", matchedFields: ["title", "comment"], sourceLabel: "Comment", snippet: "we hit another flake in the morning batch — auth middleware", snippets: [ { field: "title", label: "Title", text: "Auth middleware flakes on cold-start when session token is rotated", highlights: [{ start: 0, end: 4 }], }, { field: "comment", label: "Comment", text: "we hit another flake in the morning batch — auth middleware ate the request", highlights: [{ start: 16, end: 21 }, { start: 47, end: 51 }], }, ], }), buildIssueResult({ id: "issue-2", issue: { id: "issue-2", identifier: "PAP-3091", title: "Audit auth flake telemetry from last quarter", status: "in_review", assigneeAgentId: storybookAgents[1]?.id ?? null, }, matchedFields: ["title", "document"], sourceLabel: "Document", snippet: "the deflake plan ranks auth regressions above latency tickets", snippets: [ { field: "title", label: "Title", text: "Audit auth flake telemetry from last quarter", highlights: [{ start: 6, end: 10 }], }, { field: "document", label: "PLAN", text: "the deflake plan ranks auth regressions above latency tickets", highlights: [{ start: 12, end: 16 }, { start: 26, end: 30 }], }, ], previewImageUrl: "data:image/svg+xml;utf8,chart", }), buildIssueResult({ id: "issue-3", issue: { id: "issue-3", identifier: "PAP-2748", title: "Pin worker registration to a single auth backend", status: "done", assigneeAgentId: null, }, matchedFields: ["title", "identifier"], snippets: [ { field: "title", label: "Title", text: "Pin worker registration to a single auth backend", highlights: [{ start: 36, end: 40 }], }, ], }), ]; const fixtureAgents: CompanySearchResult[] = storybookAgents.slice(0, 1).map((agent) => ({ id: agent.id, type: "agent" as const, score: 80, title: agent.name, href: `/PAP/agents/${agent.id}`, matchedFields: ["agent"], sourceLabel: "Agent", snippet: agent.capabilities ?? null, snippets: agent.capabilities ? [ { field: "capabilities", label: "Agent", text: agent.capabilities, highlights: [], }, ] : [], updatedAt: new Date().toISOString(), previewImageUrl: null, })); const fixtureProjects: CompanySearchResult[] = storybookProjects.slice(0, 1).map((project) => ({ id: project.id, type: "project" as const, score: 70, title: project.name, href: `/PAP/projects/${project.id}`, matchedFields: ["project"], sourceLabel: "Project", snippet: project.description ?? null, snippets: project.description ? [ { field: "description", label: "Project", text: project.description, highlights: [], }, ] : [], updatedAt: new Date().toISOString(), previewImageUrl: null, })); const fixtureResponse: CompanySearchResponse = { query: "auth flake", normalizedQuery: "auth flake", scope: "all", limit: 20, offset: 0, results: [...fixtureResults, ...fixtureAgents, ...fixtureProjects], countsByType: { issue: fixtureResults.length, agent: fixtureAgents.length, project: fixtureProjects.length, }, hasMore: false, }; function ScopeTabsPreview({ active, response, }: { active: "all" | "issues" | "comments" | "documents" | "agents" | "projects"; response: CompanySearchResponse; }) { const total = (response.countsByType.issue ?? 0) + (response.countsByType.agent ?? 0) + (response.countsByType.project ?? 0); const items: PageTabItem[] = [ { value: "all", label: }, { value: "issues", label: }, { value: "comments", label: result.matchedFields.includes("comment")).length} /> }, { value: "documents", label: result.matchedFields.includes("document")).length} /> }, { value: "agents", label: }, { value: "projects", label: }, ]; return ( ); } function ScopeTabLabel({ label, count }: { label: string; count: number }) { return ( {label} {count} ); } function SearchPagePreview({ response, state, query, }: { response: CompanySearchResponse; state: "results" | "empty" | "loading" | "initial"; query: string; }) { return (
⌘K
{state === "results" ? (
{response.results.length} results · sorted by relevance
{fixtureResults.length} } className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground" />
{fixtureResults.map((result) => ( ))}
{fixtureAgents.length} } className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground" />
{fixtureAgents.map((result) => ( ))}
{fixtureProjects.length} } className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground" />
{fixtureProjects.map((result) => ( ))}
) : null} {state === "empty" ? (
No results for “{query}”

We couldn’t find a match in all scopes. Try widening the scope or rephrasing your query.

  • Try fewer tokens or a single distinctive term.
  • Use an identifier shortcut like PAP-123.
) : null} {state === "loading" ? (
Searching for “{query}”…
{Array.from({ length: 5 }).map((_, index) => (
))}
) : null} {state === "initial" ? (

Type to search company memory.

Issues, comments, plan documents, agents, projects — same surface, ranked by relevance.

  • Identifier lookup: type{" "} PAP-123 to jump straight to an issue.
  • Quoted phrases: wrap a phrase in quotes to match the exact sequence.
  • ⌘K: reopens the command palette pre-seeded with your current query.
) : null}
); } function CommandPaletteWithSearchAll({ query, emptyResults = false, }: { query: string; emptyResults?: boolean; }) { return ( {emptyResults ? ( No quick issue matches. Press{" "} {" "} to search all or keep typing to refine. ) : null} Search all for “{query}” open full search Create new issue C Create new agent Dashboard Inbox Issues Goals Agents Costs Activity {!emptyResults ? ( <> {storybookIssues.slice(0, 3).map((issue) => ( {issue.identifier} {issue.title} ))} ) : null} {storybookProjects.slice(0, 2).map((project) => ( {project.name} ))} ); } function SearchStories() { return (
Search

Full search page and Command K handoff

Snippet-forward results, scope tabs, match-source chips, and the supporting empty / loading / initial states. Cmd K palette renders the persistent “Search all for…” row when a query is non-empty.

/search

Results, query “auth flake”

/search

Initial state — no query

/search

Loading skeleton

/search

No results state

Match-source chips

Type-coded chip variants

Cmd+K palette

Search-all row with quick results

With quick issue matches
Empty results — Enter routes to /search
Search result row

Issue, agent, project rows

{fixtureResults.map((result) => ( ))} {fixtureAgents.map((result) => ( ))} {fixtureProjects.map((result) => ( ))}
); } const meta = { title: "Product/Search & Command K", component: SearchStories, parameters: { docs: { description: { component: "Full search page surfaces and Command K Search-all handoff. Reuses StatusIcon, StatusBadge, Identity, IssueGroupHeader, and PageTabBar; adds MatchSourceChip + SearchResultRow.", }, }, }, } satisfies Meta; export default meta; type Story = StoryObj; export const SearchSurfaces: Story = {};