Files
tusk/src/components/layout/Toolbar.tsx
Aleksey Shakhmatov 4f7afc17f4 feat: rescope to AI-first DB harness with multi-DB chat agent
Removes enterprise/DBA features and replaces the marginal AI bar with a
central chat agent that has progressive-discovery tools, cross-session
memory, saved-query reuse, and inline result actions. Adds ClickHouse
support alongside PostgreSQL/Greenplum.

Cleanup
- Drop ~10k LOC of advanced features: Docker, Snapshots, Validation,
  Index Advisor, Role/User Management, Data Generator, ERD, Lookup.
- Trim deps: drop @xyflow/react, dagre, @types/dagre; cut tokio features
  to rt-multi-thread/sync/time/net/macros.
- Remove unused TuskError variants and dead helpers (topological_sort,
  invalidate_schema_cache).

Multi-DB (PostgreSQL + ClickHouse)
- New src-tauri/src/db/ module: ChClient (HTTP-based, reuses reqwest),
  sql_guard (cross-flavor read-only whitelist with 8 tests).
- ConnectionConfig gains db_flavor and secure fields with serde defaults
  for backwards-compatible connections.json.
- All connection/query/schema/data commands dispatch by flavor; CH
  covers connect, execute_query, list_databases/schemas/tables/views/
  columns/completion_schema, paginated table fetch.
- Frontend: dbCapabilities matrix, ConnectionDialog engine selector
  with port auto-swap and HTTPS toggle, SqlEditor switches to
  StandardSQL dialect for CH, TableDataView surfaces CH connections as
  read-only.

AI-first chat agent
- New src/components/chat/ panel with composer, message rendering,
  collapsible tool-call/result blocks, top-level ErrorBoundary.
- Backend agent loop in commands/chat.rs with strict-JSON tool
  protocol. Nine tools: list_databases, list_tables, get_columns,
  switch_database, run_query, remember, save_query, find_queries, final.
  Forgiving parser accepts both flat and nested-input shapes.
- Compressed history: only the last 4 run_query results carry sample
  rows (≤10, cells truncated to 200 chars) into LLM context; older
  results marked omitted.
- System prompt uses lite OVERVIEW (DB list + active-DB tables only)
  instead of full DDL — schema details are loaded on demand via
  get_columns. CH OVERVIEW shows cross-DB tables since CH allows
  db.table queries.

Cross-session memory (F1)
- Per-connection markdown file at app_data_dir/memory/<connection_id>.md,
  16KB cap with oldest-block eviction. Agent appends via remember()
  tool; the file is injected into LEARNED NOTES section of every system
  prompt.
- New Memory sidebar tab with editable textarea, badge for note count,
  empty-state with template. Edits picked up on the next agent turn.

Saved-query reuse (F2)
- Tools save_query and find_queries scoped to current connection.
  save_query attaches a UUID + timestamp; find_queries returns top 10
  matches with SQL preview ≤500 chars.
- Storage shared with the sidebar Saved panel.

Inline result actions (F3)
- run_query result block in chat gets Open-full (90vw × 80vh modal with
  full ResultsTable, no row cap) and Export (reuses ExportDialog for
  CSV/JSON via existing exportCsv/exportJson commands).

Verification
- cargo check clean, zero warnings.
- cargo test --lib: 50 pass (20 chat parser + 4 memory + 8 sql_guard +
  6 clean_sql + 12 escape_ident).
- npx tsc --noEmit clean.
- npx vitest run: 20 pass.
2026-05-06 19:30:44 +03:00

161 lines
5.0 KiB
TypeScript

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ConnectionSelector } from "@/components/connections/ConnectionSelector";
import { ConnectionList } from "@/components/connections/ConnectionList";
import { ConnectionDialog } from "@/components/connections/ConnectionDialog";
import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle";
import { useAppStore } from "@/stores/app-store";
import { useConnections, useReconnect } from "@/hooks/use-connections";
import { toast } from "sonner";
import { Database, Plus, RefreshCw, Settings, Sparkles } from "lucide-react";
import type { ConnectionConfig, Tab } from "@/types";
import { getEnvironment } from "@/lib/environment";
import { AppSettingsSheet } from "@/components/settings/AppSettingsSheet";
export function Toolbar() {
const [listOpen, setListOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
const { data: connections } = useConnections();
const reconnectMutation = useReconnect();
const activeConn = connections?.find((c) => c.id === activeConnectionId);
const activeEnv = getEnvironment(activeConn?.environment);
const activeColor = activeEnv?.color ?? activeConn?.color;
const handleReconnect = () => {
if (!activeConn) return;
reconnectMutation.mutate(activeConn, {
onSuccess: () => toast.success("Reconnected"),
onError: (err) => toast.error("Reconnect failed", { description: String(err) }),
});
};
const handleNewQuery = () => {
if (!activeConnectionId) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "query",
title: "New Query",
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
sql: "",
};
addTab(tab);
};
const handleNewChat = () => {
if (!activeConnectionId) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "chat",
title: "Chat",
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
};
addTab(tab);
};
return (
<>
<div
className="tusk-toolbar tusk-conn-strip flex h-10 items-center gap-1.5 px-3"
style={{
"--strip-width": activeColor ? "3px" : "0px",
"--strip-color": activeColor ?? "transparent",
} as React.CSSProperties}
>
<Button
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={() => setListOpen(true)}
>
<Database className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Connections</span>
</Button>
<div className="mx-1 h-4 w-px bg-border" />
<ConnectionSelector />
<Button
variant="ghost"
size="icon-xs"
onClick={handleReconnect}
disabled={!activeConnectionId || reconnectMutation.isPending}
title="Reconnect"
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`h-3.5 w-3.5 ${reconnectMutation.isPending ? "animate-spin" : ""}`} />
</Button>
<div className="mx-1 h-4 w-px bg-border" />
<ReadOnlyToggle />
<div className="mx-1 h-4 w-px bg-border" />
<Button
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={handleNewChat}
disabled={!activeConnectionId}
>
<Sparkles className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Ask AI</span>
</Button>
<Button
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={handleNewQuery}
disabled={!activeConnectionId}
>
<Plus className="h-3.5 w-3.5" />
<span className="text-xs font-medium">New Query</span>
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="icon-xs"
onClick={() => setSettingsOpen(true)}
title="Settings"
className="text-muted-foreground hover:text-foreground"
>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
<ConnectionList
open={listOpen}
onOpenChange={setListOpen}
onEdit={(conn) => {
setEditingConn(conn);
setDialogOpen(true);
}}
onNew={() => {
setEditingConn(null);
setDialogOpen(true);
}}
/>
<ConnectionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
connection={editingConn}
/>
<AppSettingsSheet
open={settingsOpen}
onOpenChange={setSettingsOpen}
/>
</>
);
}