- new design system in globals.css: warm graphite surfaces, ivory text, honey accent; semantic status/data-type/syntax tokens replacing hardcoded colors - IBM Plex Mono as the universal UI font (sans + mono), tabular numerals - custom CodeMirror SQL theme (src/lib/editor-theme.ts) matching the palette - data grid: zebra striping + honey row hover, stronger sticky header - route status dots, JSON syntax, EXPLAIN cost, schema-tree icons and the read/write toggle through the new tokens - TUSK wordmark in the toolbar
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { useChat } from "@/hooks/use-chat";
|
|
import { ChatComposer } from "./ChatComposer";
|
|
import { ChatMessageView } from "./ChatMessageView";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { Eraser, Sparkles, Layers } from "lucide-react";
|
|
import { useAppStore } from "@/stores/app-store";
|
|
import { useAiSettings } from "@/hooks/use-ai";
|
|
import type { ContextUsage } from "@/types";
|
|
|
|
interface Props {
|
|
tabId: string;
|
|
connectionId: string;
|
|
}
|
|
|
|
export function ChatPanel({ tabId, connectionId }: Props) {
|
|
const { messages, pending, usage, send, clear, compact } = useChat(tabId, connectionId);
|
|
const dbFlavors = useAppStore((s) => s.dbFlavors);
|
|
const flavor = dbFlavors[connectionId];
|
|
const { data: aiSettings } = useAiSettings();
|
|
const aiReady = !!aiSettings?.model;
|
|
|
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
scrollerRef.current?.scrollTo({
|
|
top: scrollerRef.current.scrollHeight,
|
|
behavior: "smooth",
|
|
});
|
|
}, [messages.length, pending]);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<div className="flex h-9 items-center justify-between border-b border-border/40 px-3">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Sparkles className="h-3.5 w-3.5 text-primary/70" />
|
|
<span className="font-medium">AI Assistant</span>
|
|
{flavor && (
|
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground/60">
|
|
· {flavor}
|
|
</span>
|
|
)}
|
|
{aiSettings?.model && (
|
|
<span className="text-[10px] text-muted-foreground/60">· {aiSettings.model}</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<UsageBadge usage={usage} />
|
|
<Button
|
|
size="xs"
|
|
variant="ghost"
|
|
onClick={() => compact()}
|
|
disabled={messages.length === 0 || pending}
|
|
title="Summarize older messages to free context (also: type /compact)"
|
|
className="h-6 gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Layers className="h-3 w-3" />
|
|
Compact
|
|
</Button>
|
|
<Button
|
|
size="xs"
|
|
variant="ghost"
|
|
onClick={clear}
|
|
disabled={messages.length === 0 || pending}
|
|
className="h-6 gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Eraser className="h-3 w-3" />
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref={scrollerRef} className="min-h-0 flex-1 overflow-y-auto">
|
|
{messages.length === 0 && !pending ? (
|
|
<EmptyState aiReady={aiReady} flavor={flavor} />
|
|
) : (
|
|
<div className="flex flex-col gap-3 px-4 py-3">
|
|
{messages.map((m) => (
|
|
<ChatMessageView key={m.id} message={m} />
|
|
))}
|
|
{pending && <PendingIndicator />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border/40 bg-background/40 p-2">
|
|
<ChatComposer
|
|
onSend={send}
|
|
disabled={pending || !aiReady}
|
|
placeholder={
|
|
aiReady
|
|
? "Ask in plain language. /compact to summarise, /clear to wipe."
|
|
: "Configure an AI model in Settings to enable chat."
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UsageBadge({ usage }: { usage: ContextUsage | undefined }) {
|
|
if (!usage || usage.budget_chars === 0) return null;
|
|
const ratio = Math.min(usage.used_chars / usage.budget_chars, 1.5);
|
|
const usedTok = Math.round(usage.used_chars / 3 / 100) / 10; // ~k-tokens with 1 decimal
|
|
const budgetTok = Math.round(usage.budget_chars / 3 / 100) / 10;
|
|
const percent = Math.round(ratio * 100);
|
|
|
|
let toneClass = "text-muted-foreground/70";
|
|
if (ratio >= 0.85) toneClass = "text-destructive";
|
|
else if (ratio >= 0.6) toneClass = "text-warning";
|
|
else if (ratio >= 0.3) toneClass = "text-success/80";
|
|
|
|
const trackClass = "h-1.5 w-12 overflow-hidden rounded-full bg-muted";
|
|
let fillClass = "bg-success/70";
|
|
if (ratio >= 0.85) fillClass = "bg-destructive";
|
|
else if (ratio >= 0.6) fillClass = "bg-warning";
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="flex items-center gap-1.5 text-[10px]">
|
|
<div className={trackClass}>
|
|
<div
|
|
className={fillClass}
|
|
style={{
|
|
height: "100%",
|
|
width: `${Math.min(ratio, 1) * 100}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
<span className={toneClass}>
|
|
{usedTok}k / {budgetTok}k tok · {percent}%
|
|
</span>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="max-w-[260px] text-xs">
|
|
Approximate context usage. {usage.used_chars.toLocaleString()} chars sent to the model
|
|
last turn out of {usage.budget_chars.toLocaleString()} budget.
|
|
{ratio >= 0.6 && " Type /compact (or click Compact) to summarise older history."}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function PendingIndicator() {
|
|
return (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground/70">
|
|
<span className="inline-flex gap-0.5">
|
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary/70" />
|
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary/70 [animation-delay:120ms]" />
|
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary/70 [animation-delay:240ms]" />
|
|
</span>
|
|
Thinking...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ aiReady, flavor }: { aiReady: boolean; flavor: string | undefined }) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center p-6">
|
|
<div className="max-w-md space-y-3 text-center">
|
|
<Sparkles className="mx-auto h-8 w-8 text-primary/50" />
|
|
<h3 className="text-sm font-medium">Ask anything about your data</h3>
|
|
<p className="text-xs text-muted-foreground">
|
|
{aiReady
|
|
? `Connected to ${flavor ?? "database"}. Try: "How many rows in each table?", "Top 10 customers by total spend", "Show me last week's orders".`
|
|
: "Open Settings → AI to choose an Ollama model. Tusk will then assist with natural-language queries."}
|
|
</p>
|
|
{aiReady && (
|
|
<p className="text-[11px] text-muted-foreground/60">
|
|
Slash commands: <code>/compact</code> · <code>/clear</code>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|