|
| 1 | +import { useEffect, useState } from "react"; |
| 2 | +import { Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Tab, TabList, Text, tokens } from "@fluentui/react-components"; |
| 3 | +import { ArrowClockwiseRegular, ChevronDownRegular, ChevronUpRegular, CopyRegular, PlugConnectedRegular } from "@fluentui/react-icons"; |
| 4 | +import { invoke } from "@tauri-apps/api/core"; |
| 5 | +import type { McpHistoryEntry, McpHistoryResult, McpStatusResult } from "@omni-sql/ts-types"; |
| 6 | +import { backend } from "../lib/backend"; |
| 7 | +import { useLanguage } from "../i18n"; |
| 8 | +import type { McpVisualState } from "./StatusBar"; |
| 9 | + |
| 10 | +interface McpLauncherConfig { command: string; args: string[]; endpoint?: string } |
| 11 | + |
| 12 | +export function createCopilotVsCodeMcpConfig(config: Pick<McpLauncherConfig, "command" | "args">): string { |
| 13 | + return JSON.stringify({ servers: { "omni-sql": { command: config.command, args: config.args } } }, null, 2); |
| 14 | +} |
| 15 | + |
| 16 | +interface McpStatusDialogProps { |
| 17 | + open: boolean; |
| 18 | + onOpenChange: (open: boolean) => void; |
| 19 | + state: McpVisualState; |
| 20 | + status?: McpStatusResult | null; |
| 21 | + error?: string | null; |
| 22 | +} |
| 23 | + |
| 24 | +export function McpStatusDialog({ open, onOpenChange, state, status, error }: McpStatusDialogProps) { |
| 25 | + const { t } = useLanguage(); |
| 26 | + const [section, setSection] = useState<"configuration" | "activity">("configuration"); |
| 27 | + const [client, setClient] = useState<"copilot" | "stdio" | "http">("copilot"); |
| 28 | + const [config, setConfig] = useState<McpLauncherConfig | null>(null); |
| 29 | + const [configError, setConfigError] = useState<string | null>(null); |
| 30 | + const [history, setHistory] = useState<readonly McpHistoryEntry[] | null>(null); |
| 31 | + const [historyError, setHistoryError] = useState<string | null>(null); |
| 32 | + const [historyLoading, setHistoryLoading] = useState(false); |
| 33 | + const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set()); |
| 34 | + const label = state === "connected" ? t("mcpConnected") : state === "error" ? t("mcpError") : state === "listening" ? t("mcpListening") : t("mcpInactive"); |
| 35 | + const color = state === "connected" ? tokens.colorPaletteGreenForeground1 : state === "error" ? tokens.colorPaletteRedForeground1 : state === "listening" ? tokens.colorPaletteYellowForeground1 : tokens.colorNeutralForeground2; |
| 36 | + const connected = state === "connected" || status?.uiConnected === true; |
| 37 | + const endpoint = safeHttpEndpoint(config?.endpoint); |
| 38 | + |
| 39 | + const loadHistory = () => { |
| 40 | + setHistoryError(null); |
| 41 | + setHistoryLoading(true); |
| 42 | + void backend.call<McpHistoryResult>("mcp.history", undefined) |
| 43 | + .then((result) => setHistory(result.entries)) |
| 44 | + .catch((reason: unknown) => { setHistory(null); setHistoryError(reason instanceof Error ? reason.message : String(reason)); }) |
| 45 | + .finally(() => setHistoryLoading(false)); |
| 46 | + }; |
| 47 | + |
| 48 | + useEffect(() => { |
| 49 | + if (!open) return; |
| 50 | + setSection("configuration"); |
| 51 | + setConfig(null); |
| 52 | + setConfigError(null); |
| 53 | + setHistory(null); |
| 54 | + setHistoryError(null); |
| 55 | + setExpanded(new Set()); |
| 56 | + void invoke<McpLauncherConfig>("get_mcp_launcher_config") |
| 57 | + .then((value) => { setConfig(value); setClient(safeHttpEndpoint(value.endpoint) ? "http" : "copilot"); }) |
| 58 | + .catch((reason: unknown) => setConfigError(reason instanceof Error ? reason.message : String(reason))); |
| 59 | + }, [open]); |
| 60 | + |
| 61 | + const selectSection = (value: "configuration" | "activity") => { |
| 62 | + setSection(value); |
| 63 | + if (value === "activity" && history === null && !historyLoading) loadHistory(); |
| 64 | + }; |
| 65 | + |
| 66 | + return ( |
| 67 | + <Dialog open={open} onOpenChange={(_, data) => onOpenChange(data.open)}> |
| 68 | + <DialogSurface className="omni-standard-dialog omni-mcp-status-dialog"> |
| 69 | + <DialogBody className="omni-dialog-body"> |
| 70 | + <DialogTitle>{t("mcpStatusTitle")}</DialogTitle> |
| 71 | + <DialogContent className="omni-mcp-dialog-content"> |
| 72 | + <section className="omni-mcp-summary" aria-label={label}> |
| 73 | + <span className="omni-mcp-summary-icon" style={{ color }}><PlugConnectedRegular /></span> |
| 74 | + <div className="omni-mcp-summary-copy"> |
| 75 | + <Text weight="semibold" style={{ color }}>{label}</Text> |
| 76 | + <Text size={200} style={{ color: tokens.colorNeutralForeground2 }}> |
| 77 | + {connected ? `${t("mcpActiveClient")} · ${t("mcpQueue")}: ${status?.queueSize ?? 0}` : t("mcpNoClient")} |
| 78 | + </Text> |
| 79 | + </div> |
| 80 | + <span className="omni-mcp-status-dot" style={{ background: color }} /> |
| 81 | + </section> |
| 82 | + |
| 83 | + {(error || configError) && <div className="omni-mcp-error" role="alert">{error ?? configError}</div>} |
| 84 | + |
| 85 | + <TabList selectedValue={section} onTabSelect={(_, data) => selectSection(data.value as "configuration" | "activity")} aria-label={t("mcpStatusTitle")}> |
| 86 | + <Tab value="configuration">{t("mcpConfigurationTab")}</Tab> |
| 87 | + <Tab value="activity">{t("mcpActivityTab")}</Tab> |
| 88 | + </TabList> |
| 89 | + |
| 90 | + {section === "configuration" && ( |
| 91 | + <div className="omni-mcp-section"> |
| 92 | + <div> |
| 93 | + <Text weight="semibold">{t("configureClient")}</Text> |
| 94 | + <Text block size={200} style={{ color: tokens.colorNeutralForeground2 }}>{t("mcpStartFirst")}</Text> |
| 95 | + </div> |
| 96 | + {config ? ( |
| 97 | + <> |
| 98 | + <TabList size="small" selectedValue={client} onTabSelect={(_, data) => setClient(data.value as typeof client)} aria-label={t("configureClient")}> |
| 99 | + {!endpoint && <Tab value="copilot">{t("mcpCopilotVsCodeConfig")}</Tab>} |
| 100 | + {endpoint && <Tab value="http">HTTP</Tab>} |
| 101 | + <Tab value="stdio">{t("mcpStdioTab")}</Tab> |
| 102 | + </TabList> |
| 103 | + {client === "copilot" && <McpValue value={createCopilotVsCodeMcpConfig(config)} copyLabel={t("copyCopilotVsCodeConfig")} copiedLabel={t("copied")} multiline help={t("mcpCopilotVsCodeHelp")} />} |
| 104 | + {client === "http" && endpoint && <McpValue label={t("mcpHttpEndpoint")} value={endpoint} copyLabel={t("copyEndpoint")} copiedLabel={t("copied")} />} |
| 105 | + {client === "stdio" && <div className="omni-mcp-stdio-list"> |
| 106 | + <McpValue label={t("mcpCommand")} value={config.command} copyLabel={t("copyCommand")} copiedLabel={t("copied")} /> |
| 107 | + {config.args.map((argument, index) => <McpValue key={`${index}-${argument}`} label={`${t("mcpArgument")} ${index + 1}`} value={argument} copyLabel={`${t("copyArgument")} ${index + 1}`} copiedLabel={t("copied")} />)} |
| 108 | + </div>} |
| 109 | + </> |
| 110 | + ) : !configError && <div className="omni-empty-state">{t("loading")}</div>} |
| 111 | + </div> |
| 112 | + )} |
| 113 | + |
| 114 | + {section === "activity" && ( |
| 115 | + <div className="omni-mcp-section"> |
| 116 | + <div className="omni-mcp-section-heading"> |
| 117 | + <div><Text weight="semibold">{t("mcpRecentRequests")}</Text><Text block size={200} style={{ color: tokens.colorNeutralForeground2 }}>{t("mcpActivityHelp")}</Text></div> |
| 118 | + <Button appearance="subtle" size="small" icon={<ArrowClockwiseRegular />} aria-label={t("mcpHistoryRefresh")} title={t("mcpHistoryRefresh")} onClick={loadHistory} disabled={historyLoading} /> |
| 119 | + </div> |
| 120 | + {historyLoading && history === null && <div className="omni-empty-state">{t("loading")}</div>} |
| 121 | + {historyError && <div className="omni-mcp-error" role="alert">{historyError}</div>} |
| 122 | + {!historyLoading && !historyError && (history ?? []).length === 0 && <div className="omni-empty-state">{t("mcpHistoryEmpty")}</div>} |
| 123 | + <div className="omni-mcp-history"> |
| 124 | + {(history ?? []).map((entry) => <HistoryEntry key={entry.id} entry={entry} expanded={expanded.has(entry.id)} onToggle={() => setExpanded((current) => { |
| 125 | + const next = new Set(current); |
| 126 | + if (next.has(entry.id)) next.delete(entry.id); |
| 127 | + else next.add(entry.id); |
| 128 | + return next; |
| 129 | + })} />)} |
| 130 | + </div> |
| 131 | + </div> |
| 132 | + )} |
| 133 | + </DialogContent> |
| 134 | + <DialogActions className="omni-dialog-actions"><Button appearance="secondary" onClick={() => onOpenChange(false)}>{t("close")}</Button></DialogActions> |
| 135 | + </DialogBody> |
| 136 | + </DialogSurface> |
| 137 | + </Dialog> |
| 138 | + ); |
| 139 | +} |
| 140 | + |
| 141 | +function HistoryEntry({ entry, expanded, onToggle }: { entry: McpHistoryEntry; expanded: boolean; onToggle: () => void }) { |
| 142 | + const { t } = useLanguage(); |
| 143 | + const statusLabel = entry.status === "completed" ? t("success") : entry.status === "error" ? t("failure") : t("mcpHistoryPending"); |
| 144 | + const statusColor = entry.status === "completed" ? tokens.colorPaletteGreenForeground1 : entry.status === "error" ? tokens.colorPaletteRedForeground1 : tokens.colorPaletteYellowForeground1; |
| 145 | + return <article className="omni-mcp-history-item"> |
| 146 | + <div className="omni-mcp-history-heading"><Text size={200} className="omni-mcp-history-time">{new Date(entry.receivedAt).toLocaleTimeString()}</Text><Text weight="semibold" className="omni-mcp-history-tool">{entry.tool}</Text><Text size={200} style={{ color: statusColor }}>{statusLabel}</Text></div> |
| 147 | + <Text size={200} className="omni-mcp-history-rationale">{entry.rationale}</Text> |
| 148 | + <button type="button" className="omni-mcp-sql-toggle" onClick={onToggle} aria-expanded={expanded}>{expanded ? <ChevronUpRegular /> : <ChevronDownRegular />} {expanded ? t("mcpHideSql") : t("mcpShowSql")}</button> |
| 149 | + {expanded && <pre className="omni-mcp-history-sql">{entry.sql}</pre>} |
| 150 | + {entry.errorMessage && <Text size={200} style={{ color: tokens.colorPaletteRedForeground1, overflowWrap: "anywhere" }}>{entry.errorMessage}</Text>} |
| 151 | + </article>; |
| 152 | +} |
| 153 | + |
| 154 | +function McpValue({ label, value, copyLabel, copiedLabel, multiline, help }: { label?: string; value: string; copyLabel: string; copiedLabel: string; multiline?: boolean; help?: string }) { |
| 155 | + const [copied, setCopied] = useState(false); |
| 156 | + const copy = async () => { if (!navigator.clipboard?.writeText) return; try { await navigator.clipboard.writeText(value); setCopied(true); window.setTimeout(() => setCopied(false), 1500); } catch { /* do not report false success */ } }; |
| 157 | + return <div className="omni-mcp-config-card"> |
| 158 | + <div className="omni-mcp-config-heading"> |
| 159 | + <div>{label && <Text weight="semibold">{label}</Text>}{help && <Text block size={200} style={{ color: tokens.colorNeutralForeground2 }}>{help}</Text>}</div> |
| 160 | + <Button appearance="secondary" size="small" icon={<CopyRegular />} onClick={() => void copy()}>{copied ? copiedLabel : copyLabel}</Button> |
| 161 | + </div> |
| 162 | + {multiline ? <pre className="omni-mcp-config-value">{value}</pre> : <code className="omni-mcp-config-value">{value}</code>} |
| 163 | + </div>; |
| 164 | +} |
| 165 | + |
| 166 | +function safeHttpEndpoint(value?: string): string | null { if (!value) return null; try { const url = new URL(value); return /^https?:$/.test(url.protocol) && !url.username && !url.password ? `${url.origin}${url.pathname || "/"}` : null; } catch { return null; } } |
0 commit comments