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:
Chris Tate
2026-04-06 08:10:43 -05:00
committed by GitHub
parent fcb6615f5a
commit 317e6869b6
44 changed files with 4933 additions and 1957 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+19
View File
@@ -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;
}
+7 -4
View File
@@ -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>
);
+48 -3
View File
@@ -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">&#9679;</span>;
return <span className="flex size-4 shrink-0 items-center justify-center rounded-sm bg-muted text-[8px] text-muted-foreground">&#9679;</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 };
+2 -4
View File
@@ -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 }),
+160
View File
@@ -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,
];
+81
View File
@@ -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]);
}
+13 -8
View File
@@ -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);
}
}
});