Files
hikari-desktop/src/lib/components/McpManagementPanel.svelte
T
hikari f173892aaa
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
feat: major feature additions and improvements (#135)
## Summary

This PR includes major feature additions, bug fixes, comprehensive testing improvements, and responsive design enhancements!

## New Features โœจ

### Plugin & MCP Management (#133, #134)
- **Plugin Management Panel**: Install, uninstall, enable/disable, and update plugins
- **MCP Server Management Panel**: Add/remove MCP servers, view detailed configuration
- **Marketplace Management**: Add/remove plugin marketplaces from GitHub
- Backend commands for full CLI integration (`list_plugins`, `install_plugin`, `add_mcp_server`, etc.)
- Beautiful UI with proper loading states, error handling, and theme support

### Visual Todo List Panel (#132)
- Real-time todo list display when Hikari uses the `TodoWrite` tool
- Shows pending/in-progress/completed status with visual indicators
- Progress bar and completion count
- Automatically clears on disconnect
- Theme-aware styling

### Clear Session History Button (#130)
- "Clear All Sessions" button in Session History panel
- Confirmation dialog with session count
- Keyboard support and accessibility features
- Gives users control over disk usage

### CLI Version Display (#131)
- Displays Claude CLI version in status bar
- Auto-polls every 30 seconds for updates
- Useful for debugging and feature compatibility

## Bug Fixes ๐Ÿ›

### Stats Panel Scrolling (#136)
- **Fixed stats panel overflow**: Added scrollable container with `max-height` constraint
- Stats panel now scrolls when content (Tools Used, Historical Costs, Budget sections) gets too long
- Prevents content from overflowing off screen

### Agent Monitor Fixes (#122)
- **Fixed agents stuck in "running" state**: Added `SubagentStop` hook parsing
- **Fixed agents persisting after disconnect**: Call `clearConversation()` on disconnect
- **Fixed "Kill All" button**: Now properly marks all agents as errored
- **Fixed badge persisting after tab close**: Cleanup agents when conversation is deleted
- Comprehensive tests for agent lifecycle management

### Discord RPC Cleanup (#129)
- Removed file-based logging for Discord RPC
- Replaced with proper `tracing` framework usage
- Reduces disk usage and eliminates maintenance burden

### Close Modal Bug Fix (#128)
- Fixed close confirmation modal not triggering after Discord RPC refactor
- Removed frontend calls to deleted `log_discord_rpc` command
- Modal now works correctly after all operations

### Responsive Design Fixes (#118)
- Fixed top navigation icons getting cut off at small screen widths
- Fixed Connect button disappearing on narrow screens
- Fixed bottom status info (clock, CLI version) getting cut off
- Added flex-wrap and mobile-optimised layouts
- Icons-only mode on screens < 640px
- Vertical stacking on screens < 768px

## Testing Improvements ๐Ÿงช

### Comprehensive Test Coverage (#114)
- **417 backend tests** (up from 408)
- **387 frontend tests** (up from 363)
- **61%+ backend code coverage**
- Added E2E integration tests for cross-platform notification commands
- New test files: `agents.test.ts`, comprehensive CLI parsing tests
- Tests for `debug_logger.rs`, `bridge_manager.rs`, `notifications.rs`
- Console mocking for cleaner test output
- Fixed flaky frontend tests

### Testing Documentation
- Updated CLAUDE.md with comprehensive testing guidelines
- Documented mocking approaches (console mocking, E2E command structure testing)
- Added step-by-step guide for adding tests to new features
- Goal to maintain ~100% test coverage documented

## Closes

Closes #114
Closes #118
Closes #122
Closes #128
Closes #129
Closes #130
Closes #131
Closes #132
Closes #133
Closes #134
Closes #136

## Technical Details

- All new backend commands properly registered in `lib.rs`
- CLI output parsing with comprehensive test coverage
- Cross-platform compatibility verified through E2E tests (Linux CI can test Windows commands)
- Theme-aware UI components using CSS variables throughout
- Proper TypeScript types for all new stores and components
- ESLint and Prettier compliant
- All Clippy warnings addressed

โœจ This PR was created with help from Hikari~ ๐ŸŒธ

Reviewed-on: #135
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-07 21:15:41 -08:00

434 lines
15 KiB
Svelte

<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
import { Trash2, RefreshCw, Server, Globe, Terminal } from "lucide-svelte";
interface Props {
onClose: () => void;
}
interface McpServerInfo {
name: string;
command: string | null;
url: string | null;
transport: string; // "stdio", "http", or "sse"
env: Record<string, string> | null;
status: string | null; // "Connected" or "Failed to connect"
}
const { onClose }: Props = $props();
let servers = $state<McpServerInfo[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let selectedServer = $state<McpServerInfo | null>(null);
let isLoadingDetails = $state(false);
let actionInProgress = $state<string | null>(null);
let showAddForm = $state(false);
let serverDetails = $state<string>("");
// Add server form fields
let newServerName = $state("");
let newServerUrl = $state("");
let newServerTransport = $state("stdio");
let isAdding = $state(false);
async function loadServers(): Promise<void> {
try {
isLoading = true;
error = null;
servers = await invoke<McpServerInfo[]>("list_mcp_servers");
} catch (e) {
error = `Failed to load MCP servers: ${e}`;
console.error(error);
} finally {
isLoading = false;
}
}
async function loadServerDetails(name: string): Promise<void> {
try {
isLoadingDetails = true;
error = null;
selectedServer = await invoke<McpServerInfo>("get_mcp_server", { name });
serverDetails = await invoke<string>("get_mcp_server_details", { name });
} catch (e) {
error = `Failed to load server details: ${e}`;
console.error(error);
} finally {
isLoadingDetails = false;
}
}
async function removeServer(name: string): Promise<void> {
try {
actionInProgress = name;
error = null;
await invoke("remove_mcp_server", { name });
if (selectedServer?.name === name) {
selectedServer = null;
serverDetails = "";
}
await loadServers();
} catch (e) {
error = `Failed to remove server: ${e}`;
console.error(error);
} finally {
actionInProgress = null;
}
}
async function addServer(): Promise<void> {
if (!newServerName.trim() || !newServerUrl.trim()) return;
try {
isAdding = true;
error = null;
await invoke("add_mcp_server", {
name: newServerName.trim(),
commandOrUrl: newServerUrl.trim(),
transport: newServerTransport,
envVars: null,
headers: null,
});
newServerName = "";
newServerUrl = "";
newServerTransport = "stdio";
showAddForm = false;
await loadServers();
} catch (e) {
error = `Failed to add server: ${e}`;
console.error(error);
} finally {
isAdding = false;
}
}
function getTransportIcon(transport: string) {
switch (transport) {
case "http":
return Globe;
case "stdio":
return Terminal;
case "sse":
return Server;
default:
return Server;
}
}
function getTransportColor(transport: string) {
switch (transport) {
case "http":
return "text-blue-400";
case "stdio":
return "text-green-400";
case "sse":
return "text-purple-400";
default:
return "text-[var(--text-secondary)]";
}
}
onMount(() => {
loadServers();
});
</script>
<div
class="fixed top-0 right-0 h-full w-[700px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
<div class="flex items-center gap-3">
<div class="text-[var(--accent-primary)]">
<Server class="w-6 h-6" />
</div>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Management</h2>
<p class="text-xs text-[var(--text-secondary)]">
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
</p>
</div>
</div>
<button
onclick={onClose}
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
aria-label="Close MCP panel"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- Add Server Button -->
<div class="p-4 border-b border-[var(--border-color)]">
<button
onclick={() => (showAddForm = !showAddForm)}
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 transition-opacity flex items-center justify-center gap-2"
>
<Server class="w-4 h-4" />
{showAddForm ? "Cancel" : "Add New Server"}
</button>
</div>
<!-- Add Server Form -->
{#if showAddForm}
<div
class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg"
>
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Server Name</label
>
<input
type="text"
bind:value={newServerName}
placeholder="my-server"
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Transport</label
>
<select
bind:value={newServerTransport}
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
>
<option value="stdio">STDIO</option>
<option value="http">HTTP</option>
<option value="sse">SSE</option>
</select>
</div>
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1">
{newServerTransport === "stdio" ? "Command" : "URL"}
</label>
<input
type="text"
bind:value={newServerUrl}
placeholder={newServerTransport === "stdio"
? "npx my-mcp-server"
: "https://mcp.example.com"}
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<button
onclick={addServer}
disabled={isAdding || !newServerName.trim() || !newServerUrl.trim()}
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{#if isAdding}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Server class="w-4 h-4" />
{/if}
Add Server
</button>
</div>
</div>
{/if}
<!-- Error Display -->
{#if error}
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-sm text-red-400">{error}</p>
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 flex gap-4">
<!-- Server List -->
<div class="flex-1">
{#if isLoading}
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
<RefreshCw class="w-8 h-8 animate-spin" />
</div>
{:else if servers.length === 0}
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
<Server class="w-16 h-16 mb-4 opacity-50" />
<p class="text-center">No MCP servers configured</p>
<p class="text-sm text-center mt-2">Add servers via Settings</p>
</div>
{:else}
<div class="space-y-2">
{#each servers as server (server.name)}
<button
onclick={() => loadServerDetails(server.name)}
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
class:border-[var(--accent-primary)]={selectedServer?.name === server.name}
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
<svelte:component
this={getTransportIcon(server.transport)}
class="w-4 h-4 {getTransportColor(server.transport)}"
/>
{server.name}
{#if server.status}
{#if server.status.includes("Connected")}
<span
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
>
โœ“
</span>
{:else}
<span
class="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded border border-red-500/30"
>
โœ—
</span>
{/if}
{/if}
</h4>
<p class="text-xs text-[var(--text-secondary)] mt-1">
{server.transport.toUpperCase()}
{#if server.url}
โ€ข {server.url}
{:else if server.command}
โ€ข {server.command}
{/if}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
<!-- Server Details Panel -->
{#if selectedServer}
<div
class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]"
>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
{#if isLoadingDetails}
<div class="flex items-center justify-center h-32">
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
</div>
{:else}
<div class="space-y-4">
<!-- Name -->
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Name</label
>
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
</div>
<!-- Transport -->
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Transport</label
>
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
<svelte:component
this={getTransportIcon(selectedServer.transport)}
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
/>
{selectedServer.transport.toUpperCase()}
</p>
</div>
<!-- URL or Command -->
{#if selectedServer.url}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>URL</label
>
<p
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
{selectedServer.url}
</p>
</div>
{/if}
{#if selectedServer.command}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Command</label
>
<p
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
{selectedServer.command}
</p>
</div>
{/if}
<!-- Environment Variables -->
{#if selectedServer.env}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Environment</label
>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
selectedServer.env,
null,
2
)}</pre>
</div>
{/if}
<!-- Full Server Details -->
{#if serverDetails}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Full Details</label
>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
</div>
{/if}
<!-- Actions -->
<div class="pt-4 border-t border-[var(--border-color)]">
<button
onclick={() => selectedServer && removeServer(selectedServer.name)}
disabled={actionInProgress === selectedServer?.name}
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Trash2 class="w-4 h-4" />
Remove Server
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>