Add AI chat to dashboard, refactor stream module, snapshot --urls, batch argument mode (#1160)
* chat * refactor * fixes * fixes * fixes * fixes * improvements * download chat * batch * fixes * fixes * fixes * fmt * fixes * fixes * fixes * fmt
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -186,3 +186,22 @@ button {
|
||||
:is(.dark *).json-punct {
|
||||
color: #a1a1a1;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.shimmer-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
currentColor 25%,
|
||||
color-mix(in srgb, currentColor 40%, transparent) 50%,
|
||||
currentColor 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Geist } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { JotaiProvider } from "@/store/provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
@@ -18,11 +19,13 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={cn("dark font-sans antialiased", geist.variable)}>
|
||||
<html lang="en" className={cn("font-sans antialiased", geist.variable)} suppressHydrationWarning>
|
||||
<body>
|
||||
<JotaiProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</JotaiProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||
<JotaiProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</JotaiProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai/react";
|
||||
import { activePortAtom } from "@/store/sessions";
|
||||
import { useAtomValue, useSetAtom } from "jotai/react";
|
||||
import { activePortAtom, sessionsAtom, newSessionDialogAtom } from "@/store/sessions";
|
||||
import { useSessionsSync } from "@/store/sessions";
|
||||
import { useStreamSync, hasConsoleErrorsAtom, consoleLogsAtom } from "@/store/stream";
|
||||
import { useActivitySync } from "@/store/activity";
|
||||
import { activeExtensionsAtom } from "@/store/sessions";
|
||||
import { useChatStatusSync } from "@/store/chat";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Viewport } from "@/components/viewport";
|
||||
import { ActivityFeed } from "@/components/activity-feed";
|
||||
import { ChatPanel } from "@/components/chat-panel";
|
||||
import { ConsolePanel } from "@/components/console-panel";
|
||||
import { StoragePanel } from "@/components/storage-panel";
|
||||
import { ExtensionsPanel } from "@/components/extensions-panel";
|
||||
@@ -20,21 +22,28 @@ import {
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const activePort = useAtomValue(activePortAtom);
|
||||
useStreamSync(activePort);
|
||||
useSessionsSync();
|
||||
useActivitySync();
|
||||
useChatStatusSync();
|
||||
|
||||
const sessions = useAtomValue(sessionsAtom);
|
||||
const hasSessions = sessions.length > 0;
|
||||
const setNewSessionDialog = useSetAtom(newSessionDialogAtom);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const hasConsoleErrors = useAtomValue(hasConsoleErrorsAtom);
|
||||
const activeExtensions = useAtomValue(activeExtensionsAtom);
|
||||
|
||||
const sidePanel = (
|
||||
<Tabs defaultValue="activity" className="flex h-full flex-col">
|
||||
<Tabs defaultValue="chat" className="flex h-full flex-col">
|
||||
<div className="shrink-0 px-2 pt-1">
|
||||
<TabsList variant="line" className="h-7 w-full">
|
||||
<TabsTrigger value="chat" className="text-[11px]">Chat</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="text-[11px]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="console" className="text-[11px]">
|
||||
Console
|
||||
@@ -67,10 +76,46 @@ export default function DashboardPage() {
|
||||
<TabsContent value="extensions" className="min-h-0 flex-1 overflow-hidden">
|
||||
<ExtensionsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="chat" className="min-h-0 flex-1 overflow-hidden">
|
||||
<ChatPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
if (!hasSessions) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<ResizablePanel id="sessions" defaultSize="15%" minSize="10%" maxSize="30%">
|
||||
<SessionTree />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel id="empty" defaultSize="85%">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">No active sessions</p>
|
||||
<p className="text-xs text-muted-foreground/60">Create a session to get started</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setNewSessionDialog(true)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<ResizablePanelGroup
|
||||
|
||||
@@ -0,0 +1,822 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useAtomValue } from "jotai/react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { getChatApiUrl, chatModelAtom, availableModelsAtom } from "@/store/chat";
|
||||
import { activeSessionNameAtom } from "@/store/sessions";
|
||||
import { ModelSelector } from "@/components/model-selector";
|
||||
import { shikiTheme } from "@/lib/shiki-theme";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowUp, Square, Trash2, ChevronRight, ImagePlus, X, Loader, Copy, Check, Download } from "lucide-react";
|
||||
|
||||
type ExtraProps = { node?: unknown };
|
||||
type MdImgProps = React.ImgHTMLAttributes<HTMLImageElement> & ExtraProps;
|
||||
type MdHeadingProps = React.HTMLAttributes<HTMLHeadingElement> & ExtraProps;
|
||||
type MdAnchorProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
|
||||
type MdPreProps = React.HTMLAttributes<HTMLPreElement> & ExtraProps;
|
||||
type MdCodeProps = React.HTMLAttributes<HTMLElement> & ExtraProps;
|
||||
|
||||
const chatComponents = {
|
||||
img: ({ node: _node, src, alt, ...props }: MdImgProps) => {
|
||||
if (typeof src === "string" && src.startsWith("data:image/")) {
|
||||
return <img src={src} alt={alt} className="rounded-md border border-border max-w-full my-1" {...props} />;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
h1: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
|
||||
h2: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
|
||||
h3: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
|
||||
h4: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
|
||||
h5: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
|
||||
h6: ({ node: _node, ...props }: MdHeadingProps) => <p className="font-bold" {...props} />,
|
||||
a: ({ node: _node, href, children, ...props }: MdAnchorProps) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
pre: ({ node: _node, ...props }: MdPreProps) => (
|
||||
<pre
|
||||
className="text-[11px] bg-background border border-border rounded-md p-2 my-1.5 whitespace-pre-wrap break-all"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ className, children, node: _node, ...props }: MdCodeProps) => {
|
||||
if (className?.includes("language-")) {
|
||||
return <code className={className} {...props}>{children}</code>;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="text-[11px] bg-secondary/60 px-1 py-0.5 rounded text-foreground font-mono break-all"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = "dashboard-chat-";
|
||||
const IMAGE_DATA_URL_RE = /data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/g;
|
||||
|
||||
function stripImagesForStorage(messages: unknown[]): unknown[] {
|
||||
const json = JSON.stringify(messages);
|
||||
return JSON.parse(json.replace(IMAGE_DATA_URL_RE, "[image stripped]"));
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"Go to vercel.com",
|
||||
"Take a screenshot",
|
||||
"What's on the page?",
|
||||
"Click the first link",
|
||||
];
|
||||
|
||||
interface ToolInvocationPart {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
function isToolPart(part: { type: string }): part is ToolInvocationPart {
|
||||
return part.type.startsWith("tool-");
|
||||
}
|
||||
|
||||
function truncateOutput(text: string, maxLines = 30): string {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length <= maxLines) return text;
|
||||
return lines.slice(0, maxLines).join("\n") + `\n... (${lines.length - maxLines} more lines)`;
|
||||
}
|
||||
|
||||
function parseOutputObject(raw: unknown): Record<string, unknown> | null {
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed === "object" && parsed !== null) return parsed;
|
||||
} catch { /* not JSON */ }
|
||||
return null;
|
||||
}
|
||||
if (typeof raw === "object" && raw !== null) return raw as Record<string, unknown>;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatOutput(raw: unknown): string | null {
|
||||
if (typeof raw === "string") {
|
||||
if (!raw.trim()) return null;
|
||||
const obj = parseOutputObject(raw);
|
||||
if (obj) {
|
||||
if (typeof obj.text === "string" && obj.image) return obj.text as string;
|
||||
const { image: _, ...rest } = obj;
|
||||
return JSON.stringify(rest, null, 2);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
if (typeof raw === "object" && raw !== null) {
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (typeof r.text === "string" && r.image) return r.text as string;
|
||||
const { image: _, ...rest } = r;
|
||||
return JSON.stringify(rest, null, 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractImageUrl(raw: unknown): string | null {
|
||||
const obj = parseOutputObject(raw);
|
||||
if (!obj) return null;
|
||||
const img = obj.image;
|
||||
if (typeof img === "string" && img.startsWith("data:image/")) return img;
|
||||
return null;
|
||||
}
|
||||
|
||||
function ToolCallBlock({ part, onImageLoad }: { part: ToolInvocationPart; onImageLoad?: () => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const toolName = part.type.split("-").slice(1).join("-");
|
||||
const command = (part.input as { command?: string })?.command ?? toolName;
|
||||
const isDone = part.state === "output-available";
|
||||
const isRunning = !isDone;
|
||||
const output = isDone ? formatOutput(part.output) : null;
|
||||
const hasOutput = !!output;
|
||||
const imageUrl = isDone ? extractImageUrl(part.output) : null;
|
||||
const canExpand = hasOutput && !isRunning;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md text-[10px] font-mono overflow-hidden border border-border",
|
||||
canExpand && "cursor-pointer",
|
||||
)}
|
||||
onClick={() => canExpand && setExpanded(!expanded)}
|
||||
>
|
||||
<div className={cn(
|
||||
"px-2 py-1 flex items-center gap-2",
|
||||
expanded && hasOutput ? "border-b border-border bg-secondary/30" : "bg-secondary/30",
|
||||
)}>
|
||||
{isRunning ? (
|
||||
<Loader className="size-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
isRunning ? "text-foreground/80 shimmer-text" : "text-foreground/80",
|
||||
)}>{command}</span>
|
||||
</div>
|
||||
{expanded && hasOutput && (
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<pre className="px-2 py-1.5 text-foreground/80 whitespace-pre-wrap break-all leading-relaxed">
|
||||
{truncateOutput(output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Screenshot"
|
||||
className="rounded-md border border-border max-w-full"
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return `${n}`;
|
||||
}
|
||||
|
||||
function ContextMeter({ used, total }: { used: number; total: number }) {
|
||||
const ratio = Math.min(used / total, 1);
|
||||
const size = 16;
|
||||
const strokeWidth = 2;
|
||||
const r = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const offset = circumference * (1 - ratio);
|
||||
const color =
|
||||
ratio > 0.9 ? "text-destructive" : ratio > 0.7 ? "text-yellow-500" : "text-muted-foreground/50";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative shrink-0"
|
||||
title={`${formatTokenCount(used)} / ${formatTokenCount(total)} tokens`}
|
||||
>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-border"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className={cn(color, "transition-[stroke-dashoffset] duration-300")}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-sonnet-4.6";
|
||||
|
||||
function useTimeAgo(ts: number | undefined) {
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!ts) return;
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [ts]);
|
||||
if (!ts) return "";
|
||||
const diff = Math.floor((Date.now() - ts) / 1000);
|
||||
if (diff < 5) return "just now";
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
const mins = Math.floor(diff / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
return `${hrs}h ago`;
|
||||
}
|
||||
|
||||
function MessageFooter({ model, timestamp, text }: { model: string; timestamp?: number; text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeAgo = useTimeAgo(timestamp);
|
||||
const shortModel = model.includes("/") ? model.split("/").pop()! : model;
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 pt-0.5 text-[10px] text-muted-foreground/50">
|
||||
<span>{shortModel}</span>
|
||||
{timeAgo && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{timeAgo}</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="ml-auto hover:text-muted-foreground transition-colors"
|
||||
aria-label="Copy message"
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PendingImage {
|
||||
file: File;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
const [input, setInput] = useState("");
|
||||
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const defaultModel = useAtomValue(chatModelAtom);
|
||||
const [selectedModel, setSelectedModel] = useState<string>(defaultModel || DEFAULT_MODEL);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const sessionName = useAtomValue(activeSessionNameAtom);
|
||||
const chatId = sessionName || "default";
|
||||
const storageKey = `${STORAGE_PREFIX}${chatId}`;
|
||||
const sessionRef = useRef(chatId);
|
||||
sessionRef.current = chatId;
|
||||
const modelRef = useRef(selectedModel);
|
||||
modelRef.current = selectedModel;
|
||||
const messageTimestamps = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultModel) setSelectedModel(defaultModel);
|
||||
}, [defaultModel]);
|
||||
|
||||
const transport = useRef(
|
||||
new DefaultChatTransport({
|
||||
api: getChatApiUrl(),
|
||||
body: () => ({
|
||||
session: sessionRef.current,
|
||||
model: modelRef.current,
|
||||
}),
|
||||
}),
|
||||
).current;
|
||||
|
||||
const { messages, sendMessage, stop, status, setMessages, error } = useChat({
|
||||
chatId,
|
||||
transport,
|
||||
onError: () => setErrorDismissed(false),
|
||||
});
|
||||
|
||||
const visibleError = error && !errorDismissed ? error : undefined;
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
const hasMessages = messages.length > 0 || !!visibleError;
|
||||
|
||||
useEffect(() => {
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant" && !messageTimestamps.current[msg.id]) {
|
||||
messageTimestamps.current[msg.id] = Date.now();
|
||||
}
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const models = useAtomValue(availableModelsAtom);
|
||||
const estimatedTokens = useMemo(() => {
|
||||
let total = 0;
|
||||
for (const msg of messages) {
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "text") total += estimateTokens(part.text);
|
||||
else if (isToolPart(part)) {
|
||||
if (part.input) total += estimateTokens(JSON.stringify(part.input));
|
||||
if (part.output) {
|
||||
const raw = typeof part.output === "string" ? part.output : JSON.stringify(part.output);
|
||||
const stripped = raw.replace(/"image"\s*:\s*"data:[^"]*"/g, '"image":"[omitted]"');
|
||||
total += estimateTokens(stripped);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}, [messages]);
|
||||
const contextWindow = useMemo(() => {
|
||||
const match = models.find((m) => m.id === selectedModel);
|
||||
return match?.context_window ?? DEFAULT_CONTEXT_WINDOW;
|
||||
}, [models, selectedModel]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, visibleError, scrollToBottom]);
|
||||
|
||||
// Restore messages from localStorage when chatId changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setMessages(parsed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setMessages([]);
|
||||
}, [chatId, storageKey, setMessages]);
|
||||
|
||||
// Persist messages to localStorage (strip base64 images to save space)
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (messages.length === 0) {
|
||||
localStorage.removeItem(storageKey);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(stripImagesForStorage(messages)));
|
||||
} catch {
|
||||
// ignore quota
|
||||
}
|
||||
}, [messages, isLoading, storageKey]);
|
||||
|
||||
const addImages = useCallback((files: FileList | null) => {
|
||||
if (!files) return;
|
||||
const images = Array.from(files).filter((f) => f.type.startsWith("image/"));
|
||||
setPendingImages((prev) => [
|
||||
...prev,
|
||||
...images.map((file) => ({ file, preview: URL.createObjectURL(file) })),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((index: number) => {
|
||||
setPendingImages((prev) => {
|
||||
const next = [...prev];
|
||||
URL.revokeObjectURL(next[index].preview);
|
||||
next.splice(index, 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if ((!input.trim() && pendingImages.length === 0) || isLoading) return;
|
||||
const dt = new DataTransfer();
|
||||
for (const img of pendingImages) dt.items.add(img.file);
|
||||
const files = dt.files.length > 0 ? dt.files : undefined;
|
||||
sendMessage({ text: input, files });
|
||||
setInput("");
|
||||
setPendingImages((prev) => {
|
||||
for (const p of prev) URL.revokeObjectURL(p.preview);
|
||||
return [];
|
||||
});
|
||||
},
|
||||
[input, isLoading, sendMessage, pendingImages],
|
||||
);
|
||||
|
||||
const lastCompactedId = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (isLoading || messages.length === 0) return;
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
||||
if (!lastAssistant) return;
|
||||
if (lastAssistant.id === lastCompactedId.current) return;
|
||||
const meta = (lastAssistant as any).metadata as
|
||||
| { compacted?: boolean; summary?: string; keepLastN?: number }
|
||||
| undefined;
|
||||
if (!meta?.compacted || typeof meta.keepLastN !== "number") return;
|
||||
|
||||
lastCompactedId.current = lastAssistant.id;
|
||||
const keep = meta.keepLastN;
|
||||
if (keep >= messages.length) return;
|
||||
|
||||
const summaryMsg = {
|
||||
id: `compaction-${Date.now()}`,
|
||||
role: "assistant" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `*Earlier messages were summarized to stay within the context window.*`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const kept = messages.slice(messages.length - keep);
|
||||
setMessages([summaryMsg as any, ...kept]);
|
||||
}, [isLoading, messages, setMessages]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setMessages([]);
|
||||
setErrorDismissed(true);
|
||||
localStorage.removeItem(storageKey);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, [setMessages, storageKey]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const data = messages.map((msg) => ({
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
parts: msg.parts.map((p) => {
|
||||
if (p.type === "text") return { type: "text", text: p.text };
|
||||
if (p.type === "file") return { type: "file", filename: (p as any).filename };
|
||||
if (isToolPart(p)) {
|
||||
const out = typeof p.output === "string" ? p.output : JSON.stringify(p.output);
|
||||
const stripped = out?.replace(/"image":"data:[^"]*"/g, '"image":"[stripped]"');
|
||||
return {
|
||||
type: p.type,
|
||||
toolName: (p as any).toolName,
|
||||
state: (p as any).state,
|
||||
input: (p as any).input,
|
||||
output: stripped,
|
||||
};
|
||||
}
|
||||
return { type: p.type };
|
||||
}),
|
||||
}));
|
||||
const json = JSON.stringify({ session: chatId, model: selectedModel, messages: data }, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `chat-${chatId}-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [messages, chatId, selectedModel]);
|
||||
|
||||
const hasVisibleContent = (parts: (typeof messages)[number]["parts"]): boolean => {
|
||||
return parts.some(
|
||||
(p) => (p.type === "text" && p.text.length > 0) || p.type === "file" || isToolPart(p),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{hasMessages && (
|
||||
<div className="flex items-center justify-end gap-2 px-3 py-1.5 shrink-0 border-b border-border/40">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
aria-label="Download conversation"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
aria-label="Clear conversation"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-3 space-y-3">
|
||||
{!hasMessages && !isLoading && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Control the browser with natural language:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => sendMessage({ text: s })}
|
||||
className="text-[10px] px-2 py-1 rounded-md border bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => {
|
||||
if (message.id.startsWith("compaction-")) {
|
||||
return (
|
||||
<div key={message.id} className="flex items-center gap-2 text-[10px] text-muted-foreground/60">
|
||||
<div className="flex-1 border-t border-border/40" />
|
||||
<span>Earlier messages summarized</span>
|
||||
<div className="flex-1 border-t border-border/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!hasVisibleContent(message.parts)) return null;
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{message.role === "user" ? (
|
||||
<div className="space-y-1.5">
|
||||
{message.parts.some((p) => p.type === "file") && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{message.parts
|
||||
.filter((p): p is Extract<typeof p, { type: "file" }> => p.type === "file")
|
||||
.map((p, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={p.url}
|
||||
alt={p.filename ?? "uploaded image"}
|
||||
className="max-h-24 rounded-md border border-border object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs 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>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{(() => {
|
||||
type Group = { type: "tools" | "text"; items: (typeof message.parts)[number][] };
|
||||
const groups: Group[] = [];
|
||||
for (const part of message.parts) {
|
||||
const groupType = isToolPart(part) ? "tools" : "text";
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.type === groupType) {
|
||||
last.items.push(part);
|
||||
} else {
|
||||
groups.push({ type: groupType, items: [part] });
|
||||
}
|
||||
}
|
||||
|
||||
return groups.map((group, gi) => {
|
||||
if (group.type === "tools") {
|
||||
return (
|
||||
<div key={gi} className="space-y-0.5">
|
||||
{group.items.map((part) => {
|
||||
if (!isToolPart(part)) return null;
|
||||
return <ToolCallBlock key={part.toolCallId} part={part} onImageLoad={scrollToBottom} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const combinedText = group.items
|
||||
.filter((p): p is Extract<typeof p, { type: "text" }> => p.type === "text" && !!p.text)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
if (!combinedText) return null;
|
||||
return (
|
||||
<div key={gi} className="text-xs text-foreground">
|
||||
<Streamdown
|
||||
shikiTheme={shikiTheme}
|
||||
controls={false}
|
||||
components={chatComponents}
|
||||
>
|
||||
{combinedText}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{(() => {
|
||||
const isLast = message === messages[messages.length - 1];
|
||||
const isComplete = !isLast || !isLoading;
|
||||
if (!isComplete) return null;
|
||||
const fullText = message.parts
|
||||
.filter((p): p is Extract<typeof p, { type: "text" }> => p.type === "text" && !!p.text)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
return (
|
||||
<MessageFooter
|
||||
model={selectedModel}
|
||||
timestamp={messageTimestamps.current[message.id]}
|
||||
text={fullText}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isLoading && messages.length > 0 && (() => {
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const lastPart = lastMsg?.parts[lastMsg.parts.length - 1];
|
||||
const noVisibleContent = !lastMsg || !hasVisibleContent(lastMsg.parts);
|
||||
const lastIsCompletedTool = lastPart && isToolPart(lastPart) && lastPart.state === "output-available";
|
||||
if (noVisibleContent || lastIsCompletedTool) {
|
||||
return (
|
||||
<span className="text-[11px] text-muted-foreground shimmer-text">
|
||||
Working...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{visibleError && (
|
||||
<div className="text-[10px] text-destructive/80 bg-destructive/10 rounded-md px-2 py-1.5">
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(visibleError.message);
|
||||
return parsed.message || parsed.error || visibleError.message;
|
||||
} catch {
|
||||
return visibleError.message || "Something went wrong.";
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
|
||||
{pendingImages.map((img, i) => (
|
||||
<div key={img.preview} className="group relative">
|
||||
<img
|
||||
src={img.preview}
|
||||
alt={img.file.name}
|
||||
className="h-14 rounded-md border border-border object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute -top-1.5 -right-1.5 hidden group-hover:flex size-4 items-center justify-center rounded-full bg-background border border-border text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pt-2 pb-1.5">
|
||||
<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}
|
||||
placeholder="Ask something..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imageFiles: File[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
const dt = new DataTransfer();
|
||||
for (const f of imageFiles) dt.items.add(f);
|
||||
addImages(dt.files);
|
||||
}
|
||||
}}
|
||||
className="w-full bg-transparent text-xs text-foreground outline-none resize-none max-h-24 leading-relaxed placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3 pb-2">
|
||||
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
|
||||
<div className="flex items-center gap-2">
|
||||
{hasMessages && (
|
||||
<ContextMeter used={estimatedTokens} total={contextWindow} />
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
addImages(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0 p-1"
|
||||
aria-label="Attach image"
|
||||
>
|
||||
<ImagePlus className="size-3.5" />
|
||||
</button>
|
||||
{isLoading ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stop()}
|
||||
className="bg-primary text-primary-foreground rounded-full p-1 hover:bg-primary/90 transition-colors shrink-0"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<Square className="size-3 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() && pendingImages.length === 0}
|
||||
className="bg-primary text-primary-foreground rounded-full p-1 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<ArrowUp className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAtomValue } from "jotai/react";
|
||||
import { availableModelsAtom } from "@/store/chat";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
function formatModelLabel(id: string): string {
|
||||
const parts = id.split("/");
|
||||
return parts.length > 1 ? parts.slice(1).join("/") : id;
|
||||
}
|
||||
|
||||
function formatProvider(id: string): string {
|
||||
const parts = id.split("/");
|
||||
if (parts.length > 1) return parts[0];
|
||||
return "";
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
value: string;
|
||||
onChange: (model: string) => void;
|
||||
}
|
||||
|
||||
export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const models = useAtomValue(availableModelsAtom);
|
||||
|
||||
const providers = new Map<string, typeof models>();
|
||||
for (const m of models) {
|
||||
const provider = formatProvider(m.id) || "other";
|
||||
if (!providers.has(provider)) providers.set(provider, []);
|
||||
providers.get(provider)!.push(m);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground transition-colors truncate max-w-[180px]"
|
||||
aria-label="Select model"
|
||||
>
|
||||
<span className="truncate">{formatModelLabel(value)}</span>
|
||||
<ChevronDown className="h-2.5 w-2.5 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start" side="top">
|
||||
<Command>
|
||||
<CommandInput placeholder="Filter models..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
{models.length > 0 ? (
|
||||
Array.from(providers.entries()).map(([provider, providerModels]) => (
|
||||
<CommandGroup key={provider} heading={provider}>
|
||||
{providerModels.map((m) => (
|
||||
<CommandItem
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
onSelect={() => {
|
||||
onChange(m.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0",
|
||||
value === m.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{formatModelLabel(m.id)}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))
|
||||
) : (
|
||||
<CommandGroup>
|
||||
<CommandItem value={value} onSelect={() => setOpen(false)}>
|
||||
<Check className="h-3 w-3 shrink-0 opacity-100" />
|
||||
<span className="truncate">{value}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState, type SyntheticEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type SyntheticEvent } from "react";
|
||||
import { useAtom } from "jotai/react";
|
||||
import { useAtomValue, useSetAtom } from "jotai/react";
|
||||
import type { SessionInfo, TabInfo } from "@/types";
|
||||
import {
|
||||
@@ -13,9 +14,11 @@ import {
|
||||
closeTabAtom,
|
||||
addTabAtom,
|
||||
switchTabAtom,
|
||||
newSessionDialogAtom,
|
||||
} from "@/store/sessions";
|
||||
import { tabsForPortAtom, engineForPortAtom } from "@/store/tabs";
|
||||
import { ChevronRight, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -116,7 +119,7 @@ function getFaviconUrl(url: string): string | null {
|
||||
function TabFavicon({ url }: { url: string }) {
|
||||
const src = getFaviconUrl(url);
|
||||
if (!src) {
|
||||
return <span className="flex size-3.5 shrink-0 items-center justify-center rounded-sm bg-muted text-[8px] text-muted-foreground">●</span>;
|
||||
return <span className="flex size-4 shrink-0 items-center justify-center rounded-sm bg-muted text-[8px] text-muted-foreground">●</span>;
|
||||
}
|
||||
const handleError = (e: SyntheticEvent<HTMLImageElement>) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
@@ -125,9 +128,9 @@ function TabFavicon({ url }: { url: string }) {
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
className="size-3.5 shrink-0 rounded-sm"
|
||||
width={16}
|
||||
height={16}
|
||||
className="size-4 shrink-0 rounded-sm"
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
@@ -149,7 +152,7 @@ function TabNode({ tab, isViewed, isSessionActive, onClose, onSwitch, onSelectSe
|
||||
<button
|
||||
onClick={isClickable ? handleClick : undefined}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center gap-1.5 py-1 pr-1 pl-7 text-left text-xs",
|
||||
"flex w-full min-w-0 items-center gap-2 py-1 pr-1 pl-7 text-left text-xs",
|
||||
isViewed
|
||||
? "bg-card text-foreground"
|
||||
: "text-muted-foreground cursor-pointer hover:text-foreground",
|
||||
@@ -321,9 +324,9 @@ function SessionNode({
|
||||
))}
|
||||
<button
|
||||
onClick={onAddTab}
|
||||
className="flex w-full items-center gap-1.5 py-1 pr-1 pl-7 text-xs text-muted-foreground hover:text-foreground"
|
||||
className="flex w-full items-center gap-2 py-1 pr-1 pl-7 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<Plus className="size-4" />
|
||||
Add tab
|
||||
</button>
|
||||
</div>
|
||||
@@ -347,7 +350,7 @@ export function SessionTree() {
|
||||
const dispatchSwitchTab = useSetAtom(switchTabAtom);
|
||||
|
||||
const [expandedMap, setExpandedMap] = useState<Record<number, boolean>>({});
|
||||
const [newSessionOpen, setNewSessionOpen] = useState(false);
|
||||
const [newSessionOpen, setNewSessionOpen] = useAtom(newSessionDialogAtom);
|
||||
const [closeAllOpen, setCloseAllOpen] = useState(false);
|
||||
const [newSessionName, setNewSessionName] = useState("");
|
||||
const [newSessionBrowser, setNewSessionBrowser] = useState("chrome");
|
||||
@@ -384,11 +387,21 @@ export function SessionTree() {
|
||||
}
|
||||
}, [newSessionName, newSessionBrowser, creating, dispatchCreateSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (newSessionOpen && !newSessionName) {
|
||||
const existing = new Set(sessions.map((s) => s.session));
|
||||
let n = sessions.length + 1;
|
||||
while (existing.has(`session-${n}`)) n++;
|
||||
setNewSessionName(`session-${n}`);
|
||||
}
|
||||
}, [newSessionOpen, newSessionName, sessions]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">Sessions</span>
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<ThemeToggle />
|
||||
{sessions.some((s) => !s.pending) && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -401,7 +414,13 @@ export function SessionTree() {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewSessionOpen(true)}
|
||||
onClick={() => {
|
||||
const existing = new Set(sessions.map((s) => s.session));
|
||||
let n = sessions.length + 1;
|
||||
while (existing.has(`session-${n}`)) n++;
|
||||
setNewSessionName(`session-${n}`);
|
||||
setNewSessionOpen(true);
|
||||
}}
|
||||
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="New session"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
||||
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title={resolvedTheme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{resolvedTheme === "dark" ? (
|
||||
<Sun className="size-3" />
|
||||
) : (
|
||||
<Moon className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-2" cmdk-input-wrapper="">
|
||||
<Search className="mr-1.5 h-3 w-3 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-8 w-full rounded-md bg-transparent py-1.5 text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[200px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-4 text-center text-xs text-muted-foreground" {...props} />
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1 text-xs outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -1,5 +1,3 @@
|
||||
const DASHBOARD_PORT = 4848;
|
||||
|
||||
export interface ExecResult {
|
||||
success: boolean;
|
||||
exit_code: number | null;
|
||||
@@ -9,7 +7,7 @@ export interface ExecResult {
|
||||
|
||||
export async function execCommand(args: string[]): Promise<ExecResult> {
|
||||
try {
|
||||
const resp = await fetch(`http://localhost:${DASHBOARD_PORT}/api/exec`, {
|
||||
const resp = await fetch("/api/exec", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ args }),
|
||||
@@ -31,7 +29,7 @@ export function sessionArgs(session: string, ...args: string[]): string[] {
|
||||
|
||||
export async function killSession(session: string): Promise<{ success: boolean; killed_pid?: number }> {
|
||||
try {
|
||||
const resp = await fetch(`http://localhost:${DASHBOARD_PORT}/api/kill`, {
|
||||
const resp = await fetch("/api/kill", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session }),
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { ThemeRegistrationAny } from "streamdown";
|
||||
|
||||
const lightTheme: ThemeRegistrationAny = {
|
||||
name: "dashboard-light",
|
||||
type: "light",
|
||||
colors: {
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "#171717",
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
scope: ["comment", "punctuation.definition.comment"],
|
||||
settings: { foreground: "#6B7280" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"string",
|
||||
"string.quoted",
|
||||
"string.template",
|
||||
"punctuation.definition.string",
|
||||
],
|
||||
settings: { foreground: "#067A6E" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"constant.language.boolean",
|
||||
"constant.language.null",
|
||||
],
|
||||
settings: { foreground: "#0070C0" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword", "storage.type", "storage.modifier"],
|
||||
settings: { foreground: "#D6409F" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator", "keyword.control"],
|
||||
settings: { foreground: "#D6409F" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.function", "support.function", "meta.function-call"],
|
||||
settings: { foreground: "#6E56CF" },
|
||||
},
|
||||
{
|
||||
scope: ["variable", "variable.other"],
|
||||
settings: { foreground: "#171717" },
|
||||
},
|
||||
{
|
||||
scope: ["variable.parameter"],
|
||||
settings: { foreground: "#B45309" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
|
||||
settings: { foreground: "#D6409F" },
|
||||
},
|
||||
{
|
||||
scope: ["punctuation", "meta.brace", "meta.bracket"],
|
||||
settings: { foreground: "#6B7280" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.property-name",
|
||||
"entity.name.tag.json",
|
||||
"meta.object-literal.key",
|
||||
"punctuation.support.type.property-name",
|
||||
],
|
||||
settings: { foreground: "#D6409F" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.other.attribute-name"],
|
||||
settings: { foreground: "#067A6E" },
|
||||
},
|
||||
{
|
||||
scope: ["support.type.primitive", "entity.name.type.primitive"],
|
||||
settings: { foreground: "#067A6E" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const darkTheme: ThemeRegistrationAny = {
|
||||
name: "dashboard-dark",
|
||||
type: "dark",
|
||||
colors: {
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "#EDEDED",
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
scope: ["comment", "punctuation.definition.comment"],
|
||||
settings: { foreground: "#A1A1A1" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"string",
|
||||
"string.quoted",
|
||||
"string.template",
|
||||
"punctuation.definition.string",
|
||||
],
|
||||
settings: { foreground: "#00CA50" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"constant.language.boolean",
|
||||
"constant.language.null",
|
||||
],
|
||||
settings: { foreground: "#47A8FF" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword", "storage.type", "storage.modifier"],
|
||||
settings: { foreground: "#FF4D8D" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator", "keyword.control"],
|
||||
settings: { foreground: "#FF4D8D" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.function", "support.function", "meta.function-call"],
|
||||
settings: { foreground: "#C472FB" },
|
||||
},
|
||||
{
|
||||
scope: ["variable", "variable.other"],
|
||||
settings: { foreground: "#EDEDED" },
|
||||
},
|
||||
{
|
||||
scope: ["variable.parameter"],
|
||||
settings: { foreground: "#FF9300" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
|
||||
settings: { foreground: "#FF4D8D" },
|
||||
},
|
||||
{
|
||||
scope: ["punctuation", "meta.brace", "meta.bracket"],
|
||||
settings: { foreground: "#EDEDED" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.property-name",
|
||||
"entity.name.tag.json",
|
||||
"meta.object-literal.key",
|
||||
"punctuation.support.type.property-name",
|
||||
],
|
||||
settings: { foreground: "#FF4D8D" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.other.attribute-name"],
|
||||
settings: { foreground: "#00CA50" },
|
||||
},
|
||||
{
|
||||
scope: ["support.type.primitive", "entity.name.type.primitive"],
|
||||
settings: { foreground: "#00CA50" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const shikiTheme: [ThemeRegistrationAny, ThemeRegistrationAny] = [
|
||||
lightTheme,
|
||||
darkTheme,
|
||||
];
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useAtomCallback } from "jotai/utils";
|
||||
import { useCallback } from "react";
|
||||
|
||||
const DAEMON_URL = process.env.NEXT_PUBLIC_DAEMON_URL || "";
|
||||
|
||||
function daemonBase(): string {
|
||||
if (typeof window === "undefined" || !DAEMON_URL) return "";
|
||||
try {
|
||||
const daemon = new URL(DAEMON_URL);
|
||||
if (window.location.host === daemon.host) return "";
|
||||
return DAEMON_URL;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getChatStatusUrl(): string {
|
||||
return `${daemonBase()}/api/chat/status`;
|
||||
}
|
||||
|
||||
export function getChatApiUrl(): string {
|
||||
return `${daemonBase()}/api/chat`;
|
||||
}
|
||||
|
||||
export function getModelsApiUrl(): string {
|
||||
return `${daemonBase()}/api/models`;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
owned_by?: string;
|
||||
context_window?: number;
|
||||
}
|
||||
|
||||
export const chatEnabledAtom = atom(false);
|
||||
export const chatModelAtom = atom<string | undefined>(undefined);
|
||||
export const availableModelsAtom = atom<ModelInfo[]>([]);
|
||||
|
||||
export function useChatStatusSync() {
|
||||
const fetchStatus = useAtomCallback(
|
||||
useCallback(async (_get, set) => {
|
||||
try {
|
||||
const resp = await fetch(getChatStatusUrl());
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
set(chatEnabledAtom, !!data.enabled);
|
||||
if (data.model) set(chatModelAtom, data.model);
|
||||
}
|
||||
} catch {
|
||||
set(chatEnabledAtom, false);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(getModelsApiUrl());
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (Array.isArray(data?.data)) {
|
||||
const models: ModelInfo[] = data.data.map((m: Record<string, unknown>) => ({
|
||||
id: m.id as string,
|
||||
name: (m.name as string) || undefined,
|
||||
owned_by: (m.owned_by as string) || undefined,
|
||||
context_window: typeof m.context_window === "number" ? m.context_window : undefined,
|
||||
}));
|
||||
models.sort((a, b) => a.id.localeCompare(b.id));
|
||||
set(availableModelsAtom, models);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// models fetch failed, leave empty
|
||||
}
|
||||
}, []),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
}
|
||||
@@ -15,16 +15,10 @@ function getPort(): number {
|
||||
return p ? parseInt(p, 10) || 9223 : 9223;
|
||||
}
|
||||
|
||||
const DASHBOARD_PORT = 4848;
|
||||
export const newSessionDialogAtom = atom(false);
|
||||
|
||||
function getSessionsUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
const origin = window.location.origin;
|
||||
if (origin.includes(`:${DASHBOARD_PORT}`)) {
|
||||
return "/api/sessions";
|
||||
}
|
||||
}
|
||||
return `http://localhost:${DASHBOARD_PORT}/api/sessions`;
|
||||
return "/api/sessions";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -125,12 +119,21 @@ function parseExecError(result: ExecResult): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
const CHAT_STORAGE_PREFIX = "dashboard-chat-";
|
||||
|
||||
function clearChatStorage(sessionName: string) {
|
||||
try {
|
||||
sessionStorage.removeItem(`${CHAT_STORAGE_PREFIX}${sessionName}`);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export const closeSessionAtom = atom(null, (get, set, port: number) => {
|
||||
const sessions = get(sessionsAtom);
|
||||
const s = sessions.find((x) => x.port === port)?.session;
|
||||
if (s) {
|
||||
set(closingSessionsAtom, (prev) => new Set(prev).add(s));
|
||||
execCommand(sessionArgs(s, "close"));
|
||||
clearChatStorage(s);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -140,6 +143,7 @@ export const killSessionAtom = atom(null, (get, set, port: number) => {
|
||||
if (s) {
|
||||
set(closingSessionsAtom, (prev) => new Set(prev).add(s));
|
||||
killSession(s);
|
||||
clearChatStorage(s);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -149,6 +153,7 @@ export const closeAllSessionsAtom = atom(null, (get, set) => {
|
||||
if (!s.pending && !s.closing) {
|
||||
set(closingSessionsAtom, (prev) => new Set(prev).add(s.session));
|
||||
execCommand(sessionArgs(s.session, "close"));
|
||||
clearChatStorage(s.session);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user