Skip to content

Commit cf0687f

Browse files
committed
feat(ai-search): enhance AI search panel with improved layout and persistence
- Refactor AI search components into separate panel and trigger elements - Add localStorage persistence for search input - Improve button styling and variants - Optimize markdown rendering performance - Fix error handling in copy button functionality
1 parent 1fa7e6c commit cf0687f

File tree

6 files changed

+148
-90
lines changed

6 files changed

+148
-90
lines changed

apps/docs/app/(home)/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import Link from "next/link";
21
import { DocsIcon, GitHubIcon } from "@/components/icons";
32
import Ripple from "@/components/landing/ripple";
43
import { Button } from "@/components/ui/button";
4+
import Link from "next/link";
55

66
export default function HomePage() {
77
return (
@@ -20,14 +20,14 @@ export default function HomePage() {
2020

2121
<div className="flex w-full items-center justify-center gap-4 py-4">
2222
<Link href="/docs">
23-
<Button className="flex gap-2">
23+
<Button className="flex gap-2" size="home-default">
2424
<DocsIcon />
2525
Docs
2626
</Button>
2727
</Link>
2828

2929
<Link href="https://github.com/zayne-labs/callapi">
30-
<Button className="flex gap-2" theme="secondary">
30+
<Button className="flex gap-2" theme="secondary" size="home-default">
3131
<GitHubIcon />
3232
Github
3333
</Button>

apps/docs/app/docs/layout.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AISearchTrigger } from "@/components/ai/search";
1+
import { AISearchPanel, AISearchRoot, AISearchTrigger } from "@/components/ai/search";
22
import { BgPattern } from "@/components/icons";
33
import "fumadocs-twoslash/twoslash.css";
44
import { DocsLayout } from "fumadocs-ui/layouts/notebook";
@@ -18,7 +18,10 @@ function Layout(props: LayoutProps<"/docs">) {
1818

1919
{children}
2020

21-
<AISearchTrigger />
21+
<AISearchRoot>
22+
<AISearchPanel />
23+
<AISearchTrigger />
24+
</AISearchRoot>
2225
</DocsLayout>
2326
);
2427
}

apps/docs/components/ai/markdown.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { tw } from "@zayne-labs/toolkit-core";
12
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
23
import defaultMdxComponents from "fumadocs-ui/mdx";
34
import type { ElementContent, Root, RootContent } from "hast";
@@ -42,7 +43,7 @@ function rehypeWrapWords() {
4243
return {
4344
children: [{ type: "text", value: word }],
4445
properties: {
45-
class: "animate-fd-fade-in",
46+
class: tw`animate-fd-fade-in`,
4647
},
4748
tagName: "span",
4849
type: "element",
@@ -110,19 +111,23 @@ function Pre(props: ComponentProps<"pre">) {
110111

111112
const processor = createProcessor();
112113

113-
export function Markdown({ text }: { text: string }) {
114+
export function Markdown(props: { text: string }) {
115+
const { text } = props;
116+
114117
const deferredText = useDeferredValue(text);
115118

116119
return (
117-
<Suspense fallback={text}>
120+
<Suspense fallback={<p className="invisible">{text}</p>}>
118121
<Renderer text={deferredText} />
119122
</Suspense>
120123
);
121124
}
122125

123126
const cache = new Map<string, Promise<ReactNode>>();
124127

125-
function Renderer({ text }: { text: string }) {
128+
function Renderer(props: { text: string }) {
129+
const { text } = props;
130+
126131
const result = cache.get(text) ?? processor.process(text);
127132
cache.set(text, result);
128133

apps/docs/components/ai/page-actions.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ export function LLMCopyButton(props: CopyBtnProps) {
3232
const markDownPromise = callApi(markdownUrl, {
3333
responseType: "text",
3434
}).then((result) => {
35-
const content = result.data ?? "";
36-
Boolean(content) && cache.set(markdownUrl, content);
37-
return content;
35+
if (result.error || !result.data) {
36+
return "";
37+
}
38+
39+
cache.set(markdownUrl, result.data);
40+
41+
return result.data;
3842
});
3943

4044
const clipboardItem = new ClipboardItem({ "text/plain": markDownPromise });
@@ -58,7 +62,7 @@ export function LLMCopyButton(props: CopyBtnProps) {
5862
{checked ?
5963
<Check />
6064
: <Copy />}
61-
<p>Copy Markdown</p>
65+
Copy Markdown
6266
</Button>
6367
);
6468
}
@@ -88,12 +92,12 @@ export function ViewOptions(props: ViewOptionsProps) {
8892
<Popover>
8993
<PopoverTrigger asChild={true}>
9094
<Button theme="secondary" size="sm" className="gap-2">
91-
<p>Open</p>
95+
Open
9296
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
9397
</Button>
9498
</PopoverTrigger>
9599

96-
<PopoverContent className="flex flex-col overflow-auto">
100+
<PopoverContent className="flex flex-col">
97101
{items.map((item) => (
98102
<a
99103
key={item.href}

apps/docs/components/ai/search.tsx

Lines changed: 104 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Presence } from "@zayne-labs/ui-react/common/presence";
1010
import { DefaultChatTransport } from "ai";
1111
import Link from "fumadocs-core/link";
1212
import { Loader2, MessageCircleIcon, RefreshCw, Send, X } from "lucide-react";
13-
import { useEffect, useMemo, useRef, useState } from "react";
13+
import { useEffect, useInsertionEffect, useMemo, useRef, useState } from "react";
1414
import { toast } from "sonner";
1515
import { z } from "zod";
1616
import { Button } from "../ui/button";
@@ -41,7 +41,12 @@ function Header() {
4141
<div className="sticky top-0 flex items-start gap-2">
4242
<div className="flex-1 rounded-xl border bg-fd-card p-3 text-fd-card-foreground">
4343
<p className="mb-2 text-sm font-medium">Ask AI</p>
44-
<p className="text-xs text-fd-muted-foreground">Powered by Gemma</p>
44+
<p className="text-xs text-fd-muted-foreground">
45+
Powered by{" "}
46+
<a href="https://gemini.google.com" target="_blank" rel="noreferrer noopener">
47+
Gemma
48+
</a>
49+
</p>
4550
</div>
4651

4752
<Button
@@ -97,11 +102,13 @@ function SearchAIActions() {
97102
);
98103
}
99104

105+
const StorageKeyInput = "__ai_search_input";
106+
100107
function SearchAIInput(props: InferProps<"form">) {
101108
const { className, ...restOfProps } = props;
102109
const { sendMessage, status, stop } = useChatContext();
103110

104-
const [input, setInput] = useState("");
111+
const [input, setInput] = useState(() => localStorage.getItem(StorageKeyInput) ?? "");
105112

106113
const isLoading = status === "streaming" || status === "submitted";
107114

@@ -111,6 +118,10 @@ function SearchAIInput(props: InferProps<"form">) {
111118
setInput("");
112119
};
113120

121+
useInsertionEffect(() => {
122+
localStorage.setItem(StorageKeyInput, input);
123+
}, [input]);
124+
114125
useEffect(() => {
115126
if (isLoading) {
116127
document.querySelector<HTMLElement>("#nd-ai-input")?.focus();
@@ -286,7 +297,9 @@ const roleName = {
286297
user: "you",
287298
} satisfies Record<Exclude<UIMessage["role"], "system">, string> as Record<string, string>;
288299

289-
export function AISearchTrigger() {
300+
export function AISearchRoot(props: { children: React.ReactNode }) {
301+
const { children } = props;
302+
290303
const [open, setOpen] = useState(false);
291304

292305
const chat = useChat({
@@ -296,6 +309,36 @@ export function AISearchTrigger() {
296309
}),
297310
});
298311

312+
const contextValue = useMemo<GeneralContextType>(
313+
() => ({ chat, open, setOpen }) satisfies GeneralContextType,
314+
[chat, open]
315+
);
316+
317+
return <GeneralContextProvider value={contextValue}>{children}</GeneralContextProvider>;
318+
}
319+
320+
export function AISearchTrigger() {
321+
const { open, setOpen } = useGeneralContext();
322+
323+
return (
324+
<Button
325+
theme="secondary"
326+
className={cnMerge(
327+
`fixed end-[calc(--spacing(4)+var(--removed-body-scroll-bar-size,0px))] bottom-4 z-20 w-24
328+
gap-3 rounded-2xl text-fd-muted-foreground shadow-lg transition-[translate,opacity]`,
329+
open && "translate-y-10 opacity-0"
330+
)}
331+
onClick={() => setOpen(true)}
332+
>
333+
<MessageCircleIcon className="size-4.5" />
334+
Ask AI
335+
</Button>
336+
);
337+
}
338+
339+
export function AISearchPanel() {
340+
const { chat, open, setOpen } = useGeneralContext();
341+
299342
const onKeyPress = useCallbackRef((event: KeyboardEvent) => {
300343
if (event.key === "Escape" && open) {
301344
setOpen(false);
@@ -314,80 +357,87 @@ export function AISearchTrigger() {
314357
return () => cleanup();
315358
}, [onKeyPress]);
316359

317-
const contextValue = useMemo<GeneralContextType>(
318-
() => ({ chat, open, setOpen }) satisfies GeneralContextType,
319-
[chat, open]
320-
);
321-
322360
return (
323-
<GeneralContextProvider value={contextValue}>
361+
<>
324362
<style>
325363
{css`
326364
@keyframes ask-ai-open {
327365
from {
328-
translate: 100% 0;
366+
width: 0px;
367+
}
368+
to {
369+
width: var(--ai-chat-width);
329370
}
330371
}
331-
332372
@keyframes ask-ai-close {
373+
from {
374+
width: var(--ai-chat-width);
375+
}
333376
to {
334-
translate: 100% 0;
335-
opacity: 0;
377+
width: 0px;
336378
}
337379
}
338380
`}
339381
</style>
340382

383+
<Presence present={open}>
384+
<div
385+
data-state={open ? "open" : "closed"}
386+
className="fixed inset-0 z-30 bg-fd-overlay backdrop-blur-xs
387+
data-[state=closed]:animate-fd-fade-out data-[state=open]:animate-fd-fade-in lg:hidden"
388+
onClick={() => setOpen(false)}
389+
/>
390+
</Presence>
391+
341392
<Presence present={open}>
342393
<div
343394
className={cnMerge(
344-
`fixed inset-y-2 z-30 flex flex-col rounded-2xl border bg-fd-popover p-2
345-
text-fd-popover-foreground shadow-lg max-sm:inset-x-2 sm:end-2 sm:w-[460px]`,
346-
open ? "animate-[ask-ai-open_300ms]" : "animate-[ask-ai-close_300ms]"
395+
`z-30 overflow-hidden bg-fd-popover text-fd-popover-foreground [--ai-chat-width:400px]
396+
xl:[--ai-chat-width:460px]`,
397+
`max-lg:fixed max-lg:inset-x-2 max-lg:top-4 max-lg:rounded-2xl max-lg:border
398+
max-lg:shadow-xl`,
399+
`lg:sticky lg:top-0 lg:ms-auto lg:h-dvh lg:border-s
400+
lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:col-start-5
401+
lg:in-[#nd-notebook-layout]:row-span-full`,
402+
open ?
403+
"animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]"
404+
: "animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]"
347405
)}
348406
>
349-
<Header />
350-
<MessageList
351-
className="flex-1 overscroll-contain px-3 py-4"
352-
style={{
353-
maskImage:
354-
"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)",
355-
}}
356-
>
357-
<div className="flex flex-col gap-4">
358-
{chat.messages
359-
.filter((msg) => msg.role !== "system")
360-
.map((item) => (
361-
<Message key={item.id} message={item} />
362-
))}
363-
</div>
364-
</MessageList>
365-
366407
<div
367-
className="rounded-xl border bg-fd-card text-fd-card-foreground has-focus-visible:ring-2
368-
has-focus-visible:ring-fd-ring"
408+
className="flex size-full flex-col p-2 max-lg:max-h-[80dvh] lg:w-(--ai-chat-width)
409+
xl:p-4"
369410
>
370-
<SearchAIInput />
371-
<div className="flex items-center gap-1.5 p-1 empty:hidden">
372-
<SearchAIActions />
411+
<Header />
412+
413+
<MessageList
414+
className="flex-1 overscroll-contain px-3 py-4"
415+
style={{
416+
maskImage:
417+
"linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)",
418+
}}
419+
>
420+
<div className="flex flex-col gap-4">
421+
{chat.messages
422+
.filter((msg) => msg.role !== "system")
423+
.map((item) => (
424+
<Message key={item.id} message={item} />
425+
))}
426+
</div>
427+
</MessageList>
428+
429+
<div
430+
className="rounded-xl border bg-fd-card text-fd-card-foreground
431+
has-focus-visible:ring-2 has-focus-visible:ring-fd-ring"
432+
>
433+
<SearchAIInput />
434+
<div className="flex items-center gap-1.5 p-1 empty:hidden">
435+
<SearchAIActions />
436+
</div>
373437
</div>
374438
</div>
375439
</div>
376440
</Presence>
377-
378-
<Button
379-
unstyled={true}
380-
className={cnMerge(
381-
`fixed bottom-4 z-20 flex h-10 w-24 items-center gap-3 rounded-2xl border bg-fd-secondary
382-
px-2 text-sm font-medium text-fd-muted-foreground shadow-lg transition-[translate,opacity]`,
383-
"end-[calc(var(--removed-body-scroll-bar-size,0px)+var(--fd-layout-offset)+1rem)]",
384-
open && "translate-y-10 opacity-0"
385-
)}
386-
onClick={() => setOpen(true)}
387-
>
388-
<MessageCircleIcon className="size-4.5" />
389-
Ask AI
390-
</Button>
391-
</GeneralContextProvider>
441+
</>
392442
);
393443
}

0 commit comments

Comments
 (0)