mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 16:41:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d6ba3a9e | ||
|
|
37e7ac9f56 |
No files matched your search
@@ -356,6 +356,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Focus window on host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'When an MCP client opens a host, bring the main window to the foreground. Turn off to keep working without interruption.',
|
||||
'ai.externalMcp.approvalAlert': 'Approval alerts',
|
||||
'ai.externalMcp.approvalAlert.description': 'When an external MCP client asks for confirmation, show a system notification. While Netcatty is unfocused, the taskbar or Dock asks for attention. The notification uses the system sound.',
|
||||
'ai.externalMcp.approvalAlert.off': 'Off',
|
||||
'ai.externalMcp.approvalAlert.unfocused': 'When unfocused',
|
||||
'ai.externalMcp.approvalAlert.always': 'Always',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': 'External MCP approval',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} is waiting for approval',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} on {host} is waiting for approval',
|
||||
'ai.externalMcp.silentSessions': 'Silent MCP sessions',
|
||||
'ai.externalMcp.silentSessions.description': 'Sessions opened by AI stay out of your tab bar and are not restored after restart. View them anytime from the tray panel.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Opened session idle timeout',
|
||||
|
||||
@@ -347,6 +347,14 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
|
||||
'ai.externalMcp.approvalAlert': 'Alertas de aprobación',
|
||||
'ai.externalMcp.approvalAlert.description': 'Cuando un cliente MCP externo pide confirmación, muestra una notificación del sistema. Si Netcatty no tiene el foco, la barra de tareas o el Dock pide atención. La notificación usa el sonido del sistema.',
|
||||
'ai.externalMcp.approvalAlert.off': 'Desactivado',
|
||||
'ai.externalMcp.approvalAlert.unfocused': 'Si no tiene el foco',
|
||||
'ai.externalMcp.approvalAlert.always': 'Siempre',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': 'Aprobación de MCP externo',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} espera aprobación',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} en {host} espera aprobación',
|
||||
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
|
||||
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'ai.externalMcp.approvalAlert',
|
||||
'ai.externalMcp.approvalAlert.description',
|
||||
'ai.externalMcp.approvalAlert.off',
|
||||
'ai.externalMcp.approvalAlert.unfocused',
|
||||
'ai.externalMcp.approvalAlert.always',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle',
|
||||
'ai.externalMcp.approvalAlert.notificationBody',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost',
|
||||
] as const;
|
||||
|
||||
test('External MCP approval alerts are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing approval alert labels`);
|
||||
assert.match(messages['ai.externalMcp.approvalAlert.notificationBody'], /\{tool\}/);
|
||||
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{tool\}/);
|
||||
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{host\}/);
|
||||
}
|
||||
});
|
||||
@@ -303,6 +303,14 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'мин',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Активировать окно при host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Когда MCP-клиент открывает хост, выводить главное окно на передний план. Отключите, чтобы работать без прерываний.',
|
||||
'ai.externalMcp.approvalAlert': 'Оповещения об одобрении',
|
||||
'ai.externalMcp.approvalAlert.description': 'Когда внешний MCP-клиент запрашивает подтверждение, показать системное уведомление. Пока Netcatty не в фокусе, панель задач или Dock привлекают внимание. Уведомление использует системный звук.',
|
||||
'ai.externalMcp.approvalAlert.off': 'Выкл.',
|
||||
'ai.externalMcp.approvalAlert.unfocused': 'Без фокуса',
|
||||
'ai.externalMcp.approvalAlert.always': 'Всегда',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': 'Одобрение External MCP',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} ожидает одобрения',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} на {host} ожидает одобрения',
|
||||
'ai.externalMcp.silentSessions': 'Бесшумные сеансы MCP',
|
||||
'ai.externalMcp.silentSessions.description': 'Сеансы, открытые ИИ, не отображаются на панели вкладок и не восстанавливаются после перезапуска. Их можно посмотреть в любой момент через панель в трее.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Тайм-аут открытого сеанса',
|
||||
|
||||
@@ -356,6 +356,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': '分钟',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 时激活窗口',
|
||||
'ai.externalMcp.focusOnHostOpen.description': '当 MCP 客户端打开主机连接时,将主窗口切换到前台。关闭后可不受打扰地继续当前工作。',
|
||||
'ai.externalMcp.approvalAlert': '审批提醒',
|
||||
'ai.externalMcp.approvalAlert.description': '外部 MCP 客户端请求确认时显示系统通知。Netcatty 未聚焦时,任务栏或 Dock 会请求注意。通知使用系统提示音。',
|
||||
'ai.externalMcp.approvalAlert.off': '关闭',
|
||||
'ai.externalMcp.approvalAlert.unfocused': '未聚焦时',
|
||||
'ai.externalMcp.approvalAlert.always': '始终',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': '对外 MCP 审批',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待审批',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待审批',
|
||||
'ai.externalMcp.silentSessions': 'AI 会话静默运行',
|
||||
'ai.externalMcp.silentSessions.description': 'AI 打开的会话不会出现在标签栏,重启后也不会恢复。可随时在托盘面板中查看。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已打开会话空闲超时',
|
||||
|
||||
@@ -346,6 +346,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': '分鐘',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 時啟用視窗',
|
||||
'ai.externalMcp.focusOnHostOpen.description': '當 MCP 用戶端開啟主機連線時,將主視窗切換到前景。關閉後可不受打擾地繼續目前工作。',
|
||||
'ai.externalMcp.approvalAlert': '核准提醒',
|
||||
'ai.externalMcp.approvalAlert.description': '外部 MCP 用戶端請求確認時顯示系統通知。Netcatty 未聚焦時,工作列或 Dock 會要求注意。通知使用系統提示音。',
|
||||
'ai.externalMcp.approvalAlert.off': '關閉',
|
||||
'ai.externalMcp.approvalAlert.unfocused': '未聚焦時',
|
||||
'ai.externalMcp.approvalAlert.always': '一律',
|
||||
'ai.externalMcp.approvalAlert.notificationTitle': '對外 MCP 核准',
|
||||
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待核准',
|
||||
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待核准',
|
||||
'ai.externalMcp.silentSessions': 'AI 工作階段靜默執行',
|
||||
'ai.externalMcp.silentSessions.description': 'AI 開啟的工作階段不會出現在分頁列,重新啟動後也不會還原。可隨時在系統匣面板中查看。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已開啟工作階段閒置逾時',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { presentExternalMcpApprovalNotification } from './externalMcpApprovalAlert.ts';
|
||||
|
||||
const t = (key: string, values?: Record<string, string | number | boolean | null | undefined>) => {
|
||||
if (key === 'ai.externalMcp.approvalAlert.notificationTitle') return 'External MCP approval';
|
||||
if (key === 'ai.externalMcp.approvalAlert.notificationBody') return `${values?.tool} is waiting for approval`;
|
||||
if (key === 'ai.externalMcp.approvalAlert.notificationBodyOnHost') {
|
||||
return `${values?.tool} on ${values?.host} is waiting for approval`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
test('approval alerts stay quiet when the preference is off', () => {
|
||||
const calls: unknown[] = [];
|
||||
const shown = presentExternalMcpApprovalNotification({
|
||||
toolName: 'sftp.write',
|
||||
dedupeKey: 'mcp_approval_1',
|
||||
mode: 'off',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.equal(shown, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('unfocused approval alerts name the tool and host and defer focus to the main process', () => {
|
||||
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
|
||||
const shown = presentExternalMcpApprovalNotification({
|
||||
toolName: 'sftp.write',
|
||||
hostLabel: 'bastion',
|
||||
dedupeKey: 'mcp_approval_2',
|
||||
mode: 'unfocused',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.equal(shown, true);
|
||||
assert.deepEqual(calls, [{
|
||||
title: 'External MCP approval',
|
||||
body: 'sftp.write on bastion is waiting for approval',
|
||||
dedupeKey: 'mcp_approval_2',
|
||||
whenUnfocused: true,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('always approval alerts notify even while a window may be focused', () => {
|
||||
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
|
||||
presentExternalMcpApprovalNotification({
|
||||
toolName: 'terminal.execute',
|
||||
dedupeKey: 'mcp_approval_3',
|
||||
mode: 'always',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.deepEqual(calls, [{
|
||||
title: 'External MCP approval',
|
||||
body: 'terminal.execute is waiting for approval',
|
||||
dedupeKey: 'mcp_approval_3',
|
||||
whenUnfocused: false,
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ApprovalRequest } from '../../infrastructure/ai/shared/approvalGate';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
readExternalMcpApprovalAlertMode,
|
||||
type ExternalMcpApprovalAlertMode,
|
||||
} from './useExternalMcpToggleState';
|
||||
|
||||
export type ExternalMcpApprovalNotification = {
|
||||
title: string;
|
||||
body: string;
|
||||
dedupeKey: string;
|
||||
whenUnfocused: boolean;
|
||||
};
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
values?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Build the system notification for one External MCP approval.
|
||||
* Returns null when the preference is off. Focus gating stays in the main
|
||||
* process so every Netcatty window is considered, not only the sender.
|
||||
*/
|
||||
export function presentExternalMcpApprovalNotification(options: {
|
||||
toolName: string;
|
||||
hostLabel?: string;
|
||||
dedupeKey: string;
|
||||
mode: ExternalMcpApprovalAlertMode;
|
||||
t: Translate;
|
||||
notify: (payload: ExternalMcpApprovalNotification) => void;
|
||||
}): boolean {
|
||||
if (options.mode === 'off') return false;
|
||||
const tool = options.toolName || '';
|
||||
const host = options.hostLabel?.trim() || '';
|
||||
const body = host
|
||||
? options.t('ai.externalMcp.approvalAlert.notificationBodyOnHost', { tool, host })
|
||||
: options.t('ai.externalMcp.approvalAlert.notificationBody', { tool });
|
||||
options.notify({
|
||||
title: options.t('ai.externalMcp.approvalAlert.notificationTitle'),
|
||||
body,
|
||||
dedupeKey: options.dedupeKey,
|
||||
whenUnfocused: options.mode === 'unfocused',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function publishExternalMcpApprovalAlertMode(): void {
|
||||
void netcattyBridge.get()?.setExternalMcpApprovalAlertMode?.({
|
||||
mode: readExternalMcpApprovalAlertMode(),
|
||||
});
|
||||
}
|
||||
|
||||
export function notifyExternalMcpApproval(
|
||||
request: Pick<ApprovalRequest, 'toolName' | 'toolCallId' | 'target'>,
|
||||
t: Translate,
|
||||
): boolean {
|
||||
return presentExternalMcpApprovalNotification({
|
||||
toolName: request.toolName,
|
||||
hostLabel: request.target?.label || request.target?.hostname || '',
|
||||
dedupeKey: request.toolCallId,
|
||||
mode: readExternalMcpApprovalAlertMode(),
|
||||
t,
|
||||
notify: (payload) => {
|
||||
void netcattyBridge.get()?.showSystemNotification?.(payload);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
getExternalMcpStartupReadyWaiterCountForTests,
|
||||
isExternalMcpStartupReady,
|
||||
markExternalMcpStartupReady,
|
||||
normalizeExternalMcpApprovalAlertMode,
|
||||
normalizeExternalMcpIdleTimeoutMinutes,
|
||||
normalizeExternalMcpMode,
|
||||
normalizeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpApprovalAlertMode,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpSilentSessions,
|
||||
readExternalMcpStoredEnabled,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
syncExternalMcpStartupState,
|
||||
syncExternalMcpStartupStateOnce,
|
||||
waitForExternalMcpStartupReady,
|
||||
writeExternalMcpApprovalAlertMode,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpSilentSessions,
|
||||
} from './useExternalMcpToggleState.ts';
|
||||
@@ -69,6 +72,22 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
|
||||
assert.equal(normalizeExternalMcpApprovalAlertMode(null), 'unfocused');
|
||||
assert.equal(normalizeExternalMcpApprovalAlertMode('always'), 'always');
|
||||
assert.equal(normalizeExternalMcpApprovalAlertMode('nope'), 'unfocused');
|
||||
});
|
||||
|
||||
it('stores the approval alert preference and defaults to unfocused', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.equal(readExternalMcpApprovalAlertMode(), 'unfocused');
|
||||
assert.equal(writeExternalMcpApprovalAlertMode('off'), 'off');
|
||||
assert.equal(readExternalMcpApprovalAlertMode(), 'off');
|
||||
assert.equal(writeExternalMcpApprovalAlertMode('always'), 'always');
|
||||
assert.equal(readExternalMcpApprovalAlertMode(), 'always');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
for (const savedMinutes of [null, 30]) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
@@ -14,6 +15,9 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
|
||||
export type ExternalMcpMode = 'temporary' | 'persistent';
|
||||
export type ExternalMcpApprovalAlertMode = 'off' | 'unfocused' | 'always';
|
||||
|
||||
export const DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE: ExternalMcpApprovalAlertMode = 'unfocused';
|
||||
|
||||
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
|
||||
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
|
||||
@@ -183,6 +187,26 @@ export function writeExternalMcpFocusOnHostOpen(focusOnHostOpen: boolean): void
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN);
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpApprovalAlertMode(value: string | null): ExternalMcpApprovalAlertMode {
|
||||
return value === 'off' || value === 'always' || value === 'unfocused'
|
||||
? value
|
||||
: DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE;
|
||||
}
|
||||
|
||||
/** System notification preference for confirm-mode External MCP approvals. */
|
||||
export function readExternalMcpApprovalAlertMode(): ExternalMcpApprovalAlertMode {
|
||||
return normalizeExternalMcpApprovalAlertMode(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpApprovalAlertMode(mode: ExternalMcpApprovalAlertMode): ExternalMcpApprovalAlertMode {
|
||||
const normalized = normalizeExternalMcpApprovalAlertMode(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Whether host_open sessions stay hidden from the tab bar. Defaults to false (existing behavior). */
|
||||
export function readExternalMcpSilentSessions(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS) ?? false;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* even when the Catty AI side panel has never been opened.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
resolveCapabilityId,
|
||||
} from '../../infrastructure/ai/harness/permissionGrants';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { AI_STATE_CHANGED_EVENT } from '../../application/state/aiStateEvents';
|
||||
import {
|
||||
notifyExternalMcpApproval,
|
||||
publishExternalMcpApprovalAlertMode,
|
||||
} from '../../application/state/externalMcpApprovalAlert';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
|
||||
|
||||
@@ -31,19 +37,37 @@ function isExternalMcpApproval(request: ApprovalRequest): boolean {
|
||||
export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
|
||||
const alertedIdsRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
const pushMode = () => {
|
||||
publishExternalMcpApprovalAlertMode();
|
||||
};
|
||||
pushMode();
|
||||
const onChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT) pushMode();
|
||||
};
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onChange);
|
||||
return () => window.removeEventListener(AI_STATE_CHANGED_EVENT, onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (request: ApprovalRequest) => {
|
||||
if (!isExternalMcpApproval(request)) return;
|
||||
setPendingApprovals((prev) => new Map(prev).set(request.toolCallId, request));
|
||||
if (alertedIdsRef.current.has(request.toolCallId)) return;
|
||||
alertedIdsRef.current.add(request.toolCallId);
|
||||
notifyExternalMcpApproval(request, t);
|
||||
};
|
||||
const unsub = onApprovalRequest(handler);
|
||||
replayPendingApprovals(handler);
|
||||
return unsub;
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
return onApprovalCleared((clearedIds) => {
|
||||
for (const id of clearedIds) alertedIdsRef.current.delete(id);
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const id of clearedIds) next.delete(id);
|
||||
|
||||
@@ -2,16 +2,19 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
readExternalMcpApprovalAlertMode,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpIdleTimeoutMinutes,
|
||||
readExternalMcpMode,
|
||||
readExternalMcpSilentSessions,
|
||||
readSessionIdleTimeoutMinutes,
|
||||
writeExternalMcpApprovalAlertMode,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpIdleTimeoutMinutes,
|
||||
writeExternalMcpMode,
|
||||
writeExternalMcpSilentSessions,
|
||||
writeSessionIdleTimeoutMinutes,
|
||||
type ExternalMcpApprovalAlertMode,
|
||||
type ExternalMcpMode,
|
||||
useExternalMcpToggleState,
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
@@ -297,6 +300,9 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
const [approvalAlert, setApprovalAlertRaw] = useState<ExternalMcpApprovalAlertMode>(
|
||||
() => readExternalMcpApprovalAlertMode(),
|
||||
);
|
||||
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
|
||||
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
|
||||
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
|
||||
@@ -344,6 +350,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
|
||||
}, []);
|
||||
|
||||
const setApprovalAlert = useCallback((next: ExternalMcpApprovalAlertMode) => {
|
||||
setApprovalAlertRaw(writeExternalMcpApprovalAlertMode(next));
|
||||
}, []);
|
||||
|
||||
const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
|
||||
setSilentSessionsRaw(nextSilentSessions);
|
||||
writeExternalMcpSilentSessions(nextSilentSessions);
|
||||
@@ -698,6 +708,24 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-approval-alert"
|
||||
label={t("ai.externalMcp.approvalAlert")}
|
||||
description={t("ai.externalMcp.approvalAlert.description")}
|
||||
>
|
||||
<Select
|
||||
value={approvalAlert}
|
||||
options={[
|
||||
{ value: "always", label: t("ai.externalMcp.approvalAlert.always") },
|
||||
{ value: "unfocused", label: t("ai.externalMcp.approvalAlert.unfocused") },
|
||||
{ value: "off", label: t("ai.externalMcp.approvalAlert.off") },
|
||||
]}
|
||||
onChange={(value) => setApprovalAlert(
|
||||
value === "always" || value === "off" ? value : "unfocused",
|
||||
)}
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.mode")}
|
||||
description={t("ai.externalMcp.mode.description")}
|
||||
|
||||
@@ -714,6 +714,15 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
labelKey: "ai.externalMcp.title",
|
||||
keywords: ["mcp"],
|
||||
},
|
||||
{
|
||||
id: "ai-external-mcp-approval-alert",
|
||||
tab: "ai",
|
||||
aiSubTab: "tools",
|
||||
labelKey: "ai.externalMcp.approvalAlert",
|
||||
descriptionKey: "ai.externalMcp.approvalAlert.description",
|
||||
sectionKey: "ai.externalMcp.title",
|
||||
keywords: ["notification", "approval", "dock"],
|
||||
},
|
||||
{
|
||||
id: "ai-user-skills",
|
||||
tab: "ai",
|
||||
|
||||
@@ -25,6 +25,7 @@ const { safeSend } = require("./ipcUtils.cjs");
|
||||
const { emitTerminalSessionData } = require("./emitTerminalSessionData.cjs");
|
||||
const { getCliDiscoveryFilePath } = require("../cli/discoveryPath.cjs");
|
||||
const { EXTERNAL_MCP_CHAT_SESSION_ID } = require("../cli/externalMcpDiscoveryPath.cjs");
|
||||
const { syncExternalMcpApprovalAttention } = require("./windowAttention.cjs");
|
||||
const sftpBridge = require("./sftpBridge.cjs");
|
||||
const portForwardingBridge = require("./portForwardingBridge.cjs");
|
||||
|
||||
@@ -236,6 +237,7 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId, approvalCont
|
||||
resolve(false);
|
||||
// Notify renderer(s) to remove the stale approval card
|
||||
broadcastApprovalEvent('netcatty:ai:mcp:approval-cleared', { approvalIds: [approvalId] });
|
||||
syncExternalApprovalAttention();
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
@@ -280,6 +282,7 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId, approvalCont
|
||||
allowSession: chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID
|
||||
&& externalMcpSockets.has(approvalContext.externalSocket),
|
||||
});
|
||||
syncExternalApprovalAttention();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -338,9 +341,24 @@ function cancelApprovalTimeoutFromRenderer(approvalId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function countExternalMcpApprovals() {
|
||||
let pending = 0;
|
||||
for (const entry of pendingApprovals.values()) {
|
||||
if (entry.chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID) pending += 1;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
function syncExternalApprovalAttention() {
|
||||
try {
|
||||
syncExternalMcpApprovalAttention(countExternalMcpApprovals());
|
||||
} catch { /* attention must not change approval results */ }
|
||||
}
|
||||
|
||||
function notifyRendererApprovalCleared(approvalIds) {
|
||||
if (!Array.isArray(approvalIds) || approvalIds.length === 0) return;
|
||||
broadcastApprovalEvent("netcatty:ai:mcp:approval-cleared", { approvalIds });
|
||||
syncExternalApprovalAttention();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,10 +55,18 @@ function showSystemNotification({
|
||||
return { shown: true };
|
||||
}
|
||||
|
||||
function registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWindow }) {
|
||||
function registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWindow, getAppFocused }) {
|
||||
const shownKeys = new Set();
|
||||
ipcMain.handle("netcatty:notification:show", async (event, payload) => {
|
||||
try {
|
||||
return showSystemNotification({
|
||||
if (payload?.whenUnfocused === true && typeof getAppFocused === "function" && getAppFocused()) {
|
||||
return { shown: false, reason: "focused" };
|
||||
}
|
||||
const dedupeKey = typeof payload?.dedupeKey === "string" ? payload.dedupeKey : "";
|
||||
if (dedupeKey && shownKeys.has(dedupeKey)) {
|
||||
return { shown: false, reason: "duplicate" };
|
||||
}
|
||||
const result = showSystemNotification({
|
||||
Notification: electronModule?.Notification,
|
||||
BrowserWindow,
|
||||
sender: event?.sender,
|
||||
@@ -66,6 +74,8 @@ function registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWi
|
||||
body: payload?.body,
|
||||
sessionId: payload?.sessionId,
|
||||
});
|
||||
if (result?.shown && dedupeKey) shownKeys.add(dedupeKey);
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { shown: false, reason: err?.message || "failed" };
|
||||
}
|
||||
|
||||
@@ -72,6 +72,67 @@ test("showSystemNotification reports unsupported platforms", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("registerSystemNotificationHandlers skips unfocused alerts while the app is focused", async () => {
|
||||
const handlers = new Map();
|
||||
const shown = [];
|
||||
function FakeNotification(payload) {
|
||||
shown.push(payload);
|
||||
}
|
||||
FakeNotification.isSupported = () => true;
|
||||
FakeNotification.prototype.on = () => {};
|
||||
FakeNotification.prototype.show = () => {};
|
||||
|
||||
registerSystemNotificationHandlers({
|
||||
handle(channel, handler) { handlers.set(channel, handler); },
|
||||
}, {
|
||||
electronModule: { Notification: FakeNotification },
|
||||
BrowserWindow: { fromWebContents: () => null },
|
||||
getAppFocused: () => true,
|
||||
});
|
||||
|
||||
const skipped = await handlers.get("netcatty:notification:show")(
|
||||
{ sender: { isDestroyed: () => false, send() {} } },
|
||||
{ title: "External MCP approval", body: "sftp.write is waiting", whenUnfocused: true, dedupeKey: "a1" },
|
||||
);
|
||||
assert.deepEqual(skipped, { shown: false, reason: "focused" });
|
||||
assert.equal(shown.length, 0);
|
||||
|
||||
const forced = await handlers.get("netcatty:notification:show")(
|
||||
{ sender: { isDestroyed: () => false, send() {} } },
|
||||
{ title: "External MCP approval", body: "sftp.write is waiting", whenUnfocused: false, dedupeKey: "a1" },
|
||||
);
|
||||
assert.deepEqual(forced, { shown: true });
|
||||
assert.equal(shown.length, 1);
|
||||
});
|
||||
|
||||
test("registerSystemNotificationHandlers dedupes the same approval across windows", async () => {
|
||||
const handlers = new Map();
|
||||
const shown = [];
|
||||
function FakeNotification(payload) {
|
||||
shown.push(payload);
|
||||
}
|
||||
FakeNotification.isSupported = () => true;
|
||||
FakeNotification.prototype.on = () => {};
|
||||
FakeNotification.prototype.show = () => {};
|
||||
|
||||
registerSystemNotificationHandlers({
|
||||
handle(channel, handler) { handlers.set(channel, handler); },
|
||||
}, {
|
||||
electronModule: { Notification: FakeNotification },
|
||||
BrowserWindow: { fromWebContents: () => null },
|
||||
getAppFocused: () => false,
|
||||
});
|
||||
|
||||
const show = (dedupeKey) => handlers.get("netcatty:notification:show")(
|
||||
{ sender: { isDestroyed: () => false, send() {} } },
|
||||
{ title: "External MCP approval", body: "tool", dedupeKey },
|
||||
);
|
||||
assert.deepEqual(await show("mcp_approval_1"), { shown: true });
|
||||
assert.deepEqual(await show("mcp_approval_1"), { shown: false, reason: "duplicate" });
|
||||
assert.deepEqual(await show("mcp_approval_2"), { shown: true });
|
||||
assert.equal(shown.length, 2);
|
||||
});
|
||||
|
||||
test("registerSystemNotificationHandlers exposes the IPC channel", async () => {
|
||||
const handlers = new Map();
|
||||
const shown = [];
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_MODE = "unfocused";
|
||||
|
||||
function normalizeExternalMcpApprovalAlertMode(value) {
|
||||
return value === "off" || value === "always" || value === "unfocused" ? value : DEFAULT_MODE;
|
||||
}
|
||||
|
||||
function createWindowAttentionController({ app, getWindows, platform }) {
|
||||
let bounceId = -1;
|
||||
let flashing = false;
|
||||
let flashedWin = null;
|
||||
let watchingFocus = false;
|
||||
|
||||
function onBrowserWindowFocus() {
|
||||
stop();
|
||||
}
|
||||
|
||||
function startFocusWatch() {
|
||||
if (watchingFocus || typeof app?.on !== "function") return;
|
||||
app.on("browser-window-focus", onBrowserWindowFocus);
|
||||
watchingFocus = true;
|
||||
}
|
||||
|
||||
function stopFocusWatch() {
|
||||
if (!watchingFocus) return;
|
||||
watchingFocus = false;
|
||||
try { app.removeListener("browser-window-focus", onBrowserWindowFocus); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopFocusWatch();
|
||||
if (flashing && flashedWin && !flashedWin.isDestroyed?.() && typeof flashedWin.flashFrame === "function") {
|
||||
try { flashedWin.flashFrame(false); } catch { /* ignore */ }
|
||||
}
|
||||
flashing = false;
|
||||
flashedWin = null;
|
||||
if (bounceId >= 0) {
|
||||
try { app?.dock?.cancelBounce?.(bounceId); } catch { /* ignore */ }
|
||||
bounceId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function request() {
|
||||
const windows = (typeof getWindows === "function" ? getWindows() : [])
|
||||
.filter((win) => win && !win.isDestroyed?.());
|
||||
if (windows.length === 0) return { requested: false, reason: "no-window" };
|
||||
if (windows.some((win) => typeof win.isFocused === "function" && win.isFocused())) {
|
||||
return { requested: false, reason: "focused" };
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
if (bounceId >= 0) return { requested: true, kind: "bounce" };
|
||||
const id = app?.dock?.bounce?.("informational");
|
||||
if (typeof id !== "number" || id < 0) return { requested: false, reason: "unavailable" };
|
||||
bounceId = id;
|
||||
startFocusWatch();
|
||||
return { requested: true, kind: "bounce" };
|
||||
}
|
||||
|
||||
const win = windows[0];
|
||||
if (typeof win.flashFrame !== "function") return { requested: false, reason: "unavailable" };
|
||||
win.flashFrame(true);
|
||||
flashing = true;
|
||||
flashedWin = win;
|
||||
startFocusWatch();
|
||||
return { requested: true, kind: "flash" };
|
||||
}
|
||||
|
||||
return { request, stop };
|
||||
}
|
||||
|
||||
let mode = DEFAULT_MODE;
|
||||
let controller = null;
|
||||
|
||||
function installWindowAttention(options) {
|
||||
controller?.stop?.();
|
||||
controller = createWindowAttentionController(options);
|
||||
return controller;
|
||||
}
|
||||
|
||||
function setExternalMcpApprovalAlertMode(value) {
|
||||
mode = normalizeExternalMcpApprovalAlertMode(value);
|
||||
if (mode === "off") controller?.stop?.();
|
||||
return mode;
|
||||
}
|
||||
|
||||
function getExternalMcpApprovalAlertMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
function syncExternalMcpApprovalAttention(pendingCount) {
|
||||
if (!controller) return { attention: false, reason: "uninstalled" };
|
||||
if (!(pendingCount > 0) || mode === "off") {
|
||||
controller.stop();
|
||||
return { attention: false };
|
||||
}
|
||||
return controller.request();
|
||||
}
|
||||
|
||||
function resetWindowAttentionForTests() {
|
||||
controller?.stop?.();
|
||||
controller = null;
|
||||
mode = DEFAULT_MODE;
|
||||
}
|
||||
|
||||
function registerExternalMcpApprovalAlertHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:mcp:approval-alert-mode", (_event, payload) => {
|
||||
return { mode: setExternalMcpApprovalAlertMode(payload?.mode) };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeExternalMcpApprovalAlertMode,
|
||||
createWindowAttentionController,
|
||||
installWindowAttention,
|
||||
setExternalMcpApprovalAlertMode,
|
||||
getExternalMcpApprovalAlertMode,
|
||||
syncExternalMcpApprovalAttention,
|
||||
resetWindowAttentionForTests,
|
||||
registerExternalMcpApprovalAlertHandlers,
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
const {
|
||||
createWindowAttentionController,
|
||||
installWindowAttention,
|
||||
resetWindowAttentionForTests,
|
||||
setExternalMcpApprovalAlertMode,
|
||||
syncExternalMcpApprovalAttention,
|
||||
} = require("./windowAttention.cjs");
|
||||
|
||||
function createWindow(overrides = {}) {
|
||||
const win = new EventEmitter();
|
||||
win.destroyed = false;
|
||||
win.focused = false;
|
||||
win.flashes = [];
|
||||
win.isDestroyed = () => win.destroyed;
|
||||
win.isFocused = () => win.focused;
|
||||
win.flashFrame = (flag) => { win.flashes.push(flag); };
|
||||
return Object.assign(win, overrides);
|
||||
}
|
||||
|
||||
test("linux attention flashes until focus or stop", () => {
|
||||
const app = new EventEmitter();
|
||||
const win = createWindow();
|
||||
const controller = createWindowAttentionController({
|
||||
app,
|
||||
platform: "linux",
|
||||
getWindows: () => [win],
|
||||
});
|
||||
assert.deepEqual(controller.request(), { requested: true, kind: "flash" });
|
||||
assert.deepEqual(win.flashes, [true]);
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 1);
|
||||
app.emit("browser-window-focus", {}, win);
|
||||
assert.deepEqual(win.flashes, [true, false]);
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 0);
|
||||
|
||||
assert.deepEqual(controller.request(), { requested: true, kind: "flash" });
|
||||
controller.stop();
|
||||
assert.deepEqual(win.flashes, [true, false, true, false]);
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 0);
|
||||
});
|
||||
|
||||
test("focus on a window created after attention starts clears the flash", () => {
|
||||
const app = new EventEmitter();
|
||||
const original = createWindow();
|
||||
const windows = [original];
|
||||
const controller = createWindowAttentionController({
|
||||
app,
|
||||
platform: "win32",
|
||||
getWindows: () => windows,
|
||||
});
|
||||
assert.deepEqual(controller.request(), { requested: true, kind: "flash" });
|
||||
|
||||
const later = createWindow();
|
||||
windows.push(later);
|
||||
later.emit("focus");
|
||||
assert.deepEqual(original.flashes, [true]);
|
||||
|
||||
app.emit("browser-window-focus", {}, later);
|
||||
assert.deepEqual(original.flashes, [true, false]);
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 0);
|
||||
app.emit("browser-window-focus", {}, later);
|
||||
assert.deepEqual(original.flashes, [true, false]);
|
||||
});
|
||||
|
||||
test("attention stays quiet while any window is focused", () => {
|
||||
const idle = createWindow();
|
||||
const active = createWindow({ focused: true });
|
||||
const controller = createWindowAttentionController({
|
||||
platform: "win32",
|
||||
getWindows: () => [idle, active],
|
||||
});
|
||||
assert.deepEqual(controller.request(), { requested: false, reason: "focused" });
|
||||
assert.deepEqual(idle.flashes, []);
|
||||
});
|
||||
|
||||
test("macOS attention uses an informational dock bounce and cancels it", () => {
|
||||
const win = createWindow();
|
||||
const cancelled = [];
|
||||
const app = new EventEmitter();
|
||||
app.dock = {
|
||||
bounce(type) {
|
||||
assert.equal(type, "informational");
|
||||
return 4;
|
||||
},
|
||||
cancelBounce(id) { cancelled.push(id); },
|
||||
};
|
||||
const controller = createWindowAttentionController({
|
||||
platform: "darwin",
|
||||
app,
|
||||
getWindows: () => [win],
|
||||
});
|
||||
assert.deepEqual(controller.request(), { requested: true, kind: "bounce" });
|
||||
assert.deepEqual(controller.request(), { requested: true, kind: "bounce" });
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 1);
|
||||
assert.deepEqual(win.flashes, []);
|
||||
controller.stop();
|
||||
assert.deepEqual(cancelled, [4]);
|
||||
app.emit("browser-window-focus", {}, win);
|
||||
assert.deepEqual(cancelled, [4]);
|
||||
assert.equal(app.listenerCount("browser-window-focus"), 0);
|
||||
});
|
||||
|
||||
test("external MCP approvals flash while unfocused and stop when resolved", (t) => {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = 42;
|
||||
contents.send = (channel, payload) => contents.emit("send", channel, payload);
|
||||
contents.isDestroyed = () => false;
|
||||
const win = createWindow();
|
||||
win.webContents = contents;
|
||||
|
||||
installWindowAttention({
|
||||
platform: "linux",
|
||||
getWindows: () => [win],
|
||||
});
|
||||
setExternalMcpApprovalAlertMode("unfocused");
|
||||
mcpServerBridge.setMainWindowGetter(() => win);
|
||||
t.after(() => {
|
||||
mcpServerBridge.clearPendingApprovals();
|
||||
mcpServerBridge.setMainWindowGetter(() => null);
|
||||
resetWindowAttentionForTests();
|
||||
});
|
||||
|
||||
const requests = [];
|
||||
contents.on("send", (channel, payload) => {
|
||||
if (channel === "netcatty:ai:mcp:approval-request") requests.push(payload);
|
||||
});
|
||||
|
||||
const approval = mcpServerBridge.requestApprovalFromRenderer(
|
||||
"sftp.write",
|
||||
{ path: "/tmp/x" },
|
||||
mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
);
|
||||
assert.equal(requests.length, 1);
|
||||
assert.deepEqual(win.flashes, [true]);
|
||||
|
||||
mcpServerBridge.resolveApprovalFromRenderer(requests[0].approvalId, false);
|
||||
assert.deepEqual(win.flashes, [true, false]);
|
||||
return approval.then((approved) => {
|
||||
assert.equal(approved, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("approval attention follows the off preference and ignores in-app approvals", (t) => {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = 7;
|
||||
contents.send = (channel, payload) => contents.emit("send", channel, payload);
|
||||
contents.isDestroyed = () => false;
|
||||
const win = createWindow();
|
||||
win.webContents = contents;
|
||||
|
||||
installWindowAttention({
|
||||
platform: "linux",
|
||||
getWindows: () => [win],
|
||||
});
|
||||
setExternalMcpApprovalAlertMode("off");
|
||||
mcpServerBridge.setMainWindowGetter(() => win);
|
||||
t.after(() => {
|
||||
mcpServerBridge.clearPendingApprovals();
|
||||
mcpServerBridge.setMainWindowGetter(() => null);
|
||||
resetWindowAttentionForTests();
|
||||
});
|
||||
|
||||
const external = mcpServerBridge.requestApprovalFromRenderer(
|
||||
"sftp.write",
|
||||
{},
|
||||
mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
);
|
||||
const internal = mcpServerBridge.requestApprovalFromRenderer(
|
||||
"terminal.execute",
|
||||
{},
|
||||
"chat-1",
|
||||
);
|
||||
assert.deepEqual(win.flashes, []);
|
||||
assert.deepEqual(syncExternalMcpApprovalAttention(2), { attention: false });
|
||||
|
||||
mcpServerBridge.clearPendingApprovals();
|
||||
return Promise.all([external, internal]).then((results) => {
|
||||
assert.deepEqual(results, [false, false]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ let bridgesRegistered = false;
|
||||
let cloudSyncSessionPassword = null;
|
||||
const { readClipboardFiles, readClipboardImage, hasClipboardImage } = require("../bridges/clipboardFiles.cjs");
|
||||
const { registerSystemNotificationHandlers } = require("../bridges/systemNotification.cjs");
|
||||
const {
|
||||
installWindowAttention,
|
||||
registerExternalMcpApprovalAlertHandlers,
|
||||
} = require("../bridges/windowAttention.cjs");
|
||||
|
||||
const excludedFigSpecPrefixes = ["aws", "gcloud", "az"];
|
||||
|
||||
@@ -992,7 +996,26 @@ function createBridgeRegistrar(context) {
|
||||
);
|
||||
|
||||
// OSC 9/777/99 desktop notifications (Codex, iTerm2, kitty, rxvt)
|
||||
registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWindow });
|
||||
// and External MCP approval alerts, which reuse the same click-to-focus channel.
|
||||
registerSystemNotificationHandlers(ipcMain, {
|
||||
electronModule,
|
||||
BrowserWindow,
|
||||
getAppFocused: () => BrowserWindow.getAllWindows().some(
|
||||
(win) => win && !win.isDestroyed?.() && win.isFocused?.(),
|
||||
),
|
||||
});
|
||||
installWindowAttention({
|
||||
app,
|
||||
platform: process.platform,
|
||||
getWindows: () => {
|
||||
try {
|
||||
return BrowserWindow.getAllWindows();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
registerExternalMcpApprovalAlertHandlers(ipcMain);
|
||||
|
||||
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
|
||||
ipcMain.handle("netcatty:clipboard:readText", async () => {
|
||||
|
||||
@@ -1840,6 +1840,9 @@ function createPreloadApi(ctx) {
|
||||
showSystemNotification: async (payload) => {
|
||||
return ipcRenderer.invoke("netcatty:notification:show", payload ?? {});
|
||||
},
|
||||
setExternalMcpApprovalAlertMode: async (payload) => {
|
||||
return ipcRenderer.invoke("netcatty:mcp:approval-alert-mode", payload ?? {});
|
||||
},
|
||||
|
||||
// Clipboard fallback helpers
|
||||
readClipboardText: async () => {
|
||||
|
||||
@@ -227,6 +227,8 @@ export const STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN = 'netcatty_ai_exter
|
||||
export const STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES = 'netcatty_ai_session_idle_timeout_minutes_v1';
|
||||
/** External MCP: whether host_open sessions stay hidden from the tab bar (default false). */
|
||||
export const STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS = 'netcatty_ai_external_mcp_silent_sessions_v1';
|
||||
/** External MCP confirm-mode approval alert: off, unfocused, or always. Default unfocused. */
|
||||
export const STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT = 'netcatty_ai_external_mcp_approval_alert_v1';
|
||||
/** AI panel diagnostic hide list (comma-separated part names). */
|
||||
export const STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE = 'netcatty.aiDebug.hide';
|
||||
/** AI panel React profiler toggle. */
|
||||
|
||||
+7
@@ -139,7 +139,14 @@ declare global {
|
||||
title: string;
|
||||
body: string;
|
||||
sessionId?: string;
|
||||
/** Skip a second notification for the same approval across windows. */
|
||||
dedupeKey?: string;
|
||||
/** Show only when no Netcatty window is focused. */
|
||||
whenUnfocused?: boolean;
|
||||
}): Promise<{ shown: boolean; reason?: string }>;
|
||||
setExternalMcpApprovalAlertMode?(payload: {
|
||||
mode: 'off' | 'unfocused' | 'always';
|
||||
}): Promise<{ mode: 'off' | 'unfocused' | 'always' }>;
|
||||
readClipboardText?(): Promise<string>;
|
||||
writeClipboardText?(text: string): Promise<boolean>;
|
||||
readClipboardFiles?(): Promise<Array<{ path: string; name: string; isDirectory: boolean; size?: number }>>;
|
||||
|
||||
Reference in new issue
Block a user