Integrations
diff --git a/components/frontend/src/components/http-tool-dialog.tsx b/components/frontend/src/components/http-tool-dialog.tsx
new file mode 100644
index 000000000..e9493d7fb
--- /dev/null
+++ b/components/frontend/src/components/http-tool-dialog.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Plus, Trash2, Loader2 } from "lucide-react";
+import type { HttpToolConfig, HttpMethod } from "@/services/api/http-tools";
+
+type KVEntry = { key: string; value: string };
+
+const HTTP_METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
+
+type HttpToolDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSave: (tool: HttpToolConfig) => void;
+ saving: boolean;
+ initialTool?: HttpToolConfig;
+};
+
+export function HttpToolDialog({ open, onOpenChange, onSave, saving, initialTool }: HttpToolDialogProps) {
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [method, setMethod] = useState
("GET");
+ const [endpoint, setEndpoint] = useState("");
+ const [headers, setHeaders] = useState([]);
+ const [params, setParams] = useState([]);
+ const isEditing = !!initialTool;
+
+ useEffect(() => {
+ if (open) {
+ setName(initialTool?.name ?? "");
+ setDescription(initialTool?.description ?? "");
+ setMethod(initialTool?.method ?? "GET");
+ setEndpoint(initialTool?.endpoint ?? "");
+ setHeaders(
+ initialTool?.headers
+ ? Object.entries(initialTool.headers).map(([key, value]) => ({ key, value }))
+ : []
+ );
+ setParams(
+ initialTool?.params
+ ? Object.entries(initialTool.params).map(([key, value]) => ({ key, value }))
+ : []
+ );
+ }
+ }, [open, initialTool]);
+
+ const handleSubmit = () => {
+ if (!name.trim() || !endpoint.trim()) return;
+ const toRecord = (entries: KVEntry[]) => {
+ const rec: Record = {};
+ for (const e of entries) {
+ if (e.key.trim()) rec[e.key.trim()] = e.value;
+ }
+ return rec;
+ };
+ onSave({
+ name: name.trim(),
+ description: description.trim(),
+ method,
+ endpoint: endpoint.trim(),
+ headers: toRecord(headers),
+ params: toRecord(params),
+ });
+ };
+
+ const addEntry = (setter: typeof setHeaders) => setter((prev) => [...prev, { key: "", value: "" }]);
+ const removeEntry = (setter: typeof setHeaders, index: number) => setter((prev) => prev.filter((_, i) => i !== index));
+ const updateEntry = (setter: typeof setHeaders, index: number, field: "key" | "value", val: string) =>
+ setter((prev) => prev.map((e, i) => (i === index ? { ...e, [field]: val } : e)));
+
+ const renderKVSection = (label: string, entries: KVEntry[], setter: typeof setHeaders) => (
+
+
+
+
+
+ {entries.map((entry, i) => (
+
+ updateEntry(setter, i, "key", e.target.value)} placeholder="Key" className="flex-1" />
+ updateEntry(setter, i, "value", e.target.value)} placeholder="Value" className="flex-1" />
+
+
+ ))}
+
+ );
+
+ return (
+
+ );
+}
diff --git a/components/frontend/src/components/http-tools-tab.tsx b/components/frontend/src/components/http-tools-tab.tsx
new file mode 100644
index 000000000..953623f50
--- /dev/null
+++ b/components/frontend/src/components/http-tools-tab.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { useState } from "react";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { Plus, MoreHorizontal, Pencil, Trash2, Globe, AlertCircle } from "lucide-react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { successToast, errorToast } from "@/hooks/use-toast";
+import { useHttpTools, useUpdateHttpTools } from "@/services/queries/use-http-tools";
+import { HttpToolDialog } from "@/components/http-tool-dialog";
+import type { HttpToolConfig } from "@/services/api/http-tools";
+
+type HttpToolsTabProps = {
+ projectName: string;
+};
+
+export function HttpToolsTab({ projectName }: HttpToolsTabProps) {
+ const { data, isLoading, error } = useHttpTools(projectName);
+ const updateMutation = useUpdateHttpTools();
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingTool, setEditingTool] = useState(null);
+
+ const tools = data?.tools ?? [];
+
+ const handleAdd = () => {
+ setEditingTool(null);
+ setDialogOpen(true);
+ };
+
+ const handleEdit = (tool: HttpToolConfig) => {
+ setEditingTool(tool);
+ setDialogOpen(true);
+ };
+
+ const handleDelete = (name: string) => {
+ const updated = tools.filter((t) => t.name !== name);
+ updateMutation.mutate(
+ { projectName, data: { tools: updated } },
+ {
+ onSuccess: () => successToast(`Removed HTTP tool "${name}"`),
+ onError: () => errorToast("Failed to remove HTTP tool"),
+ }
+ );
+ };
+
+ const handleSave = (tool: HttpToolConfig) => {
+ const updated = editingTool
+ ? tools.map((t) => (t.name === editingTool.name ? tool : t))
+ : [...tools, tool];
+ updateMutation.mutate(
+ { projectName, data: { tools: updated } },
+ {
+ onSuccess: () => {
+ successToast(editingTool ? `Updated HTTP tool "${tool.name}"` : `Added HTTP tool "${tool.name}"`);
+ setDialogOpen(false);
+ setEditingTool(null);
+ },
+ onError: () => errorToast("Failed to save HTTP tool"),
+ }
+ );
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ Failed to load HTTP tools configuration: {error instanceof Error ? error.message : "Unknown error"}
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+ {tools.length === 0 ? (
+
+
+
No HTTP tools configured
+
Add an HTTP tool to give sessions access to external APIs
+
+ ) : (
+
+
+
+ Name
+ Method
+ Endpoint
+ Headers
+
+
+
+
+ {tools.map((tool) => (
+
+
+
+
{tool.name}
+ {tool.description && (
+
{tool.description}
+ )}
+
+
+
+ {tool.method}
+
+ {tool.endpoint}
+
+ {Object.keys(tool.headers ?? {}).length > 0 ? (
+ {Object.keys(tool.headers).length} headers
+ ) : (
+ --
+ )}
+
+
+
+
+
+
+
+ handleEdit(tool)}>
+ Edit
+
+ handleDelete(tool.name)} className="text-destructive">
+ Delete
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {
+ setDialogOpen(open);
+ if (!open) setEditingTool(null);
+ }}
+ onSave={handleSave}
+ saving={updateMutation.isPending}
+ initialTool={editingTool ?? undefined}
+ />
+ >
+ );
+}
diff --git a/components/frontend/src/components/label-editor.tsx b/components/frontend/src/components/label-editor.tsx
new file mode 100644
index 000000000..0ec0e8a90
--- /dev/null
+++ b/components/frontend/src/components/label-editor.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { X, Plus, ChevronDown } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+
+type LabelEditorProps = {
+ labels: Record;
+ onChange: (labels: Record) => void;
+ disabled?: boolean;
+ suggestions?: string[];
+};
+
+const DEFAULT_SUGGESTIONS = ["team", "type", "priority", "feature"];
+
+export function LabelEditor({
+ labels,
+ onChange,
+ disabled = false,
+ suggestions = DEFAULT_SUGGESTIONS,
+}: LabelEditorProps) {
+ const [inputValue, setInputValue] = useState("");
+ const [suggestionsOpen, setSuggestionsOpen] = useState(false);
+
+ const handleRemove = useCallback(
+ (key: string) => {
+ const next = { ...labels };
+ delete next[key];
+ onChange(next);
+ },
+ [labels, onChange]
+ );
+
+ const handleAdd = useCallback(() => {
+ const trimmed = inputValue.trim();
+ if (!trimmed) return;
+
+ const colonIdx = trimmed.indexOf(":");
+ if (colonIdx <= 0 || colonIdx === trimmed.length - 1) return;
+
+ const key = trimmed.slice(0, colonIdx).trim();
+ const value = trimmed.slice(colonIdx + 1).trim();
+ if (!key || !value) return;
+
+ onChange({ ...labels, [key]: value });
+ setInputValue("");
+ }, [inputValue, labels, onChange]);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAdd();
+ }
+ };
+
+ const handleSuggestionClick = (suggestion: string) => {
+ setInputValue(`${suggestion}:`);
+ setSuggestionsOpen(false);
+ };
+
+ const entries = Object.entries(labels);
+
+ return (
+
+ {/* Existing labels */}
+ {entries.length > 0 && (
+
+ {entries.map(([key, value]) => (
+
+ {key}
+ =
+ {value}
+ {!disabled && (
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Input row */}
+ {!disabled && (
+
+
+ setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="key:value"
+ disabled={disabled}
+ className="pr-8"
+ />
+
+
+
+
+
+
+ {suggestions.map((s) => (
+
+ ))}
+
+
+
+
+ )}
+
+ {!disabled && (
+
+ Add labels as key:value pairs. Use the dropdown for common keys.
+
+ )}
+
+ );
+}
diff --git a/components/frontend/src/components/mcp-config-editor.tsx b/components/frontend/src/components/mcp-config-editor.tsx
new file mode 100644
index 000000000..f076eefb1
--- /dev/null
+++ b/components/frontend/src/components/mcp-config-editor.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Loader2 } from "lucide-react";
+import { useMcpConfig } from "@/services/queries/use-mcp-config";
+import { useHttpTools } from "@/services/queries/use-http-tools";
+import { McpServersTab } from "@/components/mcp-servers-tab";
+import { HttpToolsTab } from "@/components/http-tools-tab";
+
+type McpConfigEditorProps = {
+ projectName: string;
+};
+
+export function McpConfigEditor({ projectName }: McpConfigEditorProps) {
+ const { isLoading: mcpLoading } = useMcpConfig(projectName);
+ const { isLoading: httpLoading } = useHttpTools(projectName);
+
+ if (mcpLoading || httpLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ MCP Servers & HTTP Tools
+ Configure Model Context Protocol servers and custom HTTP tools for your sessions
+
+
+
+
+ MCP Servers
+ HTTP Tools
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/frontend/src/components/mcp-server-dialog.tsx b/components/frontend/src/components/mcp-server-dialog.tsx
new file mode 100644
index 000000000..863a94da3
--- /dev/null
+++ b/components/frontend/src/components/mcp-server-dialog.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Plus, Trash2, Loader2, Zap, CheckCircle2, XCircle } from "lucide-react";
+import type { McpServerConfig, McpTestResult } from "@/services/api/mcp-config";
+import { useTestMcpServer } from "@/services/queries/use-mcp-config";
+
+type EnvEntry = { key: string; value: string };
+
+type McpServerDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSave: (name: string, config: McpServerConfig) => void;
+ saving: boolean;
+ projectName: string;
+ initialName?: string;
+ initialConfig?: McpServerConfig;
+};
+
+export function McpServerDialog({ open, onOpenChange, onSave, saving, projectName, initialName, initialConfig }: McpServerDialogProps) {
+ const [name, setName] = useState("");
+ const [command, setCommand] = useState("");
+ const [args, setArgs] = useState("");
+ const [envEntries, setEnvEntries] = useState([]);
+ const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'fail'>('idle');
+ const [testResult, setTestResult] = useState(null);
+ const isEditing = !!initialName;
+ const testMutation = useTestMcpServer();
+
+ useEffect(() => {
+ if (open) {
+ setName(initialName ?? "");
+ setCommand(initialConfig?.command ?? "");
+ setArgs(initialConfig?.args?.join(", ") ?? "");
+ const entries = initialConfig?.env
+ ? Object.entries(initialConfig.env).map(([key, value]) => ({ key, value }))
+ : [];
+ setEnvEntries(entries);
+ setTestStatus('idle');
+ setTestResult(null);
+ }
+ }, [open, initialName, initialConfig]);
+
+ const resetTest = () => {
+ setTestStatus('idle');
+ setTestResult(null);
+ };
+
+ const buildConfig = (): McpServerConfig => {
+ const parsedArgs = args
+ .split(",")
+ .map((a) => a.trim())
+ .filter(Boolean);
+ const env: Record = {};
+ for (const entry of envEntries) {
+ if (entry.key.trim()) {
+ env[entry.key.trim()] = entry.value;
+ }
+ }
+ return { command: command.trim(), args: parsedArgs, env };
+ };
+
+ const handleSubmit = () => {
+ if (!name.trim() || !command.trim()) return;
+ onSave(name.trim(), buildConfig());
+ };
+
+ const handleTest = () => {
+ if (!command.trim()) return;
+ setTestStatus('testing');
+ setTestResult(null);
+ testMutation.mutate(
+ { projectName, config: buildConfig() },
+ {
+ onSuccess: (result) => {
+ setTestResult(result);
+ setTestStatus(result.valid ? 'success' : 'fail');
+ },
+ onError: (error) => {
+ setTestResult({ valid: false, error: error instanceof Error ? error.message : 'Test request failed' });
+ setTestStatus('fail');
+ },
+ },
+ );
+ };
+
+ const addEnvEntry = () => setEnvEntries([...envEntries, { key: "", value: "" }]);
+
+ const removeEnvEntry = (index: number) => {
+ setEnvEntries(envEntries.filter((_, i) => i !== index));
+ };
+
+ const updateEnvEntry = (index: number, field: "key" | "value", val: string) => {
+ setEnvEntries(envEntries.map((e, i) => (i === index ? { ...e, [field]: val } : e)));
+ };
+
+ return (
+
+ );
+}
diff --git a/components/frontend/src/components/mcp-servers-tab.tsx b/components/frontend/src/components/mcp-servers-tab.tsx
new file mode 100644
index 000000000..9f0a96853
--- /dev/null
+++ b/components/frontend/src/components/mcp-servers-tab.tsx
@@ -0,0 +1,307 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { Plus, MoreHorizontal, Pencil, Trash2, Server, Zap, Download, Upload, ChevronDown, AlertCircle } from "lucide-react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { successToast, errorToast } from "@/hooks/use-toast";
+import { useMcpConfig, useUpdateMcpConfig, useTestMcpServer } from "@/services/queries/use-mcp-config";
+import { McpServerDialog } from "@/components/mcp-server-dialog";
+import type { McpServerConfig } from "@/services/api/mcp-config";
+
+// OpenCode local server format: {type: "local", command: ["cmd", ...args], environment?: {...}}
+type OpenCodeServer = {
+ type: string;
+ command: string[];
+ environment?: Record;
+ enabled?: boolean;
+};
+
+function toInternal(servers: Record): Record {
+ const result: Record = {};
+ for (const [name, raw] of Object.entries(servers)) {
+ if (!raw || typeof raw !== "object") continue;
+ const srv = raw as Record;
+ if (Array.isArray(srv.command) && srv.command.every((c) => typeof c === "string")) {
+ // OpenCode format: command is an array of strings
+ const [cmd = "", ...args] = srv.command as string[];
+ const env = srv.environment && typeof srv.environment === "object" ? (srv.environment as Record) : {};
+ result[name] = { command: cmd, args, env };
+ } else if (typeof srv.command === "string") {
+ // Claude Code format: command is a string, args is separate
+ const args = Array.isArray(srv.args) ? (srv.args as string[]) : [];
+ const env = srv.env && typeof srv.env === "object" ? (srv.env as Record) : {};
+ result[name] = { command: srv.command, args, env };
+ }
+ }
+ return result;
+}
+
+function toOpenCode(servers: Record): Record {
+ const result: Record = {};
+ for (const [name, srv] of Object.entries(servers)) {
+ result[name] = {
+ type: "local",
+ command: [srv.command, ...srv.args],
+ ...(Object.keys(srv.env).length > 0 ? { environment: srv.env } : {}),
+ };
+ }
+ return result;
+}
+
+function downloadJson(data: unknown, filename: string) {
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
+ const blobUrl = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = blobUrl;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(blobUrl);
+}
+
+type McpServersTabProps = {
+ projectName: string;
+};
+
+export function McpServersTab({ projectName }: McpServersTabProps) {
+ const { data: config, isLoading, error } = useMcpConfig(projectName);
+ const updateMutation = useUpdateMcpConfig();
+ const testMutation = useTestMcpServer();
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingServer, setEditingServer] = useState<{ name: string; config: McpServerConfig } | null>(null);
+ const fileInputRef = useRef(null);
+
+ const servers = config?.servers ?? {};
+ const serverEntries = Object.entries(servers);
+
+ const handleAdd = () => {
+ setEditingServer(null);
+ setDialogOpen(true);
+ };
+
+ const handleEdit = (name: string, serverConfig: McpServerConfig) => {
+ setEditingServer({ name, config: serverConfig });
+ setDialogOpen(true);
+ };
+
+ const handleDelete = (name: string) => {
+ const updated = { ...servers };
+ delete updated[name];
+ updateMutation.mutate(
+ { projectName, config: { servers: updated } },
+ {
+ onSuccess: () => successToast(`Removed MCP server "${name}"`),
+ onError: () => errorToast("Failed to remove MCP server"),
+ }
+ );
+ };
+
+ const handleSave = (name: string, serverConfig: McpServerConfig) => {
+ const updated = { ...servers, [name]: serverConfig };
+ updateMutation.mutate(
+ { projectName, config: { servers: updated } },
+ {
+ onSuccess: () => {
+ successToast(editingServer ? `Updated MCP server "${name}"` : `Added MCP server "${name}"`);
+ setDialogOpen(false);
+ setEditingServer(null);
+ },
+ onError: () => errorToast("Failed to save MCP server"),
+ }
+ );
+ };
+
+ const handleTest = (name: string, srv: McpServerConfig) => {
+ testMutation.mutate(
+ { projectName, config: srv },
+ {
+ onSuccess: (result) => {
+ if (result.valid) {
+ const info = result.serverInfo;
+ const detail = info?.name ? `${info.name}${info.version ? ` v${info.version}` : ''}` : 'OK';
+ successToast(`Server "${name}" is working — ${detail}`);
+ } else {
+ errorToast(`Server "${name}" failed: ${result.error || 'Unknown error'}`);
+ }
+ },
+ onError: (error) => {
+ errorToast(`Server "${name}" test error: ${error instanceof Error ? error.message : 'Request failed'}`);
+ },
+ }
+ );
+ };
+
+ const handleExportClaudeCode = () => {
+ downloadJson({ mcpServers: servers }, "mcp-servers.json");
+ successToast(`Exported ${serverEntries.length} server(s) (Claude Code format)`);
+ };
+
+ const handleExportOpenCode = () => {
+ downloadJson({ mcp: toOpenCode(servers) }, "opencode.json");
+ successToast(`Exported ${serverEntries.length} server(s) (OpenCode format)`);
+ };
+
+ const handleImportClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleImportFile = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ // Reset so the same file can be re-imported
+ e.target.value = "";
+ try {
+ const text = await file.text();
+ const data = JSON.parse(text);
+ // Accept: Claude Code {"mcpServers": {...}}, native {"servers": {...}}, OpenCode {"mcp": {...}}
+ const raw: Record | undefined = data.mcpServers ?? data.servers ?? data.mcp;
+ if (!raw || typeof raw !== "object") {
+ errorToast("Invalid MCP config file — must contain 'mcpServers', 'servers', or 'mcp'");
+ return;
+ }
+ const imported = toInternal(raw);
+ const merged = { ...servers, ...imported };
+ const count = Object.keys(imported).length;
+ updateMutation.mutate(
+ { projectName, config: { servers: merged } },
+ {
+ onSuccess: () => successToast(`Imported ${count} server(s)`),
+ onError: () => errorToast("Failed to import MCP servers"),
+ }
+ );
+ } catch {
+ errorToast("Could not parse the selected file as JSON");
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ Failed to load MCP server configuration: {error instanceof Error ? error.message : "Unknown error"}
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Export format
+
+
+ Claude Code / Desktop
+
+
+ OpenCode
+
+
+
+
+
+
+
+ {serverEntries.length === 0 ? (
+
+
+
No MCP servers configured
+
Add an MCP server to extend your session capabilities
+
+ ) : (
+
+
+
+ Name
+ Command
+ Args
+ Env
+
+
+
+
+ {serverEntries.map(([name, srv]) => (
+
+ {name}
+ {srv.command}
+
+ {srv.args?.length > 0 ? (
+ {srv.args.join(", ")}
+ ) : (
+ --
+ )}
+
+
+ {Object.keys(srv.env ?? {}).length > 0 ? (
+ {Object.keys(srv.env).length} vars
+ ) : (
+ --
+ )}
+
+
+
+
+
+
+
+ handleTest(name, srv)}>
+ Test
+
+ handleEdit(name, srv)}>
+ Edit
+
+ handleDelete(name)} className="text-destructive">
+ Delete
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {
+ setDialogOpen(open);
+ if (!open) setEditingServer(null);
+ }}
+ onSave={handleSave}
+ saving={updateMutation.isPending}
+ projectName={projectName}
+ initialName={editingServer?.name}
+ initialConfig={editingServer?.config}
+ />
+ >
+ );
+}
diff --git a/components/frontend/src/components/workspace-sections/sessions-section.tsx b/components/frontend/src/components/workspace-sections/sessions-section.tsx
index 8685a401a..9d83f6b84 100644
--- a/components/frontend/src/components/workspace-sections/sessions-section.tsx
+++ b/components/frontend/src/components/workspace-sections/sessions-section.tsx
@@ -1,11 +1,12 @@
'use client';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import { formatDistanceToNow } from 'date-fns';
-import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain, Search, ChevronLeft, ChevronRight, Pencil } from 'lucide-react';
+import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain, Search, ChevronLeft, ChevronRight, Pencil, X } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
@@ -28,26 +29,48 @@ export function SessionsSection({ projectName }: SessionsSectionProps) {
// Pagination and search state
const [searchInput, setSearchInput] = useState('');
const [offset, setOffset] = useState(0);
+ const [labelFilters, setLabelFilters] = useState>({});
const limit = DEFAULT_PAGE_SIZE;
// Debounce search to avoid too many API calls
const debouncedSearch = useDebounce(searchInput, 300);
- // Reset offset when search changes
+ // Build labelSelector string from filters
+ const labelSelector = Object.entries(labelFilters)
+ .map(([k, v]) => `${k}=${v}`)
+ .join(',') || undefined;
+
+ // Reset offset when search or label filters change
useEffect(() => {
setOffset(0);
- }, [debouncedSearch]);
+ }, [debouncedSearch, labelSelector]);
+
+ const addLabelFilter = useCallback((key: string, value: string) => {
+ setLabelFilters((prev) => ({ ...prev, [key]: value }));
+ }, []);
+
+ const removeLabelFilter = useCallback((key: string) => {
+ setLabelFilters((prev) => {
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ });
+ }, []);
// React Query hooks with pagination
const {
data: paginatedData,
isFetching,
refetch,
- } = useSessionsPaginated(projectName, {
- limit,
- offset,
- search: debouncedSearch || undefined,
- });
+ } = useSessionsPaginated(
+ projectName,
+ {
+ limit,
+ offset,
+ search: debouncedSearch || undefined,
+ },
+ labelSelector
+ );
const sessions = paginatedData?.items ?? [];
const totalCount = paginatedData?.totalCount ?? 0;
@@ -190,19 +213,52 @@ export function SessionsSection({ projectName }: SessionsSectionProps) {
className="pl-9"
/>
+ {/* Active label filters */}
+ {Object.keys(labelFilters).length > 0 && (
+