diff --git a/ui/storybook/stories/search.stories.tsx b/ui/storybook/stories/search.stories.tsx new file mode 100644 index 00000000000..ec779d029a7 --- /dev/null +++ b/ui/storybook/stories/search.stories.tsx @@ -0,0 +1,466 @@ +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 { Search as SearchIcon } from "lucide-react"; +import { storybookAgents, storybookProjects } from "../fixtures/paperclipData"; + +const agentsById = new Map(storybookAgents.map((agent) => [agent.id, agent])); +const projectsById = new Map(storybookProjects.map((project) => [project.id, project])); + +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, + }; +} + +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 }], + }, + ], + }), + 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(), +})); + +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(), +})); + +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="border-b border-border bg-muted/30" + /> +
+ {fixtureResults.map((result) => ( + + ))} +
+
+
+ + {fixtureAgents.length} + + } + className="border-b border-border bg-muted/30" + /> +
+ {fixtureAgents.map((result) => ( + + ))} +
+
+
+ + {fixtureProjects.length} + + } + className="border-b border-border bg-muted/30" + /> +
+ {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 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

+
+
+ + + + + +
+
+ +
+
+
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 = {};