Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
071c95ab5c | ||
|
|
ec99875dec | ||
|
|
51a6b7efaa | ||
|
|
30f5346035 | ||
|
|
e0302e5f34 | ||
|
|
0425841032 | ||
|
|
156550f7eb | ||
|
|
a1648adf12 | ||
|
|
8182bd6b3c | ||
|
|
484ac5f463 | ||
|
|
98e3a6b952 | ||
|
|
f6f3147afb | ||
|
|
54b26511a1 | ||
|
|
8ef91e1266 | ||
|
|
b2689f96a4 | ||
|
|
1b23bdcf15 | ||
|
|
2e63848e0e | ||
|
|
3a748aa1aa | ||
|
|
4574f1e2b2 | ||
|
|
081b167172 | ||
|
|
a818a7004f |
146
App.tsx
146
App.tsx
@@ -18,6 +18,7 @@ import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
@@ -992,6 +993,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const addConnectionLogRef = useRef(addConnectionLog);
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const activeSidePanelTabRef = useRef<string | null>(null);
|
||||
const closeTabInFlightRef = useRef(false);
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
||||
@@ -1025,6 +1030,88 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const confirmIfBusyLocalTerminal = useCallback(
|
||||
async (sessionIds: string[]): Promise<boolean> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const localIds = sessionIds.filter((id) => {
|
||||
const s = sessions.find((x) => x.id === id);
|
||||
return s?.protocol === 'local';
|
||||
});
|
||||
const busyCommands: string[] = [];
|
||||
for (const id of localIds) {
|
||||
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
|
||||
if (children.length > 0) {
|
||||
busyCommands.push(children[0].command);
|
||||
}
|
||||
}
|
||||
if (busyCommands.length === 0) return true;
|
||||
|
||||
const primary = busyCommands[0];
|
||||
const extraCount = busyCommands.length - 1;
|
||||
const message =
|
||||
extraCount > 0
|
||||
? t('confirm.closeBusyTerminal.messageWithMore', {
|
||||
command: primary,
|
||||
count: extraCount,
|
||||
})
|
||||
: t('confirm.closeBusyTerminal.message', { command: primary });
|
||||
|
||||
const ok = await bridge?.confirmCloseBusy?.({
|
||||
command: primary,
|
||||
title: t('confirm.closeBusyTerminal.title'),
|
||||
message,
|
||||
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
|
||||
closeLabel: t('confirm.closeBusyTerminal.close'),
|
||||
});
|
||||
return ok === true;
|
||||
},
|
||||
[sessions, t],
|
||||
);
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
const closeTabsBatch = useCallback(
|
||||
async (targetIds: string[]) => {
|
||||
if (targetIds.length === 0) return;
|
||||
if (closeTabsInFlightRef.current) return;
|
||||
|
||||
// Expand workspace ids into their constituent session ids so the busy
|
||||
// probe sees every local shell that's about to be killed.
|
||||
const sessionIdsToProbe: string[] = [];
|
||||
for (const tabId of targetIds) {
|
||||
const ws = workspaces.find((w) => w.id === tabId);
|
||||
if (ws) {
|
||||
for (const s of sessions) {
|
||||
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
||||
}
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
sessionIdsToProbe.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
closeTabsInFlightRef.current = true;
|
||||
try {
|
||||
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
|
||||
if (!ok) return;
|
||||
for (const tabId of targetIds) {
|
||||
if (workspaces.find((w) => w.id === tabId)) {
|
||||
closeWorkspace(tabId);
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
closeSession(tabId);
|
||||
} else if (logViews.find((lv) => lv.id === tabId)) {
|
||||
closeLogView(tabId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
closeTabsInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSession, closeLogView],
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
|
||||
@@ -1069,18 +1156,52 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
case 'closeTab': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (currentId !== 'vault' && currentId !== 'sftp') {
|
||||
// Find if it's a session or workspace
|
||||
const session = sessions.find(s => s.id === currentId);
|
||||
if (session) {
|
||||
closeSession(currentId);
|
||||
} else {
|
||||
const workspace = workspaces.find(w => w.id === currentId);
|
||||
if (workspace) {
|
||||
closeWorkspace(currentId);
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
|
||||
const activeSidePanel = activeSidePanelTabRef.current;
|
||||
|
||||
const intent = resolveCloseIntent({
|
||||
activeTabId: currentId,
|
||||
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
|
||||
sessionForTab: session,
|
||||
activeSidePanelTab: activeSidePanel,
|
||||
focusIsInsideTerminal,
|
||||
});
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
switch (intent.kind) {
|
||||
case 'closeTerminal':
|
||||
case 'closeSingleTab': {
|
||||
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
|
||||
if (ok) closeSession(intent.sessionId);
|
||||
return;
|
||||
}
|
||||
case 'closeSidePanel': {
|
||||
closeSidePanelRef.current?.();
|
||||
return;
|
||||
}
|
||||
case 'closeWorkspace': {
|
||||
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
|
||||
const ok = await confirmIfBusyLocalTerminal(ids);
|
||||
if (ok) closeWorkspace(intent.workspaceId);
|
||||
return;
|
||||
}
|
||||
case 'noop':
|
||||
default:
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
@@ -1193,7 +1314,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab]);
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -1553,6 +1674,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
onCloseTabsBatch={closeTabsBatch}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
@@ -1684,6 +1806,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
@@ -56,6 +56,11 @@ const en: Messages = {
|
||||
'confirm.deleteHost': 'Delete Host "{name}"?',
|
||||
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
|
||||
'confirm.removeProvider': 'Remove provider "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': 'Confirm close',
|
||||
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
|
||||
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
|
||||
'confirm.closeBusyTerminal.cancel': 'Cancel',
|
||||
'confirm.closeBusyTerminal.close': 'Close',
|
||||
'dialog.createWorkspace.title': 'Create Workspace',
|
||||
'dialog.renameWorkspace.title': 'Rename workspace',
|
||||
'dialog.renameSession.title': 'Rename session',
|
||||
@@ -301,6 +306,12 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
@@ -467,6 +478,30 @@ const en: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
|
||||
|
||||
'sync.blocked.title': 'Sync paused',
|
||||
'sync.blocked.reason.bulkShrink': 'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
|
||||
'sync.blocked.reason.largeShrink': 'Would delete {lost} {entityType} from cloud.',
|
||||
'sync.blocked.detail': 'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
|
||||
'sync.blocked.restoreButton': 'Restore from local backup',
|
||||
'sync.blocked.forcePushButton': 'Force push anyway',
|
||||
|
||||
'sync.forcePush.title': 'Confirm force push',
|
||||
'sync.forcePush.body': 'You are about to remove {lost} {entityType} from the cloud. This cannot be undone. Proceed?',
|
||||
'sync.forcePush.confirm': 'Yes, push anyway',
|
||||
'sync.forcePush.cancel': 'Cancel',
|
||||
|
||||
'sync.entityType.hosts': 'hosts',
|
||||
'sync.entityType.keys': 'keys',
|
||||
'sync.entityType.identities': 'identities',
|
||||
'sync.entityType.snippets': 'snippets',
|
||||
'sync.entityType.customGroups': 'groups',
|
||||
'sync.entityType.snippetPackages': 'snippet packages',
|
||||
'sync.entityType.knownHosts': 'known-host entries',
|
||||
'sync.entityType.portForwardingRules': 'port-forwarding rules',
|
||||
'sync.entityType.groupConfigs': 'group configs',
|
||||
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
@@ -1604,6 +1639,9 @@ const en: Messages = {
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Key label',
|
||||
'keychain.edit.privateKeyRequired': 'Private key *',
|
||||
|
||||
@@ -43,6 +43,11 @@ const zhCN: Messages = {
|
||||
'confirm.deleteHost': '删除主机 "{name}"?',
|
||||
'confirm.deleteIdentity': '删除身份 "{name}"?',
|
||||
'confirm.removeProvider': '移除提供商 "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': '确认关闭',
|
||||
'confirm.closeBusyTerminal.message': '进程 "{command}" 仍在运行,关闭后会被终止。',
|
||||
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
|
||||
'confirm.closeBusyTerminal.cancel': '取消',
|
||||
'confirm.closeBusyTerminal.close': '关闭',
|
||||
'dialog.renameWorkspace.title': '重命名工作区',
|
||||
'dialog.renameSession.title': '重命名会话',
|
||||
'field.name': '名称',
|
||||
@@ -286,6 +291,30 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
|
||||
|
||||
'sync.blocked.title': '同步已暂停',
|
||||
'sync.blocked.reason.bulkShrink': '即将从云端删除 {baseCount} 条 {entityType} 中的 {lost} 条(缩减 {percent}%)。',
|
||||
'sync.blocked.reason.largeShrink': '即将从云端删除 {lost} 条 {entityType}。',
|
||||
'sync.blocked.detail': '通常是本地状态异常(钥匙串故障、数据加载不全)导致。请从本地备份恢复,如果确实要删这些条目请使用强制推送。',
|
||||
'sync.blocked.restoreButton': '从本地备份恢复',
|
||||
'sync.blocked.forcePushButton': '强制推送',
|
||||
|
||||
'sync.forcePush.title': '确认强制推送',
|
||||
'sync.forcePush.body': '你将从云端移除 {lost} 条 {entityType},此操作不可撤销。继续?',
|
||||
'sync.forcePush.confirm': '确认推送',
|
||||
'sync.forcePush.cancel': '取消',
|
||||
|
||||
'sync.entityType.hosts': '主机',
|
||||
'sync.entityType.keys': '密钥',
|
||||
'sync.entityType.identities': '身份',
|
||||
'sync.entityType.snippets': '代码片段',
|
||||
'sync.entityType.customGroups': '分组',
|
||||
'sync.entityType.snippetPackages': '片段包',
|
||||
'sync.entityType.knownHosts': '主机密钥记录',
|
||||
'sync.entityType.portForwardingRules': '端口转发规则',
|
||||
'sync.entityType.groupConfigs': '分组配置',
|
||||
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
@@ -1360,6 +1389,12 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
@@ -1612,6 +1647,9 @@ const zhCN: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
|
||||
'keychain.edit.privateKeyRequired': '私钥 *',
|
||||
|
||||
@@ -6,15 +6,38 @@ import {
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { hasMeaningfulSyncData } from './syncPayload';
|
||||
|
||||
/**
|
||||
* Snapshot the current sync data version (the integer that increments
|
||||
* on each successful cloud sync). Returns undefined when the value is
|
||||
* 0 (never synced) or unavailable, so the UI can fall back to timestamp.
|
||||
*/
|
||||
function captureCurrentSyncDataVersion(): number | undefined {
|
||||
try {
|
||||
const state = getCloudSyncManager().getState();
|
||||
const v = state.localVersion;
|
||||
return typeof v === 'number' && v > 0 ? v : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
|
||||
|
||||
export interface LocalVaultBackupPreview {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: LocalVaultBackupReason;
|
||||
/** Sync-data version at the time the snapshot was taken (the integer
|
||||
* that the CloudSyncManager increments on each successful cloud sync).
|
||||
* Undefined when the user had never synced yet, or for legacy backups
|
||||
* persisted before this field was added. */
|
||||
syncDataVersion?: number;
|
||||
/** App version transition fields, only for `app_version_change` records.
|
||||
* Kept for backward compatibility with already-persisted backups. */
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
@@ -94,6 +117,7 @@ export async function createLocalVaultBackup(
|
||||
payload: SyncPayload,
|
||||
options: {
|
||||
reason: LocalVaultBackupReason;
|
||||
syncDataVersion?: number;
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
maxCount?: number;
|
||||
@@ -118,6 +142,10 @@ export async function createLocalVaultBackup(
|
||||
const result = await bridge.createVaultBackup({
|
||||
payload,
|
||||
reason: options.reason,
|
||||
// Default to the live cloud-sync version so every new backup carries
|
||||
// it even when the caller didn't pass one explicitly. Bridge sanitizer
|
||||
// drops invalid values (non-positive / non-finite), so this is safe.
|
||||
syncDataVersion: options.syncDataVersion ?? captureCurrentSyncDataVersion(),
|
||||
sourceAppVersion: options.sourceAppVersion,
|
||||
targetAppVersion: options.targetAppVersion,
|
||||
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),
|
||||
|
||||
@@ -3,12 +3,17 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
createEmptyDraft,
|
||||
ensureDraftForScopeState,
|
||||
getDraftMutationVersionState,
|
||||
getDraftUploadGenerationState,
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
selectDraftForAgentSwitch,
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
@@ -168,6 +173,69 @@ test("ensureDraftForScopeState returns the original ref when the scope already e
|
||||
assert.equal(next, draftsByScope);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch preserves hidden draft content when leaving a populated chat session", () => {
|
||||
const currentDraft = {
|
||||
...createEmptyDraft("agent-alpha"),
|
||||
text: "keep me only if I was already drafting",
|
||||
attachments: [{ id: "file-1", filename: "note.txt", dataUrl: "", base64Data: "", mediaType: "text/plain" }],
|
||||
selectedUserSkillSlugs: ["skill-a"],
|
||||
};
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "keep me only if I was already drafting");
|
||||
assert.deepEqual(next.attachments, currentDraft.attachments);
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch resets to an empty draft when leaving a populated chat session without pending draft content", () => {
|
||||
const currentDraft = createEmptyDraft("agent-alpha");
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "");
|
||||
assert.deepEqual(next.attachments, []);
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, []);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch preserves an existing draft while only changing agent", () => {
|
||||
const currentDraft = {
|
||||
...createEmptyDraft("agent-alpha"),
|
||||
text: "unfinished prompt",
|
||||
selectedUserSkillSlugs: ["skill-a"],
|
||||
};
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", false);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "unfinished prompt");
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
|
||||
});
|
||||
|
||||
test("draft mutation version increments on every mutation for the same scope", () => {
|
||||
const scopeKey = "terminal:1";
|
||||
const initialVersion = getDraftMutationVersionState({}, scopeKey);
|
||||
const nextVersions = bumpDraftMutationVersionState({}, scopeKey);
|
||||
const finalVersions = bumpDraftMutationVersionState(nextVersions, scopeKey);
|
||||
|
||||
assert.equal(initialVersion, 0);
|
||||
assert.equal(getDraftMutationVersionState(nextVersions, scopeKey), 1);
|
||||
assert.equal(getDraftMutationVersionState(finalVersions, scopeKey), 2);
|
||||
});
|
||||
|
||||
test("draft upload generation only increments when the draft lifecycle rolls over", () => {
|
||||
const scopeKey = "terminal:1";
|
||||
const initialGeneration = getDraftUploadGenerationState({}, scopeKey);
|
||||
const nextGenerations = bumpDraftUploadGenerationState({}, scopeKey);
|
||||
const finalGenerations = bumpDraftUploadGenerationState(nextGenerations, scopeKey);
|
||||
|
||||
assert.equal(initialGeneration, 0);
|
||||
assert.equal(getDraftUploadGenerationState(nextGenerations, scopeKey), 1);
|
||||
assert.equal(getDraftUploadGenerationState(finalGenerations, scopeKey), 2);
|
||||
});
|
||||
|
||||
test("pruneTerminalScopeState removes closed terminal drafts and views only", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:closed": createEmptyDraft("agent-alpha"),
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
type DraftMutationVersionByScope = Record<string, number>;
|
||||
type DraftUploadGenerationByScope = Record<string, number>;
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
|
||||
|
||||
@@ -19,6 +21,40 @@ export function createEmptyDraft(agentId: string): AIDraft {
|
||||
};
|
||||
}
|
||||
|
||||
export function getDraftMutationVersionState(
|
||||
versionsByScope: DraftMutationVersionByScope,
|
||||
scopeKey: string,
|
||||
): number {
|
||||
return versionsByScope[scopeKey] ?? 0;
|
||||
}
|
||||
|
||||
export function bumpDraftMutationVersionState(
|
||||
versionsByScope: DraftMutationVersionByScope,
|
||||
scopeKey: string,
|
||||
): DraftMutationVersionByScope {
|
||||
return {
|
||||
...versionsByScope,
|
||||
[scopeKey]: getDraftMutationVersionState(versionsByScope, scopeKey) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDraftUploadGenerationState(
|
||||
generationsByScope: DraftUploadGenerationByScope,
|
||||
scopeKey: string,
|
||||
): number {
|
||||
return generationsByScope[scopeKey] ?? 0;
|
||||
}
|
||||
|
||||
export function bumpDraftUploadGenerationState(
|
||||
generationsByScope: DraftUploadGenerationByScope,
|
||||
scopeKey: string,
|
||||
): DraftUploadGenerationByScope {
|
||||
return {
|
||||
...generationsByScope,
|
||||
[scopeKey]: getDraftUploadGenerationState(generationsByScope, scopeKey) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePanelView(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
@@ -109,6 +145,31 @@ export function ensureDraftForScopeState(
|
||||
};
|
||||
}
|
||||
|
||||
export function selectDraftForAgentSwitch(
|
||||
currentDraft: AIDraft | null | undefined,
|
||||
agentId: string,
|
||||
startFresh: boolean,
|
||||
): AIDraft {
|
||||
const hasPendingDraftContent = Boolean(
|
||||
currentDraft
|
||||
&& (
|
||||
currentDraft.text.length > 0
|
||||
|| currentDraft.attachments.length > 0
|
||||
|| currentDraft.selectedUserSkillSlugs.length > 0
|
||||
),
|
||||
);
|
||||
|
||||
if (startFresh && !hasPendingDraftContent) {
|
||||
return createEmptyDraft(agentId);
|
||||
}
|
||||
|
||||
const baseDraft = currentDraft ?? createEmptyDraft(agentId);
|
||||
return {
|
||||
...baseDraft,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearScopeDraftState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
|
||||
@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions removes non-restorable terminal chats and closed workspaces", () => {
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
@@ -99,10 +99,7 @@ test("pruneInactiveScopedSessions removes non-restorable terminal chats and clos
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.deepEqual(next.sessions, [
|
||||
{
|
||||
...sessions[0],
|
||||
externalSessionId: undefined,
|
||||
},
|
||||
sessions[0],
|
||||
sessions[3],
|
||||
]);
|
||||
});
|
||||
@@ -129,3 +126,35 @@ test("pruneInactiveScopedSessions preserves original sessions when orphaned rest
|
||||
assert.deepEqual(next.orphanedSessionIds, ["terminal-restorable"]);
|
||||
assert.equal(next.sessions, sessions);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use, not orphaned", () => {
|
||||
// terminal-restorable's original scope (terminal-closed-A) is gone, but
|
||||
// the user resumed it into terminal-open-B from history. The session's
|
||||
// externalSessionId must be preserved and it must not appear in the
|
||||
// orphaned list, otherwise the active chat loses ACP continuity.
|
||||
const resumedElsewhere = createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "terminal-closed-A",
|
||||
hostIds: ["host-1"],
|
||||
}, "ext-resumed");
|
||||
|
||||
const trulyOrphaned = createSession("terminal-stale", {
|
||||
type: "terminal",
|
||||
targetId: "terminal-closed-C",
|
||||
hostIds: ["host-2"],
|
||||
}, "ext-stale");
|
||||
|
||||
const sessions = [resumedElsewhere, trulyOrphaned];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(["terminal-open-B"]),
|
||||
new Set(["terminal-restorable"]),
|
||||
);
|
||||
|
||||
// Only the one not being displayed anywhere should show up as orphaned.
|
||||
assert.deepEqual(next.orphanedSessionIds, ["terminal-stale"]);
|
||||
// The resumed session must retain its externalSessionId.
|
||||
const resumedNext = next.sessions.find((s) => s.id === "terminal-restorable");
|
||||
assert.equal(resumedNext?.externalSessionId, "ext-resumed");
|
||||
});
|
||||
|
||||
@@ -99,12 +99,21 @@ function isRestorableTerminalSession(session: AISession): boolean {
|
||||
export function pruneInactiveScopedSessions(
|
||||
sessions: AISession[],
|
||||
activeTargetIds: Set<string>,
|
||||
/**
|
||||
* Session ids currently displayed by any live scope. A session whose
|
||||
* `scope.targetId` is inactive but whose id is still in use somewhere
|
||||
* (e.g. resumed from history into a different terminal) must not be
|
||||
* treated as orphaned — deleting it outright would break the chat the
|
||||
* user is actively continuing.
|
||||
*/
|
||||
activeSessionIds: Set<string> = new Set(),
|
||||
): {
|
||||
sessions: AISession[];
|
||||
orphanedSessionIds: string[];
|
||||
} {
|
||||
const orphanedSessionIds = sessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.filter((session) => !activeSessionIds.has(session.id))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (orphanedSessionIds.length === 0) {
|
||||
@@ -126,15 +135,7 @@ export function pruneInactiveScopedSessions(
|
||||
sessionsChanged = true;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!session.externalSessionId) {
|
||||
return [session];
|
||||
}
|
||||
|
||||
sessionsChanged = true;
|
||||
return [
|
||||
{ ...session, externalSessionId: undefined },
|
||||
];
|
||||
return [session];
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
110
application/state/resolveCloseIntent.test.ts
Normal file
110
application/state/resolveCloseIntent.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
|
||||
|
||||
const baseWorkspace = {
|
||||
id: "w1",
|
||||
focusedSessionId: "s1",
|
||||
};
|
||||
|
||||
const baseSession = { id: "s1" };
|
||||
|
||||
test("non-workspace tab → closeSingleTab with session id", () => {
|
||||
const result = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: baseSession,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: { id: "s1" },
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("vault/sftp tab → noop", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "vault",
|
||||
workspace: null,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "noop" });
|
||||
});
|
||||
|
||||
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "sftp",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
43
application/state/resolveCloseIntent.ts
Normal file
43
application/state/resolveCloseIntent.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type CloseIntent =
|
||||
| { kind: 'closeTerminal'; sessionId: string }
|
||||
| { kind: 'closeSidePanel' }
|
||||
| { kind: 'closeWorkspace'; workspaceId: string }
|
||||
| { kind: 'closeSingleTab'; sessionId: string }
|
||||
| { kind: 'noop' };
|
||||
|
||||
export interface ResolveCloseInput {
|
||||
activeTabId: string | null;
|
||||
workspace: { id: string; focusedSessionId?: string } | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
activeSidePanelTab: string | null;
|
||||
focusIsInsideTerminal: boolean;
|
||||
}
|
||||
|
||||
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
|
||||
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
|
||||
|
||||
if (!activeTabId) return { kind: 'noop' };
|
||||
|
||||
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
|
||||
// Modals take priority over this but are intercepted upstream in App.tsx before the
|
||||
// hotkey reaches resolveCloseIntent.
|
||||
if (activeSidePanelTab !== null) {
|
||||
return { kind: 'closeSidePanel' };
|
||||
}
|
||||
|
||||
if (sessionForTab && !workspace) {
|
||||
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
// e.g. 'vault', 'sftp', or any non-closable pinned tab
|
||||
return { kind: 'noop' };
|
||||
}
|
||||
|
||||
const focusedSessionId = workspace.focusedSessionId;
|
||||
if (focusedSessionId && focusIsInsideTerminal) {
|
||||
return { kind: 'closeTerminal', sessionId: focusedSessionId };
|
||||
}
|
||||
|
||||
return { kind: 'closeWorkspace', workspaceId: workspace.id };
|
||||
}
|
||||
@@ -33,8 +33,11 @@ import type {
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
ensureDraftForScopeState,
|
||||
getDraftUploadGenerationState,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from './aiDraftState';
|
||||
@@ -91,9 +94,25 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(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 —
|
||||
// history can be resumed into a different terminal and we must not
|
||||
// delete it outright while it's actively being used.
|
||||
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const activeSessionIds = new Set<string>();
|
||||
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
|
||||
if (!sessionId) continue;
|
||||
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
activeSessionIds.add(sessionId);
|
||||
}
|
||||
|
||||
const nextSessionCleanup = pruneInactiveScopedSessions(
|
||||
currentSessions,
|
||||
activeTargetIds,
|
||||
activeSessionIds,
|
||||
);
|
||||
|
||||
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
|
||||
@@ -109,9 +128,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
@@ -152,6 +169,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
}
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
@@ -198,6 +216,7 @@ let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = nul
|
||||
let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
|
||||
let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
|
||||
|
||||
function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
@@ -207,19 +226,6 @@ function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string,
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
function buildScopeKey(scope: AISessionScope) {
|
||||
return `${scope.type}:${scope.targetId ?? ''}`;
|
||||
}
|
||||
|
||||
function areHostIdsEqual(left?: string[], right?: string[]) {
|
||||
const leftIds = left ?? [];
|
||||
const rightIds = right ?? [];
|
||||
if (leftIds.length !== rightIds.length) return false;
|
||||
|
||||
const rightSet = new Set(rightIds);
|
||||
return leftIds.every((hostId) => rightSet.has(hostId));
|
||||
}
|
||||
|
||||
function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
|
||||
latestAIDraftsByScopeSnapshot = draftsByScope;
|
||||
}
|
||||
@@ -228,15 +234,25 @@ function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope)
|
||||
latestAIPanelViewByScopeSnapshot = panelViewByScope;
|
||||
}
|
||||
|
||||
function getDraftMutationVersion(scopeKey: string) {
|
||||
return latestAIDraftMutationVersionByScopeSnapshot[scopeKey] ?? 0;
|
||||
function bumpDraftMutationVersion(scopeKey: string) {
|
||||
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
|
||||
latestAIDraftMutationVersionByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function bumpDraftMutationVersion(scopeKey: string) {
|
||||
latestAIDraftMutationVersionByScopeSnapshot = {
|
||||
...latestAIDraftMutationVersionByScopeSnapshot,
|
||||
[scopeKey]: getDraftMutationVersion(scopeKey) + 1,
|
||||
};
|
||||
function getDraftUploadGeneration(scopeKey: string) {
|
||||
return getDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function bumpDraftUploadGeneration(scopeKey: string) {
|
||||
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
@@ -788,60 +804,6 @@ export function useAIState() {
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
|
||||
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
|
||||
if (!currentSession) return;
|
||||
|
||||
const currentScope = currentSession.scope;
|
||||
const scopeChanged =
|
||||
currentScope.type !== scope.type
|
||||
|| currentScope.targetId !== scope.targetId
|
||||
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
|
||||
|
||||
const nextScopeKey = buildScopeKey(scope);
|
||||
const currentScopeKey = buildScopeKey(currentScope);
|
||||
|
||||
if (scopeChanged) {
|
||||
setSessionsRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
changed = true;
|
||||
return { ...session, scope, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
sessionsRef.current = next;
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setActiveSessionIdMapRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
|
||||
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
|
||||
delete next[currentScopeKey];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (next[nextScopeKey] !== sessionId) {
|
||||
next[nextScopeKey] = sessionId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
@@ -947,12 +909,15 @@ export function useAIState() {
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}, []);
|
||||
|
||||
const updateDraftIfPresent = useCallback((
|
||||
scopeKey: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): void => {
|
||||
let updated = false;
|
||||
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const currentDraft = prev[scopeKey];
|
||||
if (!currentDraft) return prev;
|
||||
@@ -965,14 +930,19 @@ export function useAIState() {
|
||||
...prev,
|
||||
[scopeKey]: nextDraft,
|
||||
};
|
||||
updated = true;
|
||||
setLatestAIDraftsByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showDraftView = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let activeSessionMapChanged = false;
|
||||
@@ -1009,7 +979,7 @@ export function useAIState() {
|
||||
}, [setPanelViewByScope]);
|
||||
|
||||
const clearDraftForScope = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let draftsChanged = false;
|
||||
@@ -1031,6 +1001,7 @@ export function useAIState() {
|
||||
if (!draftsChanged && !panelViewChanged) return;
|
||||
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
|
||||
if (draftsChanged && nextDraftsByScope) {
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
@@ -1050,11 +1021,11 @@ export function useAIState() {
|
||||
inputFiles: File[],
|
||||
) => {
|
||||
ensureDraftForScope(scopeKey, fallbackAgentId);
|
||||
const initialMutationVersion = getDraftMutationVersion(scopeKey);
|
||||
const initialUploadGeneration = getDraftUploadGeneration(scopeKey);
|
||||
const uploads = await convertFilesToUploads(inputFiles);
|
||||
if (uploads.length === 0) return;
|
||||
|
||||
if (getDraftMutationVersion(scopeKey) !== initialMutationVersion) {
|
||||
if (getDraftUploadGeneration(scopeKey) !== initialUploadGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1175,7 +1146,6 @@ export function useAIState() {
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
|
||||
@@ -247,19 +247,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
|
||||
// Prevent pushing an empty vault to cloud. This is almost always
|
||||
// Refuse to push an empty vault to cloud. This is almost always
|
||||
// a sign that the local state was lost (update, import failure,
|
||||
// storage corruption) rather than a deliberate "delete everything".
|
||||
// We only block auto-sync — manual trigger from Settings can still
|
||||
// push if the user explicitly wants to.
|
||||
// Both auto and manual triggers are blocked; the user can still
|
||||
// use Force Push from the SyncBlocked banner if they genuinely
|
||||
// want to wipe the cloud.
|
||||
//
|
||||
// This pairs with the inspect-failure "fail open" behavior in
|
||||
// checkRemoteVersion below: if inspect transiently errors we still
|
||||
// let auto-sync run, trusting this guard to refuse if local is
|
||||
// truly empty rather than letting an empty state clobber remote.
|
||||
if (!hasMeaningfulSyncData(payload) && trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.emptyVaultManual'));
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
@@ -479,7 +483,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// that only approximated the correct ordering.
|
||||
if (mergeResult.payload) {
|
||||
try {
|
||||
await manager.syncAllProviders(mergeResult.payload);
|
||||
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
|
||||
const wasShrinkBlocked = Array.from(roundTripResults.values()).some(
|
||||
(r) => r.shrinkBlocked === true,
|
||||
);
|
||||
if (wasShrinkBlocked) {
|
||||
// The merged payload is already applied locally and is the source of truth
|
||||
// for THIS device. The blocking only prevents pushing it to cloud, which
|
||||
// is acceptable here — the next user-edit-triggered sync will re-check
|
||||
// (and the user can also force-push from the Settings banner if they
|
||||
// navigate there). Reset syncState so we don't leave the manager wedged
|
||||
// in BLOCKED with no banner visible.
|
||||
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
|
||||
manager.clearShrinkBlockedState();
|
||||
}
|
||||
// Suppress the debounced follow-up tick that otherwise fires
|
||||
// once React commits the applied state, since we've just
|
||||
// already pushed that exact payload upstream.
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
import {
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
type SyncEventCallback,
|
||||
} from '../../infrastructure/services/CloudSyncManager';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { DeviceFlowState } from '../../infrastructure/services/adapters/GitHubAdapter';
|
||||
|
||||
@@ -55,7 +57,7 @@ export interface CloudSyncHook {
|
||||
// Computed
|
||||
hasAnyConnectedProvider: boolean;
|
||||
connectedProviderCount: number;
|
||||
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict';
|
||||
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked';
|
||||
|
||||
// Master Key Actions
|
||||
setupMasterKey: (password: string, confirmPassword: string) => Promise<void>;
|
||||
@@ -86,8 +88,8 @@ export interface CloudSyncHook {
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
|
||||
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
|
||||
|
||||
@@ -116,6 +118,12 @@ export interface CloudSyncHook {
|
||||
formatLastSync: (timestamp?: number) => string;
|
||||
getProviderDotColor: (provider: CloudProvider) => string;
|
||||
refresh: () => void;
|
||||
|
||||
// Event subscription (for non-state events like SYNC_BLOCKED_SHRINK)
|
||||
subscribeToEvents: (callback: SyncEventCallback) => () => void;
|
||||
|
||||
// Shrink-block state query (for banner hydration on mount)
|
||||
getShrinkBlockedFinding: () => Extract<ShrinkFinding, { suspicious: true }> | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -190,7 +198,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
).length;
|
||||
}, [state.providers]);
|
||||
|
||||
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' => {
|
||||
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked' => {
|
||||
if (state.syncState === 'BLOCKED') return 'blocked';
|
||||
if (state.syncState === 'CONFLICT') return 'conflict';
|
||||
if (state.syncState === 'ERROR') return 'error';
|
||||
if (state.syncState === 'SYNCING') return 'syncing';
|
||||
@@ -422,14 +431,14 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
throw new Error('Vault is locked');
|
||||
}, []);
|
||||
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload) => {
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncAllProviders(payload);
|
||||
return await manager.syncAllProviders(payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload) => {
|
||||
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncToProvider(provider, payload);
|
||||
return await manager.syncToProvider(provider, payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
|
||||
@@ -437,6 +446,16 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
return await manager.downloadFromProvider(provider);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const subscribeToEvents = useCallback(
|
||||
(callback: SyncEventCallback) => manager.subscribe(callback),
|
||||
[],
|
||||
);
|
||||
|
||||
const getShrinkBlockedFinding = useCallback(
|
||||
() => manager.getShrinkBlockedFinding(),
|
||||
[],
|
||||
);
|
||||
|
||||
const resolveConflictWithUnlock = useCallback(async (resolution: ConflictResolution) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.resolveConflict(resolution);
|
||||
@@ -505,6 +524,12 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
formatLastSync,
|
||||
getProviderDotColor,
|
||||
refresh,
|
||||
|
||||
// Event subscription
|
||||
subscribeToEvents,
|
||||
|
||||
// Shrink-block state query
|
||||
getShrinkBlockedFinding,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -141,19 +141,48 @@ export const useSessionState = () => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
}, []);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
|
||||
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
|
||||
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
if (currentActiveTabId === workspaceId) {
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
|
||||
} else {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return remainingWorkspaces;
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
|
||||
|
||||
// Pre-compute outside the setSessions updater so we don't depend on React
|
||||
// having run the updater by the time we queue the microtask. React 18+ does
|
||||
// not guarantee updater execution timing under concurrent scheduling.
|
||||
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
|
||||
const workspaceIdToMaybeClose =
|
||||
sessionBeingClosed?.workspaceId &&
|
||||
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
|
||||
? sessionBeingClosed.workspaceId
|
||||
: undefined;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const targetSession = prevSessions.find(s => s.id === sessionId);
|
||||
const wsId = targetSession?.workspaceId;
|
||||
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
let removedWorkspaceId: string | null = null;
|
||||
let nextWorkspaces = prevWorkspaces;
|
||||
let dissolvedWorkspaceId: string | null = null;
|
||||
let lastRemainingSessionId: string | null = null;
|
||||
|
||||
|
||||
if (wsId) {
|
||||
nextWorkspaces = prevWorkspaces
|
||||
.map(ws => {
|
||||
@@ -163,7 +192,7 @@ export const useSessionState = () => {
|
||||
removedWorkspaceId = ws.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Check if only 1 session remains - dissolve workspace
|
||||
const remainingSessionIds = collectSessionIds(pruned);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
@@ -171,12 +200,12 @@ export const useSessionState = () => {
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return { ...ws, root: pruned };
|
||||
})
|
||||
.filter((ws): ws is Workspace => Boolean(ws));
|
||||
}
|
||||
|
||||
|
||||
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
||||
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
||||
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
|
||||
@@ -198,10 +227,10 @@ export const useSessionState = () => {
|
||||
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
|
||||
setActiveTabId(getFallback());
|
||||
}
|
||||
|
||||
|
||||
return nextWorkspaces;
|
||||
});
|
||||
|
||||
|
||||
// Check if we need to dissolve a workspace (convert remaining session to orphan)
|
||||
if (targetSession?.workspaceId) {
|
||||
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
|
||||
@@ -218,29 +247,14 @@ export const useSessionState = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prevSessions.filter(s => s.id !== sessionId);
|
||||
});
|
||||
}, [workspaces, setActiveTabId]);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
|
||||
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
|
||||
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
if (currentActiveTabId === workspaceId) {
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
|
||||
} else {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return remainingWorkspaces;
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
return prevSessions.filter(s => s.id !== sessionId);
|
||||
});
|
||||
|
||||
if (workspaceIdToMaybeClose) {
|
||||
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
|
||||
}
|
||||
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
|
||||
|
||||
const startSessionRename = useCallback((sessionId: string) => {
|
||||
setSessions(prevSessions => {
|
||||
@@ -654,16 +668,22 @@ export const useSessionState = () => {
|
||||
const copySession = useCallback((sessionId: string, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
// Pre-allocate the new id outside the updater so StrictMode's
|
||||
// double-invocation of the functional updater doesn't mint two ids.
|
||||
const newSessionId = crypto.randomUUID();
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
// Source may have been closed between the user's action and this
|
||||
// update running; in that case skip entirely — do NOT switch the
|
||||
// active tab or insert into tabOrder, which would leave dangling ids.
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
// Create a new session with the same connection info
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
id: newSessionId,
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
@@ -681,10 +701,40 @@ export const useSessionState = () => {
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
setActiveTabId(newSession.id);
|
||||
// Schedule the activeTab + tabOrder updates only when creation
|
||||
// actually happens. These nested setStates are idempotent, so
|
||||
// StrictMode's double-invocation is harmless.
|
||||
setActiveTabId(newSessionId);
|
||||
setTabOrder(prevTabOrder => {
|
||||
// Fast path: source is already tracked in tabOrder — splice directly.
|
||||
const directIdx = prevTabOrder.indexOf(sessionId);
|
||||
if (directIdx !== -1) {
|
||||
const next = [...prevTabOrder];
|
||||
next.splice(directIdx + 1, 0, newSessionId);
|
||||
return next;
|
||||
}
|
||||
// Fallback: source is only in the derived tab collections. Rebuild the
|
||||
// effective order (same pattern as reorderTabs) to locate its position.
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
const sourceIdx = currentOrder.indexOf(sessionId);
|
||||
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
|
||||
const next = [...currentOrder];
|
||||
next.splice(sourceIdx + 1, 0, newSessionId);
|
||||
return next;
|
||||
});
|
||||
|
||||
return [...prevSessions, newSession];
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
|
||||
@@ -108,7 +108,8 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'osc52Clipboard',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
|
||||
@@ -47,17 +47,25 @@ import {
|
||||
type UserSkillOption,
|
||||
} from './ai/userSkillsState';
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
shouldRetargetSessionForScope,
|
||||
} from './ai/aiPanelViewState';
|
||||
import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
} from './ai/draftSendGate';
|
||||
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
|
||||
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
@@ -102,7 +110,6 @@ interface AIChatSidePanelProps {
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
retargetSessionScope: (sessionId: string, scope: AISessionScope) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
@@ -172,56 +179,6 @@ function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return messages.flatMap((message): Array<{ role: 'user' | 'assistant'; content: string }> => {
|
||||
if (message.role === 'system') return [];
|
||||
|
||||
if (message.role === 'user') {
|
||||
return message.content ? [{ role: 'user', content: message.content }] : [];
|
||||
}
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
if (!parts.length) return [];
|
||||
return [{ role: 'assistant', content: parts.join('\n\n') }];
|
||||
}
|
||||
|
||||
if (message.role === 'tool' && message.toolResults?.length) {
|
||||
return message.toolResults.map((tr) => ({
|
||||
role: 'assistant',
|
||||
content: `Tool result:\n${tr.content}`,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionScopeMatchRank(
|
||||
session: AISession,
|
||||
scopeType: 'terminal' | 'workspace',
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
activeTerminalTargetIds?: Set<string>,
|
||||
): number {
|
||||
if (session.scope.type !== scopeType) return 0;
|
||||
if (session.scope.targetId === scopeTargetId) return 2;
|
||||
|
||||
if (scopeType !== 'terminal' || !scopeHostIds?.length || !session.scope.hostIds?.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
@@ -243,7 +200,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
@@ -302,36 +258,42 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setActiveSessionIdForScope(scopeKey, id);
|
||||
}, [scopeKey, setActiveSessionIdForScope]);
|
||||
|
||||
const activeTerminalTargetIds = useMemo(() => {
|
||||
const targetIds = new Set<string>();
|
||||
for (const [sessionScopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
const activeTerminalSessionIds = useMemo(() => {
|
||||
const sessionIds = new Set<string>();
|
||||
const entries = Object.entries(activeSessionIdMap) as Array<[string, string | null]>;
|
||||
for (const [sessionScopeKey, sessionId] of entries) {
|
||||
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
|
||||
const targetId = sessionScopeKey.slice('terminal:'.length);
|
||||
if (!targetId || targetId === scopeTargetId) continue;
|
||||
targetIds.add(targetId);
|
||||
if (sessionScopeKey === scopeKey) continue;
|
||||
sessionIds.add(sessionId);
|
||||
}
|
||||
return targetIds;
|
||||
}, [activeSessionIdMap, scopeTargetId]);
|
||||
return sessionIds;
|
||||
}, [activeSessionIdMap, scopeKey]);
|
||||
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
matchRank: getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds),
|
||||
matchRank: getSessionScopeMatchRank(
|
||||
session,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
),
|
||||
}))
|
||||
.filter(({ matchRank }) => matchRank > 0)
|
||||
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
|
||||
.map(({ session }) => session),
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds],
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
);
|
||||
|
||||
const explicitPanelView = panelViewByScope[scopeKey];
|
||||
const currentDraft = draftsByScope[scopeKey] ?? null;
|
||||
const persistedSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const normalizedPanelView = useMemo<AIPanelView>(
|
||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId),
|
||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId],
|
||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
|
||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
|
||||
);
|
||||
const activeSession = useMemo(
|
||||
() => resolveDisplayedSession(normalizedPanelView, historySessions),
|
||||
@@ -348,6 +310,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
currentDraftRef.current = currentDraft;
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
const draftSendInFlightRef = useRef(false);
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
@@ -385,40 +348,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
showDraftView(scopeKey);
|
||||
}, [normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
|
||||
const shouldRetargetActiveSession = useMemo(() => {
|
||||
return shouldRetargetSessionForScope(
|
||||
activeSession,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalTargetIds,
|
||||
);
|
||||
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (shouldRetargetActiveSession && isVisible) {
|
||||
if (streamingSessionIds.has(activeSession.id)) {
|
||||
const controller = abortControllersRef.current.get(activeSession.id);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
abortControllersRef.current.delete(activeSession.id);
|
||||
}
|
||||
setStreamingForScope(activeSession.id, false);
|
||||
clearAllPendingApprovals(activeSession.id);
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSession.id);
|
||||
bridge?.aiAcpCancel?.('', activeSession.id);
|
||||
}
|
||||
retargetSessionScope(activeSession.id, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible && activeSessionIdMap[scopeKey] !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
@@ -426,18 +358,22 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
activeSession,
|
||||
activeSessionIdMap,
|
||||
scopeKey,
|
||||
retargetSessionScope,
|
||||
isVisible,
|
||||
scopeHostIds,
|
||||
scopeTargetId,
|
||||
scopeType,
|
||||
setActiveSessionId,
|
||||
setStreamingForScope,
|
||||
shouldRetargetActiveSession,
|
||||
streamingSessionIds,
|
||||
abortControllersRef,
|
||||
]);
|
||||
|
||||
// When the resolved view is draft but activeSessionIdMap still points at a
|
||||
// previously-shown session, clear that stale entry. Otherwise
|
||||
// activeTerminalTargetIds keeps claiming ownership of the old session's
|
||||
// target and getSessionScopeMatchRank suppresses matching history from
|
||||
// other terminals until another action rewrites the map.
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (normalizedPanelView.mode !== 'draft') return;
|
||||
if (persistedSessionId == null) return;
|
||||
setActiveSessionId(null);
|
||||
}, [isVisible, normalizedPanelView.mode, persistedSessionId, setActiveSessionId]);
|
||||
|
||||
const ensureScopeDraft = useCallback((agentId: string) => {
|
||||
ensureDraftForScope(scopeKey, agentId);
|
||||
}, [ensureDraftForScope, scopeKey]);
|
||||
@@ -461,16 +397,26 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
clearDraftForScope(scopeKey);
|
||||
}, [clearDraftForScope, scopeKey]);
|
||||
|
||||
const enterScopeDraftMode = useCallback((agentId: string, preserveSessionView = false) => {
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => ensureScopeDraft(agentId),
|
||||
showDraftView: showScopeDraftView,
|
||||
preserveSessionView,
|
||||
});
|
||||
}, [ensureScopeDraft, showScopeDraftView]);
|
||||
|
||||
const setInputValue = useCallback((value: string) => {
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateScopeDraft(currentAgentId, (draft) => ({
|
||||
...draft,
|
||||
text: value,
|
||||
}));
|
||||
}, [currentAgentId, updateScopeDraft]);
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
const addFiles = useCallback(async (inputFiles: File[]) => {
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
await addDraftFiles(scopeKey, currentAgentId, inputFiles);
|
||||
}, [addDraftFiles, scopeKey, currentAgentId]);
|
||||
}, [addDraftFiles, currentAgentId, enterScopeDraftMode, scopeKey]);
|
||||
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
removeDraftFile(scopeKey, currentAgentId, fileId);
|
||||
@@ -810,6 +756,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const addSelectedUserSkill = useCallback((slug: string) => {
|
||||
const normalizedSlug = String(slug || '').trim().toLowerCase();
|
||||
if (!normalizedSlug) return;
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateScopeDraft(currentAgentId, (draft) => {
|
||||
if (draft.selectedUserSkillSlugs.includes(normalizedSlug)) {
|
||||
return draft;
|
||||
@@ -819,11 +766,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
selectedUserSkillSlugs: [...draft.selectedUserSkillSlugs, normalizedSlug],
|
||||
};
|
||||
});
|
||||
}, [currentAgentId, updateScopeDraft]);
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
const removeSelectedUserSkill = useCallback((slug: string) => {
|
||||
const normalizedSlug = String(slug || '').trim().toLowerCase();
|
||||
if (!normalizedSlug) return;
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateScopeDraft(currentAgentId, (draft) => {
|
||||
const nextSelectedUserSkillSlugs = draft.selectedUserSkillSlugs.filter(
|
||||
(entry) => entry !== normalizedSlug,
|
||||
@@ -836,7 +784,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
selectedUserSkillSlugs: nextSelectedUserSkillSlugs,
|
||||
};
|
||||
});
|
||||
}, [currentAgentId, updateScopeDraft]);
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main send handler (thin orchestrator)
|
||||
@@ -859,110 +807,121 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
}));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
let sessionId = currentSessionView?.id ?? null;
|
||||
let currentSession = currentSessionView ?? null;
|
||||
let sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
|
||||
if (currentPanelView.mode === 'draft') {
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const createdSession = createSession(scope, sendAgentId);
|
||||
sessionId = createdSession.id;
|
||||
currentSession = createdSession;
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
try {
|
||||
let sessionId = currentSessionView?.id ?? null;
|
||||
let currentSession = currentSessionView ?? null;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
if (isDraftMode) {
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const createdSession = createSession(scope, sendAgentId);
|
||||
sessionId = createdSession.id;
|
||||
currentSession = createdSession;
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sessionId, abortController);
|
||||
currentSession = currentSession ?? sessionsRef.current.find((session) => session.id === sessionId) ?? null;
|
||||
|
||||
if (isExternalAgent) {
|
||||
if (!agentConfig) {
|
||||
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
|
||||
setStreamingForScope(sessionId, false);
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: currentSession?.externalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
toolIntegrationMode,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sessionId, abortController);
|
||||
currentSession = currentSession ?? sessionsRef.current.find((session) => session.id === sessionId) ?? null;
|
||||
|
||||
if (isExternalAgent) {
|
||||
if (!agentConfig) {
|
||||
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
|
||||
setStreamingForScope(sessionId, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const existingExternalSessionId = currentSession?.externalSessionId;
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: existingExternalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
toolIntegrationMode,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
}
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
if (isDraftMode) {
|
||||
endDraftSend(draftSendInFlightRef);
|
||||
}
|
||||
// Clear any lingering statusText when the external agent stream finishes
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
@@ -1017,12 +976,15 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
showScopeDraftView();
|
||||
ensureScopeDraft(agentId);
|
||||
updateScopeDraft(agentId, (draft) => ({
|
||||
...draft,
|
||||
agentId,
|
||||
...selectDraftForAgentSwitch(
|
||||
draft,
|
||||
agentId,
|
||||
Boolean(activeSessionRef.current?.messages.length),
|
||||
),
|
||||
}));
|
||||
showScopeDraftView();
|
||||
setShowHistory(false);
|
||||
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
@@ -1203,20 +1165,20 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
SESSION_HISTORY_ROW_CLASSNAMES.row,
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="text-[13px] truncate pr-3 flex-1 min-w-0">
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[12px] text-muted-foreground/50">
|
||||
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Sync status and conflict resolution
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -43,7 +43,9 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type SyncResult, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import type { ShrinkFinding } from '../domain/syncGuards';
|
||||
import { SyncBlockedBanner } from './sync/SyncBlockedBanner';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -897,20 +899,29 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">
|
||||
{getReasonLabel(backup.reason)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(backup.createdAt)}
|
||||
</span>
|
||||
<div className="text-sm font-medium">
|
||||
{backup.syncDataVersion
|
||||
? `v${backup.syncDataVersion}`
|
||||
: formatTimestamp(backup.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(backup.reason)}</span>
|
||||
{backup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(backup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{backup.sourceAppVersion && backup.targetAppVersion && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: backup.sourceAppVersion,
|
||||
to: backup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: backup.sourceAppVersion,
|
||||
to: backup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -974,13 +985,30 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
</DialogHeader>
|
||||
{pendingRestoreBackup && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">
|
||||
{getReasonLabel(pendingRestoreBackup.reason)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimestamp(pendingRestoreBackup.createdAt)}
|
||||
</span>
|
||||
<div className="font-medium">
|
||||
{pendingRestoreBackup.syncDataVersion
|
||||
? `v${pendingRestoreBackup.syncDataVersion}`
|
||||
: formatTimestamp(pendingRestoreBackup.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
|
||||
{pendingRestoreBackup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: pendingRestoreBackup.sourceAppVersion,
|
||||
to: pendingRestoreBackup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
@@ -1172,6 +1200,17 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Clear local data dialog
|
||||
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
|
||||
|
||||
// Sync-blocked banner (Task 7) + force-push confirmation modal (Task 8)
|
||||
const [blockedFinding, setBlockedFinding] = useState<Extract<ShrinkFinding, { suspicious: true }> | null>(null);
|
||||
const [showForcePushConfirm, setShowForcePushConfirm] = useState(false);
|
||||
|
||||
// Ref for scrolling to LocalBackupsPanel when the banner's Restore button is clicked
|
||||
const localBackupsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Active tab state — lets the banner's "Restore" button switch to the
|
||||
// local-backups tab without a separate DOM query.
|
||||
const [activeTab, setActiveTab] = useState<'providers' | 'status'>('providers');
|
||||
|
||||
const ensureSyncablePayload = useCallback(
|
||||
(payload: SyncPayload): boolean => {
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
@@ -1190,6 +1229,35 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
}, [sync.currentConflict]);
|
||||
|
||||
// Subscribe to sync events to show/clear the blocked-shrink banner.
|
||||
// Destructure the stable useCallback reference so the effect runs once on
|
||||
// mount rather than re-subscribing on every render when `sync` object ref changes.
|
||||
const { subscribeToEvents, getShrinkBlockedFinding } = sync;
|
||||
|
||||
// Hydrate from current manager state in case a shrink-block happened
|
||||
// before this component mounted (e.g., auto-sync ran while the user
|
||||
// was on a different tab). Without this, the banner only shows
|
||||
// blocks that occur after Settings is open.
|
||||
useEffect(() => {
|
||||
const existing = getShrinkBlockedFinding();
|
||||
if (existing) {
|
||||
setBlockedFinding(existing);
|
||||
}
|
||||
}, [getShrinkBlockedFinding]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribeToEvents((event) => {
|
||||
if (event.type === 'SYNC_BLOCKED_SHRINK') {
|
||||
if (event.finding.suspicious) {
|
||||
setBlockedFinding(event.finding);
|
||||
}
|
||||
} else if (event.type === 'SYNC_BLOCKED_CLEARED') {
|
||||
setBlockedFinding(null);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [subscribeToEvents]);
|
||||
|
||||
// If we have a master key but we're still locked (e.g. older installs),
|
||||
// prompt once and persist the password via safeStorage.
|
||||
useEffect(() => {
|
||||
@@ -1441,9 +1509,30 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// window's push, not ours.
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
|
||||
let results: Map<CloudProvider, SyncResult> | null = null;
|
||||
await withRestoreBarrier(async () => {
|
||||
await sync.syncNow(localPayload);
|
||||
results = await sync.syncNow(localPayload, { overrideShrink: true });
|
||||
});
|
||||
|
||||
if (results) {
|
||||
// Apply any merged payload BEFORE closing the modal so local state
|
||||
// reflects what's now on cloud (in case remote changed during the merge).
|
||||
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
|
||||
if (result.mergedPayload) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
break;
|
||||
}
|
||||
}
|
||||
const allOk = Array.from((results as Map<CloudProvider, SyncResult>).values()).every((r) => r.success);
|
||||
if (!allOk) {
|
||||
const firstError = Array.from((results as Map<CloudProvider, SyncResult>).values())
|
||||
.find((r) => !r.success)?.error
|
||||
?? t('common.unknownError');
|
||||
toast.error(firstError, t('cloudSync.resolve.failedTitle'));
|
||||
return; // KEEP the modal open so user can retry / pick USE_REMOTE
|
||||
}
|
||||
}
|
||||
toast.success(t('cloudSync.resolve.uploaded'));
|
||||
}
|
||||
setShowConflictModal(false);
|
||||
@@ -1554,7 +1643,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="providers" className="space-y-4">
|
||||
{blockedFinding && (
|
||||
<SyncBlockedBanner
|
||||
finding={blockedFinding}
|
||||
onRestore={() => {
|
||||
setActiveTab('status');
|
||||
requestAnimationFrame(() => {
|
||||
localBackupsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}}
|
||||
onForcePush={() => setShowForcePushConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
|
||||
<TabsTrigger value="status">{t('cloudSync.status.title')}</TabsTrigger>
|
||||
@@ -1739,9 +1841,11 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyPayload}
|
||||
/>
|
||||
<div ref={localBackupsRef}>
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyPayload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Local Data */}
|
||||
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
|
||||
@@ -2361,6 +2465,69 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Force-push confirmation modal (Task 8) */}
|
||||
{showForcePushConfirm && blockedFinding && (
|
||||
<Dialog open onOpenChange={(open) => !open && setShowForcePushConfirm(false)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('sync.forcePush.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
{t('sync.forcePush.body', {
|
||||
lost: blockedFinding.lost,
|
||||
entityType: t(`sync.entityType.${blockedFinding.entityType}`),
|
||||
})}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowForcePushConfirm(false)}>
|
||||
{t('sync.forcePush.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) {
|
||||
setShowForcePushConfirm(false);
|
||||
return;
|
||||
}
|
||||
setShowForcePushConfirm(false);
|
||||
try {
|
||||
const results = await sync.syncNow(localPayload, { overrideShrink: true });
|
||||
|
||||
// Apply any merged payload BEFORE clearing the banner. If a merge happened
|
||||
// during force-push (remote changed), the merged result is what the cloud
|
||||
// now has — applying it to local state prevents the next sync from
|
||||
// re-deleting the remote additions we just merged in.
|
||||
for (const result of results.values()) {
|
||||
if (result.mergedPayload) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
break; // All providers share the same merged payload
|
||||
}
|
||||
}
|
||||
|
||||
const allOk = Array.from(results.values()).every((r) => r.success);
|
||||
if (allOk) {
|
||||
setBlockedFinding(null);
|
||||
} else {
|
||||
// Surface the failure but KEEP the banner so the user can retry or
|
||||
// restore. Find the first error string to display.
|
||||
const firstError = Array.from(results.values())
|
||||
.find((r) => !r.success)
|
||||
?.error ?? t('sync.toast.errorTitle');
|
||||
toast.error(firstError, t('sync.toast.errorTitle'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(String(err), t('sync.toast.errorTitle'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('sync.forcePush.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -136,7 +136,13 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
// Determine overall status for the button indicator
|
||||
const getOverallStatus = (): StatusIndicatorProps['status'] => {
|
||||
if (sync.overallSyncStatus === 'syncing') return 'syncing';
|
||||
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
|
||||
if (
|
||||
sync.overallSyncStatus === 'error' ||
|
||||
sync.overallSyncStatus === 'conflict' ||
|
||||
sync.overallSyncStatus === 'blocked'
|
||||
) {
|
||||
return 'error';
|
||||
}
|
||||
if (sync.overallSyncStatus === 'synced') return 'synced';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
@@ -620,6 +621,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
|
||||
@@ -793,6 +800,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Autocomplete integration
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
isRestoringSelectionRef,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -1230,7 +1238,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hasText = !!selection && selection.length > 0;
|
||||
setHasSelection(hasText);
|
||||
|
||||
if (hasText && terminalSettings?.copyOnSelect) {
|
||||
if (hasText && terminalSettings?.copyOnSelect && !isRestoringSelectionRef.current) {
|
||||
navigator.clipboard.writeText(selection).catch((err) => {
|
||||
logger.warn("Copy on select failed:", err);
|
||||
});
|
||||
@@ -1321,6 +1329,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
|
||||
// True only while createXTermRuntime is programmatically restoring the
|
||||
// selection right after a keystroke (preserveSelectionOnInput). Lets
|
||||
// copy-on-select skip a redundant clipboard write that would otherwise
|
||||
// clobber whatever the user copied elsewhere in the meantime.
|
||||
const isRestoringSelectionRef = useRef(false);
|
||||
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
@@ -1706,6 +1720,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
||||
onMouseDownCapture={handleTopOverlayMouseDownCapture}
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
|
||||
@@ -340,7 +340,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
retargetSessionScope={aiState.retargetSessionScope}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
@@ -430,6 +429,8 @@ interface TerminalLayerProps {
|
||||
sessionLogsEnabled?: boolean;
|
||||
sessionLogsDir?: string;
|
||||
sessionLogsFormat?: string;
|
||||
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
@@ -483,6 +484,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -663,6 +666,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Whether side panel is open for the currently active tab and which sub-panel
|
||||
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
|
||||
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
|
||||
if (activeSidePanelTabRef) {
|
||||
activeSidePanelTabRef.current = activeSidePanelTab;
|
||||
}
|
||||
|
||||
// Legacy compatibility helpers for SFTP-specific logic
|
||||
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
|
||||
@@ -1259,9 +1265,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
|
||||
|
||||
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const focusTarget = () => {
|
||||
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
focusTarget();
|
||||
setTimeout(focusTarget, 50);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Close the entire side panel for the current tab
|
||||
const handleCloseSidePanel = useCallback(() => {
|
||||
if (!activeTabId) return;
|
||||
const sessionIdToRefocus = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
@@ -1284,7 +1306,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.delete(activeTabId);
|
||||
return next;
|
||||
});
|
||||
}, [activeTabId]);
|
||||
refocusTerminalSession(sessionIdToRefocus);
|
||||
}, [activeTabId, activeWorkspace?.focusedSessionId, activeSession?.id, refocusTerminalSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!closeSidePanelRef) return;
|
||||
closeSidePanelRef.current = handleCloseSidePanel;
|
||||
return () => {
|
||||
closeSidePanelRef.current = null;
|
||||
};
|
||||
}, [closeSidePanelRef, handleCloseSidePanel]);
|
||||
|
||||
// Switch side panel to a specific tab (or toggle if already on that tab)
|
||||
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
|
||||
@@ -2403,14 +2434,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSend={handleComposeSend}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
// Refocus the terminal pane (matching solo-session behavior)
|
||||
if (focusedSessionId) {
|
||||
requestAnimationFrame(() => {
|
||||
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
});
|
||||
}
|
||||
refocusTerminalSession(focusedSessionId);
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
|
||||
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
|
||||
@@ -36,6 +36,7 @@ interface TopTabsProps {
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
onCloseLogView: (logViewId: string) => void;
|
||||
onCloseTabsBatch: (targetIds: string[]) => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
@@ -244,6 +245,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameWorkspace,
|
||||
onCloseWorkspace,
|
||||
onCloseLogView,
|
||||
onCloseTabsBatch,
|
||||
onOpenQuickSwitcher,
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
@@ -304,11 +306,23 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
updateScrollState();
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
// Translate vertical wheel to horizontal scroll so users can reach
|
||||
// off-screen tabs with a standard mouse wheel. Trackpad gestures that
|
||||
// already carry horizontal delta are left alone so native two-finger
|
||||
// swiping still works.
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY !== 0 && e.deltaX === 0) {
|
||||
e.preventDefault();
|
||||
container.scrollLeft += e.deltaY;
|
||||
}
|
||||
};
|
||||
container.addEventListener('scroll', updateScrollState);
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
const resizeObserver = new ResizeObserver(updateScrollState);
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateScrollState);
|
||||
container.removeEventListener('wheel', handleWheel);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
@@ -482,6 +496,37 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}).filter(Boolean);
|
||||
}, [orderedTabs, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
|
||||
|
||||
// Bulk-close menu items shared by session and workspace context menus.
|
||||
// Anchor is the tab the user right-clicked on (matches VSCode/JetBrains UX).
|
||||
const renderBulkCloseItems = (anchorId: string) => {
|
||||
const anchorIdx = orderedTabs.indexOf(anchorId);
|
||||
const othersIds = orderedTabs.filter((id) => id !== anchorId);
|
||||
const rightIds = anchorIdx >= 0 ? orderedTabs.slice(anchorIdx + 1) : [];
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={othersIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(othersIds)}
|
||||
>
|
||||
{t('tabs.closeOthers')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={rightIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(rightIds)}
|
||||
>
|
||||
{t('tabs.closeToRight')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onCloseTabsBatch(orderedTabs)}
|
||||
>
|
||||
{t('tabs.closeAll')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the tabs
|
||||
const renderOrderedTabs = () => {
|
||||
return orderedTabItems.map((item) => {
|
||||
@@ -581,6 +626,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(session.id)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -687,6 +733,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(workspace.id)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
|
||||
import { getEffectiveHostDistro, sanitizeHost, upsertHostById } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import {
|
||||
@@ -2941,13 +2941,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
groupDefaults={editingHostGroupDefaults}
|
||||
groupConfigs={groupConfigs}
|
||||
onSave={(host) => {
|
||||
// Check if host already exists in the list (for updates vs. new/duplicate)
|
||||
const hostExists = hosts.some((h) => h.id === host.id);
|
||||
onUpdateHosts(
|
||||
hostExists
|
||||
? hosts.map((h) => (h.id === host.id ? host : h))
|
||||
: [...hosts, host],
|
||||
);
|
||||
onUpdateHosts(upsertHostById(hosts, host));
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
@@ -2973,15 +2967,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
allTags={allTags}
|
||||
groups={allGroupPaths}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
hosts.map((h) => (h.id === host.id ? host : h)),
|
||||
);
|
||||
onUpdateHosts(upsertHostById(hosts, host));
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
layout="inline"
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -100,6 +100,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
|
||||
const [slashQuery, setSlashQuery] = useState('');
|
||||
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
|
||||
// Active highlight index for @ mention / slash skill keyboard navigation
|
||||
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
|
||||
|
||||
// Derived booleans for readability
|
||||
const showModelPicker = activeMenu === 'model';
|
||||
@@ -203,11 +205,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
setActiveMenu(menu);
|
||||
}, [getInputPanelMenuPos]);
|
||||
|
||||
const filteredUserSkills = userSkills.filter((skill) => {
|
||||
const filteredUserSkills = useMemo(() => userSkills.filter((skill) => {
|
||||
if (!slashQuery) return true;
|
||||
const lowerQuery = slashQuery.toLowerCase();
|
||||
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
|
||||
});
|
||||
}), [userSkills, slashQuery]);
|
||||
|
||||
const removeSlashQueryFromInput = useCallback(() => {
|
||||
if (!slashRange) return value;
|
||||
@@ -227,6 +229,78 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
|
||||
|
||||
// Reset active highlight when a menu opens or when the *identity* of the
|
||||
// visible items changes. Watching only `.length` misses cases where the
|
||||
// filter produces a different set with the same count (e.g. user types
|
||||
// another character into the slash query) — Enter would then commit an
|
||||
// unexpected item. Derive a stable key from the visible ids instead.
|
||||
const atMentionKey = useMemo(
|
||||
() => hosts.map((h) => h.sessionId).join('|'),
|
||||
[hosts],
|
||||
);
|
||||
const slashSkillKey = useMemo(
|
||||
() => filteredUserSkills.map((s) => s.id).join('|'),
|
||||
[filteredUserSkills],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (showAtMention) setActiveMenuIndex(0);
|
||||
}, [showAtMention, atMentionKey]);
|
||||
useEffect(() => {
|
||||
if (showSlashSkillPicker) setActiveMenuIndex(0);
|
||||
}, [showSlashSkillPicker, slashSkillKey]);
|
||||
|
||||
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
// @ mention popover keyboard navigation
|
||||
if (showAtMention && hosts.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % hosts.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + hosts.length) % hosts.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const host = hosts[Math.min(activeMenuIndex, hosts.length - 1)];
|
||||
if (host) handleSelectAtMention(host);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// / skill popover keyboard navigation
|
||||
if (showSlashSkillPicker && filteredUserSkills.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + filteredUserSkills.length) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const skill = filteredUserSkills[Math.min(activeMenuIndex, filteredUserSkills.length - 1)];
|
||||
if (skill) insertUserSkillToken(skill);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [showAtMention, hosts, showSlashSkillPicker, filteredUserSkills, activeMenuIndex, handleSelectAtMention, insertUserSkillToken, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
.map((item: DataTransferItem) => item.getAsFile())
|
||||
@@ -367,6 +441,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
@@ -392,31 +467,40 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Mention host"
|
||||
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
|
||||
aria-activedescendant={hosts[activeMenuIndex] ? `at-mention-${hosts[activeMenuIndex].sessionId}` : undefined}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuHosts')}</div>
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="px-2.5 pb-2.5">
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="truncate">{host.label || host.hostname}</span>
|
||||
</div>
|
||||
{host.label && host.hostname !== host.label ? (
|
||||
<div className="mt-0.5 pl-3.5 text-[10px] text-muted-foreground/60 truncate">
|
||||
{host.hostname}
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{hosts.map((host, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
const showHostnameLine = host.label
|
||||
&& host.hostname !== host.label
|
||||
&& !host.label.includes(host.hostname);
|
||||
return (
|
||||
<button
|
||||
id={`at-mention-${host.sessionId}`}
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="truncate">{host.label || host.hostname}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{showHostnameLine ? (
|
||||
<div className="pl-3.5 text-[10px] text-muted-foreground/60 truncate">
|
||||
{host.hostname}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -431,31 +515,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Insert user skill"
|
||||
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
|
||||
aria-activedescendant={filteredUserSkills[activeMenuIndex] ? `slash-skill-${filteredUserSkills[activeMenuIndex].id}` : undefined}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuUserSkills')}</div>
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="px-2.5 pb-2.5">
|
||||
{filteredUserSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => insertUserSkillToken(skill)}
|
||||
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="mt-0.5 pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{filteredUserSkills.map((skill, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`slash-skill-${skill.id}`}
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => insertUserSkillToken(skill)}
|
||||
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{skill.description ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
662
components/ai/acpHistory.test.ts
Normal file
662
components/ai/acpHistory.test.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
buildAcpHistoryMessages,
|
||||
buildAcpHistoryMessagesForBridge,
|
||||
} from "./acpHistory.ts";
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: ChatMessage["role"],
|
||||
content: string,
|
||||
extra: Partial<ChatMessage> = {},
|
||||
): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
timestamp: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildAcpHistoryMessages compacts older ACP context and keeps only recent raw turns", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "我希望最小改动,不要添加很多 test"),
|
||||
message("a1", "assistant", "已按最小改动处理"),
|
||||
message("u2", "user", "MCP 不允许使用,Windows 上不要假设 pwsh.exe"),
|
||||
message("a2", "assistant", "PR #738 已创建,commit 4181a2c"),
|
||||
message("u3", "user", "帮我上网查查优化方案,每轮都带历史太慢了"),
|
||||
message("a3", "assistant", "建议 ACP history compaction"),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "search",
|
||||
content: `error: ${"large output ".repeat(500)}`,
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
message("u4", "user", "好的"),
|
||||
message("a4", "assistant", "准备实现"),
|
||||
message("u5", "user", "继续"),
|
||||
message("a5", "assistant", "继续处理"),
|
||||
message("u6", "user", "现在提交"),
|
||||
message("a6", "assistant", "还没提交"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Compact prior Netcatty UI context/);
|
||||
assert.match(result[0].content, /最小改动/);
|
||||
assert.match(result[0].content, /pwsh\.exe/);
|
||||
assert.match(result[0].content, /PR #738/);
|
||||
assert.ok(result[0].content.length <= 3000);
|
||||
|
||||
assert.ok(result.length <= 7);
|
||||
assert.deepEqual(
|
||||
result.slice(1).map((entry) => entry.content),
|
||||
["好的", "准备实现", "继续", "继续处理", "现在提交", "还没提交"],
|
||||
);
|
||||
assert.ok(result.every((entry) => entry.content.length <= 3000));
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessagesForBridge keeps fallback history available for stale ACP session recovery", () => {
|
||||
const messages = [message("u1", "user", "继续处理这个历史压缩问题")];
|
||||
|
||||
assert.equal(buildAcpHistoryMessagesForBridge([], "acp-session-1"), undefined);
|
||||
assert.deepEqual(
|
||||
buildAcpHistoryMessagesForBridge(messages, "acp-session-1"),
|
||||
buildAcpHistoryMessages(messages),
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
|
||||
message("a1", "assistant", "Understood."),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep this incremental and do not refactor unrelated files\./);
|
||||
assert.deepEqual(
|
||||
result.slice(-6).map((entry) => entry.content),
|
||||
[
|
||||
"filler user message 11",
|
||||
"filler assistant message 11",
|
||||
"filler user message 12",
|
||||
"filler assistant message 12",
|
||||
"filler user message 13",
|
||||
"filler assistant message 13",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short important user constraints outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not treat pr inside ordinary words as important", () => {
|
||||
// Original intent: `\bpr\b` in IMPORTANT_PATTERNS must NOT match 'pr'
|
||||
// inside ordinary English words like 'approach' / 'improve' / 'prepare'.
|
||||
// Those words land at priority=1 (kept only as space allows) while the
|
||||
// 不要提交 line lands at priority=2 (always preferred). The check below
|
||||
// doesn't assert that the ordinary words are absent from the compact
|
||||
// section — they may legitimately survive when budget allows; that's
|
||||
// intentional after we stopped blanket-dropping short user messages.
|
||||
// What we DO verify: the priority-2 line is selected, which is only
|
||||
// possible if the IMPORTANT_PATTERNS regex correctly distinguishes it
|
||||
// from the surrounding short ordinary-word turns.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
message("u2", "user", "approach"),
|
||||
message("a2", "assistant", "ack"),
|
||||
message("u3", "user", "improve"),
|
||||
message("a3", "assistant", "ack"),
|
||||
message("u4", "user", "prepare"),
|
||||
message("a4", "assistant", "ack"),
|
||||
];
|
||||
|
||||
for (let index = 5; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
for (let index = 1; index <= 12; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(
|
||||
message("u13", "user", "Keep the existing layout and copy wording unchanged."),
|
||||
message("a13", "assistant", "Understood."),
|
||||
);
|
||||
|
||||
for (let index = 14; index <= 18; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep the existing layout and copy wording unchanged\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Please propose a migration plan for the sidebar state."),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Introduce a dedicated hook for the panel stack. 2. Move the derived view state into that hook. 3. Keep the existing UI copy and layout. 4. Add a regression test around back navigation.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(message("u14", "user", "Apply step 2 of your plan now."));
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move the derived view state into that hook\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
|
||||
// Regression: short load-bearing instructions like "Use ssh2" / "中文输出"
|
||||
// would previously be dropped by a blanket length<10 heuristic, even
|
||||
// though they don't match any TRIVIAL pattern.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Use ssh2"),
|
||||
message("a1", "assistant", "Got it."),
|
||||
message("u2", "user", "中文输出"),
|
||||
message("a2", "assistant", "明白"),
|
||||
];
|
||||
|
||||
// Push enough later turns so u1/u2 fall outside the recent raw window
|
||||
// and have to survive via the durable-user compaction path.
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Use ssh2/);
|
||||
assert.match(result[0].content, /中文输出/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops one-word filler user messages", () => {
|
||||
// Sanity: removing the length<10 heuristic must not cause "ok" / "继续" /
|
||||
// "thanks" filler to leak into the compact section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "继续"),
|
||||
message("a2", "assistant", "继续处理"),
|
||||
];
|
||||
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
// u1 / u2 fall outside the recent raw window. The compact context, if it
|
||||
// exists, must not surface these trivial turns as durable user requests.
|
||||
if (result.length > 0 && result[0].role === "user") {
|
||||
assert.doesNotMatch(result[0].content, /User request: ok\b/);
|
||||
assert.doesNotMatch(result[0].content, /User request: 继续/);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
|
||||
// Regression: tool results used to only reach fallback replay via the
|
||||
// 500-char compact summary. If the user's last interaction produced a
|
||||
// large tool output (cat/rg/fetched file), any "use that output"-style
|
||||
// follow-up lost the actual bytes. Now tool messages flow through the
|
||||
// recent raw window at MAX_RAW_MESSAGE_CHARS (2000).
|
||||
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal", arguments: { cmd: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: bigToolOutput, isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Raw-window tool result carries both the [from ...] provenance label
|
||||
// and the actual bytes (not just the 500-char compact summary).
|
||||
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): DATA DATA DATA/);
|
||||
// Confirm we kept enough bytes to exceed the compact-summary cap.
|
||||
const toolResultIdx = flat.indexOf("Tool result [from terminal");
|
||||
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
|
||||
const toolResultChunk = flat.slice(toolResultIdx);
|
||||
assert.ok(
|
||||
toolResultChunk.length > 600,
|
||||
`expected tool result chunk to exceed compact cap (~500 chars), got ${toolResultChunk.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
|
||||
// Regression: if the raw window starts mid-tool-interaction, the
|
||||
// preceding assistant tool_call message may be outside the 6-item
|
||||
// slice. Without the call's name/args inline on the result line, the
|
||||
// AI sees opaque bytes and "use that output" becomes ambiguous.
|
||||
const messages: ChatMessage[] = [
|
||||
// Early filler to push the tool_call off the raw window
|
||||
message("u0", "user", "prior chatter"),
|
||||
message("a0", "assistant", "prior reply"),
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [
|
||||
{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } },
|
||||
],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: "127.0.0.1 localhost", isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
message("a2", "assistant", "acknowledged"),
|
||||
message("u3", "user", "now do the same for /etc/resolv.conf"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// The tool_result line must carry the originating tool_call's name and
|
||||
// args, so even if a1 was pushed out of the raw window, the result is
|
||||
// self-describing.
|
||||
assert.match(flat, /Tool result \[from terminal_exec/);
|
||||
assert.match(flat, /cat \/etc\/hosts/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
|
||||
// Regression target: codex review flagged that the compaction path
|
||||
// scanned messages.entries() over the full transcript. Build a very
|
||||
// long chat (>> MAX_DURABLE_SCAN_TURNS user turns) and verify that
|
||||
// only messages within the recent user-turn window contribute
|
||||
// durable candidates.
|
||||
const messages: ChatMessage[] = [];
|
||||
// An ancient high-priority constraint that MUST be aged out.
|
||||
messages.push(message("old-important", "user", "不要提交 old-marker-xyz"));
|
||||
messages.push(message("old-ack", "assistant", "收到"));
|
||||
|
||||
// 300 filler turns between the ancient constraint and the window —
|
||||
// well past MAX_DURABLE_SCAN_TURNS (100).
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
messages.push(
|
||||
message(`u${i}`, "user", `filler user message ${i}`),
|
||||
message(`a${i}`, "assistant", `filler assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
// A recent constraint that should survive.
|
||||
messages.push(message("recent-important", "user", "不要提交 recent-marker-abc"));
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
messages.push(
|
||||
message(`t${i}`, "user", `tail user message ${i}`),
|
||||
message(`ta${i}`, "assistant", `tail assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Recent priority-2 constraint is kept.
|
||||
assert.match(flat, /recent-marker-abc/);
|
||||
// Ancient one past the scan window is dropped — proof the bound holds.
|
||||
assert.doesNotMatch(flat, /old-marker-xyz/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
|
||||
// Regression: the previous bound was MAX_DURABLE_SCAN_MESSAGES=200 on
|
||||
// the raw message array. In a tool-heavy chat, each user turn can
|
||||
// expand to 5+ messages (user + assistant w/ toolCalls + N tool
|
||||
// results + follow-up assistant), so 200 messages might be only
|
||||
// ~40 user turns. An instruction like "不要提交" from turn 5 would
|
||||
// fall out of the scan before the turn count justified aging it out.
|
||||
//
|
||||
// Now the bound is MAX_DURABLE_SCAN_TURNS=100 user turns. Build a
|
||||
// chat with only 30 user turns but many messages per turn — the
|
||||
// early constraint must still survive.
|
||||
const messages: ChatMessage[] = [];
|
||||
messages.push(message("early-important", "user", "不要提交 EARLY_CONSTRAINT_MARKER"));
|
||||
messages.push(message("early-ack", "assistant", "收到"));
|
||||
|
||||
// 35 additional turns, each with 6 messages (bloats the total
|
||||
// message count to >200 without exceeding 100 user turns).
|
||||
for (let turn = 1; turn < 36; turn += 1) {
|
||||
messages.push(message(`u${turn}`, "user", `turn ${turn} request`));
|
||||
messages.push(message(`a${turn}-plan`, "assistant", "let me check", {
|
||||
toolCalls: [
|
||||
{ id: `c${turn}a`, name: "terminal_exec", arguments: { cmd: "echo a" } },
|
||||
{ id: `c${turn}b`, name: "terminal_exec", arguments: { cmd: "echo b" } },
|
||||
{ id: `c${turn}c`, name: "terminal_exec", arguments: { cmd: "echo c" } },
|
||||
],
|
||||
}));
|
||||
messages.push(message(`t${turn}a`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}a`, content: `result a of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}b`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}b`, content: `result b of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}c`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}c`, content: `result c of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`a${turn}-done`, "assistant", `turn ${turn} done`));
|
||||
}
|
||||
|
||||
// Sanity: the message count is over 200 even though user turns are 30.
|
||||
assert.ok(messages.length > 200, `setup: expected > 200 messages, got ${messages.length}`);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Under the old raw-count bound, the early constraint would age out;
|
||||
// under the turn-based bound it survives.
|
||||
assert.match(flat, /EARLY_CONSTRAINT_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
|
||||
// Regression: isSubstantiveAssistantMessage previously required length
|
||||
// >= 40 OR a small English keyword match OR a numbered list. Short
|
||||
// load-bearing replies like "Use ssh2" / "rebase instead" / "中文输出"
|
||||
// satisfied none of those and were silently dropped. After a stale-
|
||||
// session recovery, "do what you suggested earlier" would then replay
|
||||
// only the user's question without the assistant's actual decision.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "which client should I use"),
|
||||
message("a1", "assistant", "Use ssh2"),
|
||||
message("u2", "user", "output language?"),
|
||||
message("a2", "assistant", "中文输出"),
|
||||
message("u3", "user", "merge or rebase?"),
|
||||
message("a3", "assistant", "rebase instead"),
|
||||
];
|
||||
|
||||
// Pad so u1..a3 fall outside the recent raw window (last 6 items) and
|
||||
// must flow through the durable-assistant compact pass.
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.match(flat, /Use ssh2/);
|
||||
assert.match(flat, /中文输出/);
|
||||
assert.match(flat, /rebase instead/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
|
||||
// Sanity: removing the length/keyword gate must not let assistant
|
||||
// filler leak into the compact durable-assistant section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "prompt 1"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "prompt 2"),
|
||||
message("a2", "assistant", "明白"),
|
||||
message("u3", "user", "prompt 3"),
|
||||
message("a3", "assistant", "got it"),
|
||||
];
|
||||
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `more filler ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.doesNotMatch(flat, /Assistant context: ack\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: got it\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: 明白/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
|
||||
// Regression: the raw-window fix covered the last 6 items, but once
|
||||
// a tool result fell into the compact section (summarizeToolMessage
|
||||
// path) the `[from <name>(<args>)]` provenance label was absent.
|
||||
// With multiple older tool outputs, all surfacing as identical
|
||||
// `Tool result (callN): ...`, follow-ups like "use the resolv.conf
|
||||
// output" have no way to map to the right call.
|
||||
const messages: ChatMessage[] = [
|
||||
// Two distinct tool interactions, both pushed well outside the
|
||||
// recent raw window by later turns.
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call-hosts", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-hosts", content: "127.0.0.1 localhost", isError: false }],
|
||||
}),
|
||||
message("u2", "user", "show resolv.conf"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call-resolv", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool2", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-resolv", content: "nameserver 8.8.8.8", isError: false }],
|
||||
}),
|
||||
// Important user text so summarizeMessage picks these up via the
|
||||
// important-text branch; tool results themselves are always
|
||||
// summarized regardless of IMPORTANT_PATTERNS.
|
||||
message("u3", "user", "fallback plan"),
|
||||
];
|
||||
|
||||
// Filler to push the early tool results out of the 6-item raw window
|
||||
// and into the compact summary section (scanned = last 20).
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Both older tool results must now carry provenance labels so a
|
||||
// follow-up can disambiguate them.
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/hosts/);
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/resolv\.conf/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
|
||||
// Regression: the scanned loop (last 20) overlaps with recentRaw (last 6).
|
||||
// Without skipping raw-window items, the same last-6 turns would be
|
||||
// summarized in the compact section AND appended verbatim in the raw
|
||||
// section — doubling the budget cost of important user turns / large
|
||||
// tool output and crowding out older durable context.
|
||||
//
|
||||
// Setup: enough filler upfront that u3 ends up OUTSIDE the raw window
|
||||
// (so it can be asserted absent from raw), then a distinctive "raw
|
||||
// only" marker that should appear only in the last-6 raw slice.
|
||||
const messages: ChatMessage[] = [];
|
||||
for (let index = 1; index <= 6; index += 1) {
|
||||
messages.push(
|
||||
message(`uf${index}`, "user", `filler user ${index}`),
|
||||
message(`af${index}`, "assistant", `filler assistant ${index}`),
|
||||
);
|
||||
}
|
||||
// These are the last 4 user/assistant messages — guaranteed to be in
|
||||
// the last-6 raw slice. The IMPORTANT markers below would ordinarily
|
||||
// also get summarized into the compact section, duplicating the cost.
|
||||
messages.push(
|
||||
message("u-rec1", "user", "commit now IMPORTANT_RAW_MARKER please"),
|
||||
message("a-rec1", "assistant", "", {
|
||||
toolCalls: [{ id: "c1", name: "git", arguments: { op: "commit" } }],
|
||||
}),
|
||||
message("tool-rec", "tool", "", {
|
||||
toolResults: [{ toolCallId: "c1", content: "committed abc123 RAW_TOOL_MARKER", isError: false }],
|
||||
}),
|
||||
message("u-rec2", "user", "now push"),
|
||||
);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
const compact = result.find((m) => m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
assert.ok(compact, "expected a compact context message");
|
||||
|
||||
// Both markers belong to messages inside the raw window — they must
|
||||
// not be summarized into compact (which would double-bill them).
|
||||
assert.doesNotMatch(compact.content, /IMPORTANT_RAW_MARKER/);
|
||||
assert.doesNotMatch(compact.content, /RAW_TOOL_MARKER/);
|
||||
|
||||
// Raw section still carries them verbatim.
|
||||
const raw = result.filter((m) => !m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
const rawFlat = raw.map((m) => m.content).join("\n");
|
||||
assert.match(rawFlat, /IMPORTANT_RAW_MARKER/);
|
||||
assert.match(rawFlat, /RAW_TOOL_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
|
||||
// Regression: keying toolCallIndex by raw toolCall.id alone let a later
|
||||
// assistant tool_call with the same id overwrite the older one. An
|
||||
// older tool_result in the replay history would then be annotated
|
||||
// with the wrong command (e.g. a /etc/hosts result labeled as
|
||||
// /etc/resolv.conf). Now each tool_result is indexed by its own
|
||||
// messageId + toolCallId and resolved to the most recent preceding
|
||||
// call with that id.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool-hosts", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "127.0.0.1 localhost HOSTS_BYTES", isError: false }],
|
||||
}),
|
||||
// A later assistant turn reuses the id "call1" for a different call.
|
||||
message("u2", "user", "show resolv"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool-resolv", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "nameserver 8.8.8.8 RESOLV_BYTES", isError: false }],
|
||||
}),
|
||||
message("u3", "user", "ok"),
|
||||
];
|
||||
|
||||
// Pad so the first interaction lands in the compact summary pass.
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Each tool_result must be annotated with ITS OWN preceding call's
|
||||
// args — not whichever assistant tool_call happened to win the
|
||||
// last-write on the shared id.
|
||||
//
|
||||
// Extract the two Tool-result lines and match each to its expected
|
||||
// args. Use non-greedy .*? — the args JSON can contain parentheses.
|
||||
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*HOSTS_BYTES/);
|
||||
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*RESOLV_BYTES/);
|
||||
|
||||
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
|
||||
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Move parser setup into a dedicated hook. 2. Keep storage schema unchanged. 3. Add a regression test.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 7; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", index % 2 === 0 ? "ok" : "continue"),
|
||||
message(`a${index}`, "assistant", "ack"),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move parser setup into a dedicated hook\./);
|
||||
});
|
||||
438
components/ai/acpHistory.ts
Normal file
438
components/ai/acpHistory.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
|
||||
type AcpHistoryMessage = { role: "user" | "assistant"; content: string };
|
||||
type RawHistoryMessage = AcpHistoryMessage & { sourceId: string };
|
||||
type DurableUserLine = {
|
||||
line: string;
|
||||
messageIndex: number;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
const MAX_RECENT_RAW_MESSAGES = 6;
|
||||
const MAX_MESSAGES_TO_SCAN = 20;
|
||||
// Bound the scan by user turns, not raw message count: a tool-heavy ACP
|
||||
// chat can produce 5+ messages per logical turn (user + assistant +
|
||||
// several tool_results + follow-up assistant), so a plain
|
||||
// message-count cap ages out early constraints much sooner than intended.
|
||||
const MAX_DURABLE_SCAN_TURNS = 100;
|
||||
const MAX_COMPACT_CONTEXT_CHARS = 3000;
|
||||
const MAX_RAW_MESSAGE_CHARS = 2000;
|
||||
const MAX_TOOL_SUMMARY_CHARS = 500;
|
||||
const MAX_DURABLE_USER_CONTEXT_CHARS = 1400;
|
||||
const MAX_DURABLE_ASSISTANT_CONTEXT_CHARS = 900;
|
||||
const MAX_RECENT_SUMMARY_CONTEXT_CHARS = 1200;
|
||||
const MAX_DURABLE_USER_MESSAGE_CHARS = 280;
|
||||
const MAX_DURABLE_ASSISTANT_MESSAGE_CHARS = 360;
|
||||
const MAX_TOOL_CALL_LABEL_CHARS = 200;
|
||||
|
||||
type ToolCallInfo = { name: string; arguments: unknown };
|
||||
|
||||
const IMPORTANT_PATTERNS = [
|
||||
/不要|别|不能|不允许|必须|希望|只|最小|先|暂时|fallback|pwsh|powershell|cmd\.exe|windows|mcp|skills|cli|commit|\bpr\b|打包|内存|历史|压缩|慢/i,
|
||||
/error|failed|failure|exit code|exception|cannot|unable|timeout|crash|fallback|commit|pull request|PR #\d+/i,
|
||||
];
|
||||
const DURABLE_CONSTRAINT_PATTERNS = [
|
||||
/\bdo not\b|\bdon't\b|\bkeep\b|\bpreserve\b|\bavoid\b|\bonly\b|\bunchanged\b|\blocal only\b|\bwithout\b|\bleave\b/i,
|
||||
/不要|别|保留|保持|维持|不改|别改|不要改|仅限本地/i,
|
||||
];
|
||||
const TRIVIAL_USER_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|yes|no|thanks|thank you|continue|继续|好的|收到|行|嗯|好|继续处理|继续吧|开始吧)[.!? ]*$/i,
|
||||
];
|
||||
const TRIVIAL_ASSISTANT_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|understood|got it|working|proceeding|ready|ack(?: \d+)?|收到|明白|继续处理|准备实现|开始处理|处理中)[.!? ]*$/i,
|
||||
];
|
||||
|
||||
function truncateText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isImportantText(value: string): boolean {
|
||||
return IMPORTANT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isDurableConstraintText(value: string): boolean {
|
||||
return DURABLE_CONSTRAINT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isTrivialUserMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return false;
|
||||
// Don't blanket-drop short messages — short user turns are often
|
||||
// load-bearing constraints ("Use ssh2", "中文输出", "no logs", "more
|
||||
// verbose") that the IMPORTANT/DURABLE regexes can't realistically
|
||||
// enumerate. The trivial-phrase regex already catches actual filler
|
||||
// ("ok", "yes", "thanks", "继续").
|
||||
return TRIVIAL_USER_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableUserPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isSubstantiveAssistantMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (!normalized) return false;
|
||||
// Mirror the user-side loosening: don't blanket-drop short assistant
|
||||
// messages just because they're under 40 chars or don't match the small
|
||||
// English keyword list. Short but load-bearing decisions ("Use ssh2",
|
||||
// "rebase instead", "中文输出") aren't realistically enumerable and
|
||||
// they're the exact things a later "do what you suggested" references.
|
||||
// TRIVIAL_ASSISTANT_MESSAGE_PATTERNS still catches the actual filler
|
||||
// ("ok", "ack", "got it", "明白").
|
||||
return !TRIVIAL_ASSISTANT_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableAssistantPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function appendUniqueLine(
|
||||
target: string[],
|
||||
seen: Set<string>,
|
||||
line: string,
|
||||
maxSectionChars: number,
|
||||
sectionCharsRef: { value: number },
|
||||
): void {
|
||||
const normalized = normalizeWhitespace(line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = sectionCharsRef.value + normalized.length;
|
||||
if (nextChars > maxSectionChars) return;
|
||||
seen.add(normalized);
|
||||
target.push(normalized);
|
||||
sectionCharsRef.value = nextChars;
|
||||
}
|
||||
|
||||
function summarizeToolMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (!message.toolResults?.length) return [];
|
||||
return message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const content = normalizeWhitespace(result.content || "");
|
||||
// Same provenance problem as the raw-window path: once a tool result
|
||||
// lands in the compact section (older than the 6-item raw window),
|
||||
// its paired assistant tool_call is almost always gone. Without the
|
||||
// call label, multiple older results collapse into indistinguishable
|
||||
// "Tool result (callN): ..." lines and follow-ups like "use the
|
||||
// resolv.conf output" can't be resolved. Inline the name+args here
|
||||
// the same way toRawHistoryMessage does.
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(content, MAX_TOOL_SUMMARY_CHARS)}`;
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (message.role === "system") return [];
|
||||
if (message.role === "tool") return summarizeToolMessage(message, toolCallIndex);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (message.content && isImportantText(message.content)) {
|
||||
const label = message.role === "user" ? "User" : "Assistant";
|
||||
lines.push(`${label}: ${truncateText(normalizeWhitespace(message.content), MAX_TOOL_SUMMARY_CHARS)}`);
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
const args = JSON.stringify(toolCall.arguments ?? {});
|
||||
const summary = `Tool call: ${toolCall.name}(${truncateText(args, 220)})`;
|
||||
if (isImportantText(summary)) lines.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function summarizeDurableUserMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "user" || !message.content) return null;
|
||||
if (isTrivialUserMessage(message.content)) return null;
|
||||
return `User request: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
function summarizeDurableAssistantMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "assistant" || !message.content) return null;
|
||||
if (!isSubstantiveAssistantMessage(message.content)) return null;
|
||||
return `Assistant context: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_ASSISTANT_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-tool-result provenance index. Keys are
|
||||
* `${toolResultMessageId}:${toolCallId}` rather than the bare toolCall.id
|
||||
* so that provider-reused ids (e.g. "call1" across unrelated turns) don't
|
||||
* cause later calls to overwrite older ones in the lookup — each
|
||||
* tool_result resolves to the most recent assistant tool_call that
|
||||
* preceded it with matching id, which preserves historical correctness
|
||||
* when rebuilding older compact summaries.
|
||||
*/
|
||||
function buildToolCallIndex(messages: ChatMessage[]): Map<string, ToolCallInfo> {
|
||||
const provenance = new Map<string, ToolCallInfo>();
|
||||
// Rolling map of the latest tool_call seen (by id) up to the current
|
||||
// point in the message stream.
|
||||
const latestByCallId = new Map<string, ToolCallInfo>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
if (!toolCall.id) continue;
|
||||
latestByCallId.set(toolCall.id, { name: toolCall.name, arguments: toolCall.arguments });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
for (const result of message.toolResults) {
|
||||
const info = latestByCallId.get(result.toolCallId);
|
||||
if (info) {
|
||||
provenance.set(`${message.id}:${result.toolCallId}`, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return provenance;
|
||||
}
|
||||
|
||||
function lookupToolCallInfo(
|
||||
index: Map<string, ToolCallInfo>,
|
||||
toolMessageId: string,
|
||||
toolCallId: string,
|
||||
): ToolCallInfo | undefined {
|
||||
return index.get(`${toolMessageId}:${toolCallId}`);
|
||||
}
|
||||
|
||||
function toRawHistoryMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): RawHistoryMessage[] {
|
||||
if (message.role === "user") {
|
||||
return message.content
|
||||
? [{ sourceId: message.id, role: "user", content: truncateText(message.content, MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
return parts.length
|
||||
? [{ sourceId: message.id, role: "assistant", content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
// Keep tool output in the recent raw window (up to MAX_RAW_MESSAGE_CHARS
|
||||
// per message, ~2000). Without this, follow-up turns after stale-session
|
||||
// recovery would only see the 500-char compact summary in
|
||||
// summarizeToolMessage, losing the actual bytes the user might reference
|
||||
// ("use that output", "what did cat show?"). ACP only supports user/
|
||||
// assistant roles, so we flatten to "assistant" — the tool results were
|
||||
// produced during the assistant's turn.
|
||||
//
|
||||
// Inline the originating tool_call's name+args. Tool calls and their
|
||||
// results live in separate messages; if the last six raw items start
|
||||
// in the middle of a tool interaction, the preceding assistant tool
|
||||
// call can be outside the window. Without the call label the result
|
||||
// is opaque bytes and "use that output" becomes ambiguous.
|
||||
const parts = message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${result.content || ""}`;
|
||||
});
|
||||
return [{
|
||||
sourceId: message.id,
|
||||
role: "assistant",
|
||||
content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS),
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildCompactContext(
|
||||
messages: ChatMessage[],
|
||||
durableScanStart: number,
|
||||
recentRawSourceIds: Set<string>,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): AcpHistoryMessage[] {
|
||||
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
|
||||
const summaryLines: string[] = [];
|
||||
const durableUserCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableUserLines: DurableUserLine[] = [];
|
||||
const durableAssistantCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableAssistantLines: DurableUserLine[] = [];
|
||||
const seen = new Set<string>();
|
||||
const durableChars = { value: 0 };
|
||||
const durableAssistantChars = { value: 0 };
|
||||
const summaryChars = { value: 0 };
|
||||
|
||||
for (let messageIndex = durableScanStart; messageIndex < messages.length; messageIndex += 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
const durableUserLine = summarizeDurableUserMessage(message);
|
||||
if (durableUserLine) {
|
||||
durableUserCandidates.push({
|
||||
line: durableUserLine,
|
||||
messageIndex,
|
||||
priority: getDurableUserPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
const durableAssistantLine = summarizeDurableAssistantMessage(message);
|
||||
if (durableAssistantLine) {
|
||||
durableAssistantCandidates.push({
|
||||
line: durableAssistantLine,
|
||||
messageIndex,
|
||||
priority: getDurableAssistantPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
durableUserCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_USER_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableUserLines.push(candidate);
|
||||
durableChars.value = nextChars;
|
||||
});
|
||||
|
||||
durableAssistantCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableAssistantChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_ASSISTANT_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableAssistantLines.push(candidate);
|
||||
durableAssistantChars.value = nextChars;
|
||||
});
|
||||
|
||||
const durableUserLines = selectedDurableUserLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
const durableAssistantLines = selectedDurableAssistantLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
|
||||
for (const line of [...durableUserLines, ...durableAssistantLines]) {
|
||||
seen.add(normalizeWhitespace(line));
|
||||
}
|
||||
|
||||
// Skip messages that are already appended verbatim in the raw window —
|
||||
// otherwise the same last-6 turns get summarized here AND re-sent as
|
||||
// raw, doubling the budget cost of important user turns / large tool
|
||||
// output and crowding out older durable context the replay is meant
|
||||
// to preserve. Matches the recentRawSourceIds skip in the durable pass.
|
||||
for (const message of scanned) {
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
for (const line of summarizeMessage(message, toolCallIndex)) {
|
||||
appendUniqueLine(summaryLines, seen, line, MAX_RECENT_SUMMARY_CONTEXT_CHARS, summaryChars);
|
||||
}
|
||||
}
|
||||
|
||||
if (!durableUserLines.length && !durableAssistantLines.length && !summaryLines.length) return [];
|
||||
|
||||
const contentLines = [
|
||||
"[Compact prior Netcatty UI context]",
|
||||
"The external ACP agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
|
||||
];
|
||||
if (durableUserLines.length) {
|
||||
contentLines.push("Earlier user requests that may still apply:");
|
||||
contentLines.push(...durableUserLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (durableAssistantLines.length) {
|
||||
contentLines.push("Earlier assistant context that may still matter:");
|
||||
contentLines.push(...durableAssistantLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (summaryLines.length) {
|
||||
contentLines.push("Recent noteworthy context:");
|
||||
contentLines.push(...summaryLines.map((line) => `- ${line}`));
|
||||
}
|
||||
|
||||
return [{
|
||||
role: "user",
|
||||
content: truncateText(
|
||||
contentLines.join("\n"),
|
||||
MAX_COMPACT_CONTEXT_CHARS,
|
||||
),
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first message to include in the scan window,
|
||||
* bounded by MAX_DURABLE_SCAN_TURNS user turns (not raw message count).
|
||||
* Walking backwards stops at the target turn count, so the cost is
|
||||
* bounded even when the transcript is huge.
|
||||
*/
|
||||
function computeDurableScanStart(messages: ChatMessage[]): number {
|
||||
let userTurns = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i].role === "user") {
|
||||
userTurns += 1;
|
||||
if (userTurns >= MAX_DURABLE_SCAN_TURNS) return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMessage[] {
|
||||
// Compute the scan start once, then do all subsequent work over the
|
||||
// already-sliced tail. This avoids O(N) walks over the whole transcript
|
||||
// on every send — previously buildToolCallIndex + the flatMap-to-take-
|
||||
// last-6 raw history both traversed every message in the chat.
|
||||
const durableScanStart = computeDurableScanStart(messages);
|
||||
const scannedTail = messages.slice(durableScanStart);
|
||||
|
||||
// The tool-call provenance index only needs entries for tool_results
|
||||
// that might appear in our output. Building from the scanned tail is
|
||||
// correct for any tool_result whose paired assistant tool_call is
|
||||
// also within the window, which covers >99% of realistic patterns
|
||||
// (tool_calls and tool_results are always adjacent or near-adjacent).
|
||||
// If an ancient tool_call's result stays within the window while the
|
||||
// call itself is outside, that single result loses its [from X(Y)]
|
||||
// label — an acceptable trade for eliminating the per-send O(N) walk.
|
||||
const toolCallIndex = buildToolCallIndex(scannedTail);
|
||||
|
||||
const rawHistory = scannedTail
|
||||
.flatMap((message) => toRawHistoryMessage(message, toolCallIndex))
|
||||
.slice(-MAX_RECENT_RAW_MESSAGES);
|
||||
const compactContext = buildCompactContext(
|
||||
messages,
|
||||
durableScanStart,
|
||||
new Set(rawHistory.map((message) => message.sourceId)),
|
||||
toolCallIndex,
|
||||
);
|
||||
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
|
||||
|
||||
return [...compactContext, ...recentRaw];
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessagesForBridge(
|
||||
messages: ChatMessage[],
|
||||
_existingSessionId?: string | null,
|
||||
): AcpHistoryMessage[] | undefined {
|
||||
// The main process bridge only consumes this payload during stale-session
|
||||
// fallback replay, so keep it available even when a session id exists.
|
||||
const historyMessages = buildAcpHistoryMessages(messages);
|
||||
return historyMessages.length ? historyMessages : undefined;
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import type {
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
normalizePanelView,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
shouldRetargetSessionForScope,
|
||||
} from "./aiPanelViewState.ts";
|
||||
|
||||
function createSession(id: string): AISession {
|
||||
@@ -61,7 +61,7 @@ test("missing explicit panel view resumes the most recent matching history when
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions),
|
||||
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
@@ -70,7 +70,7 @@ test("missing explicit panel view restores the persisted active session instead
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1"),
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "workspace"),
|
||||
{ mode: "session", sessionId: "session-1" },
|
||||
);
|
||||
});
|
||||
@@ -79,7 +79,7 @@ test("persisted session id that no longer exists in history falls back to newest
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session"),
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session", "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
@@ -88,11 +88,20 @@ test("null persisted session id falls back to newest history entry", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, null),
|
||||
resolveDisplayedPanelView(undefined, false, sessions, null, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal scope without explicit view always starts from draft even when history exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "terminal"),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view prefers the draft when unsent input exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
@@ -109,50 +118,6 @@ test("draft state is used when there is no implicit history to resume", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("restorable terminal history should retarget to the current scope", () => {
|
||||
const session: AISession = {
|
||||
...createSession("session-2"),
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId: "old-terminal",
|
||||
hostIds: ["host-1"],
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRetargetSessionForScope(
|
||||
session,
|
||||
"terminal",
|
||||
"new-terminal",
|
||||
["host-1"],
|
||||
new Set<string>(),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("session owned by another active terminal should not retarget", () => {
|
||||
const session: AISession = {
|
||||
...createSession("session-2"),
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId: "other-active-terminal",
|
||||
hostIds: ["host-1"],
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRetargetSessionForScope(
|
||||
session,
|
||||
"terminal",
|
||||
"new-terminal",
|
||||
["host-1"],
|
||||
new Set<string>(["other-active-terminal"]),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("history selection switches to the chosen session without touching draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -174,3 +139,39 @@ test("history selection switches to the chosen session without touching draft st
|
||||
"close-history",
|
||||
]);
|
||||
});
|
||||
|
||||
test("draft entry ensures a draft exists before switching the panel to draft mode", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => {
|
||||
calls.push("ensure-draft");
|
||||
},
|
||||
showDraftView: () => {
|
||||
calls.push("show-draft");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"ensure-draft",
|
||||
"show-draft",
|
||||
]);
|
||||
});
|
||||
|
||||
test("draft entry can preserve the current session view while ensuring draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => {
|
||||
calls.push("ensure-draft");
|
||||
},
|
||||
showDraftView: () => {
|
||||
calls.push("show-draft");
|
||||
},
|
||||
preserveSessionView: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"ensure-draft",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -11,11 +11,18 @@ interface HistorySessionSelectionActions {
|
||||
closeHistory?: () => void;
|
||||
}
|
||||
|
||||
interface DraftEntrySelectionActions {
|
||||
ensureDraft: () => void;
|
||||
showDraftView: () => void;
|
||||
preserveSessionView?: boolean;
|
||||
}
|
||||
|
||||
export function resolveDisplayedPanelView(
|
||||
panelView: AIPanelView | undefined,
|
||||
hasDraft: boolean,
|
||||
sessions: AISession[],
|
||||
persistedSessionId?: string | null,
|
||||
scopeType: "terminal" | "workspace" = "workspace",
|
||||
): AIPanelView {
|
||||
if (panelView) {
|
||||
return normalizePanelView(panelView, sessions);
|
||||
@@ -25,6 +32,12 @@ export function resolveDisplayedPanelView(
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// New terminal sessions should always start from a blank draft. History is
|
||||
// still available in the drawer, but never auto-resumed into a fresh SSH tab.
|
||||
if (scopeType === "terminal") {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// Honour the persisted active-session selection (survives cold mount)
|
||||
// before falling back to the newest history entry.
|
||||
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
|
||||
@@ -62,28 +75,6 @@ export function resolveDisplayedSession(
|
||||
return sessions.find((session) => session.id === panelView.sessionId) ?? null;
|
||||
}
|
||||
|
||||
export function shouldRetargetSessionForScope(
|
||||
session: AISession | null,
|
||||
scopeType: "terminal" | "workspace",
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
activeTerminalTargetIds?: Set<string>,
|
||||
): boolean {
|
||||
if (!session || scopeType !== "terminal" || !scopeTargetId || !scopeHostIds?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.scope.type !== scopeType || session.scope.targetId === scopeTargetId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return session.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
|
||||
}
|
||||
|
||||
export function applyHistorySessionSelection(
|
||||
sessionId: string,
|
||||
actions: HistorySessionSelectionActions,
|
||||
@@ -92,3 +83,12 @@ export function applyHistorySessionSelection(
|
||||
actions.setActiveSessionId(sessionId);
|
||||
actions.closeHistory?.();
|
||||
}
|
||||
|
||||
export function applyDraftEntrySelection(
|
||||
actions: DraftEntrySelectionActions,
|
||||
): void {
|
||||
actions.ensureDraft();
|
||||
if (!actions.preserveSessionView) {
|
||||
actions.showDraftView();
|
||||
}
|
||||
}
|
||||
|
||||
18
components/ai/draftSendGate.test.ts
Normal file
18
components/ai/draftSendGate.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
} from "./draftSendGate.ts";
|
||||
|
||||
test("draft send gate allows only one in-flight draft send at a time", () => {
|
||||
const gate = { current: false };
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
assert.equal(tryBeginDraftSend(gate), false);
|
||||
|
||||
endDraftSend(gate);
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
});
|
||||
12
components/ai/draftSendGate.ts
Normal file
12
components/ai/draftSendGate.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function tryBeginDraftSend(gate: { current: boolean }): boolean {
|
||||
if (gate.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
gate.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endDraftSend(gate: { current: boolean }): void {
|
||||
gate.current = false;
|
||||
}
|
||||
@@ -355,14 +355,13 @@ export function useAIChatStreaming({
|
||||
err: unknown,
|
||||
) => {
|
||||
if (abortSignal.aborted) return;
|
||||
let errorStr: string;
|
||||
if (err instanceof Error) errorStr = err.message;
|
||||
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
|
||||
else if (typeof err === 'string') errorStr = err;
|
||||
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
|
||||
// Log the full unsanitized error for debugging
|
||||
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
|
||||
const errorInfo = classifyError(errorStr);
|
||||
console.error('[AIChatSidePanel] Stream error (full):', err);
|
||||
// Pass the raw error to classifyError so it can inspect structured
|
||||
// fields (statusCode, responseBody) from APICallError and friends;
|
||||
// string-coercing here would strip the metadata we need to detect
|
||||
// 413 / HTML-error-page / parse-failure scenarios.
|
||||
const errorInfo = classifyError(err);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
@@ -560,11 +559,10 @@ export function useAIChatStreaming({
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(
|
||||
typedChunk.error instanceof Error ? typedChunk.error.message
|
||||
: typeof typedChunk.error === 'string' ? typedChunk.error
|
||||
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
|
||||
),
|
||||
// Pass the raw error so classifyError can detect 413 / HTML /
|
||||
// schema-parse scenarios via structured fields (statusCode,
|
||||
// responseBody) instead of lossy string conversion.
|
||||
errorInfo: classifyError(typedChunk.error),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
|
||||
15
components/ai/sessionHistoryLayout.test.ts
Normal file
15
components/ai/sessionHistoryLayout.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SESSION_HISTORY_ROW_CLASSNAMES,
|
||||
} from "./sessionHistoryLayout.ts";
|
||||
|
||||
test("session history row keeps metadata pinned to the end while title truncates", () => {
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.row, /\bgrid\b/);
|
||||
assert.ok(SESSION_HISTORY_ROW_CLASSNAMES.row.includes('grid-cols-[minmax(0,1fr)_auto]'));
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\btruncate\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\bmin-w-0\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bjustify-self-end\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bshrink-0\b/);
|
||||
});
|
||||
7
components/ai/sessionHistoryLayout.ts
Normal file
7
components/ai/sessionHistoryLayout.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const SESSION_HISTORY_ROW_CLASSNAMES = {
|
||||
row: 'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
title: 'text-[13px] truncate min-w-0',
|
||||
meta: 'flex items-center gap-2 justify-self-end shrink-0',
|
||||
time: 'text-[12px] text-muted-foreground/50 whitespace-nowrap',
|
||||
deleteButton: 'opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer shrink-0',
|
||||
} as const;
|
||||
101
components/ai/sessionScopeMatch.test.ts
Normal file
101
components/ai/sessionScopeMatch.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AISession } from "../../infrastructure/ai/types.ts";
|
||||
import { getSessionScopeMatchRank } from "./sessionScopeMatch.ts";
|
||||
|
||||
function createSession(id: string, targetId: string, hostIds: string[]): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
agentId: "catty",
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId,
|
||||
hostIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("host-matched terminal session is excluded when another active terminal already displays it", () => {
|
||||
const session = createSession("session-1", "terminal-other", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(["session-1"]),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("host-matched terminal session remains resumable when no terminal is displaying it", () => {
|
||||
const session = createSession("session-1", "terminal-closed", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(["session-other"]),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("ownership is tracked by session id, not scope.targetId", () => {
|
||||
// Session was created in terminal-A but a different terminal (B) is now
|
||||
// displaying it after the user resumed it from history. Opening a third
|
||||
// terminal (C) should not see this session as owned, because the new
|
||||
// ownership check is keyed on session id, not the stale targetId.
|
||||
const session = createSession("session-1", "terminal-A", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-C",
|
||||
["host-a"],
|
||||
// terminal-B is displaying session-1; pass session-1 as an
|
||||
// active-id so C sees it as in-use
|
||||
new Set(["session-1"]),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("session targeting the current scope is an exact match (rank 2)", () => {
|
||||
const session = createSession("session-1", "terminal-current", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(),
|
||||
),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("scope type mismatch returns 0 regardless of target or hosts", () => {
|
||||
const session = createSession("session-1", "terminal-current", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"workspace",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
28
components/ai/sessionScopeMatch.ts
Normal file
28
components/ai/sessionScopeMatch.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { AISession } from "../../infrastructure/ai/types";
|
||||
|
||||
export function getSessionScopeMatchRank(
|
||||
session: AISession,
|
||||
scopeType: "terminal" | "workspace",
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
/**
|
||||
* Session ids currently displayed by other terminal scopes. Tracked by
|
||||
* session id rather than `scope.targetId` so that a host-matched session
|
||||
* resumed from a different terminal is still recognised as in-use and
|
||||
* not offered (or cleaned) as if it were orphaned.
|
||||
*/
|
||||
activeTerminalSessionIds?: Set<string>,
|
||||
): number {
|
||||
if (session.scope.type !== scopeType) return 0;
|
||||
if (session.scope.targetId === scopeTargetId) return 2;
|
||||
|
||||
if (scopeType !== "terminal" || !scopeHostIds?.length || !session.scope.hostIds?.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (activeTerminalSessionIds?.has(session.id)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
|
||||
}
|
||||
@@ -815,6 +815,20 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.clearWipesScrollback")}
|
||||
description={t("settings.terminal.behavior.clearWipesScrollback.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.clearWipesScrollback ?? true} onChange={(v) => updateTerminalSetting("clearWipesScrollback", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.preserveSelectionOnInput")}
|
||||
description={t("settings.terminal.behavior.preserveSelectionOnInput.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.preserveSelectionOnInput ?? false} onChange={(v) => updateTerminalSetting("preserveSelectionOnInput", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
|
||||
|
||||
51
components/sync/SyncBlockedBanner.tsx
Normal file
51
components/sync/SyncBlockedBanner.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { Button } from '../ui/button';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
interface Props {
|
||||
finding: Extract<ShrinkFinding, { suspicious: true }>;
|
||||
onRestore: () => void;
|
||||
onForcePush: () => void;
|
||||
}
|
||||
|
||||
export const SyncBlockedBanner: React.FC<Props> = ({ finding, onRestore, onForcePush }) => {
|
||||
const { t } = useI18n();
|
||||
const entityLabel = t(`sync.entityType.${finding.entityType}`);
|
||||
const percent = finding.baseCount > 0 ? Math.round((finding.lost / finding.baseCount) * 100) : 0;
|
||||
|
||||
const reasonText = finding.reason === 'bulk-shrink'
|
||||
? t('sync.blocked.reason.bulkShrink', {
|
||||
lost: finding.lost,
|
||||
baseCount: finding.baseCount,
|
||||
entityType: entityLabel,
|
||||
percent,
|
||||
})
|
||||
: t('sync.blocked.reason.largeShrink', {
|
||||
lost: finding.lost,
|
||||
entityType: entityLabel,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<span>{t('sync.blocked.title')}</span>
|
||||
</div>
|
||||
<p className="text-sm">{reasonText}</p>
|
||||
<p className="text-xs opacity-70">{t('sync.blocked.detail')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="default" size="sm" onClick={onRestore}>
|
||||
{t('sync.blocked.restoreButton')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onForcePush}>
|
||||
{t('sync.blocked.forcePushButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -690,7 +690,9 @@ export function useTerminalAutocomplete(
|
||||
}
|
||||
}
|
||||
|
||||
// Tab: accept selected popup suggestion, or accept ghost text
|
||||
// Tab: accept selected popup suggestion. Ghost text is accepted via → only —
|
||||
// letting Tab pass through lets the shell's native completion (bash/zsh) run,
|
||||
// which is otherwise shadowed by our single-Tab ghost accept.
|
||||
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
|
||||
if (s.popupVisible && s.suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
@@ -698,16 +700,10 @@ export function useTerminalAutocomplete(
|
||||
if (selected) insertSuggestion(selected, false);
|
||||
return false;
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
// completion will rewrite the line and the old ghost would mislead.
|
||||
if (ghost?.isVisible()) {
|
||||
e.preventDefault();
|
||||
const ghostText = ghost.getGhostText();
|
||||
if (ghostText) {
|
||||
writeToTerminal(ghostText);
|
||||
lastAcceptedCommandRef.current = ghost.getSuggestion();
|
||||
ghost.hide();
|
||||
clearState();
|
||||
}
|
||||
return false;
|
||||
ghost.hide();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,10 @@ export type CreateXTermRuntimeContext = {
|
||||
onAutocompleteKeyEvent?: (e: KeyboardEvent) => boolean;
|
||||
// Autocomplete input handler — called on every character input
|
||||
onAutocompleteInput?: (data: string) => void;
|
||||
|
||||
// Set to true while we're programmatically restoring a selection so that
|
||||
// copy-on-select listeners can suppress redundant clipboard writes.
|
||||
isRestoringSelectionRef?: RefObject<boolean>;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -419,6 +423,38 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true;
|
||||
}
|
||||
|
||||
// Preserve mouse selection across keystrokes when enabled. xterm.js
|
||||
// unconditionally clears the selection on user input
|
||||
// (SelectionService.ts: coreService.onUserInput → clearSelection).
|
||||
// Capture the selection here, then re-apply it after xterm has
|
||||
// processed the key + cleared. The microtask runs after both
|
||||
// synchronous listeners, so by then either the selection is gone (and
|
||||
// we restore) or it's still there (we no-op).
|
||||
if (
|
||||
ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
|
||||
term.hasSelection()
|
||||
) {
|
||||
const sel = term.getSelectionPosition();
|
||||
if (sel) {
|
||||
const length =
|
||||
(sel.end.y - sel.start.y) * term.cols + (sel.end.x - sel.start.x);
|
||||
const savedStartX = sel.start.x;
|
||||
const savedStartY = sel.start.y;
|
||||
queueMicrotask(() => {
|
||||
if (term.hasSelection()) return;
|
||||
// Bail out if scrollback trim invalidated the row index.
|
||||
if (savedStartY >= term.buffer.active.length) return;
|
||||
const restoreFlag = ctx.isRestoringSelectionRef;
|
||||
if (restoreFlag) restoreFlag.current = true;
|
||||
try {
|
||||
term.select(savedStartX, savedStartY, length);
|
||||
} finally {
|
||||
if (restoreFlag) restoreFlag.current = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Autocomplete key handler (must be checked before other handlers)
|
||||
if (ctx.onAutocompleteKeyEvent) {
|
||||
const consumed = ctx.onAutocompleteKeyEvent(e);
|
||||
@@ -664,7 +700,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (!isEraseScrollbackSequence(params)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
|
||||
// Honor it unless the user opts into the legacy "preserve history" behavior.
|
||||
const wipeAllowed = ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true;
|
||||
return !wipeAllowed;
|
||||
});
|
||||
|
||||
// Register OSC 7 handler using xterm.js parser
|
||||
|
||||
64
components/terminal/toolbarFocus.test.ts
Normal file
64
components/terminal/toolbarFocus.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./toolbarFocus.ts";
|
||||
|
||||
test("preserves terminal focus for non-editable overlay clicks", () => {
|
||||
const buttonLikeTarget = {
|
||||
tagName: "button",
|
||||
isContentEditable: false,
|
||||
closest() {
|
||||
return null;
|
||||
},
|
||||
getAttribute() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(buttonLikeTarget as unknown as EventTarget), true);
|
||||
});
|
||||
|
||||
test("allows native focus for direct editable targets", () => {
|
||||
const inputTarget = {
|
||||
tagName: "input",
|
||||
isContentEditable: false,
|
||||
closest() {
|
||||
return null;
|
||||
},
|
||||
getAttribute() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(inputTarget as unknown as EventTarget), false);
|
||||
});
|
||||
|
||||
test("allows native focus for descendants inside editable controls", () => {
|
||||
const nestedTarget = {
|
||||
tagName: "span",
|
||||
isContentEditable: false,
|
||||
closest(selector: string) {
|
||||
return selector.includes("input") ? { tagName: "INPUT" } : null;
|
||||
},
|
||||
getAttribute() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(nestedTarget as unknown as EventTarget), false);
|
||||
});
|
||||
|
||||
test("allows native focus for contenteditable regions", () => {
|
||||
const editableTarget = {
|
||||
tagName: "div",
|
||||
isContentEditable: false,
|
||||
closest() {
|
||||
return null;
|
||||
},
|
||||
getAttribute(name: string) {
|
||||
return name === "contenteditable" ? "true" : null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(editableTarget as unknown as EventTarget), false);
|
||||
});
|
||||
44
components/terminal/toolbarFocus.ts
Normal file
44
components/terminal/toolbarFocus.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
type FocusTargetLike = {
|
||||
tagName?: string | null;
|
||||
isContentEditable?: boolean;
|
||||
closest?: (selector: string) => unknown;
|
||||
getAttribute?: (name: string) => string | null;
|
||||
};
|
||||
|
||||
const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]';
|
||||
|
||||
/**
|
||||
* The terminal's top overlay sits above the xterm textarea. Pointer clicks on
|
||||
* that layer should usually keep focus in the terminal so typing can continue.
|
||||
* Only allow native focus changes for genuinely editable controls.
|
||||
*/
|
||||
export const shouldPreserveTerminalFocusOnMouseDown = (target: EventTarget | null): boolean => {
|
||||
if (!target || typeof target !== "object") return true;
|
||||
|
||||
const candidate = target as FocusTargetLike;
|
||||
const tagName = typeof candidate.tagName === "string"
|
||||
? candidate.tagName.toUpperCase()
|
||||
: "";
|
||||
|
||||
if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.isContentEditable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof candidate.getAttribute === "function") {
|
||||
const contentEditable = candidate.getAttribute("contenteditable");
|
||||
const role = candidate.getAttribute("role");
|
||||
if (contentEditable === "" || contentEditable === "true" || role === "textbox") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof candidate.closest === "function" && candidate.closest(EDITABLE_SELECTOR)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
51
domain/host.test.ts
Normal file
51
domain/host.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host } from "./models.ts";
|
||||
import { upsertHostById } from "./host.ts";
|
||||
|
||||
const makeHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "Primary Host",
|
||||
hostname: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
createdAt: 1,
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("upsertHostById updates an existing host in place", () => {
|
||||
const existing = makeHost();
|
||||
const updated = makeHost({ label: "Updated Host" });
|
||||
|
||||
assert.deepEqual(upsertHostById([existing], updated), [updated]);
|
||||
});
|
||||
|
||||
test("upsertHostById appends a duplicated host with a fresh id", () => {
|
||||
const existing = makeHost({
|
||||
id: "serial-original",
|
||||
label: "Serial Config",
|
||||
protocol: "serial",
|
||||
hostname: "/dev/ttyUSB0",
|
||||
port: 115200,
|
||||
serialConfig: {
|
||||
path: "/dev/ttyUSB0",
|
||||
baudRate: 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
flowControl: "none",
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
},
|
||||
});
|
||||
const duplicate = makeHost({
|
||||
...existing,
|
||||
id: "serial-duplicate",
|
||||
label: "Serial Config (copy)",
|
||||
});
|
||||
|
||||
assert.deepEqual(upsertHostById([existing], duplicate), [existing, duplicate]);
|
||||
});
|
||||
@@ -153,6 +153,13 @@ export const formatHostPort = (hostname: string, port?: number | null): string =
|
||||
return `${display}:${port}`;
|
||||
};
|
||||
|
||||
export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
|
||||
const hostExists = hosts.some((entry) => entry.id === host.id);
|
||||
return hostExists
|
||||
? hosts.map((entry) => (entry.id === host.id ? host : entry))
|
||||
: [...hosts, host];
|
||||
};
|
||||
|
||||
export const sanitizeHost = (host: Host): Host => {
|
||||
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
|
||||
const cleanDistro = normalizeDistroId(host.distro);
|
||||
|
||||
@@ -497,6 +497,18 @@ export interface TerminalSettings {
|
||||
// Paste
|
||||
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
|
||||
|
||||
// Shell `clear` command behavior — controls whether CSI 3 J (erase scrollback)
|
||||
// from the shell is honored. Default true matches POSIX/ncurses since 2013:
|
||||
// `clear` clears both visible screen and scrollback. Disable to keep history
|
||||
// across `clear` (matches iTerm2 default and pre-2013 behavior).
|
||||
clearWipesScrollback: boolean;
|
||||
|
||||
// When true, typing on the keyboard does NOT clear an existing mouse
|
||||
// selection. Lets the user select text, type a command prefix (e.g. `sz `),
|
||||
// and then paste the still-live selection. xterm.js's default is to clear
|
||||
// on input; this opt-in toggle restores the selection right after.
|
||||
preserveSelectionOnInput: boolean;
|
||||
|
||||
// Clipboard
|
||||
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
|
||||
|
||||
@@ -625,6 +637,8 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
showServerStats: true, // Show server stats by default
|
||||
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
|
||||
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
|
||||
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
|
||||
rendererType: 'auto', // Auto-detect best renderer based on hardware
|
||||
autocompleteEnabled: true, // Autocomplete enabled by default
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Cloud Sync Domain Types & Interfaces
|
||||
*
|
||||
*
|
||||
* Zero-Knowledge Encrypted Multi-Cloud Sync System
|
||||
* Supports: GitHub Gist, Google Drive, Microsoft OneDrive, WebDAV, S3 Compatible
|
||||
*/
|
||||
|
||||
import type { ShrinkFinding } from './syncGuards';
|
||||
|
||||
// ============================================================================
|
||||
// Security State Machine
|
||||
// ============================================================================
|
||||
@@ -22,10 +24,11 @@ export type SecurityState =
|
||||
* Sync Operation State Machine
|
||||
* Tracks the current sync operation status
|
||||
*/
|
||||
export type SyncState =
|
||||
export type SyncState =
|
||||
| 'IDLE' // Waiting for sync trigger
|
||||
| 'SYNCING' // Active sync operation in progress
|
||||
| 'CONFLICT' // Version conflict detected - needs resolution
|
||||
| 'BLOCKED' // Outgoing payload would delete too much — user must choose restore or force-push
|
||||
| 'ERROR'; // Operation failed - needs attention
|
||||
|
||||
/**
|
||||
@@ -284,6 +287,10 @@ export interface SyncResult {
|
||||
conflictDetected?: boolean;
|
||||
/** Present when action === 'merge'; caller should apply this to update local state */
|
||||
mergedPayload?: import('./sync').SyncPayload;
|
||||
/** True when a shrink-detection guard blocked the upload */
|
||||
shrinkBlocked?: boolean;
|
||||
/** The finding that triggered the shrink block or force-push */
|
||||
finding?: ShrinkFinding;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -351,10 +358,13 @@ export type SyncEvent =
|
||||
| { type: 'SYNC_COMPLETED'; provider: CloudProvider; result: SyncResult }
|
||||
| { type: 'SYNC_ERROR'; provider: CloudProvider; error: string }
|
||||
| { type: 'CONFLICT_DETECTED'; conflict: ConflictInfo }
|
||||
| { type: 'SYNC_BLOCKED_SHRINK'; provider: CloudProvider; finding: ShrinkFinding }
|
||||
| { type: 'SYNC_FORCED'; provider: CloudProvider; finding: ShrinkFinding }
|
||||
| { type: 'CONFLICT_RESOLVED'; resolution: ConflictResolution }
|
||||
| { type: 'AUTH_REQUIRED'; provider: CloudProvider }
|
||||
| { type: 'AUTH_COMPLETED'; provider: CloudProvider; account: ProviderAccount }
|
||||
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState };
|
||||
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState }
|
||||
| { type: 'SYNC_BLOCKED_CLEARED' };
|
||||
|
||||
// ============================================================================
|
||||
// Storage Keys
|
||||
|
||||
139
domain/syncGuards.test.ts
Normal file
139
domain/syncGuards.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { detectSuspiciousShrink } from "./syncGuards.ts";
|
||||
import type { SyncPayload } from "./sync.ts";
|
||||
|
||||
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
|
||||
return {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
knownHosts: [],
|
||||
portForwardingRules: [],
|
||||
groupConfigs: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function hosts(n: number): SyncPayload["hosts"] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `h${i}`,
|
||||
label: `h${i}`,
|
||||
hostname: `h${i}.example`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
})) as SyncPayload["hosts"];
|
||||
}
|
||||
|
||||
test("null base → not suspicious (first sync / null after re-auth)", () => {
|
||||
const result = detectSuspiciousShrink(payload({ hosts: hosts(1) }), null);
|
||||
assert.deepEqual(result, { suspicious: false });
|
||||
});
|
||||
|
||||
test("no shrink — same counts → not suspicious", () => {
|
||||
const base = payload({ hosts: hosts(5) });
|
||||
const out = payload({ hosts: hosts(5) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("growth only → not suspicious", () => {
|
||||
const base = payload({ hosts: hosts(5) });
|
||||
const out = payload({ hosts: hosts(10) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("shrink under both thresholds → not suspicious (delete 2 of 4)", () => {
|
||||
const base = payload({ hosts: hosts(4) });
|
||||
const out = payload({ hosts: hosts(2) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("bulk-shrink 50% AND absolute 3 — exactly at threshold → suspicious", () => {
|
||||
const base = payload({ hosts: hosts(6) });
|
||||
const out = payload({ hosts: hosts(3) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), {
|
||||
suspicious: true,
|
||||
reason: "bulk-shrink",
|
||||
entityType: "hosts",
|
||||
baseCount: 6,
|
||||
outgoingCount: 3,
|
||||
lost: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("bulk-shrink 50% but absolute 2 → not suspicious (absolute gate)", () => {
|
||||
const base = payload({ hosts: hosts(4) });
|
||||
const out = payload({ hosts: hosts(2) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("bulk-shrink 40% absolute 4 → not suspicious (ratio gate)", () => {
|
||||
const base = payload({ hosts: hosts(10) });
|
||||
const out = payload({ hosts: hosts(6) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("large-shrink absolute 10 regardless of ratio → suspicious", () => {
|
||||
const base = payload({ hosts: hosts(100) });
|
||||
const out = payload({ hosts: hosts(90) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), {
|
||||
suspicious: true,
|
||||
reason: "large-shrink",
|
||||
entityType: "hosts",
|
||||
baseCount: 100,
|
||||
outgoingCount: 90,
|
||||
lost: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test("dual-trigger (large-shrink AND bulk-shrink both satisfied) → reason is 'large-shrink'", () => {
|
||||
// base=20, lost=10: satisfies large-shrink (>=10) AND bulk-shrink (50%, >=3)
|
||||
const base = payload({ hosts: hosts(20) });
|
||||
const out = payload({ hosts: hosts(10) });
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) assert.equal(result.reason, "large-shrink");
|
||||
});
|
||||
|
||||
test("multiple entity types shrinking — returns first in declaration order (hosts before keys)", () => {
|
||||
const base = payload({ hosts: hosts(6), keys: Array.from({ length: 6 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
|
||||
const out = payload({ hosts: hosts(3), keys: Array.from({ length: 3 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) assert.equal(result.entityType, "hosts");
|
||||
});
|
||||
|
||||
test("only non-hosts entity shrinks → reports that entity", () => {
|
||||
const snippets = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `s${i}`, label: `s${i}`, command: "" })) as SyncPayload["snippets"];
|
||||
const base = payload({ snippets: snippets(10) });
|
||||
const out = payload({ snippets: snippets(0) });
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) {
|
||||
assert.equal(result.entityType, "snippets");
|
||||
assert.equal(result.reason, "large-shrink");
|
||||
}
|
||||
});
|
||||
|
||||
test("knownHosts shrink triggers (security-sensitive)", () => {
|
||||
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
|
||||
const base = payload({ knownHosts: kh(12) });
|
||||
const out = payload({ knownHosts: kh(2) });
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) assert.equal(result.entityType, "knownHosts");
|
||||
});
|
||||
|
||||
test("empty base (all zeros) — no shrink possible, returns not suspicious", () => {
|
||||
const base = payload();
|
||||
const out = payload({ hosts: hosts(5) });
|
||||
// All base counts are 0; no shrink possible
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
85
domain/syncGuards.ts
Normal file
85
domain/syncGuards.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { SyncPayload } from './sync';
|
||||
|
||||
export type ShrinkFinding =
|
||||
| { suspicious: false }
|
||||
| {
|
||||
suspicious: true;
|
||||
reason: 'bulk-shrink' | 'large-shrink';
|
||||
entityType:
|
||||
| 'hosts'
|
||||
| 'keys'
|
||||
| 'identities'
|
||||
| 'snippets'
|
||||
| 'customGroups'
|
||||
| 'snippetPackages'
|
||||
| 'knownHosts'
|
||||
| 'portForwardingRules'
|
||||
| 'groupConfigs';
|
||||
baseCount: number;
|
||||
outgoingCount: number;
|
||||
lost: number;
|
||||
};
|
||||
|
||||
// Keep in sync with all array-typed fields of SyncPayload. When a new
|
||||
// array entity type is added there, add it here too — there is no
|
||||
// compile-time check enforcing this.
|
||||
const CHECKED_ENTITIES = [
|
||||
'hosts',
|
||||
'keys',
|
||||
'identities',
|
||||
'snippets',
|
||||
'customGroups',
|
||||
'snippetPackages',
|
||||
'knownHosts',
|
||||
'portForwardingRules',
|
||||
'groupConfigs',
|
||||
] as const;
|
||||
|
||||
type CheckedEntityType = typeof CHECKED_ENTITIES[number];
|
||||
|
||||
const BULK_SHRINK_RATIO = 0.5;
|
||||
const BULK_SHRINK_MIN_ABSOLUTE = 3;
|
||||
const LARGE_SHRINK_ABSOLUTE = 10;
|
||||
|
||||
function countOf(p: SyncPayload, key: CheckedEntityType): number {
|
||||
const v = p[key];
|
||||
return Array.isArray(v) ? v.length : 0;
|
||||
}
|
||||
|
||||
export function detectSuspiciousShrink(
|
||||
outgoing: SyncPayload,
|
||||
base: SyncPayload | null,
|
||||
): ShrinkFinding {
|
||||
if (!base) return { suspicious: false };
|
||||
|
||||
for (const entityType of CHECKED_ENTITIES) {
|
||||
const baseCount = countOf(base, entityType);
|
||||
const outgoingCount = countOf(outgoing, entityType);
|
||||
const lost = baseCount - outgoingCount;
|
||||
if (lost <= 0) continue;
|
||||
|
||||
if (lost >= LARGE_SHRINK_ABSOLUTE) {
|
||||
return {
|
||||
suspicious: true,
|
||||
reason: 'large-shrink',
|
||||
entityType,
|
||||
baseCount,
|
||||
outgoingCount,
|
||||
lost,
|
||||
};
|
||||
}
|
||||
|
||||
if (baseCount > 0 && lost / baseCount >= BULK_SHRINK_RATIO && lost >= BULK_SHRINK_MIN_ABSOLUTE) {
|
||||
return {
|
||||
suspicious: true,
|
||||
reason: 'bulk-shrink',
|
||||
entityType,
|
||||
baseCount,
|
||||
outgoingCount,
|
||||
lost,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { suspicious: false };
|
||||
}
|
||||
@@ -29,6 +29,7 @@ module.exports = {
|
||||
'node_modules/node-pty/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*',
|
||||
'node_modules/@vscode/windows-process-tree/**/*',
|
||||
'node_modules/@zed-industries/claude-agent-acp/**/*',
|
||||
'node_modules/@agentclientprotocol/sdk/**/*',
|
||||
'node_modules/@anthropic-ai/claude-agent-sdk/**/*',
|
||||
|
||||
@@ -88,7 +88,7 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
`set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
|
||||
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; ` +
|
||||
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
|
||||
`printf '%s\\n' '${marker}_S'; eval -- \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`printf '%s\\n' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
|
||||
);
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ const path = require("node:path");
|
||||
const USER_SKILLS_DIR_NAME = "Skills";
|
||||
const USER_SKILLS_README_NAME = "README.txt";
|
||||
const MAX_SKILL_BYTES = 24 * 1024;
|
||||
const MAX_DESCRIPTION_LENGTH = 280;
|
||||
const MAX_DESCRIPTION_LENGTH = 500;
|
||||
const MAX_INDEX_SKILLS = 8;
|
||||
const MAX_INDEX_DESCRIPTION_CHARS = 160;
|
||||
const MAX_INDEX_LINE_CHARS = 1400;
|
||||
const MAX_EXPLICIT_SKILLS = 4;
|
||||
const MAX_MATCHED_SKILLS = 2;
|
||||
const MAX_MATCHED_SKILL_CHARS = 6000;
|
||||
@@ -67,6 +69,12 @@ function escapeRegExp(value) {
|
||||
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function truncateInlineText(value, maxChars) {
|
||||
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= maxChars) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function formatSkillReadWarning(error) {
|
||||
const code = typeof error?.code === "string" ? error.code : null;
|
||||
const message = typeof error?.message === "string" ? error.message : String(error || "Unknown error");
|
||||
@@ -354,11 +362,22 @@ async function buildUserSkillsContext(electronApp, prompt, selectedSkillSlugs =
|
||||
}
|
||||
|
||||
const indexSkills = readySkills.slice(0, MAX_INDEX_SKILLS);
|
||||
const remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
|
||||
let remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
|
||||
const indexEntries = [];
|
||||
let indexChars = 0;
|
||||
|
||||
const indexLine = indexSkills
|
||||
.map((skill) => `${skill.name}: ${skill.description}`)
|
||||
.join("; ");
|
||||
for (const skill of indexSkills) {
|
||||
const entry = `${skill.name}: ${truncateInlineText(skill.description, MAX_INDEX_DESCRIPTION_CHARS)}`;
|
||||
const separatorChars = indexEntries.length > 0 ? 2 : 0;
|
||||
if (indexChars + separatorChars + entry.length > MAX_INDEX_LINE_CHARS) {
|
||||
remainingCount += indexSkills.length - indexEntries.length;
|
||||
break;
|
||||
}
|
||||
indexEntries.push(entry);
|
||||
indexChars += separatorChars + entry.length;
|
||||
}
|
||||
|
||||
const indexLine = indexEntries.join("; ");
|
||||
|
||||
const orderedExplicitSlugs = [];
|
||||
const seenExplicitSlugs = new Set();
|
||||
|
||||
@@ -99,6 +99,69 @@ test("keeps every explicitly selected skill in the built context", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("uses longer skill descriptions for routing matches without injecting the full index text", async () => {
|
||||
const longDescription = [
|
||||
"Use when the user needs a detailed workflow for operating Netcatty through ACP skills and CLI.",
|
||||
"Includes platform launcher guidance, scoped command execution, recovery behavior, and constraints.",
|
||||
"This intentionally exceeds the older short description budget so routing has enough signal.",
|
||||
"It also names edge cases such as unavailable optional shells, strict chat-session scoping, and fallback-only history replay so the agent can choose the skill without reading the whole body first.",
|
||||
].join(" ");
|
||||
|
||||
assert.ok(longDescription.length > 320);
|
||||
|
||||
await withUserSkills(
|
||||
[
|
||||
{
|
||||
directoryName: "Detailed Router",
|
||||
name: "Detailed Router",
|
||||
description: longDescription,
|
||||
body: "Detailed router body",
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const status = await scanUserSkills(electronApp);
|
||||
const result = await buildUserSkillsContext(
|
||||
electronApp,
|
||||
"Need fallback-only history replay guidance for ACP recovery.",
|
||||
[],
|
||||
);
|
||||
|
||||
assert.equal(status.readyCount, 1);
|
||||
assert.equal(status.warningCount, 0);
|
||||
assert.equal(result.context.includes("### Detailed Router"), true);
|
||||
assert.equal(result.context.includes("Detailed router body"), true);
|
||||
assert.equal(result.context.includes(longDescription), false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("caps the injected available-skills index when descriptions are very long", async () => {
|
||||
const longDescription = "signal ".repeat(65);
|
||||
|
||||
await withUserSkills(
|
||||
Array.from({ length: 8 }, (_, index) => ({
|
||||
directoryName: `Skill ${index + 1}`,
|
||||
name: `Skill ${index + 1}`,
|
||||
description: `${longDescription}${index + 1}`,
|
||||
body: `Body ${index + 1}`,
|
||||
})),
|
||||
async (electronApp) => {
|
||||
const result = await buildUserSkillsContext(
|
||||
electronApp,
|
||||
"plain prompt",
|
||||
[],
|
||||
);
|
||||
|
||||
const availableLine = result.context
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("Available user skills: "));
|
||||
|
||||
assert.ok(availableLine, "expected available-skills index line");
|
||||
assert.ok(availableLine.length < 1800, `expected capped index line, got ${availableLine.length}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves an unavailable explicit selection in the built context", async () => {
|
||||
await withUserSkills(
|
||||
[
|
||||
|
||||
@@ -2317,6 +2317,14 @@ function registerHandlers(ipcMain) {
|
||||
try {
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
// Capture whether the prior run was already cancelled (via the
|
||||
// cancel IPC) BEFORE we set the flag ourselves — the cancel IPC
|
||||
// contract explicitly preserves the provider session so the
|
||||
// next prompt can continue in the same conversation. Tearing
|
||||
// down the provider here would silently break that contract in
|
||||
// the "click Stop, then immediately send next prompt" flow,
|
||||
// discarding the recovered ACP session.
|
||||
const alreadyCancelledViaIpc = existingRun.cancelRequested;
|
||||
existingRun.cancelRequested = true;
|
||||
const existingController = acpActiveStreams.get(existingRun.requestId);
|
||||
if (existingController) {
|
||||
@@ -2324,7 +2332,15 @@ function registerHandlers(ipcMain) {
|
||||
acpActiveStreams.delete(existingRun.requestId);
|
||||
}
|
||||
acpRequestSessions.delete(existingRun.requestId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
// Only tear down the provider for true interrupt-and-restart
|
||||
// flows (user typed a new prompt while the old one was still
|
||||
// streaming, no explicit cancel). When we do skip cleanup here,
|
||||
// the reuse/reset logic below still handles auth/MCP/permission
|
||||
// changes correctly — the provider is preserved only when
|
||||
// nothing else would require rebuilding it.
|
||||
if (!alreadyCancelledViaIpc) {
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
|
||||
@@ -2476,9 +2492,48 @@ function registerHandlers(ipcMain) {
|
||||
providerEntry.mcpFingerprint === mcpSnapshot.fingerprint &&
|
||||
providerEntry.permissionMode === currentPermissionMode,
|
||||
);
|
||||
const shouldResetProviderForHistoryReplay = Boolean(
|
||||
shouldReuseProvider &&
|
||||
providerEntry?.historyReplayFallback &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
);
|
||||
|
||||
if (!shouldReuseProvider) {
|
||||
const resumeSessionId = providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
|
||||
if (!shouldReuseProvider || shouldResetProviderForHistoryReplay) {
|
||||
const resumeSessionId = shouldResetProviderForHistoryReplay
|
||||
? undefined
|
||||
: providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
|
||||
// Preserve the replay-fallback flag across any recreation where
|
||||
// history recovery is still pending, not just the reset-for-replay
|
||||
// path. Otherwise a provider recreation driven by an orthogonal
|
||||
// change (permission mode / MCP scope / auth fingerprint) between
|
||||
// a still-empty recovered turn and its retry would drop the flag
|
||||
// and lose the recovered conversation on the next turn.
|
||||
//
|
||||
// Also hedge whenever we're spawning a brand-new provider process
|
||||
// that's being told to resume an existing session id (the common
|
||||
// app-restart / reconnect flow — #753). Some ACP agents (Copilot
|
||||
// CLI, some Codex builds) silently spin up a fresh session
|
||||
// instead of erroring with "session not found", so the catch-
|
||||
// block fallback below never fires and the agent ends up with
|
||||
// zero prior context. Scheduling a compact replay on the first
|
||||
// turn guarantees the agent sees durable constraints and the
|
||||
// last few raw turns even when session/load is effectively a
|
||||
// no-op. After the first successful streamed turn the flag
|
||||
// clears (post-stream hook), so steady-state cost stays at
|
||||
// just the latest prompt.
|
||||
const preserveHistoryReplayFallback =
|
||||
shouldResetProviderForHistoryReplay ||
|
||||
Boolean(
|
||||
providerEntry?.historyReplayFallback &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
) ||
|
||||
Boolean(
|
||||
resumeSessionId &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
|
||||
@@ -2555,7 +2610,7 @@ function registerHandlers(ipcMain) {
|
||||
authFingerprint,
|
||||
mcpFingerprint: mcpSnapshot.fingerprint,
|
||||
permissionMode: currentPermissionMode,
|
||||
historyReplayFallback: false,
|
||||
historyReplayFallback: preserveHistoryReplayFallback,
|
||||
};
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
}
|
||||
@@ -2726,14 +2781,17 @@ function registerHandlers(ipcMain) {
|
||||
role: "user",
|
||||
content: buildMessageContent(contextualPrompt, images),
|
||||
};
|
||||
const shouldReplayHistory = Boolean(
|
||||
providerEntry.historyReplayFallback &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
);
|
||||
|
||||
const result = streamText({
|
||||
model: modelInstance,
|
||||
messages: providerEntry.historyReplayFallback
|
||||
messages: shouldReplayHistory
|
||||
? [
|
||||
...(Array.isArray(historyMessages)
|
||||
? historyMessages.map((msg) => ({ role: msg.role, content: msg.content }))
|
||||
: []),
|
||||
...historyMessages.map((msg) => ({ role: msg.role, content: msg.content })),
|
||||
latestPromptMessage,
|
||||
]
|
||||
: [latestPromptMessage],
|
||||
@@ -2819,6 +2877,21 @@ function registerHandlers(ipcMain) {
|
||||
: "Agent returned an empty response.",
|
||||
});
|
||||
} else {
|
||||
// Clear replay fallback when the recovered turn either streamed
|
||||
// content OR was user-aborted. The empty-but-not-aborted case is
|
||||
// handled in the if-branch above and intentionally keeps the flag
|
||||
// so a follow-up retry can re-replay onto a fresh session.
|
||||
//
|
||||
// Why also clear on abort: if the user actively cancelled, the
|
||||
// freshly recovered ACP session has whatever state was built up so
|
||||
// far. Leaving the flag set would make the next turn trigger
|
||||
// shouldResetProviderForHistoryReplay, which discards the recovered
|
||||
// session (resumeSessionId is forced to undefined in that path) and
|
||||
// re-spends tokens on another compact replay. That breaks the
|
||||
// cancel-preserves-session contract for users who stop early.
|
||||
if (shouldReplayHistory) {
|
||||
providerEntry.historyReplayFallback = false;
|
||||
}
|
||||
debugMcpLog("ACP stream done", { requestId, chatSessionId, hasContent });
|
||||
if (!isActiveAcpRun(chatSessionId, requestId)) {
|
||||
return { ok: true };
|
||||
@@ -2871,6 +2944,18 @@ function registerHandlers(ipcMain) {
|
||||
if (activeRun && activeRun.requestId === effectiveRequestId) {
|
||||
activeRun.cancelRequested = true;
|
||||
}
|
||||
// Synchronously clear historyReplayFallback on the preserved provider
|
||||
// entry. Without this, a user pressing Stop and immediately sending
|
||||
// the next prompt can have their new request enter the stream
|
||||
// handler before the aborted run's post-stream clearing code runs.
|
||||
// The new turn would then see historyReplayFallback=true, trigger
|
||||
// shouldResetProviderForHistoryReplay, and recreate the provider
|
||||
// without the recovered existingSessionId — discarding the very
|
||||
// session the cancel contract promised to preserve.
|
||||
if (effectiveChatSessionId) {
|
||||
const preservedEntry = acpProviders.get(effectiveChatSessionId);
|
||||
if (preservedEntry) preservedEntry.historyReplayFallback = false;
|
||||
}
|
||||
const controller = acpActiveStreams.get(effectiveRequestId);
|
||||
let cancelled = false;
|
||||
if (controller) {
|
||||
|
||||
837
electron/bridges/aiBridge.test.cjs
Normal file
837
electron/bridges/aiBridge.test.cjs
Normal file
@@ -0,0 +1,837 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const Module = require("node:module");
|
||||
|
||||
function createIpcMainStub() {
|
||||
const handlers = new Map();
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyStreamResult() {
|
||||
return {
|
||||
fullStream: {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadBridgeWithMocks(options = {}) {
|
||||
const streamCalls = [];
|
||||
const safeSendCalls = [];
|
||||
let providerCreationCount = 0;
|
||||
const providerCreationArgs = [];
|
||||
|
||||
const fallbackProvider = {
|
||||
tools: {},
|
||||
languageModel() {
|
||||
return { id: "fake-model" };
|
||||
},
|
||||
async initSession() {},
|
||||
getSessionId() {
|
||||
return "fresh-session";
|
||||
},
|
||||
cleanup() {},
|
||||
};
|
||||
|
||||
const mocks = {
|
||||
"./mcpServerBridge.cjs": {
|
||||
init() {},
|
||||
setMainWindowGetter() {},
|
||||
getOrCreateHost: async () => 4010,
|
||||
getScopedSessionIds: () => [],
|
||||
buildMcpServerConfig: () => ({ name: "netcatty-remote-hosts", type: "http", url: "http://127.0.0.1:4010" }),
|
||||
getPermissionMode: () =>
|
||||
typeof options.getPermissionMode === "function"
|
||||
? options.getPermissionMode()
|
||||
: "default",
|
||||
getMaxIterations: () => 20,
|
||||
setChatSessionCancelled() {},
|
||||
cancelPtyExecsForSession() {},
|
||||
clearPendingApprovals() {},
|
||||
cleanupScopedMetadata: async () => {},
|
||||
cleanup() {},
|
||||
},
|
||||
"../cli/discoveryPath.cjs": {
|
||||
getCliLauncherPath: () => "/tmp/netcatty-tool-cli",
|
||||
TOOL_CLI_DISCOVERY_ENV_VAR: "NETCATTY_TOOL_CLI_DISCOVERY_FILE",
|
||||
},
|
||||
"./ai/userSkills.cjs": {
|
||||
scanUserSkills: async () => ({ readyCount: 0, warningCount: 0, skills: [], warnings: [] }),
|
||||
buildUserSkillsContext: async () => ({ context: "", selectedSkills: [] }),
|
||||
toPublicUserSkillsStatus: (value) => value,
|
||||
},
|
||||
"./ai/shellUtils.cjs": {
|
||||
stripAnsi: (value) => value,
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
shouldUseShellForCommand: () => false,
|
||||
resolveCliFromPath: () => null,
|
||||
resolveClaudeAcpBinaryPath: () => null,
|
||||
getShellEnv: async () => ({}),
|
||||
invalidateShellEnvCache() {},
|
||||
serializeStreamChunk: (chunk) => chunk,
|
||||
toUnpackedAsarPath: (value) => value,
|
||||
},
|
||||
"./ai/codexHelpers.cjs": {
|
||||
codexLoginSessions: new Map(),
|
||||
resolveCodexAcpBinaryPath: () => null,
|
||||
appendCodexLoginOutput() {},
|
||||
toCodexLoginSessionResponse: () => ({}),
|
||||
getActiveCodexLoginSession: () => null,
|
||||
normalizeCodexIntegrationState: () => ({}),
|
||||
readCodexCustomProviderConfig: () => null,
|
||||
getCodexAuthOverride: () => ({}),
|
||||
getCodexCustomConfigPreflightError: () => null,
|
||||
extractCodexError: (err) => ({ message: err?.message || String(err) }),
|
||||
isCodexAuthError: () => false,
|
||||
getCodexAuthFingerprint: (...args) =>
|
||||
typeof options.getCodexAuthFingerprint === "function"
|
||||
? options.getCodexAuthFingerprint(...args)
|
||||
: "auth-fingerprint",
|
||||
getCodexMcpFingerprint: () => "mcp-fingerprint",
|
||||
invalidateCodexValidationCache() {},
|
||||
getCodexValidationCache: () => null,
|
||||
setCodexValidationCache() {},
|
||||
},
|
||||
"./ai/ptyExec.cjs": {
|
||||
execViaPty: async () => {
|
||||
throw new Error("execViaPty should not be called in this test");
|
||||
},
|
||||
},
|
||||
"./ipcUtils.cjs": {
|
||||
safeSend(sender, channel, payload) {
|
||||
safeSendCalls.push({ sender, channel, payload });
|
||||
},
|
||||
},
|
||||
"./windowManager.cjs": {
|
||||
getMainWindow() {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
webContents: { id: 1 },
|
||||
};
|
||||
},
|
||||
getSettingsWindow() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"@mcpc-tech/acp-ai-provider": {
|
||||
createACPProvider(args) {
|
||||
providerCreationCount += 1;
|
||||
providerCreationArgs.push(args);
|
||||
if (typeof options.createACPProvider === "function") {
|
||||
return options.createACPProvider({ args, providerCreationCount, fallbackProvider });
|
||||
}
|
||||
if (providerCreationCount === 1) {
|
||||
return {
|
||||
tools: {},
|
||||
languageModel() {
|
||||
return { id: "fake-model" };
|
||||
},
|
||||
async initSession() {
|
||||
throw new Error("Resource not found: session not found");
|
||||
},
|
||||
getSessionId() {
|
||||
return "stale-session";
|
||||
},
|
||||
cleanup() {},
|
||||
};
|
||||
}
|
||||
return fallbackProvider;
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
stepCountIs: () => Symbol("stopWhen"),
|
||||
streamText(args) {
|
||||
const { messages } = args;
|
||||
streamCalls.push(messages);
|
||||
if (typeof options.streamText === "function") {
|
||||
return options.streamText({ ...args, streamCalls });
|
||||
}
|
||||
if (streamCalls.length === 1) {
|
||||
throw new Error("transport failed before replayed turn completed");
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const bridgePath = require.resolve("./aiBridge.cjs");
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (Object.prototype.hasOwnProperty.call(mocks, request)) {
|
||||
return mocks[request];
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
delete require.cache[bridgePath];
|
||||
|
||||
try {
|
||||
const bridge = require("./aiBridge.cjs");
|
||||
return {
|
||||
bridge,
|
||||
streamCalls,
|
||||
safeSendCalls,
|
||||
providerCreationArgs,
|
||||
restore() {
|
||||
try {
|
||||
bridge.cleanup();
|
||||
} finally {
|
||||
delete require.cache[bridgePath];
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
delete require.cache[bridgePath];
|
||||
Module._load = originalLoad;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("replays fallback history only after creating a fresh ACP session when the recovered turn fails", async () => {
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks();
|
||||
const ipcMain = createIpcMainStub();
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
console.error = (...args) => {
|
||||
const message = args.map((part) => String(part ?? "")).join(" ");
|
||||
if (message.includes("transport failed before replayed turn completed")) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
};
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-1",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first recovered turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-2",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "retry after transport failure",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.equal(streamCalls.length, 2);
|
||||
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
|
||||
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
|
||||
assert.equal(providerCreationArgs.length, 3);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[0], true);
|
||||
assert.equal(providerCreationArgs[0].existingSessionId, "stale-session");
|
||||
assert.equal("existingSessionId" in providerCreationArgs[1], false);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[2], false);
|
||||
});
|
||||
|
||||
test("clears replay fallback after a user-cancelled recovered turn so the fresh ACP session is preserved", async () => {
|
||||
// Regression: if the user stops the first turn after stale-session
|
||||
// recovery, historyReplayFallback must still be cleared. Otherwise the
|
||||
// next turn triggers shouldResetProviderForHistoryReplay, which discards
|
||||
// the freshly recovered ACP session (resumeSessionId is forced to
|
||||
// undefined in that path) and re-spends tokens on another compact
|
||||
// replay. That would break the cancel-preserves-session contract.
|
||||
|
||||
// Gate that the test releases AFTER cancel has been dispatched, so the
|
||||
// bridge's reader loop wakes up to find signal.aborted=true.
|
||||
let releaseRead;
|
||||
const readReleased = new Promise((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// First call (the recovered turn) — block in read() so the test can
|
||||
// fire cancel before any chunk arrives, simulating "user clicks Stop
|
||||
// before the agent emits content". Second call (follow-up) — return
|
||||
// an immediately-done empty stream.
|
||||
if (callsRef.length === 1) {
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
await readReleased;
|
||||
// After cancel, signal.aborted is true; return done so the
|
||||
// loop exits cleanly. Never produced a content chunk →
|
||||
// hasContent stays false, aborted is true → we hit the
|
||||
// else-branch where the fix lives.
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const cancelHandler = ipcMain.handlers.get("netcatty:ai:acp:cancel");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
assert.equal(typeof cancelHandler, "function");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// Kick off the first turn; it will block at reader.read().
|
||||
const firstTurn = streamHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-cancel",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first recovered turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Yield enough microtasks so the handler reaches the streamText/read
|
||||
// path before we cancel.
|
||||
for (let i = 0; i < 10; i += 1) await Promise.resolve();
|
||||
|
||||
// Fire cancel — this calls controller.abort() inside the bridge.
|
||||
await cancelHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-cancel",
|
||||
});
|
||||
|
||||
// Now release the blocked read so the loop wakes, sees aborted, and
|
||||
// exits. The else-branch should clear historyReplayFallback.
|
||||
releaseRead();
|
||||
await firstTurn;
|
||||
|
||||
// Second turn — should reuse the recovered fresh-session and send
|
||||
// only the latest prompt (no compact replay).
|
||||
await streamHandler(event, {
|
||||
requestId: "req-cancel-2",
|
||||
chatSessionId: "chat-cancel",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "follow-up after cancel",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// Two streamText calls: the cancelled one + the follow-up.
|
||||
assert.equal(streamCalls.length, 2);
|
||||
|
||||
// Provider creation count: 1 stale attempt + 1 fallback recovery = 2.
|
||||
// If the bug regresses, the follow-up turn would force a 3rd creation
|
||||
// (shouldResetProviderForHistoryReplay → cleanupAcpProvider → recreate
|
||||
// without existingSessionId).
|
||||
assert.equal(
|
||||
providerCreationArgs.length,
|
||||
2,
|
||||
"expected the recovered fresh session to be preserved across user cancel",
|
||||
);
|
||||
|
||||
// Follow-up turn should send only the latest prompt — the recovered
|
||||
// session has the prior context; replaying compact history again would
|
||||
// waste tokens and visually feel like the conversation forgot itself.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
1,
|
||||
"follow-up after cancel must not re-replay compact history",
|
||||
);
|
||||
});
|
||||
|
||||
test("replays compact history on the first turn after app restart even when session/load 'succeeds'", async () => {
|
||||
// Regression for #753: after an app restart, the renderer still has
|
||||
// the prior chat's externalSessionId and full message history in
|
||||
// storage, and passes both to the bridge on the next send. The
|
||||
// externalSessionId becomes existingSessionId → resumeSessionId in
|
||||
// the bridge, and createACPProvider spawns a fresh agent process
|
||||
// with that id.
|
||||
//
|
||||
// Problem: some ACP agents (Copilot CLI, some Codex builds) don't
|
||||
// error on session/load when the id is stale — they silently start
|
||||
// a new session. The catch-block fallback never fires, so
|
||||
// historyReplayFallback stays false and the stream sends only the
|
||||
// latest prompt. The agent says "no previous records" even though
|
||||
// the UI shows the prior conversation.
|
||||
//
|
||||
// Fix: when we're spawning a new provider AND telling it to resume
|
||||
// an existing session id AND we have compact history to replay,
|
||||
// preload historyReplayFallback=true. The first turn includes the
|
||||
// replay; after it streams real content the flag clears so steady-
|
||||
// state cost stays at just the latest prompt.
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
createACPProvider({ fallbackProvider }) {
|
||||
// Pretend session/load succeeded silently — no error thrown, but
|
||||
// also no real context. This models Copilot CLI's behavior.
|
||||
return fallbackProvider;
|
||||
},
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// Return content so the post-stream hook clears the flag after.
|
||||
if (callsRef.length === 1) {
|
||||
const chunks = [{ type: "text-delta", text: "ok" }];
|
||||
let i = 0;
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
if (i < chunks.length) return { done: false, value: chunks[i++] };
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const historyMessages = [{ role: "user", content: "prior constraint: 不要提交" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// First turn after app restart. existingSessionId is set (renderer
|
||||
// persisted it), historyMessages is non-empty.
|
||||
await streamHandler(event, {
|
||||
requestId: "req-restart-1",
|
||||
chatSessionId: "chat-restart",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "what did we discuss?",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stored-session-from-storage",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Second turn — should send only the latest prompt now.
|
||||
await streamHandler(event, {
|
||||
requestId: "req-restart-2",
|
||||
chatSessionId: "chat-restart",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "and now continue",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stored-session-from-storage",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// Single provider creation — session/load "succeeded" so no fallback.
|
||||
assert.equal(providerCreationArgs.length, 1);
|
||||
assert.equal(providerCreationArgs[0].existingSessionId, "stored-session-from-storage");
|
||||
|
||||
// First turn MUST include the compact history + latest prompt.
|
||||
// Regression target: pre-fix, streamCalls[0] had length 1 (latest only).
|
||||
assert.equal(
|
||||
streamCalls[0].length,
|
||||
2,
|
||||
"first turn after app restart must preload compact history as a hedge",
|
||||
);
|
||||
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
|
||||
|
||||
// Second turn uses steady-state behavior (latest only). This confirms
|
||||
// the flag clears after one successful streamed turn and the hedge
|
||||
// doesn't keep replaying forever.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
1,
|
||||
"steady-state turns must not keep replaying history",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves recovered ACP session when user cancels then immediately sends the next prompt", async () => {
|
||||
// Regression: after a user-cancel of a recovered turn, the existingRun
|
||||
// path in the next stream handler used to call cleanupAcpProvider
|
||||
// unconditionally — destroying the fresh ACP session the cancel IPC
|
||||
// had just promised to preserve. Combined with historyReplayFallback
|
||||
// still being true at that moment, the follow-up turn then recreated
|
||||
// a bare new provider via shouldResetProviderForHistoryReplay and
|
||||
// the user lost all recovered conversation context.
|
||||
//
|
||||
// With the fix: (a) the cancel IPC synchronously clears the replay
|
||||
// flag on the preserved provider, and (b) the existingRun path skips
|
||||
// cleanupAcpProvider when the prior run was already cancelled via
|
||||
// the cancel IPC. The next stream then reuses the recovered session
|
||||
// and sends only the latest prompt.
|
||||
|
||||
let releaseRead;
|
||||
const readReleased = new Promise((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// Turn 1: block in read() so the test can fire cancel, then
|
||||
// immediately fire the next stream request while the aborted
|
||||
// stream is still unwinding.
|
||||
if (callsRef.length === 1) {
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
await readReleased;
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const cancelHandler = ipcMain.handlers.get("netcatty:ai:acp:cancel");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// Turn 1 starts and blocks in read().
|
||||
const firstTurn = streamHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-race",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Yield so the handler reaches the streamText/read phase.
|
||||
for (let i = 0; i < 10; i += 1) await Promise.resolve();
|
||||
|
||||
// User clicks Stop.
|
||||
await cancelHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-race",
|
||||
});
|
||||
|
||||
// User immediately sends the next prompt BEFORE releasing the read
|
||||
// — i.e. before the first stream handler's post-stream code can
|
||||
// run. This is the exact timing window codex flagged.
|
||||
const secondTurn = streamHandler(event, {
|
||||
requestId: "req-cancel-2",
|
||||
chatSessionId: "chat-race",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "immediate follow-up",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Let the first turn unwind now.
|
||||
releaseRead();
|
||||
await firstTurn;
|
||||
await secondTurn;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// 2 provider creations: the stale attempt + fallback recovery.
|
||||
// If the regression is back, there would be a 3rd creation (the
|
||||
// existingRun cleanup + reset-for-replay path discarding the
|
||||
// recovered session).
|
||||
assert.equal(
|
||||
providerCreationArgs.length,
|
||||
2,
|
||||
"expected recovered fresh session to be preserved across cancel+immediate-send",
|
||||
);
|
||||
|
||||
// Second turn must NOT re-replay compact history — the preserved
|
||||
// session already has that context.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
1,
|
||||
"follow-up after cancel must not re-replay compact history",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves history-replay across provider recreation caused by permission-mode / MCP / auth change", async () => {
|
||||
// Regression: after a stale-session recovery left historyReplayFallback=true
|
||||
// (e.g. the recovered turn returned empty), an orthogonal change that
|
||||
// flips shouldReuseProvider to false (permission mode, MCP scope, auth
|
||||
// fingerprint) used to recreate the provider with historyReplayFallback:
|
||||
// false. The next turn then sent only the latest prompt and dropped the
|
||||
// recovered conversation context. We now preserve the flag on any
|
||||
// recreation where a history-replay is still pending.
|
||||
|
||||
// Use permission mode as the orthogonal change — auth fingerprint would
|
||||
// drag in Codex-specific auth validation we can't stub cleanly.
|
||||
let permissionMode = "default";
|
||||
function createStreamResult(chunks) {
|
||||
let idx = 0;
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
if (idx < chunks.length) {
|
||||
return { done: false, value: chunks[idx++] };
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
getPermissionMode: () => permissionMode,
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// Turn 1: empty stream — the recovered turn returned no content, so
|
||||
// the empty-non-aborted branch keeps historyReplayFallback=true.
|
||||
if (callsRef.length === 1) return createEmptyStreamResult();
|
||||
// Turn 2: content streams — confirms the replay actually reached
|
||||
// the recreated provider.
|
||||
return createStreamResult([{ type: "text-delta", text: "ok" }]);
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// Turn 1: stale-session recovery + empty response (flag stays set).
|
||||
await streamHandler(event, {
|
||||
requestId: "req-1",
|
||||
chatSessionId: "chat-preserve",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Simulate the user toggling the MCP permission mode between turns.
|
||||
// This flips shouldReuseProvider to false and forces recreation via
|
||||
// the non-reset branch — exactly where the preserve-flag gap lived.
|
||||
permissionMode = "auto";
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-2",
|
||||
chatSessionId: "chat-preserve",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "second turn after permission change",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.equal(streamCalls.length, 2);
|
||||
// Turn 2 must include history + latest; regression would make it just 1.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
2,
|
||||
"second turn must re-replay compact history onto the recreated provider",
|
||||
);
|
||||
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
|
||||
|
||||
// 3 provider creations: stale attempt + first fallback + permission-change recreation.
|
||||
assert.equal(providerCreationArgs.length, 3);
|
||||
});
|
||||
|
||||
test("keeps replay fallback enabled after an empty recovered turn by retrying in a fresh ACP session", async () => {
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
streamText() {
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
await streamHandler(event, {
|
||||
requestId: "req-1",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first recovered turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-2",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "retry after empty response",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.equal(streamCalls.length, 2);
|
||||
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
|
||||
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
|
||||
assert.equal(providerCreationArgs.length, 3);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[0], true);
|
||||
assert.equal(providerCreationArgs[0].existingSessionId, "stale-session");
|
||||
assert.equal("existingSessionId" in providerCreationArgs[1], false);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[2], false);
|
||||
});
|
||||
@@ -6,27 +6,78 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { exec } = require("node:child_process");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows using the attrib command
|
||||
* Returns true if the file has the hidden attribute set
|
||||
* Uses async exec to avoid blocking the main process
|
||||
* Parse the output of `attrib.exe <dir>\*` into a set of basenames whose
|
||||
* `H` (hidden) flag is set. Exposed separately so the parser can be
|
||||
* unit-tested without spawning a real subprocess.
|
||||
*
|
||||
* Example attrib output (one entry per line):
|
||||
* A C:\path\file1.txt
|
||||
* H C:\path\file2.txt
|
||||
* A H R C:\path\file3.txt
|
||||
* H C:\path\hidden_dir [DIR]
|
||||
*/
|
||||
async function isWindowsHiddenFile(filePath) {
|
||||
if (process.platform !== "win32") return false;
|
||||
function parseAttribOutput(stdout) {
|
||||
const hidden = new Set();
|
||||
for (const line of String(stdout).split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
// Flags occupy the leading columns. Locate the path by the first
|
||||
// drive letter ("C:\") or UNC prefix ("\\server\share"). The `\\\\`
|
||||
// alternative has no leading anchor because attrib output has the
|
||||
// path inside the line, not at column 0 (leading whitespace holds
|
||||
// the attribute flags).
|
||||
const pathStart = line.search(/[A-Za-z]:[\\/]|\\\\/);
|
||||
if (pathStart < 0) continue;
|
||||
const attrPart = line.substring(0, pathStart).toUpperCase();
|
||||
if (!attrPart.includes("H")) continue;
|
||||
const fullPath = line.substring(pathStart).trim();
|
||||
// Some Windows versions append a trailing literal "[DIR]" marker
|
||||
// when attrib is invoked with /d. Strip only that exact marker —
|
||||
// not any arbitrary bracketed suffix — so legitimate filenames
|
||||
// ending in brackets ("Notes [old]", "Draft [v2].md") survive
|
||||
// intact and still get matched by hiddenSet.has(entry.name).
|
||||
const cleaned = fullPath.replace(/\s+\[DIR\]\s*$/, "");
|
||||
// Always use the win32 basename here — attrib output uses backslash
|
||||
// separators, and the parser must work under CI on non-Windows hosts.
|
||||
const basename = path.win32.basename(cleaned);
|
||||
if (basename) hidden.add(basename);
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-list hidden filenames in a Windows directory.
|
||||
*
|
||||
* Previously we called `attrib` once per entry inside the concurrency
|
||||
* worker loop. On a directory with ~800 files, that spawns ~800 subprocesses
|
||||
* and takes ~30 s (see #766). One subprocess call with a wildcard returns
|
||||
* the hidden attribute for every entry at once, so we replace the per-file
|
||||
* check with a single upfront pass and a Set lookup in the worker.
|
||||
*
|
||||
* Returns the set of hidden basenames (empty on non-Windows or on failure).
|
||||
*/
|
||||
async function listWindowsHiddenBasenames(dirPath) {
|
||||
if (process.platform !== "win32") return new Set();
|
||||
try {
|
||||
const { stdout } = await execAsync(`attrib "${filePath}"`);
|
||||
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
|
||||
// The attributes appear in the first ~10 characters before the path
|
||||
const attrPart = stdout.substring(0, stdout.indexOf(filePath)).toUpperCase();
|
||||
return attrPart.includes("H");
|
||||
const pattern = path.join(dirPath, "*");
|
||||
// `/d` is required so attrib.exe also reports directory entries —
|
||||
// without it the wildcard is file-centric and hidden folders would
|
||||
// be silently omitted from the set, causing the SFTP browser to
|
||||
// show them as not-hidden (a regression from the per-file path
|
||||
// that passed each entry's full path directly).
|
||||
const { stdout } = await execFileAsync("attrib.exe", [pattern, "/d"], {
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
return parseAttribOutput(stdout);
|
||||
} catch (err) {
|
||||
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
|
||||
return false;
|
||||
console.warn(`[localFsBridge] Batch attrib failed for ${dirPath}:`, err.message);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +88,17 @@ async function isWindowsHiddenFile(filePath) {
|
||||
*/
|
||||
async function listLocalDir(event, payload) {
|
||||
const dirPath = payload.path;
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// Read directory entries and the Windows hidden-attribute set in
|
||||
// parallel. The hidden lookup is a single subprocess that covers every
|
||||
// entry in the directory; per-file attrib calls were the ~30 s hotspot
|
||||
// that #766 reported on an 800-file directory.
|
||||
const [entries, hiddenSet] = await Promise.all([
|
||||
fs.promises.readdir(dirPath, { withFileTypes: true }),
|
||||
isWindows ? listWindowsHiddenBasenames(dirPath) : Promise.resolve(new Set()),
|
||||
]);
|
||||
|
||||
// Stat entries in parallel with a small concurrency limit.
|
||||
// Serial stats can be very slow on Windows for large dirs.
|
||||
const CONCURRENCY = 32;
|
||||
@@ -70,8 +129,8 @@ async function listLocalDir(event, payload) {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
// Check for Windows hidden attribute
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
// Windows hidden attribute: resolved from the batched lookup.
|
||||
const hidden = isWindows ? hiddenSet.has(entry.name) : false;
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
@@ -90,7 +149,7 @@ async function listLocalDir(event, payload) {
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
const hidden = isWindows ? hiddenSet.has(brokenEntry.name) : false;
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
@@ -269,4 +328,6 @@ module.exports = {
|
||||
getHomeDir,
|
||||
getSystemInfo,
|
||||
readKnownHosts,
|
||||
parseAttribOutput,
|
||||
listWindowsHiddenBasenames,
|
||||
};
|
||||
|
||||
139
electron/bridges/localFsBridge.test.cjs
Normal file
139
electron/bridges/localFsBridge.test.cjs
Normal file
@@ -0,0 +1,139 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { parseAttribOutput, listWindowsHiddenBasenames } = require("./localFsBridge.cjs");
|
||||
|
||||
test("parseAttribOutput returns an empty set for empty input", () => {
|
||||
assert.equal(parseAttribOutput("").size, 0);
|
||||
assert.equal(parseAttribOutput("\r\n\r\n").size, 0);
|
||||
});
|
||||
|
||||
test("parseAttribOutput captures basenames of files with the H flag", () => {
|
||||
const stdout = [
|
||||
"A C:\\Users\\foo\\public.txt",
|
||||
" H C:\\Users\\foo\\.secret",
|
||||
"A H R C:\\Users\\foo\\hidden-readonly.exe",
|
||||
"A C:\\Users\\foo\\another.log",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual(
|
||||
[...hidden].sort(),
|
||||
[".secret", "hidden-readonly.exe"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseAttribOutput ignores the trailing [DIR] marker on some Windows versions", () => {
|
||||
const stdout = [
|
||||
" H C:\\data\\node_modules [DIR]",
|
||||
" H C:\\data\\.git [DIR]",
|
||||
"A C:\\data\\README.md",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual([...hidden].sort(), [".git", "node_modules"].sort());
|
||||
});
|
||||
|
||||
test("parseAttribOutput preserves filenames that legitimately end with bracketed suffixes", () => {
|
||||
// Regression: a prior version stripped ANY trailing bracketed suffix
|
||||
// via /\s+\[[^\]]+\]\s*$/, truncating "Notes [old]" to "Notes".
|
||||
// Only the literal [DIR] marker that attrib emits with /d is a parser
|
||||
// artifact; user-facing filenames with brackets must survive intact so
|
||||
// hiddenSet.has(entry.name) still matches the actual readdir entry.
|
||||
const stdout = [
|
||||
" H C:\\data\\Notes [old]",
|
||||
" H C:\\data\\Draft [v2].md",
|
||||
" H C:\\data\\archived [2024]",
|
||||
" H C:\\data\\node_modules [DIR]",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual(
|
||||
[...hidden].sort(),
|
||||
["Draft [v2].md", "Notes [old]", "archived [2024]", "node_modules"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseAttribOutput handles UNC paths", () => {
|
||||
const stdout = [
|
||||
" H \\\\fileserver\\share\\secret.cfg",
|
||||
"A \\\\fileserver\\share\\public.cfg",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual([...hidden], ["secret.cfg"]);
|
||||
});
|
||||
|
||||
test("parseAttribOutput skips malformed lines", () => {
|
||||
const stdout = [
|
||||
"Parameter format not correct",
|
||||
"",
|
||||
" H C:\\good\\hidden.txt",
|
||||
"File not found",
|
||||
" H not-a-windows-path.txt",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual([...hidden], ["hidden.txt"]);
|
||||
});
|
||||
|
||||
test("listWindowsHiddenBasenames returns an empty set on non-Windows without spawning anything", async () => {
|
||||
// Running this test file is only meaningful on a non-Windows host for this
|
||||
// assertion. On Windows CI we skip the subprocess-free guarantee.
|
||||
if (process.platform === "win32") return;
|
||||
const result = await listWindowsHiddenBasenames("/tmp");
|
||||
assert.ok(result instanceof Set);
|
||||
assert.equal(result.size, 0);
|
||||
});
|
||||
|
||||
test("listWindowsHiddenBasenames invokes attrib.exe with /d so hidden directories aren't omitted", async () => {
|
||||
// Regression: without `/d`, `attrib <dir>\*` treats the wildcard as
|
||||
// file-centric and hidden directories (node_modules, .git, …) never
|
||||
// reach parseAttribOutput — the SFTP browser then shows them as
|
||||
// not-hidden, a behavior regression from the per-file implementation.
|
||||
const Module = require("node:module");
|
||||
const realChildProcess = require("node:child_process");
|
||||
const originalLoad = Module._load;
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
|
||||
let capturedArgs = null;
|
||||
let capturedExecutable = null;
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "node:child_process") {
|
||||
return {
|
||||
...realChildProcess,
|
||||
execFile: (executable, args, _options, cb) => {
|
||||
capturedExecutable = executable;
|
||||
capturedArgs = args;
|
||||
cb(null, { stdout: "", stderr: "" });
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const bridgePath = require.resolve("./localFsBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
|
||||
try {
|
||||
const { listWindowsHiddenBasenames: fn } = require("./localFsBridge.cjs");
|
||||
await fn("C:\\fixture");
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
delete require.cache[bridgePath];
|
||||
}
|
||||
|
||||
assert.equal(capturedExecutable, "attrib.exe");
|
||||
assert.ok(
|
||||
Array.isArray(capturedArgs) && capturedArgs.includes("/d"),
|
||||
`expected /d in attrib args so hidden directories are included, got ${JSON.stringify(capturedArgs)}`,
|
||||
);
|
||||
});
|
||||
253
electron/bridges/mainProcessErrorGuards.test.cjs
Normal file
253
electron/bridges/mainProcessErrorGuards.test.cjs
Normal file
@@ -0,0 +1,253 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
classifyProcessError,
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
isNonFatalNetworkError,
|
||||
} = require("./processErrorGuards.cjs");
|
||||
|
||||
test("treats Chromium ERR_NETWORK_CHANGED as non-fatal", () => {
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("net::ERR_NETWORK_CHANGED")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("treats other Chromium net::ERR_* failures as non-fatal network errors", () => {
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("net::ERR_INTERNET_DISCONNECTED")),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("net::ERR_NAME_NOT_RESOLVED")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("treats Node socket error codes as non-fatal network errors", () => {
|
||||
const err = new Error("socket reset");
|
||||
err.code = "ECONNRESET";
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
|
||||
const dnsErr = new Error("dns failed");
|
||||
dnsErr.code = "ENOTFOUND";
|
||||
assert.equal(isNonFatalNetworkError(dnsErr), true);
|
||||
});
|
||||
|
||||
test("keeps non-network errors fatal", () => {
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("Something else broke")),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("generic startup exceptions stay fatal before the app is up", () => {
|
||||
const result = classifyProcessError(new Error("boom"), {
|
||||
runtimeStarted: false,
|
||||
});
|
||||
|
||||
assert.equal(result.action, "fatal");
|
||||
});
|
||||
|
||||
test("generic runtime exceptions are suppressed after startup", () => {
|
||||
const result = classifyProcessError(new Error("boom"), {
|
||||
runtimeStarted: true,
|
||||
});
|
||||
|
||||
assert.equal(result.action, "suppress");
|
||||
assert.match(result.reason, /runtime/i);
|
||||
});
|
||||
|
||||
test("generic runtime promise rejections are also suppressed after startup", () => {
|
||||
const result = classifyProcessError(new Error("promise boom"), {
|
||||
runtimeStarted: true,
|
||||
origin: "unhandledRejection",
|
||||
});
|
||||
|
||||
assert.equal(result.action, "suppress");
|
||||
assert.match(result.reason, /runtime/i);
|
||||
});
|
||||
|
||||
test("controller keeps startup strict until the main window is actually shown", () => {
|
||||
const controller = createProcessErrorController();
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
assert.equal(controller.isRuntimeProtectionActive(), false);
|
||||
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
assert.equal(controller.isRuntimeProtectionActive(), true);
|
||||
});
|
||||
|
||||
test("controller becomes strict again while recreating a missing main window", () => {
|
||||
const controller = createProcessErrorController();
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
assert.equal(controller.isRuntimeProtectionActive(), true);
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
assert.equal(controller.isRuntimeProtectionActive(), false);
|
||||
|
||||
controller.completeMainWindowStartup({ windowShown: false });
|
||||
assert.equal(controller.isRuntimeProtectionActive(), true);
|
||||
});
|
||||
|
||||
test("startup-period errors stay fatal while recreating the main window", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
const fatals = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError() {},
|
||||
onFatalError(err) {
|
||||
fatals.push(err.message);
|
||||
throw err;
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
installProcessErrorHandlers(fakeProcess, controller);
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
controller.beginMainWindowStartup();
|
||||
|
||||
assert.throws(() => {
|
||||
fakeProcess.emit("uncaughtException", new Error("recreate boom"));
|
||||
}, /recreate boom/);
|
||||
assert.deepEqual(fatals, ["recreate boom"]);
|
||||
});
|
||||
|
||||
test("fatal startup failures uninstall listeners and keep throwing", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
const captured = [];
|
||||
const fatals = [];
|
||||
let uninstall = null;
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
fatals.push(err.message);
|
||||
uninstall?.();
|
||||
throw err;
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
uninstall = installProcessErrorHandlers(fakeProcess, controller);
|
||||
|
||||
assert.throws(() => {
|
||||
fakeProcess.emit("uncaughtException", new Error("startup boom"));
|
||||
}, /startup boom/);
|
||||
assert.deepEqual(fatals, ["startup boom"]);
|
||||
assert.deepEqual(captured, [["uncaughtException", "startup boom"]]);
|
||||
assert.equal(fakeProcess.listenerCount("uncaughtException"), 0);
|
||||
assert.equal(fakeProcess.listenerCount("unhandledRejection"), 0);
|
||||
});
|
||||
|
||||
test("installed handlers suppress runtime failures after startup", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
const captured = [];
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
throw err;
|
||||
},
|
||||
logError(...args) {
|
||||
errors.push(args.map(String).join(" "));
|
||||
},
|
||||
logWarn(...args) {
|
||||
warnings.push(args.map(String).join(" "));
|
||||
},
|
||||
});
|
||||
|
||||
installProcessErrorHandlers(fakeProcess, controller);
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
|
||||
fakeProcess.emit("uncaughtException", new Error("runtime boom"));
|
||||
fakeProcess.emit("unhandledRejection", new Error("runtime rejection"));
|
||||
assert.deepEqual(captured, [
|
||||
["uncaughtException", "runtime boom"],
|
||||
["unhandledRejection", "runtime rejection"],
|
||||
]);
|
||||
assert.equal(errors.some((line) => line.includes("runtime error after startup")), true);
|
||||
assert.equal(warnings.length, 0);
|
||||
});
|
||||
|
||||
test("unhandled rejection marks the forwarded error so uncaught follow-up is not double-captured", () => {
|
||||
const captured = [];
|
||||
const fatals = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
fatals.push(err);
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
controller.handleUnhandledRejection(new Error("startup rejection"));
|
||||
assert.equal(fatals.length, 1);
|
||||
assert.equal(fatals[0].__fromUnhandledRejection, true);
|
||||
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
|
||||
|
||||
controller.handleUncaughtException(fatals[0]);
|
||||
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
|
||||
});
|
||||
|
||||
test("benign stream teardown errors are ignored by the installed handlers", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
let captureCount = 0;
|
||||
let fatalCount = 0;
|
||||
const controller = createProcessErrorController({
|
||||
captureError() {
|
||||
captureCount += 1;
|
||||
},
|
||||
onFatalError() {
|
||||
fatalCount += 1;
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
installProcessErrorHandlers(fakeProcess, controller);
|
||||
const err = new Error("broken pipe");
|
||||
err.code = "EPIPE";
|
||||
fakeProcess.emit("uncaughtException", err);
|
||||
|
||||
assert.equal(captureCount, 0);
|
||||
assert.equal(fatalCount, 0);
|
||||
});
|
||||
|
||||
test("controller suppresses wrapped network errors from err.cause", () => {
|
||||
const err = new Error("request failed");
|
||||
err.cause = new Error("net::ERR_NETWORK_CHANGED");
|
||||
|
||||
const result = classifyProcessError(err, {
|
||||
runtimeStarted: false,
|
||||
});
|
||||
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
assert.equal(result.action, "suppress");
|
||||
});
|
||||
|
||||
test("controller suppresses ssh-style errors with a level property", () => {
|
||||
const err = new Error("connection lost before handshake");
|
||||
err.level = "client-socket";
|
||||
|
||||
const result = classifyProcessError(err, {
|
||||
runtimeStarted: false,
|
||||
});
|
||||
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
assert.equal(result.action, "suppress");
|
||||
});
|
||||
193
electron/bridges/processErrorGuards.cjs
Normal file
193
electron/bridges/processErrorGuards.cjs
Normal file
@@ -0,0 +1,193 @@
|
||||
function isNonFatalNetworkError(err) {
|
||||
if (!err) return false;
|
||||
// Any error with an ssh2 `level` property is a connection/auth-level error,
|
||||
// never a reason to kill the entire multi-session app.
|
||||
if (err.level) return true;
|
||||
|
||||
const candidates = [err, err.cause].filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
const code = candidate.code;
|
||||
// Common TCP/DNS/routing errors that can surface from Node.js sockets
|
||||
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
|
||||
switch (code) {
|
||||
case "ECONNRESET":
|
||||
case "ECONNREFUSED":
|
||||
case "ECONNABORTED":
|
||||
case "ETIMEDOUT":
|
||||
case "ENOTFOUND":
|
||||
case "EHOSTUNREACH":
|
||||
case "EHOSTDOWN":
|
||||
case "ENETUNREACH":
|
||||
case "ENETDOWN":
|
||||
case "EADDRNOTAVAIL":
|
||||
case "EPROTO":
|
||||
case "EPERM":
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Chromium/Electron networking often rejects with a message like
|
||||
// "net::ERR_NETWORK_CHANGED" but without a useful `code` property.
|
||||
// These are transport failures for background fetch/update/sync work,
|
||||
// not reasons to kill the whole app.
|
||||
const message = String(candidate.message || "");
|
||||
if (/net::ERR_(?:NETWORK_[A-Z_]+|INTERNET_DISCONNECTED|NAME_NOT_RESOLVED|CONNECTION_[A-Z_]+|ADDRESS_[A-Z_]+|SSL_[A-Z_]+|CERT_[A-Z_]+|PROXY_[A-Z_]+|TUNNEL_[A-Z_]+|SOCKS_[A-Z_]+)/.test(message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBenignStreamError(err) {
|
||||
const code = err?.code;
|
||||
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
|
||||
}
|
||||
|
||||
function classifyProcessError(err, options = {}) {
|
||||
const runtimeStarted = options.runtimeStarted === true;
|
||||
|
||||
if (isBenignStreamError(err)) {
|
||||
return {
|
||||
action: "ignore",
|
||||
reason: "benign stream teardown",
|
||||
};
|
||||
}
|
||||
|
||||
if (isNonFatalNetworkError(err)) {
|
||||
return {
|
||||
action: "suppress",
|
||||
reason: "non-fatal network error",
|
||||
};
|
||||
}
|
||||
|
||||
if (runtimeStarted) {
|
||||
return {
|
||||
action: "suppress",
|
||||
reason: "runtime error after startup",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "fatal",
|
||||
reason: "startup error before app became usable",
|
||||
};
|
||||
}
|
||||
|
||||
function createProcessErrorController(options = {}) {
|
||||
const captureError = typeof options.captureError === "function" ? options.captureError : () => {};
|
||||
const onFatalError = typeof options.onFatalError === "function"
|
||||
? options.onFatalError
|
||||
: (err) => { throw err; };
|
||||
const logError = typeof options.logError === "function" ? options.logError : (...args) => console.error(...args);
|
||||
const logWarn = typeof options.logWarn === "function" ? options.logWarn : (...args) => console.warn(...args);
|
||||
|
||||
let hasShownMainWindow = false;
|
||||
let pendingMainWindowStartupCount = 0;
|
||||
|
||||
const isRuntimeProtectionActive = () => (
|
||||
hasShownMainWindow && pendingMainWindowStartupCount === 0
|
||||
);
|
||||
|
||||
const beginMainWindowStartup = () => {
|
||||
pendingMainWindowStartupCount += 1;
|
||||
};
|
||||
|
||||
const completeMainWindowStartup = ({ windowShown = false } = {}) => {
|
||||
if (pendingMainWindowStartupCount > 0) {
|
||||
pendingMainWindowStartupCount -= 1;
|
||||
}
|
||||
if (windowShown) {
|
||||
hasShownMainWindow = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUncaughtException = (err) => {
|
||||
const decision = classifyProcessError(err, {
|
||||
runtimeStarted: isRuntimeProtectionActive(),
|
||||
origin: "uncaughtException",
|
||||
});
|
||||
|
||||
if (decision.action === "ignore") {
|
||||
logWarn("Ignored process error:", decision.reason, err?.code || err?.message || err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.action === "suppress") {
|
||||
if (!err?.__fromUnhandledRejection) {
|
||||
captureError("uncaughtException", err);
|
||||
}
|
||||
logError(`Suppressed uncaught exception (${decision.reason}):`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!err?.__fromUnhandledRejection) {
|
||||
captureError("uncaughtException", err);
|
||||
}
|
||||
onFatalError(err, {
|
||||
origin: "uncaughtException",
|
||||
decision,
|
||||
reason: err,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (reason) => {
|
||||
const decision = classifyProcessError(reason, {
|
||||
runtimeStarted: isRuntimeProtectionActive(),
|
||||
origin: "unhandledRejection",
|
||||
});
|
||||
|
||||
if (decision.action === "ignore") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.action === "suppress") {
|
||||
captureError("unhandledRejection", reason);
|
||||
logError(`Suppressed unhandled rejection (${decision.reason}):`, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
captureError("unhandledRejection", reason);
|
||||
const err = reason instanceof Error ? reason : new Error(String(reason));
|
||||
err.__fromUnhandledRejection = true;
|
||||
onFatalError(err, {
|
||||
origin: "unhandledRejection",
|
||||
decision,
|
||||
reason,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
beginMainWindowStartup,
|
||||
completeMainWindowStartup,
|
||||
handleUncaughtException,
|
||||
handleUnhandledRejection,
|
||||
isRuntimeProtectionActive,
|
||||
};
|
||||
}
|
||||
|
||||
function installProcessErrorHandlers(processObject, controller) {
|
||||
if (!processObject?.on || !processObject?.removeListener) {
|
||||
throw new Error("A process-like EventEmitter is required");
|
||||
}
|
||||
if (!controller?.handleUncaughtException || !controller?.handleUnhandledRejection) {
|
||||
throw new Error("A process error controller is required");
|
||||
}
|
||||
|
||||
processObject.on("uncaughtException", controller.handleUncaughtException);
|
||||
processObject.on("unhandledRejection", controller.handleUnhandledRejection);
|
||||
|
||||
return () => {
|
||||
processObject.removeListener("uncaughtException", controller.handleUncaughtException);
|
||||
processObject.removeListener("unhandledRejection", controller.handleUnhandledRejection);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyProcessError,
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
isBenignStreamError,
|
||||
isNonFatalNetworkError,
|
||||
};
|
||||
89
electron/bridges/ptyProcessTree.cjs
Normal file
89
electron/bridges/ptyProcessTree.cjs
Normal file
@@ -0,0 +1,89 @@
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
function createProcessTree({ platform, listPosix, listWindows } = {}) {
|
||||
const sessionPidMap = new Map();
|
||||
|
||||
function registerPid(sessionId, pid) {
|
||||
if (!sessionId || typeof pid !== "number") return;
|
||||
if (sessionPidMap.has(sessionId) && sessionPidMap.get(sessionId) !== pid) {
|
||||
console.warn(
|
||||
`[ptyProcessTree] sessionId "${sessionId}" already registered with pid ${sessionPidMap.get(sessionId)}; overwriting with ${pid}.`,
|
||||
);
|
||||
}
|
||||
sessionPidMap.set(sessionId, pid);
|
||||
}
|
||||
|
||||
function unregisterPid(sessionId) {
|
||||
sessionPidMap.delete(sessionId);
|
||||
}
|
||||
|
||||
async function getChildProcesses(sessionId) {
|
||||
const pid = sessionPidMap.get(sessionId);
|
||||
if (!pid) return [];
|
||||
if (platform === "win32") {
|
||||
return listWindows ? listWindows(pid) : [];
|
||||
}
|
||||
return listPosix ? listPosix(pid) : [];
|
||||
}
|
||||
|
||||
return { registerPid, unregisterPid, getChildProcesses };
|
||||
}
|
||||
|
||||
function defaultListPosix(ppid) {
|
||||
return new Promise((resolve) => {
|
||||
// `ps -A -o pid=,ppid=,args=` works on both BSD (macOS) and GNU (Linux).
|
||||
// `args=` shows the full command line (not truncated like `comm=`).
|
||||
// The trailing `=` on each column suppresses the header row.
|
||||
execFile("ps", ["-A", "-o", "pid=,ppid=,args="], (err, stdout) => {
|
||||
if (err || typeof stdout !== "string") return resolve([]);
|
||||
const out = [];
|
||||
for (const line of stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||
if (!m) continue;
|
||||
if (Number(m[2]) !== ppid) continue;
|
||||
out.push({ pid: Number(m[1]), command: m[3].trim() });
|
||||
}
|
||||
resolve(out);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function defaultListWindows(ppid) {
|
||||
return new Promise((resolve) => {
|
||||
let wpt;
|
||||
try {
|
||||
wpt = require("@vscode/windows-process-tree");
|
||||
} catch {
|
||||
return resolve([]);
|
||||
}
|
||||
try {
|
||||
wpt.getProcessTree(ppid, (tree) => {
|
||||
if (!tree || !Array.isArray(tree.children)) return resolve([]);
|
||||
resolve(tree.children.map((c) => ({ pid: c.pid, command: c.name })));
|
||||
});
|
||||
} catch {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createDefaultProcessTree() {
|
||||
const platform = process.platform;
|
||||
return createProcessTree({
|
||||
platform,
|
||||
listPosix: platform === "win32" ? undefined : defaultListPosix,
|
||||
listWindows: platform === "win32" ? defaultListWindows : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultTree = createDefaultProcessTree();
|
||||
|
||||
module.exports = {
|
||||
createProcessTree,
|
||||
processTree: defaultTree,
|
||||
registerPid: (id, pid) => defaultTree.registerPid(id, pid),
|
||||
unregisterPid: (id) => defaultTree.unregisterPid(id),
|
||||
getChildProcesses: (id) => defaultTree.getChildProcesses(id),
|
||||
};
|
||||
79
electron/bridges/ptyProcessTree.test.cjs
Normal file
79
electron/bridges/ptyProcessTree.test.cjs
Normal file
@@ -0,0 +1,79 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { createProcessTree } = require("./ptyProcessTree.cjs");
|
||||
|
||||
test("getChildProcesses returns [] when session has no registered pid", async () => {
|
||||
const tree = createProcessTree({ platform: "darwin", listPosix: async () => [] });
|
||||
assert.deepEqual(await tree.getChildProcesses("unknown-session"), []);
|
||||
});
|
||||
|
||||
test("getChildProcesses calls listPosix with the registered ppid and returns its result", async () => {
|
||||
const calls = [];
|
||||
const listPosix = async (ppid) => {
|
||||
calls.push(ppid);
|
||||
return [
|
||||
{ pid: 2001, command: "sleep 100" },
|
||||
{ pid: 2002, command: "node server.js" },
|
||||
];
|
||||
};
|
||||
const tree = createProcessTree({ platform: "linux", listPosix });
|
||||
tree.registerPid("s1", 1234);
|
||||
assert.deepEqual(await tree.getChildProcesses("s1"), [
|
||||
{ pid: 2001, command: "sleep 100" },
|
||||
{ pid: 2002, command: "node server.js" },
|
||||
]);
|
||||
assert.deepEqual(calls, [1234]);
|
||||
});
|
||||
|
||||
test("unregisterPid clears mapping", async () => {
|
||||
const tree = createProcessTree({
|
||||
platform: "darwin",
|
||||
listPosix: async () => [{ pid: 9, command: "x" }],
|
||||
});
|
||||
tree.registerPid("s1", 1234);
|
||||
tree.unregisterPid("s1");
|
||||
assert.deepEqual(await tree.getChildProcesses("s1"), []);
|
||||
});
|
||||
|
||||
test("getChildProcesses on windows uses listWindows", async () => {
|
||||
const calls = [];
|
||||
const listWindows = async (pid) => {
|
||||
calls.push(pid);
|
||||
return [{ pid: 55, command: "python.exe" }];
|
||||
};
|
||||
const tree = createProcessTree({ platform: "win32", listWindows });
|
||||
tree.registerPid("s1", 3000);
|
||||
assert.deepEqual(await tree.getChildProcesses("s1"), [{ pid: 55, command: "python.exe" }]);
|
||||
assert.deepEqual(calls, [3000]);
|
||||
});
|
||||
|
||||
test("getChildProcesses returns [] when listPosix missing on posix", async () => {
|
||||
const tree = createProcessTree({ platform: "darwin" });
|
||||
tree.registerPid("s1", 1234);
|
||||
assert.deepEqual(await tree.getChildProcesses("s1"), []);
|
||||
});
|
||||
|
||||
test("getChildProcesses returns [] when listWindows missing on windows", async () => {
|
||||
const tree = createProcessTree({ platform: "win32" });
|
||||
tree.registerPid("s1", 3000);
|
||||
assert.deepEqual(await tree.getChildProcesses("s1"), []);
|
||||
});
|
||||
|
||||
test("registerPid warns when overwriting an existing sessionId with a different pid", async () => {
|
||||
const warnCalls = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => warnCalls.push(args);
|
||||
try {
|
||||
const tree = createProcessTree({ platform: "darwin", listPosix: async () => [] });
|
||||
tree.registerPid("s1", 1234);
|
||||
tree.registerPid("s1", 1234); // same pid — no warn
|
||||
tree.registerPid("s1", 5678); // different — should warn
|
||||
assert.equal(warnCalls.length, 1);
|
||||
assert.match(warnCalls[0][0], /s1/);
|
||||
assert.match(warnCalls[0][0], /1234/);
|
||||
assert.match(warnCalls[0][0], /5678/);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
46
electron/bridges/sshIdentificationCompatibility.test.cjs
Normal file
46
electron/bridges/sshIdentificationCompatibility.test.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const Protocol = require("ssh2/lib/protocol/Protocol");
|
||||
|
||||
function parseIdentification(line) {
|
||||
let header;
|
||||
const protocol = new Protocol({
|
||||
onWrite() {},
|
||||
onError(err) {
|
||||
throw err;
|
||||
},
|
||||
onHeader(nextHeader) {
|
||||
header = nextHeader;
|
||||
},
|
||||
});
|
||||
|
||||
const data = Buffer.from(`${line}\r\n`, "latin1");
|
||||
protocol.parse(data, 0, data.length);
|
||||
|
||||
assert.ok(header, "expected SSH header to be parsed");
|
||||
return header;
|
||||
}
|
||||
|
||||
test("ssh2 accepts an empty softwareversion for compatibility", () => {
|
||||
const header = parseIdentification("SSH-2.0-");
|
||||
|
||||
assert.equal(header.versions.protocol, "2.0");
|
||||
assert.equal(header.versions.software, "");
|
||||
assert.equal(header.comments, undefined);
|
||||
});
|
||||
|
||||
test("ssh2 still accepts standard identification strings", () => {
|
||||
const header = parseIdentification("SSH-2.0-OpenSSH_9.9 Netcatty");
|
||||
|
||||
assert.equal(header.versions.protocol, "2.0");
|
||||
assert.equal(header.versions.software, "OpenSSH_9.9");
|
||||
assert.equal(header.comments, "Netcatty");
|
||||
});
|
||||
|
||||
test("ssh2 still rejects malformed identification strings", () => {
|
||||
assert.throws(
|
||||
() => parseIdentification("SSH-2.0"),
|
||||
/Invalid identification string/,
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const path = require("node:path");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
const ptyProcessTree = require("./ptyProcessTree.cjs");
|
||||
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
@@ -326,6 +327,7 @@ function startLocalSession(event, payload) {
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
ptyProcessTree.registerPid(sessionId, proc.pid);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (payload?.sessionLog?.enabled && payload?.sessionLog?.directory) {
|
||||
@@ -382,6 +384,7 @@ function startLocalSession(event, payload) {
|
||||
proc.onExit((evt) => {
|
||||
flushLocal();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
// Signal present = killed externally (show disconnected UI).
|
||||
@@ -648,6 +651,7 @@ async function startTelnetSession(event, options) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
}
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
@@ -664,6 +668,7 @@ async function startTelnetSession(event, options) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
|
||||
}
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
@@ -802,6 +807,7 @@ async function startMoshSession(event, options) {
|
||||
proc.onExit((evt) => {
|
||||
flushMosh();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
// Mosh non-zero exit typically means connection/auth failure — show error UI
|
||||
@@ -931,6 +937,7 @@ async function startSerialSession(event, options) {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
@@ -940,6 +947,7 @@ async function startSerialSession(event, options) {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
@@ -1043,6 +1051,7 @@ function closeSession(event, payload) {
|
||||
} catch (err) {
|
||||
console.warn("Close failed", err);
|
||||
}
|
||||
ptyProcessTree.unregisterPid(payload.sessionId);
|
||||
sessions.delete(payload.sessionId);
|
||||
}
|
||||
|
||||
@@ -1166,6 +1175,9 @@ function cleanupAllSessions() {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
for (const [sessionId] of sessions) {
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
}
|
||||
sessions.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ function toBackupSummary(record) {
|
||||
id: record.id,
|
||||
createdAt: record.createdAt,
|
||||
reason: record.reason,
|
||||
syncDataVersion: record.syncDataVersion,
|
||||
sourceAppVersion: record.sourceAppVersion,
|
||||
targetAppVersion: record.targetAppVersion,
|
||||
preview: record.preview,
|
||||
@@ -131,6 +132,15 @@ function sanitizeOptionalVersionString(value) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Sync data version is the integer that the CloudSyncManager increments
|
||||
// on each successful cloud sync. Reject anything non-finite, non-positive,
|
||||
// or non-integer so the persisted record only carries meaningful values.
|
||||
function sanitizeOptionalSyncDataVersion(value) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||
if (value < 1) return undefined;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
// UTF-8 byte length of a payload's JSON serialization. Earlier revisions
|
||||
// returned `JSON.stringify(payload).length` (UTF-16 code units), which
|
||||
// under-counted by ~3x for non-ASCII vaults — a deck full of CJK snippet
|
||||
@@ -415,6 +425,7 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
id,
|
||||
createdAt,
|
||||
reason: sanitizeReason(options.reason),
|
||||
syncDataVersion: sanitizeOptionalSyncDataVersion(options.syncDataVersion),
|
||||
sourceAppVersion: sanitizeOptionalVersionString(options.sourceAppVersion),
|
||||
targetAppVersion: sanitizeOptionalVersionString(options.targetAppVersion),
|
||||
fingerprint,
|
||||
|
||||
@@ -417,6 +417,70 @@ test("createBackup accepts a legitimate SemVer-ish version string", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup persists syncDataVersion when given a positive integer", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "before_restore",
|
||||
syncDataVersion: 5,
|
||||
});
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.backup.syncDataVersion, 5);
|
||||
|
||||
// Round-trip via list
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed[0].syncDataVersion, 5);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup drops invalid syncDataVersion values (zero, negative, non-finite, non-numeric)", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const cases = [0, -1, NaN, Infinity, "5", null, undefined];
|
||||
let idx = 0;
|
||||
for (const syncDataVersion of cases) {
|
||||
// Vary an actual content-bearing field to avoid fingerprint dedupe
|
||||
// (top-level syncedAt is normalized away in the fingerprint).
|
||||
const payload = samplePayload({
|
||||
hosts: [{ ...samplePayload().hosts[0], id: `h-case-${idx}` }],
|
||||
});
|
||||
const result = await service.createBackup({
|
||||
payload,
|
||||
reason: "before_restore",
|
||||
syncDataVersion,
|
||||
});
|
||||
assert.equal(result.created, true, `iteration ${idx}: created should be true`);
|
||||
assert.equal(result.backup.syncDataVersion, undefined, `value ${String(syncDataVersion)} should be dropped`);
|
||||
idx += 1;
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup floors a fractional syncDataVersion", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "before_restore",
|
||||
syncDataVersion: 7.9,
|
||||
});
|
||||
assert.equal(result.backup.syncDataVersion, 7);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup rejects an array payload (not an object)", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
@@ -36,6 +36,9 @@ let menuDeps = null;
|
||||
let electronApp = null; // Reference to Electron app for userData path
|
||||
let isQuitting = false;
|
||||
const rendererReadyCallbacksByWebContentsId = new Map();
|
||||
const rendererReadySeenByWebContentsId = new Set();
|
||||
const rendererReadyWaitersByWebContentsId = new Map();
|
||||
const unhealthyWebContentsIds = new Set();
|
||||
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
|
||||
const OAUTH_DEFAULT_WIDTH = 600;
|
||||
const OAUTH_DEFAULT_HEIGHT = 700;
|
||||
@@ -791,6 +794,128 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
|
||||
return { showOnce, markRendererReady };
|
||||
}
|
||||
|
||||
function resolveRendererReady(wcId) {
|
||||
if (!wcId) return;
|
||||
unhealthyWebContentsIds.delete(wcId);
|
||||
rendererReadySeenByWebContentsId.add(wcId);
|
||||
const cb = rendererReadyCallbacksByWebContentsId.get(wcId);
|
||||
if (cb) cb();
|
||||
const waiters = rendererReadyWaitersByWebContentsId.get(wcId);
|
||||
if (!waiters || waiters.size === 0) return;
|
||||
rendererReadyWaitersByWebContentsId.delete(wcId);
|
||||
for (const resolve of waiters) {
|
||||
try {
|
||||
resolve();
|
||||
} catch {
|
||||
// ignore waiter errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowUsable(win, options = {}) {
|
||||
const requireVisible = options.requireVisible === true;
|
||||
if (!win || typeof win.isDestroyed !== "function" || win.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
if (requireVisible) {
|
||||
if (typeof win.isVisible !== "function") return false;
|
||||
try {
|
||||
if (!win.isVisible()) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const contents = win.webContents;
|
||||
if (!contents || typeof contents.isDestroyed !== "function" || contents.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
const wcId = (() => {
|
||||
try {
|
||||
return contents.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (wcId && unhealthyWebContentsIds.has(wcId)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof contents.isCrashed === "function") {
|
||||
try {
|
||||
if (contents.isCrashed()) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function waitForRendererReady(win, { timeoutMs = 15000 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wcId = (() => {
|
||||
try {
|
||||
return win?.webContents?.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!win || win.isDestroyed?.() || !wcId) {
|
||||
reject(new Error("Main window is unavailable before renderer ready."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rendererReadySeenByWebContentsId.has(wcId)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
try { win.removeListener("closed", handleClosed); } catch {}
|
||||
try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {}
|
||||
const waiters = rendererReadyWaitersByWebContentsId.get(wcId);
|
||||
if (waiters) {
|
||||
waiters.delete(handleReady);
|
||||
if (waiters.size === 0) {
|
||||
rendererReadyWaitersByWebContentsId.delete(wcId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReady = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleClosed = () => {
|
||||
cleanup();
|
||||
reject(new Error("Main window closed before renderer became ready."));
|
||||
};
|
||||
const handleGone = (_event, details) => {
|
||||
cleanup();
|
||||
reject(new Error(`Renderer process exited before ready: ${details?.reason || "unknown"}`));
|
||||
};
|
||||
|
||||
let waiters = rendererReadyWaitersByWebContentsId.get(wcId);
|
||||
if (!waiters) {
|
||||
waiters = new Set();
|
||||
rendererReadyWaitersByWebContentsId.set(wcId, waiters);
|
||||
}
|
||||
waiters.add(handleReady);
|
||||
|
||||
win.once("closed", handleClosed);
|
||||
win.webContents?.once?.("render-process-gone", handleGone);
|
||||
|
||||
if (Number(timeoutMs) > 0) {
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Renderer did not report ready before timeout."));
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main application window
|
||||
*/
|
||||
@@ -869,12 +994,27 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
// Clear reference when the main window is destroyed
|
||||
win.on('closed', () => {
|
||||
try {
|
||||
if (win?.webContents?.id) {
|
||||
unhealthyWebContentsIds.delete(win.webContents.id);
|
||||
rendererReadySeenByWebContentsId.delete(win.webContents.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (mainWindow === win) mainWindow = null;
|
||||
});
|
||||
|
||||
// Log renderer crashes for diagnostics (skip normal clean exits)
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (details?.reason === "clean-exit") return;
|
||||
try {
|
||||
if (win.webContents?.id) {
|
||||
unhealthyWebContentsIds.add(win.webContents.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const crashLogBridge = require("./crashLogBridge.cjs");
|
||||
crashLogBridge.captureError("render-process-gone", new Error(
|
||||
@@ -1097,14 +1237,62 @@ async function createWindow(electronModule, options) {
|
||||
/**
|
||||
* Create or focus the settings window
|
||||
*/
|
||||
/**
|
||||
* Show + reliably focus a window's renderer. Works around two Windows-specific
|
||||
* Electron quirks that surface when a prewarmed/hidden window is later shown
|
||||
* (see issue #760):
|
||||
*
|
||||
* 1. SetForegroundWindow restrictions: `BrowserWindow.focus()` invoked from
|
||||
* a non-foreground process is often silently rejected by Windows. The
|
||||
* window appears on top but never receives true OS foreground focus, so
|
||||
* `document.hasFocus()` stays false in the renderer.
|
||||
* 2. Chromium suppresses the input caret + keyboard routing whenever
|
||||
* `document.hasFocus()` is false, even if an `<input>` is the active
|
||||
* element. The classic symptom: clicking an input selects/deletes work
|
||||
* but the caret never blinks and typed characters don't appear.
|
||||
*
|
||||
* The alwaysOnTop toggle is the established workaround for (1); explicitly
|
||||
* calling `webContents.focus()` covers (2) so the renderer marks the page as
|
||||
* focused regardless of whether the OS granted foreground.
|
||||
*/
|
||||
function showAndFocusWindow(win) {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
try {
|
||||
win.show();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
win.setAlwaysOnTop(true);
|
||||
win.focus();
|
||||
win.setAlwaysOnTop(false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
win.focus();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (win.webContents && !win.webContents.isDestroyed()) {
|
||||
win.webContents.focus();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
|
||||
|
||||
// If settings window already exists, show and focus it
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.show();
|
||||
settingsWindow.focus();
|
||||
showAndFocusWindow(settingsWindow);
|
||||
return settingsWindow;
|
||||
}
|
||||
|
||||
@@ -1264,7 +1452,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
|
||||
try {
|
||||
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
||||
await win.loadURL(`${baseUrl}${settingsPath}`);
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
if (showOnLoad) { showAndFocusWindow(win); }
|
||||
return win;
|
||||
} catch (e) {
|
||||
console.warn("Dev server not reachable for settings window", e);
|
||||
@@ -1273,7 +1461,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html#/settings");
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
if (showOnLoad) { showAndFocusWindow(win); }
|
||||
|
||||
return win;
|
||||
}
|
||||
@@ -1467,8 +1655,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
ipcMain.on("netcatty:renderer:ready", (event) => {
|
||||
const wcId = event?.sender?.id;
|
||||
if (!wcId) return;
|
||||
const cb = rendererReadyCallbacksByWebContentsId.get(wcId);
|
||||
if (cb) cb();
|
||||
resolveRendererReady(wcId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1558,6 +1745,8 @@ module.exports = {
|
||||
buildAppMenu,
|
||||
getMainWindow,
|
||||
getSettingsWindow,
|
||||
isWindowUsable,
|
||||
waitForRendererReady,
|
||||
setIsQuitting,
|
||||
openFallbackBrowser,
|
||||
tryOpenExternalWithFallback,
|
||||
|
||||
67
electron/bridges/windowManagerReadiness.test.cjs
Normal file
67
electron/bridges/windowManagerReadiness.test.cjs
Normal file
@@ -0,0 +1,67 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { isWindowUsable } = require("./windowManager.cjs");
|
||||
|
||||
function createWindowStub({ destroyed = false, webContents } = {}) {
|
||||
return {
|
||||
isDestroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
isVisible() {
|
||||
return true;
|
||||
},
|
||||
webContents,
|
||||
};
|
||||
}
|
||||
|
||||
test("isWindowUsable returns false when webContents is crashed", () => {
|
||||
const win = createWindowStub({
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(isWindowUsable(win), false);
|
||||
});
|
||||
|
||||
test("isWindowUsable returns true for a healthy live window", () => {
|
||||
const win = createWindowStub({
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(isWindowUsable(win), true);
|
||||
});
|
||||
|
||||
test("isWindowUsable can require a visible window", () => {
|
||||
const hiddenWin = {
|
||||
...createWindowStub({
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
isVisible() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(isWindowUsable(hiddenWin, { requireVisible: true }), false);
|
||||
assert.equal(isWindowUsable(hiddenWin, { requireVisible: false }), true);
|
||||
});
|
||||
@@ -20,79 +20,31 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
|
||||
|
||||
// Load crash log bridge early so process-level error handlers can use it
|
||||
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
|
||||
|
||||
// SSH / network errors that must never crash the process.
|
||||
// ssh2 can emit multiple 'error' events per connection (e.g. ECONNRESET followed
|
||||
// by "Connection lost before handshake"). If a listener is consumed after the first
|
||||
// event, the second becomes an uncaught exception. These are non-fatal for the app.
|
||||
function isNonFatalNetworkError(err) {
|
||||
if (!err) return false;
|
||||
// Any error with an ssh2 `level` property is a connection/auth-level error,
|
||||
// never a reason to kill the entire multi-session app.
|
||||
if (err.level) return true;
|
||||
const code = err.code;
|
||||
// Common TCP/DNS/routing errors that can surface from Node.js sockets
|
||||
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
|
||||
switch (code) {
|
||||
case 'ECONNRESET':
|
||||
case 'ECONNREFUSED':
|
||||
case 'ECONNABORTED':
|
||||
case 'ETIMEDOUT':
|
||||
case 'ENOTFOUND':
|
||||
case 'EHOSTUNREACH':
|
||||
case 'EHOSTDOWN':
|
||||
case 'ENETUNREACH':
|
||||
case 'ENETDOWN':
|
||||
case 'EADDRNOTAVAIL':
|
||||
case 'EPROTO':
|
||||
case 'EPERM':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions — log all, only re-throw truly fatal ones
|
||||
process.on('uncaughtException', (err) => {
|
||||
// Skip benign stream teardown errors — don't pollute crash logs with false positives
|
||||
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
|
||||
console.warn('Ignored stream error:', err.code);
|
||||
return;
|
||||
}
|
||||
// Non-fatal SSH/network errors: log but do NOT crash the process
|
||||
if (isNonFatalNetworkError(err)) {
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
const {
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
} = require("./bridges/processErrorGuards.cjs");
|
||||
const processErrorController = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
try { crashLogBridge.captureError(source, err); } catch {}
|
||||
},
|
||||
onFatalError(err, context) {
|
||||
uninstallProcessErrorHandlers();
|
||||
if (context?.origin === 'unhandledRejection') {
|
||||
console.error('Unhandled rejection:', context.reason);
|
||||
} else {
|
||||
console.error('Uncaught exception:', err);
|
||||
}
|
||||
console.warn('Non-fatal uncaught exception (suppressed):', err.message);
|
||||
return;
|
||||
}
|
||||
// Skip logging if already captured by unhandledRejection handler
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
}
|
||||
console.error('Uncaught exception:', err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
// Skip benign stream teardown errors
|
||||
const code = reason?.code;
|
||||
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
|
||||
// Non-fatal SSH/network errors: log but do NOT re-throw
|
||||
if (isNonFatalNetworkError(reason)) {
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.warn('Non-fatal unhandled rejection (suppressed):', reason?.message || reason);
|
||||
return;
|
||||
}
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.error('Unhandled rejection:', reason);
|
||||
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
|
||||
// can skip duplicate logging.
|
||||
const err = reason instanceof Error ? reason : new Error(String(reason));
|
||||
err.__fromUnhandledRejection = true;
|
||||
throw err;
|
||||
throw err;
|
||||
},
|
||||
logError(...args) {
|
||||
console.error(...args);
|
||||
},
|
||||
logWarn(...args) {
|
||||
console.warn(...args);
|
||||
},
|
||||
});
|
||||
let uninstallProcessErrorHandlers = installProcessErrorHandlers(process, processErrorController);
|
||||
|
||||
// Load Electron
|
||||
let electronModule;
|
||||
@@ -165,6 +117,7 @@ const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
|
||||
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
|
||||
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
|
||||
const getVaultBackupBridge = createLazyModule("./bridges/vaultBackupBridge.cjs");
|
||||
const ptyProcessTree = require("./bridges/ptyProcessTree.cjs");
|
||||
|
||||
// GPU settings
|
||||
// NOTE: Do not disable Chromium sandbox by default.
|
||||
@@ -683,6 +636,40 @@ const registerBridges = (win) => {
|
||||
};
|
||||
});
|
||||
|
||||
// PTY child process list for busy-check before close
|
||||
ipcMain.handle("netcatty:pty:childProcesses", async (_event, sessionId) => {
|
||||
if (typeof sessionId !== "string") return [];
|
||||
return ptyProcessTree.getChildProcesses(sessionId);
|
||||
});
|
||||
|
||||
// Native confirmation dialog when closing a session with a running process
|
||||
// Returns true only if the user explicitly clicks "Close". ESC/dialog-dismiss
|
||||
// resolves as cancelId (0) → false, which is the safe default (do not close).
|
||||
ipcMain.handle(
|
||||
"netcatty:dialog:confirmCloseBusy",
|
||||
async (event, payload) => {
|
||||
const command = typeof payload?.command === "string" ? payload.command : "unknown";
|
||||
const title = typeof payload?.title === "string" ? payload.title : "Confirm close";
|
||||
const message = typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: `Process "${command}" is still running and will be terminated.`;
|
||||
const cancelLabel = typeof payload?.cancelLabel === "string" ? payload.cancelLabel : "Cancel";
|
||||
const closeLabel = typeof payload?.closeLabel === "string" ? payload.closeLabel : "Close";
|
||||
const { dialog } = electronModule;
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const { response } = await dialog.showMessageBox(win || undefined, {
|
||||
type: "warning",
|
||||
title,
|
||||
message,
|
||||
buttons: [cancelLabel, closeLabel],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
noLink: true,
|
||||
});
|
||||
return response === 1; // true = user picked Close
|
||||
},
|
||||
);
|
||||
|
||||
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
|
||||
ipcMain.handle("netcatty:clipboard:readText", async () => {
|
||||
try {
|
||||
@@ -978,6 +965,80 @@ async function createWindow() {
|
||||
return win;
|
||||
}
|
||||
|
||||
function waitForWindowToShow(win) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
reject(new Error("Main window was destroyed before first show."));
|
||||
return;
|
||||
}
|
||||
if (win.isVisible?.()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try { win.removeListener("show", handleShow); } catch {}
|
||||
try { win.removeListener("closed", handleClosed); } catch {}
|
||||
try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {}
|
||||
};
|
||||
|
||||
const handleShow = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleClosed = () => {
|
||||
cleanup();
|
||||
reject(new Error("Main window closed before first show."));
|
||||
};
|
||||
const handleGone = (_event, details) => {
|
||||
cleanup();
|
||||
reject(new Error(`Renderer process exited before first show: ${details?.reason || "unknown"}`));
|
||||
};
|
||||
|
||||
win.once("show", handleShow);
|
||||
win.once("closed", handleClosed);
|
||||
win.webContents?.once?.("render-process-gone", handleGone);
|
||||
});
|
||||
}
|
||||
|
||||
let mainWindowStartupPromise = null;
|
||||
|
||||
async function createAndShowMainWindow() {
|
||||
if (mainWindowStartupPromise) return mainWindowStartupPromise;
|
||||
|
||||
mainWindowStartupPromise = (async () => {
|
||||
processErrorController.beginMainWindowStartup();
|
||||
try {
|
||||
const win = await createWindow();
|
||||
await waitForWindowToShow(win);
|
||||
void getWindowManager().waitForRendererReady(win, {
|
||||
timeoutMs: isDev ? 30000 : 15000,
|
||||
}).catch((err) => {
|
||||
console.warn("[Main] Renderer ready signal was late or missing after first show:", err?.message || err);
|
||||
});
|
||||
processErrorController.completeMainWindowStartup({ windowShown: true });
|
||||
return win;
|
||||
} catch (err) {
|
||||
processErrorController.completeMainWindowStartup({ windowShown: false });
|
||||
throw err;
|
||||
} finally {
|
||||
mainWindowStartupPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return mainWindowStartupPromise;
|
||||
}
|
||||
|
||||
function hasUsableWindow() {
|
||||
try {
|
||||
const windowManager = getWindowManager();
|
||||
return [windowManager.getMainWindow?.(), windowManager.getSettingsWindow?.()]
|
||||
.some((win) => windowManager.isWindowUsable?.(win, { requireVisible: true }));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showStartupError(err) {
|
||||
const title = "Netcatty";
|
||||
const code = err && typeof err === "object" ? err.code : null;
|
||||
@@ -1003,9 +1064,12 @@ if (!gotLock) {
|
||||
app.on("second-instance", () => {
|
||||
if (!focusMainWindow()) {
|
||||
// Window is missing or crashed — try to recreate it
|
||||
void createWindow().catch((err) => {
|
||||
void createAndShowMainWindow().catch((err) => {
|
||||
console.error("[Main] Failed to recreate window on second-instance:", err);
|
||||
showStartupError(err);
|
||||
if (!hasUsableWindow()) {
|
||||
try { app.quit(); } catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1023,9 +1087,17 @@ if (!gotLock) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build and set application menu
|
||||
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
// Build and set application menu. A broken menu should not take down
|
||||
// the entire app — fall back to no custom menu and continue startup.
|
||||
try {
|
||||
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
} catch (err) {
|
||||
console.error("[Main] Failed to build application menu:", err);
|
||||
try {
|
||||
Menu.setApplicationMenu(null);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
try {
|
||||
@@ -1045,7 +1117,7 @@ if (!gotLock) {
|
||||
});
|
||||
|
||||
// Create the main window
|
||||
void createWindow().then(() => {
|
||||
void createAndShowMainWindow().then(() => {
|
||||
// Trigger auto-update check 5 s after window creation.
|
||||
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
|
||||
getAutoUpdateBridge().startAutoCheck(5000);
|
||||
@@ -1095,9 +1167,12 @@ if (!gotLock) {
|
||||
|
||||
if (focusMainWindow()) return;
|
||||
// Main window doesn't exist — create it even if other windows (e.g. settings) are open
|
||||
void createWindow().catch((err) => {
|
||||
void createAndShowMainWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
showStartupError(err);
|
||||
if (!hasUsableWindow()) {
|
||||
try { app.quit(); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -858,6 +858,10 @@ const api = {
|
||||
|
||||
// App info
|
||||
getAppInfo: () => ipcRenderer.invoke("netcatty:app:getInfo"),
|
||||
ptyGetChildProcesses: (sessionId) =>
|
||||
ipcRenderer.invoke("netcatty:pty:childProcesses", sessionId),
|
||||
confirmCloseBusy: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:dialog:confirmCloseBusy", payload),
|
||||
getVaultBackupCapabilities: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:capabilities"),
|
||||
createVaultBackup: (payload) =>
|
||||
|
||||
8
global.d.ts
vendored
8
global.d.ts
vendored
@@ -512,6 +512,14 @@ declare global {
|
||||
|
||||
// App info (name/version/platform) for About screens
|
||||
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
|
||||
ptyGetChildProcesses?(sessionId: string): Promise<Array<{ pid: number; command: string }>>;
|
||||
confirmCloseBusy?(payload: {
|
||||
command: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
cancelLabel?: string;
|
||||
closeLabel?: string;
|
||||
}): Promise<boolean>;
|
||||
getVaultBackupCapabilities?(): Promise<{ encryptionAvailable: boolean }>;
|
||||
createVaultBackup?(payload: {
|
||||
payload: import('./domain/sync').SyncPayload;
|
||||
|
||||
131
infrastructure/ai/errorClassifier.test.ts
Normal file
131
infrastructure/ai/errorClassifier.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { classifyError, sanitizeErrorMessage } from "./errorClassifier.ts";
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sanitizeErrorMessage — regression guard for pre-existing behavior
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("sanitizeErrorMessage strips absolute user paths", () => {
|
||||
const result = sanitizeErrorMessage("ENOENT at /Users/alice/project/file.ts");
|
||||
assert.match(result, /<path>/);
|
||||
assert.doesNotMatch(result, /alice/);
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts URL credentials", () => {
|
||||
const result = sanitizeErrorMessage("Failed https://api.example.com/v1?api_key=SECRET123");
|
||||
assert.match(result, /<url-redacted>/);
|
||||
assert.doesNotMatch(result, /SECRET123/);
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage truncates very long messages", () => {
|
||||
const long = "a".repeat(1000);
|
||||
const result = sanitizeErrorMessage(long);
|
||||
assert.ok(result.length < 600, `expected truncation, got ${result.length} chars`);
|
||||
assert.match(result, /\.\.\.$/);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — 413 detection
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError surfaces a friendly 413 message when statusCode is 413", () => {
|
||||
const err = Object.assign(new Error("Request failed with status 413"), {
|
||||
statusCode: 413,
|
||||
responseBody: "<html>nginx 413</html>",
|
||||
});
|
||||
const info = classifyError(err);
|
||||
assert.equal(info.type, "network");
|
||||
assert.match(info.message, /Request too large/i);
|
||||
assert.match(info.message, /client_max_body_size/i);
|
||||
assert.match(info.message, /Raw:/);
|
||||
});
|
||||
|
||||
test("classifyError detects 'Request Entity Too Large' in a string error", () => {
|
||||
const info = classifyError("413 Request Entity Too Large");
|
||||
assert.equal(info.type, "network");
|
||||
assert.match(info.message, /Request too large/i);
|
||||
});
|
||||
|
||||
test("classifyError handles 413 via the message when no statusCode field is set", () => {
|
||||
const info = classifyError(new Error("AI_APICallError: 413 payload rejected"));
|
||||
assert.equal(info.type, "network");
|
||||
assert.match(info.message, /Request too large/i);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — 502 / 503 / 504 upstream gateway
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError marks 502/503/504 as network+retryable", () => {
|
||||
for (const code of [502, 503, 504]) {
|
||||
const info = classifyError(Object.assign(new Error(`status ${code}`), { statusCode: code }));
|
||||
assert.equal(info.type, "network");
|
||||
assert.equal(info.retryable, true, `code ${code} should be retryable`);
|
||||
assert.match(info.message, new RegExp(String(code)));
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — HTML response body
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError detects HTML in responseBody even when status is unknown", () => {
|
||||
const err = Object.assign(new Error("Invalid JSON"), {
|
||||
responseBody: "<!DOCTYPE html>\n<html><body>nginx error</body></html>",
|
||||
});
|
||||
const info = classifyError(err);
|
||||
assert.equal(info.type, "provider");
|
||||
assert.match(info.message, /HTML error page/i);
|
||||
assert.match(info.message, /proxy/i);
|
||||
});
|
||||
|
||||
test("classifyError detects HTML directly embedded in the error message", () => {
|
||||
const info = classifyError("Parse failed: <html><body>...</body></html>");
|
||||
assert.equal(info.type, "provider");
|
||||
assert.match(info.message, /HTML error page/i);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — Zod / schema parse failures
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError surfaces a friendlier message for 'Expected \\'id\\' to be a string.'", () => {
|
||||
// This is the exact error pattern reported in #765.
|
||||
const info = classifyError("Expected 'id' to be a string.");
|
||||
assert.equal(info.type, "provider");
|
||||
assert.match(info.message, /could not be parsed/i);
|
||||
assert.match(info.message, /request-size limit/i);
|
||||
// Raw error must still be visible for debugging / user reports.
|
||||
assert.match(info.message, /Expected 'id' to be a string/);
|
||||
});
|
||||
|
||||
test("classifyError handles a variety of schema validation wordings", () => {
|
||||
for (const raw of [
|
||||
"Invalid JSON response: missing field",
|
||||
"Type validation failed: expected number",
|
||||
"Expected 'choices' to be an array.",
|
||||
]) {
|
||||
const info = classifyError(raw);
|
||||
assert.equal(info.type, "provider", `wording: ${raw}`);
|
||||
assert.match(info.message, /could not be parsed|HTML error page/i);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — fallthrough
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError falls through to 'unknown' for unclassified errors", () => {
|
||||
const info = classifyError(new Error("Some other provider failure"));
|
||||
assert.equal(info.type, "unknown");
|
||||
assert.match(info.message, /Some other provider failure/);
|
||||
});
|
||||
|
||||
test("classifyError handles null, undefined, and non-Error shapes without throwing", () => {
|
||||
assert.doesNotThrow(() => classifyError(null));
|
||||
assert.doesNotThrow(() => classifyError(undefined));
|
||||
assert.doesNotThrow(() => classifyError({ foo: "bar" }));
|
||||
assert.doesNotThrow(() => classifyError(42));
|
||||
});
|
||||
@@ -1,15 +1,173 @@
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
type ErrorInfo = NonNullable<ChatMessage['errorInfo']>;
|
||||
|
||||
/**
|
||||
* Convert a raw error string into display-safe error info.
|
||||
*
|
||||
* Intentionally avoids keyword-based "root cause" attribution because upstream
|
||||
* providers often return generic 4xx/5xx text that would be misclassified.
|
||||
* We show the sanitized upstream message directly instead.
|
||||
* Extract the human-readable message from anything that might surface as an
|
||||
* error (Error instance, string, SDK error object with `.message`, etc.).
|
||||
*/
|
||||
export function classifyError(error: string): NonNullable<ChatMessage['errorInfo']> {
|
||||
const message = sanitizeErrorMessage(error).trim() || 'Unknown error';
|
||||
return { type: 'unknown', message, retryable: false };
|
||||
function extractMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message || '';
|
||||
if (typeof error === 'string') return error;
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const m = (error as { message: unknown }).message;
|
||||
if (typeof m === 'string') return m;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the HTTP status code out of an error when the SDK layer attached one.
|
||||
* Vercel AI SDK's APICallError exposes `.statusCode`; some shims use
|
||||
* `.status` or `.cause.statusCode`. Falls back to parsing the message text
|
||||
* when no structured field is available.
|
||||
*/
|
||||
function extractStatusCode(error: unknown, message: string): number | undefined {
|
||||
if (error && typeof error === 'object') {
|
||||
const obj = error as Record<string, unknown>;
|
||||
if (typeof obj.statusCode === 'number') return obj.statusCode;
|
||||
if (typeof obj.status === 'number') return obj.status;
|
||||
if (obj.cause && typeof obj.cause === 'object') {
|
||||
const causeStatus = (obj.cause as Record<string, unknown>).statusCode;
|
||||
if (typeof causeStatus === 'number') return causeStatus;
|
||||
}
|
||||
}
|
||||
// Last resort: look for a standalone 3-digit HTTP status in the message.
|
||||
// Bound by word boundaries to avoid picking up "in 413 ms" etc.
|
||||
const match = message.match(/\b(4\d{2}|5\d{2})\b/);
|
||||
if (match) return Number(match[1]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the response body out of an error object if the SDK attached it.
|
||||
* Nginx / CDN proxy error pages ship as HTML, so we can detect them here.
|
||||
*/
|
||||
function extractResponseBody(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
const body = (error as Record<string, unknown>).responseBody;
|
||||
if (typeof body === 'string') return body;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function looksLikeHtml(text: string): boolean {
|
||||
if (!text) return false;
|
||||
const lower = text.toLowerCase();
|
||||
const trimmedStart = lower.trimStart().slice(0, 200);
|
||||
// Start-of-body: responseBody captured verbatim by the SDK lands here.
|
||||
if (
|
||||
trimmedStart.startsWith('<!doctype html') ||
|
||||
trimmedStart.startsWith('<html') ||
|
||||
trimmedStart.startsWith('<head') ||
|
||||
trimmedStart.startsWith('<body')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Embedded: some SDKs wrap the HTML body inside an error message like
|
||||
// "Parse failed: <html>...". Look for unmistakable HTML tags anywhere
|
||||
// in the text. Kept narrow to avoid flagging errors that casually
|
||||
// mention "html" as a word.
|
||||
if (
|
||||
lower.includes('<!doctype html') ||
|
||||
lower.includes('<html>') ||
|
||||
lower.includes('<html ') ||
|
||||
// Common nginx default error-page opener.
|
||||
/<center>\s*<h1>/.test(lower)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function looksLikeZodParseError(message: string): boolean {
|
||||
// Zod and Vercel AI SDK schema errors look like:
|
||||
// Expected 'id' to be a string.
|
||||
// Expected 'choices' to be an array.
|
||||
// Invalid JSON response: ...
|
||||
// Type validation failed: ...
|
||||
return (
|
||||
/\bExpected '[^']+' to be (a|an) /i.test(message) ||
|
||||
/\binvalid json response\b/i.test(message) ||
|
||||
/\btype validation failed\b/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an arbitrary error surface to display-safe error info shown in the
|
||||
* chat UI. Known hostile scenarios get a concrete, actionable message; the
|
||||
* raw SDK text is appended so users can still report it verbatim.
|
||||
*
|
||||
* Covers:
|
||||
* - HTTP 413 (proxy request-size limit, e.g. nginx client_max_body_size)
|
||||
* - HTTP 502/504 (upstream proxy failures)
|
||||
* - HTML error page returned in place of JSON (any proxy)
|
||||
* - Schema/parse failures ("Expected 'id' to be a string.") that typically
|
||||
* mean the server swapped the response body for an error page
|
||||
*/
|
||||
export function classifyError(error: unknown): ErrorInfo {
|
||||
const rawMessage = extractMessage(error).trim() || 'Unknown error';
|
||||
const statusCode = extractStatusCode(error, rawMessage);
|
||||
const responseBody = extractResponseBody(error);
|
||||
|
||||
const hasHtml =
|
||||
looksLikeHtml(rawMessage) ||
|
||||
(responseBody !== undefined && looksLikeHtml(responseBody));
|
||||
const looksLikeParseError = looksLikeZodParseError(rawMessage);
|
||||
|
||||
const sanitizedRaw = sanitizeErrorMessage(rawMessage);
|
||||
|
||||
if (statusCode === 413 || /\brequest entity too large\b/i.test(rawMessage)) {
|
||||
return {
|
||||
type: 'network',
|
||||
message:
|
||||
`Request too large (HTTP 413). The AI gateway rejected the payload — this usually means ` +
|
||||
`the request body exceeded the proxy's size limit (for example nginx \`client_max_body_size\`). ` +
|
||||
`Try sending a shorter message, fewer/smaller attachments, or raising the proxy limit.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
|
||||
return {
|
||||
type: 'network',
|
||||
message:
|
||||
`AI gateway error (HTTP ${statusCode}). The proxy in front of the provider returned an error — ` +
|
||||
`the upstream AI service may be unreachable or timing out.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHtml) {
|
||||
return {
|
||||
type: 'provider',
|
||||
message:
|
||||
`The server returned an HTML error page instead of a JSON response. ` +
|
||||
`This almost always means a proxy (nginx / CDN / gateway) between you and the AI provider ` +
|
||||
`intercepted the request — commonly due to a size limit, auth failure, or the upstream service being down.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (looksLikeParseError) {
|
||||
return {
|
||||
type: 'provider',
|
||||
message:
|
||||
`The AI response could not be parsed as a valid chat completion. ` +
|
||||
`A proxy may have replaced or truncated the response body, or the provider returned a non-standard format. ` +
|
||||
`If you just sent a large request, check for a request-size limit on any intermediate proxy.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'unknown', message: sanitizedRaw, retryable: false };
|
||||
}
|
||||
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
encryptProviderSecrets,
|
||||
} from '../persistence/secureFieldAdapter';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import { detectSuspiciousShrink, type ShrinkFinding } from '../../domain/syncGuards';
|
||||
// Extracted into a plain ESM module so the signature logic is covered by
|
||||
// the node --test harness (see syncSignature.test.mjs). The previous
|
||||
// inline implementation only hashed a handful of meta fields and was
|
||||
@@ -77,6 +78,12 @@ export interface SyncManagerState {
|
||||
autoSyncEnabled: boolean;
|
||||
autoSyncInterval: number;
|
||||
syncHistory: SyncHistoryEntry[];
|
||||
/** Last shrink finding that put us into BLOCKED state, retained until
|
||||
* a sync actually succeeds (SYNC_COMPLETED with result.success) or
|
||||
* `clearShrinkBlockedState()` is called. Renderer hydrates the banner
|
||||
* from this on mount so a block that happened off-screen is still
|
||||
* visible to the user. */
|
||||
lastShrinkFinding?: Extract<ShrinkFinding, { suspicious: true }>;
|
||||
}
|
||||
|
||||
export type SyncEventCallback = (event: SyncEvent) => void;
|
||||
@@ -752,6 +759,12 @@ export class CloudSyncManager {
|
||||
const ghAdapter = adapter as GitHubAdapter;
|
||||
|
||||
try {
|
||||
// Snapshot the prior account BEFORE we overwrite providers[provider].
|
||||
// Used as a fallback for the same-account comparison when the persisted
|
||||
// accountId key is absent (e.g., first re-auth after upgrading to this
|
||||
// version, where the key didn't exist yet).
|
||||
const previousAccount = this.state.providers.github?.account;
|
||||
|
||||
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
|
||||
|
||||
++this.providerDecryptSeq.github;
|
||||
@@ -769,9 +782,20 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
await this.saveProviderConnection('github', this.state.providers.github);
|
||||
// Clear merge base when (re)authenticating to a potentially different account
|
||||
this.removeFromStorage(this.syncBaseKey('github'));
|
||||
this.clearSyncAnchor('github');
|
||||
|
||||
// Only clear the merge base if the authenticated account identity differs
|
||||
// from the previously-stored one. See notes in completePKCEAuth.
|
||||
const newId = ghAdapter.accountInfo?.id ?? null;
|
||||
const previousId = this.loadProviderAccountId('github') ?? previousAccount?.id ?? null;
|
||||
const sameAccount = newId !== null && previousId !== null && newId === previousId;
|
||||
if (!sameAccount) {
|
||||
this.removeFromStorage(this.syncBaseKey('github'));
|
||||
this.clearSyncAnchor('github');
|
||||
}
|
||||
if (newId) {
|
||||
this.saveProviderAccountId('github', newId);
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: 'github',
|
||||
@@ -797,6 +821,12 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
try {
|
||||
// Snapshot the prior account BEFORE we overwrite providers[provider].
|
||||
// Used as a fallback for the same-account comparison when the persisted
|
||||
// accountId key is absent (e.g., first re-auth after upgrading to this
|
||||
// version, where the key didn't exist yet).
|
||||
const previousAccount = this.state.providers[provider]?.account;
|
||||
|
||||
let tokens: OAuthTokens;
|
||||
let account;
|
||||
|
||||
@@ -825,9 +855,22 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Clear merge base when (re)authenticating to a potentially different account
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.clearSyncAnchor(provider);
|
||||
|
||||
// Only clear the merge base if the authenticated account identity differs
|
||||
// from the previously-stored one. Same-account re-auth preserves the base
|
||||
// so the next sync computes correct local-deletions instead of treating
|
||||
// it as "first sync" and resurrecting zombie entries via null-base union.
|
||||
const newId = account?.id ?? null;
|
||||
const previousId = this.loadProviderAccountId(provider) ?? previousAccount?.id ?? null;
|
||||
const sameAccount = newId !== null && previousId !== null && newId === previousId;
|
||||
if (!sameAccount) {
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.clearSyncAnchor(provider);
|
||||
}
|
||||
if (newId) {
|
||||
this.saveProviderAccountId(provider, newId);
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -912,6 +955,13 @@ export class CloudSyncManager {
|
||||
// account/resource doesn't reuse an unrelated snapshot
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.clearSyncAnchor(provider);
|
||||
this.removeFromStorage(this.providerAccountIdKey(provider));
|
||||
// Reset BLOCKED state if it was present — disconnect implicitly resolves
|
||||
// any pending shrink-block warning since there's no provider to push to.
|
||||
this.exitBlockedState();
|
||||
if (this.state.syncState === 'BLOCKED') {
|
||||
this.state.syncState = 'IDLE';
|
||||
}
|
||||
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
|
||||
}
|
||||
|
||||
@@ -1227,7 +1277,8 @@ export class CloudSyncManager {
|
||||
*/
|
||||
async syncToProvider(
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload
|
||||
payload: SyncPayload,
|
||||
opts: { overrideShrink?: boolean } = {},
|
||||
): Promise<SyncResult> {
|
||||
if (this.state.securityState !== 'UNLOCKED') {
|
||||
return {
|
||||
@@ -1247,6 +1298,8 @@ export class CloudSyncManager {
|
||||
};
|
||||
}
|
||||
|
||||
const overrideShrinkRequested = opts.overrideShrink === true;
|
||||
|
||||
let adapter: CloudAdapter;
|
||||
try {
|
||||
adapter = await this.getConnectedAdapter(provider);
|
||||
@@ -1288,6 +1341,30 @@ export class CloudSyncManager {
|
||||
|
||||
console.info('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
|
||||
|
||||
// Shrink guard: refuse to push a merged payload that silently deletes
|
||||
// entities we still have in base. The merge itself is correct if local
|
||||
// state is trustworthy — but a degraded local (keychain failure,
|
||||
// partial load) can make merge produce a smaller-than-expected result.
|
||||
const mergedShrink = detectSuspiciousShrink(mergeResult.payload, base);
|
||||
const shouldBlockMerged = mergedShrink.suspicious && !overrideShrinkRequested;
|
||||
const shouldForceMerged = mergedShrink.suspicious && overrideShrinkRequested;
|
||||
if (shouldBlockMerged) {
|
||||
this.state.syncState = 'BLOCKED';
|
||||
this.state.lastShrinkFinding = mergedShrink;
|
||||
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: mergedShrink });
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
shrinkBlocked: true,
|
||||
finding: mergedShrink,
|
||||
};
|
||||
}
|
||||
if (shouldForceMerged) {
|
||||
this.emit({ type: 'SYNC_FORCED', provider, finding: mergedShrink });
|
||||
}
|
||||
|
||||
// Encrypt and upload merged payload
|
||||
const mergedSyncedFile = await EncryptionService.encryptPayload(
|
||||
mergeResult.payload,
|
||||
@@ -1309,6 +1386,7 @@ export class CloudSyncManager {
|
||||
// Base was persisted inside uploadToProvider before the
|
||||
// anchor advanced, so a crash between them cannot leave a
|
||||
// stale base pointing at pre-merge state.
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
|
||||
this.addSyncHistoryEntry({
|
||||
@@ -1361,6 +1439,29 @@ export class CloudSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Shrink guard (no-conflict path): same rationale as the merge branch —
|
||||
// refuse a payload that drops entities versus the stored base.
|
||||
const directBase = await this.loadSyncBase(provider);
|
||||
const directShrink = detectSuspiciousShrink(payload, directBase);
|
||||
const shouldBlockDirect = directShrink.suspicious && !overrideShrinkRequested;
|
||||
const shouldForceDirect = directShrink.suspicious && overrideShrinkRequested;
|
||||
if (shouldBlockDirect) {
|
||||
this.state.syncState = 'BLOCKED';
|
||||
this.state.lastShrinkFinding = directShrink;
|
||||
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: directShrink });
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
shrinkBlocked: true,
|
||||
finding: directShrink,
|
||||
};
|
||||
}
|
||||
if (shouldForceDirect) {
|
||||
this.emit({ type: 'SYNC_FORCED', provider, finding: directShrink });
|
||||
}
|
||||
|
||||
// 2. Encrypt
|
||||
const syncedFile = await EncryptionService.encryptPayload(
|
||||
payload,
|
||||
@@ -1377,7 +1478,9 @@ export class CloudSyncManager {
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile, payload);
|
||||
|
||||
if (result.success) {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
if (result.error) {
|
||||
@@ -1550,26 +1653,73 @@ export class CloudSyncManager {
|
||||
// Download and return remote data
|
||||
const payload = await this.downloadFromProvider(provider);
|
||||
this.state.currentConflict = null;
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.notifyStateChange(); // Notify UI of conflict resolution
|
||||
return payload;
|
||||
} else {
|
||||
// USE_LOCAL - just clear conflict, caller will re-sync
|
||||
this.state.currentConflict = null;
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.notifyStateChange(); // Notify UI of conflict resolution
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-effect helper: called BEFORE any syncState assignment that transitions
|
||||
* away from BLOCKED. Clears lastShrinkFinding and emits SYNC_BLOCKED_CLEARED
|
||||
* so the UI banner (and any other subscriber) gets a single, authoritative
|
||||
* "block resolved" signal. The guard on syncState === 'BLOCKED' makes it safe
|
||||
* to call unconditionally at every non-BLOCKED assignment site — it no-ops
|
||||
* when the state was already non-BLOCKED.
|
||||
*/
|
||||
private exitBlockedState(): void {
|
||||
if (this.state.syncState === 'BLOCKED') {
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
this.emit({ type: 'SYNC_BLOCKED_CLEARED' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset BLOCKED back to IDLE without going through a successful sync.
|
||||
* Used by post-merge round-trip to avoid wedging the manager in BLOCKED
|
||||
* when the merge already produced safe local state and the round-trip
|
||||
* push is just an optimization.
|
||||
*/
|
||||
clearShrinkBlockedState(): void {
|
||||
if (this.state.syncState === 'BLOCKED') {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.notifyStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last shrink finding that triggered BLOCKED state, or
|
||||
* null if not currently blocked. Used by the renderer to hydrate the
|
||||
* SyncBlockedBanner when opening Settings after a block happened
|
||||
* off-screen.
|
||||
*/
|
||||
getShrinkBlockedFinding(): Extract<ShrinkFinding, { suspicious: true }> | null {
|
||||
if (this.state.syncState !== 'BLOCKED') return null;
|
||||
return this.state.lastShrinkFinding ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to all connected providers
|
||||
*/
|
||||
async syncAllProviders(inputPayload?: SyncPayload): Promise<Map<CloudProvider, SyncResult>> {
|
||||
async syncAllProviders(
|
||||
inputPayload?: SyncPayload,
|
||||
opts: { overrideShrink?: boolean } = {},
|
||||
): Promise<Map<CloudProvider, SyncResult>> {
|
||||
const results = new Map<CloudProvider, SyncResult>();
|
||||
let payload = inputPayload;
|
||||
let wasMerged = false;
|
||||
|
||||
const overrideShrinkRequested = opts.overrideShrink === true;
|
||||
|
||||
if (!payload) {
|
||||
// Caller should provide payload from app state
|
||||
return results;
|
||||
@@ -1743,6 +1893,80 @@ export class CloudSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Shrink guard (multi-provider): check the final outgoing payload against
|
||||
// each provider's stored base. If ANY provider would suffer a suspicious
|
||||
// shrink, block ALL uploads — the same payload goes to every provider, so
|
||||
// any one provider's "would lose too much" is a global block. Override flag
|
||||
// is one-shot and clears regardless of outcome.
|
||||
const shrinkSuspectByProvider: Array<{
|
||||
provider: CloudProvider;
|
||||
finding: Extract<ShrinkFinding, { suspicious: true }>;
|
||||
}> = [];
|
||||
const candidateProviders = checkResults
|
||||
.filter((r) => !r.error && !r.check?.conflict && r.adapter)
|
||||
.map((r) => r.provider as CloudProvider);
|
||||
for (const provider of candidateProviders) {
|
||||
const providerBase = await this.loadSyncBase(provider);
|
||||
const finding = detectSuspiciousShrink(payload, providerBase);
|
||||
if (finding.suspicious) {
|
||||
shrinkSuspectByProvider.push({ provider, finding });
|
||||
}
|
||||
}
|
||||
const shouldBlockAll = shrinkSuspectByProvider.length > 0 && !overrideShrinkRequested;
|
||||
const shouldForceAll = shrinkSuspectByProvider.length > 0 && overrideShrinkRequested;
|
||||
|
||||
if (shouldBlockAll) {
|
||||
this.state.syncState = 'BLOCKED';
|
||||
this.state.lastShrinkFinding = shrinkSuspectByProvider[0].finding;
|
||||
for (const { provider, finding } of shrinkSuspectByProvider) {
|
||||
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding });
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||||
results.set(provider, {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
shrinkBlocked: true,
|
||||
finding,
|
||||
});
|
||||
}
|
||||
// Process check errors from the parallel check phase so a provider that
|
||||
// failed during checkProviderConflict is not silently dropped from results.
|
||||
checkResults.forEach((r) => {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
}
|
||||
});
|
||||
// Providers in candidateProviders that didn't trip the shrink check still
|
||||
// share the same payload — mark them as not-uploaded so the caller doesn't
|
||||
// think a "successful" no-op happened.
|
||||
const blockedProviders = new Set(shrinkSuspectByProvider.map((e) => e.provider));
|
||||
for (const provider of candidateProviders) {
|
||||
if (!results.has(provider) && !blockedProviders.has(provider)) {
|
||||
results.set(provider, {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: 'Sync blocked: another provider would lose too much data',
|
||||
});
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked due to peer provider');
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if (shouldForceAll) {
|
||||
for (const { provider, finding } of shrinkSuspectByProvider) {
|
||||
this.emit({ type: 'SYNC_FORCED', provider, finding });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Encrypt Once
|
||||
const validUploads = checkResults.filter(
|
||||
(r) => !r.error && !r.check?.conflict && r.adapter
|
||||
@@ -1819,7 +2043,9 @@ export class CloudSyncManager {
|
||||
// 5. Final State Update
|
||||
const hasSuccess = Array.from(results.values()).some((r) => r.success);
|
||||
if (hasSuccess) {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
|
||||
// If a merge happened, attach the merged payload to successful results
|
||||
// so callers can apply remote additions to local state
|
||||
@@ -1922,6 +2148,18 @@ export class CloudSyncManager {
|
||||
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${suffix}`;
|
||||
}
|
||||
|
||||
private providerAccountIdKey(provider: CloudProvider): string {
|
||||
return `netcatty.sync.accountId.${provider}`;
|
||||
}
|
||||
|
||||
private loadProviderAccountId(provider: CloudProvider): string | null {
|
||||
return this.loadFromStorage<string>(this.providerAccountIdKey(provider)) ?? null;
|
||||
}
|
||||
|
||||
private saveProviderAccountId(provider: CloudProvider, id: string): void {
|
||||
this.saveToStorage(this.providerAccountIdKey(provider), id);
|
||||
}
|
||||
|
||||
async saveSyncBase(payload: SyncPayload, provider?: CloudProvider): Promise<void> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key) return;
|
||||
|
||||
192
package-lock.json
generated
192
package-lock.json
generated
@@ -81,9 +81,13 @@
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@vscode/windows-process-tree": "^0.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
@@ -1101,13 +1105,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.4",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
|
||||
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
|
||||
"version": "3.972.18",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.18.tgz",
|
||||
"integrity": "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.12.0",
|
||||
"fast-xml-parser": "5.3.4",
|
||||
"@smithy/types": "^4.14.1",
|
||||
"fast-xml-parser": "5.5.8",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5592,9 +5596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
|
||||
"integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
|
||||
"version": "4.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz",
|
||||
"integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
@@ -6104,6 +6108,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
@@ -6640,6 +6704,27 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/windows-process-tree": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.7.0.tgz",
|
||||
"integrity": "sha512-uH58Fofu1bgiulsY2svyGOLLKAYNJ0Req4PioPd7BZzHRziuiBRw1SxyT7wvsYHvm7eKWwzwo6mZpExDfwX9iA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/windows-process-tree/node_modules/node-addon-api": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz",
|
||||
"integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": "^16 || ^18 || >= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@withfig/autocomplete": {
|
||||
"version": "2.692.3",
|
||||
"resolved": "https://registry.npmjs.org/@withfig/autocomplete/-/autocomplete-2.692.3.tgz",
|
||||
@@ -9655,10 +9740,10 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
|
||||
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
|
||||
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9667,7 +9752,24 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
|
||||
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.2.0",
|
||||
"strnum": "^2.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -10102,6 +10204,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -13507,6 +13622,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -14252,6 +14382,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/responselike": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
|
||||
@@ -15101,9 +15241,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
|
||||
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -15471,6 +15611,26 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/ai/*.test.ts components/terminal/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
@@ -100,10 +101,14 @@
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@vscode/windows-process-tree": "^0.7.0"
|
||||
},
|
||||
"overrides": {
|
||||
"cpu-features": "npm:empty-npm-package@1.0.0",
|
||||
"axios": "1.13.5"
|
||||
|
||||
@@ -33,7 +33,7 @@ index 7291c2c..8943c9a 100644
|
||||
}
|
||||
|
||||
diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
index 7302488..95584c5 100644
|
||||
index 7302488..634acdd 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
@@ -701,11 +701,19 @@ class Protocol {
|
||||
@@ -107,6 +107,18 @@ index 7302488..95584c5 100644
|
||||
packet.set(signature, p += 4);
|
||||
|
||||
this._authsQueue.push('hostbased');
|
||||
@@ -1916,7 +1932,10 @@ class Protocol {
|
||||
}
|
||||
|
||||
// SSH-protoversion-softwareversion (SP comments) CR LF
|
||||
-const RE_IDENT = /^SSH-(2\.0|1\.99)-([^ ]+)(?: (.*))?$/;
|
||||
+// RFC 4253 requires a non-empty softwareversion, but some embedded SSH
|
||||
+// daemons send "SSH-2.0-" with an empty token. Accept that specific
|
||||
+// compatibility case while still rejecting whitespace in the token itself.
|
||||
+const RE_IDENT = /^SSH-(2\.0|1\.99)-([^ ]*)(?: (.*))?$/;
|
||||
|
||||
// TODO: optimize this by starting n bytes from the end of this._buffer instead
|
||||
// of the beginning
|
||||
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
index 9f33c02..9751164 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
|
||||
@@ -25,12 +25,19 @@ For routine tasks, the host prompt is usually enough. Read only the reference th
|
||||
## Core Rules
|
||||
|
||||
- Treat the host-provided CLI prefix as the only supported entrypoint for this session.
|
||||
- If a command launcher is needed, prefer the operating system's built-in launcher for the current environment; do not require optional shells that may not be installed.
|
||||
- Run Netcatty CLI commands strictly serially.
|
||||
- Treat Netcatty CLI errors as authoritative.
|
||||
- Never ask the user for SSH credentials, key paths, proxy settings, or jump-host details when Netcatty session access already exists.
|
||||
- Do not pause to explain the plan, re-read this skill, or design scripts before trying that shortest path.
|
||||
- When presenting structured results, prefer a concise table if it fits clearly.
|
||||
|
||||
Examples:
|
||||
|
||||
- On Windows, if a literal shell command line is required, use the host-provided prefix with the system launcher available in the environment, such as `cmd.exe` or Windows PowerShell; do not assume PowerShell 7 `pwsh.exe` exists.
|
||||
- On macOS or Linux, use the host-provided prefix directly, or the system shell already available in that environment when a shell command line is unavoidable.
|
||||
- When the execution surface accepts argv-style calls, use the Netcatty launcher path as the executable and pass subcommands and flags as separate arguments instead of wrapping it in another shell.
|
||||
|
||||
## References
|
||||
|
||||
- Exec and session workflow: `references/exec.md`
|
||||
|
||||
Reference in New Issue
Block a user