docs: mdx, light/dark mode, ask (#400)

This commit is contained in:
Chris Tate
2026-02-09 11:16:21 -06:00
committed by GitHub
parent 4d8097a56f
commit e8ceafcbe1
36 changed files with 4221 additions and 1258 deletions
+437
View File
@@ -0,0 +1,437 @@
"use client";
import { useRef, useEffect, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Streamdown } from "streamdown";
import Link from "next/link";
const STORAGE_KEY = "docs-chat-messages";
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
const TOOL_LABELS: Record<
string,
{ label: string; pastLabel: string; argKey?: string }
> = {
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
};
function isToolPart(part: { type: string }): part is {
type: string;
toolCallId: string;
toolName?: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
} {
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
}
function getToolName(part: { type: string; toolName?: string }): string {
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
return part.type.replace(/^tool-/, "");
}
function ToolCallDisplay({
part,
}: {
part: {
type: string;
toolCallId: string;
toolName?: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
};
}) {
const toolName = getToolName(part);
const config = TOOL_LABELS[toolName] ?? {
label: toolName,
pastLabel: toolName,
};
const isDone = part.state === "output-available";
const isError = part.state === "output-error";
const isRunning = !isDone && !isError;
const displayLabel = isRunning ? config.label : config.pastLabel;
const args = (part.input ?? {}) as Record<string, unknown>;
const argValue = config.argKey ? args[config.argKey] : undefined;
const argPreview =
argValue != null
? String(argValue)
.replace(/^\/workspace\//, "/")
.replace(/\.md$/, "")
.replace(/\/index$/, "") || "/"
: "";
// Link to the docs page if it's a readFile path
const docsLink =
toolName === "readFile" && argPreview.startsWith("/") ? argPreview : null;
const argEl = argPreview ? (
docsLink ? (
<Link href={docsLink} className="truncate underline underline-offset-2">
{argPreview}
</Link>
) : (
<span className="truncate">{argPreview}</span>
)
) : null;
return (
<div className="text-xs py-0.5 min-w-0">
{isRunning ? (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0">
<span className="shrink-0">{displayLabel}</span>
{argEl}
</span>
) : (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0">
<span className="shrink-0">{displayLabel}</span>
{argEl}
{isError && <span className="text-destructive">failed</span>}
</span>
)}
</div>
);
}
const SUGGESTIONS = [
"What is agent-browser?",
"How do I install it?",
"What commands are available?",
"How do snapshots work?",
"How do I use CDP mode?",
];
export function DocsChat() {
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [focused, setFocused] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const { messages, sendMessage, status, setMessages, error } = useChat({
transport,
});
const isLoading = status === "streaming" || status === "submitted";
// Restore messages from sessionStorage on mount
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed);
}
}
} catch {
// ignore parse errors
}
}, [setMessages]);
// Save completed messages to sessionStorage
useEffect(() => {
if (!restoredRef.current) return;
if (isLoading) return;
if (messages.length === 0) {
sessionStorage.removeItem(STORAGE_KEY);
return;
}
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
} catch {
// ignore quota errors
}
}, [messages, isLoading]);
// Auto-open when new messages arrive (but not on initial restore)
const prevMessageCount = useRef<number | null>(null);
const initializedRef = useRef(false);
useEffect(() => {
// Skip until after the first sessionStorage restore cycle
if (!initializedRef.current) {
// Wait one tick after mount to let restore settle
const id = requestAnimationFrame(() => {
prevMessageCount.current = messages.length;
initializedRef.current = true;
});
return () => cancelAnimationFrame(id);
}
if (
prevMessageCount.current !== null &&
messages.length > prevMessageCount.current
) {
setOpen(true);
}
prevMessageCount.current = messages.length;
}, [messages.length]);
// Scroll to bottom when messages change or error occurs
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, error]);
// Cmd+K to focus prompt, Esc to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
inputRef.current?.focus();
}
if (e.key === "Escape" && open) {
setOpen(false);
inputRef.current?.blur();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open]);
// Close message area when clicking outside
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ text: input });
setInput("");
};
const handleClear = () => {
setMessages([]);
sessionStorage.removeItem(STORAGE_KEY);
setOpen(false);
inputRef.current?.focus();
};
const hasVisibleContent = (
parts: (typeof messages)[number]["parts"],
): boolean => {
return parts.some(
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
);
};
// Auto-open when error occurs
useEffect(() => {
if (error) setOpen(true);
}, [error]);
const showMessages = open && (messages.length > 0 || !!error);
const showSuggestions = focused && messages.length === 0 && !isLoading;
return (
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
<div
ref={containerRef}
className={`mx-auto px-4 pb-4 *:pointer-events-auto transition-all duration-300 ${focused || showMessages ? "max-w-xl" : "max-w-56"}`}
>
<div
className={`border rounded-lg overflow-hidden ${focused || showMessages ? "border-background" : "border-[var(--chat-bg)]"}`}
style={{ backgroundColor: "var(--chat-bg)" }}
>
{/* Suggestions panel */}
{showSuggestions && (
<div>
<div className="flex items-center px-4 py-2 border-b border-background shrink-0">
<span className="text-xs font-medium text-muted-foreground">
agent-browser Docs
</span>
</div>
<div className="flex flex-wrap gap-2 p-3">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onMouseDown={(e) => {
e.preventDefault();
setOpen(true);
sendMessage({ text: s });
}}
className="text-xs px-3 py-1.5 rounded-full border border-background bg-background font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{s}
</button>
))}
</div>
</div>
)}
{/* Messages panel */}
{showMessages && (
<div className="max-h-[60vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-2 border-b border-background shrink-0">
<span className="text-xs font-medium text-muted-foreground">
agent-browser Docs
</span>
<button
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear conversation"
>
Clear
</button>
</div>
<div
className="p-4 space-y-4 overflow-y-auto"
onClick={(e) => {
if ((e.target as HTMLElement).closest("a")) {
setOpen(false);
}
}}
>
{messages.map((message) => {
if (!hasVisibleContent(message.parts)) return null;
return (
<div key={message.id}>
{message.role === "user" ? (
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{message.parts
.filter(
(p): p is Extract<typeof p, { type: "text" }> =>
p.type === "text",
)
.map((p) => p.text)
.join("")}
</div>
) : (
<div className="space-y-2">
{message.parts.map((part, i) => {
if (part.type === "text" && part.text) {
return (
<div
key={i}
className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
>
<Streamdown>{part.text}</Streamdown>
</div>
);
}
if (isToolPart(part)) {
return (
<ToolCallDisplay
key={part.toolCallId}
part={part}
/>
);
}
return null;
})}
</div>
)}
</div>
);
})}
{error && (
<div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
{(() => {
try {
const parsed = JSON.parse(error.message);
return parsed.message || parsed.error || error.message;
} catch {
return (
error.message ||
"Something went wrong. Please try again."
);
}
})()}
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
)}
{/* Input bar */}
<form
onSubmit={handleSubmit}
onClick={() => inputRef.current?.focus()}
className={`relative flex items-end gap-2 px-3 py-2 cursor-text${showMessages ? " border-t border-background" : ""}`}
>
{!input && (
<div className="absolute inset-0 flex items-center px-3 pointer-events-none">
<span className="text-sm text-muted-foreground truncate flex-1">
Ask a question...
</span>
{!focused && !showMessages && (
<span className="text-muted-foreground/40 font-mono text-xs shrink-0">
&#8984;K
</span>
)}
</div>
)}
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
rows={1}
onFocus={() => {
setFocused(true);
if (messages.length > 0) setOpen(true);
}}
onBlur={() => {
setFocused(false);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
inputRef.current?.blur();
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed relative z-10"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className={`bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30${!focused && !showMessages ? " hidden" : ""}`}
aria-label="Send message"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</form>
</div>
</div>
</div>
);
}
+16 -6
View File
@@ -2,12 +2,13 @@
import Link from "next/link";
import { useMobileNav } from "./mobile-nav-context";
import { ThemeToggle } from "./theme-toggle";
export function Header() {
const { isOpen, toggle } = useMobileNav();
return (
<header className="sticky top-0 z-50 bg-black/90 backdrop-blur-sm">
<header className="sticky top-0 z-50 bg-background/90 backdrop-blur-sm">
<div className="flex h-14 items-center justify-between px-4 gap-6">
<div className="flex items-center gap-2">
<Link href="https://vercel.com" title="Made with love by Vercel">
@@ -27,7 +28,7 @@ export function Header() {
></path>
</svg>
</Link>
<span className="text-[#333]">
<span className="text-border">
<svg
data-testid="geist-icon"
height="16"
@@ -55,21 +56,30 @@ export function Header() {
href="https://github.com/vercel-labs/agent-browser"
target="_blank"
rel="noopener noreferrer"
className="hidden sm:block text-sm text-[#666] hover:text-[#999] transition-colors"
className="hidden sm:flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
GitHub
<svg
viewBox="0 0 16 16"
className="h-4 w-4"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
<span>13.4k</span>
</a>
<a
href="https://www.npmjs.com/package/agent-browser"
target="_blank"
rel="noopener noreferrer"
className="hidden sm:block text-sm text-[#666] hover:text-[#999] transition-colors"
className="hidden sm:block text-sm text-muted-foreground hover:text-foreground transition-colors"
>
npm
</a>
<ThemeToggle />
<button
onClick={toggle}
className="lg:hidden p-2 -mr-2 text-[#888] hover:text-white transition-colors"
className="lg:hidden p-2 -mr-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Toggle menu"
>
{isOpen ? (
+4 -4
View File
@@ -27,7 +27,7 @@ export function Sidebar() {
{/* Mobile overlay */}
{isOpen && (
<div
className="lg:hidden fixed inset-0 z-40 bg-black/80"
className="lg:hidden fixed inset-0 z-40 bg-background/80"
onClick={() => setIsOpen(false)}
/>
)}
@@ -37,7 +37,7 @@ export function Sidebar() {
className={`
fixed lg:sticky top-14 left-0 z-50 lg:z-auto
w-56 lg:w-48 h-[calc(100vh-3.5rem)]
bg-black
bg-background
transform transition-transform duration-150 ease-out
${isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
`}
@@ -53,8 +53,8 @@ export function Sidebar() {
href={item.href}
className={`block px-2 py-1.5 text-sm transition-colors ${
isActive
? "text-white"
: "text-[#666] hover:text-[#999]"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{item.name}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="w-8 h-8" />;
}
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="w-8 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Toggle theme"
>
{theme === "dark" ? (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
)}
</button>
);
}