Compare commits

...

3 Commits

Author SHA1 Message Date
lucaronin
38fb8a7370 feat: copy mcp config from app 2026-04-28 14:03:10 +02:00
lucaronin
be5a8bb300 fix: make editor left handle draggable 2026-04-28 12:31:13 +02:00
lucaronin
ce040c75fd test: cover table hover stability 2026-04-28 10:46:06 +02:00
26 changed files with 736 additions and 67 deletions

View File

@@ -345,7 +345,7 @@ Command-layer path access is fenced to the active vault before file operations r
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files.
### Vault Caching

View File

@@ -329,7 +329,7 @@ Tolaria can register itself as an MCP server in:
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
### Architecture
@@ -377,6 +377,7 @@ flowchart LR
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request |
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
@@ -727,6 +728,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.

View File

@@ -425,6 +425,6 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
### Work with external MCP setup
1. **Backend registration/status**: Edit `src-tauri/src/mcp.rs`; registration must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and write an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx`; users should see the Node.js prerequisite and the manual config shape before Tolaria writes third-party config files
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes

View File

@@ -131,6 +131,15 @@ pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatu
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path))
.await
.map_err(|e| format!("MCP config task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
@@ -167,6 +176,12 @@ pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStat
Ok(crate::mcp::McpStatus::NotInstalled)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {

View File

@@ -435,6 +435,7 @@ macro_rules! app_invoke_handler {
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::get_mcp_config_snippet,
commands::sync_mcp_bridge_vault,
commands::repair_vault,
commands::reinit_telemetry,

View File

@@ -313,6 +313,28 @@ fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serd
})
}
fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String> {
let mut servers = serde_json::Map::new();
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
let config = serde_json::json!({ "mcpServers": servers });
serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize MCP config snippet: {e}"))
}
/// Build the exact MCP config JSON users can copy into compatible tools.
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
})?;
let server_dir = mcp_server_dir()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
build_mcp_config_snippet(&entry)
}
/// Write MCP registration to a list of config file paths.
/// Returns "registered" on first registration, "updated" if already present.
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
@@ -518,6 +540,22 @@ mod tests {
assert_eq!(entry["env"]["WS_UI_PORT"], "9711");
}
#[test]
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
let snippet = build_mcp_config_snippet(&entry).unwrap();
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/path/to/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/my/vault"
);
}
#[test]
fn first_node_lookup_path_uses_first_non_empty_line() {
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";

View File

@@ -453,7 +453,16 @@ function App() {
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
const {
mcpStatus,
connectMcp,
disconnectMcp,
mcpConfigSnippet,
mcpConfigLoading,
mcpConfigError,
loadMcpConfigSnippet,
copyMcpConfig,
} = useMcpStatus(resolvedPath, setToastMessage)
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
const loadVaultModifiedFiles = vault.loadModifiedFiles
const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus
@@ -493,6 +502,14 @@ function App() {
}
}, [disconnectMcp])
const handleCopyMcpConfig = useCallback(() => {
void copyMcpConfig()
}, [copyMcpConfig])
const handleLoadMcpConfigSnippet = useCallback(() => {
void loadMcpConfigSnippet().catch(() => undefined)
}, [loadMcpConfigSnippet])
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
@@ -1601,6 +1618,7 @@ function App() {
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
onCopyMcpConfig={handleCopyMcpConfig}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
@@ -1685,7 +1703,7 @@ function App() {
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onCopyManualConfig={handleCopyMcpConfig} onDisconnect={handleDisconnectMcp} onLoadManualConfig={handleLoadMcpConfigSnippet} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog

View File

@@ -85,6 +85,15 @@ describe('AiPanel', () => {
expect(mockClearConversation).toHaveBeenCalledOnce()
})
it('copies the MCP config from the AI panel header action', () => {
const onCopyMcpConfig = vi.fn()
render(<AiPanel onClose={vi.fn()} onCopyMcpConfig={onCopyMcpConfig} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByRole('button', { name: 'Copy MCP config' }))
expect(onCopyMcpConfig).toHaveBeenCalledOnce()
})
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy()

View File

@@ -21,6 +21,7 @@ export type { AiAgentMessage } from '../hooks/useCliAiAgent'
interface AiPanelProps {
onClose: () => void
onCopyMcpConfig?: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
@@ -42,6 +43,7 @@ interface AiPanelProps {
interface AiPanelViewProps {
controller: AiPanelController
onClose: () => void
onCopyMcpConfig?: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
@@ -58,6 +60,7 @@ function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
export function AiPanelView({
controller,
onClose,
onCopyMcpConfig,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
@@ -113,6 +116,7 @@ export function AiPanelView({
agentLabel={agentLabel}
agentReadiness={defaultAiAgentReadiness}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onNewChat={handleNewChat}
/>
{activeEntry && (
@@ -144,6 +148,7 @@ export function AiPanelView({
export function AiPanel({
onClose,
onCopyMcpConfig,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
@@ -183,6 +188,7 @@ export function AiPanel({
<AiPanelView
controller={controller}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={providedDefaultAiAgent}

View File

@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { Copy } from 'lucide-react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
@@ -12,6 +14,7 @@ interface AiPanelHeaderProps {
agentLabel: string
agentReadiness: AiAgentReadiness
onClose: () => void
onCopyMcpConfig?: () => void
onNewChat: () => void
}
@@ -122,6 +125,7 @@ export function AiPanelHeader({
agentLabel,
agentReadiness,
onClose,
onCopyMcpConfig,
onNewChat,
}: AiPanelHeaderProps) {
return (
@@ -140,21 +144,39 @@ export function AiPanelHeader({
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
</span>
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
{onCopyMcpConfig ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopyMcpConfig}
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
>
<Copy size={15} />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</button>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close AI panel"
title="Close AI panel"
>
<X size={16} />
</button>
</Button>
</div>
)
}

View File

@@ -67,6 +67,7 @@ interface EditorProps {
onInitializeProperties?: (path: string) => void
showAIChat?: boolean
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
vaultPath?: string
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
@@ -338,6 +339,7 @@ function EditorLayout({
showDiffToggle,
showAIChat,
onToggleAIChat,
onCopyMcpConfig,
inspectorCollapsed,
onToggleInspector,
onNavigateWikilink,
@@ -400,6 +402,7 @@ function EditorLayout({
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
@@ -520,6 +523,7 @@ function EditorLayout({
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}
onToggleAIChat={onToggleAIChat}
onCopyMcpConfig={onCopyMcpConfig}
onNavigateWikilink={onNavigateWikilink}
onViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
@@ -551,6 +555,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
onCopyMcpConfig,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
onDeleteNote, onArchiveNote, onUnarchiveNote,
@@ -611,6 +616,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
onCopyMcpConfig={onCopyMcpConfig}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}

View File

@@ -25,6 +25,7 @@ interface EditorRightPanelProps {
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
onNavigateWikilink: (target: string) => void
onViewCommitDiff: (commitHash: string) => Promise<void>
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
@@ -48,6 +49,7 @@ export function EditorRightPanel({
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onCopyMcpConfig,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
locale,
@@ -87,6 +89,7 @@ export function EditorRightPanel({
<AiPanelView
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onCopyMcpConfig={onCopyMcpConfig}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}

View File

@@ -2,6 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { McpSetupDialog } from './McpSetupDialog'
const MANUAL_CONFIG = JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/Applications/Tolaria.app/Contents/Resources/mcp-server/index.js'],
env: {
VAULT_PATH: '/Users/luca/Laputa',
WS_UI_PORT: '9711',
},
},
},
}, null, 2)
describe('McpSetupDialog', () => {
it('renders the explicit setup flow without mutating config by default', () => {
render(
@@ -9,6 +23,7 @@ describe('McpSetupDialog', () => {
open={true}
status="not_installed"
busyAction={null}
manualConfigSnippet={MANUAL_CONFIG}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
@@ -18,9 +33,9 @@ describe('McpSetupDialog', () => {
expect(screen.getByText('Set Up External AI Tools')).toBeInTheDocument()
expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument()
expect(screen.getByText(/requires Node.js 18\+ on PATH/i)).toBeInTheDocument()
expect(screen.getByText(/type: stdio/i)).toBeInTheDocument()
expect(screen.getByText(/VAULT_PATH/i)).toBeInTheDocument()
expect(screen.getByText(/WS_UI_PORT/i)).toBeInTheDocument()
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"type": "stdio"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"VAULT_PATH": "/Users/luca/Laputa"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"WS_UI_PORT": "9711"')
expect(screen.getAllByText('~/.claude.json')).toHaveLength(2)
expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument()
expect(screen.getAllByText('~/.config/mcp/mcp.json')).toHaveLength(2)
@@ -49,6 +64,7 @@ describe('McpSetupDialog', () => {
it('routes actions through the dialog buttons', () => {
const onClose = vi.fn()
const onConnect = vi.fn()
const onCopyManualConfig = vi.fn()
const onDisconnect = vi.fn()
render(
@@ -58,16 +74,37 @@ describe('McpSetupDialog', () => {
busyAction={null}
onClose={onClose}
onConnect={onConnect}
onCopyManualConfig={onCopyManualConfig}
onDisconnect={onDisconnect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByTestId('mcp-copy-config'))
fireEvent.click(screen.getByTestId('mcp-setup-connect'))
fireEvent.click(screen.getByTestId('mcp-setup-disconnect'))
expect(onClose).toHaveBeenCalledOnce()
expect(onCopyManualConfig).toHaveBeenCalledOnce()
expect(onConnect).toHaveBeenCalledOnce()
expect(onDisconnect).toHaveBeenCalledOnce()
})
it('loads exact manual config when opened', () => {
const onLoadManualConfig = vi.fn()
render(
<McpSetupDialog
open={true}
status="not_installed"
busyAction={null}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
onLoadManualConfig={onLoadManualConfig}
/>,
)
expect(onLoadManualConfig).toHaveBeenCalledOnce()
})
})

View File

@@ -1,4 +1,5 @@
import { ShieldCheck } from 'lucide-react'
import { useEffect } from 'react'
import { Copy, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -14,6 +15,29 @@ interface McpSetupDialogProps {
open: boolean
status: McpStatus
busyAction: 'connect' | 'disconnect' | null
manualConfigError?: string | null
manualConfigLoading?: boolean
manualConfigSnippet?: string | null
onClose: () => void
onConnect: () => void
onCopyManualConfig?: () => void
onDisconnect: () => void
onLoadManualConfig?: () => void
}
interface ManualMcpConfigSectionProps {
error?: string | null
loading: boolean
onCopy?: () => void
snippet?: string | null
}
interface McpSetupActionsProps {
buttonsDisabled: boolean
connectBusy: boolean
disconnectBusy: boolean
primaryLabel: string
secondaryLabel: string | null
onClose: () => void
onConnect: () => void
onDisconnect: () => void
@@ -41,19 +65,100 @@ function actionCopy(status: McpStatus) {
}
}
function manualConfigText({ error, loading, snippet }: ManualMcpConfigSectionProps): string {
if (loading) return 'Loading exact MCP config...'
return error ?? snippet ?? 'Exact config is available after a vault is open.'
}
function ManualMcpConfigSection(props: ManualMcpConfigSectionProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="m-0 text-sm font-medium text-foreground">Manual MCP config</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={props.onCopy}
disabled={!props.onCopy || props.loading}
data-testid="mcp-copy-config"
>
<Copy size={14} />
Copy MCP config
</Button>
</div>
<pre
tabIndex={0}
className="max-h-48 overflow-auto rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground"
data-testid="mcp-config-snippet"
>
{manualConfigText(props)}
</pre>
</div>
)
}
function McpSetupActions({
buttonsDisabled,
connectBusy,
disconnectBusy,
primaryLabel,
secondaryLabel,
onClose,
onConnect,
onDisconnect,
}: McpSetupActionsProps) {
return (
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : primaryLabel}
</Button>
</DialogFooter>
)
}
export function McpSetupDialog({
open,
status,
busyAction,
manualConfigError,
manualConfigLoading = false,
manualConfigSnippet,
onClose,
onConnect,
onCopyManualConfig,
onDisconnect,
onLoadManualConfig,
}: McpSetupDialogProps) {
const copy = actionCopy(status)
const connectBusy = busyAction === 'connect'
const disconnectBusy = busyAction === 'disconnect'
const buttonsDisabled = busyAction !== null || status === 'checking'
useEffect(() => {
if (open) onLoadManualConfig?.()
}, [open, onLoadManualConfig])
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[520px]" data-testid="mcp-setup-dialog">
@@ -78,43 +183,27 @@ export function McpSetupDialog({
<div>~/.cursor/mcp.json</div>
<div>~/.config/mcp/mcp.json</div>
</div>
<div className="rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground">
<div>type: stdio</div>
<div>command: node</div>
<div>args: &lt;Tolaria resources&gt;/mcp-server/index.js</div>
<div>VAULT_PATH: active vault</div>
<div>WS_UI_PORT: 9711</div>
</div>
<ManualMcpConfigSection
error={manualConfigError}
loading={manualConfigLoading}
onCopy={onCopyManualConfig}
snippet={manualConfigSnippet}
/>
<p>
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
</p>
</div>
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{copy.secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : copy.secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : copy.primaryLabel}
</Button>
</DialogFooter>
<McpSetupActions
buttonsDisabled={buttonsDisabled}
connectBusy={connectBusy}
disconnectBusy={disconnectBusy}
primaryLabel={copy.primaryLabel}
secondaryLabel={copy.secondaryLabel}
onClose={onClose}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
</DialogContent>
</Dialog>
)

View File

@@ -6,14 +6,20 @@ import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
let capturedDragHandleMenu: ComponentType | null = null
vi.mock('@blocknote/react', () => ({
AddBlockButton: () => <button type="button">Add block</button>,
DragHandleMenu: ({ children }: PropsWithChildren) => (
<div data-testid="drag-handle-menu">{children}</div>
),
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
DragHandleButton: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
capturedDragHandleMenu = dragHandleMenu ?? null
return <div data-testid="side-menu" />
return (
<button type="button" draggable>
Open block menu
</button>
)
},
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ children }: PropsWithChildren) => <div data-testid="side-menu">{children}</div>,
TableColumnHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
TableRowHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
useDictionary: () => ({
@@ -32,6 +38,10 @@ describe('TolariaSideMenu', () => {
expect(screen.getByTestId('side-menu')).toBeInTheDocument()
expect(capturedDragHandleMenu).not.toBeNull()
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
'Open block menu',
'Add block',
])
const DragHandleMenuComponent = capturedDragHandleMenu!
render(<DragHandleMenuComponent />)

View File

@@ -1,5 +1,7 @@
import {
AddBlockButton,
DragHandleMenu,
DragHandleButton,
RemoveBlockItem,
SideMenu,
TableColumnHeaderItem,
@@ -21,5 +23,10 @@ function TolariaDragHandleMenu() {
}
export function TolariaSideMenu(props: SideMenuProps) {
return <SideMenu {...props} dragHandleMenu={TolariaDragHandleMenu} />
return (
<SideMenu {...props}>
<DragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
<AddBlockButton />
</SideMenu>
)
}

View File

@@ -149,7 +149,7 @@ function buildMaintenanceCommands({
id: 'install-mcp',
label: mcpStatus === 'installed' ? 'Manage External AI Tools…' : 'Set Up External AI Tools…',
group: 'Settings',
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'],
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'details', 'copy', 'export', 'manual', 'config', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'],
enabled: true,
execute: () => onInstallMcp?.(),
},

View File

@@ -24,6 +24,14 @@ function renderSubject(onToast = vi.fn()) {
return renderHook(() => useMcpStatus('/vault', onToast))
}
function mockClipboard(writeText = vi.fn(() => Promise.resolve())) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
function mockStatusFlow(
initialStatus: 'installed' | 'not_installed',
overrides: Partial<Record<'register_mcp_tools' | 'remove_mcp_tools', unknown>> = {},
@@ -76,6 +84,7 @@ async function runMutationScenario({
describe('useMcpStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
mockClipboard()
})
it('checks the active vault status without auto-registering on mount', async () => {
@@ -160,4 +169,47 @@ describe('useMcpStatus', () => {
expect(mockInvoke).toHaveBeenCalledWith(overrideKey, commandArgs)
expect(onToast).toHaveBeenCalledWith(expect.stringContaining(toastFragment))
})
it('loads the exact manual MCP config snippet for the active vault', async () => {
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
mockCommands({
check_mcp_status: 'installed',
get_mcp_config_snippet: snippet,
})
const { result } = renderSubject()
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
await act(async () => {
await result.current.loadMcpConfigSnippet()
})
expect(result.current.mcpConfigSnippet).toBe(snippet)
expect(result.current.mcpConfigError).toBeNull()
expect(mockInvoke).toHaveBeenCalledWith('get_mcp_config_snippet', { vaultPath: '/vault' })
})
it('copies the manual MCP config snippet to the clipboard', async () => {
const writeText = mockClipboard()
const onToast = vi.fn()
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
mockCommands({
check_mcp_status: 'not_installed',
get_mcp_config_snippet: snippet,
})
const { result } = renderSubject(onToast)
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
await act(async () => {
await expect(result.current.copyMcpConfig()).resolves.toBe(true)
})
expect(writeText).toHaveBeenCalledWith(snippet)
expect(onToast).toHaveBeenCalledWith('Tolaria MCP config copied to clipboard')
})
})

View File

@@ -1,49 +1,136 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export type McpStatus = 'checking' | 'installed' | 'not_installed'
type ManualConfigSnippet = string
type McpCommand =
| 'check_mcp_status'
| 'get_mcp_config_snippet'
| 'register_mcp_tools'
| 'remove_mcp_tools'
type McpCommandResult = string
type McpStatusResponse = string
type ToastHandler = (msg: ToastMessage) => void
type ToastMessage = string
type VaultPath = string
function tauriCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
interface ManualMcpConfigState {
error: ToastMessage | null
loading: boolean
snippet: ManualConfigSnippet | null
vaultPath: VaultPath
}
const EMPTY_MANUAL_CONFIG: ManualMcpConfigState = {
error: null,
loading: false,
snippet: null,
vaultPath: '',
}
function tauriCall<T>(command: McpCommand, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
function normalizeMcpStatus(value: string | null | undefined): McpStatus {
function normalizeMcpStatus(value: McpStatusResponse | null | undefined): McpStatus {
return value === 'installed' ? 'installed' : 'not_installed'
}
async function fetchMcpStatus(vaultPath: string): Promise<McpStatus> {
async function fetchMcpStatus(vaultPath: VaultPath): Promise<McpStatus> {
try {
const result = await tauriCall<string>('check_mcp_status', { vaultPath })
const result = await tauriCall<McpStatusResponse>('check_mcp_status', { vaultPath })
return normalizeMcpStatus(result)
} catch {
return 'not_installed'
}
}
function connectSuccessToast(result: string): string {
function connectSuccessToast(result: McpCommandResult): ToastMessage {
return result === 'registered'
? 'Tolaria external AI tools connected successfully'
: 'Tolaria external AI tools setup refreshed successfully'
}
function disconnectSuccessToast(result: string): string {
function disconnectSuccessToast(result: McpCommandResult): ToastMessage {
return result === 'removed'
? 'Tolaria external AI tools disconnected successfully'
: 'Tolaria external AI tools were already disconnected'
}
function errorMessage(error: unknown): ToastMessage {
return error instanceof Error ? error.message : String(error)
}
function visibleManualConfig(
state: ManualMcpConfigState,
vaultPath: VaultPath,
): ManualMcpConfigState {
return state.vaultPath === vaultPath ? state : EMPTY_MANUAL_CONFIG
}
async function writeClipboardText(value: ManualConfigSnippet): Promise<void> {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API is unavailable')
}
await navigator.clipboard.writeText(value)
}
function useManualMcpConfig(
vaultPath: VaultPath,
onToastRef: MutableRefObject<ToastHandler>,
) {
const [manualConfig, setManualConfig] = useState<ManualMcpConfigState>(EMPTY_MANUAL_CONFIG)
const loadMcpConfigSnippet = useCallback(async () => {
setManualConfig({ error: null, loading: true, snippet: null, vaultPath })
try {
const snippet = await tauriCall<ManualConfigSnippet>('get_mcp_config_snippet', { vaultPath })
setManualConfig({ error: null, loading: false, snippet, vaultPath })
return snippet
} catch (error) {
const message = errorMessage(error)
setManualConfig({ error: message, loading: false, snippet: null, vaultPath })
throw error
}
}, [vaultPath])
const copyMcpConfig = useCallback(async () => {
try {
const snippet = await loadMcpConfigSnippet()
await writeClipboardText(snippet)
onToastRef.current('Tolaria MCP config copied to clipboard')
return true
} catch (error) {
onToastRef.current(`Copy MCP config failed: ${errorMessage(error)}`)
return false
}
}, [loadMcpConfigSnippet, onToastRef])
const currentManualConfig = visibleManualConfig(manualConfig, vaultPath)
return {
mcpConfigSnippet: currentManualConfig.snippet,
mcpConfigLoading: currentManualConfig.loading,
mcpConfigError: currentManualConfig.error,
loadMcpConfigSnippet,
copyMcpConfig,
}
}
/**
* Detects whether the active vault is explicitly connected to external MCP
* clients and exposes connect / disconnect actions.
*/
export function useMcpStatus(
vaultPath: string,
onToast: (msg: string) => void,
vaultPath: VaultPath,
onToast: ToastHandler,
) {
const [status, setStatus] = useState<McpStatus>('checking')
const onToastRef = useRef(onToast)
useEffect(() => { onToastRef.current = onToast })
const manualConfigActions = useManualMcpConfig(vaultPath, onToastRef)
const refreshMcpStatus = useCallback(async () => {
const nextStatus = await fetchMcpStatus(vaultPath)
@@ -91,5 +178,11 @@ export function useMcpStatus(
}
}, [refreshMcpStatus])
return { mcpStatus: status, refreshMcpStatus, connectMcp, disconnectMcp }
return {
mcpStatus: status,
refreshMcpStatus,
connectMcp,
disconnectMcp,
...manualConfigActions,
}
}

View File

@@ -486,6 +486,19 @@ export const mockHandlers: Record<string, (args: any) => any> = {
},
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
get_mcp_config_snippet: (args: { vaultPath?: string }) => JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/mock/Tolaria/mcp-server/index.js'],
env: {
VAULT_PATH: args.vaultPath ?? '/Users/mock/Documents/Getting Started',
WS_UI_PORT: '9711',
},
},
},
}, null, 2),
sync_mcp_bridge_vault: (args: { vaultPath?: string | null }) => args.vaultPath ? 'started' : 'stopped',
repair_vault: (): string => {
mockVaultAiGuidanceStatus = {

View File

@@ -252,6 +252,19 @@ async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo }: Fix
save_vault_list: () => null,
save_settings: () => null,
register_mcp_tools: () => null,
get_mcp_config_snippet: () => JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/fixture/Tolaria/mcp-server/index.js'],
env: {
VAULT_PATH: resolvedVaultPath,
WS_UI_PORT: '9711',
},
},
},
}, null, 2),
reinit_telemetry: () => null,
update_menu_state: () => null,
get_settings: () => ({

View File

@@ -100,7 +100,7 @@ test.describe('AI chat conversation history', () => {
await expect(restoredMessage).toContainText('Keep this thread alive')
await expect(restoredMessage).toContainText('[mock-claude code]')
await page.keyboard.press('Tab')
await page.getByTitle('New AI chat').focus()
await expect(page.getByTitle('New AI chat')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('ai-message')).toHaveCount(0)

View File

@@ -19,10 +19,11 @@ async function blockOuterForText(page: Page, text: string): Promise<Locator> {
return textNode.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " bn-block-outer ")][1]')
}
async function visibleDragHandle(page: Page, block: Locator): Promise<Locator> {
async function visibleLeftBlockHandle(page: Page, block: Locator): Promise<Locator> {
await block.hover()
const handle = page.locator('.bn-side-menu [draggable="true"]').first()
const handle = page.locator('.bn-side-menu button').first()
await expect(handle).toBeVisible({ timeout: 5_000 })
await expect(handle).toHaveAttribute('draggable', 'true')
return handle
}
@@ -59,7 +60,7 @@ test('dragging the left block handle reorders editor blocks', async ({ page }) =
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*This is a test project[\s\S]*Notes/)
const handle = await visibleDragHandle(page, notesHeading)
const handle = await visibleLeftBlockHandle(page, notesHeading)
await dragHandleToBlock(page, handle, paragraph)
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*Notes[\s\S]*This is a test project/)

View File

@@ -0,0 +1,47 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
test.describe('MCP config copy', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/')
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
})
test('copies the active-vault MCP config from the AI panel using only the keyboard', async ({ context, page }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.locator('.app__note-list .cursor-pointer').first().click()
await page.locator('.bn-editor').click()
await sendShortcut(page, 'L', ['Meta', 'Shift'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3_000 })
await page.getByTestId('agent-input').focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Shift+Tab')
const copyButton = page.getByRole('button', { name: 'Copy MCP config' })
await expect(copyButton).toBeFocused()
await copyButton.press('Enter')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('"mcpServers"')
const copiedConfig = await page.evaluate(() => navigator.clipboard.readText())
const parsedConfig = JSON.parse(copiedConfig) as {
mcpServers: {
tolaria: {
args: string[]
command: string
env: Record<string, string>
type: string
}
}
}
const tolariaServer = parsedConfig.mcpServers.tolaria
expect(tolariaServer.type).toBe('stdio')
expect(tolariaServer.command).toBe('node')
expect(tolariaServer.args[0]).toContain('mcp-server/index.js')
expect(tolariaServer.env.VAULT_PATH).toBeTruthy()
expect(tolariaServer.env.WS_UI_PORT).toBe('9711')
})
})

View File

@@ -0,0 +1,80 @@
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
let tempVaultDir: string
function trackUnexpectedErrors(page: Page): string[] {
const errors: string[] = []
page.on('pageerror', (error) => {
errors.push(error.message)
})
page.on('console', (message) => {
if (message.type() !== 'error') return
const text = message.text()
if (text.includes('ws://localhost:9711')) return
errors.push(text)
})
return errors
}
async function createUntitledNote(page: Page): Promise<void> {
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function moveAcrossElement(page: Page, selector: string): Promise<void> {
const target = page.locator(selector).first()
await expect(target).toBeVisible({ timeout: 5_000 })
const box = await target.boundingBox()
expect(box).not.toBeNull()
if (!box) return
const points = [
{ x: box.x + 2, y: box.y + 2 },
{ x: box.x + box.width / 2, y: box.y + box.height / 2 },
{ x: box.x + Math.max(2, box.width - 2), y: box.y + Math.max(2, box.height - 2) },
]
for (const point of points) {
await page.mouse.move(point.x, point.y, { steps: 4 })
}
}
test.describe('table hover crash regression', () => {
test.beforeEach(({ page }, testInfo) => {
void page
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('moving through table wrappers, cells, and nearby text keeps the editor stable', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await seedBlockNoteTable(page, [180, 120, 120])
await expect(page.locator('div.tableWrapper')).toBeVisible({ timeout: 5_000 })
await moveAcrossElement(page, 'div.tableWrapper')
await page.locator('table th').first().hover()
await page.locator('table td').first().hover()
const trailingParagraph = page.locator('.bn-editor [data-content-type="paragraph"]').last()
await trailingParagraph.hover()
await trailingParagraph.click()
await page.keyboard.type('stable after table hover')
const editor = page.getByRole('textbox').last()
await expect(editor).toContainText('stable after table hover')
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
})

View File

@@ -14098,6 +14098,113 @@
]
}
]
},
{
"type": "frame",
"id": "mcp_config_copy_design",
"x": 0,
"y": 12580,
"name": "MCP Config Copy — Light",
"theme": {
"Mode": "Light"
},
"width": 1440,
"fill": "$--background",
"layout": "vertical",
"gap": 20,
"padding": 40,
"children": [
{
"type": "text",
"id": "mcp_config_copy_title",
"fill": "$--foreground",
"content": "MCP Config Copy",
"fontFamily": "Inter",
"fontSize": 24,
"fontWeight": "700",
"letterSpacing": 0
},
{
"type": "text",
"id": "mcp_config_copy_description",
"fill": "$--muted-foreground",
"content": "AI panel exposes a compact Copy MCP config action; setup dialog shows the exact generated JSON with a keyboard-focusable code block and copy button.",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "normal",
"letterSpacing": 0
},
{
"type": "frame",
"id": "mcp_config_copy_ai_header",
"name": "AI panel header action",
"width": 520,
"height": 52,
"fill": "$--background",
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "$--border"
},
"children": [
{
"type": "text",
"id": "mcp_config_copy_ai_label",
"fill": "$--muted-foreground",
"content": "AI Agent · Claude Code",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600",
"letterSpacing": 0
},
{
"type": "rectangle",
"id": "mcp_config_copy_icon_button",
"name": "Copy MCP config icon button",
"x": 440,
"y": 14,
"width": 24,
"height": 24,
"cornerRadius": 6,
"fill": "$--muted"
}
]
},
{
"type": "frame",
"id": "mcp_config_copy_manual_block",
"name": "Manual MCP config block",
"width": 520,
"fill": "$--background",
"layout": "vertical",
"gap": 8,
"children": [
{
"type": "text",
"id": "mcp_config_copy_manual_title",
"fill": "$--foreground",
"content": "Manual MCP config",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600",
"letterSpacing": 0
},
{
"type": "rectangle",
"id": "mcp_config_copy_code_block",
"width": 520,
"height": 148,
"cornerRadius": 8,
"fill": "$--background",
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "$--border"
}
}
]
}
]
}
],
"themes": {