SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
8.5 KB · 197 lines tsx
Raw Blame History
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