Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 31 additions & 18 deletions frontend/src/api/tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ApiError } from './errors';

export interface QueryResult {
export interface StatementResult {
sql: string;
columns: string[];
rows: any[][];
rowCount: number;
Expand All @@ -18,11 +19,16 @@ interface McpResponse {
};
}

interface StatementData {
sql: string;
rows: Record<string, any>[];
count: number;
}

interface ToolResultData {
success: boolean;
data: {
rows: Record<string, any>[];
count: number;
statements: StatementData[];
source_id: string;
} | null;
error: string | null;
Expand All @@ -31,7 +37,7 @@ interface ToolResultData {
export async function executeTool(
toolName: string,
args: Record<string, any>
): Promise<QueryResult> {
): Promise<StatementResult[]> {
const response = await fetch('/mcp', {
method: 'POST',
headers: {
Expand Down Expand Up @@ -69,22 +75,29 @@ export async function executeTool(
throw new ApiError(toolResult.error || 'Tool execution failed', 500);
}

if (!toolResult.data || !toolResult.data.rows) {
return { columns: [], rows: [], rowCount: 0 };
if (!toolResult.data || !toolResult.data.statements) {
return [];
}

const rows = toolResult.data.rows;
if (rows.length === 0) {
// For INSERT/UPDATE/DELETE, rows is empty but count reflects affected rows
return { columns: [], rows: [], rowCount: toolResult.data.count };
}
return toolResult.data.statements.map((stmt) => {
const rows = stmt.rows;
if (rows.length === 0) {
return {
sql: stmt.sql,
columns: [],
rows: [],
rowCount: stmt.count,
};
}

const columns = Object.keys(rows[0]);
const rowArrays = rows.map((row) => columns.map((col) => row[col]));
const columns = Object.keys(rows[0]);
const rowArrays = rows.map((row) => columns.map((col) => row[col]));

return {
columns,
rows: rowArrays,
rowCount: toolResult.data.count,
};
return {
sql: stmt.sql,
columns,
rows: rowArrays,
rowCount: stmt.count,
};
});
}
11 changes: 11 additions & 0 deletions frontend/src/components/icons/ChevronLeftIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
interface ChevronLeftIconProps {
className?: string;
}

export default function ChevronLeftIcon({ className = 'w-4 h-4' }: ChevronLeftIconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
);
}
11 changes: 11 additions & 0 deletions frontend/src/components/icons/ChevronRightIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
interface ChevronRightIconProps {
className?: string;
}

export default function ChevronRightIcon({ className = 'w-4 h-4' }: ChevronRightIconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
}
11 changes: 5 additions & 6 deletions frontend/src/components/tool/ResultsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { useRef, useState, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { QueryResult } from '../../api/tools';
import type { StatementResult } from '../../api/tools';

interface ResultsTableProps {
result: QueryResult | null;
result: StatementResult | null;
error: string | null;
isLoading?: boolean;
executedSql?: string;
executionTimeMs?: number;
}

Expand Down Expand Up @@ -53,7 +52,7 @@ function highlightText(text: string, searchTerm: string): React.ReactNode {
return parts;
}

export function ResultsTable({ result, error, isLoading, executedSql, executionTimeMs }: ResultsTableProps) {
export function ResultsTable({ result, error, isLoading, executionTimeMs }: ResultsTableProps) {
const parentRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState('');

Expand Down Expand Up @@ -198,9 +197,9 @@ export function ResultsTable({ result, error, isLoading, executedSql, executionT
</div>
</div>
</div>
{executedSql && executionTimeMs !== undefined && (
{result.sql && executionTimeMs !== undefined && (
<div className="flex justify-between items-center text-sm text-muted-foreground">
<span>{executedSql}</span>
<span>{result.sql}</span>
<span>Executed in {formatExecutionTime(executionTimeMs)}</span>
</div>
)}
Expand Down
152 changes: 117 additions & 35 deletions frontend/src/components/tool/ResultsTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useMemo } from 'react';
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
import { cn } from '../../lib/utils';
import { ResultsTable } from './ResultsTable';
import type { ResultTab } from './types';
import XIcon from '../icons/XIcon';
import ChevronLeftIcon from '../icons/ChevronLeftIcon';
import ChevronRightIcon from '../icons/ChevronRightIcon';

interface ResultsTabsProps {
tabs: ResultTab[];
Expand All @@ -21,18 +23,64 @@ function formatTimestamp(date: Date): string {
});
}

function formatTabLabel(tab: ResultTab): string {
return formatTimestamp(tab.timestamp);
}

const SCROLL_AMOUNT = 150;

export function ResultsTabs({
tabs,
activeTabId,
onTabSelect,
onTabClose,
isLoading,
}: ResultsTabsProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);

const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeTabId),
[tabs, activeTabId]
);

const updateScrollButtons = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;

setCanScrollLeft(container.scrollLeft > 0);
setCanScrollRight(
container.scrollLeft < container.scrollWidth - container.clientWidth - 1
);
}, []);

useEffect(() => {
updateScrollButtons();
const container = scrollContainerRef.current;
if (!container) return;

container.addEventListener('scroll', updateScrollButtons);
window.addEventListener('resize', updateScrollButtons);

return () => {
container.removeEventListener('scroll', updateScrollButtons);
window.removeEventListener('resize', updateScrollButtons);
};
}, [updateScrollButtons, tabs]);

const scrollLeft = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
container.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' });
}, []);

const scrollRight = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
container.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' });
}, []);

// Loading state (no tabs yet)
if (isLoading && tabs.length === 0) {
return (
Expand All @@ -55,45 +103,80 @@ export function ResultsTabs({

return (
<div className="space-y-2">
{/* Tab bar */}
<div className="flex items-center gap-1 border-b border-border overflow-x-auto">
{tabs.map((tab) => (
{/* Tab bar with scroll buttons */}
<div className="relative flex items-center border-b border-border">
{/* Left scroll button */}
{canScrollLeft && (
<button
key={tab.id}
type="button"
onClick={() => onTabSelect(tab.id)}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-sm whitespace-nowrap',
'border-b-2 -mb-px transition-colors cursor-pointer',
tab.id === activeTabId
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
onClick={scrollLeft}
className="absolute left-0 z-10 flex items-center justify-center w-6 h-full bg-gradient-to-r from-background via-background to-transparent pr-2 cursor-pointer"
aria-label="Scroll tabs left"
>
<span>{formatTimestamp(tab.timestamp)}</span>
{tab.error && (
<span className="w-1.5 h-1.5 rounded-full bg-destructive" aria-label="Error" />
)}
<span
role="button"
tabIndex={0}
aria-label="Close tab"
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
<ChevronLeftIcon className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
)}

{/* Scrollable tab container */}
<div
ref={scrollContainerRef}
className={cn(
'flex items-center gap-1 overflow-x-auto scrollbar-none',
canScrollLeft && 'pl-6',
canScrollRight && 'pr-6'
)}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onTabSelect(tab.id)}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-sm whitespace-nowrap',
'border-b-2 -mb-px transition-colors cursor-pointer',
tab.id === activeTabId
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
<span>{formatTabLabel(tab)}</span>
{tab.error && (
<span className="w-1.5 h-1.5 rounded-full bg-destructive" aria-label="Error" />
)}
<span
role="button"
tabIndex={0}
aria-label="Close tab"
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}
}}
className="opacity-0 group-hover:opacity-100 hover:bg-muted rounded p-0.5 transition-opacity"
>
<XIcon className="w-3 h-3" />
</span>
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
onTabClose(tab.id);
}
}}
className="opacity-0 group-hover:opacity-100 hover:bg-muted rounded p-0.5 transition-opacity"
>
<XIcon className="w-3 h-3" />
</span>
</button>
))}
</div>

{/* Right scroll button */}
{canScrollRight && (
<button
type="button"
onClick={scrollRight}
className="absolute right-0 z-10 flex items-center justify-center w-6 h-full bg-gradient-to-l from-background via-background to-transparent pl-2 cursor-pointer"
aria-label="Scroll tabs right"
>
<ChevronRightIcon className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
))}
)}
</div>

{/* Active tab content */}
Expand All @@ -102,7 +185,6 @@ export function ResultsTabs({
result={activeTab.result}
error={activeTab.error}
isLoading={isLoading}
executedSql={activeTab.executedSql}
executionTimeMs={activeTab.executionTimeMs}
/>
)}
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/components/tool/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { QueryResult } from '../../api/tools';
import type { StatementResult } from '../../api/tools';

export interface ResultTab {
id: string;
timestamp: Date;
result: QueryResult | null;
result: StatementResult | null;
error: string | null;
executedSql: string;
executionTimeMs: number;
}
Loading
Loading