feat: enhance API key management and GitHub stars integration

- Introduced ApiKeyService for managing API keys, including listing, saving, deleting, and looking up values.
- Added IPC channels for API key operations and integrated them into the Electron API.
- Implemented GitHub stars fetching for MCP servers, enhancing visibility of repository popularity.
- Updated UI components to display GitHub stars and API key management features, including a dedicated API Keys tab.
- Enhanced McpInstallService to support custom MCP server installations with improved validation and error handling.
- Refactored various components to accommodate new features and improve user experience.
This commit is contained in:
iliya 2026-03-10 20:05:04 +02:00
parent 1949084bd8
commit a4210936f9
47 changed files with 3060 additions and 220 deletions

View file

@ -22,10 +22,11 @@
## What is this
A new approach to task management with AI agents.
A new approach to task management with AI agent teams.
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments
- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task

View file

@ -73,6 +73,7 @@ import {
UpdaterService,
} from './services';
import {
ApiKeyService,
ExtensionFacadeService,
GlamaMcpEnrichmentService,
McpCatalogAggregator,
@ -720,6 +721,7 @@ function initializeServices(): void {
// Install services — pass null for binary (uses PATH lookup via execCli fallback)
const pluginInstallService = new PluginInstallService(null, pluginCatalogService);
const mcpInstallService = new McpInstallService(null, mcpAggregator);
const apiKeyService = new ApiKeyService();
// warmup() and ensureInstalled() are deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup.
httpServer = new HttpServer();
@ -780,6 +782,7 @@ function initializeServices(): void {
extensionFacadeService,
pluginInstallService,
mcpInstallService,
apiKeyService,
crossTeamService
);

View file

@ -8,9 +8,14 @@
import { createLogger } from '@shared/utils/logger';
import type {
ApiKeyEntry,
ApiKeyLookupResult,
ApiKeySaveRequest,
ApiKeyStorageStatus,
EnrichedPlugin,
InstalledMcpEntry,
McpCatalogItem,
McpCustomInstallRequest,
McpInstallRequest,
McpSearchResult,
OperationResult,
@ -21,12 +26,20 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
import {
API_KEYS_DELETE,
API_KEYS_LIST,
API_KEYS_LOOKUP,
API_KEYS_SAVE,
API_KEYS_STORAGE_STATUS,
MCP_GITHUB_STARS,
MCP_REGISTRY_BROWSE,
MCP_REGISTRY_GET_BY_ID,
MCP_REGISTRY_GET_INSTALLED,
MCP_REGISTRY_INSTALL,
MCP_REGISTRY_INSTALL_CUSTOM,
MCP_REGISTRY_SEARCH,
MCP_REGISTRY_UNINSTALL,
PLUGIN_GET_ALL,
@ -35,6 +48,8 @@ import {
PLUGIN_UNINSTALL,
} from '@preload/constants/ipcChannels';
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
const logger = createLogger('IPC:extensions');
/** Allowed scope values */
@ -42,20 +57,25 @@ const VALID_SCOPES = new Set(['local', 'user', 'project']);
// ── Module state ───────────────────────────────────────────────────────────
const starsService = new GitHubStarsService();
let extensionFacade: ExtensionFacadeService | null = null;
let pluginInstaller: PluginInstallService | null = null;
let mcpInstaller: McpInstallService | null = null;
let apiKeyService: ApiKeyService | null = null;
// ── Lifecycle ──────────────────────────────────────────────────────────────
export function initializeExtensionHandlers(
facade: ExtensionFacadeService,
pluginInstall?: PluginInstallService,
mcpInstall?: McpInstallService
mcpInstall?: McpInstallService,
apiKeys?: ApiKeyService
): void {
extensionFacade = facade;
pluginInstaller = pluginInstall ?? null;
mcpInstaller = mcpInstall ?? null;
apiKeyService = apiKeys ?? null;
}
export function registerExtensionHandlers(ipcMain: IpcMain): void {
@ -68,7 +88,14 @@ export function registerExtensionHandlers(ipcMain: IpcMain): void {
ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById);
ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled);
ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall);
ipcMain.handle(MCP_REGISTRY_INSTALL_CUSTOM, handleMcpInstallCustom);
ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall);
ipcMain.handle(API_KEYS_LIST, handleApiKeysList);
ipcMain.handle(API_KEYS_SAVE, handleApiKeysSave);
ipcMain.handle(API_KEYS_DELETE, handleApiKeysDelete);
ipcMain.handle(API_KEYS_LOOKUP, handleApiKeysLookup);
ipcMain.handle(API_KEYS_STORAGE_STATUS, handleApiKeysStorageStatus);
ipcMain.handle(MCP_GITHUB_STARS, handleMcpGitHubStars);
}
export function removeExtensionHandlers(ipcMain: IpcMain): void {
@ -81,7 +108,14 @@ export function removeExtensionHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID);
ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED);
ipcMain.removeHandler(MCP_REGISTRY_INSTALL);
ipcMain.removeHandler(MCP_REGISTRY_INSTALL_CUSTOM);
ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL);
ipcMain.removeHandler(API_KEYS_LIST);
ipcMain.removeHandler(API_KEYS_SAVE);
ipcMain.removeHandler(API_KEYS_DELETE);
ipcMain.removeHandler(API_KEYS_LOOKUP);
ipcMain.removeHandler(API_KEYS_STORAGE_STATUS);
ipcMain.removeHandler(MCP_GITHUB_STARS);
}
// ── Service guard ──────────────────────────────────────────────────────────
@ -270,6 +304,28 @@ async function handleMcpInstall(
});
}
async function handleMcpInstallCustom(
_event: IpcMainInvokeEvent,
request?: McpCustomInstallRequest
): Promise<IpcResult<OperationResult>> {
return wrapHandler('mcpInstallCustom', async () => {
if (!request || typeof request.serverName !== 'string' || !request.serverName) {
throw new Error('Invalid custom install request: serverName is required');
}
if (!request.installSpec) {
throw new Error('Invalid custom install request: installSpec is required');
}
if (request.scope && !VALID_SCOPES.has(request.scope)) {
throw new Error(`Invalid scope: "${request.scope}"`);
}
const result = await getMcpInstaller().installCustom(request);
if (result.state === 'success') {
getFacade().invalidateInstalledCache();
}
return result;
});
}
async function handleMcpUninstall(
_event: IpcMainInvokeEvent,
name?: string,
@ -294,3 +350,62 @@ async function handleMcpUninstall(
return result;
});
}
// ── API Keys Guard ─────────────────────────────────────────────────────────
function getApiKeyService(): ApiKeyService {
if (!apiKeyService) throw new Error('API key service not initialized');
return apiKeyService;
}
// ── API Keys Handlers ──────────────────────────────────────────────────────
async function handleApiKeysList(): Promise<IpcResult<ApiKeyEntry[]>> {
return wrapHandler('apiKeysList', () => getApiKeyService().list());
}
async function handleApiKeysSave(
_event: IpcMainInvokeEvent,
request?: ApiKeySaveRequest
): Promise<IpcResult<ApiKeyEntry>> {
return wrapHandler('apiKeysSave', () => {
if (!request) throw new Error('Request is required');
return getApiKeyService().save(request);
});
}
async function handleApiKeysDelete(
_event: IpcMainInvokeEvent,
id?: string
): Promise<IpcResult<void>> {
return wrapHandler('apiKeysDelete', () => {
if (typeof id !== 'string' || !id) throw new Error('Key ID is required');
return getApiKeyService().delete(id);
});
}
async function handleApiKeysLookup(
_event: IpcMainInvokeEvent,
envVarNames?: string[]
): Promise<IpcResult<ApiKeyLookupResult[]>> {
return wrapHandler('apiKeysLookup', () => {
if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required');
return getApiKeyService().lookup(envVarNames);
});
}
async function handleApiKeysStorageStatus(): Promise<IpcResult<ApiKeyStorageStatus>> {
return wrapHandler('apiKeysStorageStatus', () => getApiKeyService().getStorageStatus());
}
// ── GitHub Stars Handler ──────────────────────────────────────────────────
async function handleMcpGitHubStars(
_event: IpcMainInvokeEvent,
repositoryUrls?: string[]
): Promise<IpcResult<Record<string, number>>> {
return wrapHandler('mcpGitHubStars', () => {
if (!Array.isArray(repositoryUrls)) throw new Error('repositoryUrls array is required');
return starsService.fetchStars(repositoryUrls);
});
}

View file

@ -107,6 +107,7 @@ import type { CrossTeamService } from '../services/team/CrossTeamService';
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
import type { SchedulerService } from '../services/schedule/SchedulerService';
/**
@ -139,6 +140,7 @@ export function initializeIpcHandlers(
extensionFacade?: ExtensionFacadeService,
pluginInstaller?: PluginInstallService,
mcpInstaller?: McpInstallService,
apiKeyService?: ApiKeyService,
crossTeamService?: CrossTeamService
): void {
// Initialize domain handlers with registry
@ -175,7 +177,7 @@ export function initializeIpcHandlers(
initializeScheduleHandlers(schedulerService);
}
if (extensionFacade) {
initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller);
initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller, apiKeyService);
}
if (crossTeamService) {
initializeCrossTeamHandlers(crossTeamService);

View file

@ -0,0 +1,398 @@
/**
* ApiKeyService encrypted API key storage with CRUD operations.
*
* Encryption strategy (in priority order):
* 1. Electron safeStorage (OS keychain: macOS Keychain / Windows DPAPI / Linux gnome-libsecret/kwallet)
* 2. AES-256-GCM with machine-derived key (Linux fallback when no keyring is available)
*
* File permissions: 0o600 (owner read/write only) on Unix systems.
* Storage file: ~/.claude/api-keys.json
*/
import { safeStorage } from 'electron';
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import os from 'node:os';
import { createLogger } from '@shared/utils/logger';
import type {
ApiKeyEntry,
ApiKeyLookupResult,
ApiKeySaveRequest,
ApiKeyStorageStatus,
} from '@shared/types/extensions';
const logger = createLogger('Extensions:ApiKeys');
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
/** How the value was encrypted on disk */
type EncryptionMethod = 'safeStorage' | 'aes-local' | 'base64';
interface StoredApiKey {
id: string;
name: string;
envVarName: string;
encryptedValue: string;
/** @deprecated Use encryptionMethod instead. Kept for migration. */
encrypted?: boolean;
encryptionMethod?: EncryptionMethod;
scope: 'user' | 'project';
createdAt: string;
updatedAt?: string;
}
/** AES-256-GCM constants */
const AES_IV_BYTES = 12;
const AES_TAG_BYTES = 16;
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_KEY_BYTES = 32;
const PBKDF2_SALT = 'claude-apikey-storage-v1';
export class ApiKeyService {
private readonly filePath: string;
private cache: StoredApiKey[] | null = null;
private aesKey: Buffer | null = null;
constructor(claudeDir?: string) {
const baseDir = claudeDir ?? path.join(os.homedir(), '.claude');
this.filePath = path.join(baseDir, 'api-keys.json');
}
// ── Public API ──────────────────────────────────────────────────────────
async list(): Promise<ApiKeyEntry[]> {
const keys = await this.readStore();
return keys.map((k) => ({
id: k.id,
name: k.name,
envVarName: k.envVarName,
maskedValue: this.mask(this.decrypt(k)),
scope: k.scope,
createdAt: k.createdAt,
}));
}
async save(request: ApiKeySaveRequest): Promise<ApiKeyEntry> {
if (!request.name?.trim()) throw new Error('Key name is required');
if (!request.envVarName?.trim()) throw new Error('Environment variable name is required');
if (!ENV_KEY_RE.test(request.envVarName)) {
throw new Error(
`Invalid env var name: "${request.envVarName}". Use uppercase letters, digits, underscores.`
);
}
if (!request.value) throw new Error('Key value is required');
const keys = await this.readStore();
const now = new Date().toISOString();
const { method, value } = this.encrypt(request.value);
if (request.id) {
const idx = keys.findIndex((k) => k.id === request.id);
if (idx === -1) throw new Error(`API key not found: ${request.id}`);
keys[idx] = {
...keys[idx],
name: request.name.trim(),
envVarName: request.envVarName.trim(),
encryptedValue: value,
encryptionMethod: method,
scope: request.scope,
updatedAt: now,
};
// Remove legacy field
delete keys[idx].encrypted;
} else {
keys.push({
id: crypto.randomUUID(),
name: request.name.trim(),
envVarName: request.envVarName.trim(),
encryptedValue: value,
encryptionMethod: method,
scope: request.scope,
createdAt: now,
});
}
await this.writeStore(keys);
const saved = keys[request.id ? keys.findIndex((k) => k.id === request.id) : keys.length - 1];
return {
id: saved.id,
name: saved.name,
envVarName: saved.envVarName,
maskedValue: this.mask(request.value),
scope: saved.scope,
createdAt: saved.createdAt,
};
}
async delete(id: string): Promise<void> {
const keys = await this.readStore();
const filtered = keys.filter((k) => k.id !== id);
if (filtered.length === keys.length) throw new Error(`API key not found: ${id}`);
await this.writeStore(filtered);
}
async lookup(envVarNames: string[]): Promise<ApiKeyLookupResult[]> {
if (!envVarNames.length) return [];
const keys = await this.readStore();
const nameSet = new Set(envVarNames);
return keys
.filter((k) => nameSet.has(k.envVarName))
.map((k) => ({
envVarName: k.envVarName,
value: this.decrypt(k),
}));
}
async getStorageStatus(): Promise<ApiKeyStorageStatus> {
const secure = this.isSecureBackend();
const backend = this.getBackendName();
let fileSecure = true;
if (process.platform !== 'win32') {
try {
const stat = await fs.stat(this.filePath);
fileSecure = (stat.mode & 0o077) === 0;
} catch {
// File doesn't exist yet — considered secure
}
}
return {
encryptionMethod: secure ? 'os-keychain' : 'aes-local',
backend,
fileSecure,
};
}
// ── Encryption ──────────────────────────────────────────────────────────
/**
* Check if the OS provides a real secure backend (not basic_text).
* On Linux, safeStorage.isEncryptionAvailable() returns true even with basic_text
* backend (hardcoded password), so we must check the actual backend name.
*/
private isSecureBackend(): boolean {
try {
if (!safeStorage.isEncryptionAvailable()) return false;
if (process.platform === 'linux') {
const backend = safeStorage.getSelectedStorageBackend();
return backend !== 'basic_text' && backend !== 'unknown';
}
return true; // macOS Keychain / Windows DPAPI are always reliable
} catch {
return false;
}
}
private getBackendName(): string {
if (process.platform === 'darwin') return 'macOS Keychain';
if (process.platform === 'win32') return 'Windows DPAPI';
try {
return safeStorage.getSelectedStorageBackend();
} catch {
return 'unknown';
}
}
private encrypt(value: string): { method: EncryptionMethod; value: string } {
// Try OS keychain first
if (this.isSecureBackend()) {
try {
const buf = safeStorage.encryptString(value);
return { method: 'safeStorage', value: buf.toString('base64') };
} catch (err) {
logger.warn('safeStorage encryption failed, falling back to AES-local:', err);
}
}
// Fallback: AES-256-GCM with machine-derived key
return { method: 'aes-local', value: this.encryptAesLocal(value) };
}
private decrypt(stored: StoredApiKey): string {
try {
const method = this.resolveMethod(stored);
switch (method) {
case 'safeStorage': {
const buf = Buffer.from(stored.encryptedValue, 'base64');
return safeStorage.decryptString(buf);
}
case 'aes-local':
return this.decryptAesLocal(stored.encryptedValue);
case 'base64':
return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8');
}
} catch (err) {
logger.error(`Failed to decrypt API key "${stored.name}":`, err);
return '';
}
}
/** Resolve encryption method, handling legacy entries without encryptionMethod field */
private resolveMethod(stored: StoredApiKey): EncryptionMethod {
if (stored.encryptionMethod) return stored.encryptionMethod;
// Legacy migration: encrypted boolean → method
return stored.encrypted ? 'safeStorage' : 'base64';
}
// ── AES-256-GCM local encryption ───────────────────────────────────────
/**
* Derive an AES key from machine-specific info.
* Not as secure as OS keychain (extractable with root), but far better than plaintext.
* Protects against casual read by other users.
*/
private getAesKey(): Buffer {
if (this.aesKey) return this.aesKey;
const material = [os.hostname(), os.userInfo().username, this.getMachineId()].join(':');
this.aesKey = crypto.pbkdf2Sync(
material,
PBKDF2_SALT,
PBKDF2_ITERATIONS,
PBKDF2_KEY_BYTES,
'sha512'
);
return this.aesKey;
}
private getMachineId(): string {
if (process.platform !== 'linux') {
return `${process.platform}-${os.arch()}-${os.cpus()[0]?.model ?? 'unknown'}`;
}
// Linux: try /etc/machine-id (systemd) or /var/lib/dbus/machine-id
const candidates = ['/etc/machine-id', '/var/lib/dbus/machine-id'];
for (const p of candidates) {
try {
// Synchronous read is OK — called once, result cached in aesKey
const { readFileSync } = require('node:fs') as typeof import('node:fs');
const id = readFileSync(p, 'utf-8').trim();
if (id) return id;
} catch {
// Try next candidate
}
}
return `linux-${os.arch()}-${os.cpus()[0]?.model ?? 'unknown'}`;
}
private encryptAesLocal(value: string): string {
const key = this.getAesKey();
const iv = crypto.randomBytes(AES_IV_BYTES);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]);
const tag = cipher.getAuthTag();
// Format: iv:tag:ciphertext (all base64)
return [iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
}
private decryptAesLocal(stored: string): string {
const parts = stored.split(':');
if (parts.length !== 3) throw new Error('Invalid AES-local format');
const [ivB64, tagB64, dataB64] = parts;
const key = this.getAesKey();
const iv = Buffer.from(ivB64, 'base64');
const tag = Buffer.from(tagB64, 'base64');
const data = Buffer.from(dataB64, 'base64');
if (iv.length !== AES_IV_BYTES) throw new Error('Invalid IV length');
if (tag.length !== AES_TAG_BYTES) throw new Error('Invalid auth tag length');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return decipher.update(data).toString('utf-8') + decipher.final('utf-8');
}
// ── Helpers ─────────────────────────────────────────────────────────────
private mask(value: string): string {
if (value.length <= 3) return '***';
return '*'.repeat(Math.min(value.length - 3, 20)) + value.slice(-3);
}
// ── Storage I/O ─────────────────────────────────────────────────────────
private async readStore(): Promise<StoredApiKey[]> {
if (this.cache) return this.cache;
try {
const raw = await fs.readFile(this.filePath, 'utf-8');
const data = JSON.parse(raw);
const keys: StoredApiKey[] = Array.isArray(data) ? data : [];
// Fix file permissions if insecure
await this.ensureFilePermissions();
// Migrate legacy entries (base64 → current method)
const migrated = this.migrateKeys(keys);
if (migrated) {
await this.writeStore(keys);
}
this.cache = keys;
} catch {
this.cache = [];
}
return this.cache;
}
/**
* Migrate legacy entries: re-encrypt base64 entries with current best method.
* Returns true if any entries were migrated.
*/
private migrateKeys(keys: StoredApiKey[]): boolean {
let migrated = false;
for (let i = 0; i < keys.length; i++) {
const method = this.resolveMethod(keys[i]);
if (method === 'base64') {
try {
const plaintext = Buffer.from(keys[i].encryptedValue, 'base64').toString('utf-8');
if (!plaintext) continue;
const { method: newMethod, value } = this.encrypt(plaintext);
keys[i] = {
...keys[i],
encryptedValue: value,
encryptionMethod: newMethod,
};
delete keys[i].encrypted;
migrated = true;
logger.info(`Migrated API key "${keys[i].name}" from base64 to ${newMethod}`);
} catch (err) {
logger.warn(`Failed to migrate API key "${keys[i].name}":`, err);
}
}
}
return migrated;
}
private async writeStore(keys: StoredApiKey[]): Promise<void> {
const dir = path.dirname(this.filePath);
await fs.mkdir(dir, { recursive: true });
const tmpPath = this.filePath + `.tmp.${crypto.randomUUID()}`;
await fs.writeFile(tmpPath, JSON.stringify(keys, null, 2), 'utf-8');
// Set restrictive permissions before rename (Unix only)
if (process.platform !== 'win32') {
try {
await fs.chmod(tmpPath, 0o600);
} catch {
// Best-effort — Windows or restricted FS
}
}
await fs.rename(tmpPath, this.filePath);
this.cache = keys;
}
/** Ensure the storage file has owner-only permissions */
private async ensureFilePermissions(): Promise<void> {
if (process.platform === 'win32') return;
try {
const stat = await fs.stat(this.filePath);
if ((stat.mode & 0o077) !== 0) {
logger.warn('API keys file has insecure permissions, fixing to 0600');
await fs.chmod(this.filePath, 0o600);
}
} catch {
// File may not exist yet
}
}
}

View file

@ -0,0 +1,184 @@
/**
* GitHubStarsService fetches stargazer counts from the GitHub public API.
*
* - Batch interface: accepts repository URLs, returns map of URL stars
* - In-memory cache with 1-hour TTL
* - Concurrency-limited to 5 parallel requests
* - Silent failure: 404, timeout, rate-limit entry skipped
*/
import https from 'node:https';
import { createLogger } from '@shared/utils/logger';
import { parseGitHubOwnerRepo } from '@shared/utils/extensionNormalizers';
const logger = createLogger('Extensions:GitHubStars');
// ── Constants ──────────────────────────────────────────────────────────────
const CACHE_TTL_MS = 60 * 60_000; // 1 hour
const HTTP_TIMEOUT_MS = 10_000; // 10 seconds
const MAX_CONCURRENCY = 5;
const MAX_BODY_SIZE = 256 * 1024; // 256KB (GitHub repo JSON is ~10KB)
// ── Cache entry ────────────────────────────────────────────────────────────
interface CacheEntry {
stars: number;
fetchedAt: number;
}
// ── Service ────────────────────────────────────────────────────────────────
export class GitHubStarsService {
private cache = new Map<string, CacheEntry>();
/**
* Fetch GitHub stars for a batch of repository URLs.
* Returns `Record<repositoryUrl, starCount>` only includes URLs with valid results.
*/
async fetchStars(repositoryUrls: string[]): Promise<Record<string, number>> {
const result: Record<string, number> = {};
const tasks: Array<{ url: string; owner: string; repo: string }> = [];
for (const url of repositoryUrls) {
const parsed = parseGitHubOwnerRepo(url);
if (!parsed) continue;
const cacheKey = `${parsed.owner}/${parsed.repo}`.toLowerCase();
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
result[url] = cached.stars;
continue;
}
tasks.push({ url, owner: parsed.owner, repo: parsed.repo });
}
if (tasks.length === 0) return result;
// Fetch with concurrency limit
const outcomes = await this.withConcurrencyLimit(
tasks.map(({ url, owner, repo }) => async () => {
const stars = await this.fetchSingle(owner, repo);
if (stars != null) {
const cacheKey = `${owner}/${repo}`.toLowerCase();
this.cache.set(cacheKey, { stars, fetchedAt: Date.now() });
result[url] = stars;
}
}),
MAX_CONCURRENCY
);
// Log any failures for debugging (already silent to caller)
const failures = outcomes.filter((o) => o === 'error').length;
if (failures > 0) {
logger.debug(
`GitHub stars: ${tasks.length - failures}/${tasks.length} fetched, ${failures} failed`
);
}
return result;
}
/**
* Fetch stargazer count for a single repo. Returns null on any error.
*/
private async fetchSingle(owner: string, repo: string): Promise<number | null> {
try {
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
const { statusCode, body } = await githubGet(url);
if (statusCode !== 200) {
if (statusCode === 403 || statusCode === 429) {
logger.debug(`GitHub API rate limit hit (${statusCode}), skipping remaining`);
}
return null;
}
const data = JSON.parse(body) as { stargazers_count?: number };
return typeof data.stargazers_count === 'number' ? data.stargazers_count : null;
} catch {
return null;
}
}
/**
* Run async tasks with a concurrency limit.
*/
private async withConcurrencyLimit(
tasks: Array<() => Promise<void>>,
limit: number
): Promise<Array<'ok' | 'error'>> {
const results: Array<'ok' | 'error'> = [];
let index = 0;
const run = async (): Promise<void> => {
while (index < tasks.length) {
const i = index++;
try {
await tasks[i]();
results[i] = 'ok';
} catch {
results[i] = 'error';
}
}
};
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => run());
await Promise.all(workers);
return results;
}
}
// ── HTTP helper (GitHub-specific) ──────────────────────────────────────────
function githubGet(url: string): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
let settled = false;
const settleResolve = (v: { statusCode: number; body: string }): void => {
if (!settled) {
settled = true;
resolve(v);
}
};
const settleReject = (e: Error): void => {
if (!settled) {
settled = true;
reject(e);
}
};
const req = https.get(
url,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'claude-devtools',
},
},
(res) => {
const status = res.statusCode ?? 0;
const chunks: Buffer[] = [];
let totalSize = 0;
res.on('data', (c: Buffer) => {
totalSize += c.length;
if (totalSize > MAX_BODY_SIZE) {
res.destroy(new Error('Response too large'));
return;
}
chunks.push(c);
});
res.on('end', () =>
settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
);
res.on('error', settleReject);
}
);
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout: ${url}`)));
req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
});
}

View file

@ -13,7 +13,7 @@ import https from 'node:https';
import http from 'node:http';
import { createLogger } from '@shared/utils/logger';
import type { McpCatalogItem, McpToolDef } from '@shared/types/extensions';
import type { McpCatalogItem, McpHostingType, McpToolDef } from '@shared/types/extensions';
const logger = createLogger('Extensions:GlamaMcp');
@ -103,6 +103,7 @@ interface GlamaServer {
repository?: { url: string };
spdxLicense?: { name: string; url?: string } | null;
tools?: { name?: string; description?: string }[];
attributes?: string[];
}
// ── Service ────────────────────────────────────────────────────────────────
@ -171,6 +172,18 @@ export class GlamaMcpEnrichmentService {
tools,
glamaUrl: raw.url,
requiresAuth: false,
author: raw.namespace,
hostingType: this.deriveHostingType(raw.attributes),
};
}
private deriveHostingType(attributes?: string[]): McpHostingType | undefined {
if (!attributes?.length) return undefined;
const hasLocal = attributes.includes('hosting:local-only');
const hasRemote = attributes.includes('hosting:remote-capable');
if (hasLocal && hasRemote) return 'both';
if (hasLocal) return 'local';
if (hasRemote) return 'remote';
return undefined;
}
}

View file

@ -141,6 +141,8 @@ export class McpCatalogAggregator {
license: item.license ?? glamaMatch.license,
tools: item.tools.length > 0 ? item.tools : glamaMatch.tools,
glamaUrl: glamaMatch.glamaUrl,
author: item.author ?? glamaMatch.author,
hostingType: item.hostingType ?? glamaMatch.hostingType,
};
});
}

View file

@ -109,6 +109,8 @@ interface RegistryServerEntry {
'io.modelcontextprotocol.registry/official'?: {
status?: string;
isLatest?: boolean;
publishedAt?: string;
updatedAt?: string;
};
};
}
@ -238,10 +240,17 @@ export class OfficialMcpRegistryService {
return meta?.isLatest !== false; // include if isLatest is true or undefined
});
// Filter to active only (include servers with no status or status "active")
const active = latest.filter((e) => {
const meta = e._meta?.['io.modelcontextprotocol.registry/official'];
const status = meta?.status;
return !status || status === 'active';
});
// Deduplicate by server name (take first = latest version)
const seen = new Set<string>();
const unique: RegistryServerEntry[] = [];
for (const entry of latest) {
for (const entry of active) {
if (!seen.has(entry.server.name)) {
seen.add(entry.server.name);
unique.push(entry);
@ -253,6 +262,7 @@ export class OfficialMcpRegistryService {
private normalizeEntry(entry: RegistryServerEntry): McpCatalogItem {
const { server } = entry;
const meta = entry._meta?.['io.modelcontextprotocol.registry/official'];
const installSpec = this.deriveInstallSpec(server);
const envVars = this.collectEnvVars(server);
const requiresAuth = this.detectAuthRequired(server);
@ -271,6 +281,10 @@ export class OfficialMcpRegistryService {
glamaUrl: undefined,
requiresAuth,
iconUrl: this.pickIconUrl(server.icons),
websiteUrl: server.websiteUrl,
status: meta?.status,
publishedAt: meta?.publishedAt,
updatedAt: meta?.updatedAt,
};
}

View file

@ -11,3 +11,5 @@ export { McpInstallationStateService } from './state/McpInstallationStateService
export { ExtensionFacadeService } from './ExtensionFacadeService';
export { PluginInstallService } from './install/PluginInstallService';
export { McpInstallService } from './install/McpInstallService';
export { ApiKeyService } from './apikeys/ApiKeyService';
export { GitHubStarsService } from './catalog/GitHubStarsService';

View file

@ -10,7 +10,11 @@
import { execCli } from '@main/utils/childProcess';
import { createLogger } from '@shared/utils/logger';
import type { McpInstallRequest, OperationResult } from '@shared/types/extensions';
import type {
McpCustomInstallRequest,
McpInstallRequest,
OperationResult,
} from '@shared/types/extensions';
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
const logger = createLogger('Extensions:McpInstall');
@ -180,6 +184,107 @@ export class McpInstallService {
}
}
/**
* Install a custom MCP server user provides installSpec directly (bypasses registry).
*/
async installCustom(request: McpCustomInstallRequest): Promise<OperationResult> {
const { serverName, scope, projectPath, installSpec, envValues, headers } = request;
// Validate inputs (same rules as registry install)
if (!SERVER_NAME_RE.test(serverName)) {
return {
state: 'error',
error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`,
};
}
if (scope && !VALID_SCOPES.has(scope)) {
return { state: 'error', error: `Invalid scope: "${scope}".` };
}
for (const key of Object.keys(envValues)) {
if (!ENV_KEY_RE.test(key)) {
return { state: 'error', error: `Invalid env var name: "${key}".` };
}
}
for (const header of headers) {
if (header.key && !HEADER_KEY_RE.test(header.key)) {
return { state: 'error', error: `Invalid header name: "${header.key}".` };
}
}
if (projectPath && !projectPath.startsWith('/')) {
return { state: 'error', error: 'projectPath must be an absolute path' };
}
// Build CLI args from provided installSpec
const args: string[] = ['mcp', 'add'];
if (scope && scope !== 'local') {
args.push('-s', scope);
}
if (installSpec.type === 'stdio') {
for (const [key, value] of Object.entries(envValues)) {
if (key && value) args.push('-e', `${key}=${value}`);
}
args.push(serverName);
args.push('--');
args.push('npx', '-y');
const pkg = installSpec.npmVersion
? `${installSpec.npmPackage}@${installSpec.npmVersion}`
: installSpec.npmPackage;
args.push(pkg);
} else if (installSpec.type === 'http') {
const transport = installSpec.transportType === 'sse' ? 'sse' : 'http';
args.push('-t', transport);
for (const header of headers) {
if (header.key && header.value) {
args.push('-H', `${header.key}: ${header.value}`);
}
}
for (const [key, value] of Object.entries(envValues)) {
if (key && value) args.push('-e', `${key}=${value}`);
}
args.push(serverName);
args.push(installSpec.url);
} else {
return { state: 'error', error: 'Unsupported install spec type' };
}
logger.info(
`Installing custom MCP server: ${serverName} (type: ${installSpec.type}, scope: ${scope ?? 'local'})`
);
try {
const { stderr } = await execCli(this.claudeBinary, args, {
timeout: TIMEOUT_MS,
cwd: projectPath,
});
if (stderr) {
logger.warn(`Custom MCP install stderr: ${stderr.slice(0, 200)}`);
}
return { state: 'success' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const safeMessage = maskSecrets(
message,
envValues,
headers.map((h) => h.value)
);
logger.error(`Custom MCP install failed: ${safeMessage}`);
return { state: 'error', error: safeMessage };
}
}
async uninstall(name: string, scope?: string, projectPath?: string): Promise<OperationResult> {
if (!SERVER_NAME_RE.test(name)) {
return {

View file

@ -605,3 +605,28 @@ export const MCP_REGISTRY_INSTALL = 'mcpRegistry:install';
/** Uninstall an MCP server */
export const MCP_REGISTRY_UNINSTALL = 'mcpRegistry:uninstall';
/** Install a custom MCP server (bypasses registry) */
export const MCP_REGISTRY_INSTALL_CUSTOM = 'mcpRegistry:installCustom';
/** Fetch GitHub stars for MCP server repositories */
export const MCP_GITHUB_STARS = 'mcpRegistry:githubStars';
// =============================================================================
// API Keys Management Channels
// =============================================================================
/** List all saved API keys (masked values) */
export const API_KEYS_LIST = 'apiKeys:list';
/** Save (create or update) an API key */
export const API_KEYS_SAVE = 'apiKeys:save';
/** Delete an API key by ID */
export const API_KEYS_DELETE = 'apiKeys:delete';
/** Lookup decrypted values by env var names (for auto-fill) */
export const API_KEYS_LOOKUP = 'apiKeys:lookup';
/** Get storage encryption status (for UI display) */
export const API_KEYS_STORAGE_STATUS = 'apiKeys:storageStatus';

View file

@ -154,7 +154,14 @@ import {
MCP_REGISTRY_GET_BY_ID,
MCP_REGISTRY_GET_INSTALLED,
MCP_REGISTRY_INSTALL,
MCP_REGISTRY_INSTALL_CUSTOM,
MCP_REGISTRY_UNINSTALL,
MCP_GITHUB_STARS,
API_KEYS_LIST,
API_KEYS_SAVE,
API_KEYS_DELETE,
API_KEYS_LOOKUP,
API_KEYS_STORAGE_STATUS,
} from './constants/ipcChannels';
import {
CONFIG_ADD_CUSTOM_PROJECT_PATH,
@ -259,9 +266,14 @@ import type {
WslClaudeRootCandidate,
} from '@shared/types';
import type {
ApiKeyEntry,
ApiKeyLookupResult,
ApiKeySaveRequest,
ApiKeyStorageStatus,
EnrichedPlugin,
InstalledMcpEntry,
McpCatalogItem,
McpCustomInstallRequest,
McpInstallRequest,
McpSearchResult,
OperationResult,
@ -1384,8 +1396,22 @@ const electronAPI: ElectronAPI = {
invokeIpcWithResult<InstalledMcpEntry[]>(MCP_REGISTRY_GET_INSTALLED, projectPath),
install: (request: McpInstallRequest) =>
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL, request),
installCustom: (request: McpCustomInstallRequest) =>
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_INSTALL_CUSTOM, request),
uninstall: (name: string, scope?: string, projectPath?: string) =>
invokeIpcWithResult<OperationResult>(MCP_REGISTRY_UNINSTALL, name, scope, projectPath),
githubStars: (repositoryUrls: string[]) =>
invokeIpcWithResult<Record<string, number>>(MCP_GITHUB_STARS, repositoryUrls),
},
// ===== API Keys API (Electron-only) =====
apiKeys: {
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),
save: (request: ApiKeySaveRequest) => invokeIpcWithResult<ApiKeyEntry>(API_KEYS_SAVE, request),
delete: (id: string) => invokeIpcWithResult<void>(API_KEYS_DELETE, id),
lookup: (envVarNames: string[]) =>
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames),
getStorageStatus: () => invokeIpcWithResult<ApiKeyStorageStatus>(API_KEYS_STORAGE_STATUS),
},
};

View file

@ -28,7 +28,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
import { FileText } from 'lucide-react';
import { FileText, UsersRound } from 'lucide-react';
import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow';
@ -67,11 +67,12 @@ interface MarkdownViewerProps {
// =============================================================================
/**
* Custom URL transform that preserves task:// and mention:// protocols.
* Custom URL transform that preserves task://, mention://, and team:// protocols.
* react-markdown v10 strips non-standard protocols by default.
*/
function allowCustomProtocols(url: string): string {
if (url.startsWith('task://') || url.startsWith('mention://')) return url;
if (url.startsWith('task://') || url.startsWith('mention://') || url.startsWith('team://'))
return url;
return defaultUrlTransform(url);
}
@ -245,6 +246,31 @@ function createViewerMarkdownComponents(
}
return badge;
}
if (href?.startsWith('team://')) {
let teamName = '';
try {
teamName = decodeURIComponent(href.slice('team://'.length));
} catch {
// malformed percent-encoding
}
return (
<span
className="inline-flex items-center gap-0.5"
style={{
backgroundColor: 'rgba(168, 85, 247, 0.15)',
color: '#c084fc',
borderRadius: '3px',
boxShadow: '0 0 0 1.5px rgba(168, 85, 247, 0.15)',
fontSize: 'inherit',
cursor: 'default',
}}
title={teamName ? `Team: ${teamName}` : undefined}
>
<UsersRound size={11} className="shrink-0" style={{ opacity: 0.8 }} />
{children}
</span>
);
}
if (href?.startsWith('task://')) {
const taskId = href.slice('task://'.length);
return (

View file

@ -4,7 +4,7 @@
* Global catalog data comes from Zustand store.
*/
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
@ -17,13 +17,16 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { AlertTriangle, Info, Puzzle, RefreshCw, Server } from 'lucide-react';
import { AlertTriangle, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
import { McpServersPanel } from './mcp/McpServersPanel';
import { PluginsPanel } from './plugins/PluginsPanel';
export const ExtensionStoreView = (): React.JSX.Element => {
const fetchPluginCatalog = useStore((s) => s.fetchPluginCatalog);
const fetchApiKeys = useStore((s) => s.fetchApiKeys);
const mcpBrowse = useStore((s) => s.mcpBrowse);
const mcpFetchInstalled = useStore((s) => s.mcpFetchInstalled);
const pluginCatalogLoading = useStore((s) => s.pluginCatalogLoading);
@ -33,6 +36,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
const hasOngoingSessions = useStore((s) => s.sessions.some((sess) => sess.isOngoing));
const tabState = useExtensionsTabState();
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
// Fetch plugin catalog on mount
useEffect(() => {
@ -44,6 +48,11 @@ export const ExtensionStoreView = (): React.JSX.Element => {
void mcpFetchInstalled();
}, [mcpFetchInstalled]);
// Fetch API keys on mount
useEffect(() => {
void fetchApiKeys();
}, [fetchApiKeys]);
// Refresh all data (plugins + MCP browse + installed)
const handleRefresh = useCallback(() => {
void fetchPluginCatalog(undefined, true);
@ -104,18 +113,37 @@ export const ExtensionStoreView = (): React.JSX.Element => {
)}
<Tabs
value={tabState.activeSubTab}
onValueChange={(v) => tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers')}
onValueChange={(v) =>
tabState.setActiveSubTab(v as 'plugins' | 'mcp-servers' | 'api-keys')
}
>
<TabsList className="mb-4">
<TabsTrigger value="plugins" className="gap-1.5">
<Puzzle className="size-3.5" />
Plugins
</TabsTrigger>
<TabsTrigger value="mcp-servers" className="gap-1.5">
<Server className="size-3.5" />
MCP Servers
</TabsTrigger>
</TabsList>
<div className="mb-4 flex items-center justify-between">
<TabsList>
<TabsTrigger value="plugins" className="gap-1.5">
<Puzzle className="size-3.5" />
Plugins
</TabsTrigger>
<TabsTrigger value="mcp-servers" className="gap-1.5">
<Server className="size-3.5" />
MCP Servers
</TabsTrigger>
<TabsTrigger value="api-keys" className="gap-1.5">
<Key className="size-3.5" />
API Keys
</TabsTrigger>
</TabsList>
{tabState.activeSubTab === 'mcp-servers' && (
<Button
variant="outline"
size="sm"
onClick={() => setCustomMcpDialogOpen(true)}
className="whitespace-nowrap"
>
<Plus className="mr-1 size-3.5" />
Add Custom
</Button>
)}
</div>
<TabsContent value="plugins">
<PluginsPanel
@ -144,7 +172,17 @@ export const ExtensionStoreView = (): React.JSX.Element => {
setSelectedMcpServerId={tabState.setSelectedMcpServerId}
/>
</TabsContent>
<TabsContent value="api-keys">
<ApiKeysPanel />
</TabsContent>
</Tabs>
{/* Custom MCP server dialog (lifted to store view level) */}
<CustomMcpServerDialog
open={customMcpDialogOpen}
onClose={() => setCustomMcpDialogOpen(false)}
/>
</div>
</div>
);

View file

@ -0,0 +1,137 @@
/**
* ApiKeyCard displays a single API key entry with edit/delete controls.
*/
import { useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { Copy, Check, Pencil, Trash2 } from 'lucide-react';
import type { ApiKeyEntry } from '@shared/types/extensions';
interface ApiKeyCardProps {
apiKey: ApiKeyEntry;
onEdit: (key: ApiKeyEntry) => void;
}
export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Element => {
const deleteApiKey = useStore((s) => s.deleteApiKey);
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const handleCopyEnvVar = async () => {
await navigator.clipboard.writeText(apiKey.envVarName);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const handleDelete = () => {
if (!confirmDelete) {
setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000);
return;
}
void deleteApiKey(apiKey.id);
setConfirmDelete(false);
};
const createdDate = new Date(apiKey.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<div className="flex flex-col gap-2.5 rounded-lg border border-border bg-surface p-4 transition-colors hover:border-border-emphasis">
{/* Name + scope badge */}
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-sm font-medium text-text">{apiKey.name}</h3>
<Badge
variant="outline"
className={
apiKey.scope === 'user'
? 'border-blue-500/30 bg-blue-500/10 text-blue-400'
: 'border-purple-500/30 bg-purple-500/10 text-purple-400'
}
>
{apiKey.scope}
</Badge>
</div>
{/* Env var name */}
<div className="flex items-center gap-1.5">
<code className="rounded bg-surface-raised px-1.5 py-0.5 text-xs text-blue-400">
{apiKey.envVarName}
</code>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-5"
onClick={() => void handleCopyEnvVar()}
>
{copied ? (
<Check className="size-3 text-emerald-400" />
) : (
<Copy className="size-3 text-text-muted" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? 'Copied!' : 'Copy env var name'}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Masked value */}
<p className="font-mono text-xs text-text-muted">{apiKey.maskedValue}</p>
{/* Footer: date + actions */}
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-text-muted">{createdDate}</span>
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => onEdit(apiKey)}
>
<Pencil className="size-3.5 text-text-muted" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${confirmDelete ? 'text-red-400 hover:bg-red-500/10' : 'text-text-muted'}`}
onClick={handleDelete}
>
<Trash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{confirmDelete ? 'Click again to confirm' : 'Delete'}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,243 @@
/**
* ApiKeyFormDialog create or edit an API key entry.
* Edit mode pre-fills all fields except the value (which must be re-entered).
*/
import { useEffect, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { AlertTriangle, Key } from 'lucide-react';
import type { ApiKeyEntry } from '@shared/types/extensions';
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
interface ApiKeyFormDialogProps {
open: boolean;
editingKey: ApiKeyEntry | null;
onClose: () => void;
}
type Scope = 'user' | 'project';
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
];
export const ApiKeyFormDialog = ({
open,
editingKey,
onClose,
}: ApiKeyFormDialogProps): React.JSX.Element => {
const saveApiKey = useStore((s) => s.saveApiKey);
const apiKeySaving = useStore((s) => s.apiKeySaving);
const storageStatus = useStore((s) => s.apiKeyStorageStatus);
const [name, setName] = useState('');
const [envVarName, setEnvVarName] = useState('');
const [value, setValue] = useState('');
const [scope, setScope] = useState<Scope>('user');
const [error, setError] = useState<string | null>(null);
const [envVarError, setEnvVarError] = useState<string | null>(null);
// Reset form when dialog opens/closes or editing key changes
useEffect(() => {
if (open) {
if (editingKey) {
setName(editingKey.name);
setEnvVarName(editingKey.envVarName);
setScope(editingKey.scope);
setValue('');
} else {
setName('');
setEnvVarName('');
setValue('');
setScope('user');
}
setError(null);
setEnvVarError(null);
}
}, [open, editingKey]);
const validateEnvVar = (v: string) => {
if (!v.trim()) {
setEnvVarError(null);
return;
}
if (!ENV_KEY_RE.test(v)) {
setEnvVarError('Use letters, digits, underscores. Must start with a letter or underscore.');
} else {
setEnvVarError(null);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError('Name is required');
return;
}
if (!envVarName.trim()) {
setError('Environment variable name is required');
return;
}
if (!ENV_KEY_RE.test(envVarName)) {
setError('Invalid environment variable name');
return;
}
if (!value) {
setError('Key value is required');
return;
}
try {
await saveApiKey({
id: editingKey?.id,
name: name.trim(),
envVarName: envVarName.trim(),
value,
scope,
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
}
};
const isEdit = editingKey !== null;
const canSubmit = name.trim() && envVarName.trim() && value && !envVarError && !apiKeySaving;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg border border-border bg-surface-raised">
<Key className="size-4 text-text-muted" />
</div>
<div>
<DialogTitle>{isEdit ? 'Edit API Key' : 'Add API Key'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Update the key details. You must re-enter the value.'
: 'Store an API key for auto-filling in MCP server installations.'}
</DialogDescription>
</div>
</div>
</DialogHeader>
{storageStatus && storageStatus.encryptionMethod !== 'os-keychain' && (
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-400">
<AlertTriangle className="size-3.5 shrink-0" />
OS keychain unavailable keys encrypted with AES-256 locally. Install gnome-keyring for
OS-level protection.
</div>
)}
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="apikey-name" className="text-xs">
Name
</Label>
<Input
id="apikey-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. OpenAI Production"
className="h-8 text-sm"
autoFocus
/>
</div>
{/* Env var name */}
<div className="space-y-1.5">
<Label htmlFor="apikey-envvar" className="text-xs">
Environment Variable Name
</Label>
<Input
id="apikey-envvar"
value={envVarName}
onChange={(e) => {
setEnvVarName(e.target.value);
validateEnvVar(e.target.value);
}}
placeholder="e.g. OPENAI_API_KEY"
className={`h-8 font-mono text-sm ${envVarError ? 'border-red-500/50' : ''}`}
/>
{envVarError && <p className="text-xs text-red-400">{envVarError}</p>}
</div>
{/* Value */}
<div className="space-y-1.5">
<Label htmlFor="apikey-value" className="text-xs">
Value
</Label>
<Input
id="apikey-value"
type="password"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={isEdit ? 'Re-enter key value' : 'sk-...'}
className="h-8 text-sm"
/>
</div>
{/* Scope */}
<div className="space-y-1.5">
<Label className="text-xs">Scope</Label>
<Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Error display */}
{error && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button type="submit" size="sm" disabled={!canSubmit}>
{apiKeySaving ? 'Saving...' : isEdit ? 'Update' : 'Save'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,139 @@
/**
* ApiKeysPanel grid of saved API keys with add button and empty state.
*/
import { useEffect, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { AlertTriangle, Info, Key, Plus } from 'lucide-react';
import { ApiKeyCard } from './ApiKeyCard';
import { ApiKeyFormDialog } from './ApiKeyFormDialog';
import type { ApiKeyEntry } from '@shared/types/extensions';
export const ApiKeysPanel = (): React.JSX.Element => {
const apiKeys = useStore((s) => s.apiKeys);
const apiKeysLoading = useStore((s) => s.apiKeysLoading);
const apiKeysError = useStore((s) => s.apiKeysError);
const storageStatus = useStore((s) => s.apiKeyStorageStatus);
const fetchStorageStatus = useStore((s) => s.fetchApiKeyStorageStatus);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingKey, setEditingKey] = useState<ApiKeyEntry | null>(null);
useEffect(() => {
void fetchStorageStatus();
}, [fetchStorageStatus]);
const handleAdd = () => {
setEditingKey(null);
setDialogOpen(true);
};
const handleEdit = (key: ApiKeyEntry) => {
setEditingKey(key);
setDialogOpen(true);
};
const handleDialogClose = () => {
setDialogOpen(false);
setEditingKey(null);
};
const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain';
return (
<div className="flex flex-col gap-4">
{/* Header row */}
<div className="flex items-center justify-between">
<p className="flex items-center gap-1.5 text-sm text-text-secondary">
Securely store API keys for auto-filling when installing MCP servers.
{storageStatus && (
<Tooltip>
<TooltipTrigger asChild>
{isOsKeychain ? (
<Info className="size-3.5 shrink-0 cursor-help text-text-muted" />
) : (
<AlertTriangle className="size-3.5 shrink-0 cursor-help text-amber-400" />
)}
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
{isOsKeychain ? (
<p>
Keys are encrypted via {storageStatus.backend} and stored with restricted file
permissions (owner-only).
</p>
) : (
<p>
OS keychain unavailable keys are encrypted locally with AES-256. For stronger
protection, install a keyring service (gnome-keyring, kwallet).
</p>
)}
</TooltipContent>
</Tooltip>
)}
</p>
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">
<Plus className="size-3.5" />
Add API Key
</Button>
</div>
{/* Error */}
{apiKeysError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
{apiKeysError}
</div>
)}
{/* Skeleton loading */}
{apiKeysLoading && apiKeys.length === 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }, (_, i) => (
<div
key={i}
className="skeleton-card flex flex-col gap-2 rounded-lg border border-border p-4"
style={{ animationDelay: `${i * 80}ms` }}
>
<div className="h-4 w-32 rounded bg-surface-raised" />
<div className="h-3 w-24 rounded bg-surface-raised" />
<div className="h-3 w-40 rounded bg-surface-raised" />
</div>
))}
</div>
)}
{/* Empty state */}
{!apiKeysLoading && apiKeys.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
<div className="flex size-10 items-center justify-center rounded-lg border border-border bg-surface-raised">
<Key className="size-5 text-text-muted" />
</div>
<p className="text-sm text-text-secondary">No API keys saved</p>
<p className="text-xs text-text-muted">
Add keys to auto-fill environment variables when installing MCP servers.
</p>
<Button variant="outline" size="sm" onClick={handleAdd} className="mt-2 gap-1.5">
<Plus className="size-3.5" />
Add your first key
</Button>
</div>
)}
{/* Key cards grid */}
{apiKeys.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{apiKeys.map((key) => (
<ApiKeyCard key={key.id} apiKey={key} onEdit={handleEdit} />
))}
</div>
)}
{/* Form dialog */}
<ApiKeyFormDialog open={dialogOpen} editingKey={editingKey} onClose={handleDialogClose} />
</div>
);
};

View file

@ -0,0 +1,444 @@
/**
* CustomMcpServerDialog add a custom MCP server by providing install spec directly.
* Supports stdio (npm package) and HTTP/SSE transports.
*/
import { useEffect, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { api } from '@renderer/api';
import { Plus, Server, Trash2 } from 'lucide-react';
import type {
McpCustomInstallRequest,
McpHeaderDef,
McpInstallSpec,
} from '@shared/types/extensions';
const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
interface CustomMcpServerDialogProps {
open: boolean;
onClose: () => void;
}
type TransportMode = 'stdio' | 'http';
type HttpTransport = 'streamable-http' | 'sse' | 'http';
type Scope = 'local' | 'user' | 'project';
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
{ value: 'local', label: 'Local' },
];
const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [
{ value: 'streamable-http', label: 'Streamable HTTP' },
{ value: 'sse', label: 'SSE' },
{ value: 'http', label: 'HTTP' },
];
interface EnvEntry {
key: string;
value: string;
}
export const CustomMcpServerDialog = ({
open,
onClose,
}: CustomMcpServerDialogProps): React.JSX.Element => {
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
// Form state
const [serverName, setServerName] = useState('');
const [transportMode, setTransportMode] = useState<TransportMode>('stdio');
const [scope, setScope] = useState<Scope>('user');
// Stdio fields
const [npmPackage, setNpmPackage] = useState('');
const [npmVersion, setNpmVersion] = useState('');
// HTTP fields
const [httpUrl, setHttpUrl] = useState('');
const [httpTransport, setHttpTransport] = useState<HttpTransport>('streamable-http');
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
// Shared
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
// Reset on open
useEffect(() => {
if (open) {
setServerName('');
setTransportMode('stdio');
setScope('user');
setNpmPackage('');
setNpmVersion('');
setHttpUrl('');
setHttpTransport('streamable-http');
setHeaders([]);
setEnvVars([]);
setError(null);
setInstalling(false);
}
}, [open]);
// Auto-fill env vars from saved API keys
useEffect(() => {
if (!open || envVars.length === 0 || !api.apiKeys) return;
const envVarNames = envVars.map((e) => e.key).filter(Boolean);
if (envVarNames.length === 0) return;
void api.apiKeys.lookup(envVarNames).then(
(results) => {
if (results.length === 0) return;
const lookup = new Map(results.map((r) => [r.envVarName, r.value]));
setEnvVars((prev) =>
prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e))
);
},
() => {
// Silently fail
}
);
}, [open, envVars.length]); // eslint-disable-line react-hooks/exhaustive-deps
const handleInstall = async () => {
setError(null);
if (!serverName.trim()) {
setError('Server name is required');
return;
}
if (!SERVER_NAME_RE.test(serverName)) {
setError('Invalid server name. Use alphanumeric characters, dashes, underscores, dots.');
return;
}
let installSpec: McpInstallSpec;
if (transportMode === 'stdio') {
if (!npmPackage.trim()) {
setError('npm package name is required');
return;
}
installSpec = {
type: 'stdio',
npmPackage: npmPackage.trim(),
npmVersion: npmVersion.trim() || undefined,
};
} else {
if (!httpUrl.trim()) {
setError('Server URL is required');
return;
}
installSpec = {
type: 'http',
url: httpUrl.trim(),
transportType: httpTransport,
};
}
const envValues: Record<string, string> = {};
for (const entry of envVars) {
if (entry.key.trim() && entry.value) {
envValues[entry.key.trim()] = entry.value;
}
}
const request: McpCustomInstallRequest = {
serverName,
scope,
installSpec,
envValues,
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
};
setInstalling(true);
try {
await installCustomMcpServer(request);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Install failed');
} finally {
setInstalling(false);
}
};
const addEnvVar = () => setEnvVars((prev) => [...prev, { key: '', value: '' }]);
const removeEnvVar = (i: number) => setEnvVars((prev) => prev.filter((_, idx) => idx !== i));
const updateEnvVar = (i: number, field: 'key' | 'value', val: string) =>
setEnvVars((prev) => prev.map((e, idx) => (idx === i ? { ...e, [field]: val } : e)));
const addHeader = () => setHeaders((prev) => [...prev, { key: '', value: '' }]);
const removeHeader = (i: number) => setHeaders((prev) => prev.filter((_, idx) => idx !== i));
const updateHeader = (i: number, field: 'key' | 'value', val: string) =>
setHeaders((prev) => prev.map((h, idx) => (idx === i ? { ...h, [field]: val } : h)));
const canSubmit =
serverName.trim() &&
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
!installing;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg border border-border bg-surface-raised">
<Server className="size-4 text-text-muted" />
</div>
<div>
<DialogTitle>Add Custom MCP Server</DialogTitle>
<DialogDescription>Add a server manually without the catalog.</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Server name */}
<div className="space-y-1.5">
<Label htmlFor="custom-name" className="text-xs">
Server Name
</Label>
<Input
id="custom-name"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
placeholder="my-server"
className="h-8 text-sm"
autoFocus
/>
</div>
{/* Transport toggle */}
<div className="space-y-1.5">
<Label className="text-xs">Transport</Label>
<div className="flex gap-2">
<Button
type="button"
variant={transportMode === 'stdio' ? 'default' : 'outline'}
size="sm"
onClick={() => setTransportMode('stdio')}
>
Stdio (npm)
</Button>
<Button
type="button"
variant={transportMode === 'http' ? 'default' : 'outline'}
size="sm"
onClick={() => setTransportMode('http')}
>
HTTP / SSE
</Button>
</div>
</div>
{/* Stdio fields */}
{transportMode === 'stdio' && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="custom-npm" className="text-xs">
npm Package
</Label>
<Input
id="custom-npm"
value={npmPackage}
onChange={(e) => setNpmPackage(e.target.value)}
placeholder="@example/mcp-server"
className="h-8 font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-version" className="text-xs">
Version (optional)
</Label>
<Input
id="custom-version"
value={npmVersion}
onChange={(e) => setNpmVersion(e.target.value)}
placeholder="latest"
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* HTTP fields */}
{transportMode === 'http' && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="custom-url" className="text-xs">
Server URL
</Label>
<Input
id="custom-url"
value={httpUrl}
onChange={(e) => setHttpUrl(e.target.value)}
placeholder="https://api.example.com/mcp"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Transport Type</Label>
<Select
value={httpTransport}
onValueChange={(v) => setHttpTransport(v as HttpTransport)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HTTP_TRANSPORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Headers */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Headers</Label>
<Button
variant="ghost"
size="sm"
onClick={addHeader}
className="h-6 px-1.5 text-xs"
>
<Plus className="mr-1 size-3" />
Add
</Button>
</div>
{headers.length > 0 && (
<div className="space-y-2">
{headers.map((header, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={header.key}
onChange={(e) => updateHeader(i, 'key', e.target.value)}
className="h-7 w-32 text-xs"
placeholder="Header-Name"
/>
<Input
value={header.value}
onChange={(e) => updateHeader(i, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
placeholder="value"
/>
<Button
variant="ghost"
size="icon"
className="size-7 text-red-400 hover:bg-red-500/10"
onClick={() => removeHeader(i)}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Scope */}
<div className="space-y-1.5">
<Label className="text-xs">Scope</Label>
<Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Environment variables */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Environment Variables</Label>
<Button variant="ghost" size="sm" onClick={addEnvVar} className="h-6 px-1.5 text-xs">
<Plus className="mr-1 size-3" />
Add
</Button>
</div>
{envVars.length > 0 && (
<div className="space-y-2">
{envVars.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={entry.key}
onChange={(e) => updateEnvVar(i, 'key', e.target.value)}
className="h-7 w-40 font-mono text-xs"
placeholder="ENV_VAR_NAME"
/>
<Input
type="password"
value={entry.value}
onChange={(e) => updateEnvVar(i, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
placeholder="value"
/>
<Button
variant="ghost"
size="icon"
className="size-7 text-red-400 hover:bg-red-500/10"
onClick={() => removeEnvVar(i)}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Error */}
{error && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" disabled={!canSubmit} onClick={() => void handleInstall()}>
{installing ? 'Installing...' : 'Install'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -6,15 +6,26 @@
import { useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { Lock, Server, Wrench } from 'lucide-react';
import { api } from '@renderer/api';
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
import { Cloud, Clock, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
// eslint-disable-next-line @typescript-eslint/no-deprecated -- lucide naming migration, alias is stable
import { Github as GithubIcon } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
import type { McpCatalogItem } from '@shared/types/extensions';
/** Ribbon colors by source */
const RIBBON_STYLES: Record<string, string> = {
official: 'bg-blue-500/90 text-white',
glama: 'bg-zinc-600/90 text-zinc-200',
};
interface McpServerCardProps {
server: McpCatalogItem;
isInstalled: boolean;
@ -30,31 +41,50 @@ export const McpServerCard = ({
const installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
const installError = useStore((s) => s.installErrors[server.id]);
const stars = useStore((s) =>
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
);
const canAutoInstall = !!server.installSpec;
const [imgError, setImgError] = useState(false);
const hasIcon = !!server.iconUrl && !imgError;
return (
<button
<div
role="button"
tabIndex={0}
onClick={() => onClick(server.id)}
className={`flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(server.id);
}
}}
className={`relative flex w-full cursor-pointer flex-col gap-2 overflow-hidden rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
}`}
>
{/* Header: icon + name + source */}
<div className="flex items-start gap-2.5">
{/* Server icon or fallback */}
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
{server.iconUrl && !imgError ? (
{/* Source ribbon (top-left corner) */}
<div className="pointer-events-none absolute -left-[1px] -top-[1px] size-16 overflow-hidden">
<div
className={`absolute left-[-18px] top-[8px] w-[80px] rotate-[-45deg] text-center text-[9px] font-semibold leading-[18px] shadow-sm ${RIBBON_STYLES[server.source] ?? RIBBON_STYLES.glama}`}
>
{server.source === 'official' ? 'Official' : 'Glama'}
</div>
</div>
{/* Header: icon + name */}
<div className={`flex items-start gap-2.5 ${hasIcon ? 'pl-5' : 'pl-7'}`}>
{/* Server icon (only when available) */}
{hasIcon && (
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
<img
src={server.iconUrl}
src={server.iconUrl!}
alt=""
className="size-7 rounded object-contain"
onError={() => setImgError(true)}
/>
) : (
<Server className="size-4 text-text-muted" />
)}
</div>
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-sm font-semibold text-text">{server.name}</h3>
@ -67,7 +97,6 @@ export const McpServerCard = ({
Installed
</Badge>
)}
<SourceBadge source={server.source} />
</div>
</div>
</div>
@ -78,11 +107,17 @@ export const McpServerCard = ({
{/* Footer indicators + install button */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-muted">
{server.tools.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-surface-raised px-1.5 py-0.5 ring-1 ring-border">
<Wrench className="size-3" />
{server.tools.length}
{server.tools.length} {server.tools.length === 1 ? 'tool' : 'tools'}
</span>
)}
{server.envVars.length > 0 && (
<span className="inline-flex items-center gap-1">
<KeyRound className="size-3" />
{server.envVars.length} {server.envVars.length === 1 ? 'env' : 'envs'}
</span>
)}
{server.requiresAuth && (
@ -91,7 +126,76 @@ export const McpServerCard = ({
Auth
</span>
)}
{server.license && <span>{server.license}</span>}
{server.version && (
<span className="inline-flex items-center gap-1">
<Tag className="size-3" />
{server.version}
</span>
)}
{server.updatedAt && (
<span className="inline-flex items-center gap-1">
<Clock className="size-3" />
{formatRelativeTime(server.updatedAt)}
</span>
)}
{server.author && <span className="truncate">by {server.author}</span>}
{server.hostingType === 'remote' && (
<span className="inline-flex items-center gap-1">
<Cloud className="size-3" />
Remote
</span>
)}
{server.hostingType === 'local' && (
<span className="inline-flex items-center gap-1">
<Monitor className="size-3" />
Local
</span>
)}
{server.hostingType === 'both' && (
<span className="inline-flex items-center gap-1">
<Globe className="size-3" />
Both
</span>
)}
{/* External links + stars */}
{server.repositoryUrl && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex items-center gap-1.5 text-text-muted transition-colors hover:text-text"
onClick={(e) => {
e.stopPropagation();
void api.openExternal(server.repositoryUrl!);
}}
>
<GithubIcon className="size-3.5" />
{stars != null && (
<span className="inline-flex items-center gap-0.5">
<Star className="size-3 fill-amber-400 text-amber-400" />
{formatCompactNumber(stars)}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">Repository</TooltipContent>
</Tooltip>
)}
{server.websiteUrl && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex items-center text-text-muted transition-colors hover:text-text"
onClick={(e) => {
e.stopPropagation();
void api.openExternal(server.websiteUrl!);
}}
>
<Globe className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Website</TooltipContent>
</Tooltip>
)}
</div>
{canAutoInstall && (
<div className="shrink-0">
@ -114,6 +218,6 @@ export const McpServerCard = ({
</div>
)}
</div>
</button>
</div>
);
};

View file

@ -3,7 +3,7 @@
* Uses Radix UI Kit for all form elements.
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Dialog,
@ -25,7 +25,7 @@ import {
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { api } from '@renderer/api';
import { ExternalLink, Lock, Plus, Server, Trash2, Wrench } from 'lucide-react';
import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
@ -60,12 +60,16 @@ export const McpServerDetailDialog = ({
const installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined));
const stars = useStore((s) =>
server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
);
const [scope, setScope] = useState<Scope>('user');
const [serverName, setServerName] = useState('');
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
const [imgError, setImgError] = useState(false);
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
// Initialize form when server changes
const [lastServerId, setLastServerId] = useState<string | null>(null);
@ -75,12 +79,69 @@ export const McpServerDetailDialog = ({
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
setHeaders([]);
setImgError(false);
setAutoFilledFields(new Set());
}
// Auto-fill env values from saved API keys
useEffect(() => {
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
const envVarNames = server.envVars.map((e) => e.name);
void api.apiKeys.lookup(envVarNames).then(
(results) => {
if (results.length === 0) return;
const filled = new Set<string>();
const values: Record<string, string> = {};
for (const r of results) {
values[r.envVarName] = r.value;
filled.add(r.envVarName);
}
setEnvValues((prev) => ({ ...prev, ...values }));
setAutoFilledFields(filled);
},
() => {
// Silently fail — auto-fill is supplementary
}
);
}, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-fill env vars from saved API keys
useEffect(() => {
if (!server || server.envVars.length === 0 || !api.apiKeys) return;
const envVarNames = server.envVars.map((env) => env.name);
void api.apiKeys.lookup(envVarNames).then(
(results) => {
if (results.length === 0) return;
const filled = new Set<string>();
const updates: Record<string, string> = {};
for (const r of results) {
updates[r.envVarName] = r.value;
filled.add(r.envVarName);
}
setEnvValues((prev) => {
const next = { ...prev };
for (const [k, v] of Object.entries(updates)) {
// Only auto-fill if the field is empty
if (!next[k]) {
next[k] = v;
}
}
return next;
});
setAutoFilledFields(filled);
},
() => {
// Silently ignore lookup failures
}
);
}, [server?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (!server) return <></>;
const canAutoInstall = !!server.installSpec;
const isHttp = server.installSpec?.type === 'http';
const hasIcon = !!server.iconUrl && !imgError;
const handleInstall = () => {
installMcpServer({
@ -113,19 +174,17 @@ export const McpServerDetailDialog = ({
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<div className="flex items-start gap-3">
{/* Server icon */}
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
{server.iconUrl && !imgError ? (
{/* Server icon (only when available) */}
{hasIcon && (
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
<img
src={server.iconUrl}
src={server.iconUrl!}
alt=""
className="size-8 rounded object-contain"
onError={() => setImgError(true)}
/>
) : (
<Server className="size-5 text-text-muted" />
)}
</div>
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@ -154,6 +213,15 @@ export const McpServerDetailDialog = ({
<span className="text-text-muted">Source</span>
<p className="capitalize text-text">{server.source}</p>
</div>
{stars != null && (
<div>
<span className="text-text-muted">GitHub Stars</span>
<p className="flex items-center gap-1 text-text">
<Star className="size-3.5 fill-amber-400 text-amber-400" />
{stars.toLocaleString()}
</p>
</div>
)}
{server.version && (
<div>
<span className="text-text-muted">Version</span>
@ -176,6 +244,30 @@ export const McpServerDetailDialog = ({
: 'Manual setup required'}
</p>
</div>
{server.author && (
<div>
<span className="text-text-muted">Author</span>
<p className="text-text">{server.author}</p>
</div>
)}
{server.hostingType && (
<div>
<span className="text-text-muted">Hosting</span>
<p className="capitalize text-text">{server.hostingType}</p>
</div>
)}
{server.publishedAt && (
<div>
<span className="text-text-muted">Published</span>
<p className="text-text">{new Date(server.publishedAt).toLocaleDateString()}</p>
</div>
)}
{server.updatedAt && (
<div>
<span className="text-text-muted">Updated</span>
<p className="text-text">{new Date(server.updatedAt).toLocaleDateString()}</p>
</div>
)}
</div>
{/* Auth indicator */}
@ -243,6 +335,9 @@ export const McpServerDetailDialog = ({
className="h-7 flex-1 text-xs"
placeholder={env.description ?? env.name}
/>
{autoFilledFields.has(env.name) && envValues[env.name] && (
<span className="shrink-0 text-[10px] text-emerald-400">Auto-filled</span>
)}
</div>
))}
</div>
@ -357,6 +452,16 @@ export const McpServerDetailDialog = ({
Glama
</Button>
)}
{server.websiteUrl && (
<Button
variant="link"
className="h-auto p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(server.websiteUrl!)}
>
<ExternalLink className="mr-1 size-3.5" />
Website
</Button>
)}
</div>
</DialogContent>
</Dialog>

View file

@ -73,6 +73,7 @@ export const McpServersPanel = ({
const browseError = useStore((s) => s.mcpBrowseError);
const mcpBrowse = useStore((s) => s.mcpBrowse);
const installedServers = useStore((s) => s.mcpInstalledServers);
const fetchMcpGitHubStars = useStore((s) => s.fetchMcpGitHubStars);
const [mcpSort, setMcpSort] = useState<McpSortValue>('name-asc');
const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false);
@ -84,6 +85,14 @@ export const McpServersPanel = ({
}
}, [browseCatalog.length, browseLoading, mcpBrowse]);
// Fetch GitHub stars after catalog loads (fire-and-forget)
useEffect(() => {
const urls = browseCatalog.map((s) => s.repositoryUrl).filter((u): u is string => !!u);
if (urls.length > 0) {
fetchMcpGitHubStars(urls);
}
}, [browseCatalog, fetchMcpGitHubStars]);
// Decide which list to show: search results or browse
const isSearching = mcpSearchQuery.trim().length > 0;
const rawServers = isSearching ? mcpSearchResults : browseCatalog;

View file

@ -34,7 +34,7 @@ export const CapabilityChips = ({
}, [plugins]);
return (
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap gap-2">
{ALL_CAPABILITIES.map((cap) => {
const count = capabilityCounts.get(cap) ?? 0;
if (count === 0) return null;
@ -45,14 +45,23 @@ export const CapabilityChips = ({
variant="ghost"
size="sm"
onClick={() => onToggle(cap)}
className={`h-7 rounded-full px-2.5 text-xs font-medium ${
aria-pressed={isActive}
className={`h-8 rounded-full border px-3 text-xs font-medium transition-all ${
isActive
? 'bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/40 hover:bg-purple-500/30'
: 'text-text-muted hover:text-text-secondary'
? 'border-purple-500/40 bg-purple-500/15 text-purple-300 shadow-sm'
: 'hover:bg-surface-raised/60 border-border bg-transparent text-text-secondary hover:border-border-emphasis hover:text-text'
}`}
>
{getCapabilityLabel(cap)}
<span className="ml-1 text-text-muted">({count})</span>
<span>{getCapabilityLabel(cap)}</span>
<span
className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
isActive
? 'bg-surface-raised text-text-secondary'
: 'bg-surface-raised/70 text-text-muted'
}`}
>
{count}
</span>
</Button>
);
})}

View file

@ -33,7 +33,7 @@ export const CategoryChips = ({
if (categoryCounts.length === 0) return <></>;
return (
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap gap-2">
{categoryCounts.map(([category, count]) => {
const isActive = selected.includes(category);
return (
@ -42,14 +42,23 @@ export const CategoryChips = ({
variant="ghost"
size="sm"
onClick={() => onToggle(category)}
className={`h-7 rounded-full px-2.5 text-xs font-medium ${
aria-pressed={isActive}
className={`h-8 rounded-full border px-3 text-xs font-medium transition-all ${
isActive
? 'bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/40 hover:bg-blue-500/30'
: 'text-text-muted hover:text-text-secondary'
? 'border-blue-500/40 bg-blue-500/15 text-blue-300 shadow-sm'
: 'hover:bg-surface-raised/60 border-border bg-transparent text-text-secondary hover:border-border-emphasis hover:text-text'
}`}
>
{category}
<span className="ml-1 text-text-muted">({count})</span>
<span>{category}</span>
<span
className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
isActive
? 'bg-surface-raised text-text-secondary'
: 'bg-surface-raised/70 text-text-muted'
}`}
>
{count}
</span>
</Button>
);
})}

View file

@ -9,6 +9,7 @@ import {
inferCapabilities,
normalizeCategory,
} from '@shared/utils/extensionNormalizers';
import { Tag } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge';
@ -39,49 +40,57 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem
onClick(plugin.pluginId);
}
}}
className={`flex w-full cursor-pointer flex-col gap-2 rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
className={`hover:bg-surface-raised/45 flex w-full cursor-pointer flex-col gap-3 rounded-xl border bg-transparent p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/35' : 'border-border'
}`}
>
{/* Header: name + installed badge */}
{/* Header: name + status/meta */}
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-text">{plugin.name}</h3>
{plugin.isInstalled && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
</Badge>
)}
<div className="min-w-0 space-y-1">
<h3 className="truncate text-sm font-semibold text-text">{plugin.name}</h3>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="secondary" className="text-[11px]">
{category}
</Badge>
{capabilities.map((cap) => (
<Badge
key={cap}
variant="outline"
className="bg-surface-raised/60 border-border text-[11px] text-text-secondary"
>
{getCapabilityLabel(cap)}
</Badge>
))}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<InstallCountBadge count={plugin.installCount} />
{plugin.isInstalled && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
</Badge>
)}
</div>
</div>
{/* Description */}
<p className="line-clamp-2 text-xs text-text-secondary">{plugin.description}</p>
<p className="line-clamp-3 min-h-[3.75rem] text-xs leading-5 text-text-secondary">
{plugin.description}
</p>
{/* Category + Capabilities */}
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="secondary" className="text-xs">
{category}
</Badge>
{capabilities.map((cap) => (
<Badge
key={cap}
variant="outline"
className="border-purple-500/30 bg-purple-500/10 text-xs text-purple-400"
>
{getCapabilityLabel(cap)}
</Badge>
))}
</div>
{/* Footer: author, install count, install button */}
{/* Footer: author + version + install button */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs text-text-muted">
{plugin.author?.name ?? 'Unknown author'}
</span>
<InstallCountBadge count={plugin.installCount} />
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
<span className="truncate">{plugin.author?.name ?? 'Unknown author'}</span>
{plugin.version && (
<span className="inline-flex shrink-0 items-center gap-1">
<Tag className="size-3" />
{plugin.version}
</span>
)}
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>

View file

@ -28,7 +28,7 @@ import {
inferCapabilities,
normalizeCategory,
} from '@shared/utils/extensionNormalizers';
import { ExternalLink, Loader2 } from 'lucide-react';
import { ExternalLink, Loader2, Mail } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge';
@ -108,6 +108,12 @@ export const PluginDetailDialog = ({
<span className="text-text-muted">Category</span>
<p className="capitalize text-text">{category}</p>
</div>
{plugin.version && (
<div>
<span className="text-text-muted">Version</span>
<p className="text-text">{plugin.version}</p>
</div>
)}
<div>
<span className="text-text-muted">Capabilities</span>
<div className="mt-0.5 flex flex-wrap gap-1">
@ -157,17 +163,29 @@ export const PluginDetailDialog = ({
/>
</div>
{/* Homepage link */}
{plugin.homepage && (
<Button
variant="link"
className="h-auto justify-start p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(plugin.homepage!)}
>
<ExternalLink className="mr-1 size-3.5" />
Homepage
</Button>
)}
{/* Links */}
<div className="flex items-center gap-4">
{plugin.homepage && (
<Button
variant="link"
className="h-auto justify-start p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(plugin.homepage!)}
>
<ExternalLink className="mr-1 size-3.5" />
Homepage
</Button>
)}
{plugin.author?.email && (
<Button
variant="link"
className="h-auto justify-start p-0 text-sm text-blue-400"
onClick={() => void api.openExternal(`mailto:${plugin.author!.email}`)}
>
<Mail className="mr-1 size-3.5" />
Contact
</Button>
)}
</div>
{/* README */}
<div className="mt-2 max-h-80 overflow-y-auto rounded-md border border-border bg-surface-raised p-4">

View file

@ -4,6 +4,7 @@
import { useMemo } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
@ -16,7 +17,7 @@ import {
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { inferCapabilities, normalizeCategory } from '@shared/utils/extensionNormalizers';
import { Puzzle, Search } from 'lucide-react';
import { Filter, Puzzle, Search } from 'lucide-react';
import { SearchInput } from '../common/SearchInput';
@ -95,7 +96,7 @@ function selectFilteredPlugins(
result = [...result].sort((a, b) => {
switch (sort.field) {
case 'popularity':
return (b.installCount - a.installCount) * direction;
return (a.installCount - b.installCount) * direction;
case 'name':
return a.name.localeCompare(b.name) * direction;
case 'category':
@ -137,11 +138,29 @@ export const PluginsPanel = ({
);
const sortValue = `${pluginSort.field}:${pluginSort.order}`;
const activeFilterCount =
pluginFilters.categories.length +
pluginFilters.capabilities.length +
(pluginFilters.installedOnly ? 1 : 0) +
(pluginFilters.search ? 1 : 0);
const totalCategoryCount = useMemo(
() => new Set(catalog.map((plugin) => normalizeCategory(plugin.category))).size,
[catalog]
);
const totalCapabilityCount = useMemo(() => {
const counts = new Set<PluginCapability>();
for (const plugin of catalog) {
for (const capability of inferCapabilities(plugin)) {
counts.add(capability);
}
}
return counts.size;
}, [catalog]);
return (
<div className="flex flex-col gap-4">
{/* Search + Sort + Installed only row */}
<div className="flex items-center gap-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex-1">
<SearchInput
value={pluginFilters.search}
@ -149,76 +168,132 @@ export const PluginsPanel = ({
placeholder="Search plugins..."
/>
</div>
<Select
value={sortValue}
onValueChange={(v) => {
const [field, order] = v.split(':') as [PluginSortField, 'asc' | 'desc'];
setPluginSort({ field, order });
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Checkbox
id="installed-only"
checked={pluginFilters.installedOnly}
onCheckedChange={toggleInstalledOnly}
/>
<Label htmlFor="installed-only" className="whitespace-nowrap text-xs text-text-secondary">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select
value={sortValue}
onValueChange={(v) => {
const [field, order] = v.split(':') as [PluginSortField, 'asc' | 'desc'];
setPluginSort({ field, order });
}}
>
<SelectTrigger className="w-full sm:w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Label
htmlFor="installed-only"
className="bg-surface-raised/40 flex h-10 cursor-pointer items-center gap-2 rounded-lg border border-border px-3 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
>
<Checkbox
id="installed-only"
checked={pluginFilters.installedOnly}
onCheckedChange={toggleInstalledOnly}
/>
Installed only
</Label>
</div>
</div>
{/* Filters */}
<div className="bg-surface-raised/50 rounded-md border border-border p-3">
<div className="flex flex-col gap-2.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-muted">
Categories
</span>
<div className="bg-surface-raised/20 rounded-xl p-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="bg-surface-raised/60 flex size-8 items-center justify-center rounded-lg border border-border">
<Filter className="size-4 text-text-muted" />
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold text-text">Browse by fit</h2>
<Badge variant="outline" className="text-[11px] text-text-muted">
{activeFilterCount} active
</Badge>
</div>
<p className="text-xs text-text-muted">
Narrow the catalog by category, capability, or installed state.
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-text-muted">
<Badge variant="secondary" className="font-normal">
{catalog.length} plugins
</Badge>
<Badge variant="secondary" className="font-normal">
{totalCategoryCount} categories
</Badge>
<Badge variant="secondary" className="font-normal">
{totalCapabilityCount} capabilities
</Badge>
</div>
</div>
{hasActiveFilters && (
<Button
variant="link"
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-auto p-0 text-xs text-[var(--color-accent)]"
className="justify-start rounded-lg border border-border px-3 text-xs text-text-secondary hover:text-text lg:justify-center"
>
Clear all
Clear all filters
</Button>
)}
</div>
<CategoryChips
plugins={catalog}
selected={pluginFilters.categories}
onToggle={toggleCategory}
/>
<div className="border-b border-border" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-muted">
Capabilities
</span>
<CapabilityChips
plugins={catalog}
selected={pluginFilters.capabilities}
onToggle={toggleCapability}
/>
<div className="grid gap-4 xl:grid-cols-2">
<section className="space-y-3 rounded-lg border border-border bg-transparent p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
Categories
</span>
<span className="text-[11px] text-text-muted">
{pluginFilters.categories.length} selected
</span>
</div>
<CategoryChips
plugins={catalog}
selected={pluginFilters.categories}
onToggle={toggleCategory}
/>
</section>
<section className="space-y-3 rounded-lg border border-border bg-transparent p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted">
Capabilities
</span>
<span className="text-[11px] text-text-muted">
{pluginFilters.capabilities.length} selected
</span>
</div>
<CapabilityChips
plugins={catalog}
selected={pluginFilters.capabilities}
onToggle={toggleCapability}
/>
</section>
</div>
</div>
</div>
{/* Result count */}
{!loading && !error && filtered.length > 0 && (
<p className="text-xs text-text-muted">
{filtered.length} plugin{filtered.length !== 1 ? 's' : ''}
</p>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-text-muted">
Showing {filtered.length} of {catalog.length} plugin{catalog.length !== 1 ? 's' : ''}
</p>
{hasActiveFilters && (
<p className="text-xs text-text-muted">
Results update instantly as you refine filters.
</p>
)}
</div>
)}
{/* Content */}

View file

@ -183,10 +183,16 @@ export const CommandPalette = (): React.JSX.Element | null => {
const [totalMatches, setTotalMatches] = useState(0);
const [searchIsPartial, setSearchIsPartial] = useState(false);
const [globalSearchEnabled, setGlobalSearchEnabled] = useState(false);
const [browsingProjects, setBrowsingProjects] = useState(false);
const latestSearchRequestRef = useRef(0);
// Determine search mode based on whether a project is selected OR global search is enabled
const searchMode: SearchMode = selectedProjectId || globalSearchEnabled ? 'sessions' : 'projects';
// browsingProjects overrides back to project selection
const searchMode: SearchMode = browsingProjects
? 'projects'
: selectedProjectId || globalSearchEnabled
? 'sessions'
: 'projects';
// Filter projects for project search mode
const filteredProjects = useMemo(() => {
@ -235,6 +241,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
setTotalMatches(0);
setSearchIsPartial(false);
setGlobalSearchEnabled(false);
setBrowsingProjects(false);
}
}, [commandPaletteOpen]);
@ -291,15 +298,32 @@ export const CommandPalette = (): React.JSX.Element | null => {
setSelectedIndex(0);
}, [filteredProjects, sessionResults]);
// Handle project click
// Handle project click — select project without closing palette
const handleProjectClick = useCallback(
(repo: RepositoryGroup) => {
closeCommandPalette();
selectRepository(repo.id);
setBrowsingProjects(false);
setQuery('');
setSessionResults([]);
setSelectedIndex(0);
setTotalMatches(0);
setSearchIsPartial(false);
inputRef.current?.focus();
},
[closeCommandPalette, selectRepository]
[selectRepository]
);
// Handle clearing project filter — go back to project browsing
const handleClearProject = useCallback(() => {
setBrowsingProjects(true);
setQuery('');
setSessionResults([]);
setSelectedIndex(0);
setTotalMatches(0);
setSearchIsPartial(false);
inputRef.current?.focus();
}, []);
// Handle session result click
const handleSessionResultClick = useCallback(
(result: SearchResult) => {
@ -438,14 +462,20 @@ export const CommandPalette = (): React.JSX.Element | null => {
<span className="text-xs text-text-muted">
{globalSearchEnabled ? 'Search across all projects' : 'Search in project'}
</span>
{!globalSearchEnabled && (
{!globalSearchEnabled && selectedProjectId && (
<>
<span className="text-text-muted/50 mx-1 text-xs">·</span>
<span className="truncate text-xs text-text-secondary">
{repositoryGroups.find((r) =>
r.worktrees.some((w) => w.id === selectedProjectId)
)?.name ?? 'Current project'}
</span>
<button
onClick={handleClearProject}
className="flex items-center gap-1.5 rounded-full bg-surface-raised px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-surface-overlay hover:text-text"
>
<span className="max-w-[200px] truncate">
{repositoryGroups.find((r) =>
r.worktrees.some((w) => w.id === selectedProjectId)
)?.name ?? 'Current project'}
</span>
<X className="size-3 shrink-0" />
</button>
</>
)}
</>

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -198,6 +198,41 @@ export const GlobalTaskList = ({
const readState = useReadStateSnapshot();
const taskLocalState = useTaskLocalState();
// --- New-task animation tracking (same pattern as ChatHistory) ---
const knownTaskIdsRef = useRef<Set<string>>(new Set());
const isInitialTaskLoadRef = useRef(true);
const newTaskIds = useMemo(() => {
if (!globalTasksInitialized || globalTasks.length === 0) {
return new Set<string>();
}
// First load: seed all known IDs, no animations
if (isInitialTaskLoadRef.current) {
isInitialTaskLoadRef.current = false;
for (const t of globalTasks) {
knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`);
}
return new Set<string>();
}
// Subsequent updates: detect truly new tasks
const newIds = new Set<string>();
for (const t of globalTasks) {
const key = `${t.teamName}:${t.id}`;
if (!knownTaskIdsRef.current.has(key)) {
newIds.add(key);
knownTaskIdsRef.current.add(key);
}
}
return newIds;
}, [globalTasks, globalTasksInitialized]);
const isNewTask = useCallback(
(task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`),
[newTaskIds]
);
// Local project filter (independent from sessions tab)
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(null);
@ -481,6 +516,7 @@ export const GlobalTaskList = ({
<SidebarTaskItem
task={task}
showTeamName
isNew={isNewTask(task)}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
@ -578,6 +614,7 @@ export const GlobalTaskList = ({
<SidebarTaskItem
task={task}
showTeamName
isNew={isNewTask(task)}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
@ -643,6 +680,7 @@ export const GlobalTaskList = ({
<SidebarTaskItem
task={task}
hideTeamName
isNew={isNewTask(task)}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
@ -708,6 +746,7 @@ export const GlobalTaskList = ({
>
<SidebarTaskItem
task={task}
isNew={isNewTask(task)}
renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}

View file

@ -58,6 +58,8 @@ interface SidebarTaskItemProps {
task: GlobalTask;
hideTeamName?: boolean;
showTeamName?: boolean;
/** When true, the item plays an enter animation */
isNew?: boolean;
/** The composite key "teamName:taskId" of the task being renamed, or null */
renamingKey?: string | null;
/** Called when rename is completed with Enter or blur */
@ -72,6 +74,7 @@ export const SidebarTaskItem = ({
task,
hideTeamName,
showTeamName,
isNew,
renamingKey,
onRenameComplete,
onRenameCancel,
@ -144,10 +147,12 @@ export const SidebarTaskItem = ({
const showTeamRow = showTeamName && !hideTeamName;
const enterClass = isNew ? 'task-item-enter-animate' : '';
return (
<button
type="button"
className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''}`}
className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''} ${enterClass}`}
style={{ borderColor: 'var(--color-border)' }}
onClick={() => {
if (!isRenaming) {

View file

@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useAttachments } from '@renderer/hooks/useAttachments';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useStore } from '@renderer/store';
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
@ -183,6 +184,8 @@ export const SendMessageDialog = ({
[members, colorMap]
);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
const trimmedText = textDraft.value.trim();
@ -437,6 +440,7 @@ export const SendMessageDialog = ({
value={textDraft.value}
onValueChange={textDraft.setValue}
suggestions={mentionSuggestions}
teamSuggestions={teamMentionSuggestions}
chips={chipDraft.chips}
onChipRemove={handleChipRemove}
projectPath={projectPath}

View file

@ -8,6 +8,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useComposerDraft } from '@renderer/hooks/useComposerDraft';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
@ -131,6 +132,8 @@ export const MessageComposer = ({
[members, colorMap]
);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const trimmed = draft.text.trim();
const selectedMember = members.find((m) => m.name === recipient);
@ -751,6 +754,7 @@ export const MessageComposer = ({
value={draft.text}
onValueChange={draft.setText}
suggestions={mentionSuggestions}
teamSuggestions={teamMentionSuggestions}
chips={draft.chips}
onChipRemove={draft.removeChip}
projectPath={projectPath}

View file

@ -2,7 +2,8 @@ import { useEffect, useRef } from 'react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { Loader2 } from 'lucide-react';
import { nameColorSet } from '@renderer/utils/projectColor';
import { Loader2, UsersRound } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -68,33 +69,53 @@ export const MentionSuggestionList = ({
if (suggestions.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-3 py-2 text-xs text-[var(--color-text-muted)]">
{hasFileSearch ? 'No matching members or files' : 'No matching members'}
{hasFileSearch ? 'No matching members, teams, or files' : 'No matching members'}
</div>
);
}
// Determine if we need grouped sections
const hasMemberItems = suggestions.some((s) => s.type !== 'file');
const hasFileItems = suggestions.some((s) => s.type === 'file');
const showSections = hasMemberItems && hasFileItems;
// Categorize suggestions
type Section = 'member' | 'team' | 'file';
const getSuggestionSection = (s: MentionSuggestion): Section => {
if (s.type === 'file') return 'file';
if (s.type === 'team') return 'team';
return 'member';
};
const sectionLabel: Record<Section, string> = {
member: 'Members',
team: 'Teams',
file: 'Files',
};
// Determine which sections are present
const presentSections = new Set(suggestions.map(getSuggestionSection));
const showSections = presentSections.size > 1;
// Build items with section headers inserted
const items: React.JSX.Element[] = [];
let currentSection: 'member' | 'file' | null = null;
let currentSection: Section | null = null;
let optionIndex = 0;
for (const s of suggestions) {
const isFile = s.type === 'file';
const section = isFile ? 'file' : 'member';
const section = getSuggestionSection(s);
const isFile = section === 'file';
const isTeam = section === 'team';
// Insert section header on transition
if (showSections && section !== currentSection) {
items.push(<SectionHeader key={`section-${section}`} label={isFile ? 'Files' : 'Members'} />);
items.push(<SectionHeader key={`section-${section}`} label={sectionLabel[section]} />);
currentSection = section;
}
const isSelected = optionIndex === selectedIndex;
const colorSet = !isFile && s.color ? getTeamColorSet(s.color) : null;
const colorSet = isFile
? null
: s.color
? getTeamColorSet(s.color)
: isTeam
? nameColorSet(s.name)
: null;
const idx = optionIndex;
optionIndex++;
@ -116,6 +137,12 @@ export const MentionSuggestionList = ({
>
{isFile ? (
<FileIcon fileName={s.name} className="size-3.5" />
) : isTeam ? (
<UsersRound
size={13}
className="shrink-0"
style={{ color: colorSet?.text ?? 'var(--color-text-muted)' }}
/>
) : (
<span
className="inline-block size-2.5 shrink-0 rounded-full"
@ -128,6 +155,13 @@ export const MentionSuggestionList = ({
>
<HighlightedName name={s.name} query={query} />
</span>
{isTeam && s.isOnline !== undefined ? (
<span
className="inline-block size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: s.isOnline ? '#22c55e' : '#71717a' }}
title={s.isOnline ? 'Online' : 'Offline'}
/>
) : null}
{s.subtitle ? (
<span className="truncate text-[var(--color-text-muted)]">{s.subtitle}</span>
) : null}

View file

@ -6,6 +6,7 @@ import { useMentionDetection } from '@renderer/hooks/useMentionDetection';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { chipToken } from '@renderer/types/inlineChip';
import { nameColorSet } from '@renderer/utils/projectColor';
import {
createChipFromSelection,
findChipBoundary,
@ -207,6 +208,8 @@ interface MentionableTextareaProps extends Omit<
projectPath?: string | null;
/** Called when a file chip is created via @ selection. Parent must add chip to state. */
onFileChipInsert?: (chip: InlineChip) => void;
/** Team suggestions for cross-team @mentions */
teamSuggestions?: MentionSuggestion[];
/** Called when Enter (without Shift) is pressed. */
onModEnter?: () => void;
}
@ -226,6 +229,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
onChipRemove,
projectPath,
onFileChipInsert,
teamSuggestions = [],
onModEnter,
style,
className,
@ -271,7 +275,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
value,
onValueChange,
textareaRef: internalRef,
enableTriggerAlways: enableFiles,
enableTriggerAlways: enableFiles || teamSuggestions.length > 0,
});
// --- File suggestions ---
@ -281,12 +285,23 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
isOpen && enableFiles
);
// Merged suggestion list: members first, then files
// --- Team suggestions filtered by query ---
const filteredTeamSuggestions = React.useMemo(() => {
if (teamSuggestions.length === 0 || !isOpen) return [];
if (!query) return teamSuggestions;
const lower = query.toLowerCase();
return teamSuggestions.filter((t) => t.name.toLowerCase().includes(lower));
}, [teamSuggestions, isOpen, query]);
// Merged suggestion list: members → online teams → offline teams → files
const allSuggestions = React.useMemo(() => {
if (!enableFiles) return memberSuggestions;
if (fileSuggestions.length === 0) return memberSuggestions;
return [...memberSuggestions, ...fileSuggestions];
}, [enableFiles, memberSuggestions, fileSuggestions]);
const onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline);
const offlineTeams = filteredTeamSuggestions.filter((t) => !t.isOnline);
const merged = [...memberSuggestions, ...onlineTeams, ...offlineTeams];
if (!enableFiles) return merged;
if (fileSuggestions.length === 0) return merged;
return [...merged, ...fileSuggestions];
}, [memberSuggestions, filteredTeamSuggestions, enableFiles, fileSuggestions]);
// When files are enabled, manage our own selectedIndex for the merged list
const [mergedIndex, setMergedIndex] = React.useState(0);
@ -296,9 +311,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
setMergedIndex(0);
}, [query, allSuggestions.length]);
// Effective index: use merged when files enabled, hook's index otherwise
const effectiveIndex = enableFiles ? mergedIndex : selectedIndex;
const effectiveSuggestions = enableFiles ? allSuggestions : memberSuggestions;
// Use merged index when we have extra suggestion types (teams or files)
const hasMergedSuggestions = enableFiles || teamSuggestions.length > 0;
// Effective index: use merged when extra types present, hook's index otherwise
const effectiveIndex = hasMergedSuggestions ? mergedIndex : selectedIndex;
const effectiveSuggestions = hasMergedSuggestions ? allSuggestions : memberSuggestions;
// --- File selection handler ---
const handleFileSelect = React.useCallback(
@ -386,11 +404,17 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
}, [value]);
// --- Overlay activation ---
const hasOverlay = suggestions.length > 0 || chips.length > 0;
const hasOverlay = suggestions.length > 0 || teamSuggestions.length > 0 || chips.length > 0;
// Combine member + team suggestions for overlay parsing
const allOverlaySuggestions = React.useMemo(
() => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions),
[suggestions, teamSuggestions]
);
const segments = React.useMemo(
() => (hasOverlay ? parseSegments(value, suggestions, chips) : []),
[hasOverlay, value, suggestions, chips]
() => (hasOverlay ? parseSegments(value, allOverlaySuggestions, chips) : []),
[hasOverlay, value, allOverlaySuggestions, chips]
);
// Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer
@ -512,7 +536,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// When mention dropdown is open, let mention handler consume Enter/Arrow keys first
if (isOpen && effectiveSuggestions.length > 0) {
if (enableFiles) {
if (hasMergedSuggestions) {
fileMentionHandleKeyDown(e);
} else {
mentionHandleKeyDown(e);
@ -527,7 +551,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
}
handleChipKeyDown(e);
if (!e.defaultPrevented && !isOpen) {
if (enableFiles) {
if (hasMergedSuggestions) {
fileMentionHandleKeyDown(e);
} else {
mentionHandleKeyDown(e);
@ -537,7 +561,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
[
onModEnter,
handleChipKeyDown,
enableFiles,
hasMergedSuggestions,
fileMentionHandleKeyDown,
mentionHandleKeyDown,
isOpen,
@ -645,7 +669,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
}, [advanceTip]);
const resolvedHintText = hintText ?? rotatingTips[tipIndex];
const showHintRow = showHint && (suggestions.length > 0 || enableFiles);
const showHintRow =
showHint && (suggestions.length > 0 || enableFiles || teamSuggestions.length > 0);
const showFooter = showHintRow || footerRight;
return (
@ -673,10 +698,13 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
if (seg.type === 'chip') {
return <CodeChipBadge key={idx} chip={seg.chip} tokenText={seg.value} />;
}
// mention
// mention (member or team)
const isTeamMention = seg.suggestion.type === 'team';
const colorSet = seg.suggestion.color
? getTeamColorSet(seg.suggestion.color)
: null;
: isTeamMention
? nameColorSet(seg.suggestion.name, isLight)
: null;
const bg = colorSet ? getThemedBadge(colorSet, isLight) : DEFAULT_MENTION_BG;
const fg = colorSet?.text ?? DEFAULT_MENTION_TEXT;
return (
@ -751,7 +779,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
<MentionSuggestionList
suggestions={effectiveSuggestions}
selectedIndex={effectiveIndex}
onSelect={enableFiles ? handleMergedSelect : selectSuggestion}
onSelect={hasMergedSuggestions ? handleMergedSelect : selectSuggestion}
query={query}
hasFileSearch={enableFiles}
filesLoading={enableFiles && filesLoading}

View file

@ -16,7 +16,7 @@ import type {
PluginSortField,
} from '@shared/types/extensions';
export type ExtensionsSubTab = 'plugins' | 'mcp-servers';
export type ExtensionsSubTab = 'plugins' | 'mcp-servers' | 'api-keys';
interface PluginSortState {
field: PluginSortField;

View file

@ -0,0 +1,77 @@
/**
* Hook for providing team @-mention suggestions.
*
* Returns non-deleted teams (excluding the current one) as MentionSuggestion[]
* with online/offline status. Uses the alive list API to determine status.
*
* The returned list is unfiltered query filtering is handled downstream
* by useMentionDetection inside MentionableTextarea.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import type { MentionSuggestion } from '@renderer/types/mention';
export interface UseTeamSuggestionsResult {
suggestions: MentionSuggestion[];
loading: boolean;
}
/**
* Returns team MentionSuggestion[] sorted by online status (online first).
*
* @param currentTeamName - The current team name to exclude from suggestions
*/
export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult {
const teams = useStore((s) => s.teams);
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const fetchAlive = useCallback(async () => {
setLoading(true);
try {
const list = await api.teams.aliveList();
setAliveTeams(new Set(list));
} catch {
// best-effort — treat all as offline on error
} finally {
setLoading(false);
}
}, []);
// Fetch on mount and when teams list changes
useEffect(() => {
void fetchAlive();
}, [fetchAlive, teams]);
// Build suggestion list sorted: online first, then offline
const suggestions = useMemo<MentionSuggestion[]>(() => {
const nonDeleted = teams.filter((t) => !t.deletedAt && t.teamName !== currentTeamName);
const result: MentionSuggestion[] = nonDeleted.map((t) => {
const isOnline = aliveTeams.has(t.teamName);
return {
id: `team:${t.teamName}`,
name: t.displayName || t.teamName,
subtitle: isOnline ? 'online' : 'offline',
color: t.color,
type: 'team' as const,
isOnline,
};
});
// Sort: online teams first
result.sort((a, b) => {
if (a.isOnline && !b.isOnline) return -1;
if (!a.isOnline && b.isOnline) return 1;
return 0;
});
return result;
}, [teams, currentTeamName, aliveTeams]);
return { suggestions, loading };
}

View file

@ -709,6 +709,24 @@ body {
animation: chat-message-enter 350ms ease-out both;
}
@keyframes task-item-enter {
from {
opacity: 0;
max-height: 0;
transform: translateY(-4px);
overflow: hidden;
}
to {
opacity: 1;
max-height: 80px;
transform: translateY(0);
}
}
.task-item-enter-animate {
animation: task-item-enter 280ms ease-out both;
}
@keyframes thought-expand {
from {
max-height: 0;

View file

@ -7,11 +7,15 @@ import { api } from '@renderer/api';
import type { AppState } from '../types';
import type {
ApiKeyEntry,
ApiKeySaveRequest,
ApiKeyStorageStatus,
EnrichedPlugin,
ExtensionOperationState,
InstallScope,
InstalledMcpEntry,
McpCatalogItem,
McpCustomInstallRequest,
McpInstallRequest,
PluginInstallRequest,
} from '@shared/types/extensions';
@ -43,6 +47,16 @@ export interface ExtensionsSlice {
mcpInstallProgress: Record<string, ExtensionOperationState>;
installErrors: Record<string, string>; // keyed by pluginId or registryId
// ── API Keys ──
apiKeys: ApiKeyEntry[];
apiKeysLoading: boolean;
apiKeysError: string | null;
apiKeySaving: boolean;
apiKeyStorageStatus: ApiKeyStorageStatus | null;
// ── GitHub Stars (supplementary) ──
mcpGitHubStars: Record<string, number>;
// ── Read actions ──
fetchPluginCatalog: (projectPath?: string, forceRefresh?: boolean) => Promise<void>;
fetchPluginReadme: (pluginId: string) => void;
@ -53,6 +67,7 @@ export interface ExtensionsSlice {
installPlugin: (request: PluginInstallRequest) => Promise<void>;
uninstallPlugin: (pluginId: string, scope?: InstallScope, projectPath?: string) => Promise<void>;
installMcpServer: (request: McpInstallRequest) => Promise<void>;
installCustomMcpServer: (request: McpCustomInstallRequest) => Promise<void>;
uninstallMcpServer: (
registryId: string,
name: string,
@ -60,8 +75,17 @@ export interface ExtensionsSlice {
projectPath?: string
) => Promise<void>;
// ── API Keys actions ──
fetchApiKeys: () => Promise<void>;
fetchApiKeyStorageStatus: () => Promise<void>;
saveApiKey: (request: ApiKeySaveRequest) => Promise<void>;
deleteApiKey: (id: string) => Promise<void>;
// ── Tab opener ──
openExtensionsTab: () => void;
// ── GitHub Stars ──
fetchMcpGitHubStars: (repositoryUrls: string[]) => void;
}
// =============================================================================
@ -96,6 +120,14 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
mcpInstallProgress: {},
installErrors: {},
apiKeys: [],
apiKeysLoading: false,
apiKeysError: null,
apiKeySaving: false,
apiKeyStorageStatus: null,
mcpGitHubStars: {},
// ── Plugin catalog fetch ──
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
if (!api.plugins) return;
@ -163,11 +195,23 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
set({ mcpBrowseLoading: true, mcpBrowseError: null });
try {
const result = await api.mcpRegistry.browse(cursor);
set((prev) => ({
mcpBrowseCatalog: cursor ? [...prev.mcpBrowseCatalog, ...result.servers] : result.servers,
mcpBrowseNextCursor: result.nextCursor,
mcpBrowseLoading: false,
}));
set((prev) => {
if (!cursor) {
return {
mcpBrowseCatalog: result.servers,
mcpBrowseNextCursor: result.nextCursor,
mcpBrowseLoading: false,
};
}
// Deduplicate: existing IDs take precedence
const existingIds = new Set(prev.mcpBrowseCatalog.map((s) => s.id));
const newServers = result.servers.filter((s) => !existingIds.has(s.id));
return {
mcpBrowseCatalog: [...prev.mcpBrowseCatalog, ...newServers],
mcpBrowseNextCursor: result.nextCursor,
mcpBrowseLoading: false,
};
});
} catch (err) {
set({
mcpBrowseLoading: false,
@ -324,6 +368,53 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}
},
// ── MCP custom install ──
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
if (!api.mcpRegistry) {
const progressKey = `custom:${request.serverName}`;
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
}));
return;
}
const progressKey = `custom:${request.serverName}`;
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
}));
try {
const result = await api.mcpRegistry.installCustom(request);
if (result.state === 'error') {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' },
}));
return;
}
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
}));
// Refresh installed list
void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined);
setTimeout(() => {
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch (err) {
const message = err instanceof Error ? err.message : 'Install failed';
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
installErrors: { ...prev.installErrors, [progressKey]: message },
}));
}
},
// ── MCP uninstall ──
uninstallMcpServer: async (
registryId: string,
@ -376,6 +467,67 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}
},
// ── API Keys fetch ──
fetchApiKeys: async () => {
if (!api.apiKeys) return;
set({ apiKeysLoading: true, apiKeysError: null });
try {
const keys = await api.apiKeys.list();
set({ apiKeys: keys, apiKeysLoading: false });
} catch (err) {
set({
apiKeysLoading: false,
apiKeysError: err instanceof Error ? err.message : 'Failed to load API keys',
});
}
},
fetchApiKeyStorageStatus: async () => {
if (!api.apiKeys) return;
try {
const status = await api.apiKeys.getStorageStatus();
set({ apiKeyStorageStatus: status });
} catch {
// Non-critical — UI will just not show the info icon
}
},
// ── API Key save ──
saveApiKey: async (request: ApiKeySaveRequest) => {
if (!api.apiKeys) return;
set({ apiKeySaving: true, apiKeysError: null });
try {
await api.apiKeys.save(request);
// Refresh the list to get updated masked values
const keys = await api.apiKeys.list();
set({ apiKeys: keys, apiKeySaving: false });
} catch (err) {
set({
apiKeySaving: false,
apiKeysError: err instanceof Error ? err.message : 'Failed to save API key',
});
throw err; // Re-throw so the dialog can show the error
}
},
// ── API Key delete ──
deleteApiKey: async (id: string) => {
if (!api.apiKeys) return;
try {
await api.apiKeys.delete(id);
set((prev) => ({
apiKeys: prev.apiKeys.filter((k) => k.id !== id),
}));
} catch (err) {
set({
apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key',
});
}
},
// ── Tab opener ──
openExtensionsTab: () => {
const state = get();
@ -391,4 +543,21 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
label: 'Extensions',
});
},
// ── GitHub Stars (fire-and-forget) ──
fetchMcpGitHubStars: (repositoryUrls: string[]) => {
if (!api.mcpRegistry || repositoryUrls.length === 0) return;
void api.mcpRegistry
.githubStars(repositoryUrls)
.then((stars) => {
if (Object.keys(stars).length > 0) {
set((prev) => ({
mcpGitHubStars: { ...prev.mcpGitHubStars, ...stars },
}));
}
})
.catch(() => {
// Silent failure — stars are supplementary data
});
},
});

View file

@ -7,8 +7,10 @@ export interface MentionSuggestion {
subtitle?: string;
/** Color name from TeamColorSet palette */
color?: string;
/** Suggestion type — 'member' (default) or 'file' */
type?: 'member' | 'file';
/** Suggestion type — 'member' (default), 'team', or 'file' */
type?: 'member' | 'team' | 'file';
/** Whether the team is currently online (team suggestions only) */
isOnline?: boolean;
/** Absolute file path (file suggestions only) */
filePath?: string;
/** Relative display path (file suggestions only) */

View file

@ -45,3 +45,17 @@ export function formatDuration(ms: number): string {
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}
/**
* Formats a number in compact notation for UI display.
* 42 "42", 1200 "1.2k", 45300 "45.3k", 120000 "120k", 1500000 "1.5M"
*/
export function formatCompactNumber(n: number): string {
if (n < 1_000) return String(n);
if (n < 1_000_000) {
const k = n / 1_000;
return k < 10 ? `${k.toFixed(1)}k` : `${Math.round(k)}k`;
}
const m = n / 1_000_000;
return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`;
}

View file

@ -1,9 +1,9 @@
/**
* Shared utility for converting @memberName mentions in plain text
* to markdown links with mention:// protocol.
* Shared utility for converting @memberName and @teamName mentions in plain text
* to markdown links with mention:// and team:// protocols.
*
* Used by UserChatGroup, TeammateMessageItem, ActivityItem, TaskCommentsSection.
* MarkdownViewer already handles rendering mention:// links as colored badges.
* MarkdownViewer already handles rendering mention:// and team:// links as colored badges.
*/
/**
@ -34,3 +34,51 @@ export function linkifyMentionsInMarkdown(
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
});
}
/**
* Convert `@teamName` in plain text to markdown links with team:// protocol.
* Greedy match: longer names are tried first to avoid partial matches.
*
* @param text - The plain text to process
* @param teamNames - Set or array of known team names
* @returns Text with @teamName replaced by markdown links
*/
export function linkifyTeamMentionsInMarkdown(
text: string,
teamNames: ReadonlySet<string> | readonly string[]
): string {
const names = Array.isArray(teamNames) ? teamNames : [...teamNames];
if (names.length === 0) return text;
// Sort by name length descending for greedy matching
const sorted = [...names].sort((a, b) => b.length - a.length);
const escaped = sorted.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
// eslint-disable-next-line no-useless-escape -- escaped chars needed for regex character class
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`, 'gi');
return text.replace(pattern, (_match, prefix: string, name: string) => {
const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
return `${prefix}[@${canonical}](team://${encodeURIComponent(canonical)})`;
});
}
/**
* Apply both member and team linkification. Team names are matched first to avoid
* team names being captured as member mentions when they share similar names.
*
* @param text - The plain text to process
* @param memberColorMap - Map of member name color key
* @param teamNames - Known team names
* @returns Text with both @member and @team mentions replaced by markdown links
*/
export function linkifyAllMentionsInMarkdown(
text: string,
memberColorMap: Map<string, string>,
teamNames: ReadonlySet<string> | readonly string[] = []
): string {
// Apply team linkification first (team names tend to be longer / more specific)
let result = linkifyTeamMentionsInMarkdown(text, teamNames);
// Then member linkification on the remaining text
result = linkifyMentionsInMarkdown(result, memberColorMap);
return result;
}

View file

@ -10,7 +10,7 @@
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
import type { CliInstallerAPI } from './cliInstaller';
import type { EditorAPI, ProjectAPI } from './editor';
import type { McpCatalogAPI, PluginCatalogAPI } from './extensions';
import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI } from './extensions';
import type {
AppConfig,
DetectedError,
@ -811,6 +811,9 @@ export interface ElectronAPI {
// Extension Store — MCP Registry API (Electron-only, optional)
mcpRegistry?: McpCatalogAPI;
// Extension Store — API Keys Management (Electron-only, optional)
apiKeys?: ApiKeysAPI;
}
// =============================================================================

View file

@ -3,9 +3,21 @@
* Both APIs are OPTIONAL in ElectronAPI (Electron-only V1).
*/
import type {
ApiKeyEntry,
ApiKeyLookupResult,
ApiKeySaveRequest,
ApiKeyStorageStatus,
} from './apikey';
import type { InstallScope, OperationResult } from './common';
import type { EnrichedPlugin, PluginInstallRequest } from './plugin';
import type { InstalledMcpEntry, McpCatalogItem, McpInstallRequest, McpSearchResult } from './mcp';
import type {
InstalledMcpEntry,
McpCatalogItem,
McpCustomInstallRequest,
McpInstallRequest,
McpSearchResult,
} from './mcp';
// ── Plugin API ─────────────────────────────────────────────────────────────
@ -31,5 +43,17 @@ export interface McpCatalogAPI {
getById: (registryId: string) => Promise<McpCatalogItem | null>;
getInstalled: (projectPath?: string) => Promise<InstalledMcpEntry[]>;
install: (request: McpInstallRequest) => Promise<OperationResult>;
installCustom: (request: McpCustomInstallRequest) => Promise<OperationResult>;
uninstall: (name: string, scope?: string, projectPath?: string) => Promise<OperationResult>;
githubStars: (repositoryUrls: string[]) => Promise<Record<string, number>>;
}
// ── API Keys API ──────────────────────────────────────────────────────────
export interface ApiKeysAPI {
list: () => Promise<ApiKeyEntry[]>;
save: (request: ApiKeySaveRequest) => Promise<ApiKeyEntry>;
delete: (id: string) => Promise<void>;
lookup: (envVarNames: string[]) => Promise<ApiKeyLookupResult[]>;
getStorageStatus: () => Promise<ApiKeyStorageStatus>;
}

View file

@ -0,0 +1,38 @@
/**
* API Key management types stored encrypted, transmitted masked.
*/
/** API key entry returned from backend (with masked value) */
export interface ApiKeyEntry {
id: string;
name: string;
envVarName: string;
maskedValue: string;
scope: 'user' | 'project';
createdAt: string;
}
/** Request to create or update an API key */
export interface ApiKeySaveRequest {
id?: string;
name: string;
envVarName: string;
value: string;
scope: 'user' | 'project';
}
/** Decrypted key lookup result (for auto-fill) */
export interface ApiKeyLookupResult {
envVarName: string;
value: string;
}
/** Storage encryption status (for UI display) */
export interface ApiKeyStorageStatus {
/** How keys are encrypted: OS keychain or local AES-256-GCM */
encryptionMethod: 'os-keychain' | 'aes-local';
/** Human-readable backend name: "macOS Keychain", "gnome_libsecret", etc. */
backend: string;
/** Whether the storage file has secure permissions (0o600) */
fileSecure: boolean;
}

View file

@ -18,8 +18,10 @@ export { inferCapabilities } from './plugin';
export type {
InstalledMcpEntry,
McpCatalogItem,
McpCustomInstallRequest,
McpEnvVarDef,
McpHeaderDef,
McpHostingType,
McpHttpInstallSpec,
McpInstallRequest,
McpInstallSpec,
@ -28,4 +30,11 @@ export type {
McpToolDef,
} from './mcp';
export type { McpCatalogAPI, PluginCatalogAPI } from './api';
export type {
ApiKeyEntry,
ApiKeyLookupResult,
ApiKeySaveRequest,
ApiKeyStorageStatus,
} from './apikey';
export type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI } from './api';

View file

@ -4,6 +4,8 @@
// ── Catalog item (normalized from Official Registry / Glama) ───────────────
export type McpHostingType = 'local' | 'remote' | 'both';
export interface McpCatalogItem {
id: string; // Official: reverse-DNS (e.g. "io.github.upstash/context7"), Glama: "glama:<id>"
name: string; // display name
@ -18,6 +20,12 @@ export interface McpCatalogItem {
glamaUrl?: string;
requiresAuth: boolean; // true if HTTP server has required headers
iconUrl?: string; // First icon URL from official registry (icons[0].src)
websiteUrl?: string;
status?: string;
publishedAt?: string;
updatedAt?: string;
author?: string;
hostingType?: McpHostingType;
}
export interface McpToolDef {
@ -77,6 +85,17 @@ export interface McpInstallRequest {
headers: McpHeaderDef[]; // for HTTP/SSE servers (CLI --header flag)
}
// ── Custom install request (bypasses registry, user provides spec) ──────────
export interface McpCustomInstallRequest {
serverName: string;
scope: 'local' | 'user' | 'project';
projectPath?: string;
installSpec: McpInstallSpec; // user provides directly
envValues: Record<string, string>;
headers: McpHeaderDef[];
}
// ── Search result wrapper ──────────────────────────────────────────────────
export interface McpSearchResult {

View file

@ -100,3 +100,23 @@ export function sanitizeMcpServerName(displayName: string): string {
.replace(/\s+/g, '-')
.replace(/[^\w.-]/g, '');
}
/**
* Extract owner/repo from a GitHub URL. Returns null for non-GitHub URLs.
* Handles: https://github.com/owner/repo, https://github.com/owner/repo.git, trailing slashes.
*/
export function parseGitHubOwnerRepo(url: string): { owner: string; repo: string } | null {
try {
const parsed = new URL(url);
if (parsed.hostname !== 'github.com') return null;
const parts = parsed.pathname
.replace(/^\//, '')
.replace(/\.git$/, '')
.replace(/\/+$/, '')
.split('/');
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
return { owner: parts[0], repo: parts[1] };
} catch {
return null;
}
}