Skip to content

Commit 7d55caf

Browse files
author
Cristian Carlos dos Santos
committed
Redesign MCP client dialog
1 parent 11922e4 commit 7d55caf

6 files changed

Lines changed: 223 additions & 209 deletions

File tree

apps/desktop/src-tauri/src/lib.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -899,10 +899,10 @@ fn get_mcp_launcher_config<R: tauri::Runtime>(
899899
}
900900
let server_entry_path = mcp_server_entry_path(&app)?;
901901
Ok(McpLauncherConfig {
902-
command: node_executable.display().to_string(),
902+
command: strip_verbatim_prefix(node_executable).display().to_string(),
903903
args: vec![
904-
server_entry_path.display().to_string(),
905-
descriptor_path.display().to_string(),
904+
strip_verbatim_prefix(server_entry_path).display().to_string(),
905+
strip_verbatim_prefix(descriptor_path).display().to_string(),
906906
],
907907
})
908908
}
@@ -1905,6 +1905,15 @@ mod tests {
19051905
assert!(!security._local_system_sid.is_empty());
19061906
}
19071907

1908+
#[cfg(windows)]
1909+
#[test]
1910+
fn windows_launcher_paths_do_not_expose_verbatim_prefix() {
1911+
assert_eq!(
1912+
strip_verbatim_prefix(PathBuf::from(r"\\?\C:\Users\developer\node.exe")),
1913+
PathBuf::from(r"C:\Users\developer\node.exe")
1914+
);
1915+
}
1916+
19081917
#[test]
19091918
fn mcp_launcher_config_contains_no_secret() {
19101919
let config = McpLauncherConfig {
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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; } }

apps/desktop/src/components/StatusBar.test.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ test("StatusBar: shows HTTP endpoint without exposing descriptor data", async ()
132132
assert.equal(screen.getAllByRole("button", { name: "Copy HTTP endpoint" }).length, 1);
133133
});
134134

135-
test("StatusBar: organizes MCP dialog into tabs and shows request history", async () => {
135+
test("StatusBar: separates MCP configuration from activity and expands SQL on demand", async () => {
136136
const launcher = { command: "/usr/bin/node", args: ["/opt/mcp/index.js", "/run/mcp.json"] };
137137
vi.mocked(invoke).mockResolvedValue(launcher);
138138
vi.mocked(backend.call).mockResolvedValue({
@@ -156,10 +156,13 @@ test("StatusBar: organizes MCP dialog into tabs and shows request history", asyn
156156
expect(await screen.findByText("STDIO command")).toBeTruthy();
157157
expect(screen.getByText("Argument 1")).toBeTruthy();
158158
expect(screen.getByText("/usr/bin/node")).toBeTruthy();
159+
expect(screen.getByRole("button", { name: "Copy argument 1" })).toBeTruthy();
159160

160-
fireEvent.click(screen.getByRole("tab", { name: "History" }));
161+
fireEvent.click(screen.getByRole("tab", { name: "Activity" }));
161162
expect(await screen.findByText("proposeSqlEdit")).toBeTruthy();
162-
expect(screen.getByText(/Improve query/)).toBeTruthy();
163+
expect(screen.getByText("Improve query")).toBeTruthy();
164+
expect(screen.queryByText("SELECT 1")).toBeNull();
165+
fireEvent.click(screen.getByRole("button", { name: "Show SQL" }));
163166
expect(screen.getByText("SELECT 1")).toBeTruthy();
164167
expect(screen.getByText("Success")).toBeTruthy();
165168
assert.equal(vi.mocked(backend.call).mock.calls.filter(([method]) => method === "mcp.history").length, 1);
@@ -171,6 +174,6 @@ test("StatusBar: shows empty state when no MCP requests were recorded", async ()
171174
renderWithLanguage(<StatusBar mcpState="listening" />);
172175

173176
fireEvent.click(screen.getByRole("button", { name: /MCP: MCP ready/ }));
174-
fireEvent.click(await screen.findByRole("tab", { name: "History" }));
177+
fireEvent.click(await screen.findByRole("tab", { name: "Activity" }));
175178
expect(await screen.findByText("No MCP requests received yet.")).toBeTruthy();
176179
});

0 commit comments

Comments
 (0)