mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-02 03:42:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dc3fc3cd1 | ||
|
|
bb02f5cbd0 | ||
|
|
a6dda1771c | ||
|
|
6620b85f2d | ||
|
|
942f106240 | ||
|
|
0c0d2ca558 | ||
|
|
e98e236d54 | ||
|
|
1e063dfa3c | ||
|
|
8b5b0c4416 | ||
|
|
afb0585016 | ||
|
|
6d65c89ecf | ||
|
|
2f214b8725 | ||
|
|
350917fa0e | ||
|
|
fb691318c4 | ||
|
|
9c1af5c73f | ||
|
|
555f193d23 | ||
|
|
88dd20a918 | ||
|
|
57e682dc83 |
No files matched your search
@@ -525,6 +525,8 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Always',
|
||||
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
|
||||
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
|
||||
'ai.chat.slashRenameDesc': 'Rename the current chat (/rename <title>)',
|
||||
'ai.chat.slashChatsDesc': 'List the chats visible to this panel',
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
@@ -587,6 +589,28 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
'ai.chat.renameChat': 'Rename chat',
|
||||
'ai.chat.renameUsage': 'Usage: /rename <new title>',
|
||||
'ai.chat.renameNoActive': 'No active chat to rename.',
|
||||
'ai.chat.renameDone': 'Chat renamed to "{title}".',
|
||||
'ai.chat.exportSessions': 'Export AI chats',
|
||||
'ai.chat.importSessions': 'Import AI chats',
|
||||
'ai.chat.exportDone': 'AI chats exported to {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'AI chats exported.',
|
||||
'ai.chat.exportUnavailable': 'AI chat export is unavailable in this environment.',
|
||||
'ai.chat.importDone': 'Imported {n} AI chat(s).',
|
||||
'ai.chat.importNone': 'No AI chats found in the selected file.',
|
||||
'ai.chat.chatsHeader': 'Chats visible in this panel:',
|
||||
'ai.chat.chatsEmpty': 'No chats visible in this panel.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Terminal',
|
||||
'ai.chat.chatsScopeGlobal': 'Global',
|
||||
'ai.chat.chatsScopeDangling': 'Dissociated',
|
||||
'ai.chat.slashRebindDesc': 'Rebind a disconnected chat to this panel (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Disconnected chats can be restored with /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Usage: /rebind <number from /chats>',
|
||||
'ai.chat.rebindDone': 'Chat "{title}" rebound to this panel.',
|
||||
'ai.chat.rebindUnavailable': 'No rebind target in this panel scope.',
|
||||
'ai.chat.justNow': 'Just now',
|
||||
'ai.chat.minutesAgo': '{n}m ago',
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
@@ -795,4 +819,27 @@ export const enAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
"ai.toolSettings.immediate": "Changes take effect immediately. Closing this window does not undo them.",
|
||||
"ai.toolSettings.privacyOn": "Text redaction enabled",
|
||||
"ai.toolSettings.privacyOff": "Text redaction disabled",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} additional IP ranges · {patterns} custom patterns",
|
||||
"ai.toolSettings.configurePrivacy": "Configure privacy",
|
||||
"ai.toolSettings.unsaved": "Unsaved changes",
|
||||
"ai.toolSettings.viewSkills": "View CLI guidance",
|
||||
"ai.toolSettings.viewPrompt": "View setup prompt",
|
||||
"ai.toolSettings.behavior": "MCP behavior",
|
||||
"ai.toolSettings.configureBehavior": "Configure behavior",
|
||||
"ai.toolSettings.configureClients": "Configure clients",
|
||||
"ai.toolSettings.httpOn": "HTTP enabled",
|
||||
"ai.toolSettings.httpOff": "HTTP disabled",
|
||||
"ai.toolSettings.loopback": "Local connections only (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Configure HTTP",
|
||||
"ai.toolSettings.hideSecret": "Hide secret",
|
||||
"ai.toolSettings.showSecret": "Show secret",
|
||||
"ai.toolSettings.manualSetup": "Manual setup",
|
||||
"ai.toolSettings.format": "Format",
|
||||
"ai.toolSettings.connectionDetails": "Connection details",
|
||||
"ai.toolSettings.conflict": "Settings changed in another window. Your draft is preserved. Cancel and reopen to load the saved settings.",
|
||||
"ai.toolSettings.retry": "Retry",
|
||||
"ai.toolSettings.applyingHttp": "Applying HTTP configuration...",
|
||||
};
|
||||
@@ -525,6 +525,8 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Siempre',
|
||||
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
|
||||
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
|
||||
'ai.chat.slashRenameDesc': 'Renombrar el chat actual (/rename <título>)',
|
||||
'ai.chat.slashChatsDesc': 'Listar los chats visibles en este panel',
|
||||
'ai.chat.reject': 'Rechazar',
|
||||
'ai.chat.toolLabel': 'Herramienta',
|
||||
'ai.chat.targetLabel': 'Destino',
|
||||
@@ -587,6 +589,28 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
'ai.chat.untitled': 'Sin título',
|
||||
'ai.chat.renameChat': 'Renombrar chat',
|
||||
'ai.chat.renameUsage': 'Uso: /rename <nuevo título>',
|
||||
'ai.chat.renameNoActive': 'No hay ningún chat activo para renombrar.',
|
||||
'ai.chat.renameDone': 'Chat renombrado a «{title}».',
|
||||
'ai.chat.exportSessions': 'Exportar chats de IA',
|
||||
'ai.chat.importSessions': 'Importar chats de IA',
|
||||
'ai.chat.exportDone': 'Chats de IA exportados a {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'Chats de IA exportados.',
|
||||
'ai.chat.exportUnavailable': 'La exportación de chats de IA no está disponible en este entorno.',
|
||||
'ai.chat.importDone': 'Se importaron {n} chat(s) de IA.',
|
||||
'ai.chat.importNone': 'No se encontraron chats de IA en el archivo seleccionado.',
|
||||
'ai.chat.chatsHeader': 'Chats visibles en este panel:',
|
||||
'ai.chat.chatsEmpty': 'No hay chats visibles en este panel.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Terminal',
|
||||
'ai.chat.chatsScopeGlobal': 'Global',
|
||||
'ai.chat.chatsScopeDangling': 'Desasociado',
|
||||
'ai.chat.slashRebindDesc': 'Reasociar un chat desconectado a este panel (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Los chats desconectados se pueden restaurar con /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Uso: /rebind <número de /chats>',
|
||||
'ai.chat.rebindDone': 'Chat «{title}» reasociado a este panel.',
|
||||
'ai.chat.rebindUnavailable': 'No hay destino de reasociación en el ámbito de este panel.',
|
||||
'ai.chat.justNow': 'Ahora mismo',
|
||||
'ai.chat.minutesAgo': 'hace {n} min',
|
||||
'ai.chat.hoursAgo': 'hace {n} h',
|
||||
@@ -795,4 +819,27 @@ export const esAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Omitir',
|
||||
'zmodem.overwrite.cancel': 'Cancelar',
|
||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
||||
"ai.toolSettings.immediate": "Los cambios se aplican inmediatamente. Cerrar esta ventana no los deshace.",
|
||||
"ai.toolSettings.privacyOn": "Ocultación de texto activada",
|
||||
"ai.toolSettings.privacyOff": "Ocultación de texto desactivada",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} rangos IP adicionales · {patterns} patrones personalizados",
|
||||
"ai.toolSettings.configurePrivacy": "Configurar privacidad",
|
||||
"ai.toolSettings.unsaved": "Cambios sin guardar",
|
||||
"ai.toolSettings.viewSkills": "Ver instrucciones de CLI",
|
||||
"ai.toolSettings.viewPrompt": "Ver instrucciones de conexión",
|
||||
"ai.toolSettings.behavior": "Comportamiento de MCP",
|
||||
"ai.toolSettings.configureBehavior": "Configurar comportamiento",
|
||||
"ai.toolSettings.configureClients": "Configurar clientes",
|
||||
"ai.toolSettings.httpOn": "HTTP activado",
|
||||
"ai.toolSettings.httpOff": "HTTP desactivado",
|
||||
"ai.toolSettings.loopback": "Solo conexiones locales (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Configurar HTTP",
|
||||
"ai.toolSettings.hideSecret": "Ocultar secreto",
|
||||
"ai.toolSettings.showSecret": "Mostrar secreto",
|
||||
"ai.toolSettings.manualSetup": "Configuración manual",
|
||||
"ai.toolSettings.format": "Formato",
|
||||
"ai.toolSettings.connectionDetails": "Detalles de conexión",
|
||||
"ai.toolSettings.conflict": "La configuración cambió en otra ventana. Se conserva el borrador. Cancela y vuelve a abrir para cargar la configuración guardada.",
|
||||
"ai.toolSettings.retry": "Reintentar",
|
||||
"ai.toolSettings.applyingHttp": "Aplicando la configuración HTTP...",
|
||||
};
|
||||
@@ -464,6 +464,8 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': 'Всегда разрешать',
|
||||
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
|
||||
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
|
||||
'ai.chat.slashRenameDesc': 'Переименовать текущий чат (/rename <название>)',
|
||||
'ai.chat.slashChatsDesc': 'Показать чаты, видимые в этой панели',
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
@@ -526,6 +528,28 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
'ai.chat.untitled': 'Без названия',
|
||||
'ai.chat.renameChat': 'Переименовать чат',
|
||||
'ai.chat.renameUsage': 'Использование: /rename <новое название>',
|
||||
'ai.chat.renameNoActive': 'Нет активного чата для переименования.',
|
||||
'ai.chat.renameDone': 'Чат переименован в «{title}».',
|
||||
'ai.chat.exportSessions': 'Экспортировать чаты ИИ',
|
||||
'ai.chat.importSessions': 'Импортировать чаты ИИ',
|
||||
'ai.chat.exportDone': 'Чаты ИИ экспортированы в {path}.',
|
||||
'ai.chat.exportDoneNoPath': 'Чаты ИИ экспортированы.',
|
||||
'ai.chat.exportUnavailable': 'Экспорт чатов ИИ недоступен в этой среде.',
|
||||
'ai.chat.importDone': 'Импортировано чатов ИИ: {n}.',
|
||||
'ai.chat.importNone': 'В выбранном файле не найдено чатов ИИ.',
|
||||
'ai.chat.chatsHeader': 'Чаты, видимые в этой панели:',
|
||||
'ai.chat.chatsEmpty': 'В этой панели нет видимых чатов.',
|
||||
'ai.chat.chatsScopeWorkspace': 'Workspace',
|
||||
'ai.chat.chatsScopeTerminal': 'Терминал',
|
||||
'ai.chat.chatsScopeGlobal': 'Глобально',
|
||||
'ai.chat.chatsScopeDangling': 'Связь потеряна',
|
||||
'ai.chat.slashRebindDesc': 'Привязать потерянный чат к этой панели (/rebind <n>)',
|
||||
'ai.chat.chatsRebindHint': 'Потерянные чаты можно восстановить командой /rebind <n>.',
|
||||
'ai.chat.rebindUsage': 'Использование: /rebind <номер из /chats>',
|
||||
'ai.chat.rebindDone': 'Чат «{title}» привязан к этой панели.',
|
||||
'ai.chat.rebindUnavailable': 'В области этой панели нет цели для привязки.',
|
||||
'ai.chat.justNow': 'Только что',
|
||||
'ai.chat.minutesAgo': '{n}м назад',
|
||||
'ai.chat.hoursAgo': '{n}ч назад',
|
||||
@@ -724,4 +748,27 @@ export const ruAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Пропустить',
|
||||
'zmodem.overwrite.cancel': 'Отмена',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
"ai.toolSettings.immediate": "Изменения применяются сразу. Закрытие окна не отменяет их.",
|
||||
"ai.toolSettings.privacyOn": "Скрытие текста включено",
|
||||
"ai.toolSettings.privacyOff": "Скрытие текста выключено",
|
||||
"ai.toolSettings.ruleSummary": "Дополнительные диапазоны IP: {cidrs} · Пользовательские шаблоны: {patterns}",
|
||||
"ai.toolSettings.configurePrivacy": "Настроить приватность",
|
||||
"ai.toolSettings.unsaved": "Есть несохранённые изменения",
|
||||
"ai.toolSettings.viewSkills": "Инструкции CLI",
|
||||
"ai.toolSettings.viewPrompt": "Инструкции подключения",
|
||||
"ai.toolSettings.behavior": "Поведение MCP",
|
||||
"ai.toolSettings.configureBehavior": "Настроить поведение",
|
||||
"ai.toolSettings.configureClients": "Настроить клиенты",
|
||||
"ai.toolSettings.httpOn": "HTTP включён",
|
||||
"ai.toolSettings.httpOff": "HTTP выключен",
|
||||
"ai.toolSettings.loopback": "Только локальные подключения (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Настроить HTTP",
|
||||
"ai.toolSettings.hideSecret": "Скрыть секрет",
|
||||
"ai.toolSettings.showSecret": "Показать секрет",
|
||||
"ai.toolSettings.manualSetup": "Ручная настройка",
|
||||
"ai.toolSettings.format": "Формат",
|
||||
"ai.toolSettings.connectionDetails": "Параметры подключения",
|
||||
"ai.toolSettings.conflict": "Настройки изменены в другом окне. Черновик сохранён. Отмените и откройте окно снова, чтобы загрузить сохранённые настройки.",
|
||||
"ai.toolSettings.retry": "Повторить",
|
||||
"ai.toolSettings.applyingHttp": "Применение настроек HTTP...",
|
||||
};
|
||||
@@ -525,6 +525,8 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': '始终',
|
||||
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
|
||||
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
|
||||
'ai.chat.slashRenameDesc': '重命名当前聊天(/rename <标题>)',
|
||||
'ai.chat.slashChatsDesc': '列出此面板可见的聊天',
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
@@ -587,6 +589,28 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
'ai.chat.renameChat': '重命名聊天',
|
||||
'ai.chat.renameUsage': '用法:/rename <新标题>',
|
||||
'ai.chat.renameNoActive': '当前没有可重命名的聊天。',
|
||||
'ai.chat.renameDone': '聊天已重命名为「{title}」。',
|
||||
'ai.chat.exportSessions': '导出 AI 会话',
|
||||
'ai.chat.importSessions': '导入 AI 会话',
|
||||
'ai.chat.exportDone': 'AI 会话已导出到 {path}。',
|
||||
'ai.chat.exportDoneNoPath': 'AI 会话已导出。',
|
||||
'ai.chat.exportUnavailable': '当前环境不支持导出 AI 会话。',
|
||||
'ai.chat.importDone': '已导入 {n} 个 AI 会话。',
|
||||
'ai.chat.importNone': '所选文件中没有可导入的 AI 会话。',
|
||||
'ai.chat.chatsHeader': '此面板可见的聊天:',
|
||||
'ai.chat.chatsEmpty': '此面板没有可见的聊天。',
|
||||
'ai.chat.chatsScopeWorkspace': '工作区',
|
||||
'ai.chat.chatsScopeTerminal': '终端',
|
||||
'ai.chat.chatsScopeGlobal': '全局',
|
||||
'ai.chat.chatsScopeDangling': '已断开关联',
|
||||
'ai.chat.slashRebindDesc': '将断联的聊天重绑到此面板(/rebind <序号>)',
|
||||
'ai.chat.chatsRebindHint': '断联的聊天可用 /rebind <序号> 恢复。',
|
||||
'ai.chat.rebindUsage': '用法:/rebind <序号>',
|
||||
'ai.chat.rebindDone': '聊天“{title}”已重绑到此面板。',
|
||||
'ai.chat.rebindUnavailable': '当前面板范围内没有可用的重绑目标。',
|
||||
'ai.chat.justNow': '刚刚',
|
||||
'ai.chat.minutesAgo': '{n}分钟前',
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
@@ -795,4 +819,27 @@ export const zhCNAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
"ai.toolSettings.immediate": "更改立即生效,关闭此窗口不会撤销。",
|
||||
"ai.toolSettings.privacyOn": "已启用文本脱敏",
|
||||
"ai.toolSettings.privacyOff": "未启用文本脱敏",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} 个附加 IP 范围 · {patterns} 条自定义规则",
|
||||
"ai.toolSettings.configurePrivacy": "配置隐私",
|
||||
"ai.toolSettings.unsaved": "有未保存的更改",
|
||||
"ai.toolSettings.viewSkills": "查看 CLI 指引",
|
||||
"ai.toolSettings.viewPrompt": "查看接入提示词",
|
||||
"ai.toolSettings.behavior": "MCP 行为",
|
||||
"ai.toolSettings.configureBehavior": "配置行为",
|
||||
"ai.toolSettings.configureClients": "配置客户端",
|
||||
"ai.toolSettings.httpOn": "HTTP 已启用",
|
||||
"ai.toolSettings.httpOff": "HTTP 未启用",
|
||||
"ai.toolSettings.loopback": "仅本地连接(127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "配置 HTTP",
|
||||
"ai.toolSettings.hideSecret": "隐藏密钥",
|
||||
"ai.toolSettings.showSecret": "显示密钥",
|
||||
"ai.toolSettings.manualSetup": "手动配置",
|
||||
"ai.toolSettings.format": "格式",
|
||||
"ai.toolSettings.connectionDetails": "连接详情",
|
||||
"ai.toolSettings.conflict": "设置已在另一窗口更改,当前草稿已保留。请取消并重新打开以加载已保存的设置。",
|
||||
"ai.toolSettings.retry": "重试",
|
||||
"ai.toolSettings.applyingHttp": "正在应用 HTTP 配置…",
|
||||
};
|
||||
@@ -515,6 +515,8 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.alwaysAllow': '始終',
|
||||
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
|
||||
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
|
||||
'ai.chat.slashRenameDesc': '重新命名目前聊天(/rename <標題>)',
|
||||
'ai.chat.slashChatsDesc': '列出此面板可見的聊天',
|
||||
'ai.chat.reject': '拒絕',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目標',
|
||||
@@ -577,6 +579,28 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
'ai.chat.untitled': '無標題',
|
||||
'ai.chat.renameChat': '重新命名聊天',
|
||||
'ai.chat.renameUsage': '用法:/rename <新標題>',
|
||||
'ai.chat.renameNoActive': '目前沒有可重新命名的聊天。',
|
||||
'ai.chat.renameDone': '聊天已重新命名為「{title}」。',
|
||||
'ai.chat.exportSessions': '匯出 AI 會話',
|
||||
'ai.chat.importSessions': '匯入 AI 會話',
|
||||
'ai.chat.exportDone': 'AI 會話已匯出到 {path}。',
|
||||
'ai.chat.exportDoneNoPath': 'AI 會話已匯出。',
|
||||
'ai.chat.exportUnavailable': '目前環境不支援匯出 AI 會話。',
|
||||
'ai.chat.importDone': '已匯入 {n} 個 AI 會話。',
|
||||
'ai.chat.importNone': '所選檔案中沒有可匯入的 AI 會話。',
|
||||
'ai.chat.chatsHeader': '此面板可見的聊天:',
|
||||
'ai.chat.chatsEmpty': '此面板沒有可見的聊天。',
|
||||
'ai.chat.chatsScopeWorkspace': '工作區',
|
||||
'ai.chat.chatsScopeTerminal': '終端',
|
||||
'ai.chat.chatsScopeGlobal': '全域',
|
||||
'ai.chat.chatsScopeDangling': '已斷開關聯',
|
||||
'ai.chat.slashRebindDesc': '將斷開關聯的聊天重新綁定到此面板(/rebind <序號>)',
|
||||
'ai.chat.chatsRebindHint': '斷開關聯的聊天可用 /rebind <序號> 恢復。',
|
||||
'ai.chat.rebindUsage': '用法:/rebind <序號>',
|
||||
'ai.chat.rebindDone': '聊天「{title}」已重新綁定到此面板。',
|
||||
'ai.chat.rebindUnavailable': '目前面板範圍內沒有可用的重新綁定目標。',
|
||||
'ai.chat.justNow': '剛剛',
|
||||
'ai.chat.minutesAgo': '{n}分鐘前',
|
||||
'ai.chat.hoursAgo': '{n}小時前',
|
||||
@@ -785,4 +809,27 @@ export const zhTWAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': '跳過',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重設為預設值',
|
||||
"ai.toolSettings.immediate": "變更立即生效,關閉此視窗不會復原。",
|
||||
"ai.toolSettings.privacyOn": "已啟用文字遮蔽",
|
||||
"ai.toolSettings.privacyOff": "未啟用文字遮蔽",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} 個額外 IP 範圍 · {patterns} 條自訂規則",
|
||||
"ai.toolSettings.configurePrivacy": "設定隱私",
|
||||
"ai.toolSettings.unsaved": "有未儲存的變更",
|
||||
"ai.toolSettings.viewSkills": "查看 CLI 指引",
|
||||
"ai.toolSettings.viewPrompt": "查看接入提示詞",
|
||||
"ai.toolSettings.behavior": "MCP 行為",
|
||||
"ai.toolSettings.configureBehavior": "設定行為",
|
||||
"ai.toolSettings.configureClients": "設定用戶端",
|
||||
"ai.toolSettings.httpOn": "HTTP 已啟用",
|
||||
"ai.toolSettings.httpOff": "HTTP 未啟用",
|
||||
"ai.toolSettings.loopback": "僅本機連線(127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "設定 HTTP",
|
||||
"ai.toolSettings.hideSecret": "隱藏金鑰",
|
||||
"ai.toolSettings.showSecret": "顯示金鑰",
|
||||
"ai.toolSettings.manualSetup": "手動設定",
|
||||
"ai.toolSettings.format": "格式",
|
||||
"ai.toolSettings.connectionDetails": "連線詳情",
|
||||
"ai.toolSettings.conflict": "設定已在其他視窗變更,目前草稿已保留。請取消並重新開啟以載入已儲存的設定。",
|
||||
"ai.toolSettings.retry": "重試",
|
||||
"ai.toolSettings.applyingHttp": "正在套用 HTTP 設定…",
|
||||
};
|
||||
@@ -2,14 +2,9 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import { createEmptyDraft } from "./aiDraftState.ts";
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from "./aiScopeCleanup.ts";
|
||||
import { pruneInactiveScopedSessions } from "./aiScopeCleanup.ts";
|
||||
|
||||
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
|
||||
return {
|
||||
@@ -24,47 +19,6 @@ function createSession(id: string, scope: AISession["scope"], externalSessionId?
|
||||
};
|
||||
}
|
||||
|
||||
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"terminal:closed-terminal": "session-closed-terminal",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
"workspace:closed-workspace": "session-closed-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:open-terminal": createEmptyDraft("catty"),
|
||||
"terminal:closed-terminal": createEmptyDraft("catty"),
|
||||
"workspace:open-workspace": createEmptyDraft("catty"),
|
||||
"workspace:closed-workspace": createEmptyDraft("catty"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open-terminal": { mode: "draft" },
|
||||
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
|
||||
"workspace:open-workspace": { mode: "draft" },
|
||||
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
|
||||
} satisfies Record<string, AIPanelView>;
|
||||
|
||||
const next = pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open-terminal", "open-workspace"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
});
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
|
||||
function isInactiveScopedTarget(
|
||||
scopeKey: string,
|
||||
@@ -55,41 +54,6 @@ export function pruneInactiveScopedState(
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTargetIds: Set<string>,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
|
||||
activeSessionMapChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextActiveSessionIdMap[scopeKey] = sessionId;
|
||||
}
|
||||
|
||||
const nextScopedState = pruneInactiveScopedState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
return {
|
||||
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
||||
draftsByScope: nextScopedState.draftsByScope,
|
||||
panelViewByScope: nextScopedState.panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedSessions(
|
||||
sessions: AISession[],
|
||||
activeTargetIds: Set<string>,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DebouncedBackupMirror,
|
||||
isMissingOrCorruptSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
shouldRestoreFromBackup,
|
||||
} from './aiSessionsBackup';
|
||||
import { prepareRestoredSessions, pruneSessionsForStorage } from './aiStateSnapshots';
|
||||
|
||||
function makeSession(id: string, updatedAt = 1_000): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'catty',
|
||||
scope: { type: 'workspace', targetId: 'ws-1' },
|
||||
messages: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('isMissingOrCorruptSessionsStore distinguishes missing or corrupt stores from valid arrays', () => {
|
||||
assert.equal(isMissingOrCorruptSessionsStore(null), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore(''), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('[]'), false);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('{corrupt'), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('{}'), true);
|
||||
assert.equal(isMissingOrCorruptSessionsStore('[{"id":"s1"}]'), false);
|
||||
});
|
||||
|
||||
test('readLiveSessionsArray normalizes missing and corrupt stores', () => {
|
||||
for (const raw of [null, undefined, '', '{}', 'not-json']) {
|
||||
assert.deepEqual(readLiveSessionsArray(raw), []);
|
||||
}
|
||||
const sessions = [makeSession('s1')];
|
||||
assert.deepEqual(readLiveSessionsArray(JSON.stringify(sessions)), sessions);
|
||||
});
|
||||
|
||||
test('parseBackupSessions ignores corrupt payloads and drops malformed entries', () => {
|
||||
assert.equal(parseBackupSessions(null), null);
|
||||
assert.equal(parseBackupSessions(''), null);
|
||||
assert.equal(parseBackupSessions('not json'), null);
|
||||
assert.equal(parseBackupSessions('{"id":"s1"}'), null);
|
||||
assert.equal(parseBackupSessions(JSON.stringify([{ id: 's1' }])), null);
|
||||
const sessions = parseBackupSessions(JSON.stringify([
|
||||
{ id: 's1' },
|
||||
{ nope: true },
|
||||
{ id: 'missing-scope', title: 'Missing scope', agentId: 'catty', messages: [], updatedAt: 1000 },
|
||||
{ ...makeSession('null-scope'), scope: null },
|
||||
{ ...makeSession('missing-title'), title: undefined },
|
||||
{ ...makeSession('missing-agent'), agentId: undefined },
|
||||
{ ...makeSession('null-message'), messages: [null] },
|
||||
{ ...makeSession('incomplete-message'), messages: [{ id: 'm' }] },
|
||||
{ ...makeSession('missing-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000 }] },
|
||||
{ ...makeSession('invalid-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: null }] },
|
||||
{ ...makeSession('invalid-timestamp'), messages: [{ id: 'm', role: 'user', timestamp: null, content: 'hello' }] },
|
||||
null,
|
||||
{ ...makeSession('s2'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: 'hello' }] },
|
||||
]));
|
||||
assert.deepEqual(sessions?.map((session) => session.id), ['s2']);
|
||||
});
|
||||
|
||||
test('restore preserves all sessions when cloud sync is enabled', () => {
|
||||
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
|
||||
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
|
||||
const local = prepareRestoredSessions(sessions, false);
|
||||
assert.equal(local.length, 50);
|
||||
assert.equal(local.some(session => session.id === 's0'), false);
|
||||
});
|
||||
|
||||
test('malformed imports cannot reach pruning or inject broken sessions', () => {
|
||||
const current = [makeSession('live')];
|
||||
const malformed = parseBackupSessions(JSON.stringify([{ id: 's1' }]));
|
||||
assert.deepEqual(mergeImportedSessions(current, pruneSessionsForStorage(malformed ?? [])), current);
|
||||
const valid = parseBackupSessions(JSON.stringify([{ id: 's1' }, makeSession('valid')]));
|
||||
const merged = mergeImportedSessions(current, pruneSessionsForStorage(valid ?? []));
|
||||
assert.deepEqual(new Set(merged.map((session) => session.id)), new Set(['live', 'valid']));
|
||||
});
|
||||
|
||||
test('restore happens only on a missing or corrupt live store with a non-empty backup', () => {
|
||||
const backup = JSON.stringify([makeSession('s1')]);
|
||||
// Empty (or wiped) store -> restore.
|
||||
assert.equal(shouldRestoreFromBackup(null, backup), true);
|
||||
assert.equal(shouldRestoreFromBackup('', backup), true);
|
||||
assert.equal(shouldRestoreFromBackup('[]', backup), false);
|
||||
assert.equal(shouldRestoreFromBackup('{corrupt', backup), true);
|
||||
// Non-empty live store -> never restore (explicit deletions must win).
|
||||
assert.equal(shouldRestoreFromBackup('[{"id":"live"}]', backup), false);
|
||||
// Empty/corrupt backup -> nothing to restore.
|
||||
assert.equal(shouldRestoreFromBackup(null, null), false);
|
||||
assert.equal(shouldRestoreFromBackup(null, '[]'), false);
|
||||
assert.equal(shouldRestoreFromBackup(null, '{corrupt'), false);
|
||||
});
|
||||
|
||||
test('mergeImportedSessions keys by id with the newer updatedAt winning', () => {
|
||||
const current = [makeSession('keep', 100), makeSession('older', 100)];
|
||||
const imported = [makeSession('newer', 200), makeSession('older', 50), makeSession('added', 1)];
|
||||
const merged = mergeImportedSessions(current, imported);
|
||||
const byId = new Map(merged.map((session) => [session.id, session]));
|
||||
assert.equal(byId.get('older')?.updatedAt, 100);
|
||||
assert.equal(byId.get('newer')?.updatedAt, 200);
|
||||
assert.equal(byId.has('added'), true);
|
||||
assert.equal(byId.has('keep'), true);
|
||||
// Sorted newest-first for stable history ordering.
|
||||
assert.deepEqual(
|
||||
merged.map((session) => session.updatedAt),
|
||||
[...merged.map((session) => session.updatedAt)].sort((a, b) => b - a),
|
||||
);
|
||||
});
|
||||
|
||||
test('DebouncedBackupMirror writes once per batch with the last payload', async () => {
|
||||
const writes: string[] = [];
|
||||
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 15);
|
||||
mirror.schedule('{"gen":1}');
|
||||
mirror.schedule('{"gen":2}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
mirror.schedule('{"gen":3}');
|
||||
mirror.schedule('{"gen":4}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
assert.deepEqual(writes, ['{"gen":2}', '{"gen":4}']);
|
||||
});
|
||||
|
||||
test('DebouncedBackupMirror flushNow writes the pending payload immediately', () => {
|
||||
const writes: string[] = [];
|
||||
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 60_000);
|
||||
mirror.schedule('{"gen":1}');
|
||||
mirror.flushNow();
|
||||
assert.deepEqual(writes, ['{"gen":1}']);
|
||||
// A second flush with nothing pending is a no-op.
|
||||
mirror.flushNow();
|
||||
assert.deepEqual(writes, ['{"gen":1}']);
|
||||
});
|
||||
|
||||
test('readLiveSessionsArray treats non-array JSON as empty (corrupt)', () => {
|
||||
assert.deepEqual(readLiveSessionsArray(null), []);
|
||||
assert.deepEqual(readLiveSessionsArray(''), []);
|
||||
assert.deepEqual(readLiveSessionsArray('{}'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('{"sessions":[]}'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('not-json'), []);
|
||||
assert.deepEqual(readLiveSessionsArray('[]'), []);
|
||||
assert.equal(readLiveSessionsArray('[{"id":"a"}]').length, 1);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
|
||||
/**
|
||||
* Renderer-side logic for the AI session durability layer (issue #4015 R2
|
||||
* follow-up): a disk mirror of the localStorage session store plus a
|
||||
* restore-on-empty-store boot path. The IPC plumbing lives in
|
||||
* `electron/bridges/aiSessionsBackupBridge.cjs`; this module keeps the
|
||||
* decisions pure so they can be tested without Electron.
|
||||
*/
|
||||
|
||||
/** Debounce window: at most one backup write per mutation burst (~2s). */
|
||||
export const AI_SESSIONS_BACKUP_DEBOUNCE_MS = 2000;
|
||||
|
||||
export interface AiSessionsBackupBridge {
|
||||
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
|
||||
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
|
||||
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
|
||||
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
|
||||
}
|
||||
|
||||
/** Restore only when the live store is missing or malformed. A valid empty
|
||||
* array represents an intentional deletion and must remain authoritative. */
|
||||
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
|
||||
if (raw == null || raw === '') return true;
|
||||
try {
|
||||
return !Array.isArray(JSON.parse(raw));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the live sessions array from a raw localStorage string. Returns [] when
|
||||
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
|
||||
* treat a non-array as AISession[]. */
|
||||
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
|
||||
if (isMissingOrCorruptSessionsStore(raw)) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw as string);
|
||||
return Array.isArray(parsed) ? parsed as AISession[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a backup payload into sessions. Returns null when the payload is
|
||||
* corrupt or malformed (ignored gracefully); also drops entries without a
|
||||
* usable required fields. Backup generations are written by the same serializer as the
|
||||
* live store, so entries are expected to be full AISession objects.
|
||||
*/
|
||||
|
||||
export function parseBackupSessions(json: string | null | undefined): AISession[] | null {
|
||||
if (typeof json !== 'string' || json === '') return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const sessions = parsed.filter(
|
||||
(entry): entry is AISession => (
|
||||
typeof entry === 'object' && entry !== null
|
||||
&& typeof (entry as AISession).id === 'string'
|
||||
&& typeof (entry as AISession).title === 'string'
|
||||
&& typeof (entry as AISession).agentId === 'string'
|
||||
&& typeof (entry as AISession).scope === 'object'
|
||||
&& (entry as AISession).scope !== null
|
||||
&& typeof (entry as AISession).scope.type === 'string'
|
||||
&& Array.isArray((entry as AISession).messages)
|
||||
&& (entry as AISession).messages.every(message => (
|
||||
typeof message === 'object' && message !== null
|
||||
&& typeof message.id === 'string'
|
||||
&& typeof message.role === 'string'
|
||||
&& typeof message.content === 'string'
|
||||
&& typeof message.timestamp === 'number'
|
||||
&& Number.isFinite(message.timestamp)
|
||||
))
|
||||
&& typeof (entry as AISession).updatedAt === 'number'
|
||||
&& Number.isFinite((entry as AISession).updatedAt)
|
||||
),
|
||||
);
|
||||
return sessions.length > 0 ? sessions : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to restore on boot: only when the live store is missing or corrupt AND
|
||||
* the backup actually contains at least one session.
|
||||
*/
|
||||
export function shouldRestoreFromBackup(
|
||||
liveRaw: string | null | undefined,
|
||||
backupJson: string | null | undefined,
|
||||
): boolean {
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) return false;
|
||||
const sessions = parseBackupSessions(backupJson);
|
||||
return sessions != null && sessions.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an imported backup into the current store: entries are keyed by id
|
||||
* and the newer `updatedAt` wins. Explicit user action, so it may overwrite
|
||||
* the live store (unlike the boot restore, which is empty-only).
|
||||
*/
|
||||
export function mergeImportedSessions(current: AISession[], imported: AISession[]): AISession[] {
|
||||
const byId = new Map<string, AISession>();
|
||||
for (const session of current) byId.set(session.id, session);
|
||||
for (const session of imported) {
|
||||
const existing = byId.get(session.id);
|
||||
if (!existing || (session.updatedAt ?? 0) >= (existing.updatedAt ?? 0)) {
|
||||
byId.set(session.id, session);
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values()).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced mirror scheduler. One rolling timer per process: the last
|
||||
* mutation batch wins, and the write happens `debounceMs` after the batch
|
||||
* settles (at most once per batch, per the durability spec).
|
||||
*/
|
||||
export class DebouncedBackupMirror {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingJson: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly write: (json: string) => void,
|
||||
private readonly debounceMs: number = AI_SESSIONS_BACKUP_DEBOUNCE_MS,
|
||||
) {}
|
||||
|
||||
schedule(json: string) {
|
||||
this.pendingJson = json;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
const json = this.pendingJson;
|
||||
this.pendingJson = null;
|
||||
if (json != null) this.write(json);
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
flushNow() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
const json = this.pendingJson;
|
||||
this.pendingJson = null;
|
||||
if (json != null) this.write(json);
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.pendingJson = null;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
rebindActiveSessionMap,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -90,7 +91,9 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -122,8 +125,24 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
// Issue #4015 R2: the dropped chat is marked dangling but its record and
|
||||
// its active pointer stay intact so /chats can offer a rebind.
|
||||
const persistedSessions = latestAISessionsSnapshot;
|
||||
assert.ok(persistedSessions);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-orphan')?.scopeDangling,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-live')?.scopeDangling,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
persistedSessions.find((session) => session.id === 'privacy-resumed')?.scopeDangling,
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
@@ -158,6 +177,95 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
|
||||
}
|
||||
});
|
||||
|
||||
test('orphan cleanup marks dangling chats instead of stripping them, and clears the flag when the target returns', (t) => {
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { dispatchEvent: () => true, netcatty: {} },
|
||||
});
|
||||
const dangling = {
|
||||
...makeSession('dangling-chat', 1, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'closed-ws' },
|
||||
};
|
||||
const live = {
|
||||
...makeSession('live-chat', 2, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'open' },
|
||||
};
|
||||
setLatestAISessionsSnapshot([dangling, live]);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed-ws': 'dangling-chat',
|
||||
'terminal:open': 'live-chat',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open']));
|
||||
|
||||
let persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, true);
|
||||
assert.equal(persisted.find((s) => s.id === 'live-chat')?.scopeDangling, undefined);
|
||||
// The active pointer survives the dangling marking.
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:closed-ws': 'dangling-chat',
|
||||
'terminal:open': 'live-chat',
|
||||
});
|
||||
assert.ok(writeString.mock.callCount() > 0);
|
||||
|
||||
// The scope target becomes live again → the dangling flag clears.
|
||||
read.mock.mockImplementation(() => null);
|
||||
cleanupOrphanedAISessions(new Set(['open', 'closed-ws']));
|
||||
persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, undefined);
|
||||
} finally {
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('orphan cleanup with an empty live-target set marks all scoped chats dangling', (t) => {
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { dispatchEvent: () => true, netcatty: {} },
|
||||
});
|
||||
const orphan = {
|
||||
...makeSession('orphan-chat', 1, []),
|
||||
scope: { type: 'terminal' as const, targetId: 'gone' },
|
||||
};
|
||||
setLatestAISessionsSnapshot([orphan]);
|
||||
setLatestAIActiveSessionMapSnapshot({ 'terminal:gone': 'orphan-chat' });
|
||||
|
||||
try {
|
||||
// Empty is a valid observed live set (nothing mounted), not "unknown".
|
||||
cleanupOrphanedAISessions(new Set());
|
||||
const persisted = latestAISessionsSnapshot;
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.find((s) => s.id === 'orphan-chat')?.scopeDangling, true);
|
||||
} finally {
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
@@ -817,3 +925,65 @@ for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
|
||||
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
|
||||
});
|
||||
}
|
||||
test('successful store writes schedule a throttled disk backup mirror', async (t) => {
|
||||
const { writeSessionsForStorage, flushBackupMirror, __resetBackupMirrorForTests } =
|
||||
await import('./aiStateSnapshots');
|
||||
__resetBackupMirrorForTests();
|
||||
const backupWrites: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
aiSessionsBackupWrite: async (payload: { json: string }) => {
|
||||
backupWrites.push(payload.json);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const sessions = freezeHistory(makePersistenceHistory(3, 10));
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
t.mock.method(localStorageAdapter, 'read', () => null);
|
||||
try {
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
// Throttled: nothing has been written to disk yet.
|
||||
assert.equal(backupWrites.length, 0);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1);
|
||||
// The mirror carries exactly the JSON that landed in localStorage.
|
||||
assert.equal(backupWrites[0], writeString.mock.calls[0].arguments[1]);
|
||||
// Failed store writes never schedule a mirror.
|
||||
writeString.mock.mockImplementation(() => false);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1);
|
||||
} finally {
|
||||
__resetBackupMirrorForTests();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('rebindActiveSessionMap drops stale pointers and sets the new scope', () => {
|
||||
const cleaned = rebindActiveSessionMap(
|
||||
{
|
||||
'terminal:old-ws': 'chat-1',
|
||||
'terminal:other': 'chat-2',
|
||||
'workspace:keep': 'chat-1',
|
||||
},
|
||||
'chat-1',
|
||||
'terminal:new-ws',
|
||||
);
|
||||
assert.deepEqual(cleaned, {
|
||||
'terminal:other': 'chat-2',
|
||||
'terminal:new-ws': 'chat-1',
|
||||
});
|
||||
// Idempotent when already bound to the target scope with no other pointers.
|
||||
assert.deepEqual(
|
||||
rebindActiveSessionMap({ 'terminal:new-ws': 'chat-1' }, 'chat-1', 'terminal:new-ws'),
|
||||
{ 'terminal:new-ws': 'chat-1' },
|
||||
);
|
||||
});
|
||||
@@ -20,10 +20,11 @@ import {
|
||||
} from './aiDraftState';
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
pruneInactiveScopedState,
|
||||
} from './aiScopeCleanup';
|
||||
import { emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
@@ -35,6 +36,11 @@ export interface AIBridge {
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
||||
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
|
||||
/** Durability mirror: write one timestamped backup generation to disk. */
|
||||
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
|
||||
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
|
||||
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
|
||||
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
|
||||
}
|
||||
|
||||
export function getAIBridge() {
|
||||
@@ -82,10 +88,13 @@ function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
|
||||
return activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
/** Last live terminal/workspace target ids observed by orphan cleanup. */
|
||||
export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
latestAIActiveTargetIdsSnapshot = activeTargetIds;
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
@@ -111,65 +120,62 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
writeSessionsForStorage(nextSessionCleanup.sessions);
|
||||
// Issue #4015 R2: a dropped chat is recoverable now, so mark it dangling
|
||||
// instead of stripping its record or pointer. `/chats` lists dangling chats
|
||||
// with a rebind affordance; the flag clears when the chat is rebound or its
|
||||
// scope target becomes live again.
|
||||
const orphanedIds = new Set(nextSessionCleanup.orphanedSessionIds);
|
||||
let sessionsMarked = false;
|
||||
const markedSessions = currentSessions.map((session) => {
|
||||
if (orphanedIds.has(session.id)) {
|
||||
if (session.scopeDangling) return session;
|
||||
sessionsMarked = true;
|
||||
return { ...session, scopeDangling: true };
|
||||
}
|
||||
if (
|
||||
session.scopeDangling
|
||||
&& (!session.scope.targetId || activeTargetIds.has(session.scope.targetId))
|
||||
) {
|
||||
sessionsMarked = true;
|
||||
const { scopeDangling: _cleared, ...rest } = session;
|
||||
return rest as AISession;
|
||||
}
|
||||
return session;
|
||||
});
|
||||
|
||||
if (sessionsMarked) {
|
||||
setLatestAISessionsSnapshot(markedSessions);
|
||||
writeSessionsForStorage(markedSessions);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const scopeKey of Object.keys(activeSessionIdMap)) {
|
||||
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
const currentActiveSessionIdMap = activeSessionMapChanged
|
||||
? nextActiveSessionIdMap
|
||||
: activeSessionIdMap;
|
||||
// Issue #4015 R2: keep active-pointer entries for scopes whose target
|
||||
// closed. Clearing them used to make dangling chats invisible (rank 0) and
|
||||
// unrecoverable from the UI; the pointer is rewritten on rebind instead.
|
||||
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const prunedScopedTransientState = pruneInactiveScopedTransientState(
|
||||
currentActiveSessionIdMap,
|
||||
const prunedScopedState = pruneInactiveScopedState(
|
||||
currentDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
prunedScopedTransientState.activeSessionIdMap,
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
|
||||
if (prunedScopedState.draftsByScope !== currentDraftsByScope) {
|
||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
||||
if (scopeKey in prunedScopedState.draftsByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
}
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedState.draftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
|
||||
if (prunedScopedState.panelViewByScope !== currentPanelViewByScope) {
|
||||
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
|
||||
if (scopeKey in prunedScopedState.panelViewByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedState.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}
|
||||
@@ -349,6 +355,10 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function prepareRestoredSessions(sessions: AISession[], syncEnabled: boolean): AISession[] {
|
||||
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
@@ -486,11 +496,47 @@ export function serializeSessionsForStorage(
|
||||
* Returns true when the write succeeded; a false result means the payload
|
||||
* could not be persisted even after escalation (it stays memory-only).
|
||||
*/
|
||||
|
||||
// Durability mirror (issue #4015 follow-up): every successful store write
|
||||
// also schedules a debounced disk backup so a wiped/recreated profile does
|
||||
// not take the chat history with it. The exact JSON that landed in
|
||||
// localStorage is mirrored (already byte-budgeted), throttled to at most one
|
||||
// write per mutation batch.
|
||||
const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
try {
|
||||
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
|
||||
} catch {
|
||||
// Backup failures must never break chat persistence.
|
||||
}
|
||||
});
|
||||
|
||||
export function flushBackupMirror() {
|
||||
try {
|
||||
backupMirror.flushNow();
|
||||
} catch {
|
||||
// Unload and persistence must remain fail-safe.
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetBackupMirrorForTests() {
|
||||
backupMirror.reset();
|
||||
}
|
||||
|
||||
export function scheduleBackupMirror(json: string) {
|
||||
try {
|
||||
backupMirror.schedule(json);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
// Synced records are authoritative after restart: silently evicting a
|
||||
// session or turn here would turn local retention into cloud deletion.
|
||||
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
const syncedOk = localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
if (syncedOk) scheduleBackupMirror(JSON.stringify(sessions));
|
||||
return syncedOk;
|
||||
}
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
@@ -505,7 +551,10 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
if (budget < 2) break;
|
||||
const candidate = serializeSessionsForStorage(sessions, budget);
|
||||
if (candidate.json.length >= previousLength) continue;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) {
|
||||
scheduleBackupMirror(candidate.json);
|
||||
return true;
|
||||
}
|
||||
previousLength = candidate.json.length;
|
||||
}
|
||||
// Last resort: attempt the smallest representation this serializer can
|
||||
@@ -516,6 +565,7 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
minimalCandidate.json.length < previousLength
|
||||
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
|
||||
) {
|
||||
scheduleBackupMirror(minimalCandidate.json);
|
||||
return true;
|
||||
}
|
||||
console.warn(
|
||||
@@ -531,6 +581,28 @@ export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
|
||||
|
||||
|
||||
/**
|
||||
* Issue #4015 R2: rewrite the active-session map when rebinding a dangling
|
||||
* chat. Drops every pointer that still names `sessionId` under a different
|
||||
* scope key, then sets the new scope as active. Callers must persist and
|
||||
* publish the returned map (do not rebuild from a stale snapshot).
|
||||
*/
|
||||
export function rebindActiveSessionMap(
|
||||
activeSessionIdMap: Record<string, string | null>,
|
||||
sessionId: string,
|
||||
scopeKey: string,
|
||||
): Record<string, string | null> {
|
||||
let next = activeSessionIdMap;
|
||||
for (const [key, id] of Object.entries(activeSessionIdMap)) {
|
||||
if (id !== sessionId || key === scopeKey) continue;
|
||||
if (next === activeSessionIdMap) next = { ...activeSessionIdMap };
|
||||
delete next[key];
|
||||
}
|
||||
if (next[scopeKey] === sessionId) return next;
|
||||
return { ...next, [scopeKey]: sessionId };
|
||||
}
|
||||
|
||||
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_THINKING_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||
@@ -63,12 +64,17 @@ import {
|
||||
bumpDraftUploadGeneration,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
latestAIActiveTargetIdsSnapshot,
|
||||
getAIBridge,
|
||||
flushBackupMirror,
|
||||
getDraftUploadGeneration,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAIDraftsByScopeSnapshot,
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
prepareRestoredSessions,
|
||||
scheduleBackupMirror,
|
||||
rebindActiveSessionMap,
|
||||
writeSessionsForStorage,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
@@ -77,6 +83,12 @@ import {
|
||||
type DraftsByScope,
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import {
|
||||
isMissingOrCorruptSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
} from './aiSessionsBackup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
@@ -168,9 +180,76 @@ export function useAIState() {
|
||||
// ── Sessions ──
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? []
|
||||
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
|
||||
);
|
||||
// An explicit empty array is authoritative, even if the backup is stale.
|
||||
// Only a missing or corrupt store can be restored from disk.
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushBackupMirror);
|
||||
return () => window.removeEventListener('beforeunload', flushBackupMirror);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) {
|
||||
if (readLiveSessionsArray(liveRaw).length > 0 && liveRaw != null) scheduleBackupMirror(liveRaw);
|
||||
return;
|
||||
}
|
||||
if (latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupReadLatest) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
if (!isMissingOrCorruptSessionsStore(liveRaw)) return;
|
||||
const backup = await bridge.aiSessionsBackupReadLatest?.();
|
||||
if (cancelled || !backup?.ok) return;
|
||||
// Re-check after the async gap: a session may have been created
|
||||
// while the backup was being read; never restore over live state.
|
||||
// Cover both persisted storage and in-memory snapshot/React state —
|
||||
// createSession updates the snapshot before the debounced disk write.
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
|
||||
if (restored.length === 0) return;
|
||||
// Apply restore synchronously (not inside a setState updater). A
|
||||
// deferred updater would leave didRestore false and skip dangling
|
||||
// marking even when the records later land in React state.
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
if (sessionsRef.current.length > 0) return;
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
setLatestAISessionsSnapshot(restored);
|
||||
writeSessionsForStorage(restored);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
// Restore does not change live terminals/workspaces, so the orphan-
|
||||
// cleanup effect will not run. Reuse the last known live target set
|
||||
// (empty is valid: nothing live) so chats bound to missing scopes
|
||||
// become dangling and /chats can offer /rebind.
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions = latestAISessionsSnapshot ?? restored;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
console.info(
|
||||
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
|
||||
);
|
||||
} catch {
|
||||
// Backup restore must never break startup.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
// Ref that always holds the latest sessions for use inside debounced callbacks
|
||||
const sessionsRef = useRef(sessions);
|
||||
useEffect(() => {
|
||||
@@ -866,6 +945,101 @@ export function useAIState() {
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Issue #4015 R2: rebind a dangling chat to the panel's current scope.
|
||||
// Rewrites `scope`, clears the dangling marker and makes the chat the
|
||||
// active session of its new scope so it shows in the scoped history again.
|
||||
const rebindSessionScope = useCallback((
|
||||
sessionId: string,
|
||||
scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] },
|
||||
) => {
|
||||
const scopeKey = `${scope.type}:${scope.targetId}`;
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
const { scopeDangling: _cleared, ...rest } = s;
|
||||
// Replace scope entirely (including hostIds) so history matching follows
|
||||
// the destination terminal/workspace rather than the chat's old host.
|
||||
const nextScope: AISession['scope'] = {
|
||||
type: scope.type,
|
||||
targetId: scope.targetId,
|
||||
};
|
||||
if (scope.hostIds && scope.hostIds.length > 0) {
|
||||
nextScope.hostIds = [...scope.hostIds];
|
||||
}
|
||||
return {
|
||||
...rest,
|
||||
scope: nextScope,
|
||||
updatedAt: Date.now(),
|
||||
} as AISession;
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
// Drop stale pointers that still reference this chat under its previous
|
||||
// scope key, then restore the active pointer for the new scope.
|
||||
// Compute + persist inside the updater so a deferred React setState still
|
||||
// writes the cleaned map (outer capture would stay null and skip persist).
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
const next = rebindActiveSessionMap(prev, sessionId, scopeKey);
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
// Open the rebound chat in this panel; active-map alone loses to an
|
||||
// explicit draft/session panelView that still points elsewhere.
|
||||
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
|
||||
}, [persistSessions, setPanelViewByScope]);
|
||||
|
||||
// Manual durability escape hatch (issue #4015 follow-up): export the
|
||||
// current store to a user-chosen JSON file, or import one back in.
|
||||
// Import is an explicit action, so it may overwrite the live store.
|
||||
const exportSessionsBackup = useCallback(async (): Promise<string | null> => {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupExport) return null;
|
||||
const result = await bridge.aiSessionsBackupExport({
|
||||
json: JSON.stringify(sessionsRef.current),
|
||||
});
|
||||
return result?.success ? (result.filePath ?? '') : null;
|
||||
}, []);
|
||||
|
||||
const importSessionsBackup = useCallback(async (): Promise<number> => {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupImport) return 0;
|
||||
const result = await bridge.aiSessionsBackupImport();
|
||||
if (!result?.success || !result.json) return 0;
|
||||
const imported = parseBackupSessions(result.json);
|
||||
if (!imported || imported.length === 0) return 0;
|
||||
// Merge synchronously against the live ref so orphan cleanup sees the
|
||||
// imported chats even if React would defer a setState updater.
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
|
||||
if (!writeSessionsForStorage(next)) return 0;
|
||||
const persisted = readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
const importedIds = new Set(imported.map(session => session.id));
|
||||
sessionsRef.current = persisted;
|
||||
setLatestAISessionsSnapshot(persisted);
|
||||
setSessionsRaw(persisted);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
// Import does not change live terminals/workspaces, so the orphan-cleanup
|
||||
// effect will not run. Reuse the last known live target set (empty is
|
||||
// valid: nothing live) to mark chats bound to missing scopes as dangling
|
||||
// so /chats can offer /rebind.
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? persisted;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
return persisted.filter(session => importedIds.has(session.id)).length;
|
||||
}, []);
|
||||
|
||||
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => (
|
||||
@@ -1370,6 +1544,9 @@ export function useAIState() {
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
@@ -1432,6 +1609,9 @@ export function useAIState() {
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
|
||||
@@ -24,6 +24,7 @@ reorderWorkspaceFocusSessionOrder,
|
||||
SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
invertSplitPosition,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import {
|
||||
@@ -795,12 +796,68 @@ export const useSessionState = ({
|
||||
return workspace.id;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const addSessionToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint) return;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session || session.workspaceId) return prevSessions;
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
|
||||
if (!targetWorkspace) return prevWorkspaces;
|
||||
|
||||
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
|
||||
});
|
||||
|
||||
setActiveTabId(workspaceId);
|
||||
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Merge two tabs into one workspace. When one of them already belongs to a
|
||||
// workspace, that workspace (and its id) is reused instead of dissolving
|
||||
// and recreating: the id is what workspace-scoped AI chats bind to, so
|
||||
// regenerating it used to strand every conversation anchored to it
|
||||
// (issue #4015 R3). Only two genuinely standalone tabs create a new id.
|
||||
const createWorkspaceFromSessions = useCallback((
|
||||
baseSessionId: string,
|
||||
joiningSessionId: string,
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint || baseSessionId === joiningSessionId) return;
|
||||
const baseSession = sessionsRef.current.find((s) => s.id === baseSessionId);
|
||||
const joiningSession = sessionsRef.current.find((s) => s.id === joiningSessionId);
|
||||
const anchorWorkspaceId = baseSession?.workspaceId ?? joiningSession?.workspaceId;
|
||||
if (anchorWorkspaceId) {
|
||||
const anchorSessionId = baseSession?.workspaceId ? baseSessionId : joiningSessionId;
|
||||
const orphanSessionId = anchorSessionId === baseSessionId ? joiningSessionId : baseSessionId;
|
||||
const orphanSession = sessionsRef.current.find((s) => s.id === orphanSessionId);
|
||||
if (!orphanSession || orphanSession.workspaceId) return;
|
||||
// Split next to the anchor's existing pane: the raw drop hint targets
|
||||
// the orphan's pane, which is not part of this workspace tree yet.
|
||||
// When the dragged (joining) pane already owns the workspace, the orphan
|
||||
// is what gets inserted — invert the drop position so the previewed
|
||||
// side still matches (hint describes where the dragged pane should sit).
|
||||
const joinOwnsWorkspace = Boolean(joiningSession?.workspaceId) && !baseSession?.workspaceId;
|
||||
const insertHint = {
|
||||
...hint,
|
||||
targetSessionId: anchorSessionId,
|
||||
position: joinOwnsWorkspace ? invertSplitPosition(hint.position) : hint.position,
|
||||
};
|
||||
addSessionToWorkspace(
|
||||
anchorWorkspaceId,
|
||||
orphanSessionId,
|
||||
insertHint,
|
||||
);
|
||||
// The orphan's standalone tab slot is replaced by the workspace tab.
|
||||
setTabOrder((prevTabOrder) => prevTabOrder.filter((id) => id !== orphanSessionId));
|
||||
return;
|
||||
}
|
||||
// Name the merged workspace after both tabs' connection labels (e.g.
|
||||
// "01/02") so the window title and the rename dialog show the merged
|
||||
// identity instead of the generic "Workspace". Falls back to the default
|
||||
@@ -855,30 +912,7 @@ export const useSessionState = ({
|
||||
return s;
|
||||
});
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const addSessionToWorkspace = useCallback((
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint) return;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session || session.workspaceId) return prevSessions;
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
|
||||
if (!targetWorkspace) return prevWorkspaces;
|
||||
|
||||
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
|
||||
});
|
||||
|
||||
setActiveTabId(workspaceId);
|
||||
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
}, [addSessionToWorkspace, setActiveTabId]);
|
||||
|
||||
// Add a host into an existing workspace by creating a new session for
|
||||
// that host and appending it as the last pane at the workspace root.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { collectSessionIds } from "../../domain/workspace.ts";
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("merging a workspace tab with a standalone tab reuses the workspace id (issue #4015 R3)", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
t.after(() => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
t.after(() => {
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
});
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
let baseId = "";
|
||||
let memberId = "";
|
||||
let standaloneId = "";
|
||||
await act(async () => {
|
||||
baseId = current().createLocalTerminal({ shellName: "01" });
|
||||
memberId = current().createLocalTerminal({ shellName: "02" });
|
||||
standaloneId = current().createLocalTerminal({ shellName: "03" });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(baseId, memberId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: baseId,
|
||||
});
|
||||
});
|
||||
const workspaceId = current().workspaces[0]?.id;
|
||||
assert.ok(workspaceId);
|
||||
assert.equal(current().sessions.find((s) => s.id === standaloneId)?.workspaceId ?? "", "");
|
||||
|
||||
// Dropping the standalone tab onto a member of the workspace must join the
|
||||
// existing workspace — regenerating the id would strand AI chats bound to it.
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(standaloneId, memberId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: standaloneId,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(current().workspaces.length, 1);
|
||||
assert.equal(current().workspaces[0]?.id, workspaceId);
|
||||
assert.equal(
|
||||
current().sessions.find((s) => s.id === standaloneId)?.workspaceId,
|
||||
workspaceId,
|
||||
);
|
||||
const memberIds = collectSessionIds(current().workspaces[0]!.root);
|
||||
assert.ok(memberIds.includes(standaloneId));
|
||||
assert.ok(memberIds.includes(memberId));
|
||||
// The standalone tab slot is replaced by the workspace tab.
|
||||
assert.ok(!current().orderedTabs.includes(standaloneId));
|
||||
|
||||
|
||||
// Dropping a workspace member onto a standalone tab must insert the
|
||||
// standalone on the opposite side of the drop hint (hint describes where
|
||||
// the dragged member should appear relative to the standalone).
|
||||
let orientationStandaloneId = "";
|
||||
let orientationMemberId = "";
|
||||
await act(async () => {
|
||||
orientationStandaloneId = current().createLocalTerminal({ shellName: "orient-standalone" });
|
||||
});
|
||||
// memberId is already in workspaceId; drop member onto the new standalone with position "right"
|
||||
// (preview: member to the right of standalone ⇒ standalone should end up LEFT of member).
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(orientationStandaloneId, memberId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: orientationStandaloneId,
|
||||
});
|
||||
});
|
||||
{
|
||||
const root = current().workspaces.find((ws) => ws.id === workspaceId)?.root;
|
||||
assert.ok(root);
|
||||
const ordered = collectSessionIds(root!);
|
||||
const standaloneIdx = ordered.indexOf(orientationStandaloneId);
|
||||
const memberIdx = ordered.indexOf(memberId);
|
||||
assert.ok(standaloneIdx >= 0 && memberIdx >= 0);
|
||||
assert.ok(
|
||||
standaloneIdx < memberIdx,
|
||||
`expected standalone left of member after inverted right-drop, got order ${ordered.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Two genuinely standalone tabs still create a fresh workspace.
|
||||
let firstStandaloneId = "";
|
||||
let secondStandaloneId = "";
|
||||
await act(async () => {
|
||||
firstStandaloneId = current().createLocalTerminal({ shellName: "04" });
|
||||
secondStandaloneId = current().createLocalTerminal({ shellName: "05" });
|
||||
});
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(firstStandaloneId, secondStandaloneId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: firstStandaloneId,
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(current().workspaces.length, 2);
|
||||
const freshWorkspace = current().workspaces.find((candidate) => candidate.id !== workspaceId);
|
||||
assert.ok(freshWorkspace);
|
||||
assert.notEqual(freshWorkspace.id, workspaceId);
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useToolAccessGuidanceState, type ToolAccessGuidanceState } from './useToolAccessGuidanceState';
|
||||
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
async function harness() {
|
||||
const requests: ReturnType<typeof deferred<unknown>>[] = [];
|
||||
const events = new EventTarget();
|
||||
const values = new Map([[STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true']]);
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) },
|
||||
window: {
|
||||
netcatty: {
|
||||
externalMcpGetStatus: () => { const request = deferred<unknown>(); requests.push(request); return request.promise; },
|
||||
aiSkillsCliGetInvocation: async () => ({ ok: true, skillPath: '/fixture/skills/SKILL.md', commandPrefix: 'fixture-cli' }),
|
||||
},
|
||||
addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events), setTimeout, clearTimeout,
|
||||
},
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
|
||||
}
|
||||
let result!: ToolAccessGuidanceState;
|
||||
function Harness({ mode }: { mode: AIToolIntegrationMode }) { result = useToolAccessGuidanceState(mode); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(<Harness mode="mcp" />); });
|
||||
return {
|
||||
requests, events, values, result: () => result,
|
||||
skills: async () => { await act(async () => renderer.update(<Harness mode="skills" />)); },
|
||||
close: async () => {
|
||||
await act(async () => renderer.unmount());
|
||||
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
|
||||
},
|
||||
};
|
||||
}
|
||||
const status = (path: string) => ({ ok: true, enabled: true, state: 'running', launcherPath: path, discoveryPath: `${path}.json` });
|
||||
|
||||
test('older MCP guidance response cannot overwrite a newer enable-state refresh', async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
assert.equal(h.requests.length, 1);
|
||||
await act(async () => { h.events.dispatchEvent(new CustomEvent(AI_STATE_CHANGED_EVENT, { detail: { key: STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } })); });
|
||||
assert.equal(h.requests.length, 2);
|
||||
await act(async () => h.requests[1].resolve(status('/fixture/new')));
|
||||
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/old')));
|
||||
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test('switching to Skills ignores pending MCP results and preserves resolved Skills guidance', async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
await h.skills();
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
|
||||
assert.equal(h.result().mcpLauncherPath, null);
|
||||
assert.equal(h.result().skillPath, '/fixture/skills/SKILL.md');
|
||||
assert.equal(h.result().commandPrefix, 'fixture-cli');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test('MCP guidance completion after unmount has no visible state or retry work', async () => {
|
||||
const h = await harness();
|
||||
const initial = h.result();
|
||||
await h.close();
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
|
||||
assert.equal(h.result(), initial);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -38,14 +38,15 @@ export type ToolAccessGuidanceState = {
|
||||
* runtime is still starting or after an idle shutdown).
|
||||
*/
|
||||
export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAccessGuidanceState {
|
||||
const requestGeneration = useRef(0);
|
||||
const [skillPath, setSkillPath] = useState<string | null>(null);
|
||||
const [commandPrefix, setCommandPrefix] = useState('');
|
||||
const [mcpLauncherPath, setMcpLauncherPath] = useState<string | null>(null);
|
||||
const [mcpDiscoveryPath, setMcpDiscoveryPath] = useState<string | null>(null);
|
||||
|
||||
const refreshMcpStatus = useCallback(async (): Promise<string | null | undefined> => {
|
||||
const refreshMcpStatus = useCallback(async (isCurrent: () => boolean): Promise<string | null | undefined> => {
|
||||
const status = await netcattyBridge.get()?.externalMcpGetStatus?.();
|
||||
if (!status?.ok) return undefined;
|
||||
if (!isCurrent() || !status?.ok) return undefined;
|
||||
// Do not trust `enabled` alone: the main process flips `enabled` before
|
||||
// host credentials are resolved and the discovery file is written, and it
|
||||
// keeps reporting the configured discovery/launcher paths while starting.
|
||||
@@ -93,9 +94,11 @@ export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAcc
|
||||
|
||||
const refetch = async () => {
|
||||
if (cancelled) return;
|
||||
const generation = ++requestGeneration.current;
|
||||
const isCurrent = () => !cancelled && generation === requestGeneration.current;
|
||||
const storedEnabled = readExternalMcpStoredEnabled();
|
||||
const readyLauncherPath = await refreshMcpStatus().catch(() => undefined);
|
||||
if (cancelled) return;
|
||||
const readyLauncherPath = await refreshMcpStatus(isCurrent).catch(() => undefined);
|
||||
if (!isCurrent()) return;
|
||||
// The persisted switch is off but the status check failed: drop any
|
||||
// cached launcher/discovery paths. The stored credentials were revoked
|
||||
// by the disable, and falling through to the comparison below would see
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
|
||||
import { STORAGE_KEY_SHELL_HISTORY } from "../../infrastructure/config/storageKeys";
|
||||
|
||||
test("shell history append yields the input turn and persists merged entries", async () => {
|
||||
const dom = new JSDOM("", { url: "http://localhost" });
|
||||
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
|
||||
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
Object.defineProperties(globalThis, {
|
||||
window: { value: dom.window, configurable: true },
|
||||
localStorage: { value: dom.window.localStorage, configurable: true },
|
||||
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
|
||||
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
|
||||
});
|
||||
installBrowserLockFixture();
|
||||
const { useVaultState } = await import("./useVaultState");
|
||||
let state: ReturnType<typeof useVaultState>;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
}
|
||||
assert.equal(state!.isInitialized, true);
|
||||
const entry = { command: " echo hello ", hostId: "local", hostLabel: "Local", sessionId: "one" };
|
||||
act(() => {
|
||||
state!.addShellHistoryEntry(entry);
|
||||
state!.addShellHistoryEntry({ ...entry, sessionId: "two" });
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY), null);
|
||||
assert.equal(state!.shellHistory.length, 0);
|
||||
});
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY)!);
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].command, "echo hello");
|
||||
assert.equal(saved[0].sessionId, "two");
|
||||
assert.deepEqual(state!.shellHistory, saved);
|
||||
|
||||
for (const clear of [() => state!.clearShellHistory(), () => state!.clearAllCommandHistory()]) {
|
||||
act(() => {
|
||||
state!.addShellHistoryEntry(entry);
|
||||
state!.addShellHistoryEntry({ ...entry, command: "another command" });
|
||||
clear();
|
||||
});
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
assert.deepEqual(state!.shellHistory, []);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY)!), []);
|
||||
}
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
dom.window.close();
|
||||
for (const key of globals) {
|
||||
const descriptor = previous.get(key);
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -317,6 +317,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const [noteGroups, setNoteGroups] = useState<string[]>([]);
|
||||
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
|
||||
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
|
||||
const shellHistoryGenerationRef = useRef(0);
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
|
||||
@@ -1445,19 +1446,25 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
||||
setShellHistory((prev) => {
|
||||
const updated = mergeGlobalHistoryOnAppend(prev, entry);
|
||||
if (updated === prev) return prev;
|
||||
// Persist immediately so crash between commit and layout effect cannot drop history.
|
||||
// Store republish is also done in useLayoutEffect for load/storage-event paths.
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
const generation = shellHistoryGenerationRef.current;
|
||||
// Yield the input turn before history rendering and synchronous storage work.
|
||||
// Persist in the next task to keep the crash-loss window small.
|
||||
setTimeout(() => {
|
||||
if (generation !== shellHistoryGenerationRef.current) return;
|
||||
setShellHistory((prev) => {
|
||||
if (generation !== shellHistoryGenerationRef.current) return prev;
|
||||
const updated = mergeGlobalHistoryOnAppend(prev, entry);
|
||||
if (updated === prev) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
}, 0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearShellHistory = useCallback(() => {
|
||||
shellHistoryGenerationRef.current += 1;
|
||||
setShellHistory([]);
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, []);
|
||||
publishShellHistorySnapshot([]);
|
||||
|
||||
@@ -40,3 +40,35 @@ test('terminal side panel tools share one primary toolbar height', () => {
|
||||
assert.match(source, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
|
||||
}
|
||||
});
|
||||
|
||||
test('/rename and /chats panel commands stay out of the agent message path', () => {
|
||||
const sidePanel = preparingSource;
|
||||
// Panel implements the commands with updateSessionTitle + a note state —
|
||||
// never addMessageToSession, so the commands cannot leak into chat history.
|
||||
assert.match(sidePanel, /handleRenameSession = useCallback/);
|
||||
assert.match(sidePanel, /handleListChats = useCallback/);
|
||||
assert.match(sidePanel, /updateSessionTitle\(sessionId, title\)/);
|
||||
assert.match(sidePanel, /setPanelCommandNote\(formatAiChatsListingNote\(entries, t\)\)/);
|
||||
const renameHandler = sidePanel.slice(
|
||||
sidePanel.indexOf('handleRenameSession = useCallback'),
|
||||
sidePanel.indexOf('const handleRenameSlashCommand'),
|
||||
);
|
||||
assert.doesNotMatch(renameHandler, /addMessageToSession/);
|
||||
const chatsHandler = sidePanel.slice(
|
||||
sidePanel.indexOf('handleListChats = useCallback'),
|
||||
sidePanel.indexOf('const handleSend'),
|
||||
);
|
||||
assert.doesNotMatch(chatsHandler, /addMessageToSession/);
|
||||
// Wiring reaches AIChatPanelContent.
|
||||
assert.match(sidePanel, /onRenameActiveChat=\{handleRenameSlashCommand\}/);
|
||||
assert.match(sidePanel, /onListChats=\{handleListChats\}/);
|
||||
});
|
||||
|
||||
test('AIChatPanelContent renders the panel command note and wires rename/list props', () => {
|
||||
assert.match(panelSource, /panelCommandNote \? \(/);
|
||||
assert.match(panelSource, /role="status"/);
|
||||
assert.match(panelSource, /onDismissPanelCommandNote/);
|
||||
assert.match(panelSource, /onRenameSession=\{onRenameActiveChat\}/);
|
||||
assert.match(panelSource, /onListChats=\{onListChats\}/);
|
||||
assert.match(panelSource, /onRename=\{onRenameSession\}/);
|
||||
});
|
||||
@@ -57,6 +57,20 @@ interface AIChatPanelContentProps {
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
handleCompact: () => void;
|
||||
/** Rename a chat by id (history drawer edit affordance). Panel action, never agent content. */
|
||||
onRenameSession?: (sessionId: string, title: string) => void;
|
||||
/** `/rename <title>` for the current chat. Panel action, never agent content. */
|
||||
onRenameActiveChat?: (title: string) => void;
|
||||
/** `/chats`: list the chats visible to this panel. Panel action, never agent content. */
|
||||
onListChats?: () => void;
|
||||
/** `/rebind <n>`: rebind a dangling chat to this panel's scope. Panel action, never agent content. */
|
||||
onRebindChat?: (arg: string) => void;
|
||||
/** `/chats` listing output, rendered as a system-style note above the composer. */
|
||||
panelCommandNote?: string | null;
|
||||
onDismissPanelCommandNote?: () => void;
|
||||
/** Manual durability escape hatch: export/import the whole session store. */
|
||||
onExportSessions?: () => void;
|
||||
onImportSessions?: () => void;
|
||||
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
|
||||
canUndoLastTurn?: boolean;
|
||||
handleUndoLastTurn?: () => void;
|
||||
@@ -142,6 +156,14 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
setInputValue,
|
||||
handleSend,
|
||||
handleCompact,
|
||||
onRenameSession,
|
||||
onRenameActiveChat,
|
||||
onListChats,
|
||||
onRebindChat,
|
||||
panelCommandNote,
|
||||
onDismissPanelCommandNote,
|
||||
onExportSessions,
|
||||
onImportSessions,
|
||||
canUndoLastTurn = false,
|
||||
handleUndoLastTurn,
|
||||
canCompact = false,
|
||||
@@ -309,6 +331,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={sending ? () => undefined : handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onRename={onRenameSession}
|
||||
onExportSessions={onExportSessions}
|
||||
onImportSessions={onImportSessions}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
</React.Profiler>
|
||||
@@ -369,6 +394,21 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
{!hideInput && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
|
||||
<div>
|
||||
{panelCommandNote ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||
<span className="block max-h-48 overflow-y-auto whitespace-pre-wrap break-words">{panelCommandNote}</span>
|
||||
{onDismissPanelCommandNote && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onDismissPanelCommandNote}
|
||||
className="mt-1 text-[11px] text-muted-foreground/60 underline-offset-2 transition-colors hover:text-foreground hover:underline cursor-pointer"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{steerWarning ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
{steerWarning}
|
||||
@@ -396,6 +436,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onCompact={handleCompact}
|
||||
onRenameSession={onRenameActiveChat}
|
||||
onListChats={onListChats}
|
||||
onRebindChat={onRebindChat}
|
||||
canCompact={canCompact}
|
||||
onSteer={handleSteer}
|
||||
onStop={handleStop}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Search, Trash2, X } from 'lucide-react';
|
||||
import { Download, Pencil, Search, Trash2, Upload, X } from 'lucide-react';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -19,6 +19,12 @@ interface SessionHistoryDrawerProps {
|
||||
activeSessionId: string | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onDelete: (e: React.MouseEvent, sessionId: string) => void;
|
||||
/** Rename a chat inline (issue #4015 R5). Empty titles are ignored. */
|
||||
onRename?: (sessionId: string, title: string) => void;
|
||||
/** Manual durability escape hatch: export the whole store to a JSON file. */
|
||||
onExportSessions?: () => void;
|
||||
/** Import sessions from a JSON export; merges by id, newer wins. */
|
||||
onImportSessions?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -30,11 +36,17 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onExportSessions,
|
||||
onImportSessions,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState('');
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchHintId = useId();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const { filteredSessions, isSearching } = useSessionHistorySearch(
|
||||
@@ -52,16 +64,69 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
const displayedSessions = filteredSessions.slice(0, renderCount);
|
||||
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingSessionId) renameInputRef.current?.focus();
|
||||
}, [renamingSessionId]);
|
||||
|
||||
const beginRename = (sessionId: string, currentTitle: string) => {
|
||||
setRenamingSessionId(sessionId);
|
||||
setRenameDraft(currentTitle);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
if (renamingSessionId && renameDraft.trim()) {
|
||||
onRename?.(renamingSessionId, renameDraft.trim());
|
||||
}
|
||||
setRenamingSessionId(null);
|
||||
setRenameDraft('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onImportSessions && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImportSessions();
|
||||
}}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
aria-label={t('ai.chat.importSessions')}
|
||||
>
|
||||
<Upload size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.importSessions')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onExportSessions && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExportSessions();
|
||||
}}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
aria-label={t('ai.chat.exportSessions')}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.exportSessions')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 shrink-0 border-b border-border/30">
|
||||
<div className="relative">
|
||||
@@ -146,12 +211,52 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
)}
|
||||
>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
{renamingSessionId === session.id ? (
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameDraft}
|
||||
aria-label={t('ai.chat.renameChat')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setRenamingSessionId(null);
|
||||
setRenameDraft('');
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
className="h-6 text-[12px]"
|
||||
/>
|
||||
) : (
|
||||
session.title || t('ai.chat.untitled')
|
||||
)}
|
||||
</span>
|
||||
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
{onRename && renamingSessionId !== session.id && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
beginRename(session.id, session.title || '');
|
||||
}}
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.renameChat')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
type DefaultTargetSessionHint,
|
||||
} from '../application/state/useAIChatStreaming';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { buildAiChatsListingEntries, canRebindChat, formatAiChatsListingNote, resolveRebindTargetFromArg, type AiChatsListingEntry } from './ai/aiChatsListing';
|
||||
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
|
||||
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
|
||||
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
||||
@@ -279,6 +280,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
rebindSessionScope,
|
||||
exportSessionsBackup,
|
||||
importSessionsBackup,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
@@ -312,7 +316,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
focusedSessionId,
|
||||
workspaces = [],
|
||||
terminalSessions = [],
|
||||
liveScopeTargetIds,
|
||||
resolveExecutorContext,
|
||||
isVisible = true,
|
||||
notes = [],
|
||||
@@ -327,6 +333,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
// Inline system-style note above the composer (e.g. `/chats` listing).
|
||||
// Panel-command output, never agent content.
|
||||
const [panelCommandNote, setPanelCommandNote] = useState<string | null>(null);
|
||||
// Entries of the last `/chats` listing; `/rebind <n>` resolves against them.
|
||||
const lastChatsListingRef = useRef<AiChatsListingEntry[]>([]);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
// Undo-last-turn is still
|
||||
// running. Unlike the send flow this is not reflected in `isSending`, so it
|
||||
@@ -484,6 +495,119 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
|
||||
/** Rename a chat (slash command + history drawer affordance). Panel action: nothing is sent to the agent. */
|
||||
const handleRenameSession = useCallback((sessionId: string, rawTitle: string) => {
|
||||
const title = rawTitle.trim();
|
||||
if (!title) {
|
||||
toast.warning(t('ai.chat.renameUsage'));
|
||||
return;
|
||||
}
|
||||
if (!sessionId) {
|
||||
toast.warning(t('ai.chat.renameNoActive'));
|
||||
return;
|
||||
}
|
||||
updateSessionTitle(sessionId, title);
|
||||
toast.success(t('ai.chat.renameDone').replace('{title}', title));
|
||||
}, [t, updateSessionTitle]);
|
||||
|
||||
const handleRenameSlashCommand = useCallback((title: string) => {
|
||||
handleRenameSession(activeSessionRef.current?.id ?? null, title);
|
||||
}, [handleRenameSession]);
|
||||
|
||||
const handleListChats = useCallback(() => {
|
||||
// Prefer the app-wide live target set so chats bound to other still-open
|
||||
// terminals are not mislabelled dangling / offered for /rebind.
|
||||
const liveTerminalIds = liveScopeTargetIds
|
||||
?? (scopeType === 'workspace' && workspaceMemberTerminalIds
|
||||
? workspaceMemberTerminalIds
|
||||
: new Set(scopeTargetId ? [scopeTargetId] : []));
|
||||
// Dangling chats no longer match any live scope (rank 0), so they must be
|
||||
// appended explicitly for the listing — that is where they become visible
|
||||
// and rebindable again (issue #4015 R2).
|
||||
const listedSessionIds = new Set(historySessions.map((session) => session.id));
|
||||
const danglingSessions = sessions.filter((session) => session.scopeDangling && !listedSessionIds.has(session.id));
|
||||
const entries = buildAiChatsListingEntries({
|
||||
sessions: [...historySessions, ...danglingSessions],
|
||||
activeSessionId,
|
||||
externalAgents,
|
||||
workspaces,
|
||||
liveTerminalIds,
|
||||
currentScopeLabel: scopeLabel,
|
||||
scopeTargetId,
|
||||
t,
|
||||
});
|
||||
lastChatsListingRef.current = entries;
|
||||
setPanelCommandNote(formatAiChatsListingNote(entries, t));
|
||||
}, [activeSessionId, externalAgents, historySessions, liveScopeTargetIds, scopeLabel, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
|
||||
|
||||
// `/rebind <n>`: rebind the nth dangling chat from the last /chats listing
|
||||
// to this panel's current scope. Panel action — no agent round-trip.
|
||||
const handleRebindChat = useCallback((arg: string) => {
|
||||
if (!scopeTargetId) {
|
||||
toast.warning(t('ai.chat.rebindUnavailable'));
|
||||
return;
|
||||
}
|
||||
const target = resolveRebindTargetFromArg(arg, lastChatsListingRef.current);
|
||||
if (!target) {
|
||||
toast.warning(t('ai.chat.rebindUsage'));
|
||||
return;
|
||||
}
|
||||
const session = sessions.find((item) => item.id === target.sessionId);
|
||||
const liveTargets = new Set(liveScopeTargetIds ?? workspaceMemberTerminalIds ?? []);
|
||||
for (const workspace of workspaces) liveTargets.add(workspace.id);
|
||||
liveTargets.add(scopeTargetId);
|
||||
if (canRebindChat(session, liveTargets)) {
|
||||
rebindSessionScope?.(target.sessionId, { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds });
|
||||
toast.success(t('ai.chat.rebindDone').replace('{title}', target.title));
|
||||
} else {
|
||||
toast.warning(t('ai.chat.rebindUsage'));
|
||||
}
|
||||
// Do not rebuild /chats from this render's stale sessions (still dangling).
|
||||
// Drop the resolved entry from the stored listing so a repeat /rebind <n>
|
||||
// cannot target it; the next explicit /chats refreshes from live state.
|
||||
const remaining = lastChatsListingRef.current.filter(
|
||||
(entry) => entry.sessionId !== target.sessionId,
|
||||
);
|
||||
let danglingCounter = 0;
|
||||
for (const entry of remaining) {
|
||||
if (!entry.isDangling) {
|
||||
entry.rebindNumber = undefined;
|
||||
continue;
|
||||
}
|
||||
danglingCounter += 1;
|
||||
entry.rebindNumber = danglingCounter;
|
||||
}
|
||||
lastChatsListingRef.current = remaining;
|
||||
setPanelCommandNote(formatAiChatsListingNote(remaining, t));
|
||||
}, [liveScopeTargetIds, rebindSessionScope, scopeHostIds, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
|
||||
|
||||
// Manual durability actions (history drawer header). Export writes the
|
||||
// whole store to a user-chosen JSON file; import merges by id.
|
||||
const handleExportSessions = useCallback(() => {
|
||||
void exportSessionsBackup?.().then((filePath) => {
|
||||
if (filePath == null) {
|
||||
toast.warning(t('ai.chat.exportUnavailable'));
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
filePath
|
||||
? t('ai.chat.exportDone').replace('{path}', filePath)
|
||||
: t('ai.chat.exportDoneNoPath'),
|
||||
);
|
||||
});
|
||||
}, [exportSessionsBackup, t]);
|
||||
|
||||
const handleImportSessions = useCallback(() => {
|
||||
void importSessionsBackup?.().then((count) => {
|
||||
if (count <= 0) {
|
||||
toast.warning(t('ai.chat.importNone'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('ai.chat.importDone').replace('{n}', String(count)));
|
||||
});
|
||||
}, [importSessionsBackup, t]);
|
||||
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
|
||||
@@ -2065,6 +2189,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleCompact={handleCompact}
|
||||
onRenameSession={handleRenameSession}
|
||||
onRenameActiveChat={handleRenameSlashCommand}
|
||||
onListChats={handleListChats}
|
||||
onRebindChat={handleRebindChat}
|
||||
panelCommandNote={panelCommandNote}
|
||||
onDismissPanelCommandNote={() => setPanelCommandNote(null)}
|
||||
onExportSessions={handleExportSessions}
|
||||
onImportSessions={handleImportSessions}
|
||||
canUndoLastTurn={canUndoLastTurn}
|
||||
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
|
||||
handleSteer={handleSteer}
|
||||
@@ -2143,6 +2275,9 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'createSession',
|
||||
'deleteSession',
|
||||
'updateSessionTitle',
|
||||
'rebindSessionScope',
|
||||
'exportSessionsBackup',
|
||||
'importSessionsBackup',
|
||||
'updateSessionExternalSessionId',
|
||||
'addMessageToSession',
|
||||
'updateLastMessage',
|
||||
@@ -2193,6 +2328,8 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
|
||||
if (prev.scopeHostIds !== next.scopeHostIds) return false;
|
||||
if (prev.terminalSessions !== next.terminalSessions) return false;
|
||||
if (prev.liveScopeTargetIds !== next.liveScopeTargetIds) return false;
|
||||
if (prev.workspaces !== next.workspaces) return false;
|
||||
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
|
||||
if (prev.notes !== next.notes) return false;
|
||||
if (prev.hosts !== next.hosts) return false;
|
||||
|
||||
@@ -44,6 +44,12 @@ export interface AIChatSidePanelProps {
|
||||
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
/** Issue #4015 R2: rebind a dangling chat to this panel's scope (panel action, never agent content). */
|
||||
rebindSessionScope?: (sessionId: string, scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] }) => void;
|
||||
/** Manual durability escape hatch: export the store to a JSON file (returns the path, '' when fixed-path). */
|
||||
exportSessionsBackup?: () => Promise<string | null>;
|
||||
/** Import sessions from a JSON export; merges by id. Returns the imported count. */
|
||||
importSessionsBackup?: () => Promise<number>;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
@@ -103,6 +109,10 @@ export interface AIChatSidePanelProps {
|
||||
scopeLabel?: string;
|
||||
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
|
||||
focusedSessionId?: string;
|
||||
/** Live workspaces, used to resolve workspace titles for `/chats`. */
|
||||
workspaces?: Array<{ id: string; title: string }>;
|
||||
/** All currently live terminal (and workspace) scope target ids. */
|
||||
liveScopeTargetIds?: Set<string>;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import ChatInput from './ChatInput';
|
||||
import ChatInput, { replaceSlashQueryWithCommand, replaceSlashQueryWithRename } from './ChatInput';
|
||||
import {
|
||||
CHAT_INPUT_MAX_HEIGHT,
|
||||
CHAT_INPUT_MIN_HEIGHT,
|
||||
@@ -217,9 +217,46 @@ test('renders the Catty context usage ring after the model chip', () => {
|
||||
assert.match(html, /aria-valuenow="50"/);
|
||||
});
|
||||
|
||||
test('selecting rename completes a partial slash query without sending', () => {
|
||||
assert.equal(replaceSlashQueryWithRename('/ren', { start: 0, end: 4 }), '/rename ');
|
||||
assert.equal(replaceSlashQueryWithRename('/', { start: 0, end: 1 }), '/rename ');
|
||||
assert.equal(replaceSlashQueryWithRename('before /ren after', { start: 7, end: 11 }), 'before /rename after');
|
||||
assert.equal(replaceSlashQueryWithRename('old text', null), '/rename ');
|
||||
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
const branch = source.slice(source.indexOf("if (command === 'rename')"), source.indexOf("commitComposerText('');", source.indexOf("if (command === 'rename')")));
|
||||
assert.match(branch, /commitComposerText\(replaceSlashQueryWithRename\(readComposerText\(\), slashRange\)\)/);
|
||||
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
|
||||
assert.doesNotMatch(branch, /onSend/);
|
||||
});
|
||||
|
||||
test('selecting rebind completes a partial slash query without sending', () => {
|
||||
assert.equal(replaceSlashQueryWithCommand('/re', { start: 0, end: 3 }, 'rebind'), '/rebind ');
|
||||
assert.equal(replaceSlashQueryWithCommand('/', { start: 0, end: 1 }, 'rebind'), '/rebind ');
|
||||
assert.equal(replaceSlashQueryWithCommand('before /re after', { start: 7, end: 10 }, 'rebind'), 'before /rebind after');
|
||||
assert.equal(replaceSlashQueryWithCommand('old text', null, 'rebind'), '/rebind ');
|
||||
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
const branch = source.slice(source.indexOf("if (command === 'rebind')"), source.indexOf("if (command === 'rename')"));
|
||||
assert.match(branch, /commitComposerText\(replaceSlashQueryWithCommand\(readComposerText\(\), slashRange, 'rebind'\)\)/);
|
||||
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
|
||||
assert.doesNotMatch(branch, /onSend/);
|
||||
});
|
||||
|
||||
test('slash picker system commands clear the local composer', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);\s*commitComposerText\(''\);/s);
|
||||
// /stop falls through to the shared clear; /rename, /chats and /rebind have
|
||||
// their own branches above it (rename/rebind keep the composer so the user
|
||||
// can type an argument).
|
||||
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);[\s\S]*?commitComposerText\(''\);/);
|
||||
assert.match(source, /if \(command === 'chats'\) onListChats\?\.\(\);/);
|
||||
assert.match(source, /if \(command === 'rename'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
|
||||
assert.match(source, /if \(command === 'rebind'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
|
||||
});
|
||||
|
||||
test('/rebind routes its argument to onRebindChat as a panel action', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /if \(systemCommand === 'rebind'\) \{[\s\S]*?onRebindChat\?\.\(getRebindSlashCommandArg\(submittedText\)\);/);
|
||||
});
|
||||
|
||||
test('keeps thinking on a separate chip instead of mixing it into the model list', () => {
|
||||
@@ -333,6 +370,25 @@ test('ChatInput wires /compact through getSystemSlashCommand and canCompact', ()
|
||||
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
|
||||
});
|
||||
|
||||
test('ChatInput intercepts /rename and /chats as panel actions before onSend', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
// The interception happens in handleSubmit's system-command branch, which
|
||||
// returns before onSend() — the commands never reach the agent.
|
||||
assert.match(source, /systemCommand === 'rename'/);
|
||||
assert.match(source, /systemCommand === 'chats'/);
|
||||
assert.match(source, /onRenameSession\?\.\(getRenameSlashCommandTitle\(submittedText\)\)/);
|
||||
assert.match(source, /onListChats\?\.\(\)/);
|
||||
// Both commands clear the composer instead of falling through to onSend.
|
||||
const renameBranchStart = source.indexOf("systemCommand === 'rename'");
|
||||
const renameBranchEnd = source.indexOf('if (isStreaming && canSteer)', renameBranchStart);
|
||||
const renameBranch = source.slice(renameBranchStart, renameBranchEnd);
|
||||
assert.match(renameBranch, /commitComposerText\(''\)/);
|
||||
assert.doesNotMatch(renameBranch, /onSend\(\)/);
|
||||
// The slash picker registers the built-ins and keeps /rename text editable.
|
||||
assert.match(source, /command === 'rename'/);
|
||||
assert.match(source, /command === 'chats'/);
|
||||
});
|
||||
|
||||
test('renders a Mention Note picker entry with a searchable note list', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
filterQuickMessages,
|
||||
filterSystemSlashCommands,
|
||||
filterUserSkillsForSlash,
|
||||
getRebindSlashCommandArg,
|
||||
getRenameSlashCommandTitle,
|
||||
getSlashCommandItemKey,
|
||||
getSystemSlashCommand,
|
||||
SYSTEM_BUILTIN_SLASH_COMMANDS,
|
||||
@@ -80,6 +82,19 @@ const MENU_VIEWPORT_GUTTER = 8;
|
||||
const CONTEXT_USAGE_RING_RADIUS = 10;
|
||||
const CONTEXT_USAGE_RING_CIRCUMFERENCE = 2 * Math.PI * CONTEXT_USAGE_RING_RADIUS;
|
||||
|
||||
export function replaceSlashQueryWithCommand(
|
||||
text: string,
|
||||
range: { start: number; end: number } | null,
|
||||
command: string,
|
||||
): string {
|
||||
const replacement = `/${command} `;
|
||||
return range ? `${text.slice(0, range.start)}${replacement}${text.slice(range.end)}` : replacement;
|
||||
}
|
||||
|
||||
export function replaceSlashQueryWithRename(text: string, range: { start: number; end: number } | null): string {
|
||||
return replaceSlashQueryWithCommand(text, range, 'rename');
|
||||
}
|
||||
|
||||
function formatContextTokens(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 100_000 ? 0 : 1)}K`;
|
||||
@@ -266,6 +281,12 @@ interface ChatInputProps {
|
||||
onThinkingLevelChange?: (level: string) => void;
|
||||
/** Hidden retained panels must not leave body-portaled menus open. */
|
||||
parked?: boolean;
|
||||
/** `/rename <title>`: rename the current chat (panel action, never sent to the agent). */
|
||||
onRenameSession?: (title: string) => void;
|
||||
/** `/chats`: list the chats visible to this panel (panel action, never sent to the agent). */
|
||||
onListChats?: () => void;
|
||||
/** `/rebind <n>`: rebind a dangling chat to this panel's scope (panel action, never sent to the agent). */
|
||||
onRebindChat?: (arg: string) => void;
|
||||
}
|
||||
|
||||
const ChatInput: React.FC<ChatInputProps> = ({
|
||||
@@ -313,6 +334,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
thinkingLevel,
|
||||
onThinkingLevelChange,
|
||||
parked = false,
|
||||
onRenameSession,
|
||||
onListChats,
|
||||
onRebindChat,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
|
||||
@@ -750,6 +774,20 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onCompact?.();
|
||||
}
|
||||
if (command === 'stop') onStop?.();
|
||||
if (command === 'chats') onListChats?.();
|
||||
if (command === 'rebind') {
|
||||
// Replace a partial slash query (e.g. `/re`) with `/rebind ` so the
|
||||
// user can finish the dangling-chat number; never leave `/re 1` to
|
||||
// be sent to the agent.
|
||||
commitComposerText(replaceSlashQueryWithCommand(readComposerText(), slashRange, 'rebind'));
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
if (command === 'rename') {
|
||||
commitComposerText(replaceSlashQueryWithRename(readComposerText(), slashRange));
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
commitComposerText('');
|
||||
closeAllMenus();
|
||||
return;
|
||||
@@ -759,7 +797,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
return;
|
||||
}
|
||||
insertUserSkillToken(item.skill);
|
||||
}, [canCompact, closeAllMenus, commitComposerText, insertQuickMessage, insertUserSkillToken, onCompact, onStop]);
|
||||
}, [canCompact, closeAllMenus, commitComposerText, insertQuickMessage, insertUserSkillToken, onCompact, onListChats, onStop, readComposerText, slashRange]);
|
||||
|
||||
// Reset active highlight when a menu opens or when the *identity* of the
|
||||
// visible items changes. Watching only `.length` misses cases where the
|
||||
@@ -962,6 +1000,21 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
commitComposerText('');
|
||||
return;
|
||||
}
|
||||
if (systemCommand === 'rename') {
|
||||
onRenameSession?.(getRenameSlashCommandTitle(submittedText));
|
||||
commitComposerText('');
|
||||
return;
|
||||
}
|
||||
if (systemCommand === 'chats') {
|
||||
onListChats?.();
|
||||
commitComposerText('');
|
||||
return;
|
||||
}
|
||||
if (systemCommand === 'rebind') {
|
||||
onRebindChat?.(getRebindSlashCommandArg(submittedText));
|
||||
commitComposerText('');
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isStreaming && canSteer) {
|
||||
@@ -972,7 +1025,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
// abort; the parent clears value only after the user message is accepted.
|
||||
onSend();
|
||||
},
|
||||
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onSend, onSteer, onStop, readComposerText],
|
||||
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onListChats, onRebindChat, onRenameSession, onSend, onSteer, onStop, readComposerText],
|
||||
);
|
||||
|
||||
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AISession, ExternalAgentConfig } from '../../infrastructure/ai/types.ts';
|
||||
import {
|
||||
buildAiChatsListingEntries,
|
||||
canRebindChat,
|
||||
formatAiChatsListingNote,
|
||||
resolveChatAgentLabel,
|
||||
resolveRebindTargetFromArg,
|
||||
truncateChatTitle,
|
||||
} from './aiChatsListing.ts';
|
||||
|
||||
test('rebind revalidates cached entries against current sessions and live targets', () => {
|
||||
const liveTargets = new Set(['ws-live', 'term-live']);
|
||||
assert.equal(canRebindChat(undefined, liveTargets), false);
|
||||
assert.equal(canRebindChat(createSession('restored', { scope: { type: 'workspace', targetId: 'ws-live' } }), liveTargets), false);
|
||||
assert.equal(canRebindChat(createSession('restored-terminal', { scope: { type: 'terminal', targetId: 'term-live' } }), liveTargets), false);
|
||||
assert.equal(canRebindChat(createSession('dangling', { scopeDangling: true, scope: { type: 'workspace', targetId: 'ws-old' } }), liveTargets), true);
|
||||
assert.equal(canRebindChat(createSession('unbound', { scope: { type: 'terminal', targetId: 'term-old' } }), liveTargets), true);
|
||||
});
|
||||
|
||||
function createSession(
|
||||
id: string,
|
||||
overrides: Partial<AISession> = {},
|
||||
): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'catty',
|
||||
scope: { type: 'workspace', targetId: 'ws-1' },
|
||||
messages: [],
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const t = (key: string) => key;
|
||||
const externalAgents: ExternalAgentConfig[] = [
|
||||
{ id: 'claude', name: 'Claude Code' } as ExternalAgentConfig,
|
||||
];
|
||||
|
||||
function buildEntries(sessions: AISession[], activeSessionId: string | null = 's-live') {
|
||||
return buildAiChatsListingEntries({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
externalAgents,
|
||||
workspaces: [{ id: 'ws-1', title: 'Web farm' }],
|
||||
liveTerminalIds: new Set(['term-1']),
|
||||
scopeTargetId: 'ws-1',
|
||||
t,
|
||||
});
|
||||
}
|
||||
|
||||
test('/chats listing marks the live workspace chat, terminal chat and dangling chat', () => {
|
||||
const now = Date.now();
|
||||
const sessions = [
|
||||
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' }, updatedAt: now }),
|
||||
createSession('s-term', { scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude', updatedAt: now }),
|
||||
createSession('s-dangling', { scope: { type: 'workspace', targetId: 'ws-gone' }, updatedAt: now }),
|
||||
];
|
||||
|
||||
const entries = buildEntries(sessions);
|
||||
assert.equal(entries.length, 3);
|
||||
|
||||
assert.equal(entries[0].isCurrent, true);
|
||||
assert.equal(entries[0].agentLabel, 'Catty Agent');
|
||||
assert.equal(entries[0].scopeLabel, 'ai.chat.chatsScopeWorkspace: Web farm');
|
||||
assert.equal(entries[0].isDangling, false);
|
||||
|
||||
assert.equal(entries[1].isCurrent, false);
|
||||
assert.equal(entries[1].agentLabel, 'Claude Code');
|
||||
assert.equal(entries[1].scopeLabel, 'ai.chat.chatsScopeTerminal');
|
||||
assert.equal(entries[1].isDangling, false);
|
||||
|
||||
assert.equal(entries[2].isDangling, true);
|
||||
assert.equal(entries[2].scopeLabel, 'ai.chat.chatsScopeDangling');
|
||||
});
|
||||
|
||||
test('/chats listing numbers dangling entries for /rebind and leaves live ones unnumbered', () => {
|
||||
const sessions = [
|
||||
createSession('s-dangling-a', { scope: { type: 'workspace', targetId: 'ws-gone-a' } }),
|
||||
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' } }),
|
||||
createSession('s-dangling-b', { scope: { type: 'terminal', targetId: 'term-gone' } }),
|
||||
createSession('s-dangling-c', { scope: { type: 'workspace', targetId: 'ws-gone-b' } }),
|
||||
];
|
||||
|
||||
const entries = buildEntries(sessions, null);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.rebindNumber ?? 0),
|
||||
[1, 0, 2, 3],
|
||||
);
|
||||
});
|
||||
|
||||
test('/chats listing falls back to the panel scope label for the current workspace', () => {
|
||||
const sessions = [
|
||||
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' } }),
|
||||
];
|
||||
const entries = buildAiChatsListingEntries({
|
||||
sessions,
|
||||
activeSessionId: 's-live',
|
||||
externalAgents,
|
||||
workspaces: [],
|
||||
liveTerminalIds: new Set<string>(),
|
||||
currentScopeLabel: 'Web farm',
|
||||
scopeTargetId: 'ws-1',
|
||||
t,
|
||||
});
|
||||
assert.equal(entries[0].isDangling, false);
|
||||
assert.equal(entries[0].scopeLabel, 'ai.chat.chatsScopeWorkspace: Web farm');
|
||||
});
|
||||
|
||||
test('/chats note renders header, current marker and one line per chat', () => {
|
||||
const sessions = [
|
||||
createSession('s-live', { title: 'Fix deploy' }),
|
||||
createSession('s-term', { title: 'Logs', scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude' }),
|
||||
createSession('s-dangling', { title: 'Old', scope: { type: 'workspace', targetId: 'ws-gone' } }),
|
||||
];
|
||||
const entries = buildEntries(sessions);
|
||||
const note = formatAiChatsListingNote(entries, t);
|
||||
const lines = note.split('\n');
|
||||
assert.equal(lines[0], 'ai.chat.chatsHeader');
|
||||
// Three chat lines plus the dangling rebind hint.
|
||||
assert.equal(lines.length, 5);
|
||||
assert.match(lines[1], /^• Fix deploy — Catty Agent · /);
|
||||
assert.match(lines[2], /^ {2}Logs — Claude Code · /);
|
||||
assert.match(lines[3], /ai\.chat\.chatsScopeDangling/);
|
||||
assert.ok(!lines[3].startsWith('•'));
|
||||
// The dangling line carries an inline rebind affordance, and the note
|
||||
// explains it.
|
||||
assert.match(lines[3], /\/rebind 1/);
|
||||
assert.equal(lines[4], 'ai.chat.chatsRebindHint');
|
||||
});
|
||||
|
||||
test('/chats note omits the rebind hint when nothing is dangling', () => {
|
||||
const sessions = [
|
||||
createSession('s-live', { title: 'Fix deploy' }),
|
||||
createSession('s-term', { title: 'Logs', scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude' }),
|
||||
];
|
||||
const entries = buildEntries(sessions);
|
||||
const note = formatAiChatsListingNote(entries, t);
|
||||
assert.ok(!note.includes('rebind'));
|
||||
});
|
||||
|
||||
test('resolveRebindTargetFromArg picks only dangling entries by their number', () => {
|
||||
const sessions = [
|
||||
createSession('s-live', { title: 'Fix deploy' }),
|
||||
createSession('s-dangling', { title: 'Old', scope: { type: 'workspace', targetId: 'ws-gone' } }),
|
||||
createSession('s-dangling-2', { title: 'Older', scope: { type: 'terminal', targetId: 'term-gone' } }),
|
||||
];
|
||||
const entries = buildEntries(sessions);
|
||||
const dangling = entries.find((entry) => entry.isDangling);
|
||||
assert.ok(dangling);
|
||||
|
||||
assert.equal(resolveRebindTargetFromArg('1', entries)?.sessionId, 's-dangling');
|
||||
assert.equal(resolveRebindTargetFromArg(' 2 ', entries)?.sessionId, entries.filter((entry) => entry.isDangling)[1].sessionId);
|
||||
assert.equal(resolveRebindTargetFromArg('3', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('1foo', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('1.5', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('0', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('-1', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('', entries), null);
|
||||
assert.equal(resolveRebindTargetFromArg('abc', entries), null);
|
||||
// A live entry is not rebindable even if its position would match.
|
||||
assert.equal(resolveRebindTargetFromArg('1', [entries[0]]), null);
|
||||
});
|
||||
|
||||
test('/chats note reports an empty panel', () => {
|
||||
assert.equal(formatAiChatsListingNote([], t), 'ai.chat.chatsEmpty');
|
||||
});
|
||||
|
||||
test('truncateChatTitle clamps long titles and keeps short ones intact', () => {
|
||||
assert.equal(truncateChatTitle('short'), 'short');
|
||||
assert.equal(truncateChatTitle(' '), '');
|
||||
const long = 'x'.repeat(80);
|
||||
const truncated = truncateChatTitle(long);
|
||||
assert.equal(truncated.length, 42);
|
||||
assert.ok(truncated.endsWith('…'));
|
||||
});
|
||||
|
||||
test('resolveChatAgentLabel resolves external agents and falls back to the raw id', () => {
|
||||
assert.equal(resolveChatAgentLabel('catty', externalAgents), 'Catty Agent');
|
||||
assert.equal(resolveChatAgentLabel('claude', externalAgents), 'Claude Code');
|
||||
assert.equal(resolveChatAgentLabel('mystery', externalAgents), 'mystery');
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { AISession, ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
import { formatRelativeTime } from '../AIChatSessionHistoryDrawer';
|
||||
|
||||
/**
|
||||
* Pure builder for the `/chats` panel note. Kept separate from the component
|
||||
* so a future rebind action (issue #4015 R2) can attach per-entry actions
|
||||
* without reworking the text rendering.
|
||||
*/
|
||||
|
||||
export interface AiChatsListingInput {
|
||||
/** Rank-sorted sessions visible to the current panel scope. */
|
||||
sessions: AISession[];
|
||||
activeSessionId: string | null;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
/** Live workspaces, used to resolve workspace scope titles. */
|
||||
workspaces: Array<{ id: string; title: string }>;
|
||||
/** Live terminal session ids visible to this panel. */
|
||||
liveTerminalIds: Iterable<string>;
|
||||
/** Fallback scope label for the panel's own scope (e.g. current workspace title). */
|
||||
currentScopeLabel?: string;
|
||||
scopeTargetId?: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface AiChatsListingEntry {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
agentLabel: string;
|
||||
scopeLabel: string;
|
||||
/** Scope target no longer resolves to a live workspace/terminal. */
|
||||
isDangling: boolean;
|
||||
/** 1-based number among dangling entries, used by `/rebind <n>`. */
|
||||
rebindNumber?: number;
|
||||
isCurrent: boolean;
|
||||
relativeTime: string;
|
||||
}
|
||||
|
||||
const TITLE_TRUNCATE = 42;
|
||||
|
||||
export function truncateChatTitle(title: string): string {
|
||||
const trimmed = title.trim() || '';
|
||||
if (trimmed.length <= TITLE_TRUNCATE) return trimmed;
|
||||
return `${trimmed.slice(0, TITLE_TRUNCATE - 1)}…`;
|
||||
}
|
||||
|
||||
export function resolveChatAgentLabel(
|
||||
agentId: string,
|
||||
externalAgents: ExternalAgentConfig[],
|
||||
): string {
|
||||
if (agentId === 'catty') return 'Catty Agent';
|
||||
return externalAgents.find((agent) => agent.id === agentId)?.name ?? agentId;
|
||||
}
|
||||
|
||||
export function buildAiChatsListingEntries(input: AiChatsListingInput): AiChatsListingEntry[] {
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
externalAgents,
|
||||
workspaces,
|
||||
liveTerminalIds,
|
||||
scopeTargetId,
|
||||
t,
|
||||
} = input;
|
||||
const workspaceTitles = new Map(workspaces.map((workspace) => [workspace.id, workspace.title]));
|
||||
const liveTerminals = new Set(liveTerminalIds);
|
||||
|
||||
const entries: AiChatsListingEntry[] = sessions.map((session) => {
|
||||
const isCurrent = session.id === activeSessionId;
|
||||
let scopeLabel = '';
|
||||
let isDangling = false;
|
||||
if (session.scope.type === 'workspace') {
|
||||
const title = workspaceTitles.get(session.scope.targetId ?? '');
|
||||
if (title != null) {
|
||||
scopeLabel = `${t('ai.chat.chatsScopeWorkspace')}: ${title}`;
|
||||
} else {
|
||||
// The panel's own scope may be labelled by the parent even when the
|
||||
// workspace store has not been threaded through; treat the active
|
||||
// scope target as live.
|
||||
if (isCurrent && session.scope.targetId === scopeTargetId && input.currentScopeLabel) {
|
||||
scopeLabel = `${t('ai.chat.chatsScopeWorkspace')}: ${input.currentScopeLabel}`;
|
||||
} else {
|
||||
isDangling = true;
|
||||
scopeLabel = t('ai.chat.chatsScopeDangling');
|
||||
}
|
||||
}
|
||||
} else if (session.scope.type === 'terminal') {
|
||||
if (liveTerminals.has(session.scope.targetId ?? '')) {
|
||||
scopeLabel = t('ai.chat.chatsScopeTerminal');
|
||||
} else {
|
||||
isDangling = true;
|
||||
scopeLabel = t('ai.chat.chatsScopeDangling');
|
||||
}
|
||||
} else {
|
||||
scopeLabel = t('ai.chat.chatsScopeGlobal');
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
title: truncateChatTitle(session.title || t('ai.chat.untitled')),
|
||||
agentLabel: resolveChatAgentLabel(session.agentId, externalAgents),
|
||||
scopeLabel,
|
||||
isDangling,
|
||||
isCurrent,
|
||||
relativeTime: formatRelativeTime(new Date(session.updatedAt), t),
|
||||
};
|
||||
});
|
||||
|
||||
// Number the dangling entries so the note can advertise `/rebind <n>`.
|
||||
let danglingCounter = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDangling) continue;
|
||||
danglingCounter += 1;
|
||||
entry.rebindNumber = danglingCounter;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `/rebind <n>` argument against a `/chats` listing. Only dangling
|
||||
* entries are rebindable; a non-dangling target (or a bad index) yields null.
|
||||
*/
|
||||
export function resolveRebindTargetFromArg(
|
||||
arg: string,
|
||||
entries: AiChatsListingEntry[],
|
||||
): AiChatsListingEntry | null {
|
||||
const trimmed = arg.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
const index = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isInteger(index) || index < 1) return null;
|
||||
const dangling = entries.filter((entry) => entry.isDangling);
|
||||
return dangling[index - 1] ?? null;
|
||||
}
|
||||
|
||||
/** Revalidate a cached listing entry against the current session and live scopes. */
|
||||
export function canRebindChat(session: AISession | undefined, liveScopeTargetIds: ReadonlySet<string>): boolean {
|
||||
if (!session) return false;
|
||||
return !!session.scopeDangling || !session.scope.targetId || !liveScopeTargetIds.has(session.scope.targetId);
|
||||
}
|
||||
|
||||
export function formatAiChatsListingNote(
|
||||
entries: AiChatsListingEntry[],
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (entries.length === 0) {
|
||||
return t('ai.chat.chatsEmpty');
|
||||
}
|
||||
let hasDangling = false;
|
||||
const lines = entries.map((entry) => {
|
||||
const marker = entry.isCurrent ? '•' : ' ';
|
||||
const rebindHint = entry.isDangling && entry.rebindNumber != null
|
||||
? ` · /rebind ${entry.rebindNumber}`
|
||||
: '';
|
||||
if (entry.isDangling) hasDangling = true;
|
||||
return `${marker} ${entry.title} — ${entry.agentLabel} · ${entry.scopeLabel} · ${entry.relativeTime}${rebindHint}`;
|
||||
});
|
||||
if (hasDangling) {
|
||||
lines.push(t('ai.chat.chatsRebindHint'));
|
||||
}
|
||||
return [t('ai.chat.chatsHeader'), ...lines].join('\n');
|
||||
}
|
||||
@@ -203,6 +203,7 @@ export const SettingCard: React.FC<SettingCardProps> = ({
|
||||
interface SettingRowProps {
|
||||
leading?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
className?: string;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
align?: "center" | "start";
|
||||
@@ -213,6 +214,7 @@ interface SettingRowProps {
|
||||
export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
leading,
|
||||
label,
|
||||
className,
|
||||
description,
|
||||
children,
|
||||
align = "center",
|
||||
@@ -225,6 +227,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
// Keep square corners: rounded rows bend divide-y separators at both ends.
|
||||
"flex justify-between py-3 gap-4",
|
||||
align === "start" ? "items-start" : "items-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{leading && <div className="shrink-0">{leading}</div>}
|
||||
|
||||
@@ -1260,6 +1260,34 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tools" className="m-0 space-y-6">
|
||||
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-tool-access-mode"
|
||||
title={t('ai.toolAccess.title')}
|
||||
>
|
||||
<SettingCard>
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" description={t('ai.toolAccess.description')}>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Select
|
||||
value={toolIntegrationMode}
|
||||
options={[
|
||||
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
|
||||
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
|
||||
]}
|
||||
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
|
||||
className="w-48"
|
||||
/>
|
||||
<ToolAccessGuidance mode={toolIntegrationMode} />
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<ExternalMcpCard />
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection title={t('ai.chatShortcuts.title')}>
|
||||
<SettingCard divided>
|
||||
<SettingRow
|
||||
@@ -1276,34 +1304,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-tool-access-mode"
|
||||
title={t('ai.toolAccess.title')}
|
||||
>
|
||||
<SettingCard>
|
||||
<SettingRow description={t('ai.toolAccess.description')}>
|
||||
<Select
|
||||
value={toolIntegrationMode}
|
||||
options={[
|
||||
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
|
||||
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
|
||||
]}
|
||||
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="pb-4">
|
||||
<ToolAccessGuidance mode={toolIntegrationMode} />
|
||||
</div>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
||||
<ExternalMcpCard />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-user-skills"
|
||||
title={t('ai.userSkills.title')}
|
||||
|
||||
@@ -1,183 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
|
||||
import React, { act } from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
|
||||
import { AIPrivacySection } from './AIPrivacySection';
|
||||
|
||||
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
|
||||
const values = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
window: {
|
||||
netcatty: { externalMcpSetConfig: setConfig },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
},
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
}
|
||||
return {
|
||||
values,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/#/settings' });
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'FocusEvent', 'getComputedStyle', 'localStorage'] as const) {
|
||||
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true });
|
||||
}
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (fn: () => void) => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout });
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
|
||||
const { AIPrivacySection } = await import('./AIPrivacySection');
|
||||
|
||||
async function mount(setConfig: (config: unknown) => Promise<unknown> = async () => ({ ok: true }), saved?: string) {
|
||||
dom.window.localStorage.clear();
|
||||
if (saved) dom.window.localStorage.setItem(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
Object.assign(window, { netcatty: { externalMcpSetConfig: setConfig } });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><AIPrivacySection /></I18nProvider>));
|
||||
return async () => { await act(async () => root.unmount()); };
|
||||
}
|
||||
const button = (text: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === text)!;
|
||||
const click = async (text: string) => { await act(async () => button(text).click()); };
|
||||
async function input(index: number, value: string) {
|
||||
const el = document.querySelectorAll('textarea')[index];
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, 'value')!.set!.call(el, value);
|
||||
el.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function view() {
|
||||
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
|
||||
}
|
||||
|
||||
test('privacy settings default off and explain their limits and connection scope', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('privacy overview is persisted state; configuration preserves the full scope and atomic draft', async () => {
|
||||
const unmount = await mount();
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const markup = JSON.stringify(renderer!.toJSON());
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
|
||||
assert.match(markup, /cannot detect every secret/);
|
||||
assert.match(markup, /AI privacy/);
|
||||
assert.match(markup, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(markup, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(markup, /Binary tool responses are blocked/);
|
||||
assert.match(markup, /one managed conversation or external MCP connection/);
|
||||
assert.match(markup, /reconnect external MCP clients/);
|
||||
assert.match(markup, /start a new managed conversation/);
|
||||
assert.match(markup, /Additional IP ranges/);
|
||||
assert.match(markup, /Custom regular expressions/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
assert.equal(document.querySelector('textarea'), null);
|
||||
assert.match(document.body.textContent!, /Text redaction disabled/);
|
||||
await click('Configure privacy');
|
||||
const dialog = document.querySelector('[role="dialog"]')!;
|
||||
assert.match(dialog.textContent!, /cannot detect every secret/);
|
||||
assert.match(dialog.textContent!, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(dialog.textContent!, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(dialog.textContent!, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(dialog.textContent!, /Binary tool responses are blocked/);
|
||||
assert.match(dialog.textContent!, /one managed conversation or external MCP connection/);
|
||||
assert.match(dialog.textContent!, /reconnect external MCP clients/);
|
||||
assert.equal(dialog.querySelectorAll('[role="switch"][aria-checked="false"]').length, 2);
|
||||
assert.equal(button('Apply').disabled, true);
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
|
||||
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
|
||||
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
|
||||
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
|
||||
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
|
||||
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
|
||||
});
|
||||
|
||||
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
|
||||
test('rejected privacy draft stays open and accepted draft commits once and returns focus', async () => {
|
||||
const calls: unknown[] = [];
|
||||
let accepted = false;
|
||||
const env = installEnvironment(async (config) => {
|
||||
calls.push(config);
|
||||
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const unmount = await mount(async (config) => { calls.push(config); return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' }; });
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
const privacySwitch = () => root.findAllByType('button').find((node) => (
|
||||
node.props['aria-label'] === 'Enable text redaction'
|
||||
))!;
|
||||
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
|
||||
await act(async () => { privacySwitch().props.onClick(); });
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
|
||||
});
|
||||
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
|
||||
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
|
||||
const trigger = button('Configure privacy');
|
||||
trigger.focus();
|
||||
await click('Configure privacy');
|
||||
await act(async () => (document.querySelector('[aria-label="Enable text redaction"]') as HTMLButtonElement).click());
|
||||
await input(0, '192.0.2.0/24\n\n2001:db8::/32');
|
||||
await input(1, '[');
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
|
||||
await click('Apply');
|
||||
assert.match(document.querySelector('[role="alert"]')!.textContent!, /Invalid privacy rules/);
|
||||
assert.equal(document.querySelectorAll('textarea')[1].value, '[');
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
|
||||
accepted = true;
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
|
||||
});
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, {
|
||||
enabled: true,
|
||||
redactPrivateIps: false,
|
||||
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
});
|
||||
await input(1, 'INTERNAL_[A-Z0-9]+');
|
||||
await click('Apply');
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, { enabled: true, redactPrivateIps: false, cidrs: ['192.0.2.0/24', '2001:db8::/32'], patterns: ['INTERNAL_[A-Z0-9]+'] });
|
||||
assert.deepEqual(calls[1], { redaction: saved });
|
||||
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
|
||||
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
assert.match(document.querySelector('[role="status"]')!.textContent!, /new managed conversation/);
|
||||
await click('Configure privacy');
|
||||
assert.equal(document.querySelectorAll('textarea')[1].value, 'INTERNAL_[A-Z0-9]+');
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('Cancel, X and Escape discard edits and reopening reads saved values', async () => {
|
||||
let calls = 0;
|
||||
const unmount = await mount(async () => { calls += 1; return { ok: true }; });
|
||||
try {
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
await act(async () => { renderer = create(view()); });
|
||||
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
|
||||
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
for (const close of ['Cancel', 'X', 'Escape']) {
|
||||
await click('Configure privacy');
|
||||
await input(0, '192.0.2.0/24');
|
||||
assert.match(document.querySelector('[role="status"]')!.textContent!, /Unsaved changes/);
|
||||
if (close === 'Cancel') await click('Cancel');
|
||||
else if (close === 'X') await act(async () => (document.querySelector('[data-dialog-close]:not([aria-hidden])') as HTMLButtonElement).click());
|
||||
else await act(async () => { document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
await click('Configure privacy');
|
||||
assert.equal(document.querySelector('textarea')!.value, '');
|
||||
await click('Cancel');
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('privacy apply disables draft controls until shared validation settles', async () => {
|
||||
let resolveConfig!: (result: unknown) => void;
|
||||
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('busy Apply blocks duplicate submits, edits and Escape', async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
let calls = 0;
|
||||
const unmount = await mount(() => { calls += 1; return new Promise((done) => { resolve = done; }); });
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
|
||||
});
|
||||
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
|
||||
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { resolveConfig({ ok: true }); });
|
||||
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
|
||||
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
await click('Configure privacy');
|
||||
await input(0, '192.0.2.0/24');
|
||||
await click('Apply');
|
||||
assert.ok([...document.querySelectorAll('[role="dialog"] textarea, [role="dialog"] [role="switch"]')].every((el) => (el as HTMLInputElement).disabled));
|
||||
assert.equal(button('Cancel').disabled, true);
|
||||
await act(async () => { button('Applying...').click(); document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
|
||||
assert.ok(document.querySelector('[role="dialog"]'));
|
||||
assert.equal(calls, 1);
|
||||
await act(async () => { resolve({ ok: true }); });
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('shared privacy controls refresh when another window changes the shared preference', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('malformed saved settings remain visible and repairable without being rewritten on open', async () => {
|
||||
const unmount = await mount(undefined, '{');
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
|
||||
await act(async () => { window.dispatchEvent(event); });
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
assert.match(document.querySelector('[role="alert"]')!.textContent!, /Invalid saved AI privacy settings/);
|
||||
await click('Configure privacy');
|
||||
assert.equal(button('Apply').disabled, false);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('cross-window changes refresh clean drafts but preserve dirty drafts with a conflict notice', async () => {
|
||||
const unmount = await mount();
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
const update = async () => {
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
await act(async () => { window.dispatchEvent(new dom.window.StorageEvent('storage', { key: STORAGE_KEY_AI_PRIVACY })); });
|
||||
};
|
||||
try {
|
||||
await click('Configure privacy');
|
||||
await update();
|
||||
assert.equal(document.querySelector('textarea')!.value, '192.0.2.0/24');
|
||||
await input(0, '198.51.100.0/24');
|
||||
settings.cidrs = ['203.0.113.0/24'];
|
||||
await update();
|
||||
assert.equal(document.querySelector('textarea')!.value, '198.51.100.0/24');
|
||||
assert.match(document.querySelector('[role="alert"]')!.textContent!, /another window/);
|
||||
assert.equal(button('Apply').disabled, true);
|
||||
await click('Cancel');
|
||||
await click('Configure privacy');
|
||||
assert.equal(document.querySelector('textarea')!.value, '203.0.113.0/24');
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
export const AIPrivacySection: React.FC = () => {
|
||||
@@ -12,8 +13,32 @@ export const AIPrivacySection: React.FC = () => {
|
||||
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
|
||||
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
|
||||
const [applied, setApplied] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const errorRef = useRef<HTMLParagraphElement>(null);
|
||||
useEffect(() => { if (open && error) errorRef.current?.focus(); }, [open, error]);
|
||||
const [draftBase, setDraftBase] = useState(settings);
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const dirty = enabled !== draftBase.enabled || privateIps !== draftBase.redactPrivateIps
|
||||
|| cidrs !== draftBase.cidrs.join("\n") || patterns !== draftBase.patterns.join("\n");
|
||||
const changeOpen = (next: boolean) => {
|
||||
if (applying) return;
|
||||
setDraftBase(settings);
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
setPatterns(settings.patterns.join("\n"));
|
||||
setConflict(false);
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
const draftState = useRef({ open, dirty, applying });
|
||||
draftState.current = { open, dirty, applying };
|
||||
useEffect(() => {
|
||||
if (draftState.current.open && draftState.current.dirty && !draftState.current.applying) {
|
||||
setConflict(true);
|
||||
return;
|
||||
}
|
||||
setDraftBase(settings);
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
@@ -21,6 +46,7 @@ export const AIPrivacySection: React.FC = () => {
|
||||
}, [settings]);
|
||||
|
||||
const onApply = async () => {
|
||||
if (applying || conflict) return;
|
||||
setApplied(false);
|
||||
const saved = await apply({
|
||||
enabled,
|
||||
@@ -29,13 +55,36 @@ export const AIPrivacySection: React.FC = () => {
|
||||
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
|
||||
});
|
||||
setApplied(saved);
|
||||
if (saved) setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
className="flex-col items-start md:flex-row md:items-center"
|
||||
label={t(settings.enabled ? "ai.toolSettings.privacyOn" : "ai.toolSettings.privacyOff")}
|
||||
description={`${t("ai.privacy.privateIps")}: ${t(settings.redactPrivateIps ? "common.enabled" : "common.disabled")} · ${t("ai.toolSettings.ruleSummary", { cidrs: String(settings.cidrs.length), patterns: String(settings.patterns.length) })}`}
|
||||
>
|
||||
<ToolSettingsDialog
|
||||
title={t("ai.privacy.title")}
|
||||
triggerLabel={t("ai.toolSettings.configurePrivacy")}
|
||||
open={open}
|
||||
onOpenChange={changeOpen}
|
||||
busy={applying}
|
||||
footer={<>
|
||||
{dirty ? <span role="status" className="mr-auto self-center text-xs text-muted-foreground">{t("ai.toolSettings.unsaved")}</span> : null}
|
||||
<Button type="button" variant="outline" onClick={() => changeOpen(false)} disabled={applying}>{t("common.cancel")}</Button>
|
||||
<Button type="button" onClick={() => { void onApply(); }} disabled={applying || conflict || (!dirty && !error)}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="border-0 rounded-none bg-transparent p-0">
|
||||
<SettingRow label={t("ai.privacy.enable")}>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
@@ -56,9 +105,9 @@ export const AIPrivacySection: React.FC = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<div className="space-y-4">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="text-sm font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
|
||||
<textarea
|
||||
value={cidrs}
|
||||
@@ -72,7 +121,7 @@ export const AIPrivacySection: React.FC = () => {
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="text-sm font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
|
||||
<textarea
|
||||
value={patterns}
|
||||
@@ -86,20 +135,15 @@ export const AIPrivacySection: React.FC = () => {
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("ai.privacy.failed")} {error}
|
||||
</p>
|
||||
) : applied ? (
|
||||
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
</div>
|
||||
{conflict ? <p role="alert" className="text-xs text-destructive">{t("ai.toolSettings.conflict")}</p> : null}
|
||||
{error ? <p ref={errorRef} tabIndex={-1} role="alert" className="text-xs text-destructive">{t("ai.privacy.failed")} {error}</p> : null}
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
{error && !open ? <p role="alert" className="text-xs text-destructive">{t("ai.privacy.failed")} {error}</p>
|
||||
: applied ? <p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p> : null}
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Check, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
|
||||
type CopyableCodeBlockProps = {
|
||||
label?: string;
|
||||
sensitive?: boolean;
|
||||
value: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
@@ -17,6 +19,7 @@ type CopyableCodeBlockProps = {
|
||||
|
||||
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
label,
|
||||
sensitive = false,
|
||||
value,
|
||||
copyKey,
|
||||
copied,
|
||||
@@ -26,7 +29,9 @@ export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
emptyLabel,
|
||||
className,
|
||||
}) => {
|
||||
const display = value || emptyLabel || "";
|
||||
const { t } = useI18n();
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
const display = sensitive && value && !revealed ? "••••••••" : value || emptyLabel || "";
|
||||
const canCopy = Boolean(value);
|
||||
const isCopied = copied === copyKey;
|
||||
|
||||
@@ -38,12 +43,15 @@ export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-20 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</pre>
|
||||
{sensitive && value ? <Button type="button" variant="ghost" size="sm" className="absolute right-10 top-1.5 h-7 w-7 p-0" onClick={() => setRevealed(!revealed)} aria-label={t(revealed ? "ai.toolSettings.hideSecret" : "ai.toolSettings.showSecret")}>
|
||||
{revealed ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button> : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { installAiToolAccessFixture } from '../../../../scripts/fixtures/ai-tool-access/bridge.cjs';
|
||||
|
||||
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/#/settings' });
|
||||
for (const key of ['window', 'document', 'location', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'FocusEvent', 'getComputedStyle', 'localStorage'] as const) {
|
||||
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true });
|
||||
}
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (fn: () => void) => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout });
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
|
||||
const { TooltipProvider } = await import('../../../ui/tooltip');
|
||||
const { ExternalMcpCard } = await import('./ExternalMcpCard');
|
||||
|
||||
type FixtureControl = { rejectHttp: boolean; throwHttp: boolean; holdHttp: boolean; pendingHttp: (() => void)[]; pendingClients: (() => void)[]; rejectRotate: boolean; throwRotate: boolean; calls: { name: string; args: unknown[] }[] };
|
||||
async function mount(options: { enabled?: boolean; holdClients?: boolean } = {}, openHttp = true) {
|
||||
installAiToolAccessFixture({ locale: 'en', enabled: true, ...options });
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
|
||||
const qa = (window as unknown as { __aiToolQa: FixtureControl }).__aiToolQa;
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><TooltipProvider><ExternalMcpCard /></TooltipProvider></I18nProvider>));
|
||||
if (openHttp) await click('Configure HTTP');
|
||||
return { qa, close: async () => { await act(async () => root.unmount()); } };
|
||||
}
|
||||
const dialog = () => document.querySelector<HTMLElement>('[role="dialog"]')!;
|
||||
const button = (text: string, scope: ParentNode = document) => [...scope.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === text)!;
|
||||
const click = async (text: string) => { await act(async () => button(text).click()); };
|
||||
async function changePort(value: string) {
|
||||
const port = dialog().querySelector<HTMLInputElement>('[aria-label="Port"]')!;
|
||||
await act(async () => {
|
||||
port.focus();
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!.call(port, value);
|
||||
port.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
|
||||
});
|
||||
await act(async () => port.blur());
|
||||
return port;
|
||||
}
|
||||
|
||||
for (const failure of ['returned', 'thrown'] as const) {
|
||||
test(`HTTP ${failure} configuration failure is visible inside the dialog, preserves input and can retry`, async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
h.qa.rejectHttp = failure === 'returned';
|
||||
h.qa.throwHttp = failure === 'thrown';
|
||||
const port = await changePort('47401');
|
||||
const message = failure === 'returned' ? 'Fixture: port unavailable.' : 'Fixture: HTTP configuration threw.';
|
||||
const alert = dialog().querySelector('[role="alert"]')!;
|
||||
assert.match(alert.textContent!, new RegExp(message.replaceAll('.', '\\.')));
|
||||
assert.equal(document.activeElement, alert, 'the error must be reachable without scrolling back manually');
|
||||
assert.equal(port.value, '47401');
|
||||
assert.ok(document.querySelector('[role="dialog"]'));
|
||||
assert.match(document.querySelector('[data-settings-anchor="ai-external-mcp-http"]')!.textContent!, /Fixture:/);
|
||||
const beforeRetry = h.qa.calls.filter((call) => call.name === 'config').length;
|
||||
h.qa.rejectHttp = false;
|
||||
h.qa.throwHttp = false;
|
||||
await click('Retry');
|
||||
assert.equal(h.qa.calls.filter((call) => call.name === 'config').length, beforeRetry + 1);
|
||||
assert.equal(dialog().querySelector('[role="alert"]'), null);
|
||||
assert.equal(port.value, '47401');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['returned', 'thrown'] as const) {
|
||||
test(`HTTP ${failure} token rotation failure stays in the active dialog and retries explicitly`, async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await act(async () => (dialog().querySelector('[aria-label="Enable HTTP"]') as HTMLButtonElement).click());
|
||||
await act(async () => dialog().querySelector('summary')!.click());
|
||||
h.qa.rejectRotate = failure === 'returned';
|
||||
h.qa.throwRotate = failure === 'thrown';
|
||||
await click('Regenerate');
|
||||
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /Fixture: token rotation/);
|
||||
assert.equal(dialog().textContent!.includes('SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL'), false);
|
||||
h.qa.rejectRotate = false;
|
||||
h.qa.throwRotate = false;
|
||||
await click('Retry');
|
||||
assert.equal(dialog().querySelector('[role="alert"]'), null);
|
||||
assert.equal(dialog().textContent!.includes('SYNTHETIC_ROTATED_FIXTURE_TOKEN'), false);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('HTTP clipboard failure is visible in the dialog and clears only after another copy attempt', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await act(async () => dialog().querySelector('summary')!.click());
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new Error('Fixture clipboard failure'); } } });
|
||||
const copy = () => dialog().querySelector<HTMLButtonElement>('[aria-label="Copy"]:not([disabled])')!;
|
||||
await act(async () => copy().click());
|
||||
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /copy/i);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
|
||||
await act(async () => copy().click());
|
||||
assert.equal(dialog().querySelector('[role="alert"]'), null);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
for (const failure of ['returned', 'thrown'] as const) {
|
||||
test(`a completed runtime poll cannot suppress a pending HTTP ${failure} failure`, async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
h.qa.holdHttp = true;
|
||||
h.qa.rejectHttp = failure === 'returned';
|
||||
h.qa.throwHttp = failure === 'thrown';
|
||||
const beforePoll = h.qa.calls.filter((call) => call.name === 'status').length;
|
||||
const port = await changePort('47402');
|
||||
assert.match(dialog().querySelector('[role="status"]')!.textContent!, /Applying HTTP/);
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3200)); });
|
||||
assert.ok(h.qa.calls.filter((call) => call.name === 'status').length > beforePoll);
|
||||
await act(async () => h.qa.pendingHttp.splice(0).forEach((resolve) => resolve()));
|
||||
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /Fixture:/);
|
||||
assert.equal(port.value, '47402');
|
||||
assert.equal(dialog().querySelector('[role="status"]'), null);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('completed MCP enable can be turned off while unrelated client discovery is still pending', async () => {
|
||||
const h = await mount({ enabled: false, holdClients: true }, false);
|
||||
try {
|
||||
const toggle = document.querySelector<HTMLButtonElement>('[role="switch"][aria-label="External MCP"]')!;
|
||||
await act(async () => toggle.click());
|
||||
assert.equal(toggle.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(h.qa.pendingClients.length > 0, 'client discovery remains intentionally unresolved');
|
||||
assert.equal(toggle.disabled, false, 'successful enable must release the switch before client discovery finishes');
|
||||
await act(async () => toggle.click());
|
||||
assert.equal(toggle.getAttribute('aria-checked'), 'false');
|
||||
assert.equal(toggle.disabled, false);
|
||||
assert.deepEqual(h.qa.calls.filter((call) => call.name === 'enable').map((call) => call.args[0]), [true, false]);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
@@ -31,8 +31,13 @@ import {
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
|
||||
import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../../settings/settings-ui";
|
||||
import { ToolStatusPill, type ToolStatusTone } from "./ToolStatusPill";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { useOptionalSettingsFocus } from "../../SettingsFocusContext";
|
||||
import { SettingsAnchor } from "../../settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
|
||||
import { getBridge } from "./types";
|
||||
@@ -72,23 +77,13 @@ type StatusView = {
|
||||
className: string;
|
||||
};
|
||||
|
||||
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): StatusView {
|
||||
if (!enabled) {
|
||||
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
|
||||
}
|
||||
if (!status || !status.ok) {
|
||||
return { labelKey: "ai.externalMcp.status.unavailable", className: "text-amber-500" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
return { labelKey: "ai.externalMcp.status.running", className: "text-emerald-500" };
|
||||
}
|
||||
if (status.state === "starting") {
|
||||
return { labelKey: "ai.externalMcp.status.starting", className: "text-amber-500" };
|
||||
}
|
||||
if (status.state === "error") {
|
||||
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
|
||||
}
|
||||
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
|
||||
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): { labelKey: string; tone: ToolStatusTone } {
|
||||
if (!enabled) return { labelKey: "ai.externalMcp.status.disabled", tone: "inactive" };
|
||||
if (!status || !status.ok) return { labelKey: "ai.externalMcp.status.unavailable", tone: status?.error ? "error" : "inactive" };
|
||||
if (status.state === "running") return { labelKey: "ai.externalMcp.status.running", tone: "active" };
|
||||
if (status.state === "starting") return { labelKey: "ai.externalMcp.status.starting", tone: "inactive" };
|
||||
if (status.state === "error") return { labelKey: "ai.externalMcp.status.error", tone: "error" };
|
||||
return { labelKey: "ai.externalMcp.status.disabled", tone: "inactive" };
|
||||
}
|
||||
|
||||
/** Map bridge permissionMode to a Safety i18n key for display. */
|
||||
@@ -252,6 +247,15 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
|
||||
|
||||
export const ExternalMcpCard: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [behaviorOpen, setBehaviorOpen] = useState(false);
|
||||
const focus = useOptionalSettingsFocus();
|
||||
useEffect(() => {
|
||||
if (focus?.request?.anchorId === "ai-external-mcp-approval-alert") setBehaviorOpen(true);
|
||||
}, [focus?.request]);
|
||||
const statusGeneration = useRef(0);
|
||||
const clientStatusGeneration = useRef(0);
|
||||
const httpOperationGeneration = useRef(0);
|
||||
useEffect(() => () => { statusGeneration.current += 1; clientStatusGeneration.current += 1; httpOperationGeneration.current += 1; }, []);
|
||||
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
@@ -268,8 +272,11 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
listenAddress: readExternalMcpHttpListenAddress(),
|
||||
clientHost: readExternalMcpHttpClientHost(),
|
||||
}));
|
||||
const [isApplyingHttp, setIsApplyingHttp] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
|
||||
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
|
||||
const [manualFormat, setManualFormat] = useState("command");
|
||||
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
|
||||
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
|
||||
const [claudeStatus, setClaudeStatus] = useState<ClientSetupStatus | null>(null);
|
||||
@@ -280,6 +287,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
const [isAddingClaude, setIsAddingClaude] = useState(false);
|
||||
const [isAddingGrok, setIsAddingGrok] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [httpCopyError, setHttpCopyError] = useState<string | null>(null);
|
||||
const [httpActionError, setHttpActionError] = useState<{ kind: "config" | "rotate"; message: string } | null>(null);
|
||||
const [actionMessage, setActionMessage] = useState<{ tone: "error" | "warning" | "success"; text: string } | null>(null);
|
||||
const bridgeUnavailableMessage = t("ai.externalMcp.bridgeUnavailable");
|
||||
|
||||
@@ -324,6 +333,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const applyHttpSettings = useCallback(async (next: ExternalMcpHttpSettings) => {
|
||||
const operation = ++httpOperationGeneration.current;
|
||||
const generation = ++statusGeneration.current;
|
||||
setHttpActionError(null);
|
||||
setIsApplyingHttp(true);
|
||||
const normalized: ExternalMcpHttpSettings = {
|
||||
enabled: writeExternalMcpHttpEnabled(next.enabled),
|
||||
port: writeExternalMcpHttpPort(next.port),
|
||||
@@ -332,36 +345,56 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
clientHost: writeExternalMcpHttpClientHost(next.clientHost),
|
||||
};
|
||||
setHttpSettings(normalized);
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
|
||||
httpEnabled: normalized.enabled,
|
||||
httpPort: normalized.port,
|
||||
httpAllowCustomBind: normalized.allowCustomBind,
|
||||
httpListenAddress: normalized.listenAddress,
|
||||
httpClientHost: normalized.clientHost,
|
||||
});
|
||||
if (nextStatus && typeof nextStatus === "object") {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
try {
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
|
||||
httpEnabled: normalized.enabled,
|
||||
httpPort: normalized.port,
|
||||
httpAllowCustomBind: normalized.allowCustomBind,
|
||||
httpListenAddress: normalized.listenAddress,
|
||||
httpClientHost: normalized.clientHost,
|
||||
}) as ExternalMcpStatus | undefined;
|
||||
if (operation !== httpOperationGeneration.current) return;
|
||||
if (!nextStatus?.ok || nextStatus.error || nextStatus.http?.error) {
|
||||
setHttpActionError({ kind: "config", message: nextStatus?.error || nextStatus?.http?.error || bridgeUnavailableMessage });
|
||||
return;
|
||||
}
|
||||
if (generation === statusGeneration.current) setStatus(nextStatus);
|
||||
} catch (error) {
|
||||
if (operation === httpOperationGeneration.current) {
|
||||
setHttpActionError({ kind: "config", message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
} finally {
|
||||
if (operation === httpOperationGeneration.current) setIsApplyingHttp(false);
|
||||
}
|
||||
}, []);
|
||||
}, [bridgeUnavailableMessage]);
|
||||
|
||||
const rotateHttpToken = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.externalMcpSetConfig) return;
|
||||
const operation = ++httpOperationGeneration.current;
|
||||
const generation = ++statusGeneration.current;
|
||||
setIsRotatingHttpToken(true);
|
||||
setActionMessage(null);
|
||||
setHttpActionError(null);
|
||||
try {
|
||||
const nextStatus = await bridge.externalMcpSetConfig({ rotateHttpToken: true });
|
||||
if (nextStatus && typeof nextStatus === "object") {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({ rotateHttpToken: true }) as ExternalMcpStatus | undefined;
|
||||
if (operation !== httpOperationGeneration.current) return;
|
||||
if (!nextStatus?.ok || nextStatus.error || nextStatus.http?.error) {
|
||||
setHttpActionError({ kind: "rotate", message: nextStatus?.error || nextStatus?.http?.error || bridgeUnavailableMessage });
|
||||
return;
|
||||
}
|
||||
if (generation === statusGeneration.current) setStatus(nextStatus);
|
||||
} catch (error) {
|
||||
if (operation === httpOperationGeneration.current) {
|
||||
setHttpActionError({ kind: "rotate", message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
} finally {
|
||||
setIsRotatingHttpToken(false);
|
||||
}
|
||||
}, []);
|
||||
}, [bridgeUnavailableMessage]);
|
||||
|
||||
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
|
||||
const generation = ++statusGeneration.current;
|
||||
const bridge = getBridge();
|
||||
const includeClients = options?.clients !== false;
|
||||
const clientGeneration = includeClients ? ++clientStatusGeneration.current : clientStatusGeneration.current;
|
||||
if (
|
||||
!bridge?.externalMcpGetStatus
|
||||
|| !bridge?.externalMcpCodexGetStatus
|
||||
@@ -411,10 +444,15 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
bridge.externalMcpGetUniversalSetupPrompt?.()
|
||||
?? Promise.resolve({ ok: false, prompt: "" }),
|
||||
]);
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
// Runtime polling and CLI/client discovery have separate lifetimes.
|
||||
// A fast quiet poll must not discard a slow, still-current client batch.
|
||||
if (generation === statusGeneration.current) {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
}
|
||||
}
|
||||
if (clientGeneration !== clientStatusGeneration.current) return;
|
||||
setCodexStatus(nextCodexStatus as ClientSetupStatus);
|
||||
setClaudeStatus(nextClaudeStatus as ClientSetupStatus);
|
||||
setGrokStatus(nextGrokStatus as ClientSetupStatus);
|
||||
@@ -425,13 +463,14 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
);
|
||||
} else {
|
||||
const nextStatus = await bridge.externalMcpGetStatus();
|
||||
if (generation !== statusGeneration.current) return;
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!options?.quiet) setIsRefreshing(false);
|
||||
if (!options?.quiet && clientGeneration === clientStatusGeneration.current) setIsRefreshing(false);
|
||||
}
|
||||
}, [bridgeUnavailableMessage, enabled, setEnabled]);
|
||||
|
||||
@@ -481,8 +520,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
const canAddToClaude = claudeStatus?.state === "not_configured";
|
||||
const canAddToGrok = grokStatus?.state === "not_configured";
|
||||
|
||||
const copyText = useCallback(async (key: string, text: string) => {
|
||||
const copyText = useCallback(async (key: string, text: string, scope: "client" | "http" = "client") => {
|
||||
if (!text) return;
|
||||
setCopied((current) => current === key ? null : current);
|
||||
if (scope === "http") setHttpCopyError(null);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
@@ -490,7 +531,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
setCopied((current) => (current === key ? null : current));
|
||||
}, 1200);
|
||||
} catch {
|
||||
setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
|
||||
if (scope === "http") setHttpCopyError(t("ai.externalMcp.copyFailed"));
|
||||
else setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
@@ -634,8 +676,13 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<SettingsSection
|
||||
anchorId="ai-external-mcp"
|
||||
title={t("ai.externalMcp.title")}
|
||||
actions={<ToolStatusPill tone={bridgeStatusView.tone}>{t(bridgeStatusView.labelKey)}</ToolStatusPill>}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="min-w-0 flex items-start gap-1.5">
|
||||
<p className="min-w-0 text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.description")}
|
||||
@@ -665,12 +712,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", bridgeStatusView.className)}>
|
||||
{t(bridgeStatusView.labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
|
||||
<SettingCard divided>
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("ai.externalMcp.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -679,17 +724,21 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
disabled={isToggling}
|
||||
ariaLabel={t("ai.externalMcp.title")}
|
||||
onChange={(nextEnabled) => {
|
||||
statusGeneration.current += 1;
|
||||
setActionMessage(null);
|
||||
void setEnabled(nextEnabled).then(() => refreshStatus());
|
||||
setIsToggling(true);
|
||||
void setEnabled(nextEnabled).then(() => { void refreshStatus(); }).finally(() => setIsToggling(false));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permission mode is controlled in Safety settings; surface it here so External MCP
|
||||
users see why write tools may still prompt (confirm) or run freely (auto). */}
|
||||
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="text-sm font-medium">{t("ai.externalMcp.permissionMode.label")}</div>
|
||||
<div className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.permissionMode.hint")}
|
||||
@@ -706,9 +755,11 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingsAnchor anchorId="ai-external-mcp-approval-alert">
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.toolSettings.behavior")} description={t(mode === "persistent" ? "ai.externalMcp.mode.persistent" : "ai.externalMcp.mode.temporary")}>
|
||||
<ToolSettingsDialog title={t("ai.toolSettings.behavior")} triggerLabel={t("ai.toolSettings.configureBehavior")} immediate open={behaviorOpen} onOpenChange={setBehaviorOpen}>
|
||||
<SettingCard divided className="border-0 bg-transparent p-0">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-approval-alert"
|
||||
label={t("ai.externalMcp.approvalAlert")}
|
||||
description={t("ai.externalMcp.approvalAlert.description")}
|
||||
>
|
||||
@@ -803,6 +854,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingsAnchor>
|
||||
</SettingCard>
|
||||
|
||||
<ExternalMcpHttpSection
|
||||
@@ -811,93 +866,36 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
status={status?.http || null}
|
||||
copied={copied}
|
||||
rotating={isRotatingHttpToken}
|
||||
onCopy={copyText}
|
||||
onCopy={(key, text) => { void copyText(key, text, "http"); }}
|
||||
applying={isApplyingHttp}
|
||||
operationError={httpActionError?.message ?? null}
|
||||
copyError={httpCopyError}
|
||||
onRetry={httpActionError ? () => {
|
||||
if (httpActionError.kind === "rotate") void rotateHttpToken();
|
||||
else void applyHttpSettings(httpSettings);
|
||||
} : undefined}
|
||||
onChange={(next) => { void applyHttpSettings(next); }}
|
||||
onRotateToken={() => { void rotateHttpToken(); }}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
|
||||
{t("ai.externalMcp.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.launcher")}
|
||||
value={launcherPath || ""}
|
||||
copyKey="launcher"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.discovery")}
|
||||
value={status?.discoveryPath || ""}
|
||||
copyKey="discovery"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
{!enabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<SettingCard>
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.externalMcp.clientConfiguration")} description={t("ai.externalMcp.clientConfiguration.description")}>
|
||||
<ToolSettingsDialog title={t("ai.externalMcp.clientConfiguration")} triggerLabel={t("ai.toolSettings.configureClients")}>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("ai.externalMcp.clientConfiguration")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.clientConfiguration.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("ai.externalMcp.clientConfiguration")}
|
||||
className="grid grid-cols-5 gap-1 rounded-md bg-muted p-1"
|
||||
>
|
||||
{CLIENT_TABS.map((client) => {
|
||||
const active = selectedClient === client;
|
||||
return (
|
||||
<button
|
||||
key={client}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
setSelectedClient(client);
|
||||
setActionMessage(null);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center justify-center rounded-sm px-2 text-xs font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`ai.externalMcp.client.${client}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Tabs value={selectedClient} onValueChange={(value) => { setSelectedClient(value as ExternalMcpClient); setManualFormat(value === "other" || value === "cursor" ? "snippet" : "command"); setActionMessage(null); }}>
|
||||
<TabsList aria-label={t("ai.externalMcp.clientConfiguration")} className="flex h-auto flex-wrap justify-start">
|
||||
{CLIENT_TABS.map((client) => <TabsTrigger key={client} value={client}>{t(`ai.externalMcp.client.${client}`)}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
<TabsContent value={selectedClient}>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<div className="space-y-4">
|
||||
{selectedClientMeta.kind === "installable" && selectedClientMeta.statusView ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className={cn("text-xs font-medium", selectedClientMeta.statusView.className)}>
|
||||
@@ -924,57 +922,76 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedClientMeta.kind === "installable" ? (
|
||||
<>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.cliCommand")}
|
||||
value={selectedClientMeta.command}
|
||||
copyKey="command"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.configSnippet")}
|
||||
value={selectedClientMeta.snippet}
|
||||
copyKey="snippet"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.configSnippet")}
|
||||
value={selectedClientMeta.snippet}
|
||||
copyKey="cursor"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
{selectedClient === "other" ? (
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.universalSetupPrompt")}
|
||||
value={universalSetupPrompt}
|
||||
copyKey="universal-setup-prompt"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.manualSetup")}</summary>
|
||||
<label className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
{t("ai.toolSettings.format")}
|
||||
<select aria-label={t("ai.toolSettings.format")} className="h-9 max-w-full rounded-md border border-input bg-background px-3 text-sm" value={manualFormat} onChange={(event) => setManualFormat(event.currentTarget.value)}>
|
||||
{selectedClientMeta.kind === "installable" ? <option value="command">{t("ai.externalMcp.cliCommand")}</option> : null}
|
||||
<option value="snippet">{t("ai.externalMcp.configSnippet")}</option>
|
||||
{selectedClient === "other" ? <option value="prompt">{t("ai.externalMcp.universalSetupPrompt")}</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
<CopyableCodeBlock
|
||||
label={t(manualFormat === "command" ? "ai.externalMcp.cliCommand" : manualFormat === "prompt" ? "ai.externalMcp.universalSetupPrompt" : "ai.externalMcp.configSnippet")}
|
||||
value={manualFormat === "command" ? selectedClientMeta.command : manualFormat === "prompt" ? universalSetupPrompt : selectedClientMeta.snippet}
|
||||
copyKey={manualFormat}
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.connectionDetails")}</summary>
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
|
||||
{t("ai.externalMcp.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.launcher")}
|
||||
value={launcherPath || ""}
|
||||
copyKey="launcher"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.discovery")}
|
||||
value={status?.discoveryPath || ""}
|
||||
copyKey="discovery"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
{!enabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{actionMessage ? <p role={actionMessage.tone === "error" ? "alert" : "status"} className={cn("text-xs", actionMessage.tone === "error" ? "text-destructive" : "text-muted-foreground")}>{actionMessage.text}</p> : null}
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
{actionMessage ? (
|
||||
<div
|
||||
@@ -991,6 +1008,7 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
{configError || status?.error ? (
|
||||
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
buildClaudeHttpAddCommand,
|
||||
buildCodexHttpEnvLine,
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
} from "../../../../domain/externalMcpHttp";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
|
||||
import { ToolStatusPill } from "./ToolStatusPill";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { useOptionalSettingsFocus } from "../../SettingsFocusContext";
|
||||
import { SettingsAnchor } from "../../settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
|
||||
export type ExternalMcpHttpViewStatus = {
|
||||
@@ -41,6 +44,10 @@ type ExternalMcpHttpSectionProps = {
|
||||
status: ExternalMcpHttpViewStatus | null;
|
||||
copied: string | null;
|
||||
rotating: boolean;
|
||||
applying?: boolean;
|
||||
operationError?: string | null;
|
||||
copyError?: string | null;
|
||||
onRetry?: () => void;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
onChange: (next: ExternalMcpHttpViewSettings) => void;
|
||||
onRotateToken: () => void;
|
||||
@@ -73,11 +80,25 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
status,
|
||||
copied,
|
||||
rotating,
|
||||
applying,
|
||||
operationError,
|
||||
copyError,
|
||||
onRetry,
|
||||
onCopy,
|
||||
onChange,
|
||||
onRotateToken,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const operationErrorRef = useRef<HTMLDivElement>(null);
|
||||
const errorId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (open && (operationError || copyError)) operationErrorRef.current?.focus();
|
||||
}, [open, operationError, copyError]);
|
||||
const focus = useOptionalSettingsFocus();
|
||||
useEffect(() => {
|
||||
if (focus?.request?.anchorId === "ai-external-mcp-http") setOpen(true);
|
||||
}, [focus?.request]);
|
||||
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
|
||||
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
|
||||
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
|
||||
@@ -85,6 +106,16 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
const [addressError, setAddressError] = useState(false);
|
||||
const [clientHostError, setClientHostError] = useState(false);
|
||||
|
||||
const changeOpen = (next: boolean) => {
|
||||
setPortDraft(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
|
||||
setAddressDraft(settings.listenAddress || "0.0.0.0");
|
||||
setClientHostDraft(settings.clientHost || "");
|
||||
setPortError(false);
|
||||
setAddressError(false);
|
||||
setClientHostError(false);
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
const listenAddress = settings.allowCustomBind
|
||||
? (status?.listenAddress || settings.listenAddress)
|
||||
: "127.0.0.1";
|
||||
@@ -97,14 +128,9 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
);
|
||||
const token = status?.bearerToken || "";
|
||||
const listening = Boolean(status?.listening);
|
||||
const statusLabel = listening
|
||||
? t("ai.externalMcp.http.listening")
|
||||
: t("ai.externalMcp.http.notListening");
|
||||
const statusClass = status?.errorCode
|
||||
? "text-destructive"
|
||||
: listening
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
const hasRuntimeError = Boolean(operationError || status?.errorCode || status?.error);
|
||||
const statusLabel = hasRuntimeError ? t("ai.externalMcp.status.error")
|
||||
: t(listening ? "ai.externalMcp.http.listening" : "ai.externalMcp.http.notListening");
|
||||
|
||||
let errorText = "";
|
||||
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
|
||||
@@ -116,6 +142,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
|
||||
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
|
||||
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
|
||||
else if (status?.error) errorText = status.error;
|
||||
|
||||
const commitPort = () => {
|
||||
const port = parsePort(portDraft);
|
||||
@@ -151,16 +178,25 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<SettingsAnchor anchorId="ai-external-mcp-http" className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
|
||||
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
|
||||
<ToolStatusPill tone={hasRuntimeError ? "error" : listening ? "active" : "inactive"}>{statusLabel}</ToolStatusPill>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingCard>
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t(settings.enabled ? "ai.toolSettings.httpOn" : "ai.toolSettings.httpOff")} description={settings.allowCustomBind ? t("ai.externalMcp.http.warning") : t("ai.toolSettings.loopback")}>
|
||||
<ToolSettingsDialog title={t("ai.externalMcp.http.title")} triggerLabel={t("ai.toolSettings.configureHttp")} immediate open={open} onOpenChange={changeOpen}>
|
||||
{applying ? <p role="status" className="text-xs text-muted-foreground">{t("ai.toolSettings.applyingHttp")}</p> : null}
|
||||
{operationError || copyError ? <div ref={operationErrorRef} role="alert" tabIndex={-1} className="space-y-2 text-xs text-destructive">
|
||||
{operationError ? <p>{operationError}</p> : null}
|
||||
{copyError ? <p>{copyError}</p> : null}
|
||||
{operationError && onRetry ? <Button type="button" variant="outline" size="sm" disabled={rotating || applying} onClick={onRetry}>{t("ai.toolSettings.retry")}</Button> : null}
|
||||
</div> : null}
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
|
||||
<SettingCard divided className="border-0 bg-transparent p-0">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-http"
|
||||
label={t("ai.externalMcp.http.enable")}
|
||||
>
|
||||
<Toggle
|
||||
@@ -176,6 +212,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t("ai.externalMcp.http.port")}
|
||||
aria-invalid={portError}
|
||||
aria-describedby={portError ? errorId : undefined}
|
||||
min={1024}
|
||||
max={65535}
|
||||
value={portDraft}
|
||||
@@ -207,6 +245,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.listenAddress")}
|
||||
aria-invalid={addressError}
|
||||
aria-describedby={addressError ? errorId : undefined}
|
||||
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
|
||||
value={addressDraft}
|
||||
onChange={(event) => {
|
||||
@@ -226,6 +266,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.clientHost")}
|
||||
aria-invalid={clientHostError}
|
||||
aria-describedby={clientHostError ? errorId : undefined}
|
||||
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
|
||||
value={clientHostDraft}
|
||||
onChange={(event) => {
|
||||
@@ -245,8 +287,9 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
{settings.enabled && !externalMcpEnabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
|
||||
) : null}
|
||||
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
|
||||
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
{errorText ? <p id={errorId} role="alert" className="text-xs text-destructive">{errorText}</p> : null}
|
||||
<details className="space-y-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.connectionDetails")}</summary>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.url")}
|
||||
value={url}
|
||||
@@ -259,6 +302,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.token")}
|
||||
value={token}
|
||||
sensitive
|
||||
copyKey="http-token"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -272,7 +316,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!externalMcpEnabled || !settings.enabled || rotating}
|
||||
disabled={!externalMcpEnabled || !settings.enabled || rotating || applying}
|
||||
onClick={onRotateToken}
|
||||
>
|
||||
{t("ai.externalMcp.http.regenerate")}
|
||||
@@ -290,6 +334,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.envLine")}
|
||||
value={token ? buildCodexHttpEnvLine(token) : ""}
|
||||
sensitive
|
||||
copyKey="http-env"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -300,6 +345,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.claudeCommand")}
|
||||
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
|
||||
sensitive
|
||||
copyKey="http-claude"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -310,6 +356,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.genericSnippet")}
|
||||
value={token ? buildGenericHttpMcpJson(url, token) : ""}
|
||||
sensitive
|
||||
copyKey="http-json"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -317,7 +364,14 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
{settings.enabled && !externalMcpEnabled ? <p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p> : null}
|
||||
{applying ? <p role="status" className="text-xs text-muted-foreground">{t("ai.toolSettings.applyingHttp")}</p> : null}
|
||||
{copyError ? <p role="alert" className="text-xs text-destructive">{copyError}</p> : null}
|
||||
{operationError || status?.error ? <p role="alert" className="text-xs text-destructive">{operationError || status?.error}</p> : null}
|
||||
</SettingsAnchor>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'CustomEvent', 'Event'] as const) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value: dom.window[name], configurable: true });
|
||||
}
|
||||
for (const [name, value] of Object.entries({ IS_REACT_ACT_ENVIRONMENT: true, fetch: () => { throw new Error('Network is forbidden in model discovery tests'); } })) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true });
|
||||
}
|
||||
after(() => {
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
|
||||
const { TooltipProvider } = await import('../../../ui/tooltip');
|
||||
const { ModelSelector } = await import('./ModelSelector');
|
||||
|
||||
type Response = { ok: boolean; status?: number; data: string; error?: string };
|
||||
function deferred() {
|
||||
let resolve!: (value: Response) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<Response>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
type Props = React.ComponentProps<typeof ModelSelector>;
|
||||
const baseProps: Props = {
|
||||
value: '', onChange: () => {}, baseURL: 'http://127.0.0.1:9217/v1',
|
||||
providerId: 'custom', style: 'openai', apiKey: 'SYNTHETIC_FIXTURE_KEY', customHeaders: {},
|
||||
};
|
||||
async function mount() {
|
||||
const requests: ({ url: string; method: string; body: string | undefined } & ReturnType<typeof deferred>)[] = [];
|
||||
const allowlistCalls: string[] = [];
|
||||
let overflow = false;
|
||||
Object.defineProperty(window, 'netcatty', { configurable: true, value: {
|
||||
aiAllowlistAddHost: async (baseURL: string) => { allowlistCalls.push(baseURL); return { ok: true }; },
|
||||
aiFetch: (url: string, method: string, _headers: unknown, body: string | undefined) => {
|
||||
// Hold instead of rejecting if a regression exceeds the budget, so even a
|
||||
// broken auto-fetch effect cannot create an uncontrolled test loop.
|
||||
if (requests.length >= 4) { overflow = true; return new Promise<Response>(() => {}); }
|
||||
const pending = deferred();
|
||||
requests.push({ url, method, body, ...pending });
|
||||
return pending.promise;
|
||||
},
|
||||
} });
|
||||
let renderer!: ReactTestRenderer;
|
||||
const view = (updates: Partial<Props> = {}) => <I18nProvider locale="en"><TooltipProvider>
|
||||
<ModelSelector {...baseProps} {...updates} />
|
||||
</TooltipProvider></I18nProvider>;
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const input = () => renderer.root.findByType('input');
|
||||
const open = async () => { await act(async () => input().props.onFocus()); };
|
||||
const close = async () => { await act(async () => input().props.onBlur()); };
|
||||
const refresh = async () => {
|
||||
const button = renderer.root.findAllByType('button').find((node) => typeof node.props.onClick === 'function')!;
|
||||
await act(async () => button.props.onClick({ defaultPrevented: false }));
|
||||
};
|
||||
const markup = () => JSON.stringify(renderer.toJSON());
|
||||
return {
|
||||
requests, allowlistCalls, input, open, close, refresh, markup,
|
||||
update: async (updates: Partial<Props>) => { await act(async () => renderer.update(view(updates))); },
|
||||
assertBounded: () => assert.equal(overflow, false, 'mock request budget was not exceeded'),
|
||||
unmount: async () => { await act(async () => renderer.unmount()); },
|
||||
};
|
||||
}
|
||||
const notFound: Response = { ok: false, status: 404, data: '{"error":"fixture has no model-list route"}' };
|
||||
const models = (id: string): Response => ({ ok: true, status: 200, data: JSON.stringify({ data: [{ id }] }) });
|
||||
|
||||
for (const failure of ['404', 'transport', 'invalid-json'] as const) {
|
||||
test(`model discovery ${failure} failure stays visible without auto-retry; Refresh retries once`, async () => {
|
||||
const h = await mount();
|
||||
const reject = (index: number) => {
|
||||
if (failure === 'transport') h.requests[index].reject(new Error('Synthetic model-list transport failure'));
|
||||
else h.requests[index].resolve(failure === '404' ? notFound : { ok: true, status: 200, data: '{not json' });
|
||||
};
|
||||
try {
|
||||
assert.equal(h.requests.length, 0);
|
||||
await h.open();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.requests[0].url, 'http://127.0.0.1:9217/v1/models');
|
||||
assert.equal(h.requests[0].method, 'GET');
|
||||
assert.equal(h.requests[0].body, undefined);
|
||||
await act(async () => reject(0));
|
||||
assert.equal(h.requests.length, 1, 'failure must settle instead of triggering the loading effect again');
|
||||
assert.match(h.markup(), /text-destructive/);
|
||||
assert.doesNotMatch(h.markup(), /Loading models/);
|
||||
await h.close();
|
||||
await h.open();
|
||||
await h.update({ customHeaders: {} });
|
||||
assert.equal(h.requests.length, 1, 'reopen or an equal-config render must not retry a failure');
|
||||
assert.match(h.markup(), /text-destructive/);
|
||||
await h.refresh();
|
||||
assert.equal(h.requests.length, 2);
|
||||
await act(async () => reject(1));
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.match(h.markup(), /text-destructive/);
|
||||
assert.equal(h.allowlistCalls.length, 2);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('changing provider configuration clears a settled error and permits one fresh discovery', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await act(async () => h.requests[0].resolve(notFound));
|
||||
assert.equal(h.requests.length, 1);
|
||||
await h.update({ baseURL: 'http://127.0.0.1:9217/second-provider/v1', providerId: 'openai' });
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.equal(h.requests[1].url, 'http://127.0.0.1:9217/second-provider/v1/models');
|
||||
await act(async () => h.requests[1].resolve(models('second-model')));
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.match(h.markup(), /second-model/);
|
||||
assert.doesNotMatch(h.markup(), /Failed to fetch models/);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
|
||||
test('a provider switch ignores an older pending discovery failure', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await h.update({ baseURL: 'http://127.0.0.1:9217/second-provider/v1' });
|
||||
assert.equal(h.requests.length, 2);
|
||||
await act(async () => h.requests[1].resolve(models('current-model')));
|
||||
await act(async () => h.requests[0].resolve(notFound));
|
||||
assert.equal(h.requests.length, 2);
|
||||
assert.match(h.markup(), /current-model/);
|
||||
assert.doesNotMatch(h.markup(), /Failed to fetch models/);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
|
||||
test('successful discovery remains cached across reopen and manual model entry remains controlled', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await act(async () => h.requests[0].resolve(models('listed-model')));
|
||||
await h.close();
|
||||
await h.open();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.match(h.markup(), /listed-model/);
|
||||
const changed: string[] = [];
|
||||
await h.update({ onChange: (value) => { changed.push(value); } });
|
||||
await act(async () => h.input().props.onChange({ target: { value: 'manual-model' } }));
|
||||
await h.update({ value: changed[0] });
|
||||
assert.deepEqual(changed, ['manual-model']);
|
||||
assert.equal(h.input().props.value, 'manual-model');
|
||||
assert.equal(h.requests.length, 1);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
});
|
||||
|
||||
test('closing and unmounting with a pending discovery do not trigger another request', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await h.open();
|
||||
await h.close();
|
||||
await act(async () => h.requests[0].resolve(notFound));
|
||||
assert.equal(h.requests.length, 1);
|
||||
await h.refresh();
|
||||
assert.equal(h.requests.length, 2);
|
||||
h.assertBounded();
|
||||
} finally { await h.unmount(); }
|
||||
await act(async () => h.requests[1].resolve(notFound));
|
||||
assert.equal(h.requests.length, 2);
|
||||
});
|
||||
@@ -175,12 +175,12 @@ export const ModelSelector: React.FC<{
|
||||
}
|
||||
}, [baseURL, effectiveModelsEndpoint, apiKey, customHeaders, resolvedStyle, skipTLSVerify, discoveryKey]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
// Failed discovery stays settled until explicit refresh or a new discovery key.
|
||||
useEffect(() => {
|
||||
if (isOpen && canFetch && !hasFetched && !isLoading) {
|
||||
if (isOpen && canFetch && !hasFetched && !isLoading && error === null) {
|
||||
void fetchModels();
|
||||
}
|
||||
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
|
||||
}, [isOpen, canFetch, hasFetched, isLoading, error, fetchModels]);
|
||||
|
||||
// Filter preset and discovered models by current input value (inline autocomplete).
|
||||
const suggestions = useMemo(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import type { AIToolIntegrationMode } from "../../../../infrastructure/ai/types";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { useToolAccessGuidanceState } from "../../../../application/state/useToolAccessGuidanceState";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { EXTERNAL_MCP_DISCOVERY_ENV_VAR } from "./ExternalMcpCard";
|
||||
|
||||
/** Build a ready-to-paste prompt so an external AI client can register Netcatty MCP itself. */
|
||||
@@ -45,16 +46,20 @@ type CopyRowProps = {
|
||||
|
||||
const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel, testId }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState(false);
|
||||
const { t } = useI18n();
|
||||
const canCopy = Boolean(value);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!value) return;
|
||||
setCopyError(false);
|
||||
setCopied(false);
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
// Clipboard may be unavailable; the text stays selectable in the block.
|
||||
setCopyError(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,6 +87,7 @@ const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel,
|
||||
{copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{copyError ? <p role="alert" className="text-xs text-destructive">{t("ai.externalMcp.copyFailed")}</p> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -93,7 +99,7 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
|
||||
|
||||
if (mode === "skills") {
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
|
||||
<ToolSettingsDialog title={t("ai.toolAccess.skills.file")} triggerLabel={t("ai.toolSettings.viewSkills")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.toolAccess.skills.description")}
|
||||
</p>
|
||||
@@ -110,12 +116,12 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
|
||||
{commandPrefix ? (
|
||||
<p className="text-xs text-muted-foreground/80 font-mono break-all">{commandPrefix}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ToolSettingsDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
|
||||
<ToolSettingsDialog title={t("ai.toolAccess.mcpPrompt.title")} triggerLabel={t("ai.toolSettings.viewPrompt")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.toolAccess.mcpPrompt.description")}
|
||||
</p>
|
||||
@@ -129,6 +135,6 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
|
||||
{!mcpLauncherPath ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.toolAccess.mcpPrompt.enableHint")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ToolSettingsDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../../ui/dialog";
|
||||
|
||||
/** Shared, bounded settings dialog. Callers retain their original commit semantics. */
|
||||
export const ToolSettingsDialog: React.FC<{
|
||||
title: string;
|
||||
triggerLabel: string;
|
||||
children: React.ReactNode;
|
||||
immediate?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
footer?: React.ReactNode;
|
||||
busy?: boolean;
|
||||
}> = ({ title, triggerLabel, children, immediate, open, onOpenChange, footer, busy }) => {
|
||||
const { t } = useI18n();
|
||||
const [localOpen, setLocalOpen] = React.useState(false);
|
||||
const changeOpen = (next: boolean) => {
|
||||
if (busy) return;
|
||||
setLocalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
return (
|
||||
<Dialog open={open ?? localOpen} onOpenChange={changeOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">{triggerLabel}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col gap-0 overflow-hidden p-0"
|
||||
aria-busy={busy || undefined}
|
||||
hideCloseButton={busy}
|
||||
onEscapeKeyDown={(event) => { if (busy) event.preventDefault(); }}
|
||||
onInteractOutside={(event) => { if (busy) event.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader className="shrink-0 px-6 pb-4 pt-6 pr-12 text-left">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{immediate ? <p className="text-xs text-muted-foreground">{t("ai.toolSettings.immediate")}</p> : null}
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 overflow-y-auto px-6 pb-6 space-y-4">{children}</div>
|
||||
<DialogFooter className="shrink-0 gap-2 border-t px-6 py-4">
|
||||
{footer ?? <Button type="button" variant="outline" onClick={() => changeOpen(false)}>{t("common.close")}</Button>}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
|
||||
export type ToolStatusTone = "active" | "inactive" | "error";
|
||||
|
||||
/** Non-interactive runtime status, shared by the MCP and HTTP section headers. */
|
||||
export const ToolStatusPill: React.FC<{ tone: ToolStatusTone; children: React.ReactNode }> = ({ tone, children }) => (
|
||||
<span role="status" className={cn(
|
||||
"inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium leading-5",
|
||||
tone === "active" ? "bg-primary/10 text-primary"
|
||||
: tone === "error" ? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>{children}</span>
|
||||
);
|
||||
@@ -283,6 +283,8 @@ interface AIChatPanelsHostProps {
|
||||
mountedTabIds: string[];
|
||||
activeTabId: string | null;
|
||||
activeSidePanelTab: SidePanelTab | null;
|
||||
/** All live terminal+workspace target ids (for /chats dangling detection). */
|
||||
liveScopeTargetIds: Set<string>;
|
||||
contextsByTabId: Map<string, AIPanelContext>;
|
||||
resolveExecutorContext: (scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
@@ -298,6 +300,8 @@ interface AIChatPanelsHostProps {
|
||||
onOpenVaultHostFromChat?: (hostId: string) => void;
|
||||
onOpenVaultSectionFromChat?: (section: 'notes' | 'hosts' | 'snippets') => void;
|
||||
onOpenVaultSnippetFromChat?: (snippetId: string) => void;
|
||||
/** Live workspaces, threaded to AI chats for `/chats` scope titles. */
|
||||
workspaces?: Workspace[] | null;
|
||||
}
|
||||
|
||||
const EMPTY_WORKSPACES: Workspace[] = [];
|
||||
@@ -450,6 +454,7 @@ function aiChatPanelsHostAreEqual(
|
||||
next: AIChatPanelsHostProps,
|
||||
): boolean {
|
||||
if (prev.mountedTabIds !== next.mountedTabIds) return false;
|
||||
if (prev.liveScopeTargetIds !== next.liveScopeTargetIds) return false;
|
||||
if (prev.contextsByTabId !== next.contextsByTabId) return false;
|
||||
if (prev.activeSidePanelTab !== next.activeSidePanelTab) return false;
|
||||
if (prev.pendingTerminalSelection !== next.pendingTerminalSelection) return false;
|
||||
@@ -462,6 +467,7 @@ function aiChatPanelsHostAreEqual(
|
||||
if (prev.onOpenVaultHostFromChat !== next.onOpenVaultHostFromChat) return false;
|
||||
if (prev.onOpenVaultSectionFromChat !== next.onOpenVaultSectionFromChat) return false;
|
||||
if (prev.onOpenVaultSnippetFromChat !== next.onOpenVaultSnippetFromChat) return false;
|
||||
if (prev.workspaces !== next.workspaces) return false;
|
||||
if (prev.activeTabId === next.activeTabId) return true;
|
||||
|
||||
for (let i = 0; i < prev.mountedTabIds.length; i += 1) {
|
||||
@@ -489,6 +495,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
mountedTabIds,
|
||||
activeTabId,
|
||||
activeSidePanelTab,
|
||||
liveScopeTargetIds,
|
||||
contextsByTabId,
|
||||
resolveExecutorContext,
|
||||
pendingTerminalSelection,
|
||||
@@ -500,6 +507,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
onOpenVaultHostFromChat,
|
||||
onOpenVaultSectionFromChat,
|
||||
onOpenVaultSnippetFromChat,
|
||||
workspaces,
|
||||
}) => {
|
||||
const aiConfig = useContext(AIConfigContext);
|
||||
|
||||
@@ -517,6 +525,10 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
showDraftView,
|
||||
updateDraft,
|
||||
} = aiConfig;
|
||||
const aiPanelWorkspaces = useMemo(
|
||||
() => (workspaces ?? []).map((workspace) => ({ id: workspace.id, title: workspace.title })),
|
||||
[workspaces],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTerminalSelection) return;
|
||||
@@ -588,6 +600,9 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
undoLastTurnInSession={aiConfig.undoLastTurnInSession}
|
||||
deleteSession={aiConfig.deleteSession}
|
||||
updateSessionTitle={aiConfig.updateSessionTitle}
|
||||
rebindSessionScope={aiConfig.rebindSessionScope}
|
||||
exportSessionsBackup={aiConfig.exportSessionsBackup}
|
||||
importSessionsBackup={aiConfig.importSessionsBackup}
|
||||
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
|
||||
addMessageToSession={aiConfig.addMessageToSession}
|
||||
updateLastMessage={aiConfig.updateLastMessage}
|
||||
@@ -621,7 +636,9 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
scopeHostIds={context.scopeHostIds}
|
||||
scopeLabel={context.scopeLabel}
|
||||
focusedSessionId={context.focusedSessionId}
|
||||
workspaces={aiPanelWorkspaces}
|
||||
terminalSessions={context.terminalSessions}
|
||||
liveScopeTargetIds={liveScopeTargetIds}
|
||||
resolveExecutorContext={resolveExecutorContext}
|
||||
isVisible={isVisible}
|
||||
notes={notes}
|
||||
|
||||
@@ -654,6 +654,7 @@ function SidePanelAiSlotInner({
|
||||
mountedTabIds={mountedAiTabIds}
|
||||
activeTabId={activeTabId}
|
||||
activeSidePanelTab={isVisible ? 'ai' : null}
|
||||
liveScopeTargetIds={validAIScopeTargetIds}
|
||||
contextsByTabId={aiContextsByTabId}
|
||||
resolveExecutorContext={resolveAIExecutorContext}
|
||||
pendingTerminalSelection={pendingTerminalSelectionForAI}
|
||||
@@ -665,6 +666,7 @@ function SidePanelAiSlotInner({
|
||||
onOpenVaultHostFromChat={onOpenVaultHostFromChat}
|
||||
onOpenVaultSectionFromChat={onOpenVaultSectionFromChat}
|
||||
onOpenVaultSnippetFromChat={onOpenVaultSnippetFromChat}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</AISidePanelStateRoot>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,16 @@ export type SplitHint = {
|
||||
targetSessionId?: string;
|
||||
};
|
||||
|
||||
export const invertSplitPosition = (position: SplitPosition): SplitPosition => {
|
||||
switch (position) {
|
||||
case 'left': return 'right';
|
||||
case 'right': return 'left';
|
||||
case 'top': return 'bottom';
|
||||
case 'bottom': return 'top';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const pruneWorkspaceNode = (node: WorkspaceNode, targetSessionId: string): WorkspaceNode | null => {
|
||||
if (node.type === 'pane') {
|
||||
return node.sessionId === targetSessionId ? null : node;
|
||||
|
||||
@@ -36,6 +36,79 @@ test('PTY sends no bytes while a slow shell is starting, then runs once ready',
|
||||
assert.equal(pty.writes.length, 1);
|
||||
});
|
||||
|
||||
test('macOS default zsh prompt is recognized as an idle prompt', async () => {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
const prompt = 'hexiao@JosephdeMacBook-Pro-2 ~ % ';
|
||||
trackSessionIdlePrompt(session, `Last login: Mon Oct 26\r\n${prompt}`);
|
||||
assert.equal(session.lastIdlePrompt, prompt);
|
||||
assert.equal(readyPrompt(session), prompt);
|
||||
|
||||
const job = startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(pty.writes.length, 1);
|
||||
});
|
||||
|
||||
test('zsh prompt with pending input does not become ready', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, 'hexiao@JosephdeMacBook-Pro-2 ~ % echo half-typed');
|
||||
assert.equal(readyPrompt(session), '');
|
||||
|
||||
const job = startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 50,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
t.mock.timers.tick(50);
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
|
||||
test('zsh default prompts allow directory spaces and the privileged marker', async () => {
|
||||
for (const prompt of ['alice@MacBook-Pro My Project % ', 'root@MacBook-Pro ~ # ', 'root@MacBook-Pro My Project # ']) {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, `Last login\r\n${prompt}`);
|
||||
assert.equal(getFreshIdlePrompt(session), prompt);
|
||||
assert.equal(getVisibleIdlePrompt(session), prompt);
|
||||
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true, prompt);
|
||||
assert.equal(pty.writes.length, 1, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test('zsh spaced-directory prompts with pending input stay unready', async () => {
|
||||
for (const line of ['alice@MacBook-Pro My Project % echo %', 'root@MacBook-Pro My Project # echo #']) {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, line);
|
||||
assert.equal(readyPrompt(session), '', line);
|
||||
const result = await startPtyJob(pty, 'echo forbidden', {
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 25,
|
||||
timeoutMs: 1000,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY', line);
|
||||
assert.deepEqual(pty.writes, [], line);
|
||||
}
|
||||
});
|
||||
|
||||
test('PTY readiness timeout does not send command or interrupt to a busy program', async () => {
|
||||
const pty = new TestPty();
|
||||
const job = startPtyJob(pty, 'echo OK', { getReadyPrompt:()=>'', promptWaitTimeoutMs:25, timeoutMs:1000 });
|
||||
|
||||
@@ -312,6 +312,21 @@ test("recognizes PowerShell on Linux/macOS prompts (`PS /home/alice>`)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("recognizes the macOS zsh default prompt without changing PowerShell/cmd detection", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "hexiao@JosephdeMacBook-Pro-2 ~ % "),
|
||||
"posix",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "hexiao@MacBook-Pro ~/git %", { loginShellHint: "powershell" }),
|
||||
"posix",
|
||||
);
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind("posix", "hexiao@MacBook-Pro ~ % echo half-typed"),
|
||||
"posix",
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores ANSI-coloured PowerShell prompts when detecting the shell", () => {
|
||||
assert.equal(
|
||||
resolveEffectiveShellKind(undefined, "[32mPS C:\\Users\\alice>[0m"),
|
||||
|
||||
@@ -41,6 +41,16 @@ test("prompt suffix is found after a very large flood of CRLF output", () => {
|
||||
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
|
||||
});
|
||||
|
||||
test("macOS zsh default prompt suffix matches only when the prompt is idle", () => {
|
||||
const prompt = "hexiao@JosephdeMacBook-Pro-2 ~ % ";
|
||||
assert.equal(hasExpectedPromptSuffix(`Last login\r\n${prompt}`, prompt), true);
|
||||
assert.equal(hasExpectedPromptSuffix(`Last login\r\n${prompt}echo hello`, prompt), false);
|
||||
assert.equal(
|
||||
hasExpectedPromptSuffix(`Last login\r\n${prompt}`, "hexiao@JosephdeMacBook-Pro-2 ~ %"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("live PTY CR bytes match a cached two-line idle prompt", () => {
|
||||
// Bash writes the PS1 newline as CR and ONLCR adds another, so the bytes
|
||||
// are `\r\r\n`. The idle-prompt cache keeps those as blank lines. Suffix
|
||||
|
||||
@@ -69,6 +69,13 @@ const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
|
||||
// the final `]` before `$`/`#` is the delimiter.
|
||||
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
|
||||
|
||||
// macOS ships zsh with `%n@%m %1~ %#`, which renders as
|
||||
// `user@host ~ %` / `user@host My Project %`; privileged shells end in `#`.
|
||||
// Allow spaces in the path, but keep prompt delimiters excluded so a prompt
|
||||
// followed by input (`user@host ~ % echo %`) cannot count as idle. Requiring
|
||||
// whitespace before the marker also rejects output such as `user@host 100%`.
|
||||
const ZSH_PROMPT_PATTERN = /^[^\s@]+@[^\s:]+ [^\s%#$][^%#$\r\n]* [%#]$/;
|
||||
|
||||
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
|
||||
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
|
||||
// without a prompt marker on this line. Oh My Zsh `ys` uses a different host
|
||||
@@ -103,7 +110,8 @@ function isDefaultPosixPromptLine(line) {
|
||||
const withoutEnvPrefix = normalized.replace(/^\([^()\n\r]+\)\s+/, "");
|
||||
return (
|
||||
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
|
||||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix)
|
||||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix) ||
|
||||
ZSH_PROMPT_PATTERN.test(withoutEnvPrefix)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,64 @@ test("isDefaultPosixPromptLine matches classic user@host prompts", () => {
|
||||
assert.equal(isDefaultPosixPromptLine(""), false);
|
||||
});
|
||||
|
||||
test("isDefaultPosixPromptLine matches the macOS zsh default prompt", () => {
|
||||
for (const prompt of [
|
||||
"hexiao@JosephdeMacBook-Pro-2 ~ %",
|
||||
"hexiao@JosephdeMacBook-Pro-2 ~ % ",
|
||||
"alice@MacBook-Pro ~/src % ",
|
||||
"alice@MacBook-Pro /tmp %",
|
||||
"alice@MacBook-Pro My Project % ",
|
||||
"root@MacBook-Pro ~ # ",
|
||||
"root@MacBook-Pro My Project # ",
|
||||
"(venv) alice@MacBook-Pro ~/src % ",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("zsh default prompt recognition rejects pending input and non-prompt output", () => {
|
||||
for (const line of [
|
||||
"user@host ~ % echo",
|
||||
"user@host ~ %echo",
|
||||
"user@host ~ % printf busy%",
|
||||
"user@host My Project % echo %",
|
||||
"root@host My Project # echo #",
|
||||
"user@host My Project % echo #",
|
||||
"user@host My Project % echo $",
|
||||
"user@host 100%",
|
||||
"user@host 100#",
|
||||
"progress 50 %",
|
||||
"user@host %",
|
||||
"#",
|
||||
"%",
|
||||
"progress@build 50%",
|
||||
]) {
|
||||
assert.equal(isDefaultPosixPromptLine(line), false, line);
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
|
||||
}
|
||||
});
|
||||
|
||||
test("extracts the macOS zsh default prompt and preserves trailing whitespace", () => {
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("Last login: Mon Oct 26\r\nhexiao@MacBook-Pro ~ % "),
|
||||
"hexiao@MacBook-Pro ~ % ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nhexiao@MacBook-Pro ~/git %"),
|
||||
"hexiao@MacBook-Pro ~/git %",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nalice@MacBook-Pro My Project % "),
|
||||
"alice@MacBook-Pro My Project % ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\nroot@MacBook-Pro My Project # "),
|
||||
"root@MacBook-Pro My Project # ",
|
||||
);
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nhexiao@MacBook-Pro ~ % echo"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nhexiao@MacBook-Pro ~ %\r\n"), "");
|
||||
});
|
||||
|
||||
test("isDefaultPosixPromptLine matches Alpine host:path prompts without a username", () => {
|
||||
for (const prompt of [
|
||||
"localhost:~#",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* AI Sessions Backup Bridge - durable disk mirror of the renderer's AI chat
|
||||
* session store (`netcatty_ai_sessions_v1` in localStorage).
|
||||
*
|
||||
* localStorage lives inside the Electron profile directory, so reinstalling,
|
||||
* moving or recreating the app takes the chat history with it, and in-app
|
||||
* cleanup sweeps / cap eviction can also shrink the live store. This bridge
|
||||
* keeps timestamped JSON generations under
|
||||
* `{userData}/ai-sessions-backup/` so the newest store can be restored on
|
||||
* boot when the live store is missing or empty (never on top of a non-empty
|
||||
* store — see useAIState).
|
||||
*
|
||||
* Generations are capped (default 5) and each write is atomic
|
||||
* (temp file + rename). All handlers never throw: a backup failure must not
|
||||
* break chat persistence.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
let backupDir = null;
|
||||
let electronDialog = null;
|
||||
|
||||
const BACKUP_DIR_NAME = "ai-sessions-backup";
|
||||
const FILE_PREFIX = "ai-sessions-";
|
||||
const EXPORT_PREFIX = "export-";
|
||||
const FILE_SUFFIX = ".json";
|
||||
/** Keep at most this many rolling generations (plus dated export files). */
|
||||
const MAX_GENERATIONS = 5;
|
||||
/**
|
||||
* Hard cap for a mirrored payload. localStorage itself targets ~2 MB, but when
|
||||
* cloud AI-session sync is enabled the renderer persists the full unpruned
|
||||
* history for the disk mirror — that can exceed 4 MB. Keep a generous disk
|
||||
* ceiling so synced histories still get rolling backups, while still rejecting
|
||||
* runaway payloads.
|
||||
*/
|
||||
const MAX_BACKUP_JSON_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The automatic mirror recovers from in-profile localStorage loss or corruption
|
||||
* (quota, empty or corrupt JSON, or accidental clear). Profile deletion or
|
||||
* recreation is out of scope; use the manual export/import buttons for that.
|
||||
*/
|
||||
function ensureBackupDir() {
|
||||
if (backupDir) return backupDir;
|
||||
try {
|
||||
let userDataPath = null;
|
||||
try {
|
||||
const { app } = require("electron");
|
||||
userDataPath = app?.getPath?.("userData") ?? null;
|
||||
} catch {
|
||||
userDataPath = null;
|
||||
}
|
||||
if (!userDataPath) return null;
|
||||
backupDir = path.join(userDataPath, BACKUP_DIR_NAME);
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
return backupDir;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listGenerationFiles(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((file) => file.startsWith(FILE_PREFIX) && file.endsWith(FILE_SUFFIX))
|
||||
.sort()
|
||||
.reverse(); // timestamped names sort lexicographically newest-first
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function listExportFiles(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((file) => file.startsWith(EXPORT_PREFIX) && file.endsWith(FILE_SUFFIX))
|
||||
.sort()
|
||||
.reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function timestampToken(now = new Date()) {
|
||||
return `${now.toISOString().replace(/[-:]/g, "").replace(".", "-")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one generation atomically (temp + rename) and prune older ones.
|
||||
* Returns { ok, file } and never throws.
|
||||
*/
|
||||
function writeGeneration(json) {
|
||||
const dir = ensureBackupDir();
|
||||
if (!dir) return { ok: false, error: "no-backup-dir" };
|
||||
if (typeof json !== "string" || json.length === 0) {
|
||||
return { ok: false, error: "invalid-json" };
|
||||
}
|
||||
if (Buffer.byteLength(json, "utf-8") > MAX_BACKUP_JSON_BYTES) {
|
||||
return { ok: false, error: "payload-too-large" };
|
||||
}
|
||||
|
||||
const file = `${FILE_PREFIX}${timestampToken()}${FILE_SUFFIX}`;
|
||||
const filePath = path.join(dir, file);
|
||||
const tmpPath = `${filePath}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, json, "utf-8");
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { ok: false, error: String(error) };
|
||||
}
|
||||
|
||||
// Keep only the newest MAX_GENERATIONS generations.
|
||||
const generations = listGenerationFiles(dir);
|
||||
for (const stale of generations.slice(MAX_GENERATIONS)) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(dir, stale));
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return { ok: true, file };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the newest usable session array. Invalid generations are skipped and
|
||||
* older ones are tried, so one bad write never blocks a restore.
|
||||
*/
|
||||
function readLatestGeneration() {
|
||||
const dir = ensureBackupDir();
|
||||
if (!dir) return { ok: false };
|
||||
for (const file of listGenerationFiles(dir)) {
|
||||
try {
|
||||
const json = fs.readFileSync(path.join(dir, file), "utf-8");
|
||||
const parsed = JSON.parse(json);
|
||||
// Structurally unusable payloads can fall back to an older session array.
|
||||
if (!Array.isArray(parsed)) continue;
|
||||
// An empty array is an intentional wipe and must not resurrect older chats.
|
||||
if (parsed.length === 0) return { ok: true, json, file };
|
||||
if (!parsed.some((entry) => (
|
||||
entry !== null && typeof entry === "object"
|
||||
&& typeof entry.id === "string"
|
||||
&& typeof entry.title === "string"
|
||||
&& typeof entry.agentId === "string"
|
||||
&& entry.scope !== null && typeof entry.scope === "object"
|
||||
&& typeof entry.scope.type === "string"
|
||||
&& Array.isArray(entry.messages)
|
||||
&& entry.messages.every((message) => (
|
||||
message !== null && typeof message === "object"
|
||||
&& typeof message.id === "string"
|
||||
&& typeof message.role === "string"
|
||||
&& typeof message.content === "string"
|
||||
&& typeof message.timestamp === "number" && Number.isFinite(message.timestamp)
|
||||
))
|
||||
&& typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
|
||||
))) continue;
|
||||
return { ok: true, json, file };
|
||||
} catch {
|
||||
// try the next older generation
|
||||
}
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
async function exportToFile(json) {
|
||||
const dir = ensureBackupDir();
|
||||
if (!dir) return { success: false, error: "no-backup-dir" };
|
||||
if (typeof json !== "string" || json.length === 0) {
|
||||
return { success: false, error: "invalid-json" };
|
||||
}
|
||||
try {
|
||||
let result;
|
||||
if (electronDialog?.showSaveDialog) {
|
||||
result = await electronDialog.showSaveDialog({
|
||||
defaultPath: `${EXPORT_PREFIX}${timestampToken()}${FILE_SUFFIX}`,
|
||||
filters: [
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
} else {
|
||||
// Fallback when no dialog is available (tests / unusual hosts): fixed
|
||||
// path next to the rolling generations.
|
||||
return await writeExportFixed(dir, json);
|
||||
}
|
||||
await fs.promises.writeFile(result.filePath, json, "utf-8");
|
||||
return { success: true, filePath: result.filePath };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExportFixed(dir, json) {
|
||||
const filePath = path.join(dir, `${EXPORT_PREFIX}${timestampToken()}${FILE_SUFFIX}`);
|
||||
try {
|
||||
await fs.promises.writeFile(filePath, json, "utf-8");
|
||||
return { success: true, filePath };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function importFromFile() {
|
||||
try {
|
||||
if (electronDialog?.showOpenDialog) {
|
||||
const result = await electronDialog.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
const json = await fs.promises.readFile(result.filePaths[0], "utf-8");
|
||||
return { success: true, json };
|
||||
}
|
||||
// No dialog available: import the newest fixed-path export instead of
|
||||
// failing outright.
|
||||
const dir = ensureBackupDir();
|
||||
if (!dir) return { success: false, error: "no-backup-dir" };
|
||||
const exports = listExportFiles(dir);
|
||||
if (exports.length === 0) return { success: false, error: "no-export-file" };
|
||||
const json = await fs.promises.readFile(path.join(dir, exports[0]), "utf-8");
|
||||
return { success: true, json };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:aiSessionsBackup:write", (_event, payload) =>
|
||||
writeGeneration(payload?.json));
|
||||
ipcMain.handle("netcatty:aiSessionsBackup:readLatest", () => readLatestGeneration());
|
||||
ipcMain.handle("netcatty:aiSessionsBackup:export", (_event, payload) =>
|
||||
exportToFile(payload?.json));
|
||||
ipcMain.handle("netcatty:aiSessionsBackup:import", () => importFromFile());
|
||||
}
|
||||
|
||||
const __internals = {
|
||||
BACKUP_DIR_NAME,
|
||||
FILE_PREFIX,
|
||||
EXPORT_PREFIX,
|
||||
MAX_GENERATIONS,
|
||||
MAX_BACKUP_JSON_BYTES,
|
||||
listGenerationFiles,
|
||||
listExportFiles,
|
||||
timestampToken,
|
||||
writeGeneration,
|
||||
readLatestGeneration,
|
||||
writeExportFixed,
|
||||
// Tests point the bridge at a temp directory without Electron.
|
||||
_setBackupDir(dir) {
|
||||
backupDir = dir;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
init(deps) {
|
||||
electronDialog = deps?.electronModule?.dialog ?? null;
|
||||
ensureBackupDir();
|
||||
},
|
||||
registerHandlers,
|
||||
__internals,
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const bridge = require("./aiSessionsBackupBridge.cjs");
|
||||
const session = { id: "s1", title: "Session", agentId: "agent", scope: { type: "global" }, messages: [], updatedAt: 1 };
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ai-backup-"));
|
||||
}
|
||||
|
||||
test("writeGeneration writes atomically and rotates generations to the cap", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const { MAX_GENERATIONS } = bridge.__internals;
|
||||
for (let i = 0; i < MAX_GENERATIONS + 2; i++) {
|
||||
const result = bridge.__internals.writeGeneration(JSON.stringify({ gen: i }));
|
||||
assert.equal(result.ok, true);
|
||||
// Small delay so timestamped names strictly increase.
|
||||
const stamp = bridge.__internals.timestampToken();
|
||||
while (bridge.__internals.timestampToken() === stamp) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
||||
}
|
||||
}
|
||||
const generations = bridge.__internals.listGenerationFiles(dir);
|
||||
assert.equal(generations.length, MAX_GENERATIONS);
|
||||
// Newest generation wins the lexicographic sort.
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, generations[0]), "utf-8")), {
|
||||
gen: MAX_GENERATIONS + 1,
|
||||
});
|
||||
// No temp files left behind by the atomic write.
|
||||
assert.deepEqual(fs.readdirSync(dir).filter((f) => f.endsWith(".tmp")), []);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readLatestGeneration skips corrupt generations and reads the newest parseable one", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, "ai-sessions-20260101T000000-000Z.json"), "not json");
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "ai-sessions-20260102T000000-000Z.json"),
|
||||
JSON.stringify([session]),
|
||||
);
|
||||
const latest = bridge.__internals.readLatestGeneration();
|
||||
assert.equal(latest.ok, true);
|
||||
assert.deepEqual(JSON.parse(latest.json), [session]);
|
||||
// A corrupt-only directory reports failure instead of throwing.
|
||||
const corruptOnly = makeTempDir();
|
||||
bridge.__internals._setBackupDir(corruptOnly);
|
||||
try {
|
||||
fs.writeFileSync(path.join(corruptOnly, "ai-sessions-20260103T000000-000Z.json"), "{oops");
|
||||
assert.equal(bridge.__internals.readLatestGeneration().ok, false);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
fs.rmSync(corruptOnly, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readLatestGeneration skips non-array generations", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const olderFile = "ai-sessions-20260101T000000-000Z.json";
|
||||
const sessions = [session];
|
||||
fs.writeFileSync(path.join(dir, olderFile), JSON.stringify(sessions));
|
||||
fs.writeFileSync(path.join(dir, "ai-sessions-20260102T000000-000Z.json"), JSON.stringify({ sessions }));
|
||||
|
||||
const latest = bridge.__internals.readLatestGeneration();
|
||||
assert.equal(latest.ok, true);
|
||||
assert.equal(latest.file, olderFile);
|
||||
assert.deepEqual(JSON.parse(latest.json), sessions);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readLatestGeneration skips arrays without usable sessions", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const olderFile = "ai-sessions-20260101T000000-000Z.json";
|
||||
fs.writeFileSync(path.join(dir, olderFile), JSON.stringify([session]));
|
||||
fs.writeFileSync(path.join(dir, "ai-sessions-20260102T000000-000Z.json"), JSON.stringify([{ id: "bad" }]));
|
||||
|
||||
const latest = bridge.__internals.readLatestGeneration();
|
||||
assert.equal(latest.ok, true);
|
||||
assert.equal(latest.file, olderFile);
|
||||
assert.deepEqual(JSON.parse(latest.json), [session]);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readLatestGeneration treats an empty generation as an authoritative wipe", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const newestFile = "ai-sessions-20260102T000000-000Z.json";
|
||||
fs.writeFileSync(path.join(dir, "ai-sessions-20260101T000000-000Z.json"), JSON.stringify([session]));
|
||||
fs.writeFileSync(path.join(dir, newestFile), "[]");
|
||||
|
||||
const latest = bridge.__internals.readLatestGeneration();
|
||||
assert.equal(latest.ok, true);
|
||||
assert.equal(latest.file, newestFile);
|
||||
assert.deepEqual(JSON.parse(latest.json), []);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readLatestGeneration skips sessions with malformed messages", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const olderFile = "ai-sessions-20260101T000000-000Z.json";
|
||||
const newestFile = "ai-sessions-20260102T000000-000Z.json";
|
||||
fs.writeFileSync(path.join(dir, olderFile), JSON.stringify([session]));
|
||||
for (const message of [null, {}, { id: "m", role: "user", content: "hello", timestamp: null }]) {
|
||||
fs.writeFileSync(path.join(dir, newestFile), JSON.stringify([{ ...session, messages: [message] }]));
|
||||
const latest = bridge.__internals.readLatestGeneration();
|
||||
assert.equal(latest.ok, true);
|
||||
assert.equal(latest.file, olderFile);
|
||||
assert.deepEqual(JSON.parse(latest.json), [session]);
|
||||
}
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("writeGeneration rejects invalid or oversized payloads", () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
assert.equal(bridge.__internals.writeGeneration("").ok, false);
|
||||
assert.equal(bridge.__internals.writeGeneration(null).ok, false);
|
||||
const oversized = "x".repeat(bridge.__internals.MAX_BACKUP_JSON_BYTES + 1);
|
||||
assert.equal(bridge.__internals.writeGeneration(oversized).ok, false);
|
||||
assert.deepEqual(bridge.__internals.listGenerationFiles(dir), []);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("export falls back to a fixed path when no dialog is available", async () => {
|
||||
const dir = makeTempDir();
|
||||
bridge.__internals._setBackupDir(dir);
|
||||
try {
|
||||
const result = await bridge.__internals.writeExportFixed(dir, JSON.stringify([{ id: "s1" }]));
|
||||
assert.equal(result.success, true);
|
||||
assert.ok(path.basename(result.filePath).startsWith("export-"));
|
||||
assert.equal(fs.existsSync(result.filePath), true);
|
||||
} finally {
|
||||
bridge.__internals._setBackupDir(null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -439,6 +439,7 @@ function ensureTrayPanelWindow() {
|
||||
// see-through. On Windows, disable OS rounding so it does not stack under
|
||||
// the CSS radius (#2505 / Electron #46468).
|
||||
...windowsCssRoundedOverlayChromeOptions(),
|
||||
...(process.platform === "darwin" ? { visualEffectState: "active" } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload.cjs"),
|
||||
contextIsolation: true,
|
||||
@@ -450,6 +451,8 @@ function ensureTrayPanelWindow() {
|
||||
},
|
||||
});
|
||||
|
||||
require("./windowManager.cjs").applyTrayPanelBackgroundBlur(trayPanelWindow);
|
||||
|
||||
trayPanelWindow.webContents.on("console-message", (_event, level, message) => {
|
||||
// Forward renderer logs to main process output for easy debugging.
|
||||
console.log(`[TrayPanel:renderer:${level}] ${message}`);
|
||||
|
||||
@@ -432,6 +432,7 @@ function applyWindowOpacity(opacity) {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
applyWindowOpacityToWindow(settingsWindow);
|
||||
}
|
||||
applyTrayPanelBackgroundBlur(getGlobalShortcutBridge().getTrayPanelWindow?.());
|
||||
return currentWindowOpacity;
|
||||
}
|
||||
|
||||
@@ -441,6 +442,15 @@ function isWindowBackgroundBlurActive() {
|
||||
&& currentWindowOpacity < 1;
|
||||
}
|
||||
|
||||
function applyTrayPanelBackgroundBlur(win) {
|
||||
if (process.platform !== "darwin" || !win || win.isDestroyed?.()) return;
|
||||
try {
|
||||
win.setVibrancy?.(isWindowBackgroundBlurActive() ? "hud" : null);
|
||||
} catch {
|
||||
// ignore platform-specific chrome failures
|
||||
}
|
||||
}
|
||||
|
||||
function applyWindowBackgroundChromeToWindow(win, options = {}) {
|
||||
if (!win || win.isDestroyed?.()) return;
|
||||
try {
|
||||
@@ -492,6 +502,7 @@ function applyWindowBackgroundBlur(enabled) {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
applyWindowOpacityToWindow(settingsWindow);
|
||||
}
|
||||
applyTrayPanelBackgroundBlur(getGlobalShortcutBridge().getTrayPanelWindow?.());
|
||||
return supported;
|
||||
}
|
||||
|
||||
@@ -1662,4 +1673,5 @@ module.exports = {
|
||||
clampWindowOpacity,
|
||||
applyWindowOpacity,
|
||||
applyWindowOpacityToWindow,
|
||||
applyTrayPanelBackgroundBlur,
|
||||
};
|
||||
@@ -5,7 +5,7 @@ const { createRequire } = require('node:module');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
|
||||
function loadWindowManager(platform) {
|
||||
function loadWindowManager(platform, trayWindow = null) {
|
||||
const filename = path.resolve(__dirname, '../windowManager.cjs');
|
||||
const loadedModule = { exports: {} };
|
||||
const context = vm.createContext({
|
||||
@@ -19,7 +19,11 @@ function loadWindowManager(platform) {
|
||||
`(function(exports, require, module, __filename, __dirname) { ${fs.readFileSync(filename, 'utf8')}\n})`,
|
||||
context,
|
||||
);
|
||||
load(loadedModule.exports, createRequire(filename), loadedModule, filename, path.dirname(filename));
|
||||
const originalRequire = createRequire(filename);
|
||||
const requireWithTray = (name) => name === './globalShortcutBridge.cjs'
|
||||
? { getTrayPanelWindow: () => trayWindow }
|
||||
: originalRequire(name);
|
||||
load(loadedModule.exports, requireWithTray, loadedModule, filename, path.dirname(filename));
|
||||
const handlers = new Map();
|
||||
loadedModule.exports.registerWindowHandlers({
|
||||
handle: (channel, handler) => handlers.set(channel, handler),
|
||||
@@ -35,9 +39,61 @@ function loadWindowManager(platform) {
|
||||
};
|
||||
loadedModule.exports.registerMainWindow(win);
|
||||
const invoke = (name, value) => handlers.get(`netcatty:${name}`)({}, value);
|
||||
return { invoke, state };
|
||||
return { invoke, state, windowManager: loadedModule.exports };
|
||||
}
|
||||
|
||||
test('macOS tray blur follows the saved preference and opacity without repainting its clear backing', () => {
|
||||
const materials = [];
|
||||
const backgrounds = [];
|
||||
const opacities = [];
|
||||
const trayWindow = {
|
||||
isDestroyed: () => false,
|
||||
setVibrancy: (value) => materials.push(value),
|
||||
setBackgroundColor: (value) => backgrounds.push(value),
|
||||
setOpacity: (value) => opacities.push(value),
|
||||
};
|
||||
const { invoke, windowManager } = loadWindowManager('darwin', trayWindow);
|
||||
invoke('setWindowOpacity', 0.7);
|
||||
invoke('setWindowBackgroundBlur', true);
|
||||
assert.equal(materials.at(-1), 'hud');
|
||||
invoke('setWindowOpacity', 1);
|
||||
assert.equal(materials.at(-1), null);
|
||||
invoke('setWindowOpacity', 0.85);
|
||||
assert.equal(materials.at(-1), 'hud');
|
||||
invoke('setWindowBackgroundBlur', false);
|
||||
assert.equal(materials.at(-1), null);
|
||||
assert.deepEqual(backgrounds, []);
|
||||
assert.deepEqual(opacities, []);
|
||||
assert.equal(windowManager.getAppContentWindows().includes(trayWindow), false);
|
||||
});
|
||||
|
||||
test('a tray created after blur is enabled receives the current native material', () => {
|
||||
const { invoke, windowManager } = loadWindowManager('darwin');
|
||||
invoke('setWindowOpacity', 0.7);
|
||||
invoke('setWindowBackgroundBlur', true);
|
||||
const materials = [];
|
||||
windowManager.applyTrayPanelBackgroundBlur({
|
||||
isDestroyed: () => false,
|
||||
setVibrancy: (value) => materials.push(value),
|
||||
});
|
||||
assert.deepEqual(materials, ['hud']);
|
||||
windowManager.applyTrayPanelBackgroundBlur({
|
||||
isDestroyed: () => true,
|
||||
setVibrancy: () => assert.fail('destroyed tray must not be updated'),
|
||||
});
|
||||
});
|
||||
|
||||
test('tray vibrancy is untouched on Windows and Linux', () => {
|
||||
for (const platform of ['win32', 'linux']) {
|
||||
const { invoke } = loadWindowManager(platform, {
|
||||
isDestroyed: () => false,
|
||||
setVibrancy: () => assert.fail('non-macOS tray must not use vibrancy'),
|
||||
});
|
||||
invoke('setWindowOpacity', 0.7);
|
||||
invoke('setWindowBackgroundBlur', true);
|
||||
}
|
||||
});
|
||||
|
||||
test('macOS blur keeps native opacity at one and restores normal opacity on disable', () => {
|
||||
const { invoke, state } = loadWindowManager('darwin');
|
||||
assert.equal(state.background, '#123456');
|
||||
|
||||
@@ -545,6 +545,9 @@ function createBridgeRegistrar(context) {
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
httpNetworkProxyBridge.registerHandlers(ipcMain, electronModule);
|
||||
crashLogBridge.registerHandlers(ipcMain);
|
||||
const aiSessionsBackupBridge = require("../bridges/aiSessionsBackupBridge.cjs");
|
||||
aiSessionsBackupBridge.init({ electronModule });
|
||||
aiSessionsBackupBridge.registerHandlers(ipcMain);
|
||||
vaultBackupBridge.registerHandlers(ipcMain, electronModule);
|
||||
appLockController?.registerHandlers?.(ipcMain);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { createPreloadApi } = require('./api.cjs');
|
||||
|
||||
test('backup write and export forward the serialized JSON payload unchanged', async () => {
|
||||
const calls = [];
|
||||
const api = createPreloadApi({
|
||||
ipcRenderer: { invoke: async (...args) => { calls.push(args); } },
|
||||
webUtils: {},
|
||||
});
|
||||
const payload = { json: '[]' };
|
||||
await api.aiSessionsBackupWrite(payload);
|
||||
await api.aiSessionsBackupExport(payload);
|
||||
assert.deepEqual(calls, [
|
||||
['netcatty:aiSessionsBackup:write', payload],
|
||||
['netcatty:aiSessionsBackup:export', payload],
|
||||
]);
|
||||
for (const [, argument] of calls) assert.equal(typeof argument.json, 'string');
|
||||
});
|
||||
@@ -1773,6 +1773,16 @@ function createPreloadApi(ctx) {
|
||||
openCrashLogsDir: () =>
|
||||
ipcRenderer.invoke("netcatty:crashLogs:openDir"),
|
||||
|
||||
// AI Sessions Backup (disk mirror of the AI chat session store)
|
||||
aiSessionsBackupWrite: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:aiSessionsBackup:write", payload),
|
||||
aiSessionsBackupReadLatest: () =>
|
||||
ipcRenderer.invoke("netcatty:aiSessionsBackup:readLatest"),
|
||||
aiSessionsBackupExport: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:aiSessionsBackup:export", payload),
|
||||
aiSessionsBackupImport: () =>
|
||||
ipcRenderer.invoke("netcatty:aiSessionsBackup:import"),
|
||||
|
||||
// Global Toggle Hotkey (Quake Mode)
|
||||
registerGlobalHotkey: (hotkey) =>
|
||||
ipcRenderer.invoke("netcatty:globalHotkey:register", { hotkey }),
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from 'node:test';
|
||||
import {
|
||||
buildSlashCommandItems,
|
||||
filterQuickMessages,
|
||||
getRebindSlashCommandArg,
|
||||
getRenameSlashCommandTitle,
|
||||
getSystemSlashCommand,
|
||||
isValidQuickMessageSlug,
|
||||
normalizeQuickMessageSlug,
|
||||
@@ -83,11 +85,44 @@ test('getSystemSlashCommand recognizes /compact and /stop only', () => {
|
||||
assert.equal(getSystemSlashCommand('/compress'), null);
|
||||
});
|
||||
|
||||
test('getSystemSlashCommand recognizes /rename with an argument and bare /chats', () => {
|
||||
assert.equal(getSystemSlashCommand('/rename'), 'rename');
|
||||
assert.equal(getSystemSlashCommand('/rename New title'), 'rename');
|
||||
assert.equal(getSystemSlashCommand(' /RENAME stuff '), 'rename');
|
||||
assert.equal(getSystemSlashCommand('/renamex'), null);
|
||||
assert.equal(getSystemSlashCommand('/chats'), 'chats');
|
||||
assert.equal(getSystemSlashCommand(' /Chats '), 'chats');
|
||||
assert.equal(getSystemSlashCommand('/chats now'), null);
|
||||
assert.equal(getSystemSlashCommand('/chat'), null);
|
||||
});
|
||||
|
||||
test('getRenameSlashCommandTitle extracts the argument and trims it', () => {
|
||||
assert.equal(getRenameSlashCommandTitle('/rename New title'), 'New title');
|
||||
assert.equal(getRenameSlashCommandTitle(' /RENAME spaced '), 'spaced');
|
||||
assert.equal(getRenameSlashCommandTitle('/rename'), '');
|
||||
assert.equal(getRenameSlashCommandTitle('/rename '), '');
|
||||
});
|
||||
|
||||
test('getSystemSlashCommand recognizes /rebind with an argument', () => {
|
||||
assert.equal(getSystemSlashCommand('/rebind'), 'rebind');
|
||||
assert.equal(getSystemSlashCommand('/rebind 2'), 'rebind');
|
||||
assert.equal(getSystemSlashCommand(' /Rebind 1 '), 'rebind');
|
||||
assert.equal(getSystemSlashCommand('/rebindx'), null);
|
||||
assert.equal(getSystemSlashCommand('/re'), null);
|
||||
});
|
||||
|
||||
test('getRebindSlashCommandArg extracts the dangling entry number', () => {
|
||||
assert.equal(getRebindSlashCommandArg('/rebind 2'), '2');
|
||||
assert.equal(getRebindSlashCommandArg(' /REBIND 1 '), '1');
|
||||
assert.equal(getRebindSlashCommandArg('/rebind'), '');
|
||||
assert.equal(getRebindSlashCommandArg('/rebind '), '');
|
||||
});
|
||||
|
||||
test('buildSlashCommandItems reserves system slugs over quick messages and skills', () => {
|
||||
const emptyQuery = buildSlashCommandItems([], [], '', true);
|
||||
assert.deepEqual(
|
||||
emptyQuery.filter((item) => item.kind === 'system').map((item) => item.kind === 'system' ? item.command.slug : ''),
|
||||
['compact', 'stop'],
|
||||
['compact', 'stop', 'rename', 'chats', 'rebind'],
|
||||
);
|
||||
|
||||
const withCollision = buildSlashCommandItems(
|
||||
@@ -102,7 +137,7 @@ test('buildSlashCommandItems reserves system slugs over quick messages and skill
|
||||
: item.kind === 'quickMessage' ? `qm:${item.message.slug}`
|
||||
: `sk:${item.skill.slug}`
|
||||
)),
|
||||
['sys:compact', 'sys:stop'],
|
||||
['sys:compact', 'sys:stop', 'sys:rename', 'sys:chats', 'sys:rebind'],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface UserSkillSlashOption {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type SystemSlashCommandSlug = 'stop' | 'compact';
|
||||
export type SystemSlashCommandSlug = 'stop' | 'compact' | 'rename' | 'chats' | 'rebind';
|
||||
|
||||
export interface SystemSlashCommand {
|
||||
slug: SystemSlashCommandSlug;
|
||||
@@ -124,6 +124,9 @@ export function filterUserSkillsForSlash(
|
||||
export const SYSTEM_BUILTIN_SLASH_COMMANDS: SystemSlashCommand[] = [
|
||||
{ slug: 'compact', name: 'Compact context', descriptionKey: 'ai.chat.slashCompactDesc' },
|
||||
{ slug: 'stop', name: 'Stop', descriptionKey: 'ai.chat.slashStopDesc' },
|
||||
{ slug: 'rename', name: 'Rename chat', descriptionKey: 'ai.chat.slashRenameDesc' },
|
||||
{ slug: 'chats', name: 'List chats', descriptionKey: 'ai.chat.slashChatsDesc' },
|
||||
{ slug: 'rebind', name: 'Rebind chat', descriptionKey: 'ai.chat.slashRebindDesc' },
|
||||
];
|
||||
|
||||
export function filterSystemSlashCommands(
|
||||
@@ -202,5 +205,25 @@ export function getSystemSlashCommand(input: string): SystemSlashCommandSlug | n
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
if (/^\/stop(?:\s|$)/.test(trimmed)) return 'stop';
|
||||
if (trimmed === '/compact') return 'compact';
|
||||
if (/^\/rename(?:\s|$)/.test(trimmed)) return 'rename';
|
||||
if (trimmed === '/chats') return 'chats';
|
||||
if (/^\/rebind(?:\s|$)/.test(trimmed)) return 'rebind';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the argument from a `/rebind <n>` submission (the 1-based number
|
||||
* of a dangling entry in the last `/chats` listing). Returns the empty string
|
||||
* when no argument was provided (the caller shows a usage hint).
|
||||
*/
|
||||
export function getRebindSlashCommandArg(input: string): string {
|
||||
return input.trim().replace(/^\/rebind\s*/i, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the new title from a `/rename <title>` submission. Returns the
|
||||
* empty string when no title was provided (the caller shows a usage hint).
|
||||
*/
|
||||
export function getRenameSlashCommandTitle(input: string): string {
|
||||
return input.trim().replace(/^\/rename\s*/i, '').trim();
|
||||
}
|
||||
@@ -231,6 +231,13 @@ export interface AISession {
|
||||
title: string;
|
||||
agentId: string;
|
||||
scope: AISessionScope;
|
||||
/**
|
||||
* The scope target (terminal/workspace) this chat is bound to no longer
|
||||
* exists. The record and its active pointer stay intact so `/chats` can
|
||||
* list the chat and offer a rebind; the flag clears when the chat is
|
||||
* rebound or its target becomes live again.
|
||||
*/
|
||||
scopeDangling?: boolean;
|
||||
messages: ChatMessage[];
|
||||
contextCompaction?: AISessionContextCompaction;
|
||||
externalSessionId?: string;
|
||||
|
||||
@@ -249,6 +249,51 @@ function chatSession(messageIds: string[], updatedAt = NOW): NonNullable<SyncPay
|
||||
};
|
||||
}
|
||||
|
||||
for (const overrideShrink of [false, true]) {
|
||||
test(`post-merge chat shrink is ${overrideShrink ? 'allowed with override' : 'blocked'}`, async () => {
|
||||
const encryption = installEncryptionDouble();
|
||||
try {
|
||||
const chats = Array.from({ length: 46 }, (_, index) => ({ ...chatSession(['message']), id: `chat-${index}` }));
|
||||
const local = { ...payload('base'), aiSessions: chats };
|
||||
const state = createConvergentSyncStateFromPayload(local, 'seed', NOW);
|
||||
const github = adapter(encryption.register(withConvergentSyncEnvelope(state, { syncedAt: NOW, aiSessions: chats.slice(0, 7) })));
|
||||
const baseline: ConvergentProviderBaselineV2 = {
|
||||
schemaVersion: 2, provider: 'github', remoteVersion: 1, remoteUpdatedAt: NOW,
|
||||
remoteDeviceId: 'remote', materializedPayload: local, state,
|
||||
};
|
||||
const baselines = { github: baseline };
|
||||
const subject = manager(state, { github }, baselines);
|
||||
const savedBaselines: ConvergentProviderBaselineV2[] = [];
|
||||
const saveBaseline = subject.saveConvergentProviderBaseline;
|
||||
subject.saveConvergentProviderBaseline = async (saved) => {
|
||||
savedBaselines.push(saved);
|
||||
await saveBaseline(saved);
|
||||
};
|
||||
let applied = false;
|
||||
const results = await syncConvergentProvidersUnlockedImpl.call(subject, local, {
|
||||
overrideShrink, now: () => NOW + 10, jitter: async () => {},
|
||||
applyPayload: async (_incoming, commit) => { applied = true; await commit(); },
|
||||
});
|
||||
const result = results.get('github');
|
||||
if (overrideShrink) {
|
||||
assert.equal(result?.success, true);
|
||||
assert.equal(applied, true);
|
||||
assert.ok(subject.events.some((event) => (event as { type: string }).type === 'SYNC_FORCED'));
|
||||
} else {
|
||||
assert.equal(result?.success, false);
|
||||
assert.equal(result?.shrinkBlocked, true);
|
||||
assert.equal(result?.finding?.entityType, 'aiSessions');
|
||||
assert.equal(result?.finding?.reason, 'large-shrink');
|
||||
assert.equal(subject.state.syncState, 'BLOCKED');
|
||||
assert.equal(github.uploads, 0);
|
||||
assert.equal(applied, false);
|
||||
assert.deepEqual(savedBaselines, []);
|
||||
assert.equal(baselines.github, baseline);
|
||||
}
|
||||
} finally { encryption.restore(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('chat-only convergent changes merge both transcripts, apply them, and remain idle afterwards', async () => {
|
||||
const encryption = installEncryptionDouble();
|
||||
try {
|
||||
|
||||
@@ -268,16 +268,22 @@ async function saveVerifiedBaseline(
|
||||
});
|
||||
}
|
||||
|
||||
async function markProviderVerified(
|
||||
manager: any,
|
||||
function markProviderVerified(
|
||||
runtime: ProviderRuntime,
|
||||
decoded: DecodedRemote,
|
||||
): Promise<void> {
|
||||
): void {
|
||||
runtime.verifiedState = decoded.state;
|
||||
runtime.verifiedFile = decoded.file;
|
||||
runtime.verifiedPayload = decoded.payload;
|
||||
runtime.latestRemotePayload = decoded.payload;
|
||||
runtime.error = undefined;
|
||||
}
|
||||
|
||||
async function persistProviderVerification(
|
||||
manager: any,
|
||||
runtime: ProviderRuntime,
|
||||
decoded: DecodedRemote,
|
||||
): Promise<void> {
|
||||
await saveVerifiedBaseline(manager, runtime, decoded);
|
||||
|
||||
const connection = manager.state.providers[runtime.provider];
|
||||
@@ -520,6 +526,37 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
}
|
||||
}
|
||||
|
||||
let chatShrinkForced = false;
|
||||
const blockChatShrink = (aiSessions: SyncPayload['aiSessions'], remotes: ProviderRuntime[]): boolean => {
|
||||
if (aiSessions === undefined || strategy === 'preferCloud' || isPreserveDeletionsStrategy(strategy)) return false;
|
||||
const outgoing = { ...inputPayload, aiSessions };
|
||||
// Keep this gate scoped to chats; the vault has its own pre-merge guard.
|
||||
const chatReference = (reference: SyncPayload | null | undefined) => reference
|
||||
? { ...outgoing, aiSessions: reference.aiSessions }
|
||||
: null;
|
||||
const checks = [detectSuspiciousShrink(outgoing, inputPayload, null), ...remotes.map((runtime) =>
|
||||
detectSuspiciousShrink(outgoing, chatReference(cyclePayloadBaseByProvider.get(runtime.provider)),
|
||||
chatReference(runtime.latestRemotePayload ?? runtime.verifiedPayload)))];
|
||||
const finding = checks.find((check) => check.suspicious);
|
||||
if (!finding || !finding.suspicious) return false;
|
||||
if (options.overrideShrink) {
|
||||
if (!chatShrinkForced) {
|
||||
for (const provider of providers) this.emit({ type: 'SYNC_FORCED', provider, finding });
|
||||
chatShrinkForced = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.state.syncState = 'BLOCKED';
|
||||
this.state.lastShrinkFinding = finding;
|
||||
for (const provider of providers) {
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||||
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding });
|
||||
results.set(provider, { success: false, provider, action: 'none', shrinkBlocked: true, finding });
|
||||
}
|
||||
this.notifyStateChange();
|
||||
return true;
|
||||
};
|
||||
|
||||
for (let round = 0; round < maxRounds && usable.length > 0; round += 1) {
|
||||
if (round > 0) await jitter(round);
|
||||
|
||||
@@ -560,7 +597,7 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
// The provider already contains the complete joined state. Treat the
|
||||
// preflight read as verification instead of creating a new encrypted
|
||||
// cloud revision for a no-op runtime check.
|
||||
await markProviderVerified(this, runtime, decoded);
|
||||
markProviderVerified(runtime, decoded);
|
||||
preflightVerified.set(runtime.provider, decoded);
|
||||
} catch (error) {
|
||||
runtime.error = errorMessage(error);
|
||||
@@ -637,6 +674,7 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
return providerSidecarMismatch(runtime);
|
||||
});
|
||||
const aiSessions = mergeRuntimeAiSessions(inputPayload, usable.filter((runtime) => !runtime.error), cyclePayloadBaseByProvider, strategy);
|
||||
if (blockChatShrink(aiSessions, usable)) return results;
|
||||
const providerChatMismatch = (runtime: ProviderRuntime) => {
|
||||
const decoded = preflightVerified.get(runtime.provider);
|
||||
return !decoded || !chatPayloadsEqual(decoded.payload, aiSessions);
|
||||
@@ -696,7 +734,7 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
if (!chatPayloadsEqual(decoded.payload, aiSessions)) {
|
||||
throw new Error('Remote verification did not retain the expected chat history');
|
||||
}
|
||||
await markProviderVerified(this, runtime, decoded);
|
||||
markProviderVerified(runtime, decoded);
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
runtime.error = errorMessage(error);
|
||||
@@ -722,7 +760,7 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
&& !runtime.error
|
||||
&& versionVectorDominates(runtime.verifiedState.vector, canonical.vector)
|
||||
));
|
||||
const hasSuccess = successfulRuntimes.length > 0;
|
||||
let hasSuccess = successfulRuntimes.length > 0;
|
||||
const {
|
||||
mergePluginSyncSidecarsThreeWay: mergeSidecarsThreeWayFinal,
|
||||
} = await import('../../../domain/pluginSyncSidecar');
|
||||
@@ -755,6 +793,17 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
};
|
||||
const finalAiSessions = mergeRuntimeAiSessions(inputPayload, successfulRuntimes, cyclePayloadBaseByProvider, strategy);
|
||||
if (finalAiSessions !== undefined) mergedPayload.aiSessions = finalAiSessions;
|
||||
if (blockChatShrink(finalAiSessions, runtimes)) return results;
|
||||
await Promise.all(successfulRuntimes.map(async (runtime) => {
|
||||
try {
|
||||
await persistProviderVerification(this, runtime, {
|
||||
state: runtime.verifiedState!, file: runtime.verifiedFile!, payload: runtime.verifiedPayload!,
|
||||
});
|
||||
} catch (error) {
|
||||
runtime.error = errorMessage(error);
|
||||
}
|
||||
}));
|
||||
hasSuccess = successfulRuntimes.some((runtime) => !runtime.error);
|
||||
const localPayloadChanged = !cloudSyncPayloadsEqual(inputPayload, mergedPayload)
|
||||
|| JSON.stringify(inputPayload.pluginSidecars ?? null)
|
||||
!== JSON.stringify(mergedPayload.pluginSidecars ?? null);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Verify AI Tool Access
|
||||
|
||||
The fixture replaces IPC in the real settings renderer. It records mutations in
|
||||
`window.__aiToolQa.calls`. It never starts MCP or HTTP, writes client configuration,
|
||||
or creates a real token. Paths and tokens are synthetic.
|
||||
|
||||
Use a fresh browser context or a disposable Electron profile. The initializer
|
||||
clears that context's local storage. Never inject it into a personal profile.
|
||||
|
||||
## Browser interaction checks
|
||||
|
||||
Start Vite with `npx vite --host 127.0.0.1`. In a runner with Playwright and Chromium,
|
||||
run `node scripts/fixtures/ai-tool-access/browser-qa.cjs`. Set `CHROMIUM_PATH` if the
|
||||
browser binary is elsewhere. `NETCATTY_QA_URL` defaults to `http://127.0.0.1:5173`.
|
||||
`NETCATTY_QA_OUTPUT` selects the directory for browser screenshots.
|
||||
|
||||
These checks use the production settings components and theme stylesheet.
|
||||
Browser screenshots are not native Electron acceptance.
|
||||
|
||||
## Native screenshot fixture
|
||||
|
||||
Inject `installAiToolAccessFixture` from `bridge.cjs` into the main world before
|
||||
loading `/#/settings` in a disposable Electron window. Do not launch Netcatty's
|
||||
real main-process bridges. Options are `locale`, `theme`, and `enabled`.
|
||||
The supported test locales include `zh-CN` and `en`. Themes are `dark` and `light`.
|
||||
|
||||
Open **AI**, then **Tool Access**. Capture the full settings chrome and modal edges.
|
||||
Name each original screenshot with its state, locale, theme, and commit SHA.
|
||||
|
||||
Capture these states:
|
||||
|
||||
1. Main page, top and lower sections, in Chinese and dark theme.
|
||||
2. Main page with Skills + CLI selected.
|
||||
3. MCP connection guide, including the disabled prerequisite state.
|
||||
4. Skills + CLI connection guide.
|
||||
5. Client configuration for Codex, Cursor, and Other. Include manual format
|
||||
selection and the Other setup prompt.
|
||||
6. MCP behavior with persistent and temporary availability modes.
|
||||
7. HTTP disabled, then custom wildcard binding with a client address and masked
|
||||
synthetic token. Expand connection details for the latter.
|
||||
8. Privacy configuration, then rejected input. Set
|
||||
`window.__aiToolQa.rejectPrivacy = true` to reject Apply.
|
||||
9. A narrow or short dialog with its footer visible.
|
||||
10. Main page and the densest dialog in English and light theme.
|
||||
|
||||
All created dialog types need native evidence. Preserve the original images even
|
||||
if a contact sheet is also produced. Do not reveal synthetic tokens in delivered
|
||||
screenshots; the masking behavior has separate interaction assertions.
|
||||
|
||||
## Commit semantics
|
||||
|
||||
Privacy is one draft, including both switches. Apply validates before persistence.
|
||||
Cancel, Escape, and X discard the draft. Rejected values stay in the dialog.
|
||||
Dirty drafts survive cross-window changes and show a conflict notice.
|
||||
|
||||
MCP behavior keeps immediate persistence. HTTP switches commit immediately, and
|
||||
HTTP inputs commit on blur or Enter. These dialogs explicitly say that changes
|
||||
apply immediately and use Close. Closing them does not undo saved changes.
|
||||
Client registration and token regeneration require their own explicit actions.
|
||||
|
||||
## HTTP failure states
|
||||
|
||||
Set `window.__aiToolQa.rejectHttp = true` to return a failed HTTP configuration
|
||||
result, or `throwHttp = true` to throw instead. For a failure after runtime
|
||||
polling, also set `holdHttp = true`, edit a port, and wait for a poll. Release the
|
||||
pending write with `window.__aiToolQa.pendingHttp.splice(0).forEach(resolve => resolve())`.
|
||||
Set `holdHttp = false` before retrying. These controls affect the in-memory stub.
|
||||
|
||||
`rejectRotate` and `throwRotate` cover explicit token regeneration failures.
|
||||
Configuration and rotation failures appear in the HTTP dialog and overview.
|
||||
Retry resends the saved configuration or repeats the explicit rotation action.
|
||||
Clipboard errors appear in their active dialog; the selectable preview remains.
|
||||
@@ -0,0 +1,62 @@
|
||||
// Test-only IPC replacement. Inject BEFORE loading the real settings renderer.
|
||||
// This function is self-contained so Playwright and an isolated Electron preload
|
||||
// can both serialize it. Every service mutation is synthetic and recorded.
|
||||
function installAiToolAccessFixture(options = {}) {
|
||||
if (location.hostname !== '127.0.0.1' && location.hostname !== 'localhost') throw new Error('QA fixture requires a loopback origin');
|
||||
const calls = [];
|
||||
const qa = { calls, rejectPrivacy: false, rejectHttp: false, throwHttp: false, holdHttp: false, pendingHttp: [], rejectRotate: false, throwRotate: false, holdStatus: false, pending: [], holdClients: options.holdClients || false, pendingClients: [] };
|
||||
window.__aiToolQa = qa;
|
||||
localStorage.clear();
|
||||
localStorage.setItem('netcatty_ui_language_v1', options.locale || 'en');
|
||||
localStorage.setItem('netcatty_theme_v1', options.theme || 'dark');
|
||||
localStorage.setItem('netcatty_ai_external_mcp_enabled_v1', String(options.enabled ?? true));
|
||||
localStorage.setItem('netcatty_ai_external_mcp_mode_v1', 'persistent');
|
||||
let status = {
|
||||
ok: true, enabled: options.enabled ?? true, state: options.enabled === false ? 'disabled' : 'running',
|
||||
exposedSessionCount: 2, permissionMode: 'confirm',
|
||||
launcherPath: '/fixture/netcatty/launcher', discoveryPath: '/fixture/netcatty/discovery.json',
|
||||
http: { enabled: false, listening: false, port: 47391, listenAddress: '127.0.0.1', clientHost: '', url: 'http://127.0.0.1:47391/mcp', bearerToken: null },
|
||||
};
|
||||
const clients = Object.fromEntries(['Codex', 'Claude', 'Grok'].map((name) => [name, { ok: true, state: 'not_configured', launcherPath: status.launcherPath }]));
|
||||
const record = (name, fn) => async (...args) => { calls.push({ name, args }); return fn(...args); };
|
||||
const applyConfig = (config) => {
|
||||
if (config.redaction && qa.rejectPrivacy) return { ok: false, error: 'Fixture: invalid CIDR or RE2 pattern.' };
|
||||
if ('httpEnabled' in config && qa.throwHttp) throw new Error('Fixture: HTTP configuration threw.');
|
||||
if (config.rotateHttpToken && qa.throwRotate) throw new Error('Fixture: token rotation threw.');
|
||||
if (config.rotateHttpToken && qa.rejectRotate) return { ok: false, error: 'Fixture: token rotation unavailable.' };
|
||||
if ('httpEnabled' in config && qa.rejectHttp) return { ...structuredClone(status), ok: false, error: 'Fixture: port unavailable.' };
|
||||
if ('httpEnabled' in config) {
|
||||
status.http = { ...status.http, enabled: config.httpEnabled, listening: config.httpEnabled && status.enabled,
|
||||
port: config.httpPort, listenAddress: config.httpAllowCustomBind ? config.httpListenAddress : '127.0.0.1',
|
||||
clientHost: config.httpClientHost, bearerToken: config.httpEnabled ? 'SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL' : null };
|
||||
status.http.url = `http://${config.httpClientHost || '127.0.0.1'}:${config.httpPort}/mcp`;
|
||||
}
|
||||
if (config.rotateHttpToken) status.http.bearerToken = 'SYNTHETIC_ROTATED_FIXTURE_TOKEN';
|
||||
return structuredClone(status);
|
||||
};
|
||||
const bridge = {
|
||||
getAppLockRuntimeState: async () => ({ initialized: true, locked: false, reason: null, version: 1 }),
|
||||
externalMcpGetStatus: record('status', () => qa.holdStatus ? new Promise((resolve) => qa.pending.push(() => resolve(structuredClone(status)))) : structuredClone(status)),
|
||||
externalMcpSetEnabled: record('enable', (enabled) => { status.enabled = enabled; status.state = enabled ? 'running' : 'disabled'; return structuredClone(status); }),
|
||||
externalMcpSetConfig: record('config', (config) => {
|
||||
if ('httpEnabled' in config && qa.holdHttp) {
|
||||
return new Promise((resolve, reject) => qa.pendingHttp.push(() => {
|
||||
try { resolve(applyConfig(config)); } catch (error) { reject(error); }
|
||||
}));
|
||||
}
|
||||
return applyConfig(config);
|
||||
}),
|
||||
externalMcpGetUniversalSetupPrompt: async () => ({ ok: true, prompt: 'Synthetic setup prompt: register the fixture launcher as netcatty-external.' }),
|
||||
aiSkillsCliGetInvocation: async () => ({ ok: true, skillPath: '/fixture/netcatty/skills/SKILL.md', commandPrefix: 'netcatty-tool-cli' }),
|
||||
aiUserSkillsGetStatus: async () => ({ ok: true, directoryPath: '/fixture/netcatty/skills', readyCount: 0, warningCount: 0, skills: [] }),
|
||||
aiResolveCli: async () => ({ installed: false, available: false }),
|
||||
aiCodexGetIntegrationStatus: async () => ({ ok: true, available: false }),
|
||||
aiCodexAppServerGetStatus: async () => ({ ok: true, available: false }),
|
||||
};
|
||||
for (const name of ['Codex', 'Claude', 'Grok']) {
|
||||
bridge[`externalMcp${name}GetStatus`] = record(`${name}Status`, () => qa.holdClients && name === 'Codex' ? new Promise((resolve) => qa.pendingClients.push(() => resolve(structuredClone(clients[name])))) : structuredClone(clients[name]));
|
||||
bridge[`externalMcp${name}Add`] = record(`${name}Add`, () => { clients[name].state = 'configured'; return structuredClone(clients[name]); });
|
||||
}
|
||||
Object.defineProperty(window, 'netcatty', { value: bridge, configurable: true });
|
||||
}
|
||||
module.exports = { installAiToolAccessFixture };
|
||||
@@ -0,0 +1,173 @@
|
||||
// Run against Vite in a fresh browser context. Requires Playwright in the runner.
|
||||
// Screenshots from this script are browser evidence, not native Electron acceptance.
|
||||
const assert = require('node:assert/strict');
|
||||
const { chromium } = require('playwright');
|
||||
const { installAiToolAccessFixture } = require('./bridge.cjs');
|
||||
const base = process.env.NETCATTY_QA_URL || 'http://127.0.0.1:5173';
|
||||
const output = process.env.NETCATTY_QA_OUTPUT || '/tmp/netcatty-ai-tool-qa';
|
||||
const { mkdirSync } = require('node:fs');
|
||||
mkdirSync(output, { recursive: true });
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true, args: ['--no-sandbox'] });
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 1200, height: 900 }, permissions: ['clipboard-read', 'clipboard-write'] });
|
||||
await context.addInitScript(installAiToolAccessFixture, { locale: 'en', theme: 'dark', holdClients: true });
|
||||
await context.route('**/*', (route) => new URL(route.request().url()).origin === new URL(base).origin ? route.continue() : route.abort());
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(`${base}/#/settings`);
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await page.getByRole('tab', { name: 'Tool Access', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Configure privacy', exact: true }).waitFor();
|
||||
const mcpHeader = page.locator('[data-settings-anchor="ai-external-mcp"]').locator(':scope > div').first();
|
||||
const mcpTitleBox = await mcpHeader.getByRole('heading', { name: 'External MCP', exact: true }).boundingBox();
|
||||
const mcpPill = mcpHeader.getByRole('status');
|
||||
const mcpPillBox = await mcpPill.boundingBox();
|
||||
assert.ok(Math.abs(mcpTitleBox.y + mcpTitleBox.height / 2 - mcpPillBox.y - mcpPillBox.height / 2) <= 1, 'MCP title and status pill share a vertical center');
|
||||
assert.equal(await mcpPill.getAttribute('tabindex'), null);
|
||||
assert.equal(await mcpPill.evaluate((el) => getComputedStyle(el).borderTopWidth), '0px');
|
||||
const httpHeader = page.locator('[data-settings-anchor="ai-external-mcp-http"]').locator(':scope > div').first();
|
||||
assert.match(await httpHeader.getByRole('status').innerText(), /Not listening/);
|
||||
|
||||
const dialog = () => page.getByRole('dialog');
|
||||
const close = async () => { await dialog().getByRole('button', { name: 'Close', exact: true }).last().click(); await dialog().waitFor({ state: 'hidden' }); };
|
||||
const mutations = () => page.evaluate(() => window.__aiToolQa.calls.filter((call) => ['config', 'enable', 'CodexAdd', 'ClaudeAdd', 'GrokAdd'].includes(call.name)));
|
||||
const initial = (await mutations()).length;
|
||||
await page.screenshot({ path: `${output}/main-en-dark.png` });
|
||||
await page.getByRole('button', { name: 'View setup prompt' }).click();
|
||||
assert.match(await dialog().innerText(), /fixture\/netcatty\/launcher/);
|
||||
await dialog().getByRole('button', { name: 'Copy', exact: true }).click();
|
||||
assert.match(await page.evaluate(() => navigator.clipboard.readText()), /get_environment/);
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await page.getByRole('button', { name: 'View setup prompt' }).evaluate((el) => el === document.activeElement), true);
|
||||
await page.getByRole('button', { name: 'Configure behavior' }).click();
|
||||
assert.match(await dialog().innerText(), /immediately/);
|
||||
assert.equal(await dialog().getByRole('spinbutton', { name: 'Idle timeout', exact: true }).count(), 0);
|
||||
await dialog().getByRole('combobox').nth(1).click();
|
||||
await page.getByRole('option', { name: 'Temporary', exact: true }).click();
|
||||
await dialog().getByRole('spinbutton', { name: 'Idle timeout', exact: true }).waitFor();
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Configure clients' }).click();
|
||||
assert.equal(await dialog().getByRole('button', { name: 'Add to Codex', exact: true }).isEnabled(), false);
|
||||
await page.waitForTimeout(3200); // At least one quiet runtime poll must overlap client discovery.
|
||||
await page.evaluate(() => { window.__aiToolQa.holdClients = false; window.__aiToolQa.pendingClients.splice(0).forEach((resolve) => resolve()); });
|
||||
await dialog().getByRole('button', { name: 'Add to Codex', exact: true }).and(page.locator(':enabled')).waitFor();
|
||||
const beforeAdd = (await mutations()).length;
|
||||
await dialog().getByRole('tab', { name: 'Cursor', exact: true }).click();
|
||||
await dialog().getByRole('tab', { name: 'Other', exact: true }).click();
|
||||
assert.equal((await mutations()).length, beforeAdd, 'opening and switching clients must not install anything');
|
||||
await dialog().getByText('Manual setup', { exact: true }).click();
|
||||
await dialog().getByLabel('Format', { exact: true }).selectOption('prompt');
|
||||
assert.match(await dialog().innerText(), /Synthetic setup prompt/);
|
||||
await dialog().getByRole('tab', { name: 'Codex', exact: true }).click();
|
||||
await dialog().getByRole('button', { name: 'Add to Codex', exact: true }).click();
|
||||
await dialog().getByText('Configured', { exact: true }).waitFor();
|
||||
assert.equal((await page.evaluate(() => window.__aiToolQa.calls.filter((call) => call.name === 'CodexAdd'))).length, 1);
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Configure HTTP' }).click();
|
||||
assert.match(await dialog().innerText(), /immediately/);
|
||||
assert.equal(await dialog().getByRole('button', { name: 'Regenerate token', exact: true }).isVisible(), false);
|
||||
const port = dialog().getByRole('spinbutton', { name: 'Port', exact: true });
|
||||
const beforeInvalid = (await mutations()).length;
|
||||
await port.fill('99');
|
||||
await port.blur();
|
||||
await dialog().getByRole('alert').waitFor();
|
||||
assert.equal(await port.inputValue(), '99');
|
||||
assert.equal((await mutations()).length, beforeInvalid);
|
||||
await port.fill('47400');
|
||||
await port.blur();
|
||||
assert.equal((await mutations()).length, beforeInvalid + 1);
|
||||
await page.evaluate(() => { window.__aiToolQa.rejectHttp = true; window.__aiToolQa.holdHttp = true; });
|
||||
await port.fill('47401');
|
||||
await port.blur();
|
||||
await dialog().getByText('Applying HTTP configuration...', { exact: true }).waitFor();
|
||||
await page.waitForTimeout(3200); // A quiet poll completes while the write is still pending.
|
||||
await page.evaluate(() => { window.__aiToolQa.holdHttp = false; window.__aiToolQa.pendingHttp.splice(0).forEach((resolve) => resolve()); });
|
||||
await dialog().getByRole('alert').filter({ hasText: 'Fixture: port unavailable.' }).waitFor();
|
||||
assert.equal(await port.inputValue(), '47401', 'rejected HTTP settings retain input');
|
||||
await page.screenshot({ path: `${output}/http-error-returned-en-dark.png` });
|
||||
await page.waitForTimeout(3200); // Runtime polling must not erase operation failures.
|
||||
assert.match(await dialog().innerText(), /Fixture: port unavailable/);
|
||||
await page.evaluate(() => { window.__aiToolQa.rejectHttp = false; window.__aiToolQa.throwHttp = true; });
|
||||
await dialog().getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await dialog().getByRole('alert').filter({ hasText: 'Fixture: HTTP configuration threw.' }).waitFor();
|
||||
assert.equal(await port.inputValue(), '47401', 'thrown HTTP configuration retains input');
|
||||
await page.screenshot({ path: `${output}/http-error-thrown-en-dark.png` });
|
||||
await page.evaluate(() => { window.__aiToolQa.throwHttp = false; });
|
||||
await dialog().getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await dialog().getByRole('alert').waitFor({ state: 'hidden' });
|
||||
await dialog().getByRole('switch', { name: 'Enable HTTP', exact: true }).click();
|
||||
await dialog().getByRole('switch', { name: 'Listen on another address', exact: true }).last().click();
|
||||
await dialog().getByRole('textbox', { name: 'Listen address', exact: true }).waitFor();
|
||||
await dialog().getByRole('textbox', { name: 'Client address', exact: true }).fill('192.0.2.10');
|
||||
await dialog().getByRole('textbox', { name: 'Client address', exact: true }).blur();
|
||||
await dialog().getByText('Connection details', { exact: true }).click();
|
||||
assert.equal(await dialog().locator('pre').filter({ hasText: 'SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL' }).count(), 0, 'all token-bearing previews start masked');
|
||||
await page.screenshot({ path: `${output}/http-masked-en-dark.png` });
|
||||
await dialog().getByRole('button', { name: 'Show secret', exact: true }).first().click();
|
||||
assert.equal(await dialog().locator('pre').filter({ hasText: 'SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL' }).count(), 1);
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Configure HTTP' }).click();
|
||||
await dialog().getByText('Connection details', { exact: true }).click();
|
||||
assert.equal(await dialog().locator('pre').filter({ hasText: 'SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL' }).count(), 0);
|
||||
assert.equal(await port.inputValue(), '47401');
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Configure HTTP' }).click();
|
||||
await dialog().getByText('Connection details', { exact: true }).click();
|
||||
await page.evaluate(() => { window.__aiToolQa.rejectRotate = true; });
|
||||
await dialog().getByRole('button', { name: 'Regenerate', exact: true }).click();
|
||||
await dialog().getByRole('alert').filter({ hasText: 'Fixture: token rotation unavailable.' }).waitFor();
|
||||
await page.evaluate(() => { window.__aiToolQa.rejectRotate = false; window.__aiToolQa.throwRotate = true; });
|
||||
await dialog().getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await dialog().getByRole('alert').filter({ hasText: 'Fixture: token rotation threw.' }).waitFor();
|
||||
await page.evaluate(() => { window.__aiToolQa.throwRotate = false; });
|
||||
await dialog().getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await dialog().getByRole('alert').waitFor({ state: 'hidden' });
|
||||
await page.evaluate(() => {
|
||||
window.__qaWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
|
||||
navigator.clipboard.writeText = async () => { throw new Error('Fixture: clipboard unavailable'); };
|
||||
});
|
||||
await dialog().getByRole('button', { name: 'Copy', exact: true }).first().click();
|
||||
await dialog().getByRole('alert').waitFor();
|
||||
assert.match(await dialog().innerText(), /copy/i);
|
||||
await page.evaluate(() => { navigator.clipboard.writeText = window.__qaWriteText; });
|
||||
await dialog().getByRole('button', { name: 'Copy', exact: true }).first().click();
|
||||
await dialog().getByRole('alert').waitFor({ state: 'hidden' });
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Configure privacy', exact: true }).click();
|
||||
const beforeDraft = (await mutations()).length;
|
||||
await dialog().getByRole('textbox', { name: 'Additional IP ranges (CIDR)', exact: true }).fill('192.0.2.0/24');
|
||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
assert.equal((await mutations()).length, beforeDraft);
|
||||
await page.getByRole('button', { name: 'Configure privacy', exact: true }).click();
|
||||
assert.equal(await dialog().getByRole('textbox', { name: 'Additional IP ranges (CIDR)', exact: true }).inputValue(), '');
|
||||
await dialog().getByRole('textbox', { name: 'Custom regular expressions', exact: true }).fill('[');
|
||||
await page.evaluate(() => { window.__aiToolQa.rejectPrivacy = true; });
|
||||
await dialog().getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await dialog().getByRole('alert').waitFor();
|
||||
assert.equal(await dialog().getByRole('textbox', { name: 'Custom regular expressions', exact: true }).inputValue(), '[');
|
||||
await page.screenshot({ path: `${output}/privacy-error-en-dark.png` });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Search settings', exact: true }).click();
|
||||
await page.getByRole('combobox', { name: 'Search settings…', exact: true }).fill('Streamable HTTP');
|
||||
await page.getByRole('option', { name: /Streamable HTTP/ }).click();
|
||||
await dialog().getByRole('heading', { name: 'Streamable HTTP', exact: true }).waitFor();
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Search settings', exact: true }).click();
|
||||
await page.getByRole('combobox', { name: 'Search settings…', exact: true }).fill('Approval alerts');
|
||||
await page.getByRole('option', { name: /Approval alerts/ }).click();
|
||||
await dialog().getByRole('heading', { name: 'MCP behavior', exact: true }).waitFor();
|
||||
await close();
|
||||
await page.setViewportSize({ width: 640, height: 580 });
|
||||
await page.getByRole('button', { name: 'Configure behavior' }).click();
|
||||
const bounds = await dialog().boundingBox();
|
||||
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.width <= 640 && bounds.height <= 580);
|
||||
await close();
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok((await mutations()).length > initial);
|
||||
console.log('PASS: actual settings renderer; guidance/copy/focus, behavior conditionals, explicit client install, HTTP validation/immediate commit/masking/returned-and-thrown-config-and-rotation-errors/retry/copy-failure, privacy failure/cancel/reopen, narrow viewport.');
|
||||
await context.close();
|
||||
} finally { await browser.close(); }
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user