diff --git a/databricks-builder-app/client/index.html b/databricks-builder-app/client/index.html index 06df5c39..48f3e72d 100644 --- a/databricks-builder-app/client/index.html +++ b/databricks-builder-app/client/index.html @@ -2,7 +2,7 @@ - + diff --git a/databricks-builder-app/client/public/favicon.ico b/databricks-builder-app/client/public/favicon.ico new file mode 100644 index 00000000..4cb6d015 Binary files /dev/null and b/databricks-builder-app/client/public/favicon.ico differ diff --git a/databricks-builder-app/client/src/components/FunLoader.tsx b/databricks-builder-app/client/src/components/FunLoader.tsx index e450f766..c1a283e2 100644 --- a/databricks-builder-app/client/src/components/FunLoader.tsx +++ b/databricks-builder-app/client/src/components/FunLoader.tsx @@ -56,7 +56,7 @@ export function FunLoader({ todos = [], className }: FunLoaderProps) { const currentTodo = todos.find((t) => t.status === 'in_progress'); return ( -
+
{/* Main loader with rotating message */}
diff --git a/databricks-builder-app/client/src/contexts/ProjectsContext.tsx b/databricks-builder-app/client/src/contexts/ProjectsContext.tsx index 8c3e46bf..11f1bf2a 100644 --- a/databricks-builder-app/client/src/contexts/ProjectsContext.tsx +++ b/databricks-builder-app/client/src/contexts/ProjectsContext.tsx @@ -9,6 +9,7 @@ import { import { createProject as apiCreateProject, deleteProject as apiDeleteProject, + renameProject as apiRenameProject, fetchProjects, } from "@/lib/api"; import type { Project } from "@/lib/types"; @@ -20,6 +21,7 @@ interface ProjectsContextType { refresh: () => Promise; createProject: (name: string) => Promise; deleteProject: (projectId: string) => Promise; + renameProject: (projectId: string, name: string) => Promise; } const ProjectsContext = createContext(null); @@ -53,6 +55,13 @@ export function ProjectsProvider({ children }: { children: ReactNode }) { setProjects((prev) => prev.filter((p) => p.id !== projectId)); }, []); + const renameProject = useCallback(async (projectId: string, name: string): Promise => { + await apiRenameProject(projectId, name); + setProjects((prev) => + prev.map((p) => (p.id === projectId ? { ...p, name } : p)) + ); + }, []); + useEffect(() => { refresh(); }, [refresh]); @@ -64,6 +73,7 @@ export function ProjectsProvider({ children }: { children: ReactNode }) { refresh, createProject, deleteProject, + renameProject, }; return ( diff --git a/databricks-builder-app/client/src/lib/api.ts b/databricks-builder-app/client/src/lib/api.ts index db504782..9c2babb7 100644 --- a/databricks-builder-app/client/src/lib/api.ts +++ b/databricks-builder-app/client/src/lib/api.ts @@ -56,6 +56,10 @@ export async function createProject(name: string): Promise { return request('/projects', { method: 'POST', body: { name } }); } +export async function renameProject(projectId: string, name: string): Promise { + return request(`/projects/${projectId}`, { method: 'PATCH', body: { name } }); +} + export async function deleteProject(projectId: string): Promise { return request(`/projects/${projectId}`, { method: 'DELETE' }); } diff --git a/databricks-builder-app/client/src/pages/DocPage.tsx b/databricks-builder-app/client/src/pages/DocPage.tsx index 544a36bd..f8b7b29c 100644 --- a/databricks-builder-app/client/src/pages/DocPage.tsx +++ b/databricks-builder-app/client/src/pages/DocPage.tsx @@ -1,5 +1,4 @@ import React, { useState } from 'react'; -import { Link } from 'react-router-dom'; import { Home, Database, @@ -13,8 +12,9 @@ import { Terminal, Sparkles } from 'lucide-react'; +import { MainLayout } from '@/components/layout/MainLayout'; -type DocSection = 'overview' | 'tools-skills' | 'app'; +type DocSection = 'overview' | 'app'; interface NavItem { id: DocSection; @@ -24,7 +24,6 @@ interface NavItem { const navItems: NavItem[] = [ { id: 'overview', label: 'Overview', icon: }, - { id: 'tools-skills', label: 'Tools & Skills', icon: }, { id: 'app', label: 'MCP App', icon: }, ]; @@ -356,162 +355,6 @@ function OverviewSection() { ); } -type CoverageStatus = 'done' | 'in-progress' | 'not-started' | 'tbd'; - -interface CoverageItem { - product: string; - skills: CoverageStatus; - mcpFunctions: CoverageStatus; - tested: CoverageStatus; - functionalInApp?: CoverageStatus; - owner?: string; - testLink?: string; - date?: string; - comments?: string; -} - -interface CoverageSection { - title: string; - items: CoverageItem[]; -} - -const coverageSections: CoverageSection[] = [ - { - title: 'Ingestion / ETL', - items: [ - { product: 'Lakeflow Spark Declarative Pipelines', skills: 'done', mcpFunctions: 'done', tested: 'done', functionalInApp: 'in-progress', owner: 'Cal Reynolds', date: 'Jan 13', comments: 'Tested with State Street example. Successful locally in deploying pipelines of python, sql and modifiable varieties (SCP, Iceberg, Clustering). \n\nNeeds claude-agent-sdk bug to be fixed to work with app' }, - { product: 'Lakeflow Jobs', skills: 'not-started', mcpFunctions: 'not-started', tested: 'not-started', owner: '' }, - { product: 'Synthetic Data Generation', skills: 'done', mcpFunctions: 'done', tested: 'done', owner: '' }, - { product: 'PDF / Unstructured Data Generation', skills: 'done', mcpFunctions: 'done', tested: 'done', owner: 'Quentin', comments: 'Generate realistic PDFs (invoices, contracts, reports) and unstructured data files. Uses LLM for content generation.' }, - ], - }, - { - title: 'ML / AI', - items: [ - { product: 'Agent Bricks - Knowledge Assistant', skills: 'done', mcpFunctions: 'done', tested: 'done', owner: '', comments: 'Knowledge Assistant tile management' }, - { product: 'Agent Bricks - Supervisor Agent', skills: 'done', mcpFunctions: 'done', tested: 'done', owner: '', comments: 'Supervisor Agent tile management' }, - { product: 'Agent Bricks - Genie', skills: 'done', mcpFunctions: 'done', tested: 'done', owner: '', comments: 'Genie tile management' }, - { product: 'Model Serving', skills: 'not-started', mcpFunctions: 'not-started', tested: 'not-started', owner: '' }, - { product: 'Classic ML and MLFlow', skills: 'not-started', mcpFunctions: 'not-started', tested: 'not-started', owner: '' }, - ], - }, - { - title: 'AI/BI - SQL', - items: [ - { product: 'DBSQL', skills: 'done', mcpFunctions: 'done', tested: 'done', owner: '' }, - { product: 'Unity Catalog', skills: 'done', mcpFunctions: 'done', tested: 'done', owner: '' }, - { product: 'AI/BI Dashboards', skills: 'not-started', mcpFunctions: 'not-started', tested: 'not-started', owner: '' }, - ], - }, - { - title: 'Other', - items: [ - { product: 'Databricks Asset Bundles', skills: 'done', mcpFunctions: 'not-started', tested: 'not-started', owner: '' }, - { product: 'Notebook Creation', skills: 'not-started', mcpFunctions: 'not-started', tested: 'not-started', owner: '' }, - { product: 'Lakebase', skills: 'not-started', mcpFunctions: 'not-started', tested: 'not-started', owner: '' }, - { product: 'Apps', skills: 'tbd', mcpFunctions: 'tbd', tested: 'tbd', owner: 'Ivan' }, - ], - }, -]; - -function StatusBadge({ status }: { status: CoverageStatus }) { - const config = { - 'done': { label: 'Initial Coverage', bg: 'bg-green-500/20', text: 'text-green-400', dot: 'bg-green-400' }, - 'in-progress': { label: 'In Progress', bg: 'bg-yellow-500/20', text: 'text-yellow-400', dot: 'bg-yellow-400' }, - 'not-started': { label: 'Not Started', bg: 'bg-[var(--color-text-muted)]/20', text: 'text-[var(--color-text-muted)]', dot: 'bg-[var(--color-text-muted)]' }, - 'tbd': { label: 'TBD', bg: 'bg-purple-500/20', text: 'text-purple-400', dot: 'bg-purple-400' }, - }; - const { label, bg, text, dot } = config[status]; - - return ( - - - {label} - - ); -} - -function ToolsSkillsSection() { - return ( -
-
-

- Tools & Skills Coverage -

-

- Current status of Databricks product coverage in the AI Dev Kit -

-
- - {/* Coverage Table */} -
- - - - - - - - - - - - - - - {coverageSections.map((section) => ( - - {/* Section Header */} - - - - {/* Section Items */} - {section.items.map((item, idx) => ( - - - - - - - - - - - ))} - - ))} - -
ProductSkillsMCP FunctionsTested on Local TerminalFunctional in AppOwnerDateComments
- {section.title} -
{item.product}{item.functionalInApp ? : '-'}{item.owner || '-'}{item.date || '-'}{item.comments || '-'}
-
- - {/* Legend */} -
-
- - Initial Coverage -
-
- - In Progress -
-
- - Not Started -
-
- - TBD -
-
-
- ); -} - function AppSection() { return (
@@ -840,8 +683,6 @@ export default function DocPage() { switch (activeSection) { case 'overview': return ; - case 'tools-skills': - return ; case 'app': return ; default: @@ -849,63 +690,34 @@ export default function DocPage() { } }; - return ( -
- {/* Top Bar */} -
-
-
- -
- -
- AI Dev Kit - - / - Documentation -
- +
+ {navItems.map((item) => ( +
-
- - {/* Spacer for fixed header */} -
- - {/* Main Layout */} -
- {/* Left Navigation */} - + {item.icon} + {item.label} + + ))} +
+ + ); - {/* Content Area */} -
-
- {renderSection()} -
-
+ return ( + +
+
+ {renderSection()} +
-
+ ); } diff --git a/databricks-builder-app/client/src/pages/HomePage.tsx b/databricks-builder-app/client/src/pages/HomePage.tsx index c5023cba..62fe518f 100644 --- a/databricks-builder-app/client/src/pages/HomePage.tsx +++ b/databricks-builder-app/client/src/pages/HomePage.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; +import { useState, useMemo, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Folder, Trash2, Loader2, MessageSquare } from 'lucide-react'; +import { Trash2, Loader2, MessageSquare, Plus, ArrowRight, Clock, BarChart3, Folder, Pencil, Check, X } from 'lucide-react'; import { toast } from 'sonner'; import { MainLayout } from '@/components/layout/MainLayout'; import { Button } from '@/components/ui/Button'; @@ -9,12 +9,145 @@ import { useProjects } from '@/contexts/ProjectsContext'; import { useUser } from '@/contexts/UserContext'; import { formatRelativeTime } from '@/lib/utils'; +type SortMode = 'recent' | 'conversations'; + +/* ─── Deterministic color from string ─── */ +const CARD_PALETTES = [ + { from: '#FF3621', to: '#FF6B4A' }, // red + { from: '#E8590C', to: '#FF922B' }, // orange + { from: '#D6336C', to: '#F06595' }, // pink + { from: '#7048E8', to: '#9775FA' }, // violet + { from: '#1C7ED6', to: '#4DABF7' }, // blue + { from: '#0CA678', to: '#38D9A9' }, // teal + { from: '#E03131', to: '#FF6B6B' }, // crimson + { from: '#6741D9', to: '#B197FC' }, // purple +]; + +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +function getCardPalette(id: string) { + return CARD_PALETTES[hashString(id) % CARD_PALETTES.length]; +} + +/* ─── Animated mesh gradient canvas ─── */ +function MeshGradient() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let animationId: number; + let t = 0; + + const resize = () => { + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + }; + resize(); + window.addEventListener('resize', resize); + + const blobs = [ + { x: 0.2, y: 0.25, r: 260, color: [255, 54, 33], sx: 0.7, sy: 0.5, px: 0, py: 2 }, + { x: 0.75, y: 0.35, r: 240, color: [255, 95, 70], sx: 0.4, sy: 0.6, px: 1.2, py: 0.8 }, + { x: 0.5, y: 0.65, r: 220, color: [255, 140, 50], sx: 0.55, sy: 0.35, px: 3.5, py: 1.5 }, + { x: 0.1, y: 0.6, r: 200, color: [200, 40, 20], sx: 0.3, sy: 0.7, px: 5, py: 4 }, + { x: 0.85, y: 0.2, r: 210, color: [255, 170, 80], sx: 0.6, sy: 0.45, px: 2.5, py: 3.2 }, + { x: 0.55, y: 0.3, r: 180, color: [255, 60, 40], sx: 0.5, sy: 0.55, px: 4.1, py: 5.3 }, + ]; + + const draw = () => { + const rect = canvas.getBoundingClientRect(); + const w = rect.width; + const h = rect.height; + + ctx.clearRect(0, 0, w, h); + + for (const blob of blobs) { + const bx = blob.x * w + Math.sin(t * blob.sx + blob.px) * (w * 0.15) + + Math.sin(t * blob.sx * 0.6 + blob.px * 1.7) * (w * 0.06); + const by = blob.y * h + Math.cos(t * blob.sy + blob.py) * (h * 0.18) + + Math.cos(t * blob.sy * 0.7 + blob.py * 1.3) * (h * 0.05); + const br = blob.r + Math.sin(t * 0.8 + blob.r * 0.01) * 40; + + const gradient = ctx.createRadialGradient(bx, by, 0, bx, by, br); + const [r, g, b] = blob.color; + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.45)`); + gradient.addColorStop(0.4, `rgba(${r}, ${g}, ${b}, 0.18)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, w, h); + } + + t += 0.012; + animationId = requestAnimationFrame(draw); + }; + + draw(); + return () => { + cancelAnimationFrame(animationId); + window.removeEventListener('resize', resize); + }; + }, []); + + return ( + + ); +} + +/* ─── Floating grid of subtle dots ─── */ +function DotGrid() { + return ( +
+ ); +} + export default function HomePage() { const navigate = useNavigate(); const { loading: userLoading } = useUser(); - const { projects, loading: projectsLoading, createProject, deleteProject } = useProjects(); + const { projects, loading: projectsLoading, createProject, deleteProject, renameProject } = useProjects(); const [newProjectName, setNewProjectName] = useState(''); const [isCreating, setIsCreating] = useState(false); + const [sortMode, setSortMode] = useState('recent'); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const renameInputRef = useRef(null); + + const sortedProjects = useMemo(() => { + const sorted = [...projects]; + if (sortMode === 'recent') { + sorted.sort((a, b) => { + if (!a.created_at) return 1; + if (!b.created_at) return -1; + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + } else { + sorted.sort((a, b) => b.conversation_count - a.conversation_count); + } + return sorted; + }, [projects, sortMode]); const handleCreateProject = async (e: React.FormEvent) => { e.preventDefault(); @@ -47,6 +180,33 @@ export default function HomePage() { } }; + const startRename = (e: React.MouseEvent, project: { id: string; name: string }) => { + e.stopPropagation(); + setRenamingId(project.id); + setRenameValue(project.name); + setTimeout(() => renameInputRef.current?.select(), 0); + }; + + const confirmRename = async (e?: React.FormEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + if (!renamingId || !renameValue.trim()) return; + + try { + await renameProject(renamingId, renameValue.trim()); + toast.success('Project renamed'); + } catch (error) { + toast.error('Failed to rename project'); + console.error(error); + } + setRenamingId(null); + }; + + const cancelRename = (e?: React.MouseEvent) => { + e?.stopPropagation(); + setRenamingId(null); + }; + if (userLoading || projectsLoading) { return ( @@ -59,86 +219,213 @@ export default function HomePage() { return ( -
-
- {/* Page Header */} -
-

- Your Projects -

-

- Create and manage your Databricks AI Dev Kit projects +

+ {/* ─── Hero section ─── */} +
+
+ + + +
+
+ + + +
+ +

+ AI Dev Kit +

+

+ Build, deploy, and manage Databricks resources with an AI-powered coding agent. + Create a project to get started.

-
- {/* Create Project Form - Always visible */} -
-
+ setNewProjectName(e.target.value)} placeholder="New project name..." - className="flex-1" + className="flex-1 h-12 text-base bg-[var(--color-bg-secondary)]/80 backdrop-blur border-[var(--color-border)] shadow-sm" /> -
- {/* Projects List */} +
+
+ + {/* ─── Projects section ─── */} +
+
+

+ Your Projects + + ({projects.length}) + +

+ + {projects.length > 1 && ( +
+ + +
+ )} +
+ {projects.length === 0 ? ( -
- -

+

+ +

No projects yet. Create one above to get started.

) : ( -
-
- {projects.map((project) => ( +
+ {sortedProjects.map((project) => { + const palette = getCardPalette(project.id); + const monogram = project.name.charAt(0).toUpperCase(); + const isRenaming = renamingId === project.id; + + return (
navigate(`/projects/${project.id}`)} - className="group flex cursor-pointer items-center justify-between rounded-xl border border-[var(--color-border)]/50 bg-[var(--color-bg-secondary)] p-4 transition-all duration-200 hover:border-[var(--color-border)] hover:shadow-md" + onClick={() => !isRenaming && navigate(`/projects/${project.id}`)} + className="group relative flex flex-col rounded-2xl border border-[var(--color-border)]/60 bg-[var(--color-bg-secondary)] cursor-pointer transition-all duration-200 hover:border-[var(--color-border)] hover:shadow-xl hover:shadow-black/[0.04] hover:-translate-y-0.5 overflow-hidden" > -
-
- + {/* Gradient accent bar */} +
+ +
+ {/* Top row: monogram + actions */} +
+
+ {monogram} +
+
+ + +
-
-

+ + {/* Project name — editable or static */} + {isRenaming ? ( +
e.stopPropagation()} + className="flex items-center gap-1.5" + > + setRenameValue(e.target.value)} + onKeyDown={(e) => e.key === 'Escape' && cancelRename()} + onBlur={() => confirmRename()} + className="flex-1 min-w-0 text-lg font-semibold text-[var(--color-text-heading)] bg-transparent border-b-2 border-[var(--color-accent-primary)] outline-none py-0.5" + autoFocus + /> + + +
+ ) : ( +

{project.name}

-
- - - {project.conversation_count} conversation - {project.conversation_count !== 1 ? 's' : ''} + )} + + {/* Spacer */} +
+ + {/* Bottom stats */} +
+
+ + + {project.conversation_count} conversation{project.conversation_count !== 1 ? 's' : ''} - {formatRelativeTime(project.created_at)} +
+
+ + {project.created_at ? formatRelativeTime(project.created_at) : ''} + +
-
- ))} -
+ ); + })}
)} -
+
); diff --git a/databricks-builder-app/client/src/pages/ProjectPage.tsx b/databricks-builder-app/client/src/pages/ProjectPage.tsx index 0131fdc7..7b7ea193 100644 --- a/databricks-builder-app/client/src/pages/ProjectPage.tsx +++ b/databricks-builder-app/client/src/pages/ProjectPage.tsx @@ -2,13 +2,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useUser } from '@/contexts/UserContext'; import { + ArrowUp, + Check, ChevronDown, + ClipboardCopy, ExternalLink, Loader2, - MessageSquare, - Send, + Settings2, Square, + Sparkles, Wrench, + X, } from 'lucide-react'; import { toast } from 'sonner'; import ReactMarkdown from 'react-markdown'; @@ -17,7 +21,6 @@ import { MainLayout } from '@/components/layout/MainLayout'; import { Sidebar } from '@/components/layout/Sidebar'; import { SkillsExplorer } from '@/components/SkillsExplorer'; import { FunLoader } from '@/components/FunLoader'; -import { Button } from '@/components/ui/Button'; import { createConversation, deleteConversation, @@ -45,7 +48,72 @@ interface ActivityItem { timestamp: number; } -// Minimal activity indicator - shows only current tool being executed +// Databricks logo mark SVG +function DatabricksLogo({ className }: { className?: string }) { + return ( + + + + + + ); +} + +// Expandable tools list for a message +function ToolsUsedBadge({ tools }: { tools: string[] }) { + const [expanded, setExpanded] = useState(false); + + if (tools.length === 0) return null; + + // Deduplicate and clean tool names + const uniqueTools = [...new Set(tools.map(t => t.replace('mcp__databricks__', '').replace(/_/g, ' ')))]; + + return ( +
+ + {expanded && ( +
+ {uniqueTools.map((tool, i) => ( + + + {tool} + + ))} +
+ )} +
+ ); +} + +// Copy button for code blocks +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +// Activity indicator - shows current tool with animated dots function ActivitySection({ items, }: { @@ -54,17 +122,244 @@ function ActivitySection({ }) { if (items.length === 0) return null; - // Get the most recent tool_use item (current activity) const currentTool = [...items].reverse().find((item) => item.type === 'tool_use'); - if (!currentTool) return null; + const toolName = currentTool.toolName?.replace('mcp__databricks__', '').replace(/_/g, ' ') || 'working'; + + return ( +
+
+ +
+
+ + + {toolName} + + + + + + +
+
+ ); +} + +// Custom dropdown for cluster/warehouse selection with status indicators +function ResourceDropdown({ + label, + items, + selectedId, + onSelect, + nameKey, + idKey, +}: { + label: string; + items: T[]; + selectedId?: string; + onSelect: (id: string | undefined) => void; + nameKey: keyof T; + idKey: keyof T; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + if (open) { document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); } + }, [open]); + + const selected = items.find((i) => String(i[idKey]) === selectedId); + const selectedName = selected ? String(selected[nameKey] || '') : ''; + return ( -
- - - Using {currentTool.toolName?.replace('mcp__databricks__', '')}... - +
+ + + {open && ( +
+ {items.map((item) => { + const id = String(item[idKey]); + const name = String(item[nameKey] || ''); + const isSelected = id === selectedId; + return ( + + ); + })} +
+ )} +
+ ); +} + +// Configuration panel component +function ConfigPanel({ + isOpen, + onClose, + defaultCatalog, + setDefaultCatalog, + defaultSchema, + setDefaultSchema, + clusters, + selectedClusterId, + setSelectedClusterId, + warehouses, + selectedWarehouseId, + setSelectedWarehouseId, + workspaceFolder, + setWorkspaceFolder, + mlflowExperimentName, + setMlflowExperimentName, + workspaceUrl, +}: { + isOpen: boolean; + onClose: () => void; + defaultCatalog: string; + setDefaultCatalog: (v: string) => void; + defaultSchema: string; + setDefaultSchema: (v: string) => void; + clusters: Cluster[]; + selectedClusterId?: string; + setSelectedClusterId: (v: string | undefined) => void; + warehouses: Warehouse[]; + selectedWarehouseId?: string; + setSelectedWarehouseId: (v: string | undefined) => void; + workspaceFolder: string; + setWorkspaceFolder: (v: string) => void; + mlflowExperimentName: string; + setMlflowExperimentName: (v: string) => void; + workspaceUrl: string | null; +}) { + if (!isOpen) return null; + + return ( +
+
+

Configuration

+ +
+
+ {/* Catalog & Schema - stacked for more room */} +
+ +
+ setDefaultCatalog(e.target.value)} + placeholder="catalog" + className="flex-1 h-10 px-3 bg-transparent text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)]/50 focus:outline-none min-w-0" + /> + . + setDefaultSchema(e.target.value)} + placeholder="schema" + className="flex-1 h-10 px-3 bg-transparent text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)]/50 focus:outline-none min-w-0" + /> + {workspaceUrl && defaultCatalog && defaultSchema && ( + + + + )} +
+
+ + {/* Cluster - custom dropdown */} + {clusters.length > 0 && ( + + )} + + {/* Warehouse - custom dropdown */} + {warehouses.length > 0 && ( + + )} + + {/* Workspace Folder */} +
+ + setWorkspaceFolder(e.target.value)} + placeholder="/Workspace/Users/..." + className="mt-1.5 w-full h-10 px-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-background)] text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-primary)]/30 focus:border-[var(--color-accent-primary)]/50" + /> +
+ + {/* MLflow Experiment */} +
+ + setMlflowExperimentName(e.target.value)} + placeholder="Experiment ID or name" + className="mt-1.5 w-full h-10 px-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-background)] text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-primary)]/30 focus:border-[var(--color-accent-primary)]/50" + /> +
+
); } @@ -96,16 +391,14 @@ export default function ProjectPage() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(true); - const [isStreaming, setIsStreaming] = useState(false); + const [streamingConvIds, setStreamingConvIds] = useState([]); const [streamingText, setStreamingText] = useState(''); const [activityItems, setActivityItems] = useState([]); const [todos, setTodos] = useState([]); const [clusters, setClusters] = useState([]); const [selectedClusterId, setSelectedClusterId] = useState(); - const [clusterDropdownOpen, setClusterDropdownOpen] = useState(false); const [warehouses, setWarehouses] = useState([]); const [selectedWarehouseId, setSelectedWarehouseId] = useState(); - const [warehouseDropdownOpen, setWarehouseDropdownOpen] = useState(false); const [defaultCatalog, setDefaultCatalog] = useState('ai_dev_kit'); const [defaultSchema, setDefaultSchema] = useState(''); const [workspaceFolder, setWorkspaceFolder] = useState(''); @@ -113,6 +406,7 @@ export default function ProjectPage() { const [skillsExplorerOpen, setSkillsExplorerOpen] = useState(false); const [activeExecutionId, setActiveExecutionId] = useState(null); const [isReconnecting, setIsReconnecting] = useState(false); + const [messageTools, setMessageTools] = useState>({}); // Calculate default schema from user email + project name once available const userDefaultSchema = useMemo(() => toSchemaName(user, project?.name ?? null), [user, project?.name]); @@ -120,10 +414,22 @@ export default function ProjectPage() { // Refs const messagesEndRef = useRef(null); const inputRef = useRef(null); - const abortControllerRef = useRef(null); - const clusterDropdownRef = useRef(null); - const warehouseDropdownRef = useRef(null); - const reconnectAttemptedRef = useRef(null); // Track which conversation we've checked + const reconnectAttemptedRef = useRef(null); + const currentConvIdRef = useRef(undefined); + // Per-conversation streaming data (supports concurrent streams) + const allStreamsRef = useRef>({}); + + // Keep currentConvIdRef in sync with state + useEffect(() => { currentConvIdRef.current = currentConversation?.id; }, [currentConversation?.id]); // Load project and conversations useEffect(() => { @@ -194,7 +500,7 @@ export default function ProjectPage() { // Check for active execution when conversation loads and reconnect if needed useEffect(() => { - if (!projectId || !currentConversation?.id || isLoading || isStreaming) return; + if (!projectId || !currentConversation?.id || isLoading || allStreamsRef.current[currentConversation.id]) return; // Skip if we've already checked this conversation if (reconnectAttemptedRef.current === currentConversation.id) return; @@ -206,26 +512,38 @@ export default function ProjectPage() { if (active && active.status === 'running') { console.log('[RECONNECT] Found active execution:', active.id); + const reconConvId = currentConversation.id; + const controller = new AbortController(); + allStreamsRef.current[reconConvId] = { + fullText: '', + activityItems: [], + todos: [], + tools: [], + executionId: active.id, + abortController: controller, + isReconnecting: true, + pendingMessages: [], + }; + setStreamingConvIds(prev => [...prev, reconConvId]); setIsReconnecting(true); - setIsStreaming(true); setActiveExecutionId(active.id); - // Create abort controller for reconnection - abortControllerRef.current = new AbortController(); - let fullText = ''; await reconnectToExecution({ executionId: active.id, storedEvents: active.events, - signal: abortControllerRef.current.signal, + signal: controller.signal, onEvent: (event) => { const type = event.type as string; + const stream = allStreamsRef.current[reconConvId]; + const isForeground = currentConvIdRef.current === reconConvId; if (type === 'text_delta') { const text = event.text as string; fullText += text; - setStreamingText(fullText); + if (stream) stream.fullText = fullText; + if (isForeground) setStreamingText(fullText); } else if (type === 'text') { const text = event.text as string; if (text) { @@ -233,35 +551,38 @@ export default function ProjectPage() { fullText += '\n\n'; } fullText += text; - setStreamingText(fullText); + if (stream) stream.fullText = fullText; + if (isForeground) setStreamingText(fullText); } } else if (type === 'tool_use') { - setActivityItems((prev) => [ - ...prev, - { - id: event.tool_id as string, - type: 'tool_use', - content: '', - toolName: event.tool_name as string, - toolInput: event.tool_input as Record, - timestamp: Date.now(), - }, - ]); + const newItem: ActivityItem = { + id: event.tool_id as string, + type: 'tool_use', + content: '', + toolName: event.tool_name as string, + toolInput: event.tool_input as Record, + timestamp: Date.now(), + }; + if (stream) { + stream.activityItems = [...stream.activityItems, newItem]; + stream.tools = [...stream.tools, event.tool_name as string]; + } + if (isForeground) setActivityItems(prev => [...prev, newItem]); } else if (type === 'tool_result') { - setActivityItems((prev) => [ - ...prev, - { - id: `result-${event.tool_use_id}`, - type: 'tool_result', - content: typeof event.content === 'string' ? event.content : JSON.stringify(event.content), - isError: event.is_error as boolean, - timestamp: Date.now(), - }, - ]); + const newItem: ActivityItem = { + id: `result-${event.tool_use_id}`, + type: 'tool_result', + content: typeof event.content === 'string' ? event.content : JSON.stringify(event.content), + isError: event.is_error as boolean, + timestamp: Date.now(), + }; + if (stream) stream.activityItems = [...stream.activityItems, newItem]; + if (isForeground) setActivityItems(prev => [...prev, newItem]); } else if (type === 'todos') { const todoItems = event.todos as TodoItem[]; if (todoItems) { - setTodos(todoItems); + if (stream) stream.todos = todoItems; + if (isForeground) setTodos(todoItems); } } else if (type === 'error') { toast.error(event.error as string, { duration: 8000 }); @@ -272,16 +593,20 @@ export default function ProjectPage() { toast.error('Failed to reconnect to execution'); }, onDone: async () => { - // Reload conversation to get the final messages from DB - const conv = await fetchConversation(projectId, currentConversation.id); - setCurrentConversation(conv); - setMessages(conv.messages || []); - setStreamingText(''); - setIsStreaming(false); - setIsReconnecting(false); - setActiveExecutionId(null); - setActivityItems([]); - setTodos([]); + delete allStreamsRef.current[reconConvId]; + setStreamingConvIds(prev => prev.filter(id => id !== reconConvId)); + + const conv = await fetchConversation(projectId, reconConvId); + if (currentConvIdRef.current === reconConvId) { + setCurrentConversation(conv); + setMessages(conv.messages || []); + setStreamingText(''); + setIsReconnecting(false); + setActiveExecutionId(null); + setActivityItems([]); + setTodos([]); + } + fetchConversations(projectId).then(setConversations); }, }); } @@ -299,20 +624,6 @@ export default function ProjectPage() { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, streamingText, activityItems]); - // Close dropdowns on outside click - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (clusterDropdownRef.current && !clusterDropdownRef.current.contains(event.target as Node)) { - setClusterDropdownOpen(false); - } - if (warehouseDropdownRef.current && !warehouseDropdownRef.current.contains(event.target as Node)) { - setWarehouseDropdownOpen(false); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - // Set default schema from user email once when first available const schemaDefaultApplied = useRef(false); useEffect(() => { @@ -336,14 +647,37 @@ export default function ProjectPage() { const handleSelectConversation = async (conversationId: string) => { if (!projectId || currentConversation?.id === conversationId) return; + // Update ref immediately so stream callbacks target the right conversation + currentConvIdRef.current = conversationId; // Reset reconnect tracking for the new conversation reconnectAttemptedRef.current = null; try { const conv = await fetchConversation(projectId, conversationId); setCurrentConversation(conv); - setMessages(conv.messages || []); - setActivityItems([]); + + // Sync streaming UI state for the new conversation + const stream = allStreamsRef.current[conversationId]; + if (stream) { + // Merge API messages with pending messages not yet saved to DB + const apiMessages = conv.messages || []; + const pending = stream.pendingMessages || []; + const apiIds = new Set(apiMessages.map(m => m.content + m.role)); + const missingPending = pending.filter(m => !apiIds.has(m.content + m.role)); + setMessages([...missingPending, ...apiMessages]); + setStreamingText(stream.fullText); + setActivityItems([...stream.activityItems]); + setTodos([...stream.todos]); + setActiveExecutionId(stream.executionId); + setIsReconnecting(stream.isReconnecting); + } else { + setMessages(conv.messages || []); + setStreamingText(''); + setActivityItems([]); + setTodos([]); + setActiveExecutionId(null); + setIsReconnecting(false); + } // Restore cluster selection from conversation, or default to first cluster setSelectedClusterId(conv.cluster_id || (clusters.length > 0 ? clusters[0].cluster_id : undefined)); // Restore warehouse selection from conversation, or default to first warehouse @@ -366,10 +700,16 @@ export default function ProjectPage() { try { const conv = await createConversation(projectId); + currentConvIdRef.current = conv.id; // Update ref immediately setConversations((prev) => [conv, ...prev]); setCurrentConversation(conv); setMessages([]); + // Clear streaming UI (new conv isn't streaming yet) + setStreamingText(''); setActivityItems([]); + setTodos([]); + setActiveExecutionId(null); + setIsReconnecting(false); inputRef.current?.focus(); } catch (error) { console.error('Failed to create conversation:', error); @@ -385,6 +725,14 @@ export default function ProjectPage() { await deleteConversation(projectId, conversationId); setConversations((prev) => prev.filter((c) => c.id !== conversationId)); + // Clean up any active stream for this conversation + const stream = allStreamsRef.current[conversationId]; + if (stream) { + stream.abortController?.abort(); + delete allStreamsRef.current[conversationId]; + setStreamingConvIds(prev => prev.filter(id => id !== conversationId)); + } + if (currentConversation?.id === conversationId) { const remaining = conversations.filter((c) => c.id !== conversationId); if (remaining.length > 0) { @@ -395,7 +743,10 @@ export default function ProjectPage() { setCurrentConversation(null); setMessages([]); } + setStreamingText(''); setActivityItems([]); + setTodos([]); + setActiveExecutionId(null); } toast.success('Conversation deleted'); } catch (error) { @@ -406,11 +757,13 @@ export default function ProjectPage() { // Send message const handleSendMessage = useCallback(async () => { - if (!projectId || !input.trim() || isStreaming) return; + if (!projectId || !input.trim()) return; + const convId = currentConversation?.id; + // Block only if THIS conversation is already streaming + if (convId && allStreamsRef.current[convId]) return; const userMessage = input.trim(); setInput(''); - setIsStreaming(true); setStreamingText(''); setActivityItems([]); setTodos([]); @@ -418,7 +771,7 @@ export default function ProjectPage() { // Add user message to UI immediately const tempUserMessage: Message = { id: `temp-${Date.now()}`, - conversation_id: currentConversation?.id || '', + conversation_id: convId || '', role: 'user', content: userMessage, timestamp: new Date().toISOString(), @@ -426,11 +779,24 @@ export default function ProjectPage() { }; setMessages((prev) => [...prev, tempUserMessage]); - // Create abort controller - abortControllerRef.current = new AbortController(); + // Create abort controller and initialize stream tracking + const abortController = new AbortController(); + const effectiveConvId = convId || ''; + let streamKey = effectiveConvId; + allStreamsRef.current[streamKey] = { + fullText: '', + activityItems: [], + todos: [], + tools: [], + executionId: null, + abortController, + isReconnecting: false, + pendingMessages: [tempUserMessage], + }; + setStreamingConvIds(prev => [...prev, effectiveConvId]); try { - let conversationId = currentConversation?.id; + let conversationId = convId; let fullText = ''; await invokeAgent({ @@ -443,42 +809,61 @@ export default function ProjectPage() { warehouseId: selectedWarehouseId, workspaceFolder, mlflowExperimentName: mlflowExperimentName || null, - signal: abortControllerRef.current.signal, - onExecutionId: (executionId) => setActiveExecutionId(executionId), + signal: abortController.signal, + onExecutionId: (executionId) => { + const stream = allStreamsRef.current[streamKey]; + if (stream) stream.executionId = executionId; + if (currentConvIdRef.current === streamKey) setActiveExecutionId(executionId); + }, onEvent: (event) => { const type = event.type as string; + const stream = allStreamsRef.current[streamKey]; + const isForeground = currentConvIdRef.current === streamKey; if (type === 'conversation.created') { - conversationId = event.conversation_id as string; + const newConvId = event.conversation_id as string; + // Move stream entry from old key to new key + const oldStream = allStreamsRef.current[streamKey]; + delete allStreamsRef.current[streamKey]; + const oldKey = streamKey; + streamKey = newConvId; + allStreamsRef.current[newConvId] = oldStream || { + fullText: '', activityItems: [], todos: [], tools: [], + executionId: null, abortController, isReconnecting: false, + pendingMessages: [], + }; + conversationId = newConvId; + // Update streamingConvIds from old key to new key + setStreamingConvIds(prev => prev.filter(id => id !== oldKey).concat(newConvId)); + // Set currentConversation immediately so UI stays consistent + setCurrentConversation((prev) => prev ?? { + id: newConvId, + project_id: projectId, + title: 'New Chat', + created_at: new Date().toISOString(), + conversation_count: 0, + } as unknown as Conversation); + currentConvIdRef.current = newConvId; fetchConversations(projectId).then(setConversations); } else if (type === 'text_delta') { - // Token-by-token streaming - accumulate and display for live updates const text = event.text as string; fullText += text; - console.log('[STREAM] text_delta received, fullText length:', fullText.length); - setStreamingText(fullText); + if (stream) stream.fullText = fullText; + if (isForeground) setStreamingText(fullText); } else if (type === 'text') { - // Complete text block from AssistantMessage - the authoritative final content - // This event contains the COMPLETE text for this response segment - // We always use it to ensure final responses after tool execution are captured const text = event.text as string; - console.log('[STREAM] text event received, text length:', text?.length, 'current fullText length:', fullText.length); if (text) { - // Append to fullText (there may be multiple text blocks in a conversation) - // Add separator if needed if (fullText && !fullText.endsWith('\n') && !text.startsWith('\n')) { fullText += '\n\n'; } fullText += text; - console.log('[STREAM] fullText updated, new length:', fullText.length); - setStreamingText(fullText); + if (stream) stream.fullText = fullText; + if (isForeground) setStreamingText(fullText); } } else if (type === 'thinking' || type === 'thinking_delta') { - // Handle both complete thinking blocks and streaming thinking deltas const thinking = (event.thinking as string) || ''; if (thinking) { - setActivityItems((prev) => { - // For deltas, append to the last thinking item if it exists + const updateThinking = (prev: ActivityItem[]) => { if (type === 'thinking_delta' && prev.length > 0 && prev[prev.length - 1].type === 'thinking') { const updated = [...prev]; updated[updated.length - 1] = { @@ -487,159 +872,186 @@ export default function ProjectPage() { }; return updated; } - // For complete blocks or first delta, add new item return [ ...prev, { id: `thinking-${Date.now()}`, - type: 'thinking', + type: 'thinking' as const, content: thinking, timestamp: Date.now(), }, ]; - }); + }; + if (stream) stream.activityItems = updateThinking(stream.activityItems); + if (isForeground) setActivityItems(updateThinking); } } else if (type === 'tool_use') { - setActivityItems((prev) => [ - ...prev, - { - id: event.tool_id as string, - type: 'tool_use', - content: '', - toolName: event.tool_name as string, - toolInput: event.tool_input as Record, - timestamp: Date.now(), - }, - ]); + const toolName = event.tool_name as string; + const newItem: ActivityItem = { + id: event.tool_id as string, + type: 'tool_use', + content: '', + toolName, + toolInput: event.tool_input as Record, + timestamp: Date.now(), + }; + if (stream) { + stream.tools = [...stream.tools, toolName]; + stream.activityItems = [...stream.activityItems, newItem]; + } + if (isForeground) setActivityItems(prev => [...prev, newItem]); } else if (type === 'tool_result') { let content = event.content as string; - // Parse and improve error messages if (event.is_error && typeof content === 'string') { - // Extract error from XML-style tags like ... const errorMatch = content.match(/(.*?)<\/tool_use_error>/s); if (errorMatch) { content = errorMatch[1].trim(); } - - // Improve generic "Stream closed" errors if (content === 'Stream closed' || content.includes('Stream closed')) { content = 'Tool execution interrupted: The operation took too long or the connection was lost. This may happen when operations exceed the 50-second timeout window. Check backend logs for details.'; } } - setActivityItems((prev) => [ - ...prev, - { - id: `result-${event.tool_use_id}`, - type: 'tool_result', - content: typeof content === 'string' ? content : JSON.stringify(content), - isError: event.is_error as boolean, - timestamp: Date.now(), - }, - ]); + const newItem: ActivityItem = { + id: `result-${event.tool_use_id}`, + type: 'tool_result', + content: typeof content === 'string' ? content : JSON.stringify(content), + isError: event.is_error as boolean, + timestamp: Date.now(), + }; + if (stream) stream.activityItems = [...stream.activityItems, newItem]; + if (isForeground) setActivityItems(prev => [...prev, newItem]); } else if (type === 'error') { let errorMsg = event.error as string; - - // Improve generic error messages if (errorMsg === 'Stream closed' || errorMsg.includes('Stream closed')) { errorMsg = 'Execution interrupted: The operation took too long or the connection was lost. Operations exceeding 50 seconds may be interrupted. Check backend logs for details.'; } - - toast.error(errorMsg, { - duration: 8000, - }); + toast.error(errorMsg, { duration: 8000 }); } else if (type === 'cancelled') { - // Agent was cancelled by user - show a toast notification toast.info('Generation stopped'); } else if (type === 'todos') { - // Update todo list from agent const todoItems = event.todos as TodoItem[]; if (todoItems) { - setTodos(todoItems); + if (stream) stream.todos = todoItems; + if (isForeground) setTodos(todoItems); } } }, onError: (error) => { console.error('Stream error:', error); - // Show the actual error message instead of generic text const errorMessage = error.message || 'Failed to get response'; - toast.error(errorMessage, { - duration: 8000, // Show error for 8 seconds - }); + toast.error(errorMessage, { duration: 8000 }); }, onDone: async () => { + const finalStreamKey = streamKey; + const stream = allStreamsRef.current[finalStreamKey]; + const tools = stream?.tools || []; + if (fullText) { + const msgId = `msg-${Date.now()}`; const assistantMessage: Message = { - id: `msg-${Date.now()}`, + id: msgId, conversation_id: conversationId || '', role: 'assistant', content: fullText, timestamp: new Date().toISOString(), is_error: false, }; - setMessages((prev) => [...prev, assistantMessage]); + // Only update messages if user is viewing this conversation + if (currentConvIdRef.current === finalStreamKey) { + setMessages((prev) => [...prev, assistantMessage]); + } + if (tools.length > 0) { + setMessageTools((prev) => ({ ...prev, [msgId]: tools })); + } + } + + // Clean up stream + delete allStreamsRef.current[finalStreamKey]; + setStreamingConvIds(prev => prev.filter(id => id !== finalStreamKey)); + + if (currentConvIdRef.current === finalStreamKey) { + setStreamingText(''); + setActiveExecutionId(null); + setActivityItems([]); + setTodos([]); } - setStreamingText(''); - setIsStreaming(false); - setActiveExecutionId(null); - // Clear activity items after response is finalized - only show final answer - setActivityItems([]); - setTodos([]); - - if (conversationId && !currentConversation?.id) { + + // Fetch full conversation to get updated title and messages + if (conversationId) { const conv = await fetchConversation(projectId, conversationId); - setCurrentConversation(conv); + if (currentConvIdRef.current === finalStreamKey) { + setCurrentConversation(conv); + } + fetchConversations(projectId).then(setConversations); } }, }); } catch (error) { - // Ignore AbortError — handleStopGeneration handles cleanup for user-initiated stops if (error instanceof Error && error.name === 'AbortError') return; console.error('Failed to send message:', error); const errorMessage = error instanceof Error ? error.message : 'Failed to send message'; - toast.error(errorMessage, { - duration: 8000, - }); - setIsStreaming(false); + toast.error(errorMessage, { duration: 8000 }); + // Clean up stream on error + delete allStreamsRef.current[streamKey]; + setStreamingConvIds(prev => prev.filter(id => id !== streamKey)); + if (currentConvIdRef.current === streamKey) { + setStreamingText(''); + setActiveExecutionId(null); + setActivityItems([]); + setTodos([]); + } } - }, [projectId, input, isStreaming, currentConversation?.id, selectedClusterId, defaultCatalog, defaultSchema, selectedWarehouseId, workspaceFolder, mlflowExperimentName]); + }, [projectId, input, currentConversation?.id, selectedClusterId, defaultCatalog, defaultSchema, selectedWarehouseId, workspaceFolder, mlflowExperimentName]); // Stop generation - abort client stream AND tell backend to cancel const handleStopGeneration = useCallback(async () => { - abortControllerRef.current?.abort(); + const targetId = currentConversation?.id; + if (!targetId) return; + + const stream = allStreamsRef.current[targetId]; + if (!stream) return; + + // Abort the fetch + stream.abortController?.abort(); // Tell the backend to cancel the agent execution - if (activeExecutionId) { + if (stream.executionId) { try { - await stopExecution(activeExecutionId); + await stopExecution(stream.executionId); } catch (error) { console.error('Failed to stop execution on backend:', error); } } - // Finalize UI: keep user message and save whatever partial response we have - setStreamingText((currentText) => { - if (currentText) { - setMessages((prev) => [ - ...prev, - { - id: `msg-stopped-${Date.now()}`, - conversation_id: '', - role: 'assistant' as const, - content: currentText, - timestamp: new Date().toISOString(), - is_error: false, - }, - ]); + // Save partial response + if (stream.fullText) { + const msgId = `msg-stopped-${Date.now()}`; + setMessages((prev) => [ + ...prev, + { + id: msgId, + conversation_id: targetId, + role: 'assistant' as const, + content: stream.fullText, + timestamp: new Date().toISOString(), + is_error: false, + }, + ]); + if (stream.tools.length > 0) { + setMessageTools((prev) => ({ ...prev, [msgId]: stream.tools })); } - return ''; - }); - setIsStreaming(false); + } + + // Clean up stream + delete allStreamsRef.current[targetId]; + setStreamingConvIds(prev => prev.filter(id => id !== targetId)); + setStreamingText(''); setActiveExecutionId(null); setActivityItems([]); setTodos([]); - }, [activeExecutionId]); + }, [currentConversation?.id]); // Handle keyboard submit const handleKeyDown = (e: React.KeyboardEvent) => { @@ -654,6 +1066,113 @@ export default function ProjectPage() { setSkillsExplorerOpen(true); }; + // Config panel state + const [configPanelOpen, setConfigPanelOpen] = useState(false); + const configPanelRef = useRef(null); + + // Close config panel on outside click + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (configPanelRef.current && !configPanelRef.current.contains(event.target as Node)) { + setConfigPanelOpen(false); + } + }; + if (configPanelOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [configPanelOpen]); + + // Auto-resize textarea + const handleInputChange = (e: React.ChangeEvent) => { + setInput(e.target.value); + const el = e.target; + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 200) + 'px'; + }; + + // Markdown components shared between messages and streaming + const markdownComponents = useMemo(() => ({ + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( + + {children} + + ), + pre: ({ children }: { children?: React.ReactNode }) => { + // Extract text content from children for copy button + const getTextContent = (node: React.ReactNode): string => { + if (typeof node === 'string') return node; + if (!node) return ''; + if (Array.isArray(node)) return node.map(getTextContent).join(''); + if (typeof node === 'object' && 'props' in (node as React.ReactElement)) { + return getTextContent((node as React.ReactElement).props.children); + } + return ''; + }; + const text = getTextContent(children); + return ( +
+
+            {children}
+          
+ +
+ ); + }, + code: ({ children, className }: { children?: React.ReactNode; className?: string }) => { + // Inline code (no language class) + if (!className) { + return ( + + {children} + + ); + } + // Block code inside pre + return {children}; + }, + table: ({ children }: { children?: React.ReactNode }) => ( +
+ {children}
+
+ ), + th: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + td: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + }), []); + + // Config summary for header chips + const configChips = useMemo(() => { + const chips: { label: string; color: string }[] = []; + if (defaultCatalog && defaultSchema) { + chips.push({ label: `${defaultCatalog}.${defaultSchema}`, color: 'text-[var(--color-accent-primary)]' }); + } + const cluster = clusters.find(c => c.cluster_id === selectedClusterId); + if (cluster) { + chips.push({ label: cluster.cluster_name || 'Cluster', color: cluster.state === 'RUNNING' ? 'text-[var(--color-success)]' : 'text-[var(--color-text-muted)]' }); + } + const warehouse = warehouses.find(w => w.warehouse_id === selectedWarehouseId); + if (warehouse) { + chips.push({ label: warehouse.warehouse_name || 'Warehouse', color: warehouse.state === 'RUNNING' ? 'text-[var(--color-success)]' : 'text-[var(--color-text-muted)]' }); + } + return chips; + }, [defaultCatalog, defaultSchema, clusters, selectedClusterId, warehouses, selectedWarehouseId]); + + // Only show streaming UI if viewing a conversation that is actively streaming + const isStreamingHere = streamingConvIds.includes(currentConversation?.id || ''); + if (isLoading) { return ( @@ -679,312 +1198,167 @@ export default function ProjectPage() { return (
- {/* Chat Header - always show configuration controls */} -
-

- {currentConversation?.title || 'New Chat'} -

-
- {/* Catalog.Schema Input */} -
-
- - - -
- setDefaultCatalog(e.target.value)} - placeholder="catalog" - className="h-full w-[70px] flex-shrink-0 px-2 bg-transparent text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none overflow-hidden text-ellipsis" - title={defaultCatalog || 'Default catalog'} - /> - . - setDefaultSchema(e.target.value)} - placeholder="schema" - className="h-full w-[90px] flex-shrink-0 px-2 bg-transparent text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none overflow-hidden text-ellipsis" - title={defaultSchema || 'Default schema'} - /> -
- {/* Open Catalog Button */} - {workspaceUrl && defaultCatalog && defaultSchema && ( - - - - )} - {/* Cluster Dropdown */} - {clusters.length > 0 && ( -
- - {clusterDropdownOpen && ( -
- {clusters.map((cluster) => ( - - ))} -
- )} -
- )} - {/* Warehouse Dropdown */} - {warehouses.length > 0 && ( -
- - {warehouseDropdownOpen && ( -
- {warehouses.map((warehouse) => ( - - ))} -
+ {chip.label} + + ))} +
+ {/* Settings button */} +
+
- )} - {/* Workspace Folder Input */} -
-
- - - -
- setWorkspaceFolder(e.target.value)} - placeholder="/Workspace/Users/..." - className="h-full w-[240px] flex-shrink-0 px-2 bg-transparent text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none overflow-hidden text-ellipsis" - title={workspaceFolder || 'Workspace working folder for uploading files and pipelines'} - /> -
- {/* MLflow Experiment Input */} -
-
- - - -
- setMlflowExperimentName(e.target.value)} - placeholder="MLflow Experiment ID or Name" - className="h-full w-[240px] flex-shrink-0 px-2 bg-transparent text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none overflow-hidden text-ellipsis" - title={mlflowExperimentName || 'MLflow experiment ID (e.g. 2452310130108632) or name (e.g. /Users/you@company.com/traces)'} - /> -
+ title="Configuration" + > + + + setConfigPanelOpen(false)} + defaultCatalog={defaultCatalog} + setDefaultCatalog={setDefaultCatalog} + defaultSchema={defaultSchema} + setDefaultSchema={setDefaultSchema} + clusters={clusters} + selectedClusterId={selectedClusterId} + setSelectedClusterId={setSelectedClusterId} + warehouses={warehouses} + selectedWarehouseId={selectedWarehouseId} + setSelectedWarehouseId={setSelectedWarehouseId} + workspaceFolder={workspaceFolder} + setWorkspaceFolder={setWorkspaceFolder} + mlflowExperimentName={mlflowExperimentName} + setMlflowExperimentName={setMlflowExperimentName} + workspaceUrl={workspaceUrl} + /> +
{/* Messages */} -
- {messages.length === 0 && !streamingText ? ( -
-
- -

+
+ {messages.length === 0 && !isStreamingHere ? ( + /* Empty State */ +
+
+ {/* Decorative gradient orb */} +
+
+
+ +
+
+

What can I help you build?

-

- I can help you build data pipelines, generate synthetic data, create dashboards, and more on Databricks. +

+ Build data pipelines, generate synthetic data, create dashboards, and more on Databricks.

- {/* Example prompts */} -
- - - - + {/* Example prompts - 2x2 grid */} +
+ {[ + { title: 'Generate synthetic data', desc: 'Realistic test datasets with customers, orders, and tickets', prompt: 'Generate synthetic customer data with orders and support tickets' }, + { title: 'Build a data pipeline', desc: 'ETL workflows with medallion architecture', prompt: 'Create a data pipeline to transform raw data into bronze, silver, and gold layers' }, + { title: 'Create a dashboard', desc: 'Interactive AI/BI visualizations', prompt: 'Create a dashboard to visualize customer metrics and trends' }, + { title: 'Explore my data', desc: 'Tables, volumes, and resources in your project', prompt: 'What tables and data do I have in my project?' }, + ].map((item) => ( + + ))}
) : ( -
+ /* Message Thread */ +
{messages.map((message) => ( -
-
- {message.role === 'assistant' ? ( -
- ( - - {children} - - ), - }} - > - {message.content} - +
+ {message.role === 'assistant' ? ( + /* Assistant message - left aligned with Databricks avatar */ +
+
+
- ) : ( -

{message.content}

- )} -
+
+
+ Assistant + {message.timestamp && ( + + {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + )} +
+
+ + {message.content} + +
+ +
+
+ ) : ( + /* User message - right aligned like iMessage */ +
+
+
+ {message.timestamp && ( + + {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + )} +
+
+

{message.content}

+
+
+
+ )}
))} - {/* Streaming response - show accumulated text as it arrives */} - {isStreaming && streamingText && ( -
-
-
- ( - - {children} - - ), - }} - > + {/* Streaming response */} + {isStreamingHere && streamingText && ( +
+
+ +
+
+
+ + Assistant + +
+
+ {streamingText}
@@ -992,22 +1366,32 @@ export default function ProjectPage() {
)} - {/* Activity section (thinking, tools) - shown below streaming text */} - {activityItems.length > 0 && ( - + {/* Activity section */} + {isStreamingHere && activityItems.length > 0 && ( + )} - {/* Fun loader with progress - shown while streaming before text arrives */} - {isStreaming && !streamingText && ( -
- {isReconnecting ? ( -
- - Reconnecting to agent... + {/* Loader */} + {isStreamingHere && !streamingText && ( +
+
+ +
+
+
+ + Assistant +
- ) : ( - - )} + {isReconnecting ? ( +
+ + Reconnecting to agent... +
+ ) : ( + + )} +
)} @@ -1017,40 +1401,49 @@ export default function ProjectPage() {
{/* Input Area */} -
-
-
+
+
+