TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { usePathname, useRouter } from "next/navigation";4import { Archive, Bot, FolderKanban, Link2, MessageSquare, Pin, Sparkles, User, WandSparkles } from "lucide-react";5import type { SearchConversationHit, SearchMessageHit, SearchModelHit, SearchPromptHit, SearchProjectHit, SearchPresetHit, SearchGroup, SearchResponse } from "@/lib/client/types";6import { useApp } from "@/components/app/store";7import { ProviderIcon } from "@/components/brand/provider-icon";8import { providerName } from "@/lib/client/providers";9import { cn, formatRelative } from "@/lib/utils";10import { Highlight } from "./highlight";1112export const GROUP_LABELS: Record<SearchGroup, string> = {13 conversations: "Conversations",14 messages: "Messages",15 models: "Models",16 prompts: "Prompts",17 projects: "Projects",18 presets: "Presets",19};2021export const GROUP_ORDER: SearchGroup[] = ["conversations", "messages", "projects", "models", "prompts", "presets"];2223export function groupItems(data: SearchResponse, group: SearchGroup): unknown[] {24 return data[group] as unknown[];25}2627/* ------------------------------------------------------------------------------------------------28 * Navigation — one place that knows where every hit goes.29 * messages → /app/chat/<conversationId>#<messageId> (chat-view scrolls to the anchor)30 * ---------------------------------------------------------------------------------------------- */31export function useSearchNavigation(opts: { onNavigate?: (href: string | null) => void } = {}) {32 const router = useRouter();33 const pathname = usePathname();34 const { setSelectedModelKey } = useApp();35 const done = opts.onNavigate;3637 const go = React.useCallback(38 (href: string) => {39 done?.(href);40 router.push(href);41 },42 [router, done],43 );4445 return React.useMemo(46 () => ({47 openConversation: (c: SearchConversationHit) => go(`/app/chat/${c.id}`),48 openMessage: (m: SearchMessageHit) => {49 const href = `/app/chat/${m.conversationId}#${m.id}`;50 go(href);51 // Same conversation already open: the route does not remount, so scroll ourselves.52 if (pathname === `/app/chat/${m.conversationId}`) {53 requestAnimationFrame(() => document.getElementById(m.id)?.scrollIntoView({ block: "center", behavior: "smooth" }));54 }55 },56 openModel: (m: SearchModelHit) => {57 setSelectedModelKey(m.key);58 window.dispatchEvent(new CustomEvent("polyllm:switch-model", { detail: { modelKey: m.key } }));59 if (!/^\/app\/chat/.test(pathname)) go("/app/chat");60 else done?.(null);61 },62 // Library prompts insert into the composer (workstream D contract); legacy presets open their editor.63 openPrompt: (p: SearchPromptHit) => go(p.kind === "library" ? `/app/chat?promptId=${p.id}` : `/app/prompts?edit=${p.id}`),64 openProject: (p: SearchProjectHit) => go(`/app/projects/${p.id}`),65 openPreset: (p: SearchPresetHit) => go(`/app/chat?preset=${p.id}`),66 }),67 [go, pathname, setSelectedModelKey, done],68 );69}7071/* ------------------------------------------------------------------------------------------------72 * Row contents (presentational — wrapped by <button> on phones or <Command.Item> in the palette)73 * ---------------------------------------------------------------------------------------------- */74const iconBox = "flex size-8 shrink-0 items-center justify-center rounded-lg bg-bg-muted text-fg-muted [&_svg]:size-4";7576function modelShort(key: string | null): string | null {77 if (!key) return null;78 const [, ...rest] = key.split("/");79 return rest.join("/") || key;80}8182export function ConversationHitContent({ hit, terms, dense }: { hit: SearchConversationHit; terms: string[]; dense?: boolean }) {83 return (84 <>85 <span className={iconBox}>86 <ProviderIcon provider={hit.provider} size={16} />87 </span>88 <span className="min-w-0 flex-1">89 <span className={cn("flex items-center gap-1.5", dense ? "text-[13.5px]" : "text-[15px]")}>90 <Highlight text={hit.title} terms={terms} className="truncate font-medium text-fg" />91 {hit.pinned ? <Pin className="size-3 shrink-0 text-fg-subtle" aria-label="Pinned" /> : null}92 {hit.archived ? <Archive className="size-3 shrink-0 text-fg-subtle" aria-label="Archived" /> : null}93 {hit.shared ? <Link2 className="size-3 shrink-0 text-fg-subtle" aria-label="Shared" /> : null}94 </span>95 <span className="mt-0.5 flex items-center gap-1.5 truncate text-[11.5px] tabular-nums text-fg-subtle">96 <span>{formatRelative(hit.lastMessageAt ?? hit.updatedAt)}</span>97 <span aria-hidden>·</span>98 <span>{hit.messageCount} msg</span>99 {hit.modelKey ? (100 <>101 <span aria-hidden>·</span>102 <span className="truncate">{modelShort(hit.modelKey)}</span>103 </>104 ) : null}105 </span>106 </span>107 </>108 );109}110111export function MessageHitContent({ hit, terms, dense }: { hit: SearchMessageHit; terms: string[]; dense?: boolean }) {112 const isUser = hit.role === "user";113 return (114 <>115 <span className={cn(iconBox, "mt-0.5 self-start")}>{isUser ? <User /> : <Bot />}</span>116 <span className="min-w-0 flex-1">117 <span className="flex items-center gap-1.5 text-[11.5px] text-fg-subtle">118 <span className="truncate font-medium text-fg-muted">{hit.title}</span>119 <span aria-hidden>·</span>120 <span className="shrink-0">{isUser ? "You" : modelShort(hit.modelKey) ?? "Assistant"}</span>121 <span aria-hidden>·</span>122 <span className="shrink-0 tabular-nums">{formatRelative(hit.createdAt)}</span>123 </span>124 <Highlight text={hit.snippet} terms={terms} className={cn("mt-0.5 block text-fg", dense ? "line-clamp-2 text-[13px] leading-5" : "line-clamp-3 text-[14px] leading-5")} />125 </span>126 </>127 );128}129130export function ModelHitContent({ hit, dense }: { hit: SearchModelHit; dense?: boolean }) {131 return (132 <>133 <span className={iconBox}>134 <ProviderIcon provider={hit.provider} size={16} />135 </span>136 <span className="min-w-0 flex-1">137 <span className={cn("block truncate font-medium text-fg", dense ? "text-[13.5px]" : "text-[15px]")}>{hit.displayName}</span>138 <span className="block truncate text-[11.5px] text-fg-subtle">139 {providerName(hit.provider)} · {hit.key.split("/").slice(1).join("/")}140 {hit.status === "deprecated" ? " · deprecated" : ""}141 </span>142 </span>143 </>144 );145}146147export function PromptHitContent({ hit, terms, dense }: { hit: SearchPromptHit; terms: string[]; dense?: boolean }) {148 return (149 <>150 <span className={iconBox}>151 <WandSparkles />152 </span>153 <span className="min-w-0 flex-1">154 <Highlight text={hit.name} terms={terms} className={cn("block truncate font-medium text-fg", dense ? "text-[13.5px]" : "text-[15px]")} />155 <span className="block truncate text-[11.5px] text-fg-subtle">{hit.description || (hit.kind === "library" ? "Prompt library · insert into composer" : "Prompt preset")}</span>156 </span>157 </>158 );159}160161export function ProjectHitContent({ hit, terms, dense }: { hit: SearchProjectHit; terms: string[]; dense?: boolean }) {162 return (163 <>164 <span className={iconBox} style={hit.color ? { color: hit.color } : undefined}>165 {hit.icon ? <span className="text-[15px] leading-none">{hit.icon}</span> : <FolderKanban />}166 </span>167 <span className="min-w-0 flex-1">168 <Highlight text={hit.name} terms={terms} className={cn("block truncate font-medium text-fg", dense ? "text-[13.5px]" : "text-[15px]")} />169 <span className="block truncate text-[11.5px] text-fg-subtle">{hit.description || "Project"}</span>170 </span>171 </>172 );173}174175export function PresetHitContent({ hit, terms, dense }: { hit: SearchPresetHit; terms: string[]; dense?: boolean }) {176 return (177 <>178 <span className={iconBox}>179 <Sparkles />180 </span>181 <span className="min-w-0 flex-1">182 <Highlight text={hit.name} terms={terms} className={cn("block truncate font-medium text-fg", dense ? "text-[13.5px]" : "text-[15px]")} />183 <span className="block truncate text-[11.5px] text-fg-subtle">{hit.description || `Model preset · ${modelShort(hit.modelKey)}`}</span>184 </span>185 </>186 );187}188189export const GROUP_ICONS: Record<SearchGroup, React.ReactNode> = {190 conversations: <MessageSquare />,191 messages: <MessageSquare />,192 models: <Bot />,193 prompts: <WandSparkles />,194 projects: <FolderKanban />,195 presets: <Sparkles />,196};197