-
- {isAnalyzing ? (
- <>
-
-
Analyzing SQL
-
- Building lineage, schema, and issue details for the current analysis run.
-
- >
- ) : (
- <>
-
No Analysis Results
-
- Run analysis on your SQL script to see lineage and schema details here.
-
- >
- )}
+
+
+
+
+
+
+ {isAnalyzing ? (
+ <>
+
+
Analyzing SQL
+
+ Building lineage, schema, and issue details for the current analysis run.
+
+ >
+ ) : (
+ <>
+
No Analysis Results
+
+ Run analysis on your SQL script to see lineage and schema details here.
+
+ >
+ )}
+
);
@@ -413,6 +421,7 @@ export function AnalysisView({
)}
+
@@ -484,11 +493,20 @@ export function AnalysisView({
className="h-full mt-0 p-0 absolute inset-0 data-[state=inactive]:hidden"
>
{mountedTabs.has('schema') && (
- s.librarianOpen);
+ const toggleLibrarian = useViewStateStore((s) => s.toggleLibrarian);
+ const shortcut = getShortcutDisplay('toggle-librarian') ?? '⌘L';
+
+ return (
+
+
+
+
+
+
+
+ Toggle Librarian
+
+ {shortcut}
+
+
+
+
+
+ );
+}
diff --git a/app/src/components/SchemaSearchControl.tsx b/app/src/components/SchemaSearchControl.tsx
new file mode 100644
index 00000000..cad88061
--- /dev/null
+++ b/app/src/components/SchemaSearchControl.tsx
@@ -0,0 +1,218 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { ChevronLeft, ChevronRight, Search, X } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { cn } from '@/lib/utils';
+
+interface TableWithColumns {
+ name: string;
+ columns?: { name: string }[];
+}
+
+interface SchemaSearchControlProps {
+ tableNames: string[];
+ tables?: TableWithColumns[];
+ onSelectTable: (tableName: string | undefined) => void;
+ className?: string;
+}
+
+function findAllMatches(
+ tableNames: string[],
+ query: string,
+ tables?: TableWithColumns[]
+): string[] {
+ if (!query) return [];
+ const q = query.toLowerCase();
+ const matches = new Set
();
+
+ // Match table names
+ for (const name of tableNames) {
+ if (name.toLowerCase().includes(q)) {
+ matches.add(name);
+ }
+ }
+
+ // Match column names — add owning table
+ if (tables) {
+ for (const table of tables) {
+ if (table.columns?.some((col) => col.name.toLowerCase().includes(q))) {
+ matches.add(table.name);
+ }
+ }
+ }
+
+ return Array.from(matches);
+}
+
+export function SchemaSearchControl({
+ tableNames,
+ tables,
+ onSelectTable,
+ className,
+}: SchemaSearchControlProps) {
+ const [expanded, setExpanded] = useState(false);
+ const [value, setValue] = useState('');
+ const [matchIndex, setMatchIndex] = useState(0);
+ const inputRef = useRef(null);
+ const hasInteractedRef = useRef(false);
+
+ const matches = useMemo(
+ () => findAllMatches(tableNames, value.trim(), tables),
+ [tableNames, tables, value]
+ );
+ const activeMatch =
+ matches.length > 0 ? matches[Math.min(matchIndex, matches.length - 1)] : undefined;
+
+ useEffect(() => {
+ if (expanded) {
+ inputRef.current?.focus();
+ }
+ }, [expanded]);
+
+ useEffect(() => {
+ if (matches.length > 0 && matchIndex >= matches.length) {
+ setMatchIndex(0);
+ }
+ }, [matches.length, matchIndex]);
+
+ // Update selection when an active search has matches. Do not clear an
+ // existing schema selection just because the control mounted with an empty
+ // query; only clear after the user has interacted with the search field.
+ // `hasInteractedRef` is scoped to a single search session — it is reset
+ // when the control collapses, so a fresh open behaves like a fresh mount
+ // and a parent re-render of the collapsed control does not clobber an
+ // externally-set selection.
+ useEffect(() => {
+ if (activeMatch !== undefined) {
+ onSelectTable(activeMatch);
+ } else if (hasInteractedRef.current) {
+ onSelectTable(undefined);
+ }
+ }, [activeMatch, matches.length, onSelectTable, value]);
+
+ const handleChange = useCallback((e: React.ChangeEvent) => {
+ hasInteractedRef.current = true;
+ setValue(e.target.value);
+ setMatchIndex(0);
+ }, []);
+
+ const goNext = useCallback(() => {
+ if (matches.length > 0) {
+ setMatchIndex((i) => (i + 1) % matches.length);
+ }
+ }, [matches.length]);
+
+ const goPrev = useCallback(() => {
+ if (matches.length > 0) {
+ setMatchIndex((i) => (i - 1 + matches.length) % matches.length);
+ }
+ }, [matches.length]);
+
+ const collapse = useCallback(() => {
+ hasInteractedRef.current = false;
+ setExpanded(false);
+ setValue('');
+ setMatchIndex(0);
+ onSelectTable(undefined);
+ }, [onSelectTable]);
+
+ const handleBlur = useCallback(() => {
+ if (!value) {
+ hasInteractedRef.current = false;
+ setExpanded(false);
+ }
+ }, [value]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ collapse();
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (e.shiftKey) {
+ goPrev();
+ } else {
+ goNext();
+ }
+ }
+ },
+ [collapse, goNext, goPrev]
+ );
+
+ if (!expanded) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {matches.length > 0 && (
+ <>
+
+ {matchIndex + 1}/{matches.length}
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/app/src/components/Workspace.tsx b/app/src/components/Workspace.tsx
index e228b2b2..d069e786 100644
--- a/app/src/components/Workspace.tsx
+++ b/app/src/components/Workspace.tsx
@@ -17,7 +17,7 @@ import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
import { CommandPalette } from './CommandPalette';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { useProject } from '@/lib/project-store';
-import { NavigationProvider } from '@/lib/navigation-context';
+import { NavigationProvider, useNavigation } from '@/lib/navigation-context';
import { FocusRegistryProvider } from '@/lib/focus-registry';
import { useGlobalShortcuts, useAnalysis } from '@/hooks';
import type { GlobalShortcut } from '@/hooks';
@@ -25,6 +25,9 @@ import { useThemeStore, type Theme } from '@/lib/theme-store';
import { useViewStateStore } from '@/lib/view-state-store';
import { getShortcutDisplay } from '@/lib/shortcuts';
import { useBackend } from '@/lib/backend-context';
+import { LibrarianPanel, useSyncActiveProject } from '@/features/librarian';
+import type { ChatReference } from '@/features/librarian/utils/schema-identifiers';
+import { resolveLineageNodeIds } from '@/lib/lineage-node-resolver';
interface WorkspaceProps {
backendReady: boolean;
@@ -42,6 +45,7 @@ const EDITOR_PANEL_DEFAULT_SIZE = 33;
export function Workspace({ backendReady, error, onRetry, isRetrying }: WorkspaceProps) {
const { currentProject, selectFile, activeProjectId, isBackendMode } = useProject();
+ useSyncActiveProject();
const { adapter } = useBackend();
const analysis = useAnalysis(backendReady, { adapter });
const lineageActions = useLineageActions();
@@ -71,6 +75,11 @@ export function Workspace({ backendReady, error, onRetry, isRetrying }: Workspac
toast.success(`Theme: ${nextTheme.charAt(0).toUpperCase() + nextTheme.slice(1)}`);
}, [theme, setTheme]);
+ // Librarian panel state
+ const librarianOpen = useViewStateStore((s) => s.librarianOpen);
+ const toggleLibrarian = useViewStateStore((s) => s.toggleLibrarian);
+ const setLibrarianOpen = useViewStateStore((s) => s.setLibrarianOpen);
+
const editorPanelRef = useRef(null);
const graphContainerRef = useRef(null);
@@ -206,8 +215,14 @@ export function Workspace({ backendReady, error, onRetry, isRetrying }: Workspac
cmdOrCtrl: true,
handler: () => setCommandPaletteOpen(true),
},
+ // Toggle Librarian panel
+ {
+ key: 'l',
+ cmdOrCtrl: true,
+ handler: toggleLibrarian,
+ },
],
- [toggleEditorPanel, currentProject, cycleTheme, isBackendMode]
+ [toggleEditorPanel, currentProject, cycleTheme, isBackendMode, toggleLibrarian]
);
useGlobalShortcuts(shortcuts);
@@ -237,6 +252,9 @@ export function Workspace({ backendReady, error, onRetry, isRetrying }: Workspac
case 'toggle-editor':
toggleEditorPanel();
break;
+ case 'toggle-librarian':
+ toggleLibrarian();
+ break;
// Tab switching
case 'tab-lineage':
@@ -295,6 +313,7 @@ export function Workspace({ backendReady, error, onRetry, isRetrying }: Workspac
},
[
toggleEditorPanel,
+ toggleLibrarian,
activeProjectId,
setActiveTab,
currentProject,
@@ -468,6 +487,21 @@ export function Workspace({ backendReady, error, onRetry, isRetrying }: Workspac
isAnalyzing={analysis.isAnalyzing}
/>
+
+ {/* Right: Librarian Panel */}
+ {librarianOpen && (
+ <>
+
+
+ setLibrarianOpen(false)} />
+
+ >
+ )}
@@ -475,3 +509,46 @@ export function Workspace({ backendReady, error, onRetry, isRetrying }: Workspac
);
}
+
+function LibrarianPanelWithNavigation({ onClose }: { onClose: () => void }) {
+ const { navigateTo } = useNavigation();
+ const { result, showColumnEdges } = useLineageState();
+ const { toggleColumnEdges } = useLineageActions();
+ const { activeProjectId } = useProject();
+ const updateViewState = useViewStateStore((s) => s.updateViewState);
+ const handleNavigateToReferences = useCallback(
+ (refs: ChatReference[]) => {
+ if (refs.length === 0) return;
+ // Drive the lineage search pipeline first, regardless of whether the
+ // resolver can produce concrete node ids. If the answer references any
+ // column, prefer searching by the first column name (and force
+ // "show column edges" on so columns are actually matchable); otherwise
+ // fall back to the first table name. Writing the term to the persisted
+ // per-project view state propagates to GraphView's controlled
+ // searchTerm prop, so table cards highlight via `isNodeHighlighted`
+ // even when navigation is a no-op (e.g. column nodes absent from the
+ // current statement's lineage).
+ const firstColumn = refs.find((r) => r.columnName)?.columnName;
+ const firstTable = refs.find((r) => r.tableName)?.tableName;
+ const searchTerm = firstColumn ?? firstTable;
+ if (firstColumn && !showColumnEdges) {
+ toggleColumnEdges();
+ }
+ if (searchTerm && activeProjectId) {
+ updateViewState(activeProjectId, 'lineage', { searchTerm });
+ }
+ const { nodeIds, tablesToExpand, primaryFocusId } = resolveLineageNodeIds(
+ result ?? null,
+ refs
+ );
+ if (nodeIds.length === 0) return;
+ navigateTo('lineage', {
+ highlightNodeIds: nodeIds,
+ tablesToExpand,
+ ...(primaryFocusId ? { primaryFocusId } : {}),
+ });
+ },
+ [navigateTo, result, showColumnEdges, toggleColumnEdges, activeProjectId, updateViewState]
+ );
+ return